diff --git a/.beads/.gitignore b/.beads/.gitignore new file mode 100644 index 00000000..d27a1db5 --- /dev/null +++ b/.beads/.gitignore @@ -0,0 +1,44 @@ +# SQLite databases +*.db +*.db?* +*.db-journal +*.db-wal +*.db-shm + +# Daemon runtime files +daemon.lock +daemon.log +daemon.pid +bd.sock +sync-state.json +last-touched + +# Local version tracking (prevents upgrade notification spam after git ops) +.local_version + +# Legacy database files +db.sqlite +bd.db + +# Worktree redirect file (contains relative path to main repo's .beads/) +# Must not be committed as paths would be wrong in other clones +redirect + +# Merge artifacts (temporary files from 3-way merge) +beads.base.jsonl +beads.base.meta.json +beads.left.jsonl +beads.left.meta.json +beads.right.jsonl +beads.right.meta.json + +# Sync state (local-only, per-machine) +# These files are machine-specific and should not be shared across clones +.sync.lock +sync_base.jsonl + +# NOTE: Do NOT add negation patterns (e.g., !issues.jsonl) here. +# They would override fork protection in .git/info/exclude, allowing +# contributors to accidentally commit upstream issue databases. +# The JSONL files (issues.jsonl, interactions.jsonl) and config files +# are tracked by git by default since no pattern above ignores them. diff --git a/.beads/IngrediCheck-iOS.db b/.beads/IngrediCheck-iOS.db deleted file mode 100644 index 184f9367..00000000 Binary files a/.beads/IngrediCheck-iOS.db and /dev/null differ diff --git a/.beads/README.md b/.beads/README.md new file mode 100644 index 00000000..50f281f0 --- /dev/null +++ b/.beads/README.md @@ -0,0 +1,81 @@ +# Beads - AI-Native Issue Tracking + +Welcome to Beads! This repository uses **Beads** for issue tracking - a modern, AI-native tool designed to live directly in your codebase alongside your code. + +## What is Beads? + +Beads is issue tracking that lives in your repo, making it perfect for AI coding agents and developers who want their issues close to their code. No web UI required - everything works through the CLI and integrates seamlessly with git. + +**Learn more:** [github.com/steveyegge/beads](https://github.com/steveyegge/beads) + +## Quick Start + +### Essential Commands + +```bash +# Create new issues +bd create "Add user authentication" + +# View all issues +bd list + +# View issue details +bd show + +# Update issue status +bd update --status in_progress +bd update --status done + +# Sync with git remote +bd sync +``` + +### Working with Issues + +Issues in Beads are: +- **Git-native**: Stored in `.beads/issues.jsonl` and synced like code +- **AI-friendly**: CLI-first design works perfectly with AI coding agents +- **Branch-aware**: Issues can follow your branch workflow +- **Always in sync**: Auto-syncs with your commits + +## Why Beads? + +✨ **AI-Native Design** +- Built specifically for AI-assisted development workflows +- CLI-first interface works seamlessly with AI coding agents +- No context switching to web UIs + +πŸš€ **Developer Focused** +- Issues live in your repo, right next to your code +- Works offline, syncs when you push +- Fast, lightweight, and stays out of your way + +πŸ”§ **Git Integration** +- Automatic sync with git commits +- Branch-aware issue tracking +- Intelligent JSONL merge resolution + +## Get Started with Beads + +Try Beads in your own projects: + +```bash +# Install Beads +curl -sSL https://raw.githubusercontent.com/steveyegge/beads/main/scripts/install.sh | bash + +# Initialize in your repo +bd init + +# Create your first issue +bd create "Try out Beads" +``` + +## Learn More + +- **Documentation**: [github.com/steveyegge/beads/docs](https://github.com/steveyegge/beads/tree/main/docs) +- **Quick Start Guide**: Run `bd quickstart` +- **Examples**: [github.com/steveyegge/beads/examples](https://github.com/steveyegge/beads/tree/main/examples) + +--- + +*Beads: Issue tracking that moves at the speed of thought* ⚑ diff --git a/.beads/config.yaml b/.beads/config.yaml new file mode 100644 index 00000000..f2427856 --- /dev/null +++ b/.beads/config.yaml @@ -0,0 +1,62 @@ +# Beads Configuration File +# This file configures default behavior for all bd commands in this repository +# All settings can also be set via environment variables (BD_* prefix) +# or overridden with command-line flags + +# Issue prefix for this repository (used by bd init) +# If not set, bd init will auto-detect from directory name +# Example: issue-prefix: "myproject" creates issues like "myproject-1", "myproject-2", etc. +# issue-prefix: "" + +# Use no-db mode: load from JSONL, no SQLite, write back after each command +# When true, bd will use .beads/issues.jsonl as the source of truth +# instead of SQLite database +# no-db: false + +# Disable daemon for RPC communication (forces direct database access) +# no-daemon: false + +# Disable auto-flush of database to JSONL after mutations +# no-auto-flush: false + +# Disable auto-import from JSONL when it's newer than database +# no-auto-import: false + +# Enable JSON output by default +# json: false + +# Default actor for audit trails (overridden by BD_ACTOR or --actor) +# actor: "" + +# Path to database (overridden by BEADS_DB or --db) +# db: "" + +# Auto-start daemon if not running (can also use BEADS_AUTO_START_DAEMON) +# auto-start-daemon: true + +# Debounce interval for auto-flush (can also use BEADS_FLUSH_DEBOUNCE) +# flush-debounce: "5s" + +# Git branch for beads commits (bd sync will commit to this branch) +# IMPORTANT: Set this for team projects so all clones use the same sync branch. +# This setting persists across clones (unlike database config which is gitignored). +# Can also use BEADS_SYNC_BRANCH env var for local override. +# If not set, bd sync will require you to run 'bd config set sync.branch '. +# sync-branch: "beads-sync" + +# Multi-repo configuration (experimental - bd-307) +# Allows hydrating from multiple repositories and routing writes to the correct JSONL +# repos: +# primary: "." # Primary repo (where this database lives) +# additional: # Additional repos to hydrate from (read-only) +# - ~/beads-planning # Personal planning repo +# - ~/work-planning # Work planning repo + +# Integration settings (access with 'bd config get/set') +# These are stored in the database, not in this file: +# - jira.url +# - jira.project +# - linear.url +# - linear.api-key +# - github.org +# - github.repo diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl new file mode 100644 index 00000000..e69de29b diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index c11e22d0..aba3678f 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -3,3 +3,4 @@ {"id":"IngrediCheck-iOS-3","title":"Modernize product image carousel","description":"Switch ProductImagesView to geometry-driven sizing and replace manual index math with ForEach(images.indices) plus an async image loader to avoid repeated fetches and better support iPad split view.","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-15T13:07:51.334863+05:30","updated_at":"2025-10-15T13:07:51.334863+05:30"} {"id":"IngrediCheck-iOS-4","title":"Deduplicate ingredient badge popovers","description":"Extract the repeated popover styling in TappableTextFragment into a dedicated IngredientBadge view so maybeUnsafe/definitelyUnsafe cases share one code path.","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-15T13:08:07.7728+05:30","updated_at":"2025-10-15T13:08:07.7728+05:30"} {"id":"IngrediCheck-iOS-5","title":"Rework ImageCaptureView camera lifecycle","description":"Move conditionalNavigationBarTitle into a modifier, convert toolbar buttons to .toolbar, and lift CameraManager into an Observable @StateObject so session state persists without clearing captured images on disappear.","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-15T13:08:16.976234+05:30","updated_at":"2025-10-15T13:08:16.976234+05:30"} +{"id":"IngrediCheck-iOS-tw5","title":"Test issue - delete me","status":"closed","priority":4,"issue_type":"task","owner":"sanketpatel.1805@gmail.com","created_at":"2026-01-17T19:58:19.103123+05:30","created_by":"justanotheratom","updated_at":"2026-01-17T19:58:25.107429+05:30","closed_at":"2026-01-17T19:58:25.107429+05:30","close_reason":"Test complete - CLI working"} diff --git a/.beads/metadata.json b/.beads/metadata.json new file mode 100644 index 00000000..c787975e --- /dev/null +++ b/.beads/metadata.json @@ -0,0 +1,4 @@ +{ + "database": "beads.db", + "jsonl_export": "issues.jsonl" +} \ No newline at end of file diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..9126397e --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,13 @@ +{ + "enabledPlugins": { + "swiftui-expert-skill@swiftui-expert": true + }, + "extraKnownMarketplaces": { + "swiftui-expert": { + "source": { + "source": "github", + "repo": "AvdLee/SwiftUI-Agent-Skill" + } + } + } +} diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..82dec708 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,11 @@ +{ + "enableAllProjectMcpServers": true, + "enabledMcpjsonServers": [ + "XcodeBuildMCP" + ], + "permissions": { + "allow": [ + "mcp__XcodeBuildMCP__build_sim" + ] + } +} diff --git a/.claude/skills/asc-builds/SKILL.md b/.claude/skills/asc-builds/SKILL.md new file mode 100644 index 00000000..1aea7dda --- /dev/null +++ b/.claude/skills/asc-builds/SKILL.md @@ -0,0 +1,69 @@ +--- +name: asc-builds +description: List builds from App Store Connect. Use to check build status, versions, and upload dates. +argument-hint: [all|NUM] +allowed-tools: + - Task +--- + +# List Builds + +List recent builds from App Store Connect, grouped by marketing version. + +Arguments: $ARGUMENTS +- (none): Show last 2 marketing versions with 3 builds each +- `all`: Show flat list of recent builds +- `NUM`: Show last NUM builds (flat list) + +## IMPORTANT: Run via Background Agent + +To avoid polluting context with intermediate API calls, ALWAYS use a Task agent to run the build script. + +Use the Task tool with: +- `subagent_type`: `Bash` +- `prompt`: The appropriate bash command based on arguments + +### Default (no arguments) + +Spawn a Bash agent with this prompt: +``` +Run this command and return ONLY the table output, no other commentary: +.claude/skills/asc-builds/scripts/list-by-version.sh 2 3 +``` + +### With "all" argument + +Spawn a Bash agent with this prompt: +``` +Run these commands and return ONLY the table output: +source .claude/skills/scripts/asc-common.sh +asc_load_config +asc builds list --app "$ASC_APP_ID" --limit 15 --output table +``` + +### With numeric argument (e.g., "10") + +Spawn a Bash agent with this prompt: +``` +Run these commands and return ONLY the table output: +source .claude/skills/scripts/asc-common.sh +asc_load_config +asc builds list --app "$ASC_APP_ID" --limit NUM --output table +``` +(Replace NUM with the actual number) + +## Output Format + +The agent will return a table like: +``` +Version | Build | Uploaded | External +--------|--------------|------------|------------------ +2.0 | 14 | 2026-01-27 | IN_BETA_TESTING +2.0 | 13 | 2026-01-23 | IN_BETA_TESTING +1.5.0 | 11 | 2025-12-18 | IN_BETA_TESTING +``` + +Present this to the user as a formatted markdown table with status icons: +- βœ… IN_BETA_TESTING +- β›” EXPIRED +- ⏸️ READY_FOR_BETA_SUBMISSION diff --git a/.claude/skills/asc-builds/scripts/list-by-version.sh b/.claude/skills/asc-builds/scripts/list-by-version.sh new file mode 100755 index 00000000..fc6b5636 --- /dev/null +++ b/.claude/skills/asc-builds/scripts/list-by-version.sh @@ -0,0 +1,81 @@ +#!/bin/bash +# List builds grouped by marketing version with TestFlight status +# Usage: ./list-by-version.sh [num_versions] [builds_per_version] +# Optimized: parallel beta-details queries, minimal API calls + +set -e + +source "$(dirname "$0")/../../scripts/asc-common.sh" +asc_load_config + +NUM_VERSIONS="${1:-2}" +BUILDS_PER_VERSION="${2:-3}" + +# Create temp dir for parallel results +TMPDIR=$(mktemp -d) +trap "rm -rf $TMPDIR" EXIT + +# === PHASE 1: Fetch all data upfront (2 API calls only) === +VERSIONS_JSON=$(asc versions list --app "$ASC_APP_ID") +BUILDS_JSON=$(asc builds list --app "$ASC_APP_ID" --limit 50) + +# === PHASE 2: Process versions and determine builds (no API calls) === +echo "$VERSIONS_JSON" | jq -r ".data[:$NUM_VERSIONS][] | \"\(.id)|\(.attributes.versionString)|\(.attributes.createdDate)|\(.attributes.appStoreState)\"" > "$TMPDIR/versions.txt" + +while IFS='|' read vid ver created state; do + CREATED_TS=$(date -j -f "%Y-%m-%dT%H:%M:%S" "${created:0:19}" "+%s" 2>/dev/null || echo "0") + + if [ "$state" = "PREPARE_FOR_SUBMISSION" ]; then + # Current version: most recent builds + echo "$BUILDS_JSON" | jq -r --argjson limit "$BUILDS_PER_VERSION" \ + '.data[:$limit][] | "\(.id)|\(.attributes.version)|\(.attributes.uploadedDate[:10])"' \ + > "$TMPDIR/builds_${ver}.txt" + else + # Released version: builds from 45 days before creation + WINDOW_START=$((CREATED_TS - 3888000)) + echo "$BUILDS_JSON" | jq -r --argjson start "$WINDOW_START" --argjson end "$CREATED_TS" --argjson limit "$BUILDS_PER_VERSION" ' + [.data[] | + (.attributes.uploadedDate[:19] | strptime("%Y-%m-%dT%H:%M:%S") | mktime) as $ts | + select($ts >= $start and $ts <= $end)] | + sort_by(.attributes.uploadedDate) | reverse | + .[:$limit][] | + "\(.id)|\(.attributes.version)|\(.attributes.uploadedDate[:10])" + ' > "$TMPDIR/builds_${ver}.txt" + fi + + echo "$ver" >> "$TMPDIR/version_order.txt" +done < "$TMPDIR/versions.txt" + +# === PHASE 3: Fetch beta details in PARALLEL === +cat "$TMPDIR"/builds_*.txt 2>/dev/null | cut -d'|' -f1 | sort -u > "$TMPDIR/all_build_ids.txt" + +# Query beta details in parallel (up to 6 concurrent) +while read build_id; do + [ -z "$build_id" ] && continue + ( + beta=$(asc testflight beta-details get --build "$build_id" 2>/dev/null) + external=$(echo "$beta" | jq -r '.data[0].attributes.externalBuildState // "N/A"') + echo "$external" > "$TMPDIR/beta_${build_id}.txt" + ) & + + # Limit parallelism to 6 + while [ $(jobs -r | wc -l) -ge 6 ]; do + sleep 0.05 + done +done < "$TMPDIR/all_build_ids.txt" + +wait + +# === PHASE 4: Output results === +echo "Version | Build | Uploaded | External" +echo "--------|--------------|------------|------------------" + +while read ver; do + [ -z "$ver" ] && continue + [ -f "$TMPDIR/builds_${ver}.txt" ] || continue + while IFS='|' read id build date; do + external="N/A" + [ -f "$TMPDIR/beta_${id}.txt" ] && external=$(cat "$TMPDIR/beta_${id}.txt") + printf "%-7s | %-12s | %s | %s\n" "$ver" "$build" "$date" "$external" + done < "$TMPDIR/builds_${ver}.txt" +done < "$TMPDIR/version_order.txt" diff --git a/.claude/skills/asc-publish/SKILL.md b/.claude/skills/asc-publish/SKILL.md new file mode 100644 index 00000000..7ebb4fd2 --- /dev/null +++ b/.claude/skills/asc-publish/SKILL.md @@ -0,0 +1,127 @@ +--- +name: asc-publish +description: Archive, build, and upload IngrediCheck to App Store Connect. Use to publish a new build for TestFlight or App Store. +argument-hint: [--skip-upload] +allowed-tools: + - Bash(*) +--- + +# Publish to App Store Connect + +Archive, create IPA, and upload to App Store Connect. + +Arguments: $ARGUMENTS +- (none): Full build and upload +- `--skip-upload`: Build only, don't upload + +## Quick Start + +```bash +# Full build and upload +.claude/skills/asc-publish/scripts/publish_appstore.sh + +# Build only (skip upload) +SKIP_UPLOAD=1 .claude/skills/asc-publish/scripts/publish_appstore.sh +``` + +## What It Does + +1. **Queries App Store Connect** for latest build number +2. **Archives** with `xcodebuild archive` using build number + 1 +3. **Creates IPA** from archive +4. **Uploads** via `iTMSTransporter` + +**No local project file changes needed!** Build number is determined from ASC and passed to xcodebuild at build time. + +## Prerequisites + +### 1. Transporter App +Install from Mac App Store: [Transporter](https://apps.apple.com/us/app/transporter/id1450874784) + +### 2. Distribution Certificate +- Open Xcode β†’ Settings β†’ Accounts +- Select Apple ID β†’ Manage Certificates +- Create "Apple Distribution" certificate + +### 3. App Store Connect API Key +1. Go to [App Store Connect API Keys](https://appstoreconnect.apple.com/access/integrations/api) +2. Create a new key with "App Manager" access +3. Download the `.p8` file (only available once!) +4. Note the Key ID and Issuer ID + +### 4. Configure Credentials + +Create `.asc/publish.env`: + +```bash +mkdir -p .asc +cat > .asc/publish.env << 'EOF' +APP_STORE_CONNECT_API_KEY=YOUR_KEY_ID +APP_STORE_CONNECT_API_ISSUER=YOUR_ISSUER_ID +APP_STORE_CONNECT_API_PRIVATE_KEY_PATH=./AuthKey_YOUR_KEY_ID.p8 +APP_STORE_CONNECT_API_KEY_TYPE=individual +EOF +``` + +Copy your `.p8` key file to `.asc/`: +```bash +cp ~/Downloads/AuthKey_XXXXX.p8 .asc/ +``` + +## Usage + +### Full Build & Upload +```bash +.claude/skills/asc-publish/scripts/publish_appstore.sh +``` + +### Build Only (Test) +```bash +SKIP_UPLOAD=1 .claude/skills/asc-publish/scripts/publish_appstore.sh +``` + +## Output + +``` +Using team ID: 58MYNHGN72 +Incrementing build number... +Build number set to: 15 +Archiving IngrediCheck... +Creating IPA from archive... +IPA created at build/AppStoreExport/IngrediCheck.ipa +Uploading IPA via iTMSTransporter... +========================================= +Upload complete! +Build number: 15 +Check App Store Connect for build status. +========================================= +``` + +## After Upload + +Once uploaded, the build will: +1. Process in App Store Connect (few minutes) +2. Appear in TestFlight +3. Be available for internal testers immediately +4. Require Beta App Review for external testers (if not already approved) + +Check status with: `/asc-builds` + +## Troubleshooting + +| Error | Solution | +|-------|----------| +| "Transporter CLI not found" | Install Transporter from Mac App Store | +| "Unable to detect DEVELOPMENT_TEAM" | Set `APPLE_TEAM_ID=58MYNHGN72` in `.asc/publish.env` | +| "Private key file not found" | Check `.p8` file path in `.asc/publish.env` | +| "Authentication credentials invalid" | Verify Key ID and Issuer ID | + +## Config + +| Variable | Description | +|----------|-------------| +| `APP_STORE_CONNECT_API_KEY` | API Key ID from App Store Connect | +| `APP_STORE_CONNECT_API_ISSUER` | Issuer ID from App Store Connect | +| `APP_STORE_CONNECT_API_PRIVATE_KEY_PATH` | Path to `.p8` file | +| `APP_STORE_CONNECT_API_KEY_TYPE` | `individual` or `team` | +| `APPLE_TEAM_ID` | (optional) Override team ID | diff --git a/.claude/skills/asc-publish/scripts/publish_appstore.sh b/.claude/skills/asc-publish/scripts/publish_appstore.sh new file mode 100755 index 00000000..e109cc7b --- /dev/null +++ b/.claude/skills/asc-publish/scripts/publish_appstore.sh @@ -0,0 +1,211 @@ +#!/bin/zsh +# +# App Store Distribution Script +# +# This script builds, archives, and uploads the IngrediCheck app to App Store Connect. +# Build number is auto-determined from App Store Connect (latest + 1). +# No local project file changes needed! +# +# Setup Instructions: +# 1. Create .asc/publish.env with your App Store Connect API credentials +# 2. Ensure you have Apple Distribution certificate and App Store provisioning profile +# 3. Run: asc auth login (for querying latest build) +# +# Usage: +# .claude/skills/asc-publish/scripts/publish_appstore.sh # Full build and upload +# SKIP_UPLOAD=1 .claude/skills/asc-publish/scripts/publish_appstore.sh # Build only, skip upload +# + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)" +PROJECT="${PROJECT:-IngrediCheck.xcodeproj}" +SCHEME="${SCHEME:-IngrediCheck}" +CONFIGURATION="${CONFIGURATION:-Release}" +ARCHIVE_PATH="${ARCHIVE_PATH:-$PROJECT_ROOT/build/IngrediCheck.xcarchive}" +EXPORT_PATH="${EXPORT_PATH:-$PROJECT_ROOT/build/AppStoreExport}" +IPA_NAME="${IPA_NAME:-IngrediCheck}" +IPA_PATH="$EXPORT_PATH/$IPA_NAME.ipa" +PROJECT_PATH="$PROJECT_ROOT/$PROJECT" + +# Load environment from .asc/publish.env (preferred) or publish/.env (legacy) +if [[ -f "$PROJECT_ROOT/.asc/publish.env" ]]; then + set -a + source "$PROJECT_ROOT/.asc/publish.env" + set +a + ENV_FILE="$PROJECT_ROOT/.asc/publish.env" +elif [[ -f "$PROJECT_ROOT/publish/.env" ]]; then + set -a + source "$PROJECT_ROOT/publish/.env" + set +a + ENV_FILE="$PROJECT_ROOT/publish/.env" +else + ENV_FILE="" +fi + +cd "$PROJECT_ROOT" + +# Check for required tools +if ! command -v xcodebuild >/dev/null 2>&1; then + echo "❌ xcodebuild not found. Install Xcode command line tools first." >&2 + exit 1 +fi + +if ! command -v asc >/dev/null 2>&1; then + echo "❌ asc CLI not found. Install with: brew tap rudrankriyam/tap && brew install asc" >&2 + exit 1 +fi + +# Load ASC app ID from config +if [[ -f "$PROJECT_ROOT/.asc/config.json" ]]; then + ASC_APP_ID=$(jq -r '.app_id // empty' "$PROJECT_ROOT/.asc/config.json" 2>/dev/null) +fi + +if [[ -z "${ASC_APP_ID:-}" ]]; then + echo "❌ ASC_APP_ID not configured. Run /asc-setup first." >&2 + exit 1 +fi + +# Get latest build number from App Store Connect +echo "πŸ“‘ Fetching latest build from App Store Connect..." +LATEST_BUILD=$(asc builds latest --app "$ASC_APP_ID" 2>/dev/null | jq -r '.data.attributes.version // "0"') + +if [[ ! "$LATEST_BUILD" =~ ^[0-9]+$ ]]; then + echo "⚠️ Could not parse latest build number ('$LATEST_BUILD'), starting from 1" + LATEST_BUILD=0 +fi + +NEW_BUILD=$((LATEST_BUILD + 1)) +echo "πŸ“¦ Latest build in ASC: $LATEST_BUILD β†’ New build: $NEW_BUILD" + +# Locate iTMSTransporter for upload +if [[ "${SKIP_UPLOAD:-0}" != "1" ]]; then + if [[ -z "${TRANSPORTER_CLI:-}" ]]; then + if [[ -x /Applications/Transporter.app/Contents/itms/bin/iTMSTransporter ]]; then + TRANSPORTER_CLI="/Applications/Transporter.app/Contents/itms/bin/iTMSTransporter" + elif command -v iTMSTransporter >/dev/null 2>&1; then + TRANSPORTER_CLI="$(command -v iTMSTransporter)" + else + TRANSPORTER_CLI="$(xcrun --find iTMSTransporter 2>/dev/null || true)" + fi + fi + + if [[ -z "${TRANSPORTER_CLI:-}" ]]; then + echo "❌ iTMSTransporter CLI not found. Install the Transporter app from the Mac App Store." >&2 + exit 1 + fi +fi + +# Detect team ID +if [[ -z "${APPLE_TEAM_ID:-}" ]]; then + APPLE_TEAM_ID="$(xcodebuild -project "$PROJECT_PATH" -scheme "$SCHEME" -showBuildSettings 2>/dev/null | awk '/DEVELOPMENT_TEAM/ {print $3; exit}')" +fi + +if [[ -z "${APPLE_TEAM_ID:-}" ]]; then + echo "❌ Unable to detect DEVELOPMENT_TEAM. Set APPLE_TEAM_ID and retry." >&2 + exit 1 +fi + +echo "πŸ”‘ Using team ID: $APPLE_TEAM_ID" + +# Validate upload credentials +if [[ "${SKIP_UPLOAD:-0}" != "1" ]]; then + : "${APP_STORE_CONNECT_API_KEY:?Set APP_STORE_CONNECT_API_KEY in .asc/publish.env}" + : "${APP_STORE_CONNECT_API_ISSUER:?Set APP_STORE_CONNECT_API_ISSUER in .asc/publish.env}" + : "${APP_STORE_CONNECT_API_PRIVATE_KEY_PATH:?Set APP_STORE_CONNECT_API_PRIVATE_KEY_PATH in .asc/publish.env}" + + # Resolve relative paths + if [[ -n "$ENV_FILE" && "$APP_STORE_CONNECT_API_PRIVATE_KEY_PATH" != /* ]]; then + ENV_DIR="$(dirname "$ENV_FILE")" + APP_STORE_CONNECT_API_PRIVATE_KEY_PATH="$ENV_DIR/$APP_STORE_CONNECT_API_PRIVATE_KEY_PATH" + fi + + if [[ ! -f "$APP_STORE_CONNECT_API_PRIVATE_KEY_PATH" ]]; then + echo "❌ Private key file not found at $APP_STORE_CONNECT_API_PRIVATE_KEY_PATH" >&2 + exit 1 + fi + + PRIVATE_KEYS_DIR="$PROJECT_ROOT/private_keys" + mkdir -p "$PRIVATE_KEYS_DIR" + + DEFAULT_KEY_KIND="${APP_STORE_CONNECT_API_KEY_TYPE:-individual}" + if [[ "$DEFAULT_KEY_KIND" != "team" ]]; then + DEFAULT_KEY_KIND="individual" + fi + + EXPECTED_KEY_NAME="ApiKey_${APP_STORE_CONNECT_API_KEY}.p8" + if [[ "$DEFAULT_KEY_KIND" == "team" ]]; then + EXPECTED_KEY_NAME="AuthKey_${APP_STORE_CONNECT_API_KEY}.p8" + fi + + TARGET_KEY_PATH="$PRIVATE_KEYS_DIR/$EXPECTED_KEY_NAME" + cp -f "$APP_STORE_CONNECT_API_PRIVATE_KEY_PATH" "$TARGET_KEY_PATH" +fi + +# Clean and prepare +echo "🧹 Cleaning previous build artifacts..." +rm -rf "$ARCHIVE_PATH" "$EXPORT_PATH" +mkdir -p "$EXPORT_PATH" + +# Archive with build number override (no project file changes!) +echo "πŸ”¨ Archiving $SCHEME (build $NEW_BUILD)..." +xcodebuild archive \ + -project "$PROJECT_PATH" \ + -scheme "$SCHEME" \ + -configuration "$CONFIGURATION" \ + -destination 'generic/platform=iOS' \ + -archivePath "$ARCHIVE_PATH" \ + CURRENT_PROJECT_VERSION="$NEW_BUILD" \ + SKIP_INSTALL=NO + +# Create IPA manually (workaround for Xcode 26 exportArchive issues) +echo "πŸ“¦ Creating IPA from archive..." +APP_PATH="$ARCHIVE_PATH/Products/Applications/IngrediCheck.app" +if [[ ! -d "$APP_PATH" ]]; then + echo "❌ App bundle not found at $APP_PATH" >&2 + exit 1 +fi + +PAYLOAD_DIR="$EXPORT_PATH/Payload" +mkdir -p "$PAYLOAD_DIR" +cp -R "$APP_PATH" "$PAYLOAD_DIR/" + +cd "$EXPORT_PATH" +zip -r -q "$IPA_NAME.ipa" Payload +rm -rf Payload +cd "$PROJECT_ROOT" + +if [[ ! -f "$IPA_PATH" ]]; then + echo "❌ Failed to create IPA at $IPA_PATH" >&2 + exit 1 +fi + +echo "βœ… IPA created at $IPA_PATH" + +if [[ "${SKIP_UPLOAD:-0}" == "1" ]]; then + echo "" + echo "⏭️ SKIP_UPLOAD=1 set; skipping upload." + echo " IPA ready at: $IPA_PATH" + exit 0 +fi + +echo "πŸš€ Uploading IPA via iTMSTransporter..." + +"${TRANSPORTER_CLI}" -m upload \ + -apiKey "$APP_STORE_CONNECT_API_KEY" \ + -apiIssuer "$APP_STORE_CONNECT_API_ISSUER" \ + -apiKeyType "$DEFAULT_KEY_KIND" \ + -assetFile "$IPA_PATH" \ + -v informational + +echo "" +echo "=========================================" +echo "βœ… Upload complete!" +echo " Version: 2.0" +echo " Build: $NEW_BUILD" +echo "" +echo "Next steps:" +echo " β€’ Wait for processing (~5 min)" +echo " β€’ Check status: /asc-builds" +echo "=========================================" diff --git a/.claude/skills/asc-reviews/SKILL.md b/.claude/skills/asc-reviews/SKILL.md new file mode 100644 index 00000000..0c4cd9d8 --- /dev/null +++ b/.claude/skills/asc-reviews/SKILL.md @@ -0,0 +1,172 @@ +--- +name: asc-reviews +description: Show App Store ratings and customer reviews. Use to see overall rating, user feedback, filter by stars. +argument-hint: [stars] +allowed-tools: + - Bash(*) +--- + +# Ratings & Reviews + +Show App Store ratings summary and customer reviews. + +Arguments: $ARGUMENTS (optional star rating 1-5 to filter reviews) + +## Prerequisites + +Validate setup first: +```bash +source .claude/skills/scripts/asc-common.sh +asc_validate || exit 1 +``` + +## Commands + +### Show Ratings Summary + +Always show ratings first using iTunes Lookup API: + +```bash +source .claude/skills/scripts/asc-common.sh +asc_load_config + +# Fetch ratings from iTunes Lookup API (use temp file to handle control chars) +TMPFILE=$(mktemp) +trap "rm -f $TMPFILE" EXIT +curl -s "https://itunes.apple.com/lookup?id=$ASC_APP_ID&country=us" > "$TMPFILE" +AVG_RATING=$(jq -r '.results[0].averageUserRating // "N/A"' "$TMPFILE") +RATING_COUNT=$(jq -r '.results[0].userRatingCount // "N/A"' "$TMPFILE") + +# Format rating with stars +if [ "$AVG_RATING" != "N/A" ] && [ "$AVG_RATING" != "null" ]; then + STARS_DISPLAY=$(printf "%.1f" "$AVG_RATING") +else + STARS_DISPLAY="N/A" +fi + +echo "## App Store Ratings" +echo "" +echo "| Metric | Value |" +echo "|--------|-------|" +echo "| Average Rating | $STARS_DISPLAY ⭐ |" +echo "| Total Ratings | $RATING_COUNT |" +echo "" +``` + +### List Recent Reviews + +```bash +source .claude/skills/scripts/asc-common.sh +asc_load_config + +# List recent reviews (newest first) +asc reviews --app "$ASC_APP_ID" --sort -createdDate --limit 10 --output table +``` + +### Filter by Star Rating + +```bash +source .claude/skills/scripts/asc-common.sh +asc_load_config + +# Filter by stars (1-5) +STARS="${1:-}" +if [ -n "$STARS" ]; then + asc reviews --app "$ASC_APP_ID" --stars "$STARS" --sort -createdDate --limit 20 --output table +else + asc reviews --app "$ASC_APP_ID" --sort -createdDate --limit 10 --output table +fi +``` + +### Filter by Territory + +```bash +source .claude/skills/scripts/asc-common.sh +asc_load_config + +# US reviews only +asc reviews --app "$ASC_APP_ID" --territory US --sort -createdDate --limit 10 --output table +``` + +### JSON Output (for analysis) + +```bash +source .claude/skills/scripts/asc-common.sh +asc_load_config + +# Get all 1-star reviews for analysis +asc reviews --app "$ASC_APP_ID" --stars 1 --paginate | jq '.data[] | {rating: .attributes.rating, title: .attributes.title, body: .attributes.body, date: .attributes.createdDate}' +``` + +## Review Fields + +Key fields in review output: +- `rating`: Star rating (1-5) +- `title`: Review title +- `body`: Review text +- `createdDate`: When review was posted +- `territory`: App Store region (US, GBR, etc.) +- `reviewerNickname`: Reviewer's display name + +## Common Workflows + +### Get 1-star reviews to address issues +```bash +source .claude/skills/scripts/asc-common.sh +asc_load_config +asc reviews --app "$ASC_APP_ID" --stars 1 --sort -createdDate --limit 20 --output markdown +``` + +### Full Ratings & Reviews Summary (Default) + +When running `/asc-reviews` without arguments, show both ratings and recent reviews: + +```bash +source .claude/skills/scripts/asc-common.sh +asc_load_config + +# 1. Show ratings from iTunes (use temp file to handle control chars) +TMPFILE=$(mktemp) +trap "rm -f $TMPFILE" EXIT +curl -s "https://itunes.apple.com/lookup?id=$ASC_APP_ID&country=us" > "$TMPFILE" +AVG_RATING=$(jq -r '.results[0].averageUserRating // "N/A"' "$TMPFILE") +RATING_COUNT=$(jq -r '.results[0].userRatingCount // "N/A"' "$TMPFILE") + +if [ "$AVG_RATING" != "N/A" ] && [ "$AVG_RATING" != "null" ]; then + STARS_DISPLAY=$(printf "%.1f" "$AVG_RATING") +else + STARS_DISPLAY="N/A" +fi + +echo "## App Store Ratings" +echo "" +echo "| Metric | Value |" +echo "|--------|-------|" +echo "| Average Rating | $STARS_DISPLAY ⭐ |" +echo "| Total Ratings | $RATING_COUNT |" +echo "" + +# 2. Show recent reviews +echo "## Recent Reviews" +echo "" +asc reviews --app "$ASC_APP_ID" --sort -createdDate --limit 10 --output markdown +``` + +### Respond to a review +```bash +# Get review ID from list, then: +asc reviews respond --review-id "REVIEW_ID" --response "Thank you for your feedback..." +``` + +## Responding to Reviews + +```bash +# Respond to a customer review +asc reviews respond --review-id "REVIEW_ID" --response "Thank you for your feedback! We're working on..." + +# Get existing response +asc reviews response for-review --review-id "REVIEW_ID" + +# Delete a response +asc reviews response delete --id "RESPONSE_ID" --confirm +``` diff --git a/.claude/skills/asc-sales/SKILL.md b/.claude/skills/asc-sales/SKILL.md new file mode 100644 index 00000000..79e7c1f3 --- /dev/null +++ b/.claude/skills/asc-sales/SKILL.md @@ -0,0 +1,155 @@ +--- +name: asc-sales +description: Download sales reports and analytics from App Store Connect. +argument-hint: [date YYYY-MM-DD] +allowed-tools: + - Bash(*) +--- + +# Sales & Analytics + +Download sales reports and analytics from App Store Connect. + +Arguments: $ARGUMENTS (optional: date in YYYY-MM-DD format) + +## Prerequisites + +Validate setup first: +```bash +source .claude/skills/scripts/asc-common.sh +asc_validate || exit 1 +``` + +**Note**: Sales reports require a Vendor Number. Find it in App Store Connect under "Payments and Financial Reports". + +## Daily Sales Summary + +```bash +# Get yesterday's sales (daily reports have ~1 day delay) +DATE="${1:-$(date -v-1d +%Y-%m-%d)}" +asc analytics sales \ + --vendor "$ASC_VENDOR_NUMBER" \ + --type SALES \ + --subtype SUMMARY \ + --frequency DAILY \ + --date "$DATE" \ + --decompress +``` + +## Monthly Sales Summary + +```bash +# Get last month's sales +MONTH=$(date -v-1m +%Y-%m) +asc analytics sales \ + --vendor "$ASC_VENDOR_NUMBER" \ + --type SALES \ + --subtype SUMMARY \ + --frequency MONTHLY \ + --date "$MONTH" \ + --decompress +``` + +## Subscription Reports + +```bash +# Detailed subscription report for a month +asc analytics sales \ + --vendor "$ASC_VENDOR_NUMBER" \ + --type SUBSCRIPTION \ + --subtype DETAILED \ + --frequency MONTHLY \ + --date "2025-01" \ + --decompress +``` + +## Financial Reports + +```bash +# Download financial report for a region +asc finance reports \ + --vendor "$ASC_VENDOR_NUMBER" \ + --report-type FINANCIAL \ + --region "US" \ + --date "2025-01" +``` + +### List Available Regions + +```bash +asc finance regions --output table +``` + +## App Analytics (Advanced) + +For detailed app analytics (downloads, impressions, etc.): + +### Create Analytics Request + +```bash +source .claude/skills/scripts/asc-common.sh +asc_load_config + +# Request ongoing analytics access +asc analytics request --app "$ASC_APP_ID" --access-type ONGOING +``` + +### List Analytics Requests + +```bash +source .claude/skills/scripts/asc-common.sh +asc_load_config + +asc analytics requests --app "$ASC_APP_ID" --output table +``` + +### Download Analytics Data + +```bash +# Get reports for a request +asc analytics get --request-id "REQUEST_ID" + +# Download specific report instance +asc analytics download --request-id "REQUEST_ID" --instance-id "INSTANCE_ID" +``` + +## Report Types + +| Type | Description | +|------|-------------| +| `SALES` | App sales and downloads | +| `PRE_ORDER` | Pre-order metrics | +| `NEWSSTAND` | Newsstand subscriptions | +| `SUBSCRIPTION` | In-app subscriptions | +| `SUBSCRIPTION_EVENT` | Subscription events (cancellations, etc.) | + +## Subtypes + +| Subtype | Description | +|---------|-------------| +| `SUMMARY` | Aggregated summary data | +| `DETAILED` | Line-by-line transaction data | + +## Configuration + +Add vendor number to `.asc/config.json`: + +```json +{ + "app_id": "YOUR_APP_ID", + "profile": "IngrediCheck", + "vendor_number": "YOUR_VENDOR_NUMBER" +} +``` + +Or set environment variable: +```bash +export ASC_VENDOR_NUMBER="YOUR_VENDOR_NUMBER" +``` + +## Notes + +- Daily reports have ~1 day delay +- Monthly reports available after month ends +- Financial reports require Account Holder, Admin, or Finance role +- Large reports are gzip compressed (use `--decompress` to extract) diff --git a/.claude/skills/asc-setup/SKILL.md b/.claude/skills/asc-setup/SKILL.md new file mode 100644 index 00000000..573668c4 --- /dev/null +++ b/.claude/skills/asc-setup/SKILL.md @@ -0,0 +1,106 @@ +--- +name: asc-setup +description: Setup and validate App Store Connect CLI. Use when user needs to configure asc authentication or check setup status. +allowed-tools: + - Bash(*) +--- + +# App Store Connect Setup + +Validate installation and configure authentication for the `asc` CLI. + +## Quick Check + +```bash +# Check if asc is installed +asc --version + +# Check authentication status +asc auth status +``` + +## Setup Steps + +### 1. Install asc (if needed) + +```bash +brew tap rudrankriyam/tap +brew install rudrankriyam/tap/asc +``` + +### 2. Create API Key + +If not authenticated, guide the user to create an API key: + +1. Open: https://appstoreconnect.apple.com/access/integrations/api +2. Click "+" to create a new key +3. Name: "Claude Code" (or similar) +4. Access: "App Manager" (or higher for submissions) +5. Download the `.p8` file (only available once!) +6. Note the **Key ID** and **Issuer ID** + +### 3. Authenticate + +```bash +asc auth login \ + --name "IngrediCheck" \ + --key-id "" \ + --issuer-id "" \ + --private-key /path/to/AuthKey_XXXXX.p8 +``` + +### 4. Find App ID + +```bash +# List all apps (table format for readability) +asc apps --output table + +# Or search by bundle ID +asc apps --bundle-id "llc.fungee.ingredicheck" --output table +``` + +### 5. Save Config + +Create `.asc/config.json` with the discovered app ID: + +```bash +mkdir -p .asc +cat > .asc/config.json << 'EOF' +{ + "app_id": "DISCOVERED_APP_ID", + "profile": "IngrediCheck" +} +EOF +``` + +## IngrediCheck App Details + +| Property | Value | +|----------|-------| +| Bundle ID | `llc.fungee.ingredicheck` | +| Team ID | `58MYNHGN72` | +| Scheme | `IngrediCheck` | +| Project | `IngrediCheck.xcodeproj` | + +## Verification + +After setup, verify everything works: + +```bash +# Should show authenticated profile +asc auth status + +# Should list the app +asc apps --bundle-id "llc.fungee.ingredicheck" --output table + +# Test builds access (requires app ID) +source .claude/skills/scripts/asc-common.sh +asc_load_config && asc builds list --app "$ASC_APP_ID" --limit 1 +``` + +## Troubleshooting + +- **"No credentials stored"**: Run `asc auth login` with your API key +- **"Invalid key"**: Verify key ID, issuer ID, and .p8 file path +- **"Forbidden"**: API key may lack required permissions +- **App not found**: Double-check bundle ID or use `asc apps` to list all apps diff --git a/.claude/skills/asc-submit/SKILL.md b/.claude/skills/asc-submit/SKILL.md new file mode 100644 index 00000000..6fdebe25 --- /dev/null +++ b/.claude/skills/asc-submit/SKILL.md @@ -0,0 +1,121 @@ +--- +name: asc-submit +description: Submit builds for App Store review. Use to submit for review or check submission status. +argument-hint: [status] +allowed-tools: + - Bash(*) +--- + +# App Store Submission + +Submit builds for App Store review and check submission status. + +Arguments: $ARGUMENTS (optional: "status" to check current submission) + +## Prerequisites + +Validate setup first: +```bash +source .claude/skills/scripts/asc-common.sh +asc_validate || exit 1 +``` + +## Check Submission Status + +```bash +source .claude/skills/scripts/asc-common.sh +asc_load_config + +# Check current submission status +asc submit status --app "$ASC_APP_ID" --output table +``` + +## Submit for Review + +### 1. Find the Build to Submit + +```bash +source .claude/skills/scripts/asc-common.sh +asc_load_config + +# List recent builds to find the one to submit +asc builds list --app "$ASC_APP_ID" --limit 5 --output table +``` + +### 2. Check Build Status + +```bash +# Verify build is processed and ready +asc builds info --build "BUILD_ID" | jq '{processingState: .data.attributes.processingState, version: .data.attributes.version, build: .data.attributes.buildNumber}' +``` + +### 3. Submit the Build + +```bash +# Submit for App Store review +asc submit create --app "$ASC_APP_ID" --build "BUILD_ID" +``` + +## Cancel Submission + +```bash +source .claude/skills/scripts/asc-common.sh +asc_load_config + +# Cancel an active submission +asc submit cancel --app "$ASC_APP_ID" +``` + +## Submission States + +| State | Description | +|-------|-------------| +| `WAITING_FOR_REVIEW` | Submitted, in Apple's queue | +| `IN_REVIEW` | Currently being reviewed | +| `PENDING_DEVELOPER_RELEASE` | Approved, awaiting manual release | +| `READY_FOR_SALE` | Live on the App Store | +| `REJECTED` | Review rejected | + +## Pre-Submission Checklist + +Before submitting, verify: + +1. **Build is valid** + ```bash + asc builds info --build "BUILD_ID" | jq '.data.attributes.processingState' + # Should be "VALID" + ``` + +2. **App metadata is complete** + ```bash + asc app-info get --app "$ASC_APP_ID" --output table + ``` + +3. **Screenshots uploaded** + ```bash + asc assets list --version "VERSION_ID" --output table + ``` + +## Subcommand Routing + +```bash +source .claude/skills/scripts/asc-common.sh +asc_load_config + +case "${1:-status}" in + status) + asc submit status --app "$ASC_APP_ID" --output table + ;; + *) + # Default: show status + asc submit status --app "$ASC_APP_ID" --output table + ;; +esac +``` + +## Notes + +- Submissions require "App Manager" or higher API key access +- Only one submission can be active at a time +- Rejected submissions can be resubmitted after addressing issues +- Use `asc versions` to manage App Store version metadata diff --git a/.claude/skills/asc-testflight/SKILL.md b/.claude/skills/asc-testflight/SKILL.md new file mode 100644 index 00000000..4d40d47f --- /dev/null +++ b/.claude/skills/asc-testflight/SKILL.md @@ -0,0 +1,171 @@ +--- +name: asc-testflight +description: Manage TestFlight beta testers, groups, feedback, and crashes. Use for beta testing workflows. +argument-hint: [testers|groups|feedback|crashes] +allowed-tools: + - Bash(*) +--- + +# TestFlight Management + +Manage TestFlight beta testers, groups, feedback, and crash reports. + +Arguments: $ARGUMENTS (optional: testers, groups, feedback, crashes) + +## Prerequisites + +Validate setup first: +```bash +source .claude/skills/scripts/asc-common.sh +asc_validate || exit 1 +``` + +## Overview Command + +Show TestFlight summary: +```bash +source .claude/skills/scripts/asc-common.sh +asc_load_config + +echo "=== Beta Groups ===" +asc beta-groups list --app "$ASC_APP_ID" --output table + +echo "" +echo "=== Recent Feedback ===" +asc feedback --app "$ASC_APP_ID" --limit 5 --sort -createdDate --output table + +echo "" +echo "=== Recent Crashes ===" +asc crashes --app "$ASC_APP_ID" --limit 5 --sort -createdDate --output table +``` + +## Beta Testers + +### List Testers +```bash +source .claude/skills/scripts/asc-common.sh +asc_load_config +asc beta-testers list --app "$ASC_APP_ID" --output table +``` + +### Add Tester +```bash +source .claude/skills/scripts/asc-common.sh +asc_load_config +# Add tester to app (optionally specify group) +asc beta-testers add --app "$ASC_APP_ID" --email "tester@example.com" --group "Beta Testers" +``` + +### Invite Tester +```bash +source .claude/skills/scripts/asc-common.sh +asc_load_config +asc beta-testers invite --app "$ASC_APP_ID" --email "tester@example.com" +``` + +### Remove Tester +```bash +source .claude/skills/scripts/asc-common.sh +asc_load_config +asc beta-testers remove --app "$ASC_APP_ID" --email "tester@example.com" +``` + +## Beta Groups + +### List Groups +```bash +source .claude/skills/scripts/asc-common.sh +asc_load_config +asc beta-groups list --app "$ASC_APP_ID" --output table +``` + +### Create Group +```bash +source .claude/skills/scripts/asc-common.sh +asc_load_config +asc beta-groups create --app "$ASC_APP_ID" --name "Internal Testers" +``` + +### Add Build to Group +```bash +# Enable build for a beta group +asc builds add-groups --build "BUILD_ID" --group "GROUP_ID" +``` + +## Feedback + +### List Recent Feedback +```bash +source .claude/skills/scripts/asc-common.sh +asc_load_config +asc feedback --app "$ASC_APP_ID" --sort -createdDate --limit 20 --output table +``` + +### Feedback with Screenshots +```bash +source .claude/skills/scripts/asc-common.sh +asc_load_config +asc feedback --app "$ASC_APP_ID" --include-screenshots --limit 10 +``` + +### Filter by Device +```bash +source .claude/skills/scripts/asc-common.sh +asc_load_config +asc feedback --app "$ASC_APP_ID" --device-model "iPhone15,3" --output table +``` + +## Crash Reports + +### List Recent Crashes +```bash +source .claude/skills/scripts/asc-common.sh +asc_load_config +asc crashes --app "$ASC_APP_ID" --sort -createdDate --limit 20 --output table +``` + +### Export Crashes (JSON) +```bash +source .claude/skills/scripts/asc-common.sh +asc_load_config +asc crashes --app "$ASC_APP_ID" --paginate > crashes.json +``` + +### Filter by OS Version +```bash +source .claude/skills/scripts/asc-common.sh +asc_load_config +asc crashes --app "$ASC_APP_ID" --os-version "18.0" --output table +``` + +## Subcommand Routing + +Based on argument ($ARGUMENTS), run the appropriate section: + +```bash +source .claude/skills/scripts/asc-common.sh +asc_load_config + +case "${1:-overview}" in + testers) + asc beta-testers list --app "$ASC_APP_ID" --output table + ;; + groups) + asc beta-groups list --app "$ASC_APP_ID" --output table + ;; + feedback) + asc feedback --app "$ASC_APP_ID" --sort -createdDate --limit 20 --output table + ;; + crashes) + asc crashes --app "$ASC_APP_ID" --sort -createdDate --limit 20 --output table + ;; + *) + # Overview + echo "=== Beta Groups ===" + asc beta-groups list --app "$ASC_APP_ID" --output table + echo "" + echo "=== Beta Testers (first 10) ===" + asc beta-testers list --app "$ASC_APP_ID" --limit 10 --output table + ;; +esac +``` diff --git a/.claude/skills/ios-debug/SKILL.md b/.claude/skills/ios-debug/SKILL.md new file mode 100644 index 00000000..53d6b504 --- /dev/null +++ b/.claude/skills/ios-debug/SKILL.md @@ -0,0 +1,107 @@ +--- +name: ios-debug +description: Analyze debug logs from running IngrediCheck app. Use when debugging issues, checking errors, or user reports a problem. +argument-hint: [issue description] +context: fork +agent: general-purpose +model: haiku +--- + +# Analyze Debug Logs + +User's issue: $ARGUMENTS + +--- + +## CRITICAL: Follow These Steps EXACTLY + +**DO NOT** do your own analysis. **ONLY** run the helper script and summarize its output. + +### Step 1: Run the Helper Script + +```bash +.claude/skills/ios-debug/scripts/debug-check.sh +``` + +If user specified a device name in their query, pass it as argument: +```bash +.claude/skills/ios-debug/scripts/debug-check.sh aadi +``` + +### Step 2: Summarize the Output + +Based on the script output, provide a **brief** summary: +- If errors found: list them with relevant context +- If no errors: say "No errors found in app logs" +- If no app logs: say "No app logs captured yet" +- Answer the user's specific question if possible + +**Keep response concise.** The script already shows the logs - don't repeat them verbatim. + +### Step 3: Done + +End with: "Debug check completed in Xms" (from script output) + +--- + +## Only If Script Fails + +If the helper script errors with "No active devices", tell user to run `/deploy-ios` first. + +If the script shows 0 app logs but user needs logs, suggest: +```bash +./.claude/skills/ios-deploy/scripts/deploy-device.sh -s +``` + +--- + +## DO NOT + +- Do NOT run additional grep/analysis commands unless explicitly asked +- Do NOT analyze system logs (Network, CFNetwork, etc.) - only app logs matter +- Do NOT give lengthy explanations about how logging works +- Do NOT run multiple tool calls - one script call should be enough + +## Config +- Bundle ID: `llc.fungee.ingredicheck` +- Log file (device): `/tmp/ingredicheck-logs-.txt` (per-device) +- Log file (simulator): `/tmp/ingredicheck-sim-logs.txt` + +## Technical Notes + +### Log Utility Architecture +The app uses `Log.debug/info/warning/error()` which internally calls NSLog: +- **Why NSLog?** Captured reliably via `devicectl --console` on iOS 18+ +- Log format: `[Category] message` (e.g., `[FamilyStore] loadCurrentFamily() called`) + +### Key Benefit +For physical devices, the app **continues running** during debug analysis - no restart, no lost state! + +### Quick Diagnostics Checklist +Run these in order when logs seem missing or wrong (replace `` with device UUID, `` with device UDID): +```bash +# 1. Is devicectl console running for this device? +pgrep -f "devicectl.*" + +# 2. Is the log file being written to? +ls -lh /tmp/ingredicheck-logs-.txt + +# 3. Is the device connected? +xcrun devicectl list devices + +# 4. Any app logs at all? (0 = stale logs, need to truncate and wait) +grep -a -c "IngrediCheck(Foundation)" /tmp/ingredicheck-logs-.txt + +# 5. Quick peek at app logs +grep -a "IngrediCheck(Foundation)" /tmp/ingredicheck-logs-.txt | tail -20 +``` + +### Troubleshooting +- **No logs appearing?** Check if devicectl console is running: `pgrep -f "devicectl.*"` +- **Log file empty (0 bytes)?** devicectl may have died immediately - check device connection with `xcrun devicectl list devices` +- **Large file but 0 app logs?** Stale logs from before app launched - redeploy with `./.claude/skills/ios-deploy/scripts/deploy-device.sh -s` +- **Only system logs?** Custom Log calls use NSLog; if you see `` that's os_log (shouldn't happen) +- **Binary file warning from grep?** Use `grep -a` flag for binary mode +- **Process died?** Redeploy app: `./.claude/skills/ios-deploy/scripts/deploy-device.sh -s` +- **Device locked?** Unlock the device - devicectl may not capture logs when locked +- **Multiple devices?** Each device has its own log file. Check `.claude/debug.txt` for active devices. diff --git a/.claude/skills/ios-debug/scripts/debug-check.sh b/.claude/skills/ios-debug/scripts/debug-check.sh new file mode 100755 index 00000000..d9dc6156 --- /dev/null +++ b/.claude/skills/ios-debug/scripts/debug-check.sh @@ -0,0 +1,123 @@ +#!/bin/bash +# Fast debug log extraction script +# Usage: ./debug-check.sh [device_name] +# Outputs structured data for quick parsing by the agent + +set -e + +START_TIME=$(python3 -c 'import time; print(int(time.time() * 1000))' 2>/dev/null || date +%s) + +DEBUG_FILE=".claude/debug.txt" +REQUESTED_DEVICE="${1:-}" + +# Colors for terminal output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' + +error() { echo -e "${RED}ERROR:${NC} $1" >&2; exit 1; } + +# Check debug.txt exists +[ ! -f "$DEBUG_FILE" ] && error "No debug.txt found. Run /deploy-ios first." + +# Temp files for device tracking +ACTIVE_LIST=$(mktemp) +trap "rm -f $ACTIVE_LIST" EXIT + +# Parse devices and check which are active +while IFS= read -r line; do + [ -z "$line" ] && continue + type="${line%%:*}" + + if [ "$type" = "device" ]; then + # device:::: + uuid=$(echo "$line" | cut -d: -f2) + name=$(echo "$line" | cut -d: -f3) + logfile=$(echo "$line" | cut -d: -f4) + udid=$(echo "$line" | cut -d: -f5) + + # Check if active (devicectl console running for this device UUID) + syslog_running=false + for pid in $(pgrep -f "devicectl" 2>/dev/null); do + if ps -p "$pid" -o args= 2>/dev/null | grep -q "$uuid"; then + syslog_running=true + break + fi + done + + if [ "$syslog_running" = true ] && [ -f "$logfile" ]; then + echo "$name|device|$logfile|$udid" >> "$ACTIVE_LIST" + fi + elif [ "$type" = "sim" ]; then + # sim:: + simid=$(echo "$line" | cut -d: -f2) + logfile=$(echo "$line" | cut -d: -f3) + + if pgrep -f "simctl launch.*$simid" >/dev/null 2>&1 && [ -f "$logfile" ]; then + echo "simulator|sim|$logfile|$simid" >> "$ACTIVE_LIST" + fi + fi +done < "$DEBUG_FILE" + +# Check if any devices active +DEVICE_COUNT=$(wc -l < "$ACTIVE_LIST" | tr -d ' ') +if [ "$DEVICE_COUNT" -eq 0 ]; then + error "No active devices found. Run /deploy-ios first." +fi + +# Select device +SELECTED_LINE="" +if [ -n "$REQUESTED_DEVICE" ]; then + # User specified a device + SELECTED_LINE=$(grep -i "$REQUESTED_DEVICE" "$ACTIVE_LIST" | head -1) + [ -z "$SELECTED_LINE" ] && error "Device '$REQUESTED_DEVICE' not active. Active: $(cut -d'|' -f1 "$ACTIVE_LIST" | tr '\n' ' ')" +elif [ "$DEVICE_COUNT" -eq 1 ]; then + SELECTED_LINE=$(cat "$ACTIVE_LIST") +else + echo -e "${YELLOW}Multiple active devices:${NC}" + cut -d'|' -f1 "$ACTIVE_LIST" | while read d; do echo " - $d"; done + error "Specify device name as argument" +fi + +# Parse selected device +IFS='|' read -r name dtype logfile identifier <<< "$SELECTED_LINE" + +echo -e "${CYAN}=== Debug Check: $name ===${NC}" +echo "" + +# Log file stats +LOG_SIZE_HUMAN=$(ls -lh "$logfile" 2>/dev/null | awk '{print $5}') +echo -e "${GREEN}Log file:${NC} $logfile ($LOG_SIZE_HUMAN)" + +# Count app logs - devicectl console uses [Category] format (not IngrediCheck(Foundation)) +# Match lines that start with [ followed by alphanumeric/underscore, then ] +APP_LOG_COUNT=$(grep -aE '^\[[-_A-Za-z0-9]+\]' "$logfile" 2>/dev/null | wc -l | tr -d ' ' || echo 0) +[ -z "$APP_LOG_COUNT" ] && APP_LOG_COUNT=0 +echo -e "${GREEN}App log lines:${NC} $APP_LOG_COUNT" + +# Extract app logs (last 100 lines max for speed) +echo "" +echo -e "${CYAN}=== App Logs ===${NC}" +if [ "$APP_LOG_COUNT" -gt 0 ] 2>/dev/null; then + grep -aE '^\[[-_A-Za-z0-9]+\]' "$logfile" | tail -100 +else + echo "(No app logs found - app may not have logged anything yet)" +fi + +# Check for errors (only in our app logs, exclude system noise) +echo "" +echo -e "${CYAN}=== Errors ===${NC}" +ERROR_LINES=$(grep -aE '^\[[-_A-Za-z0-9]+\]' "$logfile" 2>/dev/null | grep -a -i "error\|failed\|❌" | tail -20 || true) +if [ -n "$ERROR_LINES" ]; then + echo "$ERROR_LINES" +else + echo "(No errors found in app logs)" +fi + +# Timing +END_TIME=$(python3 -c 'import time; print(int(time.time() * 1000))' 2>/dev/null || date +%s) +DURATION=$((END_TIME - START_TIME)) +echo "" +echo -e "${GREEN}Debug check completed in ${DURATION}ms${NC}" diff --git a/.claude/skills/ios-deploy/SKILL.md b/.claude/skills/ios-deploy/SKILL.md new file mode 100644 index 00000000..18dfefb6 --- /dev/null +++ b/.claude/skills/ios-deploy/SKILL.md @@ -0,0 +1,65 @@ +--- +name: ios-deploy +description: Build and deploy IngrediCheck to iOS device. Use when user wants to deploy, test on device, or run the app. +argument-hint: [target] +allowed-tools: + - Bash(*) +--- + +# Deploy to Device + +Build and deploy IngrediCheck to a connected iOS device or simulator. + +Target: $ARGUMENTS + +## Quick Deploy (Recommended) + +Run the deploy script directly: + +```bash +./.claude/skills/ios-deploy/scripts/deploy-device.sh # Full build + install +./.claude/skills/ios-deploy/scripts/deploy-device.sh -s # Skip build, just reinstall +``` + +The script outputs "Deploy complete in Xs" at the end. Report this total time to the user. + +--- + +## Simulator (target = "sim" or "simulator") + +```bash +# Boot simulator if needed +SIMID=$(xcrun simctl list devices booted | grep -oE '[0-9A-F-]{36}' | head -1) +if [ -z "$SIMID" ]; then + SIMID=$(xcrun simctl list devices available | grep "iPhone" | head -1 | grep -oE '[0-9A-F-]{36}') + xcrun simctl boot "$SIMID" +fi + +# Build, install, launch +xcodebuild -project "IngrediCheck.xcodeproj" -scheme "IngrediCheck" -destination "id=$SIMID" build +APP_PATH=$(find ~/Library/Developer/Xcode/DerivedData -name "IngrediCheck.app" -path "*/Debug-iphonesimulator/*" -not -path "*/Index.noindex/*" | head -1) +xcrun simctl install "$SIMID" "$APP_PATH" +nohup xcrun simctl launch --console "$SIMID" llc.fungee.ingredicheck > /tmp/ingredicheck-sim-logs.txt 2>&1 & +mkdir -p .claude && echo "sim:$SIMID:/tmp/ingredicheck-sim-logs.txt" > .claude/debug.txt +``` + +## Config +- Bundle ID: `llc.fungee.ingredicheck` +- Scheme: `IngrediCheck` +- Project: `IngrediCheck.xcodeproj` +- Team ID: `58MYNHGN72` + +## Known Devices +| Name | CoreDevice UUID | UDID | +|------|-----------------|------| +| aadi | 9A624D5C-FA2D-59A1-9CB3-C24FFA4BCAEC | 00008101-000230843A68001E | + +## Troubleshooting +- Device must be unlocked and trusted +- If connection errors: unplug/replug USB, or restart device +- Logs: `tail -f /tmp/ingredicheck-logs-.txt` (replace `` with device UDID) +- Log capture uses `devicectl --console` which reliably captures NSLog on iOS 18+ +- **Missing Config.swift in worktrees**: `Config.swift` is gitignored (contains secrets/keys). When building from a new worktree, copy it from the main worktree: + ```bash + cp /Users/sanket/GitHub/IngrediCheck-iOS/IngrediCheck/Config.swift /IngrediCheck/Config.swift + ``` diff --git a/.claude/skills/ios-deploy/scripts/deploy-device.sh b/.claude/skills/ios-deploy/scripts/deploy-device.sh new file mode 100755 index 00000000..2edeacf7 --- /dev/null +++ b/.claude/skills/ios-deploy/scripts/deploy-device.sh @@ -0,0 +1,167 @@ +#!/bin/bash +# Fast iOS deployment script for IngrediCheck +# Usage: ./scripts/deploy-device.sh [--skip-build] + +set -e + +# Config +PROJECT="IngrediCheck.xcodeproj" +SCHEME="IngrediCheck" +BUNDLE_ID="llc.fungee.ingredicheck" +DEVICE_UUID="9A624D5C-FA2D-59A1-9CB3-C24FFA4BCAEC" +DEVICE_UDID="00008101-000230843A68001E" +TEAM_ID="58MYNHGN72" +LOG_FILE="/tmp/ingredicheck-logs-${DEVICE_UDID}.txt" + +# Colors +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' + +log() { echo -e "${GREEN}[DEPLOY]${NC} $1"; } +warn() { echo -e "${YELLOW}[DEPLOY]${NC} $1"; } +error() { echo -e "${RED}[DEPLOY]${NC} $1"; exit 1; } + +# Timeout function for macOS +run_with_timeout() { + local timeout=$1; shift + "$@" & local pid=$! + ( sleep "$timeout"; kill -9 $pid 2>/dev/null ) & + wait $pid 2>/dev/null +} + +START_TIME=$(date +%s) + +# Parse args +SKIP_BUILD=false +for arg in "$@"; do + case $arg in + --skip-build|-s) SKIP_BUILD=true ;; + esac +done + +# Kill existing log capture and trimmer for THIS device only, truncate log file +# Kill devicectl console processes for this device (uses UUID not UDID) +pkill -f "devicectl device process launch.*$DEVICE_UUID" 2>/dev/null || true +for pid in $(pgrep -x bash 2>/dev/null; pgrep -x zsh 2>/dev/null); do + if ps -p "$pid" -o args= 2>/dev/null | grep -q "log-trimmer.*${LOG_FILE}"; then + kill "$pid" 2>/dev/null || true + fi +done +> "$LOG_FILE" + +# Build (unless skipped) +if [ "$SKIP_BUILD" = false ]; then + log "Building for device..." + BUILD_START=$(date +%s) + + # Use generic platform - doesn't require device during build + xcodebuild -project "$PROJECT" \ + -scheme "$SCHEME" \ + -destination "generic/platform=iOS" \ + -allowProvisioningUpdates \ + CODE_SIGN_STYLE=Automatic \ + CODE_SIGN_IDENTITY="Apple Development" \ + DEVELOPMENT_TEAM="$TEAM_ID" \ + PROVISIONING_PROFILE_SPECIFIER="" \ + build 2>&1 | grep -E "(Build Succeeded|error:|warning:.*error)" || true + + BUILD_END=$(date +%s) + log "Build completed in $((BUILD_END - BUILD_START))s" +else + warn "Skipping build (--skip-build)" +fi + +# Find app (exclude Index.noindex) +APP_PATH=$(find ~/Library/Developer/Xcode/DerivedData -name "IngrediCheck.app" -path "*/Build/Products/Debug-iphoneos/*" -not -path "*/Index.noindex/*" -type d 2>/dev/null | head -1) +[ -z "$APP_PATH" ] && error "App not found in DerivedData! Run a full build first." +log "Found app: $APP_PATH" + +# Check device is connected +if ! xcrun devicectl list devices 2>/dev/null | grep -q "$DEVICE_UUID"; then + warn "Device not found. Checking available devices..." + xcrun devicectl list devices 2>/dev/null | head -5 + error "Device $DEVICE_UUID not connected" +fi + +# Install using devicectl (more reliable than ios-deploy) +log "Installing to device..." +INSTALL_START=$(date +%s) +xcrun devicectl device install app --device "$DEVICE_UUID" "$APP_PATH" 2>&1 || error "Install failed!" +INSTALL_END=$(date +%s) +log "Install completed in $((INSTALL_END - INSTALL_START))s" + +# Launch with console capture using script for PTY (devicectl buffers without TTY) +log "Launching app with console capture..." +# Use 'script -F -q' to create a pseudo-TTY with immediate flush +# -F: flush after each write (real-time output) +# -q: quiet mode (no start/stop messages) +nohup script -F -q "$LOG_FILE" xcrun devicectl device process launch --console --terminate-existing --device "$DEVICE_UUID" "$BUNDLE_ID" >/dev/null 2>&1 & +LOG_PID=$! + +# Give it time to start and verify the process is running +sleep 2 +if ! kill -0 $LOG_PID 2>/dev/null; then + warn "Console capture may have failed - script process not running" +fi + +# Note: log-trimmer disabled for devicectl --console since it captures only NSLog +# (much less verbose than idevicesyslog which captured all system logs) + +# Save debug context (multi-device aware) +mkdir -p .claude +DEBUG_FILE=".claude/debug.txt" + +# Clean stale entries (where log file doesn't exist or logging process not running) +if [ -f "$DEBUG_FILE" ]; then + TEMP_DEBUG=$(mktemp) + while IFS= read -r line; do + # Skip empty lines + [ -z "$line" ] && continue + + # Parse line type + type="${line%%:*}" + + if [ "$type" = "sim" ]; then + # Simulator format: sim:: + simid=$(echo "$line" | cut -d: -f2) + simlog=$(echo "$line" | cut -d: -f3) + # Keep if log file exists AND simctl launch is running + if [ -f "$simlog" ] && pgrep -f "simctl launch.*$simid" >/dev/null 2>&1; then + echo "$line" >> "$TEMP_DEBUG" + fi + elif [ "$type" = "device" ]; then + # Device format: device:::: + udid=$(echo "$line" | cut -d: -f5) + logfile=$(echo "$line" | cut -d: -f4) + # Skip current device (we'll re-add it) + [ "$udid" = "$DEVICE_UDID" ] && continue + # Keep if log file exists AND devicectl console is running for this device + # Extract UUID from the debug entry (field 2) + device_uuid=$(echo "$line" | cut -d: -f2) + syslog_running=false + for pid in $(pgrep -f "devicectl" 2>/dev/null); do + if ps -p "$pid" -o args= 2>/dev/null | grep -q "$device_uuid"; then + syslog_running=true + break + fi + done + if [ -f "$logfile" ] && [ "$syslog_running" = true ]; then + echo "$line" >> "$TEMP_DEBUG" + fi + fi + done < "$DEBUG_FILE" + mv "$TEMP_DEBUG" "$DEBUG_FILE" +fi + +# Add current device entry +echo "device:$DEVICE_UUID:aadi:$LOG_FILE:$DEVICE_UDID" >> "$DEBUG_FILE" + +END_TIME=$(date +%s) +TOTAL=$((END_TIME - START_TIME)) + +log "=========================================" +log "Deploy complete in ${TOTAL}s" +log "Logs: tail -f $LOG_FILE" +log "=========================================" diff --git a/.claude/skills/ios-deploy/scripts/log-trimmer.sh b/.claude/skills/ios-deploy/scripts/log-trimmer.sh new file mode 100755 index 00000000..28dbcde2 --- /dev/null +++ b/.claude/skills/ios-deploy/scripts/log-trimmer.sh @@ -0,0 +1,59 @@ +#!/bin/bash +# Trims log file to keep only the last N minutes of logs +# Usage: log-trimmer.sh [interval_seconds] [watch_pid] +# Runs continuously, trimming every interval_seconds (default: 30) +# If watch_pid is provided, exits when that process dies (e.g., idevicesyslog) + +LOG_FILE="${1:-/tmp/ingredicheck-logs.txt}" +KEEP_MINUTES="${2:-5}" +INTERVAL="${3:-30}" +WATCH_PID="${4:-}" + +trim_logs() { + [ ! -f "$LOG_FILE" ] && return + + # Get cutoff time (N minutes ago) + CUTOFF=$(date -v-${KEEP_MINUTES}M +"%b %d %H:%M:%S" 2>/dev/null || date -d "${KEEP_MINUTES} minutes ago" +"%b %d %H:%M:%S") + + # Create temp file with only recent logs + # Log format: "Jan 24 11:28:11.954 ..." + awk -v cutoff="$CUTOFF" ' + BEGIN { + split("Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec", months) + for (i in months) month_num[months[i]] = i + } + { + # Parse log timestamp: "Jan 24 11:28:11.954" + if (match($0, /^([A-Z][a-z]{2}) +([0-9]+) ([0-9]{2}:[0-9]{2}:[0-9]{2})/, m)) { + log_ts = sprintf("%02d %02d %s", month_num[m[1]], m[2], m[3]) + + # Parse cutoff timestamp + if (match(cutoff, /^([A-Z][a-z]{2}) +([0-9]+) ([0-9]{2}:[0-9]{2}:[0-9]{2})/, c)) { + cut_ts = sprintf("%02d %02d %s", month_num[c[1]], c[2], c[3]) + } + + if (log_ts >= cut_ts) print + } else { + # Keep lines without recognizable timestamp (continuation lines) + print + } + }' "$LOG_FILE" > "${LOG_FILE}.tmp" 2>/dev/null + + # Only replace if temp file was created successfully + # Use cat instead of mv to preserve the original file's inode + # (mv creates a new inode, breaking idevicesyslog's file handle) + if [ -f "${LOG_FILE}.tmp" ]; then + cat "${LOG_FILE}.tmp" > "$LOG_FILE" + rm -f "${LOG_FILE}.tmp" + fi +} + +# Run continuously (exit if watched process dies) +while true; do + # Exit if watched process died (e.g., idevicesyslog terminated) + if [ -n "$WATCH_PID" ] && ! kill -0 "$WATCH_PID" 2>/dev/null; then + exit 0 + fi + sleep "$INTERVAL" + trim_logs +done diff --git a/.claude/skills/scripts/asc-common.sh b/.claude/skills/scripts/asc-common.sh new file mode 100755 index 00000000..79a2c68a --- /dev/null +++ b/.claude/skills/scripts/asc-common.sh @@ -0,0 +1,116 @@ +#!/bin/bash +# Shared helper functions for asc-* skills +# Source this file: source "$(dirname "$0")/../scripts/asc-common.sh" + +# Colors +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +BLUE='\033[0;34m' +NC='\033[0m' + +# Logging +asc_log() { echo -e "${GREEN}[ASC]${NC} $1"; } +asc_warn() { echo -e "${YELLOW}[ASC]${NC} $1"; } +asc_error() { echo -e "${RED}[ASC]${NC} $1"; } +asc_info() { echo -e "${BLUE}[ASC]${NC} $1"; } + +# Check if asc is installed +asc_check_install() { + if ! command -v asc &>/dev/null; then + asc_error "asc CLI not installed!" + echo "" + echo "Install with:" + echo " brew tap rudrankriyam/tap" + echo " brew install rudrankriyam/tap/asc" + return 1 + fi + return 0 +} + +# Check if authenticated +asc_check_auth() { + local auth_status + auth_status=$(asc auth status 2>&1) + if echo "$auth_status" | grep -q "No credentials"; then + asc_error "Not authenticated!" + echo "" + echo "Run /asc-setup to configure authentication" + return 1 + fi + return 0 +} + +# Get app ID from config or environment +asc_get_app_id() { + # Priority: 1) Argument, 2) Config file, 3) Environment + if [ -n "$1" ]; then + echo "$1" + return 0 + fi + + local config_file=".asc/config.json" + if [ -f "$config_file" ]; then + local app_id + app_id=$(jq -r '.app_id // empty' "$config_file" 2>/dev/null) + if [ -n "$app_id" ]; then + echo "$app_id" + return 0 + fi + fi + + if [ -n "$ASC_APP_ID" ]; then + echo "$ASC_APP_ID" + return 0 + fi + + asc_error "No app ID configured!" + echo "" + echo "Run /asc-setup to discover and configure your app ID" + return 1 +} + +# Load config and set ASC_APP_ID and ASC_VENDOR_NUMBER +asc_load_config() { + local app_id + app_id=$(asc_get_app_id "$1") || return 1 + export ASC_APP_ID="$app_id" + + # Also load vendor number if available + local config_file=".asc/config.json" + if [ -f "$config_file" ]; then + local vendor_number + vendor_number=$(jq -r '.vendor_number // empty' "$config_file" 2>/dev/null) + if [ -n "$vendor_number" ]; then + export ASC_VENDOR_NUMBER="$vendor_number" + fi + fi + return 0 +} + +# Validate everything is ready +asc_validate() { + asc_check_install || return 1 + asc_check_auth || return 1 + asc_load_config "$1" || return 1 + return 0 +} + +# Format JSON output nicely for display (optional pretty print) +asc_format_json() { + if [ "$1" = "--pretty" ]; then + jq '.' + else + cat + fi +} + +# Get current profile name +asc_get_profile() { + local config_file=".asc/config.json" + if [ -f "$config_file" ]; then + jq -r '.profile // "default"' "$config_file" 2>/dev/null + else + echo "default" + fi +} diff --git a/.cursor/config.json b/.cursor/config.json new file mode 100644 index 00000000..ef66c704 --- /dev/null +++ b/.cursor/config.json @@ -0,0 +1,10 @@ +{ + "mcpServers": { + "XcodeBuildMCP": { + "command": "npx", + "args": ["-y", "xcodebuildmcp@latest"] + } + } +} + + diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..807d5983 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ + +# Use bd merge for beads JSONL files +.beads/issues.jsonl merge=beads diff --git a/.gitignore b/.gitignore index 78174ca0..d546d50e 100644 --- a/.gitignore +++ b/.gitignore @@ -93,4 +93,14 @@ DerivedData/ .env *.p8 -private_keys/ \ No newline at end of file +private_keys/ +# JetBrains IDE project files +.idea/* +!.idea/.gitignore + +# Build logs +*.log +.claude/debug.txt + +# App Store Connect CLI config +.asc/ diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 00000000..26d33521 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 00000000..89415710 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "XcodeBuildMCP": { + "command": "npx", + "args": ["-y", "xcodebuildmcp@latest"] + }, + "posthog": { + "type": "http", + "url": "https://mcp.posthog.com/mcp" + } + } +} diff --git a/AGENTS.md b/AGENTS.md index 357deb71..748092c4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,5 +28,15 @@ - PRs should summarize functional changes, list test evidence (`xcodebuild build`, `xcodebuild test`), link Supabase/issue tracker tasks, and add screenshots for UI updates. - Request at least one review, resolve Xcode warnings before merging, and ensure build settings remain in sync. +## ⚠️ CRITICAL: Config.swift Local Endpoints +**NEVER commit with `useLocalBackend = true` in `Config.swift`** + +Before committing: +1. **Check Config.swift**: Verify `useLocalBackend` is set to `false` or uses `#if DEBUG` guard +2. **Verify production URLs**: Ensure the app will connect to production endpoints +3. **Test production build**: Build with Release configuration to ensure production URLs are used + +The `useLocalBackend` flag is protected by `#if DEBUG` in code, but always double-check before committing. Local endpoints (192.168.x.x) should NEVER be in committed code. + ## Work Tracking We track work in Beads instead of Markdown. Run \`bd quickstart\` to see how. \ No newline at end of file diff --git a/AVATAR_SELECTION_COMPARISON.md b/AVATAR_SELECTION_COMPARISON.md new file mode 100644 index 00000000..0ec9102b --- /dev/null +++ b/AVATAR_SELECTION_COMPARISON.md @@ -0,0 +1,149 @@ +# Avatar Selection Implementation Comparison + +## Overview +Three views share similar UI for avatar selection but have different implementation approaches: +- **AddMoreMembers.swift** - Adding new family members +- **WhatsYourName.swift** - Creating self member during onboarding +- **EditMember.swift** - Editing existing members + +## Key Differences + +### 1. **Avatar Selection Logic & Data Flow** + +#### AddMoreMembers.swift +- **Approach**: Uses callback pattern with `continuePressed: (String, UIImage?, String?, String?) async throws -> Void` +- **Flow**: Collects all data (name, uploadImage, storagePath, colorHex) β†’ Passes to callback β†’ Callback handles `addMemberImmediate` +- **Custom Memoji Check**: Checks `memojiStore.image` (UIImage) for custom generated memojis +- **Code Pattern**: +```swift +var uploadImage: UIImage? = nil +var storagePath: String? = nil +var colorHex: String? = nil + +// Collect data... +try await continuePressed(trimmed, uploadImage, storagePath, colorHex) +``` + +#### WhatsYourName.swift +- **Approach**: Directly calls FamilyStore methods +- **Flow**: Directly calls `setPendingSelfMemberAvatarFromMemoji` or `setPendingSelfMemberAvatar` +- **Custom Memoji Check**: Checks `memojiStore.imageStoragePath` (String) for custom generated memojis +- **Code Pattern**: +```swift +if selectedImageName.hasPrefix("memoji_") { + await familyStore.setPendingSelfMemberAvatarFromMemoji( + storagePath: selectedImageName, + backgroundColorHex: colorHex + ) +} else if let storagePath = memojiStore.imageStoragePath, !storagePath.isEmpty { + await familyStore.setPendingSelfMemberAvatarFromMemoji(...) +} +``` + +#### EditMember.swift +- **Approach**: Directly calls FamilyStore methods (handles both self and other members) +- **Flow**: Directly calls `setPendingSelfMemberAvatarFromMemoji` or `setAvatarForPendingOtherMemberFromMemoji` +- **Custom Memoji Check**: Checks `memojiStore.imageStoragePath` (String) for custom generated memojis +- **Code Pattern**: +```swift +if isSelf { + // Handle self member + await familyStore.setPendingSelfMemberAvatarFromMemoji(...) +} else { + // Handle other member + await familyStore.setAvatarForPendingOtherMemberFromMemoji(...) +} +``` + +### 2. **Custom Memoji Detection** + +| View | Custom Memoji Check | +|------|---------------------| +| **AddMoreMembers** | `memojiStore.image` (UIImage) | +| **WhatsYourName** | `memojiStore.imageStoragePath` (String) | +| **EditMember** | `memojiStore.imageStoragePath` (String) | + +**Issue**: AddMoreMembers checks for `UIImage` while others check for `String` path. This inconsistency could cause issues. + +### 3. **Name Validation** + +| View | Validation | +|------|------------| +| **AddMoreMembers** | βœ… Full validation: letters only, 25 char limit, 3 words max | +| **WhatsYourName** | βœ… Full validation: letters only, 25 char limit, 3 words max | +| **EditMember** | ❌ Basic validation: only checks if empty | + +**Issue**: EditMember doesn't filter input or enforce limits. + +### 4. **Plus Button Navigation** + +| View | previousRouteForGenerateAvatar | +|------|-------------------------------| +| **AddMoreMembers** | Sets to `.addMoreMembers` | +| **WhatsYourName** | ❌ Doesn't set (defaults to onboarding) | +| **EditMember** | Sets to `.editMember(memberId, isSelf)` or `.addMoreMembersMinimal` | + +**Issue**: WhatsYourName doesn't set the route, which might cause navigation issues. + +### 5. **State Management** + +| View | State Reset/Cleanup | +|------|-------------------| +| **AddMoreMembers** | βœ… Has `resetMemojiSelectionState()` called in `onAppear` | +| **WhatsYourName** | ❌ No state reset | +| **EditMember** | βœ… Seeds initial values from store in `onAppear` | + +### 6. **Error Handling** + +| View | Error Handling | +|------|---------------| +| **AddMoreMembers** | βœ… Try-catch around `continuePressed` callback | +| **WhatsYourName** | βœ… Try-catch around `continuePressed` callback | +| **EditMember** | ❌ No error handling (synchronous `handleSave`) | + +### 7. **Button Text & Actions** + +| View | Button Text | Action | +|------|------------|--------| +| **AddMoreMembers** | "Add Member" | Calls `handleAddMember` | +| **WhatsYourName** | "Continue" | Calls `handleContinue` | +| **EditMember** | "Save" | Calls `handleSave` (synchronous) | + +## Recommendations for Consistency + +### 1. **Unify Custom Memoji Detection** +All views should check `memojiStore.imageStoragePath` (String) instead of `memojiStore.image` (UIImage): +- Update AddMoreMembers to check `imageStoragePath` like the others + +### 2. **Add Name Validation to EditMember** +EditMember should have the same validation as AddMoreMembers and WhatsYourName: +- Filter to letters and spaces only +- Limit to 25 characters +- Limit to 3 words max + +### 3. **Set Navigation Route in WhatsYourName** +WhatsYourName should set `memojiStore.previousRouteForGenerateAvatar` when navigating to GenerateAvatar: +```swift +memojiStore.previousRouteForGenerateAvatar = .whatsYourName +``` + +### 4. **Add Error Handling to EditMember** +EditMember's `handleSave` should handle errors properly, especially for async operations. + +### 5. **Consider Extracting Common Logic** +The avatar selection UI and logic could be extracted into a reusable component to reduce duplication. + +## Current Implementation Status + +βœ… **Consistent**: +- Avatar list (memoji_1 through memoji_14) +- UI layout (fixed plus button + divider + scrollable memojis) +- Local memoji detection (`hasPrefix("memoji_")`) +- Color extraction using `toHex()` + +❌ **Inconsistent**: +- Custom memoji detection (UIImage vs String) +- Name validation (full vs basic) +- Navigation route setting +- Error handling +- State management diff --git a/CREATE_FAMILY_IMPLEMENTATION.md b/CREATE_FAMILY_IMPLEMENTATION.md new file mode 100644 index 00000000..ea89accf --- /dev/null +++ b/CREATE_FAMILY_IMPLEMENTATION.md @@ -0,0 +1,136 @@ +# Create Family from Settings - Implementation Summary + +## Overview +Implemented a feature that allows users without a family (or "Just Me" users) to create a family from the Settings screen. When they tap "Create Family", they go through the family onboarding flow and return to the Settings screen after completion. + +## Changes Made + +### 1. **AppNavigationCoordinator.swift** +- **Added**: `isCreatingFamilyFromSettings` flag to track if the family creation flow was initiated from Settings +- **Purpose**: This flag helps the app know where to return the user after completing the family creation flow + +```swift +// Track if family creation was initiated from Settings +var isCreatingFamilyFromSettings: Bool = false +``` + +### 2. **SettingsSheet.swift** +- **Modified**: The Create Family / Manage Family button logic +- **Key Logic**: Checks if family exists **AND** has other members + - `if let family = familyStore.family, !family.otherMembers.isEmpty` + - This ensures "Just Me" users (who have a family but no other members) see "Create Family" +- **Behavior**: + - When family has other members β†’ Shows "**Manage Family**" and navigates to `ManageFamilyView` + - When no family OR "Just Me" family β†’ Shows "**Create Family**" and: + 1. Sets `coordinator.isCreatingFamilyFromSettings = true` + 2. Navigates to `.letsMeetYourIngrediFam` canvas + 3. Dismisses the Settings sheet + +```swift +if let family = familyStore.family, !family.otherMembers.isEmpty { + // Family with other members -> Manage Family + NavigationLink { ManageFamilyView() } label: { ... } +} else { + // No family OR "Just Me" -> Create Family + Button { + coordinator.isCreatingFamilyFromSettings = true + coordinator.showCanvas(.letsMeetYourIngrediFam) + dismiss() + } label: { ... } +} +``` + +### 3. **PersistentBottomSheet.swift** +- **Added**: `AppState` environment to access the `activeSheet` property +- **Modified**: The `.meetYourProfile` case completion handler +- **Behavior**: Checks the `isCreatingFamilyFromSettings` flag and: + - If `true`: + 1. Resets the flag + 2. Navigates to `.home` + 3. Waits 0.3 seconds for home to load + 4. Automatically reopens the Settings sheet (`appState.activeSheet = .settings`) + - If `false`: Normal flow - navigates to `.home` + +```swift +case .meetYourProfile: + MeetYourProfileView { + if coordinator.isCreatingFamilyFromSettings { + coordinator.isCreatingFamilyFromSettings = false + coordinator.showCanvas(.home) + Task { @MainActor in + try? await Task.sleep(nanoseconds: 300_000_000) + appState.activeSheet = .settings + } + } else { + coordinator.showCanvas(.home) + } + } +``` + +## User Flow + +### "Just Me" Users (Family with no other members): +1. User opens Settings +2. Sees "**Create Family**" button (even though they have a family, it's just them) +3. Taps "Create Family" +4. Goes through family creation flow +5. Settings automatically reopens showing "**Manage Family**" ✨ + +### Users with No Family: +1. User opens Settings +2. Sees "**Create Family**" button +3. Taps "Create Family" +4. Goes to "Let's meet your IngrediFam!" screen +5. Sees "Your Family Overview" with their profile +6. Adds family members +7. Goes through dietary preferences +8. Completes onboarding questions +9. Sees AI chat and summary +10. Returns to Home screen briefly +11. **Settings automatically reopens** showing "Manage Family" instead of "Create Family" ✨ + +### Users with Family (has other members): +1. User opens Settings +2. Sees "**Manage Family**" button +3. Taps it β†’ Goes to ManageFamilyView (unchanged) + +## Technical Details + +### Why Check `otherMembers.isEmpty`? +- When a user chooses "Just Me" during onboarding, the backend creates a family with only one member (themselves) +- `familyStore.family != nil` would be `true` for these users +- But they should still see "Create Family" to add more members +- Solution: Check `!family.otherMembers.isEmpty` to distinguish between: + - **Just Me users**: `family.otherMembers.isEmpty == true` β†’ Show "Create Family" + - **Family users**: `family.otherMembers.isEmpty == false` β†’ Show "Manage Family" + +### Why This Approach? +- **Minimal Changes**: Reuses existing onboarding flow components +- **Clean Separation**: Uses a flag to track the source without modifying the entire flow +- **Maintainable**: Easy to understand and modify in the future +- **Handles Edge Cases**: Properly distinguishes between "Just Me" and actual families + +### Key Components Used +- **LetsMeetYourIngrediFamView**: Shows "Your Family Overview" with the user's profile +- **MeetYourIngrediFam**: Shows "Let's meet your IngrediFam!" intro screen +- **WhatsYourName**: Allows user to enter their name +- **AddMoreMembers**: Allows adding family members +- **DietaryPreferencesSheet**: Dietary preferences selection +- **MainCanvasView**: Dynamic onboarding questions +- **IngrediBotView**: AI chat and summary +- **MeetYourProfileView**: Final profile review before completion + +## Testing Checklist +- [ ] "Just Me" user sees "Create Family" β†’ Goes to family creation flow +- [ ] User with no family sees "Create Family" β†’ Goes to family creation flow +- [ ] User completes family creation β†’ Settings automatically reopens +- [ ] Settings shows "Manage Family" after adding members +- [ ] User with existing family (other members) sees "Manage Family" β†’ Goes to ManageFamilyView +- [ ] Flag is properly reset after completion +- [ ] Normal onboarding flow (not from Settings) still works correctly + +## Notes +- The implementation leverages the existing family onboarding flow +- All UI components were already implemented - just needed proper navigation wiring +- The flag approach ensures clean separation between Settings-initiated and normal onboarding flows +- **Critical Fix**: Checking `otherMembers.isEmpty` ensures "Just Me" users can create a family diff --git a/IngrediCheck.xcodeproj/project.pbxproj b/IngrediCheck.xcodeproj/project.pbxproj index 6a6532e9..e617579e 100644 --- a/IngrediCheck.xcodeproj/project.pbxproj +++ b/IngrediCheck.xcodeproj/project.pbxproj @@ -3,12 +3,16 @@ archiveVersion = 1; classes = { }; - objectVersion = 60; + objectVersion = 70; objects = { /* Begin PBXBuildFile section */ 0C51E2EE2E335B7300A3E3A9 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C51E2ED2E335B7300A3E3A9 /* Constants.swift */; }; 0C51E2F12E335C1E00A3E3A9 /* PostHog in Frameworks */ = {isa = PBXBuildFile; productRef = 0C51E2F02E335C1E00A3E3A9 /* PostHog */; }; + 0CE0D52C2F03A1390050C904 /* DotLottie in Frameworks */ = {isa = PBXBuildFile; productRef = 0CE0D52B2F03A1390050C904 /* DotLottie */; }; + 0CF25DF12EDD9C9500C582FA /* DynamicOnboardingViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF25DF02EDD9C9500C582FA /* DynamicOnboardingViews.swift */; }; + 0CF25DF22EDD9C9600C582FA /* MeetYourProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF25DF32EDD9C9600C582FA /* MeetYourProfileView.swift */; }; + 0CF25DF52EDD9ECA00C582FA /* DynamicSteps.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF25DF42EDD9ECA00C582FA /* DynamicSteps.swift */; }; 110D4DFFDD1244FC86E17F05 /* AnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A56B13C32B7A4D9DA858F158 /* AnalyticsService.swift */; }; 1F59877A2E30C51E00381E32 /* TipJarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F5987792E30C51E00381E32 /* TipJarView.swift */; }; 1F5987B42E30E56500381E32 /* TipJarConnectFile.storekit in Resources */ = {isa = PBXBuildFile; fileRef = 1F5987B32E30E56500381E32 /* TipJarConnectFile.storekit */; }; @@ -53,20 +57,71 @@ 397DA1D92B7C153100BFB26F /* FeedbackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 397DA1D82B7C153100BFB26F /* FeedbackView.swift */; }; 39F2A87E2A9D3972004EBB9A /* IngrediCheckApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39F2A87D2A9D3972004EBB9A /* IngrediCheckApp.swift */; }; 39F2A8822A9D3974004EBB9A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 39F2A8812A9D3974004EBB9A /* Assets.xcassets */; }; + 635E2CF6A2C048BD97E90FAA /* RemoteOnboardingMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E50DF6D250314A65AF243C50 /* RemoteOnboardingMetadata.swift */; }; + AA0000012F4F000000000001 /* TutorialVideoManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000012F4F000000000002 /* TutorialVideoManager.swift */; }; + AD49592740904EC2B52AE490 /* AppRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CFADBFEDBD6464085931D95 /* AppRoute.swift */; }; + D35AB5C62F2A17C2002FB36A /* RiveRuntime in Frameworks */ = {isa = PBXBuildFile; productRef = D35AB5C52F2A17C2002FB36A /* RiveRuntime */; }; D35BCF262E27D5B600125580 /* Auth in Frameworks */ = {isa = PBXBuildFile; productRef = D35BCF252E27D5B600125580 /* Auth */; }; D35BCF282E27D5B600125580 /* Storage in Frameworks */ = {isa = PBXBuildFile; productRef = D35BCF272E27D5B600125580 /* Storage */; }; D35BCF2A2E27D5B600125580 /* Supabase in Frameworks */ = {isa = PBXBuildFile; productRef = D35BCF292E27D5B600125580 /* Supabase */; }; D35F3C242E2A25FB0002CDE7 /* GoogleSignIn in Frameworks */ = {isa = PBXBuildFile; productRef = D35F3C232E2A25FB0002CDE7 /* GoogleSignIn */; }; D35F3C262E2A25FB0002CDE7 /* GoogleSignInSwift in Frameworks */ = {isa = PBXBuildFile; productRef = D35F3C252E2A25FB0002CDE7 /* GoogleSignInSwift */; }; - D35FB7F22E56E85C009A5AA1 /* Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = D35FB7F12E56E859009A5AA1 /* Config.swift */; }; + D382E0602EDEE4B600F139B2 /* FamilyModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = D382E05D2EDEE4B600F139B2 /* FamilyModels.swift */; }; + D382E0612EDEE4B600F139B2 /* FamilyService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D382E05E2EDEE4B600F139B2 /* FamilyService.swift */; }; + D382E0622EDEE4B600F139B2 /* FamilyStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D382E05F2EDEE4B600F139B2 /* FamilyStore.swift */; }; + D382E0642EDEE4B600F139B2 /* FoodNotesStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D382E0632EDEE4B600F139B2 /* FoodNotesStore.swift */; }; + D3BECC7D2ED98D320088E044 /* WelcomeToYourFamilyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3BECC7C2ED98D320088E044 /* WelcomeToYourFamilyView.swift */; }; + D3BECC7E2ED98D320088E044 /* HeyThereScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3BECC782ED98D320088E044 /* HeyThereScreen.swift */; }; + D3BECC7F2ED98D320088E044 /* DietaryPreferencesAndRestrictions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3BECC772ED98D320088E044 /* DietaryPreferencesAndRestrictions.swift */; }; + D3BECC802ED98D320088E044 /* AppFlowRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3BECC752ED98D320088E044 /* AppFlowRouter.swift */; }; + D3BECC812ED98D320088E044 /* BlankScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3BECC762ED98D320088E044 /* BlankScreen.swift */; }; + D3BECC822ED98D320088E044 /* RootContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3BECC7B2ED98D320088E044 /* RootContainerView.swift */; }; + D3BECC832ED98D320088E044 /* LetsMeetYourIngrediFamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3BECC7A2ED98D320088E044 /* LetsMeetYourIngrediFamView.swift */; }; + D3BECC842ED98D320088E044 /* LetsGetStartedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3BECC792ED98D320088E044 /* LetsGetStartedView.swift */; }; + D3BECC8B2ED98E860088E044 /* TempStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3BECC892ED98E860088E044 /* TempStore.swift */; }; + D3BECC8C2ED98E860088E044 /* Onboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3BECC882ED98E860088E044 /* Onboarding.swift */; }; + D3BECC8D2ED98E860088E044 /* AppNavigationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3BECC872ED98E860088E044 /* AppNavigationCoordinator.swift */; }; + D3BECC8E2ED98E860088E044 /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3BECC8A2ED98E860088E044 /* User.swift */; }; + D3BECC992ED98EA10088E044 /* NunitoFontEnum.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3BECC952ED98EA10088E044 /* NunitoFontEnum.swift */; }; + D3BECC9A2ED98EA10088E044 /* ManropeFontEnum.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3BECC942ED98EA10088E044 /* ManropeFontEnum.swift */; }; + D3BECC9B2ED98EA10088E044 /* BottomSheetRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3BECC8F2ED98EA10088E044 /* BottomSheetRoute.swift */; }; + D3BECC9C2ED98EA10088E044 /* CanvasRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3BECC902ED98EA10088E044 /* CanvasRoute.swift */; }; + D3BECC9D2ED98EA10088E044 /* FlowLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3BECC922ED98EA10088E044 /* FlowLayout.swift */; }; + D3BECC9E2ED98EA10088E044 /* Temp2.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3BECC962ED98EA10088E044 /* Temp2.swift */; }; + D3BECC9F2ED98EA10088E044 /* Temp3.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3BECC972ED98EA10088E044 /* Temp3.swift */; }; + D3BECCA02ED98EA10088E044 /* Temp4.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3BECC982ED98EA10088E044 /* Temp4.swift */; }; + D3BECCA12ED98EA10088E044 /* CustomSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3BECC912ED98EA10088E044 /* CustomSheet.swift */; }; + D3BECCA22ED98EA10088E044 /* HexColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3BECC932ED98EA10088E044 /* HexColorExtension.swift */; }; + D3BECCA32ED98EA20088E044 /* UIImageExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3BECCA42ED98EA20088E044 /* UIImageExtensions.swift */; }; + D3BECCA52ED98EA30088E044 /* ChatContextBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3BECCA42ED98EA30088E044 /* ChatContextBuilder.swift */; }; + D3BECF6E2ED9970A0088E044 /* MainCanvasView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3BECF632ED9970A0088E044 /* MainCanvasView.swift */; }; + D3BED0622ED997490088E044 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3BED0612ED997490088E044 /* HomeView.swift */; }; + D3BED0642ED997490088E044 /* EditableCanvasView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3BED0632ED997490088E044 /* EditableCanvasView.swift */; }; + D3BED0662ED9974A00088E044 /* RecentScansFullView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3BED0652ED9974A00088E044 /* RecentScansFullView.swift */; }; + D3CE197A2F20EF400097B28D /* ViewExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3CE19792F20EF400097B28D /* ViewExtensions.swift */; }; + D3CHAT002000000000000000 /* ChatStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3CHAT001000000000000000 /* ChatStore.swift */; }; + D3F0E0032F0A1B2C3D4E5F60 /* MemojiModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3F0E0012F0A1B2C3D4E5F60 /* MemojiModels.swift */; }; + D3F0E0042F0A1B2C3D4E5F60 /* MemojiStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3F0E0022F0A1B2C3D4E5F60 /* MemojiStore.swift */; }; + D3F0E0062F0A1B2C3D4E5F60 /* AIMemojiService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3F0E0052F0A1B2C3D4E5F60 /* AIMemojiService.swift */; }; + D3F6A9652F0D33A00019F5C4 /* ScanHistoryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3F6A9642F0D33A00019F5C4 /* ScanHistoryStore.swift */; }; + D3F6ACC82F10E3490019F5C4 /* OnboardingPersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3F6ACC72F10E3490019F5C4 /* OnboardingPersistence.swift */; }; + D3F6ACCC2F1102EC0019F5C4 /* ToastManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3F6ACCB2F1102EC0019F5C4 /* ToastManager.swift */; }; + D3F946B6F45C9CE7AC0B5E2C /* ShimmerModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA6E6DC0F6EC4EA734D9E172 /* ShimmerModifier.swift */; }; + D3FFA0B02F0B12340088E044 /* Family/ManageFamilyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3FFA0AF2F0B12340088E044 /* Family/ManageFamilyView.swift */; }; + FA2A590D2F14B99000C57D89 /* Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA2A590C2F14B99000C57D89 /* Config.swift */; }; + FA2CB6442F078CB300E65B90 /* dynamicJsonData.json in Resources */ = {isa = PBXBuildFile; fileRef = FA2CB6432F078C7400E65B90 /* dynamicJsonData.json */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ 0C51E2ED2E335B7300A3E3A9 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; + 0CF25DF02EDD9C9500C582FA /* DynamicOnboardingViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicOnboardingViews.swift; sourceTree = ""; }; + 0CF25DF32EDD9C9600C582FA /* MeetYourProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetYourProfileView.swift; sourceTree = ""; }; + 0CF25DF42EDD9ECA00C582FA /* DynamicSteps.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicSteps.swift; sourceTree = ""; }; 1F5987792E30C51E00381E32 /* TipJarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TipJarView.swift; sourceTree = ""; }; 1F5987B32E30E56500381E32 /* TipJarConnectFile.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = TipJarConnectFile.storekit; sourceTree = ""; }; 1F5987B52E30E6DA00381E32 /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; }; 1F5987B72E30E74D00381E32 /* TipJarViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TipJarViewModel.swift; sourceTree = ""; }; + 2CFADBFEDBD6464085931D95 /* AppRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRoute.swift; sourceTree = ""; }; 390F0CA92BABA45F009081AD /* Fancy3DotsIndexView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Fancy3DotsIndexView.swift; sourceTree = ""; }; 390F0CAE2BACC50D009081AD /* ProductImagesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductImagesView.swift; sourceTree = ""; }; 390F0D432BAE0D44009081AD /* View+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Extensions.swift"; sourceTree = ""; }; @@ -107,19 +162,82 @@ 39F2A87D2A9D3972004EBB9A /* IngrediCheckApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IngrediCheckApp.swift; sourceTree = ""; }; 39F2A8812A9D3974004EBB9A /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; A56B13C32B7A4D9DA858F158 /* AnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService.swift; sourceTree = ""; }; + AA0000012F4F000000000002 /* TutorialVideoManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialVideoManager.swift; sourceTree = ""; }; D333A2702E3379F400E31F66 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; - D35FB7F12E56E859009A5AA1 /* Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Config.swift; sourceTree = ""; }; + D382E05D2EDEE4B600F139B2 /* FamilyModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FamilyModels.swift; sourceTree = ""; }; + D382E05E2EDEE4B600F139B2 /* FamilyService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FamilyService.swift; sourceTree = ""; }; + D382E05F2EDEE4B600F139B2 /* FamilyStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FamilyStore.swift; sourceTree = ""; }; + D382E0632EDEE4B600F139B2 /* FoodNotesStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoodNotesStore.swift; sourceTree = ""; }; + D3BECC752ED98D320088E044 /* AppFlowRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppFlowRouter.swift; sourceTree = ""; }; + D3BECC762ED98D320088E044 /* BlankScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlankScreen.swift; sourceTree = ""; }; + D3BECC772ED98D320088E044 /* DietaryPreferencesAndRestrictions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DietaryPreferencesAndRestrictions.swift; sourceTree = ""; }; + D3BECC782ED98D320088E044 /* HeyThereScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeyThereScreen.swift; sourceTree = ""; }; + D3BECC792ED98D320088E044 /* LetsGetStartedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LetsGetStartedView.swift; sourceTree = ""; }; + D3BECC7A2ED98D320088E044 /* LetsMeetYourIngrediFamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LetsMeetYourIngrediFamView.swift; sourceTree = ""; }; + D3BECC7B2ED98D320088E044 /* RootContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootContainerView.swift; sourceTree = ""; }; + D3BECC7C2ED98D320088E044 /* WelcomeToYourFamilyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeToYourFamilyView.swift; sourceTree = ""; }; + D3BECC872ED98E860088E044 /* AppNavigationCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppNavigationCoordinator.swift; sourceTree = ""; }; + D3BECC882ED98E860088E044 /* Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Onboarding.swift; sourceTree = ""; }; + D3BECC892ED98E860088E044 /* TempStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempStore.swift; sourceTree = ""; }; + D3BECC8A2ED98E860088E044 /* User.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = User.swift; sourceTree = ""; }; + D3BECC8F2ED98EA10088E044 /* BottomSheetRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomSheetRoute.swift; sourceTree = ""; }; + D3BECC902ED98EA10088E044 /* CanvasRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CanvasRoute.swift; sourceTree = ""; }; + D3BECC912ED98EA10088E044 /* CustomSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomSheet.swift; sourceTree = ""; }; + D3BECC922ED98EA10088E044 /* FlowLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowLayout.swift; sourceTree = ""; }; + D3BECC932ED98EA10088E044 /* HexColorExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HexColorExtension.swift; sourceTree = ""; }; + D3BECC942ED98EA10088E044 /* ManropeFontEnum.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManropeFontEnum.swift; sourceTree = ""; }; + D3BECC952ED98EA10088E044 /* NunitoFontEnum.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NunitoFontEnum.swift; sourceTree = ""; }; + D3BECC962ED98EA10088E044 /* Temp2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Temp2.swift; sourceTree = ""; }; + D3BECC972ED98EA10088E044 /* Temp3.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Temp3.swift; sourceTree = ""; }; + D3BECC982ED98EA10088E044 /* Temp4.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Temp4.swift; sourceTree = ""; }; + D3BECCA42ED98EA20088E044 /* UIImageExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIImageExtensions.swift; sourceTree = ""; }; + D3BECCA42ED98EA30088E044 /* ChatContextBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatContextBuilder.swift; sourceTree = ""; }; + D3BECF632ED9970A0088E044 /* MainCanvasView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainCanvasView.swift; sourceTree = ""; }; + D3BED0612ED997490088E044 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; + D3BED0632ED997490088E044 /* EditableCanvasView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditableCanvasView.swift; sourceTree = ""; }; + D3BED0652ED9974A00088E044 /* RecentScansFullView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentScansFullView.swift; sourceTree = ""; }; + D3CE19792F20EF400097B28D /* ViewExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewExtensions.swift; sourceTree = ""; }; + D3CHAT001000000000000000 /* ChatStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatStore.swift; sourceTree = ""; }; + D3F0E0012F0A1B2C3D4E5F60 /* MemojiModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemojiModels.swift; sourceTree = ""; }; + D3F0E0022F0A1B2C3D4E5F60 /* MemojiStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemojiStore.swift; sourceTree = ""; }; + D3F0E0052F0A1B2C3D4E5F60 /* AIMemojiService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIMemojiService.swift; sourceTree = ""; }; + D3F6A9642F0D33A00019F5C4 /* ScanHistoryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanHistoryStore.swift; sourceTree = ""; }; + D3F6ACC72F10E3490019F5C4 /* OnboardingPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingPersistence.swift; sourceTree = ""; }; + D3F6ACCB2F1102EC0019F5C4 /* ToastManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastManager.swift; sourceTree = ""; }; + D3FFA0AF2F0B12340088E044 /* Family/ManageFamilyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Family/ManageFamilyView.swift; sourceTree = ""; }; + E50DF6D250314A65AF243C50 /* RemoteOnboardingMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteOnboardingMetadata.swift; sourceTree = ""; }; + EA6E6DC0F6EC4EA734D9E172 /* ShimmerModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShimmerModifier.swift; sourceTree = ""; }; + FA2A590C2F14B99000C57D89 /* Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Config.swift; sourceTree = ""; }; + FA2CB6432F078C7400E65B90 /* dynamicJsonData.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = dynamicJsonData.json; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 0C3A858A2F04EDB20018E877 /* Sheets */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Sheets; sourceTree = ""; }; + 0CBB73342F03B575003ED0F5 /* Resources */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Resources; sourceTree = ""; }; + D33AC8782F1E3E6300EAD08F /* Canvas */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Canvas; sourceTree = ""; }; + D3BECBA72ED9898E0088E044 /* Components */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Components; sourceTree = ""; }; + D3BECBCA2ED989B70088E044 /* backend */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = backend; sourceTree = ""; }; + D3BECBE72ED989D40088E044 /* Font Family */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = "Font Family"; sourceTree = ""; }; + D3BECC0E2ED98A3E0088E044 /* AI Summary */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = "AI Summary"; sourceTree = ""; }; + D3BECC142ED98A3E0088E044 /* BarcodeScan */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = BarcodeScan; sourceTree = ""; }; + D3BECC172ED98A3E0088E044 /* ChatBot */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = ChatBot; sourceTree = ""; }; + D3BECC372ED98A3E0088E044 /* ProductDetails */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = ProductDetails; sourceTree = ""; }; + D3BECC732ED98CFF0088E044 /* Splash Screen */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = "Splash Screen"; sourceTree = ""; }; + D3BECC742ED98CFF0088E044 /* Add Family Members */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = "Add Family Members"; sourceTree = ""; }; + FAA88CBC2EF6B62300449EDF /* Resource */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Resource; sourceTree = ""; }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ 39F2A8772A9D3972004EBB9A /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 0CE0D52C2F03A1390050C904 /* DotLottie in Frameworks */, D35F3C262E2A25FB0002CDE7 /* GoogleSignInSwift in Frameworks */, D35BCF2A2E27D5B600125580 /* Supabase in Frameworks */, D35F3C242E2A25FB0002CDE7 /* GoogleSignIn in Frameworks */, D35BCF282E27D5B600125580 /* Storage in Frameworks */, + D35AB5C62F2A17C2002FB36A /* RiveRuntime in Frameworks */, 390F0CA42BA73ED7009081AD /* SwiftUIFlowLayout in Frameworks */, 0C51E2F12E335C1E00A3E3A9 /* PostHog in Frameworks */, 390F0CAD2BACBF14009081AD /* SimpleToast in Frameworks */, @@ -144,7 +262,23 @@ 1FF63CC92E32020C005BEDF1 /* Utilities */ = { isa = PBXGroup; children = ( + D3CE19792F20EF400097B28D /* ViewExtensions.swift */, + D3F6ACCB2F1102EC0019F5C4 /* ToastManager.swift */, + D3F6ACC72F10E3490019F5C4 /* OnboardingPersistence.swift */, + D3BECC8F2ED98EA10088E044 /* BottomSheetRoute.swift */, + D3BECC902ED98EA10088E044 /* CanvasRoute.swift */, + D3BECCA42ED98EA30088E044 /* ChatContextBuilder.swift */, + D3BECC912ED98EA10088E044 /* CustomSheet.swift */, + D3BECC922ED98EA10088E044 /* FlowLayout.swift */, + D3BECC932ED98EA10088E044 /* HexColorExtension.swift */, + D3BECCA42ED98EA20088E044 /* UIImageExtensions.swift */, + D3BECC942ED98EA10088E044 /* ManropeFontEnum.swift */, + D3BECC952ED98EA10088E044 /* NunitoFontEnum.swift */, + D3BECC962ED98EA10088E044 /* Temp2.swift */, + D3BECC972ED98EA10088E044 /* Temp3.swift */, + D3BECC982ED98EA10088E044 /* Temp4.swift */, 0C51E2ED2E335B7300A3E3A9 /* Constants.swift */, + EA6E6DC0F6EC4EA734D9E172 /* ShimmerModifier.swift */, ); path = Utilities; sourceTree = ""; @@ -164,14 +298,31 @@ 392D9C042B72D56700166CF1 /* Store */ = { isa = PBXGroup; children = ( + D3F6A9642F0D33A00019F5C4 /* ScanHistoryStore.swift */, + D382E05D2EDEE4B600F139B2 /* FamilyModels.swift */, + D382E05E2EDEE4B600F139B2 /* FamilyService.swift */, + D382E05F2EDEE4B600F139B2 /* FamilyStore.swift */, + D382E0632EDEE4B600F139B2 /* FoodNotesStore.swift */, + D3CHAT001000000000000000 /* ChatStore.swift */, + D3BECC872ED98E860088E044 /* AppNavigationCoordinator.swift */, + D3BECC882ED98E860088E044 /* Onboarding.swift */, + D3BECC892ED98E860088E044 /* TempStore.swift */, + E50DF6D250314A65AF243C50 /* RemoteOnboardingMetadata.swift */, + D3BECC8A2ED98E860088E044 /* User.swift */, 397C85F92B6B538500FB9DAD /* AuthController.swift */, A56B13C32B7A4D9DA858F158 /* AnalyticsService.swift */, 392D9C052B72D58400166CF1 /* UserPreferences.swift */, 397DA1D62B7A9BB000BFB26F /* PreferenceExamples.swift */, 393461CD2B93777200695FE5 /* FileCache.swift */, + AA0000012F4F000000000002 /* TutorialVideoManager.swift */, 3976C4222BC32ED50026991A /* DietaryPreferences.swift */, 39705AE92BD47DEB000E487D /* OnboardingState.swift */, 39705AF22BD49794000E487D /* NetworkState.swift */, + FA2CB6432F078C7400E65B90 /* dynamicJsonData.json */, + 0CF25DF42EDD9ECA00C582FA /* DynamicSteps.swift */, + D3F0E0012F0A1B2C3D4E5F60 /* MemojiModels.swift */, + D3F0E0022F0A1B2C3D4E5F60 /* MemojiStore.swift */, + D3F0E0052F0A1B2C3D4E5F60 /* AIMemojiService.swift */, ); path = Store; sourceTree = ""; @@ -195,6 +346,9 @@ 39705AED2BD490BC000E487D /* Onboarding */ = { isa = PBXGroup; children = ( + D3BECF632ED9970A0088E044 /* MainCanvasView.swift */, + 0CF25DF02EDD9C9500C582FA /* DynamicOnboardingViews.swift */, + 0CF25DF32EDD9C9600C582FA /* MeetYourProfileView.swift */, 39705AEB2BD48E2C000E487D /* UseCasesView.swift */, 39705AEE2BD490D0000E487D /* DisclaimerView.swift */, 39705AF02BD490DA000E487D /* SignInView.swift */, @@ -205,17 +359,37 @@ 397C86022B6C16F500FB9DAD /* Views */ = { isa = PBXGroup; children = ( + D33AC8782F1E3E6300EAD08F /* Canvas */, + 0C3A858A2F04EDB20018E877 /* Sheets */, + D3BED0612ED997490088E044 /* HomeView.swift */, + D3BED0632ED997490088E044 /* EditableCanvasView.swift */, + D3BED0652ED9974A00088E044 /* RecentScansFullView.swift */, + D3BECC752ED98D320088E044 /* AppFlowRouter.swift */, + D3BECC762ED98D320088E044 /* BlankScreen.swift */, + D3BECC772ED98D320088E044 /* DietaryPreferencesAndRestrictions.swift */, + D3BECC782ED98D320088E044 /* HeyThereScreen.swift */, + D3BECC792ED98D320088E044 /* LetsGetStartedView.swift */, + D3BECC7A2ED98D320088E044 /* LetsMeetYourIngrediFamView.swift */, + D3BECC7B2ED98D320088E044 /* RootContainerView.swift */, + D3BECC7C2ED98D320088E044 /* WelcomeToYourFamilyView.swift */, + D3BECC732ED98CFF0088E044 /* Splash Screen */, + D3BECC742ED98CFF0088E044 /* Add Family Members */, + D3BECC0E2ED98A3E0088E044 /* AI Summary */, + D3BECC142ED98A3E0088E044 /* BarcodeScan */, + D3BECC172ED98A3E0088E044 /* ChatBot */, + D3BECC372ED98A3E0088E044 /* ProductDetails */, 1F5987782E30C4F200381E32 /* TipJar */, 39705AED2BD490BC000E487D /* Onboarding */, 392D9C0A2B754FDA00166CF1 /* Lib */, 392D9BF32B72CB0100166CF1 /* Tabs */, 397C85DA2B686DF000FB9DAD /* CaptureView.swift */, 397C85D82B6858AC00FB9DAD /* ImageCaptureView.swift */, - 397C85DC2B687FF700FB9DAD /* BarcodeScannerView.swift */, 397C86052B6C22B900FB9DAD /* BarcodeAnalysisView.swift */, + 397C85DC2B687FF700FB9DAD /* BarcodeScannerView.swift */, 3946B4462B6F423B00D8B7C9 /* LabelAnalysisView.swift */, 392D9C0D2B755AE900166CF1 /* AnalysisResultView.swift */, 390F0CAE2BACC50D009081AD /* ProductImagesView.swift */, + D3FFA0AF2F0B12340088E044 /* Family/ManageFamilyView.swift */, ); path = Views; sourceTree = ""; @@ -241,7 +415,12 @@ 39F2A87C2A9D3972004EBB9A /* IngrediCheck */ = { isa = PBXGroup; children = ( - D35FB7F12E56E859009A5AA1 /* Config.swift */, + D3017FACE7F449A385F47E9B /* Models */, + 0CBB73342F03B575003ED0F5 /* Resources */, + D3BECBE72ED989D40088E044 /* Font Family */, + D3BECBCA2ED989B70088E044 /* backend */, + D3BECBA72ED9898E0088E044 /* Components */, + FA2A590C2F14B99000C57D89 /* Config.swift */, 1FF63CC92E32020C005BEDF1 /* Utilities */, 39705AF42BD5AC38000E487D /* IngrediCheck.entitlements */, 392D9C072B73665500166CF1 /* Info.plist */, @@ -265,10 +444,19 @@ name = Frameworks; sourceTree = ""; }; + D3017FACE7F449A385F47E9B /* Models */ = { + isa = PBXGroup; + children = ( + 2CFADBFEDBD6464085931D95 /* AppRoute.swift */, + ); + path = Models; + sourceTree = ""; + }; D35FB7F02E56E704009A5AA1 /* Recovered References */ = { isa = PBXGroup; children = ( D333A2702E3379F400E31F66 /* Constants.swift */, + FAA88CBC2EF6B62300449EDF /* Resource */, ); name = "Recovered References"; sourceTree = ""; @@ -288,6 +476,21 @@ ); dependencies = ( ); + fileSystemSynchronizedGroups = ( + 0C3A858A2F04EDB20018E877 /* Sheets */, + 0CBB73342F03B575003ED0F5 /* Resources */, + D33AC8782F1E3E6300EAD08F /* Canvas */, + D3BECBA72ED9898E0088E044 /* Components */, + D3BECBCA2ED989B70088E044 /* backend */, + D3BECBE72ED989D40088E044 /* Font Family */, + D3BECC0E2ED98A3E0088E044 /* AI Summary */, + D3BECC142ED98A3E0088E044 /* BarcodeScan */, + D3BECC172ED98A3E0088E044 /* ChatBot */, + D3BECC372ED98A3E0088E044 /* ProductDetails */, + D3BECC732ED98CFF0088E044 /* Splash Screen */, + D3BECC742ED98CFF0088E044 /* Add Family Members */, + FAA88CBC2EF6B62300449EDF /* Resource */, + ); name = IngrediCheck; packageProductDependencies = ( 397C85F72B6B537600FB9DAD /* KeychainSwift */, @@ -299,6 +502,8 @@ D35F3C232E2A25FB0002CDE7 /* GoogleSignIn */, D35F3C252E2A25FB0002CDE7 /* GoogleSignInSwift */, 0C51E2F02E335C1E00A3E3A9 /* PostHog */, + 0CE0D52B2F03A1390050C904 /* DotLottie */, + D35AB5C52F2A17C2002FB36A /* RiveRuntime */, ); productName = IngrediCheck; productReference = 39F2A87A2A9D3972004EBB9A /* IngrediCheck.app */; @@ -311,7 +516,7 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1430; + LastSwiftUpdateCheck = 2600; LastUpgradeCheck = 1520; TargetAttributes = { 39F2A8792A9D3972004EBB9A = { @@ -335,6 +540,8 @@ D35BCF242E27D5B600125580 /* XCRemoteSwiftPackageReference "supabase-swift" */, D35F3C222E2A25FB0002CDE7 /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */, 0C51E2EF2E335C1E00A3E3A9 /* XCRemoteSwiftPackageReference "posthog-ios" */, + 0CE0D52A2F03A1390050C904 /* XCRemoteSwiftPackageReference "dotlottie-ios" */, + D35AB5C42F2A17C2002FB36A /* XCRemoteSwiftPackageReference "rive-ios" */, ); productRefGroup = 39F2A87B2A9D3972004EBB9A /* Products */; projectDirPath = ""; @@ -352,32 +559,75 @@ files = ( 39F2A8822A9D3974004EBB9A /* Assets.xcassets in Resources */, 1F5987B42E30E56500381E32 /* TipJarConnectFile.storekit in Resources */, + FA2CB6442F078CB300E65B90 /* dynamicJsonData.json in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ -/* Begin PBXShellScriptBuildPhase section */ -/* End PBXShellScriptBuildPhase section */ - /* Begin PBXSourcesBuildPhase section */ 39F2A8762A9D3972004EBB9A /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + AD49592740904EC2B52AE490 /* AppRoute.swift in Sources */, 397C85DD2B687FF700FB9DAD /* BarcodeScannerView.swift in Sources */, 3976C41B2BBEE1750026991A /* SafariView.swift in Sources */, 392D9BF72B72CC8A00166CF1 /* HomeTab.swift in Sources */, + D3F6ACC82F10E3490019F5C4 /* OnboardingPersistence.swift in Sources */, 392D9BF92B72CCA200166CF1 /* CheckTab.swift in Sources */, 397C85DB2B686DF000FB9DAD /* CaptureView.swift in Sources */, 39397AED2BC6DA320096C5E3 /* SearchBar.swift in Sources */, + D3BECC8B2ED98E860088E044 /* TempStore.swift in Sources */, + D3BECF6E2ED9970A0088E044 /* MainCanvasView.swift in Sources */, + 0CF25DF12EDD9C9500C582FA /* DynamicOnboardingViews.swift in Sources */, + 0CF25DF22EDD9C9600C582FA /* MeetYourProfileView.swift in Sources */, + D3BECC8C2ED98E860088E044 /* Onboarding.swift in Sources */, + D3BECC8D2ED98E860088E044 /* AppNavigationCoordinator.swift in Sources */, + 635E2CF6A2C048BD97E90FAA /* RemoteOnboardingMetadata.swift in Sources */, + D3BECC8E2ED98E860088E044 /* User.swift in Sources */, 390F0CAA2BABA460009081AD /* Fancy3DotsIndexView.swift in Sources */, + D3BECC992ED98EA10088E044 /* NunitoFontEnum.swift in Sources */, + D3BECC9A2ED98EA10088E044 /* ManropeFontEnum.swift in Sources */, + D3BECC9B2ED98EA10088E044 /* BottomSheetRoute.swift in Sources */, + D3BECC9C2ED98EA10088E044 /* CanvasRoute.swift in Sources */, + D3BECCA52ED98EA30088E044 /* ChatContextBuilder.swift in Sources */, + D3BECC9D2ED98EA10088E044 /* FlowLayout.swift in Sources */, + D3F946B6F45C9CE7AC0B5E2C /* ShimmerModifier.swift in Sources */, + D3BED0622ED997490088E044 /* HomeView.swift in Sources */, + D3BED0642ED997490088E044 /* EditableCanvasView.swift in Sources */, + D3BED0662ED9974A00088E044 /* RecentScansFullView.swift in Sources */, + D3F0E0032F0A1B2C3D4E5F60 /* MemojiModels.swift in Sources */, + D3F0E0042F0A1B2C3D4E5F60 /* MemojiStore.swift in Sources */, + D3F0E0062F0A1B2C3D4E5F60 /* AIMemojiService.swift in Sources */, + D3BECC9E2ED98EA10088E044 /* Temp2.swift in Sources */, + D3BECC9F2ED98EA10088E044 /* Temp3.swift in Sources */, + D3BECCA02ED98EA10088E044 /* Temp4.swift in Sources */, + D3BECCA12ED98EA10088E044 /* CustomSheet.swift in Sources */, + D3BECCA22ED98EA10088E044 /* HexColorExtension.swift in Sources */, + D3BECCA32ED98EA20088E044 /* UIImageExtensions.swift in Sources */, 392D9C0E2B755AE900166CF1 /* AnalysisResultView.swift in Sources */, 397C85FA2B6B538500FB9DAD /* AuthController.swift in Sources */, 110D4DFFDD1244FC86E17F05 /* AnalyticsService.swift in Sources */, 39397AF32BCB39EA0096C5E3 /* Splash.swift in Sources */, 392D9C062B72D58400166CF1 /* UserPreferences.swift in Sources */, 39F2A87E2A9D3972004EBB9A /* IngrediCheckApp.swift in Sources */, + D3BECC7D2ED98D320088E044 /* WelcomeToYourFamilyView.swift in Sources */, + D3BECC7E2ED98D320088E044 /* HeyThereScreen.swift in Sources */, + D3BECC7F2ED98D320088E044 /* DietaryPreferencesAndRestrictions.swift in Sources */, + D3BECC802ED98D320088E044 /* AppFlowRouter.swift in Sources */, + FA2A590D2F14B99000C57D89 /* Config.swift in Sources */, + D3BECC812ED98D320088E044 /* BlankScreen.swift in Sources */, + D3BECC822ED98D320088E044 /* RootContainerView.swift in Sources */, + D3BECC832ED98D320088E044 /* LetsMeetYourIngrediFamView.swift in Sources */, + D3BECC842ED98D320088E044 /* LetsGetStartedView.swift in Sources */, + D382E0602EDEE4B600F139B2 /* FamilyModels.swift in Sources */, + D382E0612EDEE4B600F139B2 /* FamilyService.swift in Sources */, + D3CE197A2F20EF400097B28D /* ViewExtensions.swift in Sources */, + D382E0622EDEE4B600F139B2 /* FamilyStore.swift in Sources */, + D382E0642EDEE4B600F139B2 /* FoodNotesStore.swift in Sources */, + D3CHAT002000000000000000 /* ChatStore.swift in Sources */, + 0CF25DF52EDD9ECA00C582FA /* DynamicSteps.swift in Sources */, 1F59877A2E30C51E00381E32 /* TipJarView.swift in Sources */, 3976C4212BC0B6AF0026991A /* ThreeDotsIndexView.swift in Sources */, 397C86062B6C22B900FB9DAD /* BarcodeAnalysisView.swift in Sources */, @@ -386,7 +636,9 @@ 397C86012B6C090B00FB9DAD /* WebService.swift in Sources */, 397DA1D72B7A9BB000BFB26F /* PreferenceExamples.swift in Sources */, 393461CE2B93777200695FE5 /* FileCache.swift in Sources */, + AA0000012F4F000000000001 /* TutorialVideoManager.swift in Sources */, 39705AEA2BD47DEB000E487D /* OnboardingState.swift in Sources */, + D3F6A9652F0D33A00019F5C4 /* ScanHistoryStore.swift in Sources */, 39705AEC2BD48E2C000E487D /* UseCasesView.swift in Sources */, 397DA1D92B7C153100BFB26F /* FeedbackView.swift in Sources */, 392D9BFD2B72CCD300166CF1 /* SettingsSheet.swift in Sources */, @@ -398,13 +650,14 @@ 39397AE92BC64A200096C5E3 /* ListsTab.swift in Sources */, 0C51E2EE2E335B7300A3E3A9 /* Constants.swift in Sources */, 1F5987B82E30E74D00381E32 /* TipJarViewModel.swift in Sources */, - D35FB7F22E56E85C009A5AA1 /* Config.swift in Sources */, 39705AF12BD490DA000E487D /* SignInView.swift in Sources */, 392D9C0C2B75500000166CF1 /* CapsuleWithDivider.swift in Sources */, 3976C41D2BBF12250026991A /* WebView.swift in Sources */, + D3F6ACCC2F1102EC0019F5C4 /* ToastManager.swift in Sources */, 3946B4472B6F423B00D8B7C9 /* LabelAnalysisView.swift in Sources */, 397C85FF2B6C08FE00FB9DAD /* DTO.swift in Sources */, 39705AEF2BD490D0000E487D /* DisclaimerView.swift in Sources */, + D3FFA0B02F0B12340088E044 /* Family/ManageFamilyView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -534,8 +787,9 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = IngrediCheck/IngrediCheck.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 58MYNHGN72; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -548,12 +802,12 @@ INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; INFOPLIST_KEY_UIUserInterfaceStyle = Light; - IPHONEOS_DEPLOYMENT_TARGET = 17.2; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.4.0; + MARKETING_VERSION = 2.0; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -589,6 +843,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = llc.fungee.ingredicheck; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; @@ -604,11 +859,10 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = IngrediCheck/IngrediCheck.entitlements; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; - CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 5; - DEVELOPMENT_TEAM = ""; - "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 58MYNHGN72; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 58MYNHGN72; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = IngrediCheck/Info.plist; @@ -620,12 +874,12 @@ INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; INFOPLIST_KEY_UIUserInterfaceStyle = Light; - IPHONEOS_DEPLOYMENT_TARGET = 17.2; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.4.0; + MARKETING_VERSION = 2.0; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -662,7 +916,6 @@ PRODUCT_BUNDLE_IDENTIFIER = llc.fungee.ingredicheck; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "IngrediCheck App Store"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; @@ -705,6 +958,14 @@ minimumVersion = 3.33.0; }; }; + 0CE0D52A2F03A1390050C904 /* XCRemoteSwiftPackageReference "dotlottie-ios" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/LottieFiles/dotlottie-ios.git"; + requirement = { + branch = main; + kind = branch; + }; + }; 390F0CA22BA73ED7009081AD /* XCRemoteSwiftPackageReference "swiftui-flow-layout" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/globulus/swiftui-flow-layout"; @@ -729,6 +990,14 @@ minimumVersion = 24.0.0; }; }; + D35AB5C42F2A17C2002FB36A /* XCRemoteSwiftPackageReference "rive-ios" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/rive-app/rive-ios"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 6.15.1; + }; + }; D35BCF242E27D5B600125580 /* XCRemoteSwiftPackageReference "supabase-swift" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/supabase/supabase-swift.git"; @@ -753,6 +1022,11 @@ package = 0C51E2EF2E335C1E00A3E3A9 /* XCRemoteSwiftPackageReference "posthog-ios" */; productName = PostHog; }; + 0CE0D52B2F03A1390050C904 /* DotLottie */ = { + isa = XCSwiftPackageProductDependency; + package = 0CE0D52A2F03A1390050C904 /* XCRemoteSwiftPackageReference "dotlottie-ios" */; + productName = DotLottie; + }; 390F0CA32BA73ED7009081AD /* SwiftUIFlowLayout */ = { isa = XCSwiftPackageProductDependency; package = 390F0CA22BA73ED7009081AD /* XCRemoteSwiftPackageReference "swiftui-flow-layout" */; @@ -768,6 +1042,11 @@ package = 397C85F62B6B537600FB9DAD /* XCRemoteSwiftPackageReference "keychain-swift" */; productName = KeychainSwift; }; + D35AB5C52F2A17C2002FB36A /* RiveRuntime */ = { + isa = XCSwiftPackageProductDependency; + package = D35AB5C42F2A17C2002FB36A /* XCRemoteSwiftPackageReference "rive-ios" */; + productName = RiveRuntime; + }; D35BCF252E27D5B600125580 /* Auth */ = { isa = XCSwiftPackageProductDependency; package = D35BCF242E27D5B600125580 /* XCRemoteSwiftPackageReference "supabase-swift" */; diff --git a/IngrediCheck.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/IngrediCheck.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 67edd2fb..d4adc3d9 100644 --- a/IngrediCheck.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/IngrediCheck.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "87ec2d9982ab25467a4bb4f60f99387d5ea420c6536d5f80d1bdec00ca08bdd8", + "originHash" : "76d535a1fe31876b3ccf3933ff49f18a1a68611f0106b2cb5aaa9590904833e5", "pins" : [ { "identity" : "app-check", @@ -19,6 +19,15 @@ "version" : "2.0.0" } }, + { + "identity" : "dotlottie-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/LottieFiles/dotlottie-ios.git", + "state" : { + "branch" : "main", + "revision" : "f88d32df0e24b5dcefca052d0ea3ba0062e910bc" + } + }, { "identity" : "googlesignin-ios", "kind" : "remoteSourceControl", @@ -69,7 +78,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/PostHog/posthog-ios.git", "state" : { - "revision" : "e1af8ba396618c19649949f1dfb445b0465c0f88", + "revision" : "62ee83c9f5202c1f1163c92a44e7deb0fea5bbfe", "version" : "3.33.0" } }, @@ -82,6 +91,15 @@ "version" : "2.4.0" } }, + { + "identity" : "rive-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/rive-app/rive-ios", + "state" : { + "revision" : "388cebf85b8fc2258b93f86dd3e26a30fb070fd0", + "version" : "6.15.1" + } + }, { "identity" : "simpletoast", "kind" : "remoteSourceControl", diff --git a/IngrediCheck.xcodeproj/xcshareddata/xcschemes/IngrediCheck.xcscheme b/IngrediCheck.xcodeproj/xcshareddata/xcschemes/IngrediCheck.xcscheme index ee1303a8..05a709cc 100644 --- a/IngrediCheck.xcodeproj/xcshareddata/xcschemes/IngrediCheck.xcscheme +++ b/IngrediCheck.xcodeproj/xcshareddata/xcschemes/IngrediCheck.xcscheme @@ -51,7 +51,7 @@ + identifier = "../../../IngrediCheck/TipJarConnectFile.storekit"> + + diff --git a/IngrediCheck/Assets.xcassets/v2 image/gesture-Primary.imageset/solar_hand-shake-bold@2x.png b/IngrediCheck/Assets.xcassets/v2 image/gesture-Primary.imageset/solar_hand-shake-bold@2x.png new file mode 100644 index 00000000..a8ffec89 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/gesture-Primary.imageset/solar_hand-shake-bold@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/gesture-Primary.imageset/solar_hand-shake-bold@3x.png b/IngrediCheck/Assets.xcassets/v2 image/gesture-Primary.imageset/solar_hand-shake-bold@3x.png new file mode 100644 index 00000000..d79fbb20 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/gesture-Primary.imageset/solar_hand-shake-bold@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/gesture.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/gesture.imageset/Contents.json new file mode 100644 index 00000000..b7d12a7b --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/gesture.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "solar_hand-shake-bold.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "solar_hand-shake-bold@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "solar_hand-shake-bold@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/gesture.imageset/solar_hand-shake-bold.png b/IngrediCheck/Assets.xcassets/v2 image/gesture.imageset/solar_hand-shake-bold.png new file mode 100644 index 00000000..8a670afd Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/gesture.imageset/solar_hand-shake-bold.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/gesture.imageset/solar_hand-shake-bold@2x.png b/IngrediCheck/Assets.xcassets/v2 image/gesture.imageset/solar_hand-shake-bold@2x.png new file mode 100644 index 00000000..2643add7 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/gesture.imageset/solar_hand-shake-bold@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/gesture.imageset/solar_hand-shake-bold@3x.png b/IngrediCheck/Assets.xcassets/v2 image/gesture.imageset/solar_hand-shake-bold@3x.png new file mode 100644 index 00000000..af1a251e Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/gesture.imageset/solar_hand-shake-bold@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/google_logo 1.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/google_logo 1.imageset/Contents.json new file mode 100644 index 00000000..77199a60 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/google_logo 1.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Frame 1171276780.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Frame 1171276780@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Frame 1171276780@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/google_logo 1.imageset/Frame 1171276780.png b/IngrediCheck/Assets.xcassets/v2 image/google_logo 1.imageset/Frame 1171276780.png new file mode 100644 index 00000000..7f3864c4 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/google_logo 1.imageset/Frame 1171276780.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/google_logo 1.imageset/Frame 1171276780@2x.png b/IngrediCheck/Assets.xcassets/v2 image/google_logo 1.imageset/Frame 1171276780@2x.png new file mode 100644 index 00000000..bc6640d8 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/google_logo 1.imageset/Frame 1171276780@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/google_logo 1.imageset/Frame 1171276780@3x.png b/IngrediCheck/Assets.xcassets/v2 image/google_logo 1.imageset/Frame 1171276780@3x.png new file mode 100644 index 00000000..5737ce3e Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/google_logo 1.imageset/Frame 1171276780@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/google_logo.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/google_logo.imageset/Contents.json new file mode 100644 index 00000000..7965758c --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/google_logo.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "flowbite_google-solid.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "flowbite_google-solid@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "flowbite_google-solid@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/google_logo.imageset/flowbite_google-solid.png b/IngrediCheck/Assets.xcassets/v2 image/google_logo.imageset/flowbite_google-solid.png new file mode 100644 index 00000000..9922c508 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/google_logo.imageset/flowbite_google-solid.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/google_logo.imageset/flowbite_google-solid@2x.png b/IngrediCheck/Assets.xcassets/v2 image/google_logo.imageset/flowbite_google-solid@2x.png new file mode 100644 index 00000000..a28d054c Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/google_logo.imageset/flowbite_google-solid@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/google_logo.imageset/flowbite_google-solid@3x.png b/IngrediCheck/Assets.xcassets/v2 image/google_logo.imageset/flowbite_google-solid@3x.png new file mode 100644 index 00000000..c41cffb0 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/google_logo.imageset/flowbite_google-solid@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/hair-style-Primary.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/hair-style-Primary.imageset/Contents.json new file mode 100644 index 00000000..e993c003 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/hair-style-Primary.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "mingcute_hair-2-fill.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "mingcute_hair-2-fill@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "mingcute_hair-2-fill@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/hair-style-Primary.imageset/mingcute_hair-2-fill.png b/IngrediCheck/Assets.xcassets/v2 image/hair-style-Primary.imageset/mingcute_hair-2-fill.png new file mode 100644 index 00000000..bfa3e553 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/hair-style-Primary.imageset/mingcute_hair-2-fill.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/hair-style-Primary.imageset/mingcute_hair-2-fill@2x.png b/IngrediCheck/Assets.xcassets/v2 image/hair-style-Primary.imageset/mingcute_hair-2-fill@2x.png new file mode 100644 index 00000000..21fea23e Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/hair-style-Primary.imageset/mingcute_hair-2-fill@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/hair-style-Primary.imageset/mingcute_hair-2-fill@3x.png b/IngrediCheck/Assets.xcassets/v2 image/hair-style-Primary.imageset/mingcute_hair-2-fill@3x.png new file mode 100644 index 00000000..5d9bdfcf Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/hair-style-Primary.imageset/mingcute_hair-2-fill@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/hair-style.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/hair-style.imageset/Contents.json new file mode 100644 index 00000000..e993c003 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/hair-style.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "mingcute_hair-2-fill.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "mingcute_hair-2-fill@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "mingcute_hair-2-fill@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/hair-style.imageset/mingcute_hair-2-fill.png b/IngrediCheck/Assets.xcassets/v2 image/hair-style.imageset/mingcute_hair-2-fill.png new file mode 100644 index 00000000..b7a13ea7 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/hair-style.imageset/mingcute_hair-2-fill.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/hair-style.imageset/mingcute_hair-2-fill@2x.png b/IngrediCheck/Assets.xcassets/v2 image/hair-style.imageset/mingcute_hair-2-fill@2x.png new file mode 100644 index 00000000..0c7ee744 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/hair-style.imageset/mingcute_hair-2-fill@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/hair-style.imageset/mingcute_hair-2-fill@3x.png b/IngrediCheck/Assets.xcassets/v2 image/hair-style.imageset/mingcute_hair-2-fill@3x.png new file mode 100644 index 00000000..659028f5 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/hair-style.imageset/mingcute_hair-2-fill@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/history-emptystate.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/history-emptystate.imageset/Contents.json new file mode 100644 index 00000000..8dbe35f0 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/history-emptystate.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Group 1171276618.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Group 1171276618@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Group 1171276618@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/history-emptystate.imageset/Group 1171276618.png b/IngrediCheck/Assets.xcassets/v2 image/history-emptystate.imageset/Group 1171276618.png new file mode 100644 index 00000000..0fc1e6f9 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/history-emptystate.imageset/Group 1171276618.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/history-emptystate.imageset/Group 1171276618@2x.png b/IngrediCheck/Assets.xcassets/v2 image/history-emptystate.imageset/Group 1171276618@2x.png new file mode 100644 index 00000000..3da3711d Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/history-emptystate.imageset/Group 1171276618@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/history-emptystate.imageset/Group 1171276618@3x.png b/IngrediCheck/Assets.xcassets/v2 image/history-emptystate.imageset/Group 1171276618@3x.png new file mode 100644 index 00000000..92957209 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/history-emptystate.imageset/Group 1171276618@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/homescreenbanner.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/homescreenbanner.imageset/Contents.json new file mode 100644 index 00000000..b5fee329 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/homescreenbanner.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "image 94.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/homescreenbanner.imageset/image 94.pdf b/IngrediCheck/Assets.xcassets/v2 image/homescreenbanner.imageset/image 94.pdf new file mode 100644 index 00000000..a7ee7db5 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/homescreenbanner.imageset/image 94.pdf differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/hugeicons_plant-01.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/hugeicons_plant-01.imageset/Contents.json new file mode 100644 index 00000000..86df522e --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/hugeicons_plant-01.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Group (3).pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/hugeicons_plant-01.imageset/Group (3).pdf b/IngrediCheck/Assets.xcassets/v2 image/hugeicons_plant-01.imageset/Group (3).pdf new file mode 100644 index 00000000..bc6bd2c2 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/hugeicons_plant-01.imageset/Group (3).pdf differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/iconoir_chocolate.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/iconoir_chocolate.imageset/Contents.json new file mode 100644 index 00000000..4a2aa24e --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/iconoir_chocolate.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Vector (4).pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/iconoir_chocolate.imageset/Vector (4).pdf b/IngrediCheck/Assets.xcassets/v2 image/iconoir_chocolate.imageset/Vector (4).pdf new file mode 100644 index 00000000..d2abd266 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/iconoir_chocolate.imageset/Vector (4).pdf differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/image 1.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/image 1.imageset/Contents.json new file mode 100644 index 00000000..6a452fcb --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/image 1.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "image 1.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/image 1.imageset/image 1.pdf b/IngrediCheck/Assets.xcassets/v2 image/image 1.imageset/image 1.pdf new file mode 100644 index 00000000..b956591c Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/image 1.imageset/image 1.pdf differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/image 2.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/image 2.imageset/Contents.json new file mode 100644 index 00000000..dc4f61a7 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/image 2.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "image 2.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/image 2.imageset/image 2.pdf b/IngrediCheck/Assets.xcassets/v2 image/image 2.imageset/image 2.pdf new file mode 100644 index 00000000..8859e7bc Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/image 2.imageset/image 2.pdf differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/image 3.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/image 3.imageset/Contents.json new file mode 100644 index 00000000..26db57a2 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/image 3.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "image 3.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/image 3.imageset/image 3.pdf b/IngrediCheck/Assets.xcassets/v2 image/image 3.imageset/image 3.pdf new file mode 100644 index 00000000..e2e8cd65 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/image 3.imageset/image 3.pdf differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/image-bg1.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/image-bg1.imageset/Contents.json new file mode 100644 index 00000000..1a15551b --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/image-bg1.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Group 1171276320.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Group 1171276320@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Group 1171276320@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/image-bg1.imageset/Group 1171276320.png b/IngrediCheck/Assets.xcassets/v2 image/image-bg1.imageset/Group 1171276320.png new file mode 100644 index 00000000..5b044711 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/image-bg1.imageset/Group 1171276320.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/image-bg1.imageset/Group 1171276320@2x.png b/IngrediCheck/Assets.xcassets/v2 image/image-bg1.imageset/Group 1171276320@2x.png new file mode 100644 index 00000000..f52d94dc Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/image-bg1.imageset/Group 1171276320@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/image-bg1.imageset/Group 1171276320@3x.png b/IngrediCheck/Assets.xcassets/v2 image/image-bg1.imageset/Group 1171276320@3x.png new file mode 100644 index 00000000..2410a819 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/image-bg1.imageset/Group 1171276320@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/image-bg2.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/image-bg2.imageset/Contents.json new file mode 100644 index 00000000..fdbde9b4 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/image-bg2.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Group 1171276322.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Group 1171276322@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Group 1171276322@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/image-bg2.imageset/Group 1171276322.png b/IngrediCheck/Assets.xcassets/v2 image/image-bg2.imageset/Group 1171276322.png new file mode 100644 index 00000000..2ed779c2 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/image-bg2.imageset/Group 1171276322.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/image-bg2.imageset/Group 1171276322@2x.png b/IngrediCheck/Assets.xcassets/v2 image/image-bg2.imageset/Group 1171276322@2x.png new file mode 100644 index 00000000..2b1b74be Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/image-bg2.imageset/Group 1171276322@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/image-bg2.imageset/Group 1171276322@3x.png b/IngrediCheck/Assets.xcassets/v2 image/image-bg2.imageset/Group 1171276322@3x.png new file mode 100644 index 00000000..1fd10593 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/image-bg2.imageset/Group 1171276322@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/image-bg3.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/image-bg3.imageset/Contents.json new file mode 100644 index 00000000..afbb96b1 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/image-bg3.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Group 1171276321.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Group 1171276321@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Group 1171276321@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/image-bg3.imageset/Group 1171276321.png b/IngrediCheck/Assets.xcassets/v2 image/image-bg3.imageset/Group 1171276321.png new file mode 100644 index 00000000..f97a18a7 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/image-bg3.imageset/Group 1171276321.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/image-bg3.imageset/Group 1171276321@2x.png b/IngrediCheck/Assets.xcassets/v2 image/image-bg3.imageset/Group 1171276321@2x.png new file mode 100644 index 00000000..3af59040 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/image-bg3.imageset/Group 1171276321@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/image-bg3.imageset/Group 1171276321@3x.png b/IngrediCheck/Assets.xcassets/v2 image/image-bg3.imageset/Group 1171276321@3x.png new file mode 100644 index 00000000..8afee271 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/image-bg3.imageset/Group 1171276321@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/image-bg4.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/image-bg4.imageset/Contents.json new file mode 100644 index 00000000..4fd70bd2 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/image-bg4.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Group 1171276319.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Group 1171276319@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Group 1171276319@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/image-bg4.imageset/Group 1171276319.png b/IngrediCheck/Assets.xcassets/v2 image/image-bg4.imageset/Group 1171276319.png new file mode 100644 index 00000000..7d01fdf4 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/image-bg4.imageset/Group 1171276319.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/image-bg4.imageset/Group 1171276319@2x.png b/IngrediCheck/Assets.xcassets/v2 image/image-bg4.imageset/Group 1171276319@2x.png new file mode 100644 index 00000000..52851a27 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/image-bg4.imageset/Group 1171276319@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/image-bg4.imageset/Group 1171276319@3x.png b/IngrediCheck/Assets.xcassets/v2 image/image-bg4.imageset/Group 1171276319@3x.png new file mode 100644 index 00000000..a757d63e Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/image-bg4.imageset/Group 1171276319@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/image-bg5.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/image-bg5.imageset/Contents.json new file mode 100644 index 00000000..66188939 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/image-bg5.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Group 1171276318.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Group 1171276318@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Group 1171276318@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/image-bg5.imageset/Group 1171276318.png b/IngrediCheck/Assets.xcassets/v2 image/image-bg5.imageset/Group 1171276318.png new file mode 100644 index 00000000..87db2f47 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/image-bg5.imageset/Group 1171276318.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/image-bg5.imageset/Group 1171276318@2x.png b/IngrediCheck/Assets.xcassets/v2 image/image-bg5.imageset/Group 1171276318@2x.png new file mode 100644 index 00000000..abe70c79 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/image-bg5.imageset/Group 1171276318@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/image-bg5.imageset/Group 1171276318@3x.png b/IngrediCheck/Assets.xcassets/v2 image/image-bg5.imageset/Group 1171276318@3x.png new file mode 100644 index 00000000..a8f49187 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/image-bg5.imageset/Group 1171276318@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/ingredi-bot-button-background.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/ingredi-bot-button-background.imageset/Contents.json new file mode 100644 index 00000000..fba743cc --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/ingredi-bot-button-background.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "ingredi-bot-button-background.jpg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/ingredi-bot-button-background.imageset/ingredi-bot-button-background.jpg b/IngrediCheck/Assets.xcassets/v2 image/ingredi-bot-button-background.imageset/ingredi-bot-button-background.jpg new file mode 100644 index 00000000..5089c832 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/ingredi-bot-button-background.imageset/ingredi-bot-button-background.jpg differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/ingrediBot.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/ingrediBot.imageset/Contents.json new file mode 100644 index 00000000..7ed2c0a5 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/ingrediBot.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "image 91.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "image 91@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "image 91@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/ingrediBot.imageset/image 91.png b/IngrediCheck/Assets.xcassets/v2 image/ingrediBot.imageset/image 91.png new file mode 100644 index 00000000..b5684800 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/ingrediBot.imageset/image 91.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/ingrediBot.imageset/image 91@2x.png b/IngrediCheck/Assets.xcassets/v2 image/ingrediBot.imageset/image 91@2x.png new file mode 100644 index 00000000..2fd4f8ea Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/ingrediBot.imageset/image 91@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/ingrediBot.imageset/image 91@3x.png b/IngrediCheck/Assets.xcassets/v2 image/ingrediBot.imageset/image 91@3x.png new file mode 100644 index 00000000..3611597d Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/ingrediBot.imageset/image 91@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/ingredients-product.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/ingredients-product.imageset/Contents.json new file mode 100644 index 00000000..e8c769b6 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/ingredients-product.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Group 1171276640.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Group 1171276640@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Group 1171276640@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/ingredients-product.imageset/Group 1171276640.png b/IngrediCheck/Assets.xcassets/v2 image/ingredients-product.imageset/Group 1171276640.png new file mode 100644 index 00000000..370722be Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/ingredients-product.imageset/Group 1171276640.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/ingredients-product.imageset/Group 1171276640@2x.png b/IngrediCheck/Assets.xcassets/v2 image/ingredients-product.imageset/Group 1171276640@2x.png new file mode 100644 index 00000000..25f335ac Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/ingredients-product.imageset/Group 1171276640@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/ingredients-product.imageset/Group 1171276640@3x.png b/IngrediCheck/Assets.xcassets/v2 image/ingredients-product.imageset/Group 1171276640@3x.png new file mode 100644 index 00000000..1047d79c Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/ingredients-product.imageset/Group 1171276640@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/justMe.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/justMe.imageset/Contents.json new file mode 100644 index 00000000..67197f09 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/justMe.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "justMe.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/justMe.imageset/justMe.pdf b/IngrediCheck/Assets.xcassets/v2 image/justMe.imageset/justMe.pdf new file mode 100644 index 00000000..ee1b6321 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/justMe.imageset/justMe.pdf differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/lays.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/lays.imageset/Contents.json new file mode 100644 index 00000000..e26184df --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/lays.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "lays.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/lays.imageset/lays.png b/IngrediCheck/Assets.xcassets/v2 image/lays.imageset/lays.png new file mode 100644 index 00000000..926fda30 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/lays.imageset/lays.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/leaf-recycle.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/leaf-recycle.imageset/Contents.json new file mode 100644 index 00000000..c6f97a05 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/leaf-recycle.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Vector.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/leaf-recycle.imageset/Vector.pdf b/IngrediCheck/Assets.xcassets/v2 image/leaf-recycle.imageset/Vector.pdf new file mode 100644 index 00000000..5d6c2db1 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/leaf-recycle.imageset/Vector.pdf differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/leaf.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/leaf.imageset/Contents.json new file mode 100644 index 00000000..d6e2b496 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/leaf.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Group 1171276398.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Group 1171276398@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Group 1171276398@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/leaf.imageset/Group 1171276398.png b/IngrediCheck/Assets.xcassets/v2 image/leaf.imageset/Group 1171276398.png new file mode 100644 index 00000000..277c60ff Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/leaf.imageset/Group 1171276398.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/leaf.imageset/Group 1171276398@2x.png b/IngrediCheck/Assets.xcassets/v2 image/leaf.imageset/Group 1171276398@2x.png new file mode 100644 index 00000000..bf38c594 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/leaf.imageset/Group 1171276398@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/leaf.imageset/Group 1171276398@3x.png b/IngrediCheck/Assets.xcassets/v2 image/leaf.imageset/Group 1171276398@3x.png new file mode 100644 index 00000000..cb7f7c3c Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/leaf.imageset/Group 1171276398@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/lock-image.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/lock-image.imageset/Contents.json new file mode 100644 index 00000000..8c0bc762 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/lock-image.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "bxs_lock.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "bxs_lock@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "bxs_lock@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/lock-image.imageset/bxs_lock.png b/IngrediCheck/Assets.xcassets/v2 image/lock-image.imageset/bxs_lock.png new file mode 100644 index 00000000..8d16f078 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/lock-image.imageset/bxs_lock.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/lock-image.imageset/bxs_lock@2x.png b/IngrediCheck/Assets.xcassets/v2 image/lock-image.imageset/bxs_lock@2x.png new file mode 100644 index 00000000..a3b9227c Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/lock-image.imageset/bxs_lock@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/lock-image.imageset/bxs_lock@3x.png b/IngrediCheck/Assets.xcassets/v2 image/lock-image.imageset/bxs_lock@3x.png new file mode 100644 index 00000000..0636a38b Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/lock-image.imageset/bxs_lock@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/logo-with-name.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/logo-with-name.imageset/Contents.json new file mode 100644 index 00000000..62eaf983 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/logo-with-name.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Frame 1171276816.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Frame 1171276816@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Frame 1171276816@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/logo-with-name.imageset/Frame 1171276816.png b/IngrediCheck/Assets.xcassets/v2 image/logo-with-name.imageset/Frame 1171276816.png new file mode 100644 index 00000000..8ad486c6 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/logo-with-name.imageset/Frame 1171276816.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/logo-with-name.imageset/Frame 1171276816@2x.png b/IngrediCheck/Assets.xcassets/v2 image/logo-with-name.imageset/Frame 1171276816@2x.png new file mode 100644 index 00000000..16862013 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/logo-with-name.imageset/Frame 1171276816@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/logo-with-name.imageset/Frame 1171276816@3x.png b/IngrediCheck/Assets.xcassets/v2 image/logo-with-name.imageset/Frame 1171276816@3x.png new file mode 100644 index 00000000..58a6dbad Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/logo-with-name.imageset/Frame 1171276816@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/lucide_baby.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/lucide_baby.imageset/Contents.json new file mode 100644 index 00000000..377bc86a --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/lucide_baby.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Group (2).pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/lucide_baby.imageset/Group (2).pdf b/IngrediCheck/Assets.xcassets/v2 image/lucide_baby.imageset/Group (2).pdf new file mode 100644 index 00000000..2a1721f5 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/lucide_baby.imageset/Group (2).pdf differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/lucide_stethoscope.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/lucide_stethoscope.imageset/Contents.json new file mode 100644 index 00000000..103f3878 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/lucide_stethoscope.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Group (1).pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/lucide_stethoscope.imageset/Group (1).pdf b/IngrediCheck/Assets.xcassets/v2 image/lucide_stethoscope.imageset/Group (1).pdf new file mode 100644 index 00000000..82f69df4 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/lucide_stethoscope.imageset/Group (1).pdf differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/magnify.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/magnify.imageset/Contents.json new file mode 100644 index 00000000..c34ba9d9 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/magnify.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Union.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Union@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Union@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/magnify.imageset/Union.png b/IngrediCheck/Assets.xcassets/v2 image/magnify.imageset/Union.png new file mode 100644 index 00000000..72e76fbc Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/magnify.imageset/Union.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/magnify.imageset/Union@2x.png b/IngrediCheck/Assets.xcassets/v2 image/magnify.imageset/Union@2x.png new file mode 100644 index 00000000..229bba6b Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/magnify.imageset/Union@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/magnify.imageset/Union@3x.png b/IngrediCheck/Assets.xcassets/v2 image/magnify.imageset/Union@3x.png new file mode 100644 index 00000000..e01a9083 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/magnify.imageset/Union@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/memoji.imageset/Contents.json new file mode 100644 index 00000000..c711491e --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/memoji.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "image 35.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "image 35@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "image 35@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji.imageset/image 35.png b/IngrediCheck/Assets.xcassets/v2 image/memoji.imageset/image 35.png new file mode 100644 index 00000000..5cf50853 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/memoji.imageset/image 35.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji.imageset/image 35@2x.png b/IngrediCheck/Assets.xcassets/v2 image/memoji.imageset/image 35@2x.png new file mode 100644 index 00000000..e9721506 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/memoji.imageset/image 35@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji.imageset/image 35@3x.png b/IngrediCheck/Assets.xcassets/v2 image/memoji.imageset/image 35@3x.png new file mode 100644 index 00000000..80ee5545 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/memoji.imageset/image 35@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_1.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/memoji_1.imageset/Contents.json new file mode 100644 index 00000000..b0f23ce8 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/memoji_1.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Group 1171276633.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Group 1171276633@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Group 1171276633@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_1.imageset/Group 1171276633.png b/IngrediCheck/Assets.xcassets/v2 image/memoji_1.imageset/Group 1171276633.png new file mode 100644 index 00000000..502c2eeb Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/memoji_1.imageset/Group 1171276633.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_1.imageset/Group 1171276633@2x.png b/IngrediCheck/Assets.xcassets/v2 image/memoji_1.imageset/Group 1171276633@2x.png new file mode 100644 index 00000000..b2b72623 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/memoji_1.imageset/Group 1171276633@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_1.imageset/Group 1171276633@3x.png b/IngrediCheck/Assets.xcassets/v2 image/memoji_1.imageset/Group 1171276633@3x.png new file mode 100644 index 00000000..f37e41b1 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/memoji_1.imageset/Group 1171276633@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_10.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/memoji_10.imageset/Contents.json new file mode 100644 index 00000000..e9eb3c1b --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/memoji_10.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Group 1171276635.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Group 1171276635@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Group 1171276635@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_10.imageset/Group 1171276635.png b/IngrediCheck/Assets.xcassets/v2 image/memoji_10.imageset/Group 1171276635.png new file mode 100644 index 00000000..d7a7a1bb Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/memoji_10.imageset/Group 1171276635.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_10.imageset/Group 1171276635@2x.png b/IngrediCheck/Assets.xcassets/v2 image/memoji_10.imageset/Group 1171276635@2x.png new file mode 100644 index 00000000..ccc145b9 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/memoji_10.imageset/Group 1171276635@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_10.imageset/Group 1171276635@3x.png b/IngrediCheck/Assets.xcassets/v2 image/memoji_10.imageset/Group 1171276635@3x.png new file mode 100644 index 00000000..02f310c6 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/memoji_10.imageset/Group 1171276635@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_11.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/memoji_11.imageset/Contents.json new file mode 100644 index 00000000..682df1aa --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/memoji_11.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Group 1171276632.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Group 1171276632@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Group 1171276632@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_11.imageset/Group 1171276632.png b/IngrediCheck/Assets.xcassets/v2 image/memoji_11.imageset/Group 1171276632.png new file mode 100644 index 00000000..a465d752 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/memoji_11.imageset/Group 1171276632.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_11.imageset/Group 1171276632@2x.png b/IngrediCheck/Assets.xcassets/v2 image/memoji_11.imageset/Group 1171276632@2x.png new file mode 100644 index 00000000..a3a1b40b Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/memoji_11.imageset/Group 1171276632@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_11.imageset/Group 1171276632@3x.png b/IngrediCheck/Assets.xcassets/v2 image/memoji_11.imageset/Group 1171276632@3x.png new file mode 100644 index 00000000..4919d245 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/memoji_11.imageset/Group 1171276632@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_12.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/memoji_12.imageset/Contents.json new file mode 100644 index 00000000..ccd03386 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/memoji_12.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Group 1171276631.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Group 1171276631@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Group 1171276631@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_12.imageset/Group 1171276631.png b/IngrediCheck/Assets.xcassets/v2 image/memoji_12.imageset/Group 1171276631.png new file mode 100644 index 00000000..091e5e09 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/memoji_12.imageset/Group 1171276631.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_12.imageset/Group 1171276631@2x.png b/IngrediCheck/Assets.xcassets/v2 image/memoji_12.imageset/Group 1171276631@2x.png new file mode 100644 index 00000000..687c168f Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/memoji_12.imageset/Group 1171276631@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_12.imageset/Group 1171276631@3x.png b/IngrediCheck/Assets.xcassets/v2 image/memoji_12.imageset/Group 1171276631@3x.png new file mode 100644 index 00000000..cb7d4f00 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/memoji_12.imageset/Group 1171276631@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_13.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/memoji_13.imageset/Contents.json new file mode 100644 index 00000000..15bf71b9 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/memoji_13.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Group 1171276630.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Group 1171276630@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Group 1171276630@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_13.imageset/Group 1171276630.png b/IngrediCheck/Assets.xcassets/v2 image/memoji_13.imageset/Group 1171276630.png new file mode 100644 index 00000000..83aaf06e Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/memoji_13.imageset/Group 1171276630.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_13.imageset/Group 1171276630@2x.png b/IngrediCheck/Assets.xcassets/v2 image/memoji_13.imageset/Group 1171276630@2x.png new file mode 100644 index 00000000..6e091d2c Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/memoji_13.imageset/Group 1171276630@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_13.imageset/Group 1171276630@3x.png b/IngrediCheck/Assets.xcassets/v2 image/memoji_13.imageset/Group 1171276630@3x.png new file mode 100644 index 00000000..4e9b69b5 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/memoji_13.imageset/Group 1171276630@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_14.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/memoji_14.imageset/Contents.json new file mode 100644 index 00000000..46b7d00d --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/memoji_14.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Group 1171276629-1.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Group 1171276629@2x-1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Group 1171276629@3x-1.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_14.imageset/Group 1171276629-1.png b/IngrediCheck/Assets.xcassets/v2 image/memoji_14.imageset/Group 1171276629-1.png new file mode 100644 index 00000000..61f72eca Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/memoji_14.imageset/Group 1171276629-1.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_14.imageset/Group 1171276629@2x-1.png b/IngrediCheck/Assets.xcassets/v2 image/memoji_14.imageset/Group 1171276629@2x-1.png new file mode 100644 index 00000000..accad1b3 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/memoji_14.imageset/Group 1171276629@2x-1.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_14.imageset/Group 1171276629@3x-1.png b/IngrediCheck/Assets.xcassets/v2 image/memoji_14.imageset/Group 1171276629@3x-1.png new file mode 100644 index 00000000..3501fdcb Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/memoji_14.imageset/Group 1171276629@3x-1.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_2.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/memoji_2.imageset/Contents.json new file mode 100644 index 00000000..a1372ab9 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/memoji_2.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Group 1171276634.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Group 1171276634@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Group 1171276634@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_2.imageset/Group 1171276634.png b/IngrediCheck/Assets.xcassets/v2 image/memoji_2.imageset/Group 1171276634.png new file mode 100644 index 00000000..8972e699 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/memoji_2.imageset/Group 1171276634.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_2.imageset/Group 1171276634@2x.png b/IngrediCheck/Assets.xcassets/v2 image/memoji_2.imageset/Group 1171276634@2x.png new file mode 100644 index 00000000..ce755862 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/memoji_2.imageset/Group 1171276634@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_2.imageset/Group 1171276634@3x.png b/IngrediCheck/Assets.xcassets/v2 image/memoji_2.imageset/Group 1171276634@3x.png new file mode 100644 index 00000000..b7f95417 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/memoji_2.imageset/Group 1171276634@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_3.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/memoji_3.imageset/Contents.json new file mode 100644 index 00000000..44a4003f --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/memoji_3.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Group 1171276371.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Group 1171276371@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Group 1171276371@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_3.imageset/Group 1171276371.png b/IngrediCheck/Assets.xcassets/v2 image/memoji_3.imageset/Group 1171276371.png new file mode 100644 index 00000000..713cf6dd Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/memoji_3.imageset/Group 1171276371.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_3.imageset/Group 1171276371@2x.png b/IngrediCheck/Assets.xcassets/v2 image/memoji_3.imageset/Group 1171276371@2x.png new file mode 100644 index 00000000..eebe2b19 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/memoji_3.imageset/Group 1171276371@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_3.imageset/Group 1171276371@3x.png b/IngrediCheck/Assets.xcassets/v2 image/memoji_3.imageset/Group 1171276371@3x.png new file mode 100644 index 00000000..20dd79fe Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/memoji_3.imageset/Group 1171276371@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_4.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/memoji_4.imageset/Contents.json new file mode 100644 index 00000000..94dd5003 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/memoji_4.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Group 1171276629.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Group 1171276629@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Group 1171276629@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_4.imageset/Group 1171276629.png b/IngrediCheck/Assets.xcassets/v2 image/memoji_4.imageset/Group 1171276629.png new file mode 100644 index 00000000..5d2bccd2 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/memoji_4.imageset/Group 1171276629.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_4.imageset/Group 1171276629@2x.png b/IngrediCheck/Assets.xcassets/v2 image/memoji_4.imageset/Group 1171276629@2x.png new file mode 100644 index 00000000..4705984b Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/memoji_4.imageset/Group 1171276629@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_4.imageset/Group 1171276629@3x.png b/IngrediCheck/Assets.xcassets/v2 image/memoji_4.imageset/Group 1171276629@3x.png new file mode 100644 index 00000000..dbe808e6 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/memoji_4.imageset/Group 1171276629@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_5.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/memoji_5.imageset/Contents.json new file mode 100644 index 00000000..3b5cf176 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/memoji_5.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Group 1171276505.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Group 1171276505@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Group 1171276505@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_5.imageset/Group 1171276505.png b/IngrediCheck/Assets.xcassets/v2 image/memoji_5.imageset/Group 1171276505.png new file mode 100644 index 00000000..34a83d47 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/memoji_5.imageset/Group 1171276505.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_5.imageset/Group 1171276505@2x.png b/IngrediCheck/Assets.xcassets/v2 image/memoji_5.imageset/Group 1171276505@2x.png new file mode 100644 index 00000000..68054427 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/memoji_5.imageset/Group 1171276505@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_5.imageset/Group 1171276505@3x.png b/IngrediCheck/Assets.xcassets/v2 image/memoji_5.imageset/Group 1171276505@3x.png new file mode 100644 index 00000000..77af220f Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/memoji_5.imageset/Group 1171276505@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_6.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/memoji_6.imageset/Contents.json new file mode 100644 index 00000000..367262af --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/memoji_6.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Group 1171276317.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Group 1171276317@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Group 1171276317@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_6.imageset/Group 1171276317.png b/IngrediCheck/Assets.xcassets/v2 image/memoji_6.imageset/Group 1171276317.png new file mode 100644 index 00000000..77e1b466 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/memoji_6.imageset/Group 1171276317.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_6.imageset/Group 1171276317@2x.png b/IngrediCheck/Assets.xcassets/v2 image/memoji_6.imageset/Group 1171276317@2x.png new file mode 100644 index 00000000..58ac5859 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/memoji_6.imageset/Group 1171276317@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_6.imageset/Group 1171276317@3x.png b/IngrediCheck/Assets.xcassets/v2 image/memoji_6.imageset/Group 1171276317@3x.png new file mode 100644 index 00000000..18cab9a1 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/memoji_6.imageset/Group 1171276317@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_7.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/memoji_7.imageset/Contents.json new file mode 100644 index 00000000..fdbde9b4 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/memoji_7.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Group 1171276322.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Group 1171276322@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Group 1171276322@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_7.imageset/Group 1171276322.png b/IngrediCheck/Assets.xcassets/v2 image/memoji_7.imageset/Group 1171276322.png new file mode 100644 index 00000000..2ed779c2 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/memoji_7.imageset/Group 1171276322.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_7.imageset/Group 1171276322@2x.png b/IngrediCheck/Assets.xcassets/v2 image/memoji_7.imageset/Group 1171276322@2x.png new file mode 100644 index 00000000..2b1b74be Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/memoji_7.imageset/Group 1171276322@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_7.imageset/Group 1171276322@3x.png b/IngrediCheck/Assets.xcassets/v2 image/memoji_7.imageset/Group 1171276322@3x.png new file mode 100644 index 00000000..1fd10593 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/memoji_7.imageset/Group 1171276322@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_8.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/memoji_8.imageset/Contents.json new file mode 100644 index 00000000..afbb96b1 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/memoji_8.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Group 1171276321.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Group 1171276321@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Group 1171276321@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_8.imageset/Group 1171276321.png b/IngrediCheck/Assets.xcassets/v2 image/memoji_8.imageset/Group 1171276321.png new file mode 100644 index 00000000..f97a18a7 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/memoji_8.imageset/Group 1171276321.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_8.imageset/Group 1171276321@2x.png b/IngrediCheck/Assets.xcassets/v2 image/memoji_8.imageset/Group 1171276321@2x.png new file mode 100644 index 00000000..3af59040 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/memoji_8.imageset/Group 1171276321@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_8.imageset/Group 1171276321@3x.png b/IngrediCheck/Assets.xcassets/v2 image/memoji_8.imageset/Group 1171276321@3x.png new file mode 100644 index 00000000..8afee271 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/memoji_8.imageset/Group 1171276321@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_9.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/memoji_9.imageset/Contents.json new file mode 100644 index 00000000..91c46c39 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/memoji_9.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Group 1171276636.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Group 1171276636@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Group 1171276636@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_9.imageset/Group 1171276636.png b/IngrediCheck/Assets.xcassets/v2 image/memoji_9.imageset/Group 1171276636.png new file mode 100644 index 00000000..b673c446 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/memoji_9.imageset/Group 1171276636.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_9.imageset/Group 1171276636@2x.png b/IngrediCheck/Assets.xcassets/v2 image/memoji_9.imageset/Group 1171276636@2x.png new file mode 100644 index 00000000..b9217bf0 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/memoji_9.imageset/Group 1171276636@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/memoji_9.imageset/Group 1171276636@3x.png b/IngrediCheck/Assets.xcassets/v2 image/memoji_9.imageset/Group 1171276636@3x.png new file mode 100644 index 00000000..95bd6627 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/memoji_9.imageset/Group 1171276636@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/mingcute_alert-line.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/mingcute_alert-line.imageset/Contents.json new file mode 100644 index 00000000..93044cb4 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/mingcute_alert-line.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Group.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/mingcute_alert-line.imageset/Group.pdf b/IngrediCheck/Assets.xcassets/v2 image/mingcute_alert-line.imageset/Group.pdf new file mode 100644 index 00000000..ad5b9522 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/mingcute_alert-line.imageset/Group.pdf differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/nrk_globe.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/nrk_globe.imageset/Contents.json new file mode 100644 index 00000000..991ec632 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/nrk_globe.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Vector (1).pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/nrk_globe.imageset/Vector (1).pdf b/IngrediCheck/Assets.xcassets/v2 image/nrk_globe.imageset/Vector (1).pdf new file mode 100644 index 00000000..7cec12af Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/nrk_globe.imageset/Vector (1).pdf differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/nutrition-product.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/nutrition-product.imageset/Contents.json new file mode 100644 index 00000000..e433dd5b --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/nutrition-product.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Group 1171276639.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Group 1171276639@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Group 1171276639@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/nutrition-product.imageset/Group 1171276639.png b/IngrediCheck/Assets.xcassets/v2 image/nutrition-product.imageset/Group 1171276639.png new file mode 100644 index 00000000..c556dfde Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/nutrition-product.imageset/Group 1171276639.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/nutrition-product.imageset/Group 1171276639@2x.png b/IngrediCheck/Assets.xcassets/v2 image/nutrition-product.imageset/Group 1171276639@2x.png new file mode 100644 index 00000000..ee134cfd Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/nutrition-product.imageset/Group 1171276639@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/nutrition-product.imageset/Group 1171276639@3x.png b/IngrediCheck/Assets.xcassets/v2 image/nutrition-product.imageset/Group 1171276639@3x.png new file mode 100644 index 00000000..cfc5e5b6 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/nutrition-product.imageset/Group 1171276639@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/onbording-emptyimg1s.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/onbording-emptyimg1s.imageset/Contents.json new file mode 100644 index 00000000..8083f10a --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/onbording-emptyimg1s.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Screenshot 2025-12-17 at 2.16.44β€―PM 1.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Screenshot 2025-12-17 at 2.16.44β€―PM 1@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Screenshot 2025-12-17 at 2.16.44β€―PM 1@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git "a/IngrediCheck/Assets.xcassets/v2 image/onbording-emptyimg1s.imageset/Screenshot 2025-12-17 at 2.16.44\342\200\257PM 1.png" "b/IngrediCheck/Assets.xcassets/v2 image/onbording-emptyimg1s.imageset/Screenshot 2025-12-17 at 2.16.44\342\200\257PM 1.png" new file mode 100644 index 00000000..740407cb Binary files /dev/null and "b/IngrediCheck/Assets.xcassets/v2 image/onbording-emptyimg1s.imageset/Screenshot 2025-12-17 at 2.16.44\342\200\257PM 1.png" differ diff --git "a/IngrediCheck/Assets.xcassets/v2 image/onbording-emptyimg1s.imageset/Screenshot 2025-12-17 at 2.16.44\342\200\257PM 1@2x.png" "b/IngrediCheck/Assets.xcassets/v2 image/onbording-emptyimg1s.imageset/Screenshot 2025-12-17 at 2.16.44\342\200\257PM 1@2x.png" new file mode 100644 index 00000000..c34207e5 Binary files /dev/null and "b/IngrediCheck/Assets.xcassets/v2 image/onbording-emptyimg1s.imageset/Screenshot 2025-12-17 at 2.16.44\342\200\257PM 1@2x.png" differ diff --git "a/IngrediCheck/Assets.xcassets/v2 image/onbording-emptyimg1s.imageset/Screenshot 2025-12-17 at 2.16.44\342\200\257PM 1@3x.png" "b/IngrediCheck/Assets.xcassets/v2 image/onbording-emptyimg1s.imageset/Screenshot 2025-12-17 at 2.16.44\342\200\257PM 1@3x.png" new file mode 100644 index 00000000..98360667 Binary files /dev/null and "b/IngrediCheck/Assets.xcassets/v2 image/onbording-emptyimg1s.imageset/Screenshot 2025-12-17 at 2.16.44\342\200\257PM 1@3x.png" differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/orange.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/orange.imageset/Contents.json new file mode 100644 index 00000000..6fe70961 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/orange.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Group 1171276439.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Group 1171276439@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Group 1171276439@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/orange.imageset/Group 1171276439.png b/IngrediCheck/Assets.xcassets/v2 image/orange.imageset/Group 1171276439.png new file mode 100644 index 00000000..1017bf5f Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/orange.imageset/Group 1171276439.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/orange.imageset/Group 1171276439@2x.png b/IngrediCheck/Assets.xcassets/v2 image/orange.imageset/Group 1171276439@2x.png new file mode 100644 index 00000000..dda6f0c7 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/orange.imageset/Group 1171276439@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/orange.imageset/Group 1171276439@3x.png b/IngrediCheck/Assets.xcassets/v2 image/orange.imageset/Group 1171276439@3x.png new file mode 100644 index 00000000..3f1f9b1b Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/orange.imageset/Group 1171276439@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/pen-line.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/pen-line.imageset/Contents.json new file mode 100644 index 00000000..94cf1433 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/pen-line.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "pen-line.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "pen-line@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "pen-line@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/pen-line.imageset/pen-line.png b/IngrediCheck/Assets.xcassets/v2 image/pen-line.imageset/pen-line.png new file mode 100644 index 00000000..a9b8ee40 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/pen-line.imageset/pen-line.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/pen-line.imageset/pen-line@2x.png b/IngrediCheck/Assets.xcassets/v2 image/pen-line.imageset/pen-line@2x.png new file mode 100644 index 00000000..27bdb860 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/pen-line.imageset/pen-line@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/pen-line.imageset/pen-line@3x.png b/IngrediCheck/Assets.xcassets/v2 image/pen-line.imageset/pen-line@3x.png new file mode 100644 index 00000000..23f494de Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/pen-line.imageset/pen-line@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/ph.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/ph.imageset/Contents.json new file mode 100644 index 00000000..57abead5 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/ph.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "ph.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/ph.imageset/ph.png b/IngrediCheck/Assets.xcassets/v2 image/ph.imageset/ph.png new file mode 100644 index 00000000..39bceadd Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/ph.imageset/ph.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/pink-question-mark.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/pink-question-mark.imageset/Contents.json new file mode 100644 index 00000000..c599cc6a --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/pink-question-mark.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Group.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Group@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Group@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/pink-question-mark.imageset/Group.png b/IngrediCheck/Assets.xcassets/v2 image/pink-question-mark.imageset/Group.png new file mode 100644 index 00000000..2c94cd1b Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/pink-question-mark.imageset/Group.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/pink-question-mark.imageset/Group@2x.png b/IngrediCheck/Assets.xcassets/v2 image/pink-question-mark.imageset/Group@2x.png new file mode 100644 index 00000000..fb753864 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/pink-question-mark.imageset/Group@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/pink-question-mark.imageset/Group@3x.png b/IngrediCheck/Assets.xcassets/v2 image/pink-question-mark.imageset/Group@3x.png new file mode 100644 index 00000000..3178cae9 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/pink-question-mark.imageset/Group@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/pony-lady.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/pony-lady.imageset/Contents.json new file mode 100644 index 00000000..59bcd110 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/pony-lady.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "pony-lady.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/pony-lady.imageset/pony-lady.png b/IngrediCheck/Assets.xcassets/v2 image/pony-lady.imageset/pony-lady.png new file mode 100644 index 00000000..66c140d7 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/pony-lady.imageset/pony-lady.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/profile-ritika.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/profile-ritika.imageset/Contents.json new file mode 100644 index 00000000..c8f39403 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/profile-ritika.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "profile-ritika.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/profile-ritika.imageset/profile-ritika.png b/IngrediCheck/Assets.xcassets/v2 image/profile-ritika.imageset/profile-ritika.png new file mode 100644 index 00000000..64417d69 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/profile-ritika.imageset/profile-ritika.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/questionmark.circle.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/questionmark.circle.imageset/Contents.json new file mode 100644 index 00000000..77fc6180 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/questionmark.circle.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "questionmark.circle.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/questionmark.circle.imageset/questionmark.circle.png b/IngrediCheck/Assets.xcassets/v2 image/questionmark.circle.imageset/questionmark.circle.png new file mode 100644 index 00000000..c9595895 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/questionmark.circle.imageset/questionmark.circle.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/ram.imageset/1753588119577.jpeg b/IngrediCheck/Assets.xcassets/v2 image/ram.imageset/1753588119577.jpeg new file mode 100644 index 00000000..3d479c6e Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/ram.imageset/1753588119577.jpeg differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/ram.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/ram.imageset/Contents.json new file mode 100644 index 00000000..3fb6c144 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/ram.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "1753588119577.jpeg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/right-arrow-rounded-edge.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/right-arrow-rounded-edge.imageset/Contents.json new file mode 100644 index 00000000..a7e67757 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/right-arrow-rounded-edge.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Icon.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/right-arrow-rounded-edge.imageset/Icon.png b/IngrediCheck/Assets.xcassets/v2 image/right-arrow-rounded-edge.imageset/Icon.png new file mode 100644 index 00000000..15951ae0 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/right-arrow-rounded-edge.imageset/Icon.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/right-arrow-rounded-edge.imageset/Icon@2x.png b/IngrediCheck/Assets.xcassets/v2 image/right-arrow-rounded-edge.imageset/Icon@2x.png new file mode 100644 index 00000000..936d8f5d Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/right-arrow-rounded-edge.imageset/Icon@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/right-arrow-rounded-edge.imageset/Icon@3x.png b/IngrediCheck/Assets.xcassets/v2 image/right-arrow-rounded-edge.imageset/Icon@3x.png new file mode 100644 index 00000000..4cbd8af9 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/right-arrow-rounded-edge.imageset/Icon@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/rohank.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/rohank.imageset/Contents.json new file mode 100644 index 00000000..604dfba4 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/rohank.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "rohank.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/rohank.imageset/rohank.png b/IngrediCheck/Assets.xcassets/v2 image/rohank.imageset/rohank.png new file mode 100644 index 00000000..04e41f82 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/rohank.imageset/rohank.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/rotate.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/rotate.imageset/Contents.json new file mode 100644 index 00000000..37794a03 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/rotate.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "rotate.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/rotate.imageset/rotate.pdf b/IngrediCheck/Assets.xcassets/v2 image/rotate.imageset/rotate.pdf new file mode 100644 index 00000000..63597d56 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/rotate.imageset/rotate.pdf differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/safecircletick.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/safecircletick.imageset/Contents.json new file mode 100644 index 00000000..1646320e --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/safecircletick.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "safecircletick.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/safecircletick.imageset/safecircletick.pdf b/IngrediCheck/Assets.xcassets/v2 image/safecircletick.imageset/safecircletick.pdf new file mode 100644 index 00000000..078ba946 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/safecircletick.imageset/safecircletick.pdf differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/scan-card-jar.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/scan-card-jar.imageset/Contents.json new file mode 100644 index 00000000..16a215ac --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/scan-card-jar.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "scan-card-jar.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/scan-card-jar.imageset/scan-card-jar.pdf b/IngrediCheck/Assets.xcassets/v2 image/scan-card-jar.imageset/scan-card-jar.pdf new file mode 100644 index 00000000..6765c66e Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/scan-card-jar.imageset/scan-card-jar.pdf differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/share.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/share.imageset/Contents.json new file mode 100644 index 00000000..e1308a36 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/share.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "share.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "share@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "share@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/share.imageset/share.png b/IngrediCheck/Assets.xcassets/v2 image/share.imageset/share.png new file mode 100644 index 00000000..8a818f1c Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/share.imageset/share.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/share.imageset/share@2x.png b/IngrediCheck/Assets.xcassets/v2 image/share.imageset/share@2x.png new file mode 100644 index 00000000..b003281f Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/share.imageset/share@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/share.imageset/share@3x.png b/IngrediCheck/Assets.xcassets/v2 image/share.imageset/share@3x.png new file mode 100644 index 00000000..4a89b8e8 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/share.imageset/share@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/skin-tone-Primary.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/skin-tone-Primary.imageset/Contents.json new file mode 100644 index 00000000..84f8aebf --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/skin-tone-Primary.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Group 1171276302.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Group 1171276302@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Group 1171276302@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/skin-tone-Primary.imageset/Group 1171276302.png b/IngrediCheck/Assets.xcassets/v2 image/skin-tone-Primary.imageset/Group 1171276302.png new file mode 100644 index 00000000..99de3e0b Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/skin-tone-Primary.imageset/Group 1171276302.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/skin-tone-Primary.imageset/Group 1171276302@2x.png b/IngrediCheck/Assets.xcassets/v2 image/skin-tone-Primary.imageset/Group 1171276302@2x.png new file mode 100644 index 00000000..e8d284af Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/skin-tone-Primary.imageset/Group 1171276302@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/skin-tone-Primary.imageset/Group 1171276302@3x.png b/IngrediCheck/Assets.xcassets/v2 image/skin-tone-Primary.imageset/Group 1171276302@3x.png new file mode 100644 index 00000000..b7a5309c Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/skin-tone-Primary.imageset/Group 1171276302@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/skin-tone.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/skin-tone.imageset/Contents.json new file mode 100644 index 00000000..84f8aebf --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/skin-tone.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Group 1171276302.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Group 1171276302@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Group 1171276302@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/skin-tone.imageset/Group 1171276302.png b/IngrediCheck/Assets.xcassets/v2 image/skin-tone.imageset/Group 1171276302.png new file mode 100644 index 00000000..f91c0d5c Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/skin-tone.imageset/Group 1171276302.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/skin-tone.imageset/Group 1171276302@2x.png b/IngrediCheck/Assets.xcassets/v2 image/skin-tone.imageset/Group 1171276302@2x.png new file mode 100644 index 00000000..6e31bb63 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/skin-tone.imageset/Group 1171276302@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/skin-tone.imageset/Group 1171276302@3x.png b/IngrediCheck/Assets.xcassets/v2 image/skin-tone.imageset/Group 1171276302@3x.png new file mode 100644 index 00000000..1b6a17ce Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/skin-tone.imageset/Group 1171276302@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/star-rating.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/star-rating.imageset/Contents.json new file mode 100644 index 00000000..df240cf1 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/star-rating.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Vector.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Vector@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Vector@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/star-rating.imageset/Vector.png b/IngrediCheck/Assets.xcassets/v2 image/star-rating.imageset/Vector.png new file mode 100644 index 00000000..f3c3bd93 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/star-rating.imageset/Vector.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/star-rating.imageset/Vector@2x.png b/IngrediCheck/Assets.xcassets/v2 image/star-rating.imageset/Vector@2x.png new file mode 100644 index 00000000..2e8a0131 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/star-rating.imageset/Vector@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/star-rating.imageset/Vector@3x.png b/IngrediCheck/Assets.xcassets/v2 image/star-rating.imageset/Vector@3x.png new file mode 100644 index 00000000..d7fbbf27 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/star-rating.imageset/Vector@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/stars-generate.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/stars-generate.imageset/Contents.json new file mode 100644 index 00000000..ef05d5cf --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/stars-generate.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "lucide_stars.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "lucide_stars@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "lucide_stars@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/stars-generate.imageset/lucide_stars.png b/IngrediCheck/Assets.xcassets/v2 image/stars-generate.imageset/lucide_stars.png new file mode 100644 index 00000000..6011148d Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/stars-generate.imageset/lucide_stars.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/stars-generate.imageset/lucide_stars@2x.png b/IngrediCheck/Assets.xcassets/v2 image/stars-generate.imageset/lucide_stars@2x.png new file mode 100644 index 00000000..47883db9 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/stars-generate.imageset/lucide_stars@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/stars-generate.imageset/lucide_stars@3x.png b/IngrediCheck/Assets.xcassets/v2 image/stars-generate.imageset/lucide_stars@3x.png new file mode 100644 index 00000000..c315f418 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/stars-generate.imageset/lucide_stars@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/streamline_recycle-1-solid.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/streamline_recycle-1-solid.imageset/Contents.json new file mode 100644 index 00000000..42592fa7 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/streamline_recycle-1-solid.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Vector (3).pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/streamline_recycle-1-solid.imageset/Vector (3).pdf b/IngrediCheck/Assets.xcassets/v2 image/streamline_recycle-1-solid.imageset/Vector (3).pdf new file mode 100644 index 00000000..ed7eed5c Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/streamline_recycle-1-solid.imageset/Vector (3).pdf differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/swipe-hand.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/swipe-hand.imageset/Contents.json new file mode 100644 index 00000000..e1154ead --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/swipe-hand.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "hugeicons_drag-02.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "hugeicons_drag-02@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "hugeicons_drag-02@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/swipe-hand.imageset/hugeicons_drag-02.png b/IngrediCheck/Assets.xcassets/v2 image/swipe-hand.imageset/hugeicons_drag-02.png new file mode 100644 index 00000000..11478451 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/swipe-hand.imageset/hugeicons_drag-02.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/swipe-hand.imageset/hugeicons_drag-02@2x.png b/IngrediCheck/Assets.xcassets/v2 image/swipe-hand.imageset/hugeicons_drag-02@2x.png new file mode 100644 index 00000000..e769a942 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/swipe-hand.imageset/hugeicons_drag-02@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/swipe-hand.imageset/hugeicons_drag-02@3x.png b/IngrediCheck/Assets.xcassets/v2 image/swipe-hand.imageset/hugeicons_drag-02@3x.png new file mode 100644 index 00000000..6103aba7 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/swipe-hand.imageset/hugeicons_drag-02@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/systemuiconscapture.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/systemuiconscapture.imageset/Contents.json new file mode 100644 index 00000000..87c246d7 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/systemuiconscapture.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "system-uicons_capture.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "system-uicons_capture@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "system-uicons_capture@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/systemuiconscapture.imageset/system-uicons_capture.png b/IngrediCheck/Assets.xcassets/v2 image/systemuiconscapture.imageset/system-uicons_capture.png new file mode 100644 index 00000000..f475f181 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/systemuiconscapture.imageset/system-uicons_capture.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/systemuiconscapture.imageset/system-uicons_capture@2x.png b/IngrediCheck/Assets.xcassets/v2 image/systemuiconscapture.imageset/system-uicons_capture@2x.png new file mode 100644 index 00000000..0552a7cd Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/systemuiconscapture.imageset/system-uicons_capture@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/systemuiconscapture.imageset/system-uicons_capture@3x.png b/IngrediCheck/Assets.xcassets/v2 image/systemuiconscapture.imageset/system-uicons_capture@3x.png new file mode 100644 index 00000000..f67f5853 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/systemuiconscapture.imageset/system-uicons_capture@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/tabBar-history.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/tabBar-history.imageset/Contents.json new file mode 100644 index 00000000..370f679f --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/tabBar-history.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "proicons_history.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "proicons_history@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "proicons_history@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/tabBar-history.imageset/proicons_history.png b/IngrediCheck/Assets.xcassets/v2 image/tabBar-history.imageset/proicons_history.png new file mode 100644 index 00000000..c79ca0b1 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/tabBar-history.imageset/proicons_history.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/tabBar-history.imageset/proicons_history@2x.png b/IngrediCheck/Assets.xcassets/v2 image/tabBar-history.imageset/proicons_history@2x.png new file mode 100644 index 00000000..fac4ad9c Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/tabBar-history.imageset/proicons_history@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/tabBar-history.imageset/proicons_history@3x.png b/IngrediCheck/Assets.xcassets/v2 image/tabBar-history.imageset/proicons_history@3x.png new file mode 100644 index 00000000..bd7a1ca0 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/tabBar-history.imageset/proicons_history@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/tabBar-ingredibot.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/tabBar-ingredibot.imageset/Contents.json new file mode 100644 index 00000000..4e62b245 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/tabBar-ingredibot.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Frame 1171276503.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Frame 1171276503@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Frame 1171276503@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/tabBar-ingredibot.imageset/Frame 1171276503.png b/IngrediCheck/Assets.xcassets/v2 image/tabBar-ingredibot.imageset/Frame 1171276503.png new file mode 100644 index 00000000..12de4932 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/tabBar-ingredibot.imageset/Frame 1171276503.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/tabBar-ingredibot.imageset/Frame 1171276503@2x.png b/IngrediCheck/Assets.xcassets/v2 image/tabBar-ingredibot.imageset/Frame 1171276503@2x.png new file mode 100644 index 00000000..85ad1fa7 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/tabBar-ingredibot.imageset/Frame 1171276503@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/tabBar-ingredibot.imageset/Frame 1171276503@3x.png b/IngrediCheck/Assets.xcassets/v2 image/tabBar-ingredibot.imageset/Frame 1171276503@3x.png new file mode 100644 index 00000000..4f168808 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/tabBar-ingredibot.imageset/Frame 1171276503@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/tabBar-scanner.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/tabBar-scanner.imageset/Contents.json new file mode 100644 index 00000000..913abfdb --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/tabBar-scanner.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "iconoir_scan-barcode.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "iconoir_scan-barcode@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "iconoir_scan-barcode@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/tabBar-scanner.imageset/iconoir_scan-barcode.png b/IngrediCheck/Assets.xcassets/v2 image/tabBar-scanner.imageset/iconoir_scan-barcode.png new file mode 100644 index 00000000..b8450339 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/tabBar-scanner.imageset/iconoir_scan-barcode.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/tabBar-scanner.imageset/iconoir_scan-barcode@2x.png b/IngrediCheck/Assets.xcassets/v2 image/tabBar-scanner.imageset/iconoir_scan-barcode@2x.png new file mode 100644 index 00000000..9f800f6e Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/tabBar-scanner.imageset/iconoir_scan-barcode@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/tabBar-scanner.imageset/iconoir_scan-barcode@3x.png b/IngrediCheck/Assets.xcassets/v2 image/tabBar-scanner.imageset/iconoir_scan-barcode@3x.png new file mode 100644 index 00000000..ea3cde3c Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/tabBar-scanner.imageset/iconoir_scan-barcode@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/tabler_plus.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/tabler_plus.imageset/Contents.json new file mode 100644 index 00000000..7c9ab115 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/tabler_plus.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "tabler_plus.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "tabler_plus@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "tabler_plus@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/tabler_plus.imageset/tabler_plus.png b/IngrediCheck/Assets.xcassets/v2 image/tabler_plus.imageset/tabler_plus.png new file mode 100644 index 00000000..d3f9fb8c Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/tabler_plus.imageset/tabler_plus.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/tabler_plus.imageset/tabler_plus@2x.png b/IngrediCheck/Assets.xcassets/v2 image/tabler_plus.imageset/tabler_plus@2x.png new file mode 100644 index 00000000..2741c36e Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/tabler_plus.imageset/tabler_plus@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/tabler_plus.imageset/tabler_plus@3x.png b/IngrediCheck/Assets.xcassets/v2 image/tabler_plus.imageset/tabler_plus@3x.png new file mode 100644 index 00000000..1f3ce521 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/tabler_plus.imageset/tabler_plus@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/takeawafood.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/takeawafood.imageset/Contents.json new file mode 100644 index 00000000..d47d6717 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/takeawafood.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "take away food.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "take away food@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "take away food@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/takeawafood.imageset/take away food.png b/IngrediCheck/Assets.xcassets/v2 image/takeawafood.imageset/take away food.png new file mode 100644 index 00000000..fc0175c4 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/takeawafood.imageset/take away food.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/takeawafood.imageset/take away food@2x.png b/IngrediCheck/Assets.xcassets/v2 image/takeawafood.imageset/take away food@2x.png new file mode 100644 index 00000000..93d6ed8a Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/takeawafood.imageset/take away food@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/takeawafood.imageset/take away food@3x.png b/IngrediCheck/Assets.xcassets/v2 image/takeawafood.imageset/take away food@3x.png new file mode 100644 index 00000000..cfb2f517 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/takeawafood.imageset/take away food@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/thumbsdown.fill.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/thumbsdown.fill.imageset/Contents.json new file mode 100644 index 00000000..1ef6bfc4 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/thumbsdown.fill.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "thumbsdown.fill.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/thumbsdown.fill.imageset/thumbsdown.fill.pdf b/IngrediCheck/Assets.xcassets/v2 image/thumbsdown.fill.imageset/thumbsdown.fill.pdf new file mode 100644 index 00000000..59299012 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/thumbsdown.fill.imageset/thumbsdown.fill.pdf differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/thumbsdown.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/thumbsdown.imageset/Contents.json new file mode 100644 index 00000000..07f0898a --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/thumbsdown.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "dislike-1.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "dislike@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "dislike@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/thumbsdown.imageset/dislike-1.png b/IngrediCheck/Assets.xcassets/v2 image/thumbsdown.imageset/dislike-1.png new file mode 100644 index 00000000..aa7dec8b Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/thumbsdown.imageset/dislike-1.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/thumbsdown.imageset/dislike@2x.png b/IngrediCheck/Assets.xcassets/v2 image/thumbsdown.imageset/dislike@2x.png new file mode 100644 index 00000000..31f0d6db Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/thumbsdown.imageset/dislike@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/thumbsdown.imageset/dislike@3x.png b/IngrediCheck/Assets.xcassets/v2 image/thumbsdown.imageset/dislike@3x.png new file mode 100644 index 00000000..4055bbcb Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/thumbsdown.imageset/dislike@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/thumbsup.fill.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/thumbsup.fill.imageset/Contents.json new file mode 100644 index 00000000..b5d55910 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/thumbsup.fill.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "thumbsup.fill.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/thumbsup.fill.imageset/thumbsup.fill.pdf b/IngrediCheck/Assets.xcassets/v2 image/thumbsup.fill.imageset/thumbsup.fill.pdf new file mode 100644 index 00000000..6186526f Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/thumbsup.fill.imageset/thumbsup.fill.pdf differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/thumbsup.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/thumbsup.imageset/Contents.json new file mode 100644 index 00000000..c8a33e85 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/thumbsup.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "like.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "like@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "like@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/thumbsup.imageset/like.png b/IngrediCheck/Assets.xcassets/v2 image/thumbsup.imageset/like.png new file mode 100644 index 00000000..e67c2567 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/thumbsup.imageset/like.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/thumbsup.imageset/like@2x.png b/IngrediCheck/Assets.xcassets/v2 image/thumbsup.imageset/like@2x.png new file mode 100644 index 00000000..4ff0673e Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/thumbsup.imageset/like@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/thumbsup.imageset/like@3x.png b/IngrediCheck/Assets.xcassets/v2 image/thumbsup.imageset/like@3x.png new file mode 100644 index 00000000..fdaecbf0 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/thumbsup.imageset/like@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/time.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/time.imageset/Contents.json new file mode 100644 index 00000000..28fb8261 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/time.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "time.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/time.imageset/time.pdf b/IngrediCheck/Assets.xcassets/v2 image/time.imageset/time.pdf new file mode 100644 index 00000000..2fafbd41 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/time.imageset/time.pdf differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/trans_mockup.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/trans_mockup.imageset/Contents.json new file mode 100644 index 00000000..53722cbd --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/trans_mockup.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Group 1171276607.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Group 1171276607@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Group 1171276607@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/trans_mockup.imageset/Group 1171276607.png b/IngrediCheck/Assets.xcassets/v2 image/trans_mockup.imageset/Group 1171276607.png new file mode 100644 index 00000000..3baf9232 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/trans_mockup.imageset/Group 1171276607.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/trans_mockup.imageset/Group 1171276607@2x.png b/IngrediCheck/Assets.xcassets/v2 image/trans_mockup.imageset/Group 1171276607@2x.png new file mode 100644 index 00000000..6d69f62d Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/trans_mockup.imageset/Group 1171276607@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/trans_mockup.imageset/Group 1171276607@3x.png b/IngrediCheck/Assets.xcassets/v2 image/trans_mockup.imageset/Group 1171276607@3x.png new file mode 100644 index 00000000..e3f9a83c Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/trans_mockup.imageset/Group 1171276607@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/unsafe.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/unsafe.imageset/Contents.json new file mode 100644 index 00000000..c6f97a05 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/unsafe.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Vector.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/unsafe.imageset/Vector.pdf b/IngrediCheck/Assets.xcassets/v2 image/unsafe.imageset/Vector.pdf new file mode 100644 index 00000000..0b5f26a8 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/unsafe.imageset/Vector.pdf differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/up-trend.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/up-trend.imageset/Contents.json new file mode 100644 index 00000000..1eda38e6 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/up-trend.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "up-trend-round_svgrepo.com.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "up-trend-round_svgrepo.com@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "up-trend-round_svgrepo.com@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/up-trend.imageset/up-trend-round_svgrepo.com.png b/IngrediCheck/Assets.xcassets/v2 image/up-trend.imageset/up-trend-round_svgrepo.com.png new file mode 100644 index 00000000..006a060a Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/up-trend.imageset/up-trend-round_svgrepo.com.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/up-trend.imageset/up-trend-round_svgrepo.com@2x.png b/IngrediCheck/Assets.xcassets/v2 image/up-trend.imageset/up-trend-round_svgrepo.com@2x.png new file mode 100644 index 00000000..1a409398 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/up-trend.imageset/up-trend-round_svgrepo.com@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/up-trend.imageset/up-trend-round_svgrepo.com@3x.png b/IngrediCheck/Assets.xcassets/v2 image/up-trend.imageset/up-trend-round_svgrepo.com@3x.png new file mode 100644 index 00000000..769dcc95 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/up-trend.imageset/up-trend-round_svgrepo.com@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/weight-machine.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/weight-machine.imageset/Contents.json new file mode 100644 index 00000000..1b20049d --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/weight-machine.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "βš–οΈ.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "βš–οΈ@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "βš–οΈ@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git "a/IngrediCheck/Assets.xcassets/v2 image/weight-machine.imageset/\342\232\226\357\270\217.png" "b/IngrediCheck/Assets.xcassets/v2 image/weight-machine.imageset/\342\232\226\357\270\217.png" new file mode 100644 index 00000000..09a46c7a Binary files /dev/null and "b/IngrediCheck/Assets.xcassets/v2 image/weight-machine.imageset/\342\232\226\357\270\217.png" differ diff --git "a/IngrediCheck/Assets.xcassets/v2 image/weight-machine.imageset/\342\232\226\357\270\217@2x.png" "b/IngrediCheck/Assets.xcassets/v2 image/weight-machine.imageset/\342\232\226\357\270\217@2x.png" new file mode 100644 index 00000000..48c36b85 Binary files /dev/null and "b/IngrediCheck/Assets.xcassets/v2 image/weight-machine.imageset/\342\232\226\357\270\217@2x.png" differ diff --git "a/IngrediCheck/Assets.xcassets/v2 image/weight-machine.imageset/\342\232\226\357\270\217@3x.png" "b/IngrediCheck/Assets.xcassets/v2 image/weight-machine.imageset/\342\232\226\357\270\217@3x.png" new file mode 100644 index 00000000..0b7561be Binary files /dev/null and "b/IngrediCheck/Assets.xcassets/v2 image/weight-machine.imageset/\342\232\226\357\270\217@3x.png" differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/white-rounded-checkmark.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/white-rounded-checkmark.imageset/Contents.json new file mode 100644 index 00000000..c34ba9d9 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/white-rounded-checkmark.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Union.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Union@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Union@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/white-rounded-checkmark.imageset/Union.png b/IngrediCheck/Assets.xcassets/v2 image/white-rounded-checkmark.imageset/Union.png new file mode 100644 index 00000000..b8610e64 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/white-rounded-checkmark.imageset/Union.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/white-rounded-checkmark.imageset/Union@2x.png b/IngrediCheck/Assets.xcassets/v2 image/white-rounded-checkmark.imageset/Union@2x.png new file mode 100644 index 00000000..99fa425f Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/white-rounded-checkmark.imageset/Union@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/white-rounded-checkmark.imageset/Union@3x.png b/IngrediCheck/Assets.xcassets/v2 image/white-rounded-checkmark.imageset/Union@3x.png new file mode 100644 index 00000000..1ed88d65 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/white-rounded-checkmark.imageset/Union@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/xmark.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/xmark.imageset/Contents.json new file mode 100644 index 00000000..41b779d8 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/xmark.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "xmark.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/xmark.imageset/xmark.pdf b/IngrediCheck/Assets.xcassets/v2 image/xmark.imageset/xmark.pdf new file mode 100644 index 00000000..901dec4c Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/xmark.imageset/xmark.pdf differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/yellow-bulb.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/yellow-bulb.imageset/Contents.json new file mode 100644 index 00000000..c599cc6a --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/yellow-bulb.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Group.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Group@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Group@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/yellow-bulb.imageset/Group.png b/IngrediCheck/Assets.xcassets/v2 image/yellow-bulb.imageset/Group.png new file mode 100644 index 00000000..de4ffda0 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/yellow-bulb.imageset/Group.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/yellow-bulb.imageset/Group@2x.png b/IngrediCheck/Assets.xcassets/v2 image/yellow-bulb.imageset/Group@2x.png new file mode 100644 index 00000000..3a5f50e8 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/yellow-bulb.imageset/Group@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/yellow-bulb.imageset/Group@3x.png b/IngrediCheck/Assets.xcassets/v2 image/yellow-bulb.imageset/Group@3x.png new file mode 100644 index 00000000..2bdd36b8 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/yellow-bulb.imageset/Group@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/yellow-star.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/yellow-star.imageset/Contents.json new file mode 100644 index 00000000..df240cf1 --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/yellow-star.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Vector.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Vector@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Vector@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/yellow-star.imageset/Vector.png b/IngrediCheck/Assets.xcassets/v2 image/yellow-star.imageset/Vector.png new file mode 100644 index 00000000..9726ff58 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/yellow-star.imageset/Vector.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/yellow-star.imageset/Vector@2x.png b/IngrediCheck/Assets.xcassets/v2 image/yellow-star.imageset/Vector@2x.png new file mode 100644 index 00000000..3669a5c8 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/yellow-star.imageset/Vector@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/yellow-star.imageset/Vector@3x.png b/IngrediCheck/Assets.xcassets/v2 image/yellow-star.imageset/Vector@3x.png new file mode 100644 index 00000000..c47d4a36 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/yellow-star.imageset/Vector@3x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/your-barcode-scan.imageset/Contents.json b/IngrediCheck/Assets.xcassets/v2 image/your-barcode-scan.imageset/Contents.json new file mode 100644 index 00000000..ce10501b --- /dev/null +++ b/IngrediCheck/Assets.xcassets/v2 image/your-barcode-scan.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "scan_svgrepo.com.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "scan_svgrepo.com@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "scan_svgrepo.com@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IngrediCheck/Assets.xcassets/v2 image/your-barcode-scan.imageset/scan_svgrepo.com.png b/IngrediCheck/Assets.xcassets/v2 image/your-barcode-scan.imageset/scan_svgrepo.com.png new file mode 100644 index 00000000..32390265 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/your-barcode-scan.imageset/scan_svgrepo.com.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/your-barcode-scan.imageset/scan_svgrepo.com@2x.png b/IngrediCheck/Assets.xcassets/v2 image/your-barcode-scan.imageset/scan_svgrepo.com@2x.png new file mode 100644 index 00000000..b06284ae Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/your-barcode-scan.imageset/scan_svgrepo.com@2x.png differ diff --git a/IngrediCheck/Assets.xcassets/v2 image/your-barcode-scan.imageset/scan_svgrepo.com@3x.png b/IngrediCheck/Assets.xcassets/v2 image/your-barcode-scan.imageset/scan_svgrepo.com@3x.png new file mode 100644 index 00000000..10a8bc98 Binary files /dev/null and b/IngrediCheck/Assets.xcassets/v2 image/your-barcode-scan.imageset/scan_svgrepo.com@3x.png differ diff --git a/IngrediCheck/Components/AIBotFAB.swift b/IngrediCheck/Components/AIBotFAB.swift new file mode 100644 index 00000000..8fb6ab0d --- /dev/null +++ b/IngrediCheck/Components/AIBotFAB.swift @@ -0,0 +1,102 @@ +// +// AIBotFAB.swift +// IngrediCheck +// +// Floating Action Button for AIBot access with optional feedback prompt bubble. +// + +import SwiftUI + +struct AIBotFAB: View { + let onTap: () -> Void + var showPromptBubble: Bool = false + var onPromptTap: (() -> Void)? = nil + var onPromptDismiss: (() -> Void)? = nil + + var body: some View { + HStack { + + Spacer() + // Prompt bubble (appears above/left of FAB) + if showPromptBubble { + FeedbackPromptBubble( + onTap: { onPromptTap?() }, + onDismiss: { onPromptDismiss?() } + ) + .transition(.scale(scale: 0.8, anchor: .bottomTrailing).combined(with: .opacity)) + .offset(y: -40) + } + + // FAB button + Button(action: onTap) { + Image("aibot") + .resizable() + .scaledToFit() + .frame(width: 56, height: 56) + .shadow(color: Color.black.opacity(0.15), radius: 8, x: 0, y: 4) + } + .buttonStyle(PlainButtonStyle()) + } + } +} + +struct FeedbackPromptBubble: View { + let onTap: () -> Void + let onDismiss: () -> Void + + var body: some View { + HStack(alignment: .top, spacing: 8) { + Text("What didn't go well?\nPlease explain.") + .font(NunitoFont.semiBold.size(15)) + .lineSpacing(5) + .foregroundStyle(.white) + .multilineTextAlignment(.leading) + .lineLimit(2) + + Button(action: onDismiss) { + ZStack { + Circle() + .fill(Color.white.opacity(0.2)) + .frame(width: 24, height: 24) + Image(systemName: "xmark") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.white) + } + } + } + .padding(.leading, 16) + .padding(.trailing, 12) + .padding(.vertical, 12) + .background( + UnevenRoundedRectangle( + topLeadingRadius: 18, + bottomLeadingRadius: 18, + bottomTrailingRadius: 6, + topTrailingRadius: 18 + ) + .fill(Color.primary800) + .shadow(color: Color.black.opacity(0.23), radius: 9, x: 0, y: 4) + ) + .onTapGesture { + onTap() + } + } +} + +#Preview { + VStack(alignment: .trailing) { + Spacer() + HStack { + Spacer() + AIBotFAB( + onTap: {}, + showPromptBubble: true, + onPromptTap: {}, + onPromptDismiss: {} + ) + .padding(.trailing, 20) +// .padding(.bottom, 100) + } + } + .background(Color.gray.opacity(0.2)) +} diff --git a/IngrediCheck/Components/AllergySummaryCard.swift b/IngrediCheck/Components/AllergySummaryCard.swift new file mode 100644 index 00000000..276c5002 --- /dev/null +++ b/IngrediCheck/Components/AllergySummaryCard.swift @@ -0,0 +1,600 @@ +// +// AllergySummaryCard.swift +// IngrediCheckPreview +// +// Created by Gunjan Haldar on 10/11/25. +// + +import SwiftUI +import UIKit + +// MARK: - Exclusion Multi-Color Text (UIKit-based for text wrapping around cutout) + +/// A UIViewRepresentable that displays multi-color text with an exclusion path +/// for the bottom-right corner cutout in AllergySummaryCard +struct ExclusionMultiColorText: UIViewRepresentable { + let text: String + var delimiter: Character = "*" + var font: UIFont = UIFont(name: "Manrope-Bold", size: 14) ?? .boldSystemFont(ofSize: 14) + var containerSize: CGSize = .zero + var exclusionRect: CGRect = .zero + + func makeUIView(context: Context) -> UITextView { + let textView = UITextView() + textView.backgroundColor = .clear + textView.isEditable = false + textView.isSelectable = false + textView.isScrollEnabled = false + textView.textContainerInset = .zero + textView.textContainer.lineFragmentPadding = 0 + textView.textContainer.lineBreakMode = .byWordWrapping + textView.textContainer.maximumNumberOfLines = 0 + textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + textView.setContentHuggingPriority(.defaultLow, for: .vertical) + return textView + } + + func updateUIView(_ textView: UITextView, context: Context) { + // Set the text container size to match available space + if containerSize != .zero { + textView.textContainer.size = containerSize + } + + // Build attributed string with multi-color support + textView.attributedText = buildAttributedString() + + // Set exclusion path for bottom-right corner + if exclusionRect != .zero && exclusionRect.origin.y > 0 { + let exclusionPath = UIBezierPath(rect: exclusionRect) + textView.textContainer.exclusionPaths = [exclusionPath] + } else { + textView.textContainer.exclusionPaths = [] + } + + // Force layout update + textView.layoutIfNeeded() + } + + private func buildAttributedString() -> NSAttributedString { + let result = NSMutableAttributedString() + let components = text.components(separatedBy: String(delimiter)) + + // Paragraph style for proper word wrapping + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineBreakMode = .byWordWrapping + paragraphStyle.lineBreakStrategy = .standard + + // Colors matching MultiColorText + let defaultColor = UIColor(named: "grayScale140") ?? UIColor(red: 0.13, green: 0.15, blue: 0.17, alpha: 1.0) + let highlightColor = UIColor(named: "grayScale90") ?? UIColor(red: 0.48, green: 0.51, blue: 0.53, alpha: 1.0) + + for (index, part) in components.enumerated() { + let color = index % 2 == 0 ? defaultColor : highlightColor + let attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: color, + .paragraphStyle: paragraphStyle + ] + result.append(NSAttributedString(string: part, attributes: attributes)) + } + + return result + } +} + +// MARK: - Exclusion Markdown Text (UIKit-based for markdown **bold** support) + +/// A UIViewRepresentable that displays markdown text with **bold** formatting +/// and an exclusion path for the bottom-right corner cutout +struct ExclusionMarkdownText: UIViewRepresentable { + let text: String + var font: UIFont = UIFont(name: "Manrope-Regular", size: 14) ?? .systemFont(ofSize: 14) + var containerSize: CGSize = .zero + var exclusionRect: CGRect = .zero + + func makeUIView(context: Context) -> UITextView { + let textView = UITextView() + textView.backgroundColor = .clear + textView.isEditable = false + textView.isSelectable = false + textView.isScrollEnabled = false + textView.textContainerInset = .zero + textView.textContainer.lineFragmentPadding = 0 + textView.textContainer.lineBreakMode = .byWordWrapping + textView.textContainer.maximumNumberOfLines = 0 + textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + textView.setContentHuggingPriority(.defaultLow, for: .vertical) + return textView + } + + func updateUIView(_ textView: UITextView, context: Context) { + // Set the text container size to match available space + if containerSize != .zero { + textView.textContainer.size = containerSize + } + + // Build attributed string with markdown support + textView.attributedText = buildMarkdownAttributedString() + + // Set exclusion path for bottom-right corner + if exclusionRect != .zero && exclusionRect.origin.y > 0 { + let exclusionPath = UIBezierPath(rect: exclusionRect) + textView.textContainer.exclusionPaths = [exclusionPath] + } else { + textView.textContainer.exclusionPaths = [] + } + + // Force layout update + textView.layoutIfNeeded() + } + + private func buildMarkdownAttributedString() -> NSAttributedString { + let result = NSMutableAttributedString() + + // Paragraph style for proper word wrapping + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineBreakMode = .byWordWrapping + paragraphStyle.lineBreakStrategy = .standard + + let textColor = UIColor(named: "grayScale140") ?? UIColor(red: 0.13, green: 0.15, blue: 0.17, alpha: 1.0) + + // Parse **bold** markdown syntax + let pattern = "\\*\\*(.+?)\\*\\*" + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { + // If regex fails, return plain text + let attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: textColor, + .paragraphStyle: paragraphStyle + ] + return NSAttributedString(string: text, attributes: attributes) + } + + var currentIndex = text.startIndex + let range = NSRange(text.startIndex..., in: text) + let matches = regex.matches(in: text, options: [], range: range) + + // Create bold font variant + let boldFont: UIFont + if let fontDescriptor = font.fontDescriptor.withSymbolicTraits(.traitBold) { + boldFont = UIFont(descriptor: fontDescriptor, size: font.pointSize) + } else { + // Fallback to Manrope-Bold or system bold + boldFont = UIFont(name: "Manrope-Bold", size: font.pointSize) ?? .boldSystemFont(ofSize: font.pointSize) + } + + for match in matches { + // Add text before this match (regular font) + if let matchRange = Range(match.range, in: text), matchRange.lowerBound > currentIndex { + let beforeText = String(text[currentIndex.. [String: String] { + var mapping: [String: String] = [:] + + for step in steps { + // Type-1: options array + if let options = step.content.options { + for option in options { + let normalizedName = option.name.lowercased() + if !option.icon.isEmpty && option.icon != "✏️" && option.icon != "✏" { + mapping[normalizedName] = option.icon + } + } + } + + // Type-2: subSteps with options + if let subSteps = step.content.subSteps { + for subStep in subSteps { + if let options = subStep.options { + for option in options { + let normalizedName = option.name.lowercased() + if !option.icon.isEmpty && option.icon != "✏️" && option.icon != "✏" { + mapping[normalizedName] = option.icon + } + } + } + } + } + + // Type-3: regions with subRegions + if let regions = step.content.regions { + for region in regions { + for subRegion in region.subRegions { + let normalizedName = subRegion.name.lowercased() + if !subRegion.icon.isEmpty && subRegion.icon != "✏️" && subRegion.icon != "✏" { + mapping[normalizedName] = subRegion.icon + } + } + } + } + } + + // Add some common short aliases/variations + if let peanuts = mapping["peanuts"] { mapping["peanut"] = peanuts } + if let treeNuts = mapping["tree nuts"] { mapping["nuts"] = treeNuts } + if let shellfish = mapping["shellfish"] { mapping["crab"] = "πŸ¦€"; mapping["shrimp"] = "🦐"; mapping["lobster"] = "🦞" } + if let fish = mapping["fish"] { mapping["seafood"] = fish } + if let eggs = mapping["eggs"] { mapping["egg"] = eggs } + if let dairy = mapping["dairy"] { mapping["milk"] = dairy; mapping["cheese"] = "πŸ§€" } + if let wheat = mapping["wheat"] { mapping["gluten"] = wheat; mapping["bread"] = "🍞" } + + // Add common items that might appear in summaries + mapping["red meat"] = "πŸ₯©" + mapping["meat"] = "πŸ₯©" + mapping["chicken"] = "πŸ—" + mapping["poultry"] = "πŸ—" + mapping["soda"] = "πŸ₯€" + mapping["sugar"] = "🍬" + mapping["salt"] = "πŸ§‚" + mapping["fried food"] = "🍟" + mapping["fried foods"] = "🍟" + mapping["fast food"] = "πŸ”" + mapping["processed food"] = "🏭" + mapping["processed foods"] = "🏭" + + return mapping + } + + /// Inject emojis into summary text next to matching food names + static func injectEmojis(in text: String, using mapping: [String: String]) -> String { + var result = text + + // Sort by length descending to match longer phrases first + let sortedNames = mapping.keys.sorted { $0.count > $1.count } + + for name in sortedNames { + guard let emoji = mapping[name] else { continue } + + // Create a case-insensitive regex pattern for the food name + // Match whole words only (with word boundaries) + let pattern = "\\b\(NSRegularExpression.escapedPattern(for: name))\\b" + + if let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) { + let range = NSRange(result.startIndex..., in: result) + + // Find all matches and replace from end to start to preserve indices + let matches = regex.matches(in: result, options: [], range: range) + + for match in matches.reversed() { + if let swiftRange = Range(match.range, in: result) { + let matchedText = String(result[swiftRange]) + // Only add emoji if not already preceded by an emoji + let beforeIndex = swiftRange.lowerBound + let hasEmojiBefore: Bool = { + guard beforeIndex > result.startIndex else { return false } + let prevIndex = result.index(before: beforeIndex) + return result[prevIndex].unicodeScalars.first?.properties.isEmoji == true + }() + + if !hasEmojiBefore { + result.replaceSubrange(swiftRange, with: "\(emoji) \(matchedText)") + } + } + } + } + } + + return result + } +} + +// MARK: - AI Summary Card (for UnifiedCanvasView) + +/// Full-width summary card shown at top of Food Notes editing view +struct AISummaryCard: View { + let summary: String + var dynamicSteps: [DynamicStep] = [] + + /// Summary text with emoji icons injected (server sends **bold** markdown) + private var summaryWithEmojis: String { + let mapping = FoodEmojiMapper.buildMapping(from: dynamicSteps) + return FoodEmojiMapper.injectEmojis(in: summary, using: mapping) + } + + /// Parses the summary text as Markdown and returns an AttributedString + private var markdownSummary: AttributedString { + do { + return try AttributedString( + markdown: summaryWithEmojis, + options: AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace) + ) + } catch { + return AttributedString(summaryWithEmojis) + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + // "Summarized with AI" badge + HStack(spacing: 6) { + Image(systemName: "sparkles") + .font(.system(size: 12)) + .foregroundStyle(Color(.pink)) + Text("Summarized with AI") + .font(ManropeFont.medium.size(12)) + } + .foregroundStyle( + LinearGradient( + stops: [ + .init(color: Color(hex: "#FB4889"), location: 0), + .init(color: Color(hex: "#9A64D4"), location: 0.5048), + .init(color: Color(hex: "#0B77FF"), location: 1.0) + ], + startPoint: .leading, + endPoint: .trailing + ) + ) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + Capsule() + .fill( + LinearGradient( + stops: [ + .init(color: Color(hex: "#FEF2F2"), location: 0), + .init(color: Color(hex: "#F9EDF9"), location: 0.5048), + .init(color: Color(hex: "#EBF3FE"), location: 1.0) + ], + startPoint: .leading, + endPoint: .trailing + ) + ) + ) + + // Summary text with markdown formatting (server sends **bold**) + Text(markdownSummary) + .font(ManropeFont.regular.size(16)) + .foregroundStyle(.grayScale140) + .fixedSize(horizontal: false, vertical: true) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(Color.white) + ) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(Color(hex: "#EEEEEE"), lineWidth: 0.5) + ) + .shadow(color: Color(hex: "#ECECEC"), radius: 8) + } +} + +struct MyIcon: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + let width = rect.size.width + let height = rect.size.height + path.move(to: CGPoint(x: 0.13143*width, y: height)) + path.addCurve(to: CGPoint(x: 0, y: 0.88265*height), control1: CGPoint(x: 0.05884*width, y: height), control2: CGPoint(x: 0, y: 0.94746*height)) + path.addLine(to: CGPoint(x: 0, y: 0.11735*height)) + path.addCurve(to: CGPoint(x: 0.13143*width, y: 0), control1: CGPoint(x: 0, y: 0.05254*height), control2: CGPoint(x: 0.05884*width, y: 0)) + path.addLine(to: CGPoint(x: 0.86857*width, y: 0)) + path.addCurve(to: CGPoint(x: width, y: 0.11735*height), control1: CGPoint(x: 0.94115*width, y: 0), control2: CGPoint(x: width, y: 0.05254*height)) + path.addLine(to: CGPoint(x: width, y: 0.59843*height)) + path.addCurve(to: CGPoint(x: 0.8531*width, y: 0.72959*height), control1: CGPoint(x: width, y: 0.67087*height), control2: CGPoint(x: 0.93423*width, y: 0.72959*height)) + path.addCurve(to: CGPoint(x: 0.70621*width, y: 0.86075*height), control1: CGPoint(x: 0.77198*width, y: 0.72959*height), control2: CGPoint(x: 0.70621*width, y: 0.78831*height)) + path.addLine(to: CGPoint(x: 0.70621*width, y: 0.8648*height)) + path.addCurve(to: CGPoint(x: 0.55478*width, y: height), control1: CGPoint(x: 0.70621*width, y: 0.93947*height), control2: CGPoint(x: 0.63841*width, y: height)) + path.addLine(to: CGPoint(x: 0.13143*width, y: height)) + path.closeSubpath() + return path + } +} + +struct AllergySummaryCard: View { + var summary: String? = nil + var dynamicSteps: [DynamicStep] = [] + var onTap: (() -> Void)? = nil + + private var emptyStateFormattedText: String { + formatCardText("Add *allergies* or *dietary* needs for your *family* members to make meal *choices* easier for everyone.") + } + + /// Returns true if we should show the empty state (no data yet) + private var isEmptyState: Bool { + guard let summary = summary else { return true } + let trimmed = summary.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty || trimmed == "No Food Notes yet." + } + + /// Summary text with emoji icons injected (server sends **bold** markdown) + private var summaryWithEmojis: String { + guard let summary = summary else { return "" } + let mapping = FoodEmojiMapper.buildMapping(from: dynamicSteps) + return FoodEmojiMapper.injectEmojis(in: summary, using: mapping) + } + + /// Calculate the text container size based on card geometry + private func textContainerSize(for size: CGSize) -> CGSize { + let horizontalPadding: CGFloat = 10 + let topPadding: CGFloat = 12 + let bottomPadding: CGFloat = 17 + let badgeHeight: CGFloat = isEmptyState ? 28 : 0 // Badge + spacing for empty state + + let width = size.width - (horizontalPadding * 2) + let height = size.height - topPadding - bottomPadding - badgeHeight + + return CGSize(width: max(0, width), height: max(0, height)) + } + + /// Calculate exclusion rect for the bottom-right corner cutout + /// Based on MyIcon shape which has a curved cutout starting at ~70% width, ~60% height + private func exclusionRect(for size: CGSize) -> CGRect { + // The green button is ~43px (37 + 6 padding) in bottom-right + // Only exclude the area where the button actually sits + + let containerSize = textContainerSize(for: size) + + // Button area: approximately 50x55 pixels in bottom-right of text area + let buttonWidth: CGFloat = 55 + let buttonHeight: CGFloat = 65 + + let exclusionX = containerSize.width - buttonWidth + let exclusionY = containerSize.height - buttonHeight + + // Only create exclusion if there's enough space + guard exclusionX > 0 && exclusionY > 0 else { return .zero } + + return CGRect(x: exclusionX, y: exclusionY, width: buttonWidth + 20, height: buttonHeight + 20) + } + + var body: some View { + GeometryReader { geometry in + ZStack { + // Tappable background + MyIcon() + .fill(.grayScale10) + .overlay( + MyIcon() + .stroke(lineWidth: 0.25) + .foregroundStyle(.grayScale60) + ) + .shadow(color: Color(hex: "ECECEC"), radius: 9, x: 0, y: 0) + .contentShape(MyIcon()) + .onTapGesture { + onTap?() + } + + // Content with text exclusion for bottom-right cutout + VStack(alignment: .leading, spacing: 8) { + if isEmptyState { + // Empty state: "No Data Yet" badge + placeholder text + Text("No Data Yet") + .font(ManropeFont.regular.size(8)) + .foregroundStyle(.grayScale130) + .padding(.vertical, 4) + .padding(.horizontal, 8) + .background(.grayScale30, in: .capsule) + .overlay( + Capsule() + .stroke(lineWidth: 0.5) + .foregroundStyle(.grayScale70) + ) + + ExclusionMultiColorText( + text: emptyStateFormattedText, + font: UIFont(name: "Manrope-Bold", size: 14) ?? .boldSystemFont(ofSize: 14), + containerSize: textContainerSize(for: geometry.size), + exclusionRect: exclusionRect(for: geometry.size) + ) + .frame(width: textContainerSize(for: geometry.size).width, + height: textContainerSize(for: geometry.size).height, + alignment: .topLeading) + } else { + // Has summary: show the AI-generated summary text with emojis (server sends **bold** markdown) + ExclusionMarkdownText( + text: summaryWithEmojis, + font: UIFont(name: "Manrope-Regular", size: 14) ?? .systemFont(ofSize: 14), + containerSize: textContainerSize(for: geometry.size), + exclusionRect: exclusionRect(for: geometry.size) + ) + .frame(width: textContainerSize(for: geometry.size).width, + height: textContainerSize(for: geometry.size).height, + alignment: .topLeading) + } + } + .padding(.horizontal, 12) + .padding(.top, 12) + .padding(.bottom, 17) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .allowsHitTesting(false) + + // Button overlay - takes priority + VStack { + Spacer() + HStack { + Spacer() + Button(action: { + onTap?() + }) { + GreenCircle(iconName: "arrow-up-right", iconSize: 20, circleSize: 37) + .padding(3) + } + .buttonStyle(.plain) + } + } + .padding(.bottom, 3) + .padding(.trailing, 3) + } + } + } +} + +#Preview("Empty State") { + ZStack { + Color.gray.opacity(0.1).edgesIgnoringSafeArea(.all) + AllergySummaryCard() + .frame(width: 171, height: 196, alignment: .center) + } +} + +#Preview("With Summary") { + ZStack { + Color.gray.opacity(0.1).edgesIgnoringSafeArea(.all) + AllergySummaryCard( + summary: "You embrace a **plant‑focused**, balanced way of eating that highlights Indian flavors while keeping dishes **low in added sugars** and free from unnecessary fats." + ) + .frame(width: 171, height: 196, alignment: .center) + } +} + +#Preview("Long Text - Truncation") { + ZStack { + Color.gray.opacity(0.1).edgesIgnoringSafeArea(.all) + AllergySummaryCard( + summary: "You embrace a **plant‑focused**, balanced way of eating that highlights Indian flavors while keeping dishes **low in added sugars** and free from unnecessary fats and additives. Your family enjoys **high‑protein**, gentle, **baby‑friendly** meals that are mindful of common allergens." + ) + .frame(width: 171, height: 196, alignment: .center) + } +} + +#Preview("AISummaryCard") { + AISummaryCard(summary: "You embrace a **plant‑focused**, balanced way of eating that highlights Indian flavors while keeping dishes **low in added sugars** and free from unnecessary fats.") + .padding() +} diff --git a/IngrediCheck/Components/AskIngrediBotButton.swift b/IngrediCheck/Components/AskIngrediBotButton.swift new file mode 100644 index 00000000..d0b16e0e --- /dev/null +++ b/IngrediCheck/Components/AskIngrediBotButton.swift @@ -0,0 +1,76 @@ +// +// AskIngrediBotButton.swift +// IngrediCheckPreview +// +// Created by Gunjan Haldar on 08/10/25. +// + +import SwiftUI + +struct AskIngrediBotButton: View { + @Environment(AppNavigationCoordinator.self) private var coordinator + var action: (() -> Void)? + + var body: some View { + VStack(alignment: .leading, spacing: -16) { + Image("ingrediBot") + .frame(width: 76, height: 76) + .offset(x: -4) + .zIndex(1) + + Button { + if let action { + action() + } else { + coordinator.presentChatBot(startAtConversation: true) + } + } label: { + HStack(spacing: 4) { + Image("ai-stars") + .resizable() + .frame(width: 18, height: 18) + + Text("Ask IngrediBot") + .font(NunitoFont.semiBold.size(12)) + .foregroundStyle(.grayScale10) + } + } + .padding(.vertical, 15) + .padding(.horizontal, 20) + .background( + ZStack { + RoundedRectangle(cornerRadius: 33) + .foregroundStyle( + LinearGradient( + gradient: Gradient(stops: [ + .init(color: Color(hex: "#6F9600"), location: 0.2), // start at 0% + .init(color: Color(hex: "#90C02B"), location: 0.5), // up to 50% + .init(color: Color(hex: "#789D0E"), location: 1.1) // up to 90% + ]), + startPoint: .leading, + endPoint: .trailing + ) + .shadow( + .inner(color: .primary400.opacity(0.85), radius: 2, x: 0, y: 3) + ) + .shadow( + .drop(color: Color(hex: "C5C5C5"), radius: 8.8, x: 0, y: 2) + ) + ) + + Image("ingredi-bot-button-background") + .resizable() + .scaledToFill() + .frame(width: 146, height: 48) + .opacity(0.2) + .clipShape(RoundedRectangle(cornerRadius: 33)) + } + ) + } + } +} + +#Preview { + AskIngrediBotButton() + .environment(AppNavigationCoordinator()) +} diff --git a/IngrediCheck/Components/AverageScansCard.swift b/IngrediCheck/Components/AverageScansCard.swift new file mode 100644 index 00000000..a00cde79 --- /dev/null +++ b/IngrediCheck/Components/AverageScansCard.swift @@ -0,0 +1,200 @@ +// +// AverageScansCard.swift +// IngrediCheckPreview +// +// Created by Gunjan Haldar on 06/10/25. +// + +import SwiftUI +struct AvgModel: Identifiable { + var id = UUID().uuidString + var value: Int + var day: String + // Computed property to scale value to a bar height + var barHeight: CGFloat { + let maxBarHeight: CGFloat = 50 // Maximum height + let minBarHeight: CGFloat = 8 // Minimum height for no data + let maxValue: CGFloat = 100 // Maximum value in your data + + if value == 0 { + return minBarHeight + } + return max(minBarHeight, CGFloat(value) / maxValue * maxBarHeight) + } +} +struct AverageScansCard: View { + @State var avgArray: [AvgModel] = [ + AvgModel(value: 10, day: "M"), + AvgModel(value: 25, day: "T"), + AvgModel(value: 35, day: "W"), + AvgModel(value: 45, day: "T"), + AvgModel(value: 55, day: "F"), + AvgModel(value: 5, day: "S"), + AvgModel(value: 100, day: "S") + ] + var playsLaunchAnimation: Bool = false + var avgScans: Int = 0 + var weeklyStats: [DTO.WeeklyStat]? = nil + @State private var weeklyAverage: Int = 0 + @State private var animatedBarHeights: [CGFloat] = [] + @State private var didPlayLaunchAnimation: Bool = false + + // Check if there's no data (all values are 0) + private var hasNoData: Bool { + avgArray.allSatisfy { $0.value == 0 } + } + + private var targetBarHeights: [CGFloat] { + avgArray.map { $0.barHeight } + } + + private func playLaunchBarAnimation() { + guard !didPlayLaunchAnimation else { return } + didPlayLaunchAnimation = true + + let count = max(0, avgArray.count) + let maxBarHeight: CGFloat = 50 + + animatedBarHeights = Array(repeating: 0, count: count) + + Task { @MainActor in + let frameDuration: Double = 1.0 / 30.0 + + let phaseStep: Double = Double.pi / 5 + let cyclesPerSecond: Double = 0.5 + let cycleCount: Double = 1 + let totalDuration: Double = cycleCount / cyclesPerSecond + let frames = Int((totalDuration / frameDuration).rounded(.up)) + let omega: Double = 2 * Double.pi * cyclesPerSecond + + for frame in 0.. 1000 ? "1k+" : weeklyAverage == 1000 ? "1k" : "\(weeklyAverage)") + .font(.system(size: 44, weight: .bold)) + .foregroundStyle(.grayScale150) + + Text("Avg. Scans") + .font(ManropeFont.regular.size(10)) + .foregroundStyle(.grayScale100) + .padding(.bottom, 2) + } +// .padding(.horizontal, 12) + + Spacer() + + VStack(spacing: 4) { + // Bars + Average Line + ZStack(alignment: .bottom) { + // Weekly Average Line (only show if there's data) + if !hasNoData { + RoundedRectangle(cornerRadius: 1) + .fill(.primary300) + .frame(width: 140, height: 1.5) + .offset(y: -CGFloat(weeklyAverage) / 100 * 50) // move up from bottom + } + // Bars + HStack(alignment: .bottom, spacing: 8) { + ForEach(Array(avgArray.enumerated()), id: \.element.id) { index, array in + let height = (index < animatedBarHeights.count) ? animatedBarHeights[index] : 0 + RoundedRectangle(cornerRadius: 3) + .foregroundStyle( + hasNoData ? .grayScale60 : (array.value >= weeklyAverage ? .secondary800 : .secondary400) + ) + .frame(width: 12, height: max(height, 8)) // Ensure minimum height of 8 + } + } + } + .frame(height: 50, alignment: .bottom) // ensure ZStack height matches max bar height + + // Day Labels + HStack(spacing: 0) { + ForEach(avgArray) { array in + Text(array.day) + .font(ManropeFont.regular.size(9.2)) + .foregroundStyle(.grayScale90) + .frame(width: 12 + 8, alignment: .center) + } + } + } +// .padding(.horizontal, 14) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 24) + .foregroundStyle(.white) + .shadow(color: Color(hex: "ECECEC"), radius: 9, x: 0, y: 0) + ) + .overlay( + Image("avg-scanner") + .resizable() + .frame(width: 20, height: 20) + .padding(12) + , alignment: .topTrailing + ) + .onAppear() { + // Use provided avgScans if it has been set (even if 0), otherwise calculate from array + // Check if avgScans was explicitly provided by checking if it differs from initial state + // Since HomeView always passes a value (stats?.avgScans ?? 0), we use avgScans directly + weeklyAverage = avgScans + + // Map weeklyStats from backend to avgArray if available + if let weeklyStats = weeklyStats, !weeklyStats.isEmpty { + avgArray = weeklyStats.map { stat in + AvgModel(value: stat.value, day: stat.day) + } + } else { + // If no weeklyStats, ensure we have 7 days with 0 values for no-data state + let days = ["M", "T", "W", "T", "F", "S", "S"] + avgArray = days.map { day in + AvgModel(value: 0, day: day) + } + } + + if animatedBarHeights.isEmpty { + animatedBarHeights = targetBarHeights + } + + // Always play animation once, even for no data + if playsLaunchAnimation { + playLaunchBarAnimation() + } + } + .overlay( + RoundedRectangle(cornerRadius: 18) + .stroke(lineWidth: 0.25) + .foregroundStyle(.grayScale60) + ) + } +} +#Preview { + ZStack { +// Color.gray.opacity(0.1).ignoresSafeArea() + AverageScansCard(playsLaunchAnimation: true) + .frame(width: 200, height: 200) + } +} diff --git a/IngrediCheck/Components/Buttons/FeedbackButton.swift b/IngrediCheck/Components/Buttons/FeedbackButton.swift new file mode 100644 index 00000000..11a66852 --- /dev/null +++ b/IngrediCheck/Components/Buttons/FeedbackButton.swift @@ -0,0 +1,280 @@ +import SwiftUI + +struct FeedbackButton: View { + enum FeedbackType { + case up + case down + } + + enum Style { + /// Product Status style: Boxed with stroke that changes color + case boxed + /// Ingredients style: Plain icon, no background + case plain + /// Full Screen Viewer style: Circular dark background, white unselected icon + case overlay + /// White filled background with gray icons + case whiteBoxed + } + + let type: FeedbackType + let isSelected: Bool + var isLoading: Bool = false + var isDisabled: Bool = false + var style: Style = .plain + let action: () -> Void + + @State private var isAnimating = false + + var body: some View { + Button(action: { + guard !isLoading && !isDisabled else { return } + withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) { + isAnimating = true + } + action() + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) { + isAnimating = false + } + } + }) { + content + } + .buttonStyle(.plain) + .disabled(isLoading || isDisabled) + .opacity(isDisabled && !isLoading ? 0.5 : 1.0) + } + + @ViewBuilder + private var content: some View { + switch style { + case .boxed: + ZStack { + RoundedRectangle(cornerRadius: 8) + .stroke(strokeColor, lineWidth: 0.5) + + if isLoading { + ProgressView() + .scaleEffect(0.6) + } else { + iconView + .frame(width: 20, height: 18) + } + } + .frame(width: 32, height: 28) + + case .plain: + if isLoading { + ProgressView() + .scaleEffect(0.6) + .frame(width: 28, height: 24) + } else { + iconView + .frame(width: 28, height: 24) + } + + case .overlay: + ZStack { + if isLoading { + ProgressView() + .scaleEffect(0.7) + .tint(.white) + } else { + iconView + .frame(width: 22, height: 22) + } + } + .frame(width: 44, height: 44) + .background(Color.black.opacity(0.3)) + .clipShape(Circle()) + + case .whiteBoxed: + ZStack { + RoundedRectangle(cornerRadius: 8) + .fill(Color.white) + + if isLoading { + ProgressView() + .scaleEffect(0.6) + } else { + iconView + .frame(width: 20, height: 18) + } + } + .frame(width: 32, height: 28) + } + } + + private var iconView: some View { + Image(assetName) + .renderingMode(isSelected ? .original : .template) + .resizable() + .scaledToFit() + .foregroundStyle(iconColor) + .rotationEffect(.degrees(rotationAmount)) + .offset(y: offsetAmount) + } + + // MARK: - Animation Logic + + private var rotationAmount: Double { + guard isAnimating else { return 0 } + switch type { + case .up: return -20 + case .down: return 20 + } + } + + private var offsetAmount: CGFloat { + guard isAnimating else { return 0 } + switch type { + case .up: return -4 + case .down: return 4 + } + } + + // MARK: - Asset & Color Logic + + private var assetName: String { + switch type { + case .up: return isSelected ? "thumbsup.fill" : "thumbsup" + case .down: return isSelected ? "thumbsdown.fill" : "thumbsdown" + } + } + + private var iconColor: Color { + if isSelected { + switch type { + case .up: return .green + case .down: return .red + } + } + + switch style { + case .boxed: return .grayScale100 + case .plain: return .grayScale130 + case .overlay: return .white + case .whiteBoxed: return .grayScale100 + } + } + + private var strokeColor: Color { + guard style == .boxed else { return .clear } + + if isSelected { + switch type { + case .up: return Color(hex: "#FBCB7F") // Light orange + case .down: return Color(hex: "#FF594E") // Light red + } + } + return .grayScale100 + } +} + +#Preview("FeedbackButton Styles") { + VStack(spacing: 40) { + // Boxed style + VStack(spacing: 16) { + Text("Boxed Style") + .font(NunitoFont.bold.size(18)) + .foregroundStyle(.grayScale150) + + HStack(spacing: 16) { + FeedbackButton(type: .up, isSelected: false, style: .boxed) { + print("Thumbs up (unselected)") + } + FeedbackButton(type: .up, isSelected: true, style: .boxed) { + print("Thumbs up (selected)") + } + FeedbackButton(type: .down, isSelected: false, style: .boxed) { + print("Thumbs down (unselected)") + } + FeedbackButton(type: .down, isSelected: true, style: .boxed) { + print("Thumbs down (selected)") + } + } + } + + // Plain style + VStack(spacing: 16) { + Text("Plain Style") + .font(NunitoFont.bold.size(18)) + .foregroundStyle(.grayScale150) + + HStack(spacing: 16) { + FeedbackButton(type: .up, isSelected: false, style: .plain) { + print("Thumbs up (unselected)") + } + FeedbackButton(type: .up, isSelected: true, style: .plain) { + print("Thumbs up (selected)") + } + FeedbackButton(type: .down, isSelected: false, style: .plain) { + print("Thumbs down (unselected)") + } + FeedbackButton(type: .down, isSelected: true, style: .plain) { + print("Thumbs down (selected)") + } + } + } + + // Overlay style + VStack(spacing: 16) { + Text("Overlay Style") + .font(NunitoFont.bold.size(18)) + .foregroundStyle(.grayScale150) + + ZStack { + Color.gray.opacity(0.3) + .frame(height: 200) + .frame(maxWidth: .infinity) + + HStack(spacing: 16) { + FeedbackButton(type: .up, isSelected: false, style: .overlay) { + print("Thumbs up (unselected)") + } + FeedbackButton(type: .up, isSelected: true, style: .overlay) { + print("Thumbs up (selected)") + } + FeedbackButton(type: .down, isSelected: false, style: .overlay) { + print("Thumbs down (unselected)") + } + FeedbackButton(type: .down, isSelected: true, style: .overlay) { + print("Thumbs down (selected)") + } + } + } + } + + // White Boxed style + VStack(spacing: 16) { + Text("White Boxed Style") + .font(NunitoFont.bold.size(18)) + .foregroundStyle(.grayScale150) + + ZStack { + Color(hex: "#FFE5E5") // Light pink background like in the image + .frame(height: 100) + .frame(maxWidth: .infinity) + .cornerRadius(16) + + HStack(spacing: 16) { + FeedbackButton(type: .up, isSelected: false, style: .whiteBoxed) { + print("Thumbs up (unselected)") + } + FeedbackButton(type: .down, isSelected: false, style: .whiteBoxed) { + print("Thumbs down (unselected)") + } + FeedbackButton(type: .down, isSelected: true, style: .whiteBoxed) { + print("Thumbs down (unselected)") + } + + } + } + } + } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.pageBackground) +} diff --git a/IngrediCheck/Components/Buttons/GreenCapsule.swift b/IngrediCheck/Components/Buttons/GreenCapsule.swift new file mode 100644 index 00000000..41524e42 --- /dev/null +++ b/IngrediCheck/Components/Buttons/GreenCapsule.swift @@ -0,0 +1,143 @@ +// +// PrimaryButton.swift +// IngrediCheck +// +// Created by Gunjan Haldar on 09/10/25. +// + +import SwiftUI + +struct GreenCapsule: View { + let title: String + let icon: String? + var iconWidth: CGFloat = 20 + var iconHeight: CGFloat = 20 + var width: CGFloat = 152 + var height: CGFloat = 52 + var takeFullWidth: Bool = false + var isLoading: Bool = false + var labelFont: Font = NunitoFont.semiBold.size(16) + var isDisabled: Bool = false + + init( + title: String, + icon: String? = nil, + iconWidth: CGFloat = 20, + iconHeight: CGFloat = 20, + width: CGFloat = 152, + height: CGFloat = 52, + takeFullWidth: Bool = true, + isLoading: Bool = false, + isDisabled: Bool = false, + labelFont: Font = NunitoFont.semiBold.size(16) + ) { + self.title = title + self.icon = icon + self.iconWidth = iconWidth + self.iconHeight = iconHeight + self.width = width + self.height = height + self.takeFullWidth = takeFullWidth + self.isLoading = isLoading + self.isDisabled = isDisabled + self.labelFont = labelFont + } + + var body: some View { + HStack(spacing: 8) { + if isLoading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .grayScale10)) + .scaleEffect(0.8) + } else { + if let icon { + Image(icon) + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: iconWidth, height: iconHeight) + } + + Text(title) + .font(labelFont) + .foregroundStyle(isDisabled ? .grayScale110 : .grayScale10) + } + } + .frame(width: takeFullWidth ? nil : width, height: height) + .frame(minWidth: takeFullWidth ? 152 : 152) + .frame(maxWidth: takeFullWidth ? .infinity : nil) + .background(backgroundView) + .overlay( + Capsule() + .stroke(lineWidth: 1) + .foregroundStyle(isDisabled ? .grayScale40 : .grayScale10) + ) + // Outer drop shadow - only when not disabled + .shadow( + color: isDisabled ? Color.clear : Color(hex: "C5C5C5").opacity(0.47), + radius: 2.6, + x: 0, + y: 1 + ) + } + + @ViewBuilder + private var backgroundView: some View { + if isDisabled { + Capsule() + .fill(Color.grayScale40) + } else { + ZStack { + // Base gradient + Capsule() + .fill( + LinearGradient( + colors: [ + Color(hex: "9DCF10"), + Color(hex: "6B8E06") + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .shadow(.inner(color: Color(hex: "EDEDED").opacity(0.25), radius: 7.5, x: 2, y: 9)) + .shadow(.inner(color: Color(hex: "72930A").opacity(1.2), radius: 5.7, x: 0, y: 4)) + .shadow(.drop(color: Color(hex: "6B8E06").opacity(0.8), radius: 5.7, x: 0, y: 4)) + ) + .clipShape(Capsule()) + + // Inset depth shadow + Capsule() + .fill(Color.clear) + .shadow( + color: Color(hex: "6B8E06").opacity(0.8), + radius: 5.7, + x: 0, + y: 4 + ) + .clipShape(Capsule()) + + // Border (will be overridden by overlay) + Capsule() + .stroke(Color.white, lineWidth: 1) + } + } + } +} + +#Preview { + ZStack { +// Color.gray.opacity(0.1).ignoresSafeArea() + VStack(spacing: 20) { + GreenCapsule(title: "Get Started") + GreenCapsule(title: "Continue", isLoading: true) + GreenCapsule(title: "Disabled", isDisabled: true) + GreenCapsule(title: "With Icon", icon: "share", iconWidth: 12, iconHeight: 12) + + HStack { + GreenCapsule(title: "Get Started") + GreenCapsule(title: "Continue", isLoading: false) + } + } + .padding() + } +} diff --git a/IngrediCheck/Components/Buttons/GreenCircle.swift b/IngrediCheck/Components/Buttons/GreenCircle.swift new file mode 100644 index 00000000..757de86d --- /dev/null +++ b/IngrediCheck/Components/Buttons/GreenCircle.swift @@ -0,0 +1,53 @@ +// +// GreenCircle.swift +// IngrediCheckPreview +// +// Created by Gunjan Haldar on 09/10/25. +// + +import SwiftUI + +struct GreenCircle: View { + + var iconName: String = "right-arrow-rounded-edge" + var iconSize: CGFloat = 32 + var circleSize: CGFloat = 52 + + var body: some View { + Image(iconName) + .resizable() + .frame(width: iconSize, height: iconSize) + .padding(10) + .background( + Capsule() + .frame(width: circleSize, height: circleSize) + .foregroundStyle( + LinearGradient( + colors: [Color(hex: "9DCF10"), Color(hex: "6B8E06")], + startPoint: .top, + endPoint: .bottom + ) + .shadow( + .drop(color: Color(hex: "C5C5C5").opacity(0.57), radius: 11, x: 0, y: 4) + ) + .shadow( + .inner(color: Color(hex: "EDEDED").opacity(0.25), radius: 7.5, x: 2, y: 4) + ) + .shadow( + .inner(color: Color(hex: "72930A"), radius: 5.7, x: 0, y: 4) + ) + + ) + .rotationEffect(.degrees(17)) + .overlay( + Circle() + .stroke(lineWidth: 1) + .foregroundColor(Color(hex: "FFFFFF")) + ) + ) + } +} + +#Preview { + GreenCircle() +} diff --git a/IngrediCheck/Components/Buttons/GreenOutlinedCapsule.swift b/IngrediCheck/Components/Buttons/GreenOutlinedCapsule.swift new file mode 100644 index 00000000..05de7fc3 --- /dev/null +++ b/IngrediCheck/Components/Buttons/GreenOutlinedCapsule.swift @@ -0,0 +1,59 @@ +// +// GreenOutlinedCapsule.swift +// IngrediCheckPreview +// +// Created by Gunjan Haldar on 17/10/25. +// + +import SwiftUI + +struct GreenOutlinedCapsule: View { + var image: String? = nil + var title: String + var width: CGFloat? = 152 + var height: CGFloat? = 52 + var body: some View { + HStack(spacing: 10) { + if let image = image { + Image(image) + .renderingMode(.template) + .resizable() + .frame(width: 20, height: 20) + .foregroundStyle(Color(hex: "91B640")) + } + Text(title) + .font(NunitoFont.semiBold.size(16)) + .foregroundStyle(Color(hex: "91B640")) + } + .frame(height: height) + .frame(minWidth: 152) + .frame(maxWidth: .infinity) + .background( + Capsule() + .fill(Color.white) + ) + .overlay( + Capsule() + .stroke(lineWidth: 1.5) + .foregroundStyle(Color(hex: "91B640")) + ) + } +} + +#Preview { + GreenOutlinedCapsule(image: "stars-generate", title: "Generate") +} + +func rotatedGradient(colors: [Color], angle: Double) -> LinearGradient { + // Convert angle to a unit vector + let rad = angle * .pi / 180 + let x = 0.5 + 0.5 * cos(rad) + let y = 0.5 + 0.5 * sin(rad) + + return LinearGradient( + gradient: Gradient(colors: colors), + startPoint: UnitPoint(x: 0.5 - (x - 0.5), y: 0.5 - (y - 0.5)), + endPoint: UnitPoint(x: x, y: y) + ) +} + diff --git a/IngrediCheck/Components/Buttons/LastCard.swift b/IngrediCheck/Components/Buttons/LastCard.swift new file mode 100644 index 00000000..7bfa87bf --- /dev/null +++ b/IngrediCheck/Components/Buttons/LastCard.swift @@ -0,0 +1,26 @@ +// +// LastCard.swift +// IngrediCheck +// +// Created by Gaurav on 12/01/26. +// + +import SwiftUI + +struct LastCard: View { + @State private var size: CGFloat = UIScreen.main.bounds.width * 0.3 + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 20) + .foregroundStyle(Color(hex: "#D7EEB2")) + .frame(width: 325, height: 252) + + Circle() + .frame(width: size, height: size) + } + } +} + +#Preview { + LastCard() +} diff --git a/IngrediCheck/Components/Buttons/PrimaryButton.swift b/IngrediCheck/Components/Buttons/PrimaryButton.swift new file mode 100644 index 00000000..22678c6b --- /dev/null +++ b/IngrediCheck/Components/Buttons/PrimaryButton.swift @@ -0,0 +1,138 @@ +// +// PrimaryButton.swift +// IngrediCheck +// +// Created by Gunjan Haldar on 09/10/25. +// + +import SwiftUI + +struct PrimaryButton: View { + let title: String + let icon: String? + var iconWidth: CGFloat = 20 + var iconHeight: CGFloat = 20 + var width: CGFloat = 152 + var height: CGFloat = 52 + var takeFullWidth: Bool = true + var isLoading: Bool = false + var labelFont: Font = NunitoFont.semiBold.size(16) + var isDisabled: Bool = false + + init( + title: String, + icon: String? = nil, + iconWidth: CGFloat = 20, + iconHeight: CGFloat = 20, + width: CGFloat = 152, + height: CGFloat = 52, + takeFullWidth: Bool = true, + isLoading: Bool = false, + isDisabled: Bool = false, + labelFont: Font = NunitoFont.semiBold.size(16) + ) { + self.title = title + self.icon = icon + self.iconWidth = iconWidth + self.iconHeight = iconHeight + self.width = width + self.height = height + self.takeFullWidth = takeFullWidth + self.isLoading = isLoading + self.isDisabled = isDisabled + self.labelFont = labelFont + } + + var body: some View { + HStack(spacing: 8) { + if isLoading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .grayScale10)) + .scaleEffect(0.8) + } else { + if let icon { + Image(icon) + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: iconWidth, height: iconHeight) + } + + Text(title) + .font(labelFont) + .foregroundStyle(isDisabled ? .grayScale110 : .grayScale10) + } + } + .frame(width: takeFullWidth ? nil : width, height: height) + .frame(minWidth: takeFullWidth ? 152 : 0) + .frame(maxWidth: takeFullWidth ? .infinity : nil) + .background(backgroundView) + .overlay( + Capsule() + .stroke(lineWidth: 1) + .foregroundStyle(isDisabled ? .grayScale40 : .grayScale10) + ) + // Outer drop shadow - only when not disabled + .shadow( + color: isDisabled ? Color.clear : Color(hex: "C5C5C5").opacity(0.47), + radius: 2.6, + x: 0, + y: 1 + ) + } + + @ViewBuilder + private var backgroundView: some View { + if isDisabled { + Capsule() + .fill(Color.grayScale40) + } else { + ZStack { + // Base gradient + Capsule() + .fill( + LinearGradient( + colors: [ + Color(hex: "9DCF10"), + Color(hex: "6B8E06") + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .shadow(.inner(color: Color(hex: "EDEDED").opacity(0.25), radius: 7.5, x: 2, y: 18)) + .shadow(.inner(color: Color(hex: "72930A").opacity(1.2), radius: 5.7, x: 0, y: 0)) + .shadow(.drop(color: Color(hex: "6B8E06").opacity(0.8), radius: 5.7, x: 0, y: 4)) + ) + .clipShape(Capsule()) + + // Inset depth shadow + Capsule() + .fill(Color.clear) + .shadow( + color: Color(hex: "6B8E06").opacity(0.8), + radius: 5.7, + x: 0, + y: 4 + ) + .clipShape(Capsule()) + + // Border (will be overridden by overlay) + Capsule() + .stroke(Color.white, lineWidth: 1) + } + } + } +} + +#Preview { + ZStack { + Color.gray.opacity(0.1).ignoresSafeArea() + VStack(spacing: 20) { + PrimaryButton(title: "Get Started") + PrimaryButton(title: "Continue", isLoading: true) + PrimaryButton(title: "Disabled", isDisabled: true) + PrimaryButton(title: "With Icon", icon: "share", iconWidth: 12, iconHeight: 12) + } + .padding() + } +} diff --git a/IngrediCheck/Components/Buttons/SecondaryButton.swift b/IngrediCheck/Components/Buttons/SecondaryButton.swift new file mode 100644 index 00000000..4a71f1d9 --- /dev/null +++ b/IngrediCheck/Components/Buttons/SecondaryButton.swift @@ -0,0 +1,146 @@ +// +// SecondaryButton.swift +// IngrediCheck +// +// Created on 31/12/25. +// + +import SwiftUI + +struct SecondaryButton: View { + let title: String + let icon: String? + var iconWidth: CGFloat = 20 + var iconHeight: CGFloat = 20 + var width: CGFloat = 159 + var height: CGFloat = 52 + var takeFullWidth: Bool = true + var isLoading: Bool = false + var labelFont: Font = NunitoFont.semiBold.size(16) + var isDisabled: Bool = false + let action: () -> Void + + init( + title: String, + icon: String? = nil, + iconWidth: CGFloat = 20, + iconHeight: CGFloat = 20, + width: CGFloat = 159, + height: CGFloat = 52, + takeFullWidth: Bool = true, + isLoading: Bool = false, + isDisabled: Bool = false, + labelFont: Font = NunitoFont.semiBold.size(16), + action: @escaping () -> Void = {} + ) { + self.title = title + self.icon = icon + self.iconWidth = iconWidth + self.iconHeight = iconHeight + self.width = width + self.height = height + self.takeFullWidth = takeFullWidth + self.isLoading = isLoading + self.isDisabled = isDisabled + self.labelFont = labelFont + self.action = action + } + + var body: some View { + Button(action: action) { + HStack(spacing: 10) { + if isLoading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: Color(hex: "#75990E"))) + .scaleEffect(0.8) + } else { + if let icon { + Image(icon) + .renderingMode(.template) + .resizable() + .foregroundStyle(isDisabled ? .grayScale110 : Color(hex: "#75990E")) + .frame(width: iconWidth, height: iconHeight) + } + + Text(title) + .font(labelFont) + .foregroundStyle(isDisabled ? .grayScale110 : Color(hex: "#75990E")) + .lineLimit(1) + } + } + .padding(.vertical, 14) +// .padding(.horizontal, 37) + .frame(height: height) + .frame(minWidth: takeFullWidth ? 159 : width) + .frame(maxWidth: takeFullWidth ? .infinity : nil) + .background(backgroundView) + .overlay( + Capsule() + .strokeBorder( + Color.grayScale40, + lineWidth: 1.5 + ) + ) +// .overlay(borderView, alignment: .center) +// .opacity(isDisabled ? 0.6 : 1.0) + } + .disabled(isDisabled || isLoading) + .buttonStyle(.plain) + } + + @ViewBuilder + private var backgroundView: some View { + if isDisabled { + Capsule() + .fill(Color.grayScale40) + } else { + Capsule() + .fill(Color.white) +// .shadow(color: Color(hex: "CECECE63"), radius: 4.8, x: 0, y: 0) + } + } + + @ViewBuilder + private var borderView: some View { + GeometryReader { geometry in + if isDisabled { + Capsule() + .stroke(lineWidth: 1.5) + .foregroundStyle(Color.grayScale40) + } else { + // Gradient border using ZStack technique + ZStack { + // Outer gradient shape (full size) + Capsule() + .fill( + LinearGradient( + colors: [Color(hex: "E8EBED"), Color(hex: "FFFFFF")], + startPoint: UnitPoint(x: 0.047, y: 0.5), + endPoint: UnitPoint(x: 1.83, y: 0.5) + ) + ) + .frame(width: geometry.size.width, height: geometry.size.height) + + // Inner white shape to create 1.5px border effect + Capsule() + .fill(Color.white) + .frame(width: geometry.size.width - 3, height: geometry.size.height - 3) + } + } + } + } +} + +#Preview { + ZStack { +// Color.gray.opacity(0.1).ignoresSafeArea() + VStack(spacing: 20) { + SecondaryButton(title: "All Set!", action: {}) + SecondaryButton(title: "Maybe later", action: {}) + SecondaryButton(title: "Later", isLoading: true, action: {}) + SecondaryButton(title: "Disabled", isDisabled: true, action: {}) + SecondaryButton(title: "With Icon", icon: "share", iconWidth: 12, iconHeight: 12, action: {}) + } + .padding() + } +} diff --git a/IngrediCheck/Components/CanvasCard.swift b/IngrediCheck/Components/CanvasCard.swift new file mode 100644 index 00000000..517ed9a7 --- /dev/null +++ b/IngrediCheck/Components/CanvasCard.swift @@ -0,0 +1,139 @@ +// +// CanvasCard.swift +// IngrediCheckPreview +// +// Created by Gunjan Haider on 30/09/25. +// + +import SwiftUI + + +struct CanvasCard: View { + @Environment(FamilyStore.self) private var familyStore + + var chips: [ChipsModel]? = [ + ChipsModel(name: "Peanuts", icon: "πŸ₯œ"), + ChipsModel(name: "Sesame", icon: "❀️"), + ChipsModel(name: "Wheat", icon: "🌾"), + ChipsModel(name: "Shellfish", icon: "🦐") + ] + + var sectionedChips: [SectionedChipModel]? = nil + + var title: String = "allergies" + var iconName: String = "allergies" + var itemMemberAssociations: [String: [String: [String]]] = [:] + var showFamilyIcons: Bool = true + + // Helper function to get member identifiers for an item + // Returns "Everyone" or member UUID strings for use in ChipMemberAvatarView + private func getMemberIdentifiers(for sectionName: String, itemName: String) -> [String] { + guard let memberIds = itemMemberAssociations[sectionName]?[itemName] else { + return [] + } + + // Return member IDs directly (already UUID strings or "Everyone") + // ChipMemberAvatarView will resolve these to FamilyMember objects + return memberIds + } + + private var hasOtherSelected: Bool { + if let chips = chips, chips.contains(where: { $0.name == "Other" }) { + return true + } + if let sectionedChips = sectionedChips, sectionedChips.contains(where: { section in + section.chips.contains(where: { $0.name == "Other" }) + }) { + return true + } + return false + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Image(iconName) + .resizable() + .renderingMode(.template) + .foregroundStyle(.grayScale110) + .frame(width: 18, height: 18) + + Text(title.capitalized) + .font(NunitoFont.semiBold.size(14)) + .foregroundStyle(.grayScale110) + } + .fontWeight(.semibold) + + VStack(alignment: .leading) { + + if let sectionedChips = sectionedChips { + ForEach(sectionedChips) { ele in + VStack(alignment: .leading, spacing: 8) { + Text(ele.title) + .font(ManropeFont.semiBold.size(12)) + .foregroundStyle(.grayScale150) + + FlowLayout(horizontalSpacing: 8, verticalSpacing: 8) { + ForEach(ele.chips) { chip in + IngredientsChips( + title: chip.name, + bgColor: .secondary200, + image: chip.icon, + familyList: showFamilyIcons ? getMemberIdentifiers(for: title, itemName: chip.name) : [], + outlined: false + ) + } + } + } + } + } else if let chips = chips { + FlowLayout(horizontalSpacing: 8, verticalSpacing: 8) { + ForEach(chips, id: \.id) { chip in + IngredientsChips( + title: chip.name, + bgColor: .secondary200, + image: chip.icon, + familyList: showFamilyIcons ? getMemberIdentifiers(for: title, itemName: chip.name) : [], + outlined: false + ) + } + } + } + } + + if hasOtherSelected { + HStack(spacing: 8) { + Image("exlamation") + .resizable() + .frame(width: 16, height: 16) + + Text("Something else too, don't worry we'll ask later!") + .font(ManropeFont.regular.size(10)) + .foregroundStyle(Color(hex: "#7F7F7F")) + .italic() + } + } + } + .padding(.horizontal, 12) + .padding(.vertical, 16) + .background( + RoundedRectangle(cornerRadius: 16) + .foregroundStyle(.white) + .shadow(color: Color(hex: "ECECEC"), radius: 9, x: 0, y: 0) + ) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(lineWidth: 0.25) + .foregroundStyle(.grayScale60) + ) + + } +} + +#Preview { + ZStack { +// Color.gray.opacity(0.3).ignoresSafeArea() + CanvasCard() + .padding(.horizontal, 20) + } +} diff --git a/IngrediCheck/Components/CanvasTagBar.swift b/IngrediCheck/Components/CanvasTagBar.swift new file mode 100644 index 00000000..e797c33d --- /dev/null +++ b/IngrediCheck/Components/CanvasTagBar.swift @@ -0,0 +1,223 @@ +// +// CanvasTagBar.swift +// IngrediCheckPreview +// +// Created by Gunjan Haldar on 03/10/25. +// + +import SwiftUI + +struct CanvasTagBar: View { + + @ObservedObject var store: Onboarding + var onTapCurrentSection: (() -> Void)? = nil + @Binding var scrollTarget: UUID? + var currentBottomSheetRoute: BottomSheetRoute? = nil + var allowTappingIncompleteSections: Bool = false // Allow tapping even if section is not completed (for EditableCanvasView) + var forceDarkGreen: Bool = false + + /// Derived tag items from the dynamic sections / JSON, so that ordering, + /// titles and icons always match the config. + private var tagItems: [ChipsModel] { + store.sections.compactMap { section in + guard let stepId = section.screens.first?.stepId else { return nil } + + let iconName: String + if let step = store.step(for: stepId), + let icon = step.header.iconURL, + icon.isEmpty == false { + iconName = icon + } else { + // Fallback to a default icon if not found in JSON + iconName = "allergies" // Default fallback + } + + return ChipsModel(name: section.name, icon: iconName) + } + } + @State var visited: [String] = [] + + private var currentSelectedSectionIndex: Int { + if case .onboardingStep(let stepId) = currentBottomSheetRoute { + if let index = store.dynamicSteps.firstIndex(where: { $0.id == stepId }) { + return index + } + } + return store.currentSectionIndex + } + + var body: some View { + ScrollViewReader { proxy in + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: -1) { + ForEach(Array(tagItems.enumerated()), id: \.offset) { index, model in + TagIconCapsule( + image: model.icon ?? "", + title: model.name, + isSelected: Binding( + get: { + // If fineTuneYourExperience is shown, return empty string so nothing is selected + if case .fineTuneYourExperience = currentBottomSheetRoute { + return "" + } + if store.sections.indices.contains(currentSelectedSectionIndex) { + return store.sections[currentSelectedSectionIndex].name + } + return store.sections[store.currentSectionIndex].name + }, + set: { newName in + handleSelection(newName: newName, proxy: proxy) + } + ), + isFirst: (index == 0), + visited: $visited, + forceDarkGreen: forceDarkGreen + ) + .zIndex(index == currentSelectedSectionIndex ? 1 : 0) + .id(store.sections[safe: index]?.id) + .onTapGesture { + handleTap(index: index, proxy: proxy) + } + } + } + .padding(.horizontal, 20) + .animation(.linear(duration: 0.2), value: store.currentSectionIndex) + .animation(.linear(duration: 0.2), value: currentBottomSheetRoute) + } + .onChange(of: scrollTarget) { _ in + guard let target = scrollTarget else { return } + withAnimation(.easeInOut(duration: 0.3)) { + proxy.scrollTo(target, anchor: .center) + } + scrollTarget = nil + } + } + .onAppear() { + syncVisitedFromStore() + } + .onChange(of: store.currentSectionIndex) { _, newIndex in + // When user goes to a next section (or any section), mark it as visited + // so it turns dark green immediately. + if store.sections.indices.contains(newIndex) { + if newIndex > store.maxVisitedSectionIndex { + store.maxVisitedSectionIndex = newIndex + } + let currentName = store.sections[newIndex].name + if !visited.contains(currentName) { + visited.append(currentName) + } + } + } + .onChange(of: store.maxVisitedSectionIndex) { _, _ in + syncVisitedFromStore() + } + } + + private func syncVisitedFromStore() { + guard store.sections.isEmpty == false else { + visited = [] + return + } + let maxIndex = min(store.maxVisitedSectionIndex, max(store.sections.count - 1, 0)) + let names = store.sections.prefix(maxIndex + 1).map { $0.name } + visited = Array(names) + } + + private func handleSelection(newName: String, proxy: ScrollViewProxy) { + guard let tappedIndex = store.sections.firstIndex(where: { $0.name == newName }), + store.sections.indices.contains(tappedIndex) else { return } + + handleSelection(at: tappedIndex, proxy: proxy) + } + + private func handleTap(index: Int, proxy: ScrollViewProxy) { + handleSelection(at: index, proxy: proxy) + } + + private func handleSelection(at index: Int, proxy: ScrollViewProxy) { + guard store.sections.indices.contains(index) else { return } + + if index == store.currentSectionIndex { + onTapCurrentSection?() + return + } + + let tappedName = store.sections[index].name + // Allow tapping if: section is complete, has been visited, or if we're in edit mode (EditableCanvasView) + if allowTappingIncompleteSections || store.sections[index].isComplete || visited.contains(tappedName) { + store.currentSectionIndex = index + store.currentScreenIndex = 0 + if visited.contains(tappedName) == false { + visited.append(tappedName) + } + if let id = store.sections[index].id as UUID? { + withAnimation(.easeInOut(duration: 0.3)) { + proxy.scrollTo(id, anchor: .center) + } + } + } + } +} + +#Preview("CanvasTagBar") { + struct PreviewWrapper: View { + @StateObject private var store = Onboarding(onboardingFlowtype: .individual) + @State private var scrollTarget: UUID? + + var body: some View { + CanvasTagBar( + store: store, + scrollTarget: $scrollTarget + ) + } + } + return PreviewWrapper() +} + +extension Collection { + subscript(safe index: Index) -> Element? { + return indices.contains(index) ? self[index] : nil + } +} + +struct TagIconCapsule : View { + let image: String + let title: String + @Binding var isSelected: String + var isFirst: Bool = false + + @Binding var visited: [String] + var forceDarkGreen: Bool = false + + var body: some View { + HStack(spacing: -1) { + + if isFirst == false { + Rectangle() + .fill((forceDarkGreen || visited.contains(title)) ? .primary700 : .primary100) + .frame(width: 14, height: 12) // in figma this rectrangles width is of 12, but due to the capsule shape it looks like the rectangle is not a part of capsule to due to that the width is increased to 14, 1 from leading and trailing and adjust with the spacing. + .zIndex(1) + } + + HStack(spacing: 10) { + Image(image) + .renderingMode(.template) + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundStyle((forceDarkGreen || visited.contains(title)) ? .grayScale10 : .primary500) + .frame(width: (isSelected == title) ? 18 : 24, height: (isSelected == title) ? 18 : 24) + + if isSelected == title { + Text(title) + .font(.system(size: 11, weight: .semibold)) + } + } + .padding(.vertical,(isSelected == title) ? 11 : 8) + .padding(.trailing, (isSelected == title) ? 16 : 20) + .padding(.leading, (isSelected == title) ? 12 : 20) + .background((forceDarkGreen || visited.contains(title)) ? .primary700 : .primary100, in: .capsule) + .zIndex(10) + } + .foregroundStyle((forceDarkGreen || visited.contains(title)) ? Color.white : Color(hex: "#4A4A4A")) + } +} diff --git a/IngrediCheck/Components/CapsuleButton.swift b/IngrediCheck/Components/CapsuleButton.swift new file mode 100644 index 00000000..b933fa85 --- /dev/null +++ b/IngrediCheck/Components/CapsuleButton.swift @@ -0,0 +1,37 @@ +// +// CapsuleButton.swift +// IngrediCheckPreview +// +// Created by Gunjan Haider on 30/09/25. +// + +import SwiftUI + +struct CapsuleButton: View { + + var title: String = "Just me" + var bgColor: String = "#EBEBEB" + var fontColor: String = "000000" + var fontSize: CGFloat = 14 + var fontWeight: Font.Weight = .regular + var width: CGFloat = 120 + var height: CGFloat = 36 + var onClick: (() -> Void)? = nil + + + var body: some View { + Button { + onClick?() + } label: { + Text(title) + .font(.system(size: fontSize, weight: fontWeight)) + .foregroundStyle(Color(hex: fontColor)) + .frame(width: width, height: height) + .background(Color(hex: bgColor), in: .capsule) + } + } +} + +#Preview { + CapsuleButton() +} diff --git a/IngrediCheck/Components/ChatBubble.swift b/IngrediCheck/Components/ChatBubble.swift new file mode 100644 index 00000000..2f5e6ecb --- /dev/null +++ b/IngrediCheck/Components/ChatBubble.swift @@ -0,0 +1,197 @@ +// +// ChatBubble.swift +// IngrediCheckPreview +// +// Created by Gunjan Haldar on 06/10/25. +// + +import SwiftUI + +enum ChatRole { + case user + case assistant +} + + +struct ChatBubble: View { + // Message content + var text: String = "Would you like to explore a specific area next?" + + // Sender role for alignment (assistant on left, user on right) + var role: ChatRole = .assistant + + // Toggle to switch between the two visual variants shown in the screenshots + // Variant A: Text-only bubble + // Variant B: Text + inner white-outlined pill action + var useAlternateStyle: Bool = false + + // Optional inner pill title used in the alternate style (screenshot 2) + var pillTitle: String = "Added under Allergies" + + // Tracks measured width of the green bubble so the below row aligns with it + @State private var measuredBubbleWidth: CGFloat = 0 + + var body: some View { + HStack(alignment: .top) { + if role == .assistant { contentColumn; Spacer(minLength: 40) } + else { Spacer(minLength: 40); contentColumn } + } + } + + // MARK: - Bubble + Below Content + private var contentColumn: some View { + VStack(alignment: .leading, spacing: 12) { + bubble + if useAlternateStyle { feedbackRow } else { quickActions } + } + .onPreferenceChange(BubbleWidthKey.self) { measuredBubbleWidth = $0 } + } + + // MARK: - Bubble + private var bubble: some View { + VStack(alignment: .leading, spacing: 8) { + Text(text) + .font(ManropeFont.regular.size(12)) + .foregroundStyle(.grayScale10) + .multilineTextAlignment(.leading) + .lineSpacing(4) + + if useAlternateStyle { + HStack(spacing: 8) { + Text(pillTitle) + .font(ManropeFont.semiBold.size(10)) + Image(systemName: "arrow.up.right") + .font(.system(size: 16, weight: .semibold)) + } + .foregroundStyle(.grayScale10) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + Capsule() + .stroke(.grayScale10, lineWidth: 1) + ) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .foregroundStyle(.primary800) + ) + // Measure the bubble width for aligning the below row + .background( + GeometryReader { geo in + Color.clear.preference(key: BubbleWidthKey.self, value: geo.size.width) + } + ) + } + + // MARK: - Below Content: Variant A (Quick actions) + private var quickActions: some View { + let items = [ + "What You Eat", + "What You Avoid", + "What You Care About", + "Your Lifestyle" + ] + + return FlowLayout(horizontalSpacing: 8, verticalSpacing: 12) { + ForEach(items, id: \.self) { ele in + Text(ele) + .font(ManropeFont.medium.size(12)) + .foregroundStyle(.grayScale140) + .padding(.vertical, 8) + .padding(.horizontal, 12) + .background( + Capsule() + .stroke(lineWidth: 1) + .foregroundStyle( + LinearGradient(colors: [Color(hex: "7BA10F"), Color(hex: "B1D26C")], startPoint: .leading, endPoint: .trailing) + ) + ) + } + } + } + + // MARK: - Below Content: Variant B (Copy / Like / Dislike) + private var feedbackRow: some View { + HStack { + + HStack(spacing: 8) { + Image("copy") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(.grayScale120) + Text("Copy Text") + .font(ManropeFont.regular.size(12)) + .foregroundStyle(Color(hex: "#7F7F7F")) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 8) + .stroke(lineWidth: 0.5) + .foregroundStyle(.grayScale60) + ) + + Spacer() + + // Thumbs + HStack(spacing: 10) { + Circle() + .foregroundStyle(.grayScale10) + .frame(width: 36, height: 36) + .shadow(color: Color(hex: "FBFBFB"), radius: 9, x: 0, y: 0) + .overlay( + Image("like") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(.grayScale120) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(.grayScale80, lineWidth: 0.5) + .frame(width: 32, height: 28) + ) + + Circle() + .foregroundStyle(.grayScale10) + .frame(width: 36, height: 36) + .shadow(color: Color(hex: "FBFBFB"), radius: 9, x: 0, y: 0) + .overlay( + Image("dislike") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(.grayScale120) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(.grayScale80, lineWidth: 0.5) + .frame(width: 32, height: 28) + ) + } + } + // Constrain to the bubble width so content remains aligned regardless of text size + .frame(width: measuredBubbleWidth > 0 ? measuredBubbleWidth : nil, alignment: .leading) + } +} + +// PreferenceKey to capture rendered width of the bubble +private struct BubbleWidthKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } +} + +#Preview { + VStack(alignment: .leading, spacing: 24) { + // Variant A + ChatBubble(text: "Would you like to explore a specific area next?", + role: .assistant, + useAlternateStyle: false) + // Variant B + ChatBubble(text: "Got it πŸ‘ Keeping things natural and fresh, noted!", + role: .assistant, + useAlternateStyle: true, + pillTitle: "Added under Allergies") + } + .padding() +} diff --git a/IngrediCheck/Components/ChatbotTextField.swift b/IngrediCheck/Components/ChatbotTextField.swift new file mode 100644 index 00000000..6f398f7d --- /dev/null +++ b/IngrediCheck/Components/ChatbotTextField.swift @@ -0,0 +1,65 @@ +// +// ChatbotTextField.swift +// IngrediCheckPreview +// +// Created by Gunjan Haldar on 30/10/25. +// + +import SwiftUI + +struct ChatbotTextField: View { + @State private var text: String = "" + var onSend: (String) -> Void = { _ in } + + var body: some View { + HStack(spacing: 10) { + ZStack(alignment: .leading) { + if text.isEmpty { + Text("β€œType your answer…”") + .font(NunitoFont.italic.size(16)) + .foregroundStyle(.grayScale100) + } + + // Empty label, custom placeholder handled above + TextField("", text: $text, axis: .vertical) + .font(NunitoFont.regular.size(16)) + .foregroundStyle(.grayScale150) + .submitLabel(.send) + .onSubmit { + guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + onSend(text) + text.removeAll() + } + } + + Button { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + onSend(trimmed) + text.removeAll() + } label: { + Image("chatbot-send") + .resizable() + .frame(width: 28, height: 28) + } + .accessibilityLabel("Send") + } + .padding(.horizontal, 16) + .padding(.vertical, 16) + .background( + RoundedRectangle(cornerRadius: 16) + .foregroundStyle(.grayScale10) + .shadow(color: Color(hex: "ECECEC"), radius: 9, x: 0, y: 0) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(lineWidth: 0.5) + .foregroundStyle(.grayScale60) + ) + ) + .dismissKeyboardOnTap() + } +} + +#Preview { + ChatbotTextField() +} diff --git a/IngrediCheck/Components/CollapseFamilyList.swift b/IngrediCheck/Components/CollapseFamilyList.swift new file mode 100644 index 00000000..4d04d153 --- /dev/null +++ b/IngrediCheck/Components/CollapseFamilyList.swift @@ -0,0 +1,164 @@ +// +// CollapseFamilyList.swift +// IngrediCheckPreview +// +// Created by Gunjan Haider on 30/09/25. +// +// can delete this file + +import SwiftUI + +struct CollapseFamilyList: View { + + @Binding var collapsed: Bool + @Binding var familyNames: [UserModel] + @Binding var selectedItem: UserModel + + var body: some View { +// ScrollView { + ZStack { + ZStack { + ForEach(Array(familyNames.reversed().enumerated()), id: \.element.id) { idx, ele in + let correctIdx = (idx - familyNames.count + 1) * -1 + + if correctIdx < 3 { + nameRow(name: ele, isSelected: false) + .offset(y: collapsed ? CGFloat(correctIdx) * 70 : CGFloat(correctIdx * 8)) + .opacity(collapsed ? 1 : 1.0 - Double(correctIdx + 1) * 0.2) + .padding(.horizontal, collapsed ? 0 : CGFloat(correctIdx + 1) * 10) + .onTapGesture { + if collapsed { + selectedItemPressed(item: ele) + } + withAnimation(.spring(dampingFraction: 0.7)) { + collapsed.toggle() + } + } + } else { + nameRow(name: ele, isSelected: false) + .offset(y: collapsed ? CGFloat(correctIdx) * 70 : 0) + .opacity(collapsed ? 1 : 0) +// .padding(.horizontal, collapsed ? 0 : CGFloat(correctIdx + 1) * 10) + .onTapGesture { + if collapsed { + selectedItemPressed(item: ele) + } + withAnimation(.spring(dampingFraction: 0.7)) { + collapsed.toggle() + } + } + } + } + } + .offset(y: collapsed ? 70 : 10) + +// if let selectedItem { + nameRow(name: selectedItem, isSelected: true) + .onTapGesture { + withAnimation(.spring(dampingFraction: 0.7)) { + collapsed.toggle() + } + } +// } + } + .frame(height: collapsed ? CGFloat(familyNames.count + 1) * 70 : 60, alignment: .top) +// .background(.blue) +// .padding(.horizontal) +// } +// .onAppear() { +// if let first = familyNames.first { +// selectedItemPressed(item: first) +// } +// } + } + + func selectedItemPressed(item: UserModel) { + let temp = selectedItem + selectedItem = item + familyNames.removeAll { $0.name == item.name } // removes matching value +// if let temp { + familyNames.append(temp) +// } + } + + @ViewBuilder + func nameRow(name: UserModel, isSelected: Bool) -> some View { + HStack { + HStack(spacing: 8) { + ZStack { + Circle() + .frame(width: 36, height: 36) + .foregroundStyle(Color(hex: "F9F9F9")) + + Image(name.image) + .resizable() + .frame(width: 30, height: 30) + .shadow(color: Color(hex: "DEDDDD"), radius: 3.5, x: 0, y: 0) + } + + Text(name.name) + .font(ManropeFont.medium.size(14)) + .foregroundStyle(.grayScale150) + } + + Spacer() + + Circle() + .stroke(lineWidth: 0.5) + .foregroundStyle(isSelected ? .primary400 : .grayScale60) + .frame(width: 28, height: 28) + .overlay( + Circle() + .frame(width: 16, height: 16) + .foregroundStyle(isSelected ? .primary500 : Color(hex: "FFFFFF")) + .shadow(color: Color(hex: "B5B5B5").opacity(0.4), radius: 5, x: 0, y: 0) + ) + } + .padding(.horizontal, 12) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 19) + .stroke(lineWidth: 0.5) + .foregroundStyle(.grayScale80) + ) + .background( + RoundedRectangle(cornerRadius: 19) + .fill(.grayScale10) + ) + } +} + +#Preview { + CollapseFamilyList( + collapsed: .constant(true), + familyNames: .constant([ + UserModel(familyMemberName: "Grandfather", familyMemberImage: "image-bg3"), + UserModel(familyMemberName: "Grandmother", familyMemberImage: "image-bg2"), + UserModel(familyMemberName: "Daughter", familyMemberImage: "image-bg5"), + UserModel(familyMemberName: "Brother", familyMemberImage: "image-bg4") + ]) , + selectedItem: .constant(UserModel(familyMemberName: "Brother", familyMemberImage: "image-bg4")) + ) +// .padding(.horizontal, 10) +} + + +// MARK: Below numbers are for refrence and testing + +//nameRow(name: "younger") +// .offset(y: collapsed ? 24 : 180) +// .opacity(collapsed ? 0.4 : 1) +// .padding(.horizontal, collapsed ? 30 : 0) +// +//nameRow(name: "elder") +// .offset(y: collapsed ? 16 : 120) +// .opacity(collapsed ? 0.6 : 1) +// .padding(.horizontal, collapsed ? 20 : 0) +// +// +//nameRow(name: "mother") +// .offset(y: collapsed ? 8 : 60) +// .opacity(collapsed ? 0.8 : 1) +// .padding(.horizontal, collapsed ? 10 : 0) +// +//nameRow(name: "father") diff --git a/IngrediCheck/Components/ConfettiView.swift b/IngrediCheck/Components/ConfettiView.swift new file mode 100644 index 00000000..ab263773 --- /dev/null +++ b/IngrediCheck/Components/ConfettiView.swift @@ -0,0 +1,106 @@ +// +// ConfettiView.swift +// IngrediCheckPreview +// +// Created on 12/12/25. +// + +import SwiftUI + +struct ConfettiView: View { + @State private var confettiParticles: [ConfettiParticle] = [] + + let colors: [Color] = [ + Color(hex: "9DCF10"), // Green + Color(hex: "FFD700"), // Gold + Color(hex: "FF6B6B"), // Red + Color(hex: "4ECDC4"), // Teal + Color(hex: "95E1D3"), // Mint + Color(hex: "F38181"), // Pink + ] + + var body: some View { + GeometryReader { geometry in + ZStack { + ForEach(confettiParticles) { particle in + RoundedRectangle(cornerRadius: 2) + .fill(particle.color) + .frame(width: particle.size, height: particle.size * 1.5) + .rotationEffect(.degrees(particle.rotation)) + .position( + x: particle.x, + y: particle.y + ) + .opacity(particle.opacity) + } + } + .onAppear { + startConfetti(in: geometry.size) + } + } + .allowsHitTesting(false) + } + + private func startConfetti(in size: CGSize) { + confettiParticles = [] + let particleCount = 60 + + for i in 0.. some View { + Circle() + .frame(width: size, height: size) + .foregroundStyle(color) + .overlay( + Image(imageName) + .resizable() + .frame(width: 47.5, height: 47.5) + + ) + .clipShape(.circle) + .overlay( + Circle() + .stroke(lineWidth: 1.5) + .foregroundStyle(.grayScale10) + ) + + .shadow(color: Color(hex: "#ECECEC"), radius: 8.9, x: 0, y: 0) + } +} + +#Preview { + CreateYourAvatarCard() + .padding(.horizontal, 20) +} diff --git a/IngrediCheck/Components/CustomIngrediCheckProgressBar.swift b/IngrediCheck/Components/CustomIngrediCheckProgressBar.swift new file mode 100644 index 00000000..83c81dd0 --- /dev/null +++ b/IngrediCheck/Components/CustomIngrediCheckProgressBar.swift @@ -0,0 +1,74 @@ +// +// CustomIngrediCheckProgressBar.swift +// IngrediCheckPreview +// +// Created by Gunjan Haldar on 03/10/25. +// + +import SwiftUI + +struct CustomIngrediCheckProgressBar: View { + + var progress: CGFloat + + var body: some View { + ZStack(alignment: .leading) { + + Capsule() + .fill(Color(hex: "#EEEEEE")) + .frame(height: 4) + .padding(.horizontal, 20) + + ZStack(alignment: .trailing) { + Capsule() + .fill(.secondary600) + .frame(width: (UIScreen.main.bounds.width - 40) * progress / 100, height: 4) + .padding(.horizontal, 20) + .animation(.smooth(duration: 0.35), value: progress) + + VStack(spacing: 0) { + Text("\(Int(progress))%") + .font(NunitoFont.bold.size(12)) + .foregroundStyle(.primary700) + + ZStack(alignment: .center) { + Image("orange") + .resizable() + .frame(width: 17.39, height: 17.83) + .offset(y: -3) + .scaleEffect(progress > 0 ? 1 :1.2) + + Image("magnify") + .resizable() + .frame(width: 24, height: 27.16) + .offset(x: -2) + .opacity(progress > 0 ? 1 : 0) + .animation(.smooth, value: progress) + } + } + .padding(.trailing, 12) + .offset(x: 12,y: -8) + } + + +// VStack { +// +// +// +// Button { +// withAnimation(.smooth) { +// // progress += 10 +// } +// } label: { +// Text("Press") +// } +// +// Spacer() +// } + } + } +} + +#Preview { + CustomIngrediCheckProgressBar(progress: 0) +} diff --git a/IngrediCheck/Components/EmptyStateView.swift b/IngrediCheck/Components/EmptyStateView.swift new file mode 100644 index 00000000..98dd64c6 --- /dev/null +++ b/IngrediCheck/Components/EmptyStateView.swift @@ -0,0 +1,95 @@ +// +// EmptyStateView.swift +// IngrediCheck +// +// Created on 30/01/25. +// + +import SwiftUI + +struct EmptyStateView: View { + let imageName: String + let title: String + let description: [String] + let buttonTitle: String? + let buttonAction: (() -> Void)? + + init( + imageName: String, + title: String, + description: [String], + buttonTitle: String? = nil, + buttonAction: (() -> Void)? = nil + ) { + self.imageName = imageName + self.title = title + self.description = description + self.buttonTitle = buttonTitle + self.buttonAction = buttonAction + } + + var body: some View { + VStack { + VStack { + Image(imageName) + .resizable() + .scaledToFit() + + VStack(spacing: 0) { + Text(title) + .font(ManropeFont.bold.size(16)) + .foregroundStyle(.grayScale150) + + ForEach(description, id: \.self) { line in + Text(line) + .font(ManropeFont.regular.size(13)) + .foregroundStyle(.grayScale100) + .multilineTextAlignment(.center) + } + + if let buttonTitle = buttonTitle, let buttonAction = buttonAction { + Button { + buttonAction() + } label: { + GreenCapsule( + title: buttonTitle, + width: 159, + height: 52, + takeFullWidth: false, + labelFont: ManropeFont.bold.size(16) + ) + } + .padding(.top, 24) + .buttonStyle(.plain) + } + } + .offset(y: -UIScreen.main.bounds.height * 0.2) + } + } + } +} + +#Preview("With Button") { + EmptyStateView( + imageName: "history-emptystate", + title: "No Scans !", + description: [ + "Your recent scans will appear here once", + "you start scanning products." + ], + buttonTitle: "Start Scanning", + buttonAction: { + print("Start scanning tapped") + } + ) +} + +#Preview("Without Button") { + EmptyStateView( + imageName: "history-emptystate", + title: "No Items", + description: [ + "There are no items to display." + ] + ) +} diff --git a/IngrediCheck/Components/FamilyCarouselView.swift b/IngrediCheck/Components/FamilyCarouselView.swift new file mode 100644 index 00000000..3775ddac --- /dev/null +++ b/IngrediCheck/Components/FamilyCarouselView.swift @@ -0,0 +1,278 @@ +// +// FamilyCarouselView.swift +// IngrediCheckPreview +// +// Created by Gunjan Haldar on 01/10/25. +// + +import SwiftUI + +struct FamilyCarouselView: View { + @Environment(FamilyStore.self) private var familyStore + @Environment(WebService.self) private var webService + @Environment(FoodNotesStore.self) private var foodNotesStore + @EnvironmentObject private var store: Onboarding + @Environment(AppNavigationCoordinator.self) private var coordinator + + @State var selectedFamilyMember: UserModel? = nil + + /// When true, only the target member capsule is interactive. + private var isLocked: Bool { coordinator.isAddingPreferencesForMember } + private var lockedMemberId: String? { coordinator.addPreferencesForMemberId?.uuidString } + + // Convert FamilyMember objects to UserModel format + private var familyMembersList: [UserModel] { + var members: [UserModel] = [] + + // Always include "Everyone" as the first option + members.append( + UserModel( + id: "everyone", + familyMemberName: "Everyone", + familyMemberImage: "Everyone", + backgroundColor: .clear + ) + ) + + // Add actual family members from FamilyStore + if let family = familyStore.family { + // Add self member + members.append( + UserModel( + id: family.selfMember.id.uuidString, + familyMemberName: family.selfMember.name, + familyMemberImage: family.selfMember.name, // Use name as image identifier + backgroundColor: Color(hex: family.selfMember.color) + ) + ) + + // Add other members + for otherMember in family.otherMembers { + members.append( + UserModel( + id: otherMember.id.uuidString, + familyMemberName: otherMember.name, + familyMemberImage: otherMember.name, // Use name as image identifier + backgroundColor: Color(hex: otherMember.color) + ) + ) + } + } + + return members + } + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 18) { + ForEach(familyMembersList, id: \.id) { ele in + let isTarget = isLocked && ele.id == lockedMemberId + let isSelectedState = isLocked ? isTarget : (ele.id == selectedFamilyMember?.id) + + FamilyCarouselMemberAvatarView( + memberIdentifier: ele.id, + name: ele.name, + color: ele.backgroundColor ?? .clear, + isSelected: isSelectedState + ) + .saturation(isLocked && !isTarget ? 0 : 1) + .allowsHitTesting(!(isLocked && !isTarget)) + .onTapGesture { + Task { + await selectFamilyMember(ele: ele) + } + } + } + } + } + .onAppear { + // When locked to a specific member, force-select that member + if isLocked, let lockedId = lockedMemberId, + let match = familyMembersList.first(where: { $0.id == lockedId }) { + selectedFamilyMember = match + return + } + + // Initialize selection based on familyStore.selectedMemberId if set, + // otherwise default to "Everyone" + if selectedFamilyMember == nil { + if let memberId = familyStore.selectedMemberId { + // A specific member was selected (e.g., from Food Notes filter) + // Find and select that member + if let matchingMember = familyMembersList.first(where: { $0.id == memberId.uuidString }) { + selectedFamilyMember = matchingMember + } else { + // Fallback to "Everyone" if member not found + selectedFamilyMember = UserModel( + id: "everyone", + familyMemberName: "Everyone", + familyMemberImage: "Everyone", + backgroundColor: .clear + ) + } + } else { + // No specific member selected, default to "Everyone" + selectedFamilyMember = UserModel( + id: "everyone", + familyMemberName: "Everyone", + familyMemberImage: "Everyone", + backgroundColor: .clear + ) + } + } + } + } + + @MainActor + func selectFamilyMember(ele: UserModel) async { + Log.debug("FamilyCarouselView", "selectFamilyMember: Tapped member name=\(ele.name), id=\(ele.id)") + withAnimation(.spring(response: 0.35, dampingFraction: 0.7)) { + selectedFamilyMember = ele + } + + // "everyone" is our sentinel ID for the family-level note + let memberId: String? = (ele.id == "everyone") ? nil : ele.id + + // Keep FamilyStore in sync so other components (like EditableCanvasView) + // know whether we are editing at family level or for a specific member. + if let memberId, let uuid = UUID(uuidString: memberId) { + Log.debug("FamilyCarouselView", "selectFamilyMember: Setting FamilyStore.selectedMemberId=\(uuid)") + familyStore.selectedMemberId = uuid + } else { + Log.debug("FamilyCarouselView", "selectFamilyMember: Setting FamilyStore.selectedMemberId=nil (Everyone)") + familyStore.selectedMemberId = nil + } + + // Load food notes for the selected member using FoodNotesStore + if let memberId = memberId { + await foodNotesStore.loadFoodNotesForMember(memberId: memberId) + } else { + await foodNotesStore.loadFoodNotesForFamily() + } + } +} + +// MARK: - Family Carousel Member Avatar View + +/// Avatar view used in FamilyCarouselView to show actual member memoji avatars. +struct FamilyCarouselMemberAvatarView: View { + @Environment(FamilyStore.self) private var familyStore + + let memberIdentifier: String // "everyone" or member UUID string + let name: String? + let color: Color + let isSelected: Bool + + var body: some View { + VStack(spacing: 4) { + ZStack { + if isSelected { + Circle() + .strokeBorder(Color(hex: "91B640"), lineWidth: 2) + .frame(width: 52, height: 52) + } + + if memberIdentifier == "everyone" { + // "Everyone" option + Circle() + .frame(width: 46, height: 46) + .foregroundStyle( + LinearGradient(colors: [Color(hex: "FFC552"), Color(hex: "FFAA28")], startPoint: .top, endPoint: .bottom) + ) + .overlay { + Image("Everyone") + .resizable() + .scaledToFill() + .frame(width: 28, height: 28) + } + } else { + // Individual member - use centralized MemberAvatar component + if let member = resolvedMember { + MemberAvatar.custom(member: member, size: 46, imagePadding: 0) + } else { + // Fallback for "everyone" case + Circle() + .fill(color) + .frame(width: 46, height: 46) + .overlay( + Circle() + .stroke(lineWidth: 1) + .foregroundStyle(Color.white) + ) + } + } + } + + if let name = name { + Text(name) + .font(isSelected ? ManropeFont.bold.size(10) : ManropeFont.regular.size(10)) + .foregroundStyle(isSelected ? Color(hex: "91B640") : .grayScale130) + } + } + .animation(.spring(response: 0.35, dampingFraction: 0.7), value: isSelected) + } + + private var resolvedMember: FamilyMember? { + guard memberIdentifier != "everyone", + let uuid = UUID(uuidString: memberIdentifier), + let family = familyStore.family else { + return nil + } + + if uuid == family.selfMember.id { + return family.selfMember + } + return family.otherMembers.first { $0.id == uuid } + } +} + +#Preview { + let webService = WebService() + let onboarding = Onboarding(onboardingFlowtype: .family) + let foodNotesStore = FoodNotesStore(webService: webService, onboardingStore: onboarding) + + // Create mock family with multiple members + let familyStore = FamilyStore() + let mockFamily = Family( + name: "Smith Family", + selfMember: FamilyMember( + id: UUID(), + name: "Alex", + color: "#FFB3BA", + joined: true, + imageFileHash: "memoji_1" + ), + otherMembers: [ + FamilyMember( + id: UUID(), + name: "Jordan", + color: "#BAFFC9", + joined: true, + imageFileHash: "memoji_2" + ), + FamilyMember( + id: UUID(), + name: "Taylor", + color: "#BAE1FF", + joined: false, + imageFileHash: "memoji_3" + ), + FamilyMember( + id: UUID(), + name: "Sam", + color: "#E0BBE4", + joined: true, + imageFileHash: "memoji_4" + ) + ], + version: 1 + ) + familyStore.setMockFamilyForPreview(mockFamily) + + return FamilyCarouselView() + .environment(familyStore) + .environmentObject(onboarding) + .environment(AppNavigationCoordinator()) + .environment(webService) + .environment(foodNotesStore) +} diff --git a/IngrediCheck/Components/FilterSegmentedControl.swift b/IngrediCheck/Components/FilterSegmentedControl.swift new file mode 100644 index 00000000..6a84ecac --- /dev/null +++ b/IngrediCheck/Components/FilterSegmentedControl.swift @@ -0,0 +1,78 @@ +// +// FilterSegmentedControl.swift +// IngrediCheck +// +// Segmented control for filtering recent scans between All and Favorites +// + +import SwiftUI + +enum RecentScansFilter { + case all + case favorites +} + +struct FilterSegmentedControl: View { + @Binding var selection: RecentScansFilter + + var body: some View { + HStack(spacing: 0) { + // All button + Button { + withAnimation(.easeInOut(duration: 0.2)) { + selection = .all + } + } label: { + Text("All") + .font(NunitoFont.semiBold.size(12)) + .foregroundStyle(selection == .all ? .grayScale140 : .grayScale100) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background( + Capsule() + .fill(selection == .all ? Color.white : Color.clear) + ) + } + .buttonStyle(.plain) + + // Favorites button + Button { + withAnimation(.easeInOut(duration: 0.2)) { + selection = .favorites + } + } label: { + HStack(spacing: 3) { + Image(systemName: "heart.fill") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(selection == .favorites ? .red : .grayScale100) + + Text("Fav") + .font(NunitoFont.semiBold.size(12)) + .foregroundStyle(selection == .favorites ? .grayScale140 : .grayScale100) + } + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background( + Capsule() + .fill(selection == .favorites ? Color.white : Color.clear) + ) + } + .buttonStyle(.plain) + } + .padding(2) + .frame(width: 105, height: 31) + .background( + Capsule() + .stroke(Color.white) + ) + } +} + +#Preview { + VStack(spacing: 20) { + FilterSegmentedControl(selection: .constant(.all)) + FilterSegmentedControl(selection: .constant(.favorites)) + } + .padding() + .background(Color.pageBackground) +} diff --git a/IngrediCheck/Components/FormatedTextCard.swift b/IngrediCheck/Components/FormatedTextCard.swift new file mode 100644 index 00000000..8ef3c03a --- /dev/null +++ b/IngrediCheck/Components/FormatedTextCard.swift @@ -0,0 +1,87 @@ +// +// FormatedTextCard.swift +// IngrediCheck +// +// Created by Gaurav on 09/01/26. +// + +import Foundation +func formatCardText( + _ text: String, + maxCharsPerLine: Int = 28, + maxLines: Int = 4 +) -> String { + + // NOTE: + // We intentionally do NOT insert manual "\n" here. + // SwiftUI should wrap naturally based on the available width. + // This function focuses on producing a clean, stable string so + // MultiColorText never renders wrapped lines with leading-space indentation. + + // 1) Trim surrounding quotes and whitespace/newlines. + let trimmed = text + .trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + .trimmingCharacters(in: .whitespacesAndNewlines) + + // 2) Collapse all internal whitespace (including newlines/tabs) to a single space. + let collapsed = trimmed + .components(separatedBy: .whitespacesAndNewlines) + .filter { !$0.isEmpty } + .joined(separator: " ") + + // 3) Preserve '*' markers for MultiColorText coloring, but ensure that + // any whitespace AFTER a closing '*' is moved INSIDE the highlighted segment. + // This prevents wrapped lines from starting with a space when SwiftUI wraps + // at a boundary between Text segments. + var result = "" + result.reserveCapacity(collapsed.count) + + let chars = Array(collapsed) + var i = 0 + var isInsideHighlight = false + + while i < chars.count { + let ch = chars[i] + + if ch == "*" { + if isInsideHighlight { + // Closing '*': move any following spaces inside before closing. + var j = i + 1 + var sawWhitespace = false + while j < chars.count, chars[j].isWhitespace { + sawWhitespace = true + j += 1 + } + + if sawWhitespace { + result.append(" ") + result.append("*") + i = j + } else { + result.append("*") + i += 1 + } + + isInsideHighlight = false + continue + } else { + // Opening '*' + isInsideHighlight = true + result.append("*") + i += 1 + continue + } + } + + result.append(ch) + i += 1 + } + + // 4) Final cleanup: trim and collapse repeated spaces WITHOUT splitting tokens. + // Using components(separatedBy:) here would break '*' markers (e.g. "*or *dietary"). + var cleaned = result.trimmingCharacters(in: .whitespacesAndNewlines) + while cleaned.contains(" ") { + cleaned = cleaned.replacingOccurrences(of: " ", with: " ") + } + return cleaned +} diff --git a/IngrediCheck/Components/GenerateAvatarToolPill.swift b/IngrediCheck/Components/GenerateAvatarToolPill.swift new file mode 100644 index 00000000..f84f030f --- /dev/null +++ b/IngrediCheck/Components/GenerateAvatarToolPill.swift @@ -0,0 +1,50 @@ +// +// GenerateAvatarToolPill.swift +// IngrediCheckPreview +// +// Created by Gunjan Haldar on 06/10/25. +// + +import SwiftUI + +struct GenerateAvatarToolPill: View { + var icon: String = "family-member" + var title: String = "Family Member" + @Binding var isSelected: String + var selectedItemIcon: String? = nil // Icon of the selected item within this tool category + var primaryIcon: String? = nil // Primary icon name for selected state (e.g., "family-member-Primary") + var onTap: (() -> Void)? = nil + var body: some View { + VStack(spacing: 9) { + // When selected, use primaryIcon if provided, otherwise use default icon + // When not selected, use default icon + let iconToDisplay = (isSelected == icon && primaryIcon != nil) ? primaryIcon! : icon + Image(iconToDisplay) + .renderingMode(.original) // Add this line to prevent template rendering + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 28, height: 28) + .clipped() + + + } + .contentShape(Rectangle()) + .onTapGesture { + withAnimation(.easeInOut(duration: 0.4)) { + onTap?() + } + } + } +} + +#Preview { + ZStack { + Color.gray.opacity(0.1).ignoresSafeArea() + HStack { + GenerateAvatarToolPill(isSelected: .constant("family-member")) + GenerateAvatarToolPill(isSelected: .constant("")) + GenerateAvatarToolPill(isSelected: .constant("")) + } + + } +} diff --git a/IngrediCheck/Components/IngrediBotWithText.swift b/IngrediCheck/Components/IngrediBotWithText.swift new file mode 100644 index 00000000..6f031f41 --- /dev/null +++ b/IngrediCheck/Components/IngrediBotWithText.swift @@ -0,0 +1,120 @@ +// +// IngrediBotWithText.swift +// IngrediCheckPreview +// +// Created on 13/11/25. +// + +import SwiftUI + +struct IngrediBotWithText: View { + let text: String + var showBackgroundImage: Bool = true + var viewDidAppear: (() -> Void)? = nil + var delay: TimeInterval = 2.0 + @State private var backgroundOpacity: Double = 0.3 + @State private var shimmerOffset: CGFloat = -200 + @State private var botOffsetX: CGFloat = 0 + @State private var botOffsetY: CGFloat = 0 + + var body: some View { + VStack( ) { + ZStack{ + if showBackgroundImage { + Image("backgroundimage") + .resizable() + .scaledToFit() + .clipped() + .frame(width: 335, height: 199) + .opacity(backgroundOpacity) + } + Image("ingrediBot") + .resizable() + .scaledToFit() + .frame(width: 147, height: 147) + .clipped() + .offset(x: botOffsetX, y: botOffsetY) + .overlay( + // Shimmer effect + LinearGradient( + gradient: Gradient(colors: [ + Color.clear, + Color.white.opacity(0.4), + Color.white.opacity(0.6), + Color.white.opacity(0.4), + Color.clear + ]), + startPoint: .leading, + endPoint: .trailing + ) + .frame(width: 70) + .offset(x: shimmerOffset) + .blendMode(.overlay) + ) + } + + VStack(spacing: 24) { + Text(text) + .font(NunitoFont.bold.size(20)) + .foregroundStyle(.grayScale150) + .multilineTextAlignment(.center) + .offset(x : 0, y : -30) + } + + } + .padding(.horizontal, 20) + .frame(maxWidth: .infinity, maxHeight: .infinity) +// .overlay( +// RoundedRectangle(cornerRadius: 4) +// .fill(.neutral500) +// .frame(width: 60, height: 4) +// .padding(.top, 11) +// , alignment: .top +// ) + .onAppear() { + // Start the fade animation + if showBackgroundImage { + withAnimation(.easeInOut(duration: 2.0).repeatForever(autoreverses: true)) { + backgroundOpacity = 1.0 + } + } + + // Start the shimmer animation - continuously loop from left to right + shimmerOffset = -200 + withAnimation(.linear(duration: 3.6).repeatForever(autoreverses: false)) { + shimmerOffset = 200 + } + + // Start the robot movement animation - smooth floating movement + // Horizontal movement (left-right) + withAnimation(.easeInOut(duration: 3.0).repeatForever(autoreverses: true)) { + botOffsetX = 8 + } + + // Vertical movement (up-down) with slight delay for more natural movement + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + withAnimation(.easeInOut(duration: 2.5).repeatForever(autoreverses: true)) { + botOffsetY = -6 + } + } + + if let viewDidAppear = viewDidAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + viewDidAppear() + } + } + } + .onDisappear { + // Reset positions when view disappears + botOffsetX = 0 + botOffsetY = 0 + shimmerOffset = -200 + backgroundOpacity = 0.3 + } + } +} + +#Preview { + IngrediBotWithText(text: "Bringing your avatar to life... it's going to be awesome!") +} + diff --git a/IngrediCheck/Components/IngredientsChips.swift b/IngrediCheck/Components/IngredientsChips.swift new file mode 100644 index 00000000..8bbcf435 --- /dev/null +++ b/IngrediCheck/Components/IngredientsChips.swift @@ -0,0 +1,191 @@ +// +// IngredientsChips.swift +// IngrediCheckPreview +// +// Created by Gunjan Haider on 30/09/25. +// + +import SwiftUI + +struct IngredientsChips: View { + var title: String = "Peanuts" + @State var bgColor: Color? = nil + var fontColor: String = "000000" + var fontSize: CGFloat = 12 + var fontWeight: Font.Weight = .regular + var image: String? = nil + var familyList: [String] = [] // Can be "Everyone" or member IDs (UUID strings) + var onClick: (() -> Void)? = nil + var isSelected: Bool = false + var outlined: Bool = true + + @Environment(FamilyStore.self) private var familyStore + @Environment(WebService.self) private var webService + + var body: some View { + Button { + onClick?() + } label: { + HStack(spacing: 8) { + if let image = image { + Text(image) + .font(.system(size: 18)) + .frame(width: 24, height: 24) + } + + Text(familyList.isEmpty ? title : String(title.prefix(25)) + (title.count > 25 ? "..." : "")) + .font(ManropeFont.medium.size(14)) + .foregroundStyle(isSelected ? .primary100 : Color(hex: fontColor)) + .lineLimit(1) + .truncationMode(.tail) + + if !familyList.isEmpty { + HStack(spacing: -7) { + ForEach(familyList.prefix(4), id: \.self) { memberIdentifier in + ChipMemberAvatarView(memberIdentifier: memberIdentifier) + } + } + } + } + .padding(.vertical, (image != nil) ? 6 : 7.5) + .padding(.trailing, !familyList.isEmpty ? 8 : 16) + .padding(.leading, (image != nil) ? 12 : 16) + .background( + (bgColor != nil) + ? LinearGradient( + gradient: Gradient(stops: [ + .init(color: bgColor ?? .white, location: 1.0) + ]), + startPoint: .top, + endPoint: .bottom + ) + : isSelected + ? LinearGradient( + colors: [Color(hex: "9DCF10"), Color(hex: "6B8E06")], + startPoint: .top, + endPoint: .bottom + ) + : LinearGradient( + gradient: Gradient(stops: [ + .init(color: .clear, location: 1.0) + ]), + startPoint: .top, + endPoint: .bottom + ) + , in: .capsule + ) + .overlay( + Capsule() + .stroke(lineWidth: (isSelected || outlined == false) ? 0 : 1) + .foregroundStyle(.grayScale60) + ) + } + } + + // MARK: - Chip Member Avatar View + + /// Small avatar (24x24) used on chips to show which member selected an item. + /// Shows the member's memoji if imageFileHash is present, otherwise shows + /// "Everyone" icon or the first letter of their name. + struct ChipMemberAvatarView: View { + @Environment(FamilyStore.self) private var familyStore + + let memberIdentifier: String // "Everyone" or member UUID string + + var body: some View { + Group { + if memberIdentifier == "Everyone" { + // Show "Everyone" icon with background circle + Circle() + .fill( + LinearGradient( + colors: [Color(hex: "#FFC552"), Color(hex: "#FFAA28")], + startPoint: .top, + endPoint: .bottom + ) + ) + .frame(width: 24, height: 24) + .overlay { + Image("Everyone") + .resizable() + .scaledToFill() + .frame(width: 22, height: 22) + .clipShape(Circle()) + } + .overlay { + Circle() + .stroke(lineWidth: 1) + .foregroundStyle(Color.white) + } + } else { + // Use centralized MemberAvatar component for individual members + if let member = resolvedMember { + MemberAvatar.custom(member: member, size: 24, imagePadding: 0) + } else { + // Fallback circle if member not found + Circle() + .fill(circleBackgroundColor) + .frame(width: 24, height: 24) + .overlay( + Circle() + .stroke(lineWidth: 1) + .foregroundStyle(Color.white) + ) + } + } + } + } + + private var circleBackgroundColor: Color { + if memberIdentifier == "Everyone" { + return Color(hex: "#D9D9D9") + } + if let member = resolvedMember { + return Color(hex: member.color) + } + return Color(hex: "#D9D9D9") + } + + private var resolvedMember: FamilyMember? { + guard memberIdentifier != "Everyone", + let uuid = UUID(uuidString: memberIdentifier), + let family = familyStore.family else { + return nil + } + + if uuid == family.selfMember.id { + return family.selfMember + } + return family.otherMembers.first { $0.id == uuid } + } + } + + +// #Preview { +// VStack { +// IngredientsChips( +// title: "Peanuts", +// image: "πŸ₯œ" +// ) +// IngredientsChips( +// title: "Sellfish", +// image: "🦐" +// ) +// IngredientsChips( +// title: "Wheat", +// image: "🌾" +// ) +// IngredientsChips( +// title: "Sesame", +// image: "❀️" +// ) +// IngredientsChips(title: "India & South Asia") +// IngredientsChips( +// title: "Peanuts", +// image: "πŸ₯œ", +// familyList: ["image 1", "image 2", "image 3"] +// ) +// } +// +// } +} diff --git a/IngrediCheck/Components/IngredientsChipsForStackedCards.swift b/IngrediCheck/Components/IngredientsChipsForStackedCards.swift new file mode 100644 index 00000000..c8033127 --- /dev/null +++ b/IngrediCheck/Components/IngredientsChipsForStackedCards.swift @@ -0,0 +1,92 @@ +// +// IngrediChipsForStackedCards.swift +// IngrediCheckPreview +// +// Created by Gunjan Haldar on 17/11/25. +// + +import SwiftUI + +struct IngredientsChipsForStackedCards: View { + var title: String = "Peanuts" + @State var bgColor: Color? = nil + var fontColor: String = "000000" + var fontSize: CGFloat = 12 + var fontWeight: Font.Weight = .regular + var image: String? = nil + var familyList: [String] = [] + var onClick: (() -> Void)? = nil + var isSelected: Bool = false + var outlined: Bool = false + + var body: some View { + Button { + onClick?() + } label: { + HStack(spacing: 8) { + if let image = image { + Text(image) + .font(.system(size: 18)) + .frame(width: 24, height: 24) + } + + Text(familyList.isEmpty ? title : String(title.prefix(25)) + (title.count > 25 ? "..." : "")) + .font(ManropeFont.medium.size(14)) + .foregroundStyle(isSelected ? .primary100 : Color(hex: fontColor)) + .lineLimit(1) + .truncationMode(.tail) + + if !familyList.isEmpty { + HStack(spacing: -7) { + ForEach(familyList.prefix(4), id: \.self) { image in + familyIcon(image: image) + } + } + } + } + .padding(.vertical, (image != nil) ? 6 : 7.5) + .padding(.trailing, !familyList.isEmpty ? 8 : 16) + .padding(.leading, (image != nil) ? 12 : 16) + .background( + (isSelected == false) + ? LinearGradient( + gradient: Gradient(stops: [ + .init(color: bgColor ?? .white, location: 1.0) + ]), + startPoint: .top, + endPoint: .bottom + ) + : LinearGradient( + colors: [Color(hex: "9DCF10"), Color(hex: "6B8E06")], + startPoint: .top, + endPoint: .bottom + ) + , in: .capsule + ) + .overlay( + Capsule() + .stroke(lineWidth: (isSelected || outlined == false) ? 0 : 1) + .foregroundStyle(.grayScale60) + ) + } + } + + @ViewBuilder + func familyIcon(image: String) -> some View { + Circle() + .stroke(lineWidth: 1) + .frame(width: 24, height: 24) + .foregroundStyle(Color(hex: "#B6B6B6")) + .background(Color(hex: "#D9D9D9")) + .overlay( + Image(image) + .resizable() + .frame(width: 24, height: 24) + ) + } + +} + +#Preview { + IngredientsChipsForStackedCards() +} diff --git a/IngrediCheck/Components/LegalDisclaimerView.swift b/IngrediCheck/Components/LegalDisclaimerView.swift new file mode 100644 index 00000000..ccb9c023 --- /dev/null +++ b/IngrediCheck/Components/LegalDisclaimerView.swift @@ -0,0 +1,36 @@ +import SwiftUI + +/// Reusable legal disclaimer view with Terms of Use and Privacy Policy links +struct LegalDisclaimerView: View { + var showShieldIcon: Bool = true + + private let termsURL = "https://www.ingredicheck.app/terms-conditions" + private let privacyURL = "https://www.ingredicheck.app/privacy-policy" + + private var legalAttributedString: AttributedString { + let markdown = "Review my **[Terms of Use](\(termsURL))** and **[Privacy Policy](\(privacyURL))**." + return (try? AttributedString(markdown: markdown)) ?? AttributedString(markdown) + } + + var body: some View { + HStack { + if showShieldIcon { + Image("jam-sheld-half") + .frame(width: 16, height: 16) + } + Text(legalAttributedString) + .multilineTextAlignment(.center) + .font(showShieldIcon ? ManropeFont.regular.size(12) : .footnote) + .tint(.paletteAccent) + .foregroundStyle(showShieldIcon ? Color.grayScale100 : .primary) + } + } +} + +#Preview("With Shield Icon") { + LegalDisclaimerView(showShieldIcon: true) +} + +#Preview("Without Shield Icon") { + LegalDisclaimerView(showShieldIcon: false) +} diff --git a/IngrediCheck/Components/LifestyleAndChoicesCard.swift b/IngrediCheck/Components/LifestyleAndChoicesCard.swift new file mode 100644 index 00000000..204e3c59 --- /dev/null +++ b/IngrediCheck/Components/LifestyleAndChoicesCard.swift @@ -0,0 +1,8 @@ +// +// LifestyleAndChoicesCard.swift +// IngrediCheckPreview +// +// Created by Gunjan Haldar on 08/10/25. +// + +import SwiftUI diff --git a/IngrediCheck/Components/MatchingRateCard.swift b/IngrediCheck/Components/MatchingRateCard.swift new file mode 100644 index 00000000..9854ecf6 --- /dev/null +++ b/IngrediCheck/Components/MatchingRateCard.swift @@ -0,0 +1,177 @@ +// +// MatchingRateCard.swift +// IngrediCheckPreview +// +// Created by Gunjan Haldar on 07/10/25. +// + +import SwiftUI + +struct MatchingRateCard: View { + var matchedCount: Int + var uncertainCount: Int + var unmatchedCount: Int + var increaseValue: Int? = nil + + init(matchedCount: Int = 0, uncertainCount: Int = 0, unmatchedCount: Int = 0, increaseValue: Int? = nil) { + self.matchedCount = matchedCount + self.uncertainCount = uncertainCount + self.unmatchedCount = unmatchedCount + self.increaseValue = increaseValue + } + + private var totalCount: Int { + matchedCount + uncertainCount + unmatchedCount + } + + private var isEmptyState: Bool { + totalCount <= 0 + } + + private var matchedPercentage: Int { + guard totalCount > 0 else { return 0 } + return Int(round((Double(matchedCount) / Double(totalCount)) * 100.0)) + } + + var body: some View { + VStack { + ZStack { + Rectangle() + .fill(.clear) + .frame(width: 230) + .overlay( + MatchingRateProgressBar( + matchedCount: matchedCount, + uncertainCount: uncertainCount, + unmatchedCount: unmatchedCount + ) + .scaleEffect(UIScreen.main.bounds.width * 0.00217) + .offset(y: 45) + , alignment: .bottom) + + VStack() { + HStack(alignment: .bottom, spacing: 0) { + Text("\(matchedPercentage)") + .font(.system(size: 24, weight: .semibold)) + + Text("%") + .font(.system(size: 24, weight: .semibold)) + + } + .foregroundStyle(.grayScale150) + + Text("Matched") + .font(ManropeFont.regular.size(13.15)) + .foregroundStyle(.grayScale100) + }.offset(y : 35) + + if isEmptyState { + Text("Start scanning to unlock your matching insights") + .font(ManropeFont.regular.size(10)) + .foregroundStyle(.grayScale120) + .padding(.vertical, 8) + .padding(.horizontal, 12) + .background( + Capsule() + .fill(.white) + .overlay( + Capsule() + .stroke(Color(hex: "#EEEEEE"), lineWidth: 1) + ) + ) + .offset(y: 92) + } else if let increaseValue { + MatchingRateCard1(increaseValue: increaseValue) + .offset(y: 80) + } + } + + + + } + .frame(maxWidth: .infinity) + .frame(height: 229) + .background(content: { + Color.white + .cornerRadius(24) + .shadow( + color: Color(hex: "#ECECEC"), + radius: 9, + x: 0, + y: 0 + ) + }) + .overlay( + Text("Matching Rate") + .frame(height: 17) + .font(ManropeFont.semiBold.size(20)) + .padding(16) ,alignment: .topLeading) + + } + +} + + +struct MatchingRateCard1: View { + var increaseValue: Int = 20 + + var body: some View { + HStack(spacing: 8) { + + Text("Your matching rate increased by") + .font(ManropeFont.regular.size(10)) + .lineLimit(1) + + + + HStack(spacing: 6) { + Image("up-trend") + .renderingMode(.template) + .frame(width: 16, height: 16) + + Text("+\(increaseValue)") + .font(.system(size: 12, weight: .bold)) + + } + .foregroundColor(Color(hex: "#75990E")) + + .padding(.vertical, 4) + .background( + + Capsule() + .fill(Color(hex: "#E6F6CD")) + .frame( width : 56 ,height: 24) + .padding(.horizontal, 8) + ) + } + .padding(.horizontal, 12) + .frame(height: 32) // βœ… works correctly now +// .frame(maxWidth: .infinity) + .overlay( + RoundedRectangle(cornerRadius: 24) + .stroke(Color.gray.opacity(0.3), lineWidth: 0.5) + ) + } +} + + + +#Preview { + Group { + // Filled state preview + ZStack { +// Color.gray.opacity(0.1).ignoresSafeArea() + MatchingRateCard(matchedCount: 0, uncertainCount: 0, unmatchedCount: 47, increaseValue: nil) + .padding(.horizontal, 20) + } + .previewDisplayName("Filled State") + + // Empty state preview + ZStack { +// Color.gray.opacity(0.9).ignoresSafeArea() + MatchingRateCard(matchedCount: 0, uncertainCount: 0, unmatchedCount: 0) + .padding(.horizontal, 20) + } + .previewDisplayName("Empty State") + } +} diff --git a/IngrediCheck/Components/MatchingRateProgressBar.swift b/IngrediCheck/Components/MatchingRateProgressBar.swift new file mode 100644 index 00000000..19d25a8d --- /dev/null +++ b/IngrediCheck/Components/MatchingRateProgressBar.swift @@ -0,0 +1,378 @@ +// +// MatchingRateProgressBar.swift +// IngrediCheckPreview +// +// Created by Gunjan Haldar on 07/10/25. +// + +import SwiftUI + +// Tapered wedge-like bar (narrow inner edge, wider outer edge) +struct TaperedBar: Shape { + // angle: center angle in radians + // halfWidth: how wide the wedge is (in radians) + var angle: Double + var halfWidth: Double + var innerRadius: CGFloat + var outerRadius: CGFloat + var cornerRadius: CGFloat = 4 + + // Convenience init to specify full angular width (in radians) + init(angle: Double, + width: Double, + innerRadius: CGFloat, + outerRadius: CGFloat, + cornerRadius: CGFloat = 4) { + self.angle = angle + self.halfWidth = width / 2 + self.innerRadius = innerRadius + self.outerRadius = outerRadius + self.cornerRadius = cornerRadius + } + + func path(in rect: CGRect) -> Path { + var path = Path() + let center = CGPoint(x: rect.midX, y: rect.midY) + + let start = angle - halfWidth + let end = angle + halfWidth + + // compute positions; convert trig (Double) -> CGFloat + let innerLeft = CGPoint( + x: center.x + CGFloat(cos(start)) * innerRadius, + y: center.y + CGFloat(sin(start)) * innerRadius + ) + let innerRight = CGPoint( + x: center.x + CGFloat(cos(end)) * innerRadius, + y: center.y + CGFloat(sin(end)) * innerRadius + ) + let outerLeft = CGPoint( + x: center.x + CGFloat(cos(start)) * outerRadius, + y: center.y + CGFloat(sin(start)) * outerRadius + ) + let outerRight = CGPoint( + x: center.x + CGFloat(cos(end)) * outerRadius, + y: center.y + CGFloat(sin(end)) * outerRadius + ) + + // Helper to ensure radius fits available edge lengths + func distance(_ a: CGPoint, _ b: CGPoint) -> CGFloat { + let dx = a.x - b.x + let dy = a.y - b.y + return sqrt(dx * dx + dy * dy) + } + + // Clamp per-corner radius to avoid self-intersection on very small sides + let r1 = min(cornerRadius, 0.5 * min(distance(innerLeft, outerLeft), distance(outerLeft, outerRight))) // at outerLeft + let r2 = min(cornerRadius, 0.5 * min(distance(outerLeft, outerRight), distance(outerRight, innerRight))) // at outerRight + let r3 = min(cornerRadius, 0.5 * min(distance(outerRight, innerRight), distance(innerRight, innerLeft))) // at innerRight + let r4 = min(cornerRadius, 0.5 * min(distance(innerRight, innerLeft), distance(innerLeft, outerLeft))) // at innerLeft + + // Draw rounded quadrilateral using tangent arcs at each corner + path.move(to: innerLeft) + path.addArc(tangent1End: outerLeft, tangent2End: outerRight, radius: r4) + path.addArc(tangent1End: outerRight, tangent2End: innerRight, radius: r1) + path.addArc(tangent1End: innerRight, tangent2End: innerLeft, radius: r2) + path.addArc(tangent1End: innerLeft, tangent2End: outerLeft, radius: r3) + path.closeSubpath() + + return path + } +} + +struct MatchingRateProgressBar: View { + enum Mode { + case single(filledSegments: Int) + case breakdown(matchedCount: Int, uncertainCount: Int, unmatchedCount: Int) + } + + enum SegmentKind: Hashable { + case matched + case uncertain + case unmatched + } + + let totalSegments: Int + let mode: Mode + + @State private var selectedKind: SegmentKind? + + let innerRadius: CGFloat = 74 + let outerRadius: CGFloat = 130 + let segmentWidthFactor: Double = 0.88 // 0..1 of the per-segment angle + + init(filledSegments: Int = 7, totalSegments: Int = 12) { + self.totalSegments = totalSegments + self.mode = .single(filledSegments: filledSegments) + } + + init(matchedCount: Int, uncertainCount: Int, unmatchedCount: Int, totalSegments: Int = 12) { + self.totalSegments = totalSegments + self.mode = .breakdown( + matchedCount: matchedCount, + uncertainCount: uncertainCount, + unmatchedCount: unmatchedCount + ) + } + + private var matchedColor: Color { Color(hex: "#82B611") } + private var uncertainColor: Color { Color(hex: "#FFBE18") } + private var unmatchedColor: Color { Color(hex: "#FF1606") } + + private func segmentsForBreakdown(matched: Int, uncertain: Int, unmatched: Int) -> [SegmentKind]? { + let total = matched + uncertain + unmatched + if total <= 0 { + return nil + } + + let counts: [(SegmentKind, Int)] = [(.matched, matched), (.uncertain, uncertain), (.unmatched, unmatched)] + + let raw: [(SegmentKind, Double)] = counts.map { kind, count in + (kind, (Double(count) / Double(total)) * Double(totalSegments)) + } + + var base: [SegmentKind: Int] = Dictionary(uniqueKeysWithValues: raw.map { ($0.0, Int(floor($0.1))) }) + var used = base.values.reduce(0, +) + var remainder = max(0, totalSegments - used) + + let fractionalSorted = raw + .map { (kind: $0.0, frac: $0.1 - floor($0.1)) } + .sorted { a, b in + if a.frac == b.frac { + return String(describing: a.kind) < String(describing: b.kind) + } + return a.frac > b.frac + } + + var idx = 0 + while remainder > 0 && !fractionalSorted.isEmpty { + let kind = fractionalSorted[idx % fractionalSorted.count].kind + base[kind, default: 0] += 1 + used += 1 + remainder -= 1 + idx += 1 + } + + var result: [SegmentKind] = [] + result.reserveCapacity(totalSegments) + result.append(contentsOf: Array(repeating: .matched, count: base[.matched, default: 0])) + result.append(contentsOf: Array(repeating: .uncertain, count: base[.uncertain, default: 0])) + result.append(contentsOf: Array(repeating: .unmatched, count: base[.unmatched, default: 0])) + + if result.count > totalSegments { + result = Array(result.prefix(totalSegments)) + } else if result.count < totalSegments { + result.append(contentsOf: Array(repeating: .unmatched, count: totalSegments - result.count)) + } + + return result + } + + private func fillColor(for kind: SegmentKind) -> Color { + switch kind { + case .matched: + return matchedColor + case .uncertain: + return uncertainColor + case .unmatched: + return unmatchedColor + } + } + + private func tooltipText(kind: SegmentKind, matched: Int, uncertain: Int, unmatched: Int) -> String { + switch kind { + case .matched: + return "\(matched) items matched" + case .uncertain: + return "\(uncertain) items uncertain" + case .unmatched: + return "\(unmatched) items unmatched" + } + } + + private func angleForSegment(index: Int) -> Double { + Double(index) * Double.pi / Double(max(1, totalSegments - 1)) - Double.pi / 2 + } + + private func rotatedAngleForTooltip(index: Int) -> Double { + angleForSegment(index: index) - Double.pi / 2 + } + + private func tooltipPoint(index: Int, in size: CGSize) -> CGPoint { + let center = CGPoint(x: size.width / 2, y: size.height / 2) + let radius = outerRadius + 18 + let angle = rotatedAngleForTooltip(index: index) + + return CGPoint( + x: center.x + CGFloat(cos(angle)) * radius, + y: center.y + CGFloat(sin(angle)) * radius + ) + } + + private func segmentPoint(index: Int, in size: CGSize) -> CGPoint { + let center = CGPoint(x: size.width / 2, y: size.height / 2) + let radius = outerRadius + let angle = rotatedAngleForTooltip(index: index) + + return CGPoint( + x: center.x + CGFloat(cos(angle)) * radius, + y: center.y + CGFloat(sin(angle)) * radius + ) + } + + var body: some View { + ZStack { + ZStack { + switch mode { + case .single(let filledSegments): + ForEach(0.. Element? { + if index < 0 || index >= count { + return nil + } + return self[index] + } +} + +private struct MatchingRateTooltipArrow: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + path.move(to: CGPoint(x: rect.midX, y: rect.maxY)) + path.addLine(to: CGPoint(x: rect.minX, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY)) + path.closeSubpath() + return path + } +} + +struct PreviewProvider_Previews: PreviewProvider { + static var previews: some View { + MatchingRateProgressBar() + } +} + + + + diff --git a/IngrediCheck/Components/MemberAvatar.swift b/IngrediCheck/Components/MemberAvatar.swift new file mode 100644 index 00000000..2d9dd32c --- /dev/null +++ b/IngrediCheck/Components/MemberAvatar.swift @@ -0,0 +1,203 @@ +// +// MemberAvatar.swift +// IngrediCheck +// +// Centralized avatar component for consistent memoji rendering throughout the app +// + +import SwiftUI + +/// Centralized avatar view that handles loading, displaying, and updating family member avatars. +/// This component ensures consistent rendering across the app: +/// - Colored circle background +/// - Transparent PNG memoji image on top +/// - Fallback to initial letter if no image +/// - Automatic loading and caching +/// - Handles updates and deletions automatically +struct MemberAvatar: View { + @Environment(WebService.self) private var webService + let member: FamilyMember + let size: CGFloat + let showBorder: Bool + let borderWidth: CGFloat + let imagePadding: CGFloat + + @State private var avatarImage: UIImage? = nil + @State private var loadedHash: String? = nil + @State private var isLoading: Bool = false + @State private var loadFailed: Bool = false + + /// Initializes a member avatar view + /// - Parameters: + /// - member: The family member to display + /// - size: The size of the avatar circle (default: 48) + /// - showBorder: Whether to show a white border (default: true) + /// - borderWidth: Width of the border (default: 1) + /// - imagePadding: Padding between image and circle edge to show background color ring (default: 2) + init( + member: FamilyMember, + size: CGFloat = 48, + showBorder: Bool = true, + borderWidth: CGFloat = 1, + imagePadding: CGFloat = 2 + ) { + self.member = member + self.size = size + self.showBorder = showBorder + self.borderWidth = borderWidth + self.imagePadding = imagePadding + } + + var body: some View { + ZStack { + // Background circle (behind the image) + Circle() + .fill(Color(hex: member.color)) + .frame(width: size, height: size) + + // Memoji image on top (transparent PNG should show circle through) + if let img = avatarImage { + Image(uiImage: img) + .resizable() + .renderingMode(.original) // Preserve transparency + .scaledToFit() // Preserve aspect ratio + .frame(width: size - imagePadding * 2, height: size - imagePadding * 2) + .clipShape(Circle()) + } else if isLoading && member.imageFileHash != nil && !member.imageFileHash!.isEmpty { + // Show loading indicator while loading (only if there's supposed to be an image) + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .scaleEffect(size > 60 ? 1.0 : 0.7) + } else { + // Fallback: initial letter (shown when no image hash or load failed) + Text(String(member.name.prefix(1))) + .font(NunitoFont.semiBold.size(size * 0.375)) + .foregroundStyle(.white) + } + } + .overlay( + Group { + if showBorder { + Circle() + .stroke(lineWidth: borderWidth) + .foregroundStyle(Color.white) + } + } + ) + .task(id: member.imageFileHash) { + await loadAvatarIfNeeded() + } + } + + @MainActor + private func loadAvatarIfNeeded() async { + // If there is no hash, clear any cached avatar and fall back to initials. + guard let hash = member.imageFileHash, !hash.isEmpty else { + if avatarImage != nil { + Log.debug("MemberAvatar", "imageFileHash cleared for \(member.name), resetting avatarImage") + } + avatarImage = nil + loadedHash = nil + isLoading = false + loadFailed = false + return + } + + // If we've already loaded this exact hash in this view instance, skip re-fetching. + if loadedHash == hash, let existingImage = avatarImage { + let isValid = existingImage.size.width > 0 && existingImage.size.height > 0 + if isValid { + Log.debug("MemberAvatar", "Avatar for \(member.name) already loaded for hash \(hash), skipping reload") + isLoading = false + loadFailed = false + return + } + } + + // 1) Try local asset first (for local memojis) - NO loading spinner needed + if hash.hasPrefix("memoji_") { + if let local = UIImage(named: hash) { + let isValid = local.size.width > 0 && local.size.height > 0 + if isValid { + avatarImage = local + loadedHash = hash + isLoading = false + loadFailed = false +// Log.debug("MemberAvatar", "βœ… Loaded local memoji for \(member.name) (hash=\(hash))") + return + } + } + } + + // 2) Try remote (uses WebService disk cache internally) + // Only show loading indicator for remote images + isLoading = true + loadFailed = false + Log.debug("MemberAvatar", "Loading avatar for \(member.name), imageFileHash=\(hash)") + do { + let uiImage = try await webService.fetchImage( + imageLocation: .imageFileHash(hash), + imageSize: size <= 36 ? .small : (size <= 64 ? .medium : .large) + ) + + // Validate loaded image + let isValid = uiImage.size.width > 0 && uiImage.size.height > 0 + + guard isValid else { + Log.debug("MemberAvatar", "⚠️ Loaded image has invalid size, skipping") + avatarImage = nil + loadedHash = nil + isLoading = false + loadFailed = true + return + } + + avatarImage = uiImage + loadedHash = hash + isLoading = false + loadFailed = false + Log.debug("MemberAvatar", "βœ… Loaded avatar for \(member.name) (hash=\(hash))") + } catch { + Log.debug("MemberAvatar", "❌ Failed to load avatar for \(member.name): \(error.localizedDescription)") + avatarImage = nil + loadedHash = nil + isLoading = false + loadFailed = true + } + } +} + +// MARK: - Convenience Extensions + +extension MemberAvatar { + /// Small avatar (36x36) - typically used in lists and compact views + static func small(member: FamilyMember) -> some View { + MemberAvatar(member: member, size: 36, imagePadding: 0) + } + + /// Medium avatar (48x48) - standard size for most views + static func medium(member: FamilyMember) -> some View { + MemberAvatar(member: member, size: 48, imagePadding: 2) + } + + /// Large avatar (120x120) - used in profile views and detailed displays + static func large(member: FamilyMember) -> some View { + MemberAvatar(member: member, size: 120, borderWidth: 2, imagePadding: 5) + } + + /// Custom size avatar + static func custom(member: FamilyMember, size: CGFloat, imagePadding: CGFloat = 2) -> some View { + MemberAvatar(member: member, size: size, imagePadding: imagePadding) + } +} + +#Preview { + VStack(spacing: 20) { + MemberAvatar.small(member: FamilyMember(id: UUID(), name: "Alice", color: "#E0BBE4", joined: true, imageFileHash: nil)) + MemberAvatar.medium(member: FamilyMember(id: UUID(), name: "Bob", color: "#BAE1FF", joined: true, imageFileHash: nil)) + MemberAvatar.large(member: FamilyMember(id: UUID(), name: "Charlie", color: "#BAFFC9", joined: true, imageFileHash: nil)) + } + .padding() + .environment(WebService()) +} + diff --git a/IngrediCheck/Components/MemojiQuestionariesCard.swift b/IngrediCheck/Components/MemojiQuestionariesCard.swift new file mode 100644 index 00000000..83eefebd --- /dev/null +++ b/IngrediCheck/Components/MemojiQuestionariesCard.swift @@ -0,0 +1,72 @@ +// +// MemojiQuestionariesCard.swift +// IngrediCheckPreview +// +// Created by Gunjan Haider on 01/10/25. +// + +import SwiftUI + +struct MemojiQuestionariesCard: View { + @State var iconsArr: [String] = [ + "allergies", + "mingcute_alert-line", + "lucide_stethoscope", + "lucide_baby", + "nrk_globe", + "charm_circle-cross", + "hugeicons_plant-01", + "fluent-emoji-high-contrast_fork-and-knife-with-plate", + "streamline_recycle-1-solid", + "iconoir_chocolate" + ] + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("John Doe") + .font(.system(size: 14, weight: .regular)) + .foregroundStyle(Color(hex: "#1C1C1C")) + + ZStack { + ForEach(Array(iconsArr.enumerated()), id: \.offset) { idx, icon in + questionariesIconCircle(image: icon) + .offset(x: CGFloat(idx) * 13.5) + .zIndex(Double(iconsArr.count - idx)) + } + } + } + + Spacer() + + Image("memoji") + .resizable() + .frame(width: 40, height: 40) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(Color(hex: "#FBFBFB"), in: RoundedRectangle(cornerRadius: 16)) + } + + + @ViewBuilder + func questionariesIconCircle(image: String) -> some View { + Circle() + .stroke(style: StrokeStyle(lineWidth: 0.25)) + .foregroundStyle(Color(hex: "#B6B6B6")) + .frame(width: 16.5, height: 16.5) + .background(Color(hex: "#EBEBEB"), in: .circle) + .overlay( + Image(image) + .resizable() + .frame(width: 10.31, height: 10.31) + ) + } +} + +#Preview { + ZStack { + Color.gray.opacity(0.3).ignoresSafeArea() + MemojiQuestionariesCard() + .padding(.horizontal) + } +} diff --git a/IngrediCheck/Components/MultiColorText.swift b/IngrediCheck/Components/MultiColorText.swift new file mode 100644 index 00000000..1700a37e --- /dev/null +++ b/IngrediCheck/Components/MultiColorText.swift @@ -0,0 +1,34 @@ +// +// MultiColorText.swift +// IngrediCheck +// +// Created by Gaurav on 09/01/26. +// +import SwiftUI +struct MultiColorText: View { + var text: String + var delimiter: Character = "*" + + // .foregroundStyle(.grayScale140) + var font: Font = ManropeFont.bold.size(14) + var body: some View { + let components = text.components(separatedBy: String(delimiter)) + return components.enumerated().reduce(Text("")) { (currentText, indexAndString) in + let (index, part) = indexAndString + // Even index = Black (Default) + // Odd index = Highlight Color (#7B8288) + let color = index % 2 == 0 ? Color.grayScale140 : Color.grayScale90 + return currentText + Text(part).foregroundColor(color) + } + .font(font) + } +} +#Preview { + VStack(spacing: 20) { + MultiColorText(text: "Are you *a* new user *or an* existing one?", font: NunitoFont.black.size(20)) + .multilineTextAlignment(.center) + MultiColorText(text: "Simply *Highlight* anything easily!") + .multilineTextAlignment(.center) + } + .padding() +} diff --git a/IngrediCheck/Components/OnboardingPhoneCanvas.swift b/IngrediCheck/Components/OnboardingPhoneCanvas.swift new file mode 100644 index 00000000..2efa57d6 --- /dev/null +++ b/IngrediCheck/Components/OnboardingPhoneCanvas.swift @@ -0,0 +1,55 @@ +import SwiftUI + +struct OnboardingPhoneCanvas: View { + let phoneImageName: String + + init(phoneImageName: String = "Iphone-image") { + self.phoneImageName = phoneImageName + } + + var body: some View { + VStack { + Image("Ingredicheck-logo") + .frame(width: 107.3, height: 36) + .padding(.top, 44) + .padding(.bottom, 33) + + ZStack { + Image(phoneImageName) + .resizable() + .frame(width: 238, height: 460) + .overlay(alignment: .bottom, content: { + LinearGradient( + colors: [ + Color.white.opacity(0.1), + Color.white, + ], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 150) + .frame(maxWidth: .infinity) + }) + } + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background( + LinearGradient( + colors: [ + Color(hex: "#FFFFFF"), + Color(hex: "#F7F7F7"), + ], + startPoint: .top, + endPoint: .bottom + ) + ) + .navigationBarBackButtonHidden(true) + .toolbar(.hidden, for: .navigationBar) + } +} + +#Preview { + OnboardingPhoneCanvas() +} diff --git a/IngrediCheck/Components/PersistentBottomSheet.swift b/IngrediCheck/Components/PersistentBottomSheet.swift new file mode 100644 index 00000000..ae9d9e3b --- /dev/null +++ b/IngrediCheck/Components/PersistentBottomSheet.swift @@ -0,0 +1,1886 @@ +// +// PersistentBottomSheet.swift +// IngrediCheckPreview +// +// Created on 13/11/25. +// + +import SwiftUI +import UIKit + +struct PersistentBottomSheet: View { + @Environment(AppNavigationCoordinator.self) private var coordinator + @Environment(AuthController.self) private var authController + @Environment(FamilyStore.self) private var familyStore + @Environment(MemojiStore.self) private var memojiStore + @Environment(WebService.self) private var webService + @Environment(AppState.self) private var appState + @Environment(FoodNotesStore.self) private var foodNotesStore + @EnvironmentObject private var store: Onboarding + @State private var keyboardHeight: CGFloat = 0 + @State private var isExpandedMinimal: Bool = false + @State private var generationTask: Task? + @State private var tutorialData: TutorialData? + @State private var isAnimatingHand: Bool = false + @State private var dragOffsetY: CGFloat = 0 + @State private var isGeneratingInviteCode: Bool = false + @State private var tutorialCardSwipeOffset: CGFloat = 0 + + // MARK: - CONSTANTS + + private let appStoreURL = "https://apps.apple.com/us/app/ingredicheck-grocery-scanner/id6477521615" + + var body: some View { + @Bindable var coordinator = coordinator + @Bindable var memojiStore = memojiStore + + let canTapOutsideToDismiss: Bool = { + guard case .home = coordinator.currentCanvasRoute else { return false } + + switch coordinator.currentBottomSheetRoute { + case .homeDefault: + return false + case .yourCurrentAvatar, .setUpAvatarFor, .generateAvatar, .bringingYourAvatar, .meetYourAvatar, .meetYourProfile, .meetYourProfileIntro: + return true + default: + return false + } + }() + + // Block background interaction when add member flow sheets are open from home screen + let shouldBlockBackgroundInteraction: Bool = { + guard case .home = coordinator.currentCanvasRoute else { return false } + switch coordinator.currentBottomSheetRoute { + case .addMoreMembers, .wouldYouLikeToInvite, .addPreferencesForMember: + return true + default: + return false + } + }() + + ZStack(alignment: .bottom) { + if canTapOutsideToDismiss { + Color.black + .opacity(0.0) + .ignoresSafeArea() + .contentShape(Rectangle()) + .onTapGesture { + coordinator.navigateInBottomSheet(.homeDefault) + } + } + + // Block all background interactions when add member flow sheets are open from home + if shouldBlockBackgroundInteraction { + Color.black + .opacity(0.01) // Minimal opacity but still blocks touches + .ignoresSafeArea() + .contentShape(Rectangle()) + .allowsHitTesting(true) // Block all touches + } + + VStack { + Spacer() + + bottomSheetContainer() + } + + // Show loginToContinue as overlay alert when opened from PermissionsCanvas + if coordinator.currentBottomSheetRoute == .loginToContinue && + coordinator.currentCanvasRoute == .whyWeNeedThesePermissions { + bottomSheetContent(for: .loginToContinue) + } + } + .background( + .clear + ) + .padding(.bottom, keyboardHeight) + .ignoresSafeArea(edges: .bottom) + .onChange(of: coordinator.currentBottomSheetRoute) { oldValue, newValue in + trackOnboardingRouteChange(to: newValue) + + // Cancel generation task only when leaving avatar-related routes + // Don't cancel when transitioning between avatar routes (generateAvatar -> bringingYourAvatar -> meetYourAvatar) + let avatarRoutes: Set = [.generateAvatar, .bringingYourAvatar, .meetYourAvatar, .yourCurrentAvatar, .setUpAvatarFor] + let wasInAvatarFlow = avatarRoutes.contains(oldValue) + let isInAvatarFlow = avatarRoutes.contains(newValue) + + // Only cancel if we're leaving the avatar flow entirely + if wasInAvatarFlow && !isInAvatarFlow { + Log.debug("PersistentBottomSheet", "Leaving avatar flow, cancelling generation task") + generationTask?.cancel() + generationTask = nil + } + + // Animate sheet presentation (swipe-up feel) for avatar sheets opened from Home/Settings + if (newValue == .yourCurrentAvatar || newValue == .setUpAvatarFor), + !(oldValue == .yourCurrentAvatar || oldValue == .setUpAvatarFor), + case .home = coordinator.currentCanvasRoute { + dragOffsetY = 700 + withAnimation(.easeOut(duration: 0.28)) { + dragOffsetY = 0 + } + } else if newValue == .homeDefault { + // Don't reset dragOffsetY when dismissing to homeDefault via drag + // Keep it at dismiss position to prevent blank sheet flash + } else if oldValue == .homeDefault && newValue != .homeDefault { + // Reset dragOffsetY only when presenting a NEW sheet from homeDefault + dragOffsetY = 0 + } else if oldValue != .homeDefault && newValue != .homeDefault { + // Transitioning between non-home sheets, reset offset + dragOffsetY = 0 + } + } + .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillChangeFrameNotification)) { notification in + guard let userInfo = notification.userInfo, + let frameValue = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect + else { return } + + let screenHeight = UIScreen.main.bounds.height + let keyboardVisibleHeight = max(0, screenHeight - frameValue.origin.y) + + withAnimation(.easeInOut(duration: 0.25)) { + keyboardHeight = keyboardVisibleHeight + } + } + .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in + withAnimation(.easeInOut(duration: 0.25)) { + keyboardHeight = 0 + } + } + .onPreferenceChange(TutorialOverlayPreferenceKey.self) { value in + // Only update if value changed to avoid loops, though Equatable handles it + self.tutorialData = value + } + .overlay( + Group { + if let data = tutorialData, data.show { + GeometryReader { proxy in + // We are already at the screen coordinate space in PersistentBottomSheet (mostly) + // But let's use global origin to be safe + let globalOrigin = proxy.frame(in: .global).origin + + ZStack { + // Dimmed background with cutout hole to show original card + Color.black.opacity(0.63) + .mask( + ZStack { + Rectangle().fill(Color.black) + + // cutout to show original card underneath + RoundedRectangle(cornerRadius: 24) + .frame(width: data.cardFrame.width, height: data.cardFrame.height) + .position(x: data.cardFrame.midX, y: data.cardFrame.midY) + .blendMode(.destinationOut) + } + .compositingGroup() + ) + + // Redacted dummy card on top of original card (swipeable) + TutorialRedactedCard() + .frame(width: data.cardFrame.width, height: data.cardFrame.height) + .offset(x: tutorialCardSwipeOffset) + .rotationEffect(.degrees(tutorialCardSwipeOffset / 30)) + .position(x: data.cardFrame.midX, y: data.cardFrame.midY) + .gesture( + DragGesture() + .onChanged { value in + tutorialCardSwipeOffset = value.translation.width + } + .onEnded { value in + let threshold: CGFloat = 80 + if abs(value.translation.width) > threshold || + abs(value.predictedEndTranslation.width) > 150 { + // Swipe detected - dismiss overlay + let direction: CGFloat = value.translation.width > 0 ? 1 : -1 + withAnimation(.easeOut(duration: 0.3)) { + tutorialCardSwipeOffset = direction * 400 + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + NotificationCenter.default.post(name: .dismissSwipeTutorial, object: nil) + isAnimatingHand = false + tutorialCardSwipeOffset = 0 + } + } else { + // Snap back + withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) { + tutorialCardSwipeOffset = 0 + } + } + } + ) + + // Hand icon and text + VStack(spacing: -1) { + Image("swipe-hand") + .resizable() + .scaledToFit() + .frame(width: 80, height : 80) + .foregroundStyle(.white) + .rotationEffect(.degrees(-10)) + .offset(x: tutorialCardSwipeOffset * 0.4, y: 0) + .onAppear { + isAnimatingHand = true + // Start auto-swipe animation after a short delay + startTutorialSwipeAnimation() + } + + Text("Swipe cards to review each category") + .font(NunitoFont.bold.size(16)) + .foregroundStyle(.white) + } + .offset(x: 0, y: -40) + .position(x: data.cardFrame.midX, y: data.cardFrame.maxY + 60) + } + .frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height) + .position(x: UIScreen.main.bounds.width / 2, y: UIScreen.main.bounds.height / 2) + .ignoresSafeArea() + .offset(x: -globalOrigin.x, y: -globalOrigin.y) + .contentShape(Rectangle()) + .onTapGesture { + // Dismiss tutorial on tap anywhere + NotificationCenter.default.post(name: .dismissSwipeTutorial, object: nil) + isAnimatingHand = false + tutorialCardSwipeOffset = 0 + } + } + .zIndex(9999) // Ensure it's on top of everything + .allowsHitTesting(true) // Allow interactions + } + } + ) + } + + @ViewBuilder + private func bottomSheetContainer() -> some View { + // Allow swipe to dismiss for avatar sheets and addMoreMembers when opened from home + let canSwipeToDismiss = coordinator.currentBottomSheetRoute == .yourCurrentAvatar || + coordinator.currentBottomSheetRoute == .setUpAvatarFor || + (coordinator.currentBottomSheetRoute == .addMoreMembers && coordinator.currentCanvasRoute == .home) + let dismissThreshold: CGFloat = 120 + let dismissAnimationDistance: CGFloat = 700 + + // Velocity threshold for dismissal (points per second) + let velocityThreshold: CGFloat = 500 + + let dragGesture = DragGesture(minimumDistance: 0) + .onChanged { value in + guard canSwipeToDismiss else { return } + let t = value.translation.height + // Direct 1:1 tracking without animation for native feel + // Add rubber-banding when trying to drag upward (negative values) + if t < 0 { + // Rubber-band effect: diminishing returns when dragging up + dragOffsetY = t / 3 + } else { + dragOffsetY = t + } + } + .onEnded { value in + guard canSwipeToDismiss else { return } + let t = value.translation.height + let velocity = value.predictedEndTranslation.height - t + + // Dismiss if: dragged past threshold OR fast downward velocity + let shouldDismiss = t > dismissThreshold || (t > 50 && velocity > velocityThreshold) + + if shouldDismiss { + // Calculate animation duration based on remaining distance and velocity + let remainingDistance = dismissAnimationDistance - t + let baseDuration = 0.25 + let velocityFactor = min(1.0, max(0.5, 1.0 - (velocity / 2000))) + let duration = baseDuration * velocityFactor + + // Animate sheet down with velocity-aware timing + withAnimation(.easeOut(duration: duration)) { + dragOffsetY = dismissAnimationDistance + } + // Navigate after animation completes + Task { @MainActor in + try? await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000)) + coordinator.navigateInBottomSheet(.homeDefault) + // Don't reset dragOffsetY here - keep it offscreen to prevent blank sheet flash + // It will be reset when a new sheet is presented + } + } else { + // Snap back with spring animation (native feel) + withAnimation(.spring(response: 0.35, dampingFraction: 0.75, blendDuration: 0)) { + dragOffsetY = 0 + } + } + } + + // Always show quickAccessNeeded in bottom sheet when on PermissionsCanvas + // Login alert will be shown as overlay independently + let isOnPermissionsCanvas = coordinator.currentCanvasRoute == .whyWeNeedThesePermissions + let bottomSheetRouteToShow: BottomSheetRoute = isOnPermissionsCanvas ? .quickAccessNeeded : coordinator.currentBottomSheetRoute + + let sheet = ZStack(alignment: .bottomTrailing) { + let _ = Log.debug("PersistentBottomSheet", "currentCanvasRoute=\(coordinator.currentCanvasRoute), bottomSheetRoute=\(coordinator.currentBottomSheetRoute)") + bottomSheetContent(for: bottomSheetRouteToShow) + .frame(maxWidth: .infinity, alignment: .top) + + if shouldShowOnboardingNextArrow { + Button(action: handleOnboardingNextTapped) { + if familyStore.pendingUploadCount > 0 { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .frame(width: 52, height: 52) + .background( + Capsule() + .foregroundStyle( + LinearGradient( + colors: [Color(hex: "4CAF50"), Color(hex: "8BC34A")], + startPoint: .leading, + endPoint: .trailing + ) + ) + ) + } else { + GreenCircle() + } + } + .buttonStyle(.plain) + .disabled(familyStore.pendingUploadCount > 0) + .padding(.trailing, 20) + .padding(.bottom, 24) + } + } + + if let height = getBottomSheetHeight(for: bottomSheetRouteToShow) { + sheet + .frame(height: height) + .frame(maxWidth: .infinity) + .background(Color.white) + .cornerRadius(36, corners: [.topLeft, .topRight]) + + .shadow(color: .grayScale70, radius: 27.5) + .offset(y: dragOffsetY) + .gesture(dragGesture) + // Hide sheet when it's being dismissed (offset is beyond screen) + .opacity(dragOffsetY > 600 ? 0 : 1) + +// .overlay( +// LinearGradient( +// gradient: Gradient(colors: [ +// Color.red.opacity(1.0), +// Color.red.opacity(0.0) +// ]), +// startPoint: .bottom, +// endPoint: .top +// ) +// .frame(height: 161) +// .allowsHitTesting(false) +// .offset(y: -120), +// alignment: .top +// ) + .ignoresSafeArea(edges: .bottom) + } else { + sheet + .frame(maxWidth: .infinity, alignment: .top) + .background(Color.white) + .cornerRadius(36, corners: [.topLeft, .topRight]) + .shadow(color: .grayScale70, radius: 27.5) + .offset(y: dragOffsetY) + .gesture(dragGesture) +// .shadow(radius: 27.5) +// .overlay( +// LinearGradient( +// gradient: Gradient(colors: [ +// Color.white.opacity(1.0), +// Color.white.opacity(0.0) +// ]), +// startPoint: .bottom, +// endPoint: .top +// ) +// .frame(height: 123) +// .allowsHitTesting(false) +// .offset(y: -23), +// alignment: .top +// ) + .ignoresSafeArea(edges: .bottom) + } + } + + private func getBottomSheetHeight(for route: BottomSheetRoute? = nil) -> CGFloat? { + let routeToCheck = route ?? coordinator.currentBottomSheetRoute + switch routeToCheck { + case .alreadyHaveAnAccount: + return 244 + case .doYouHaveAnInviteCode: + return 220 + case .welcomeBack: + return 252 + case .enterInviteCode: + return 380 + case .whosThisFor: + return 264 + case .letsMeetYourIngrediFam: + return 397 + case .whatsYourName, .addMoreMembers: + return 438 + case .addMoreMembersMinimal: + return 244 + case .editMember: + return 438 + case .wouldYouLikeToInvite(_, _): + return 244 + case .addPreferencesForMember(_, _): + return 300 + case .generateAvatar: + return 379 + case .bringingYourAvatar: + return 316 + case .meetYourAvatar: + return 391 + case .yourCurrentAvatar: + return nil + case .setUpAvatarFor: + return nil + case .dietaryPreferencesSheet(let isFamilyFlow): + return nil + case .allSetToJoinYourFamily: + return 264 + // For preference sheets shown from MainCanvasView, let the + // content determine its own height instead of forcing a static one. + case .onboardingStep: + return nil + case .fineTuneYourExperience: + return 244 + case .homeDefault: + return 0 + case .chatIntro: + return nil + case .chatConversation: + return 450 // Let content determine height dynamically + case .workingOnSummary: + return 281 + case .meetYourProfileIntro: + return 200 + case .meetYourProfile: + return 397 + case .preferencesAddedSuccess: + return 264 + case .readyToScanFirstProduct: + return 244 + case .seeHowScanningWorks: + return 176 + case .quickAccessNeeded: + return 220 + case .loginToContinue: + return 244 + case .updateAvatar(memberId: let memberId): + return 492 + } + } + + // MARK: - Onboarding next arrow + + private var shouldShowOnboardingNextArrow: Bool { + // Only show the forward arrow when we are on the main onboarding canvas + // and the bottom sheet is one of the preference questions. + guard case .mainCanvas = coordinator.currentCanvasRoute else { + return false + } + + // Show arrow for any onboarding step, but not for fineTuneYourExperience + if case .onboardingStep = coordinator.currentBottomSheetRoute { + return true + } + return false + } + + private func handleOnboardingNextTapped() { + Task { + // Wait for all pending uploads to complete before navigating + await familyStore.waitForPendingUploads() + + await MainActor.run { + // Reset member filter to "Everyone" for each new onboarding question + // but preserve the locked member in singleMember flow + if !coordinator.isAddingPreferencesForMember { + familyStore.selectedMemberId = nil + } + + // Get current step ID from route + guard case .onboardingStep(let currentStepId) = coordinator.currentBottomSheetRoute else { + return + } + + // Fire step completed BEFORE advancing state + if let stepIndex = store.dynamicSteps.firstIndex(where: { $0.id == currentStepId }), + let step = store.step(for: currentStepId) { + AnalyticsService.shared.trackOnboarding("Onboarding Step Completed", properties: [ + "step_id": currentStepId, + "step_index": stepIndex, + "step_name": step.header.name, + "flow_type": getOnboardingFlowType().rawValue, + "has_selections": hasSelections(for: step) + ]) + } + + // Check if current step is "lifeStyle" β†’ show FineTuneYourExperience + if currentStepId == "lifeStyle" { + coordinator.navigateInBottomSheet(.fineTuneYourExperience) + return + } + + // Check if this is the last step β†’ mark as complete, show summary, then IngrediBotView (stay on MainCanvasView) + if store.isLastStep { + // Mark the last section as complete to show 100% progress + store.next() + coordinator.navigateInBottomSheet(.workingOnSummary) + return + } + + // Advance logical onboarding progress (for progress bar & tag bar) + store.next() + + // Move the bottom sheet to the *newly current* onboarding question + if let newCurrentStepId = store.currentStepId { + coordinator.navigateInBottomSheet(.onboardingStep(stepId: newCurrentStepId)) + } + } + } + } + + @ViewBuilder + private func bottomSheetContent(for route: BottomSheetRoute) -> some View { + switch route { + case .alreadyHaveAnAccount: + AlreadyHaveAnAccount { + AnalyticsService.shared.trackOnboarding("Onboarding Existing User") + coordinator.navigateInBottomSheet(.welcomeBack) + } noPressed: { + AnalyticsService.shared.trackOnboarding("Onboarding New User") + coordinator.navigateInBottomSheet(.doYouHaveAnInviteCode) + } + + case .welcomeBack: + WelcomeBack() + + case .doYouHaveAnInviteCode: + DoYouHaveAnInviteCode { + AnalyticsService.shared.trackOnboarding("Onboarding Has Invite Code") + coordinator.navigateInBottomSheet(.enterInviteCode) + } noPressed: { + AnalyticsService.shared.trackOnboarding("Onboarding No Invite Code") + coordinator.navigateInBottomSheet(.whosThisFor) + } + + case .enterInviteCode: + EnterYourInviteCode( + yesPressed: { + AnalyticsService.shared.trackOnboarding("Onboarding Invite Code Entered") + // Show profile screen first before welcome screen + coordinator.isJoiningViaInviteCode = true + coordinator.showCanvas(.letsMeetYourIngrediFam) + coordinator.navigateInBottomSheet(.meetYourProfile(memberId: nil)) + }, + noPressed: { + coordinator.navigateInBottomSheet(.whosThisFor) + } + ) + + case .whosThisFor: + WhosThisFor { + AnalyticsService.shared.trackOnboarding("Onboarding Flow Selected", properties: ["flow_type": "individual"]) + // Guest login already happened on .heyThere screen, just proceed + do { + try await familyStore.createBiteBuddyFamily() + coordinator.showCanvas(.dietaryPreferencesAndRestrictions(isFamilyFlow: false)) + coordinator.navigateInBottomSheet(.dietaryPreferencesSheet(isFamilyFlow: false)) + } catch { + Log.error("PersistentBottomSheet", "Failed to create Bite Buddy family: \(error)") + // Don't navigate forward on error - user stays on current screen + } + } addFamilyPressed: { + AnalyticsService.shared.trackOnboarding("Onboarding Flow Selected", properties: ["flow_type": "family"]) + // Guest login already happened on .heyThere screen, just proceed + coordinator.showCanvas(.letsMeetYourIngrediFam) + } + + case .letsMeetYourIngrediFam: + MeetYourIngrediFam { + // If coming from Settings, user already exists - skip to adding members + // Otherwise, go to whatsYourName for new family creation + if coordinator.isCreatingFamilyFromSettings { + // User already exists, create pending self member from existing family + if let family = familyStore.family { + familyStore.setPendingSelfMemberFromExisting(family.selfMember) + } + coordinator.navigateInBottomSheet(.addMoreMembers) + } else if familyStore.pendingSelfMember != nil || familyStore.family != nil { + // Self member already exists (created earlier or family exists) + // Skip "What's your name?" and go directly to "Add more members" + coordinator.navigateInBottomSheet(.addMoreMembers) + } else { + // No self member yet - show "What's your name?" for initial creation + coordinator.navigateInBottomSheet(.whatsYourName) + } + } + + case .whatsYourName: + WhatsYourName { name in + // Async closure wrapper for immediate family creation + try await familyStore.createFamilyImmediate(selfName: name) + AnalyticsService.shared.trackOnboarding("Onboarding Family Member Added", properties: ["member_count": 1]) + coordinator.navigateInBottomSheet(.addMoreMembers) + } + + case .addMoreMembers: + AddMoreMembers { name, image, storagePath, color in + // Async closure wrapper for immediate member addition + let newMember = try await familyStore.addMemberImmediate( + name: name, + image: image, + storagePath: storagePath, + color: color, + webService: webService + ) + let memberCount = (familyStore.family?.otherMembers.count ?? 0) + 1 + AnalyticsService.shared.trackOnboarding("Onboarding Family Member Added", properties: ["member_count": memberCount]) + + // If coming from home screen, navigate to WouldYouLikeToInvite + // Otherwise, navigate to addMoreMembersMinimal (onboarding flow) + if case .home = coordinator.currentCanvasRoute { + coordinator.navigateInBottomSheet(.wouldYouLikeToInvite(memberId: newMember.id, name: name)) + } else { + coordinator.navigateInBottomSheet(.addMoreMembersMinimal) + } + } + + case .addMoreMembersMinimal: + AddMoreMembersMinimal { + let memberCount = (familyStore.family?.otherMembers.count ?? 0) + 1 + AnalyticsService.shared.trackOnboarding("Onboarding Family Members Choice", properties: [ + "choice": "all_set", + "member_count": memberCount + ]) + Task { + // If creating family from Settings, add members to existing family + // Otherwise, just proceed (family already created incrementally) + if coordinator.isCreatingFamilyFromSettings { + // Logic handled incrementally now? + // If we used `addMemberImmediate` in AddMoreMembers view, they are already added. + // But `AddMoreMembersMinimal` manages the list and "Continue". + // Wait, `AddMoreMembersMinimal` view uses `familyStore`. + // If we are in "Immediate" mode, the members are already in `family.otherMembers`. + // `AddMoreMembersMinimal` might be relying on `pendingOtherMembers`. + // I need to check `AddMoreMembersMinimal`. + + // For now, I will assume `AddMoreMembersMinimal` continues navigation. + // Existing logic called `createFamilyFromPendingIfNeeded` or `addPendingMembersToExistingFamily`. + // Since we are creating IMMEDIATELY, these pending lists should be empty or unused? + // `WhatsYourName` clears pending self member. + // `AddMoreMembers` (immediate) adds to family directly. + // So `pendingOtherMembers` should be empty? + // If so, `createFamilyFromPendingIfNeeded` does nothing? + // Let's verify. + + // Whatever we do, we just navigate to dietary preferences. + } else { + // Family created at WhatsYourName step. + // Members added at AddMoreMembers step. + // So we just proceed. + } + coordinator.showCanvas(.dietaryPreferencesAndRestrictions(isFamilyFlow: true)) + } + } addMorePressed: { + let memberCount = (familyStore.family?.otherMembers.count ?? 0) + 1 + AnalyticsService.shared.trackOnboarding("Onboarding Family Members Choice", properties: [ + "choice": "add_more", + "member_count": memberCount + ]) + coordinator.navigateInBottomSheet(.addMoreMembers) + } + + case .editMember(let memberId, let isSelf): + EditMember(memberId: memberId, isSelf: isSelf) { + coordinator.navigateInBottomSheet(.addMoreMembersMinimal) + } + + case .wouldYouLikeToInvite(let memberId, let name): + let _ = Log.debug("PersistentBottomSheet", "Rendering .wouldYouLikeToInvite for \(name) (id: \(memberId))") + WouldYouLikeToInvite( + name: name, + isLoading: isGeneratingInviteCode + ) { + Task { @MainActor in + await handleInviteShare(memberId: memberId, name: name) + } + } continuePressed: { + // Maybe later -> do NOT mark pending; only invited members should show "Pending" + // If this flow was started from Home/Manage Family, show "Add preferences?" sheet. + // Otherwise, keep onboarding behavior. + isGeneratingInviteCode = false + if case .home = coordinator.currentCanvasRoute { + // Show "Add preferences?" sheet instead of going home + coordinator.navigateInBottomSheet(.addPreferencesForMember(memberId: memberId, name: name)) + } else { + coordinator.navigateInBottomSheet(.addMoreMembersMinimal) + } + } + + case .addPreferencesForMember(let memberId, let name): + AddPreferencesForMemberSheet( + name: name, + laterPressed: { + // Reset member selection so other flows default to "Everyone" + familyStore.selectedMemberId = nil + + // Return to origin screen + if coordinator.isCreatingFamilyFromSettings { + appState.navigate(to: .manageFamily) + coordinator.navigateInBottomSheet(.homeDefault) + } else { + coordinator.navigateInBottomSheet(.homeDefault) + } + }, + yesPressed: { + // 1. Set flags to track this flow and origin + coordinator.isAddingPreferencesForMember = true + coordinator.addPreferencesForMemberId = memberId + coordinator.addPreferencesOriginIsSettings = coordinator.isCreatingFamilyFromSettings + + // 2. Pre-select the member in FamilyStore + familyStore.selectedMemberId = memberId + + // 3. Clear FoodNotesStore state BEFORE reset to prevent + // preparePreferencesForMember from saving empty prefs over old cache + // and ensure the member starts with a clean slate + foodNotesStore.clearCurrentPreferencesOwner() + foodNotesStore.clearMemberCache(for: memberId) + + // 4. Reset onboarding to start fresh for this specific member + let memberColor = familyStore.family?.otherMembers.first(where: { $0.id == memberId })?.color + store.reset(flowType: .singleMember, memberName: name, memberColor: memberColor) + + // 4. Navigate to food notes canvas + let steps = DynamicStepsProvider.loadSteps() + if let firstStepId = steps.first?.id { + coordinator.navigateInBottomSheet(.onboardingStep(stepId: firstStepId)) + } + coordinator.showCanvas(.mainCanvas(flow: .singleMember)) + } + ) + + case .generateAvatar: + GenerateAvatar( + isExpandedMinimal: $isExpandedMinimal, + randomPressed: { selection in + // Cancel any existing generation task + generationTask?.cancel() + generationTask = Task { + await memojiStore.generate(selection: selection, coordinator: coordinator) + } + }, + generatePressed: { selection in + // Cancel any existing generation task + generationTask?.cancel() + generationTask = Task { + await memojiStore.generate(selection: selection, coordinator: coordinator) + } + } + ) + .onAppear { + // Reset to collapsed state when appearing + isExpandedMinimal = false + } + + case .bringingYourAvatar: + IngrediBotWithText(text: "Bringing your avatar to life... it's going to be awesome!") + + case .meetYourAvatar: + // CRITICAL: Capture image and background color immediately to prevent EXC_BAD_ACCESS + // This ensures the image is not deallocated while the view is being created + let capturedImage = memojiStore.image + let capturedBackgroundColor = memojiStore.backgroundColorHex + + MeetYourAvatar( + image: capturedImage, + backgroundColorHex: capturedBackgroundColor + ) { + coordinator.navigateInBottomSheet(.generateAvatar) + } assignedPressed: { + let newMemberInfo = await handleAssignAvatar( + memojiStore: memojiStore, + familyStore: familyStore, + webService: webService + ) + + // If a new member was created AND we're on home screen, go to invite screen + if let (memberId, name) = newMemberInfo, + case .home = coordinator.currentCanvasRoute { + coordinator.navigateInBottomSheet(.wouldYouLikeToInvite(memberId: memberId, name: name)) + memojiStore.previousRouteForGenerateAvatar = nil + return + } + + // Navigate back based on where we came from + if let previousRoute = memojiStore.previousRouteForGenerateAvatar { + // If we came from meetYourProfile, go back there with the same memberId + if case .meetYourProfile(let memberId) = previousRoute { + coordinator.navigateInBottomSheet(.meetYourProfile(memberId: memberId)) + memojiStore.previousRouteForGenerateAvatar = nil + } else if case .addMoreMembers = previousRoute { + // If we came from AddMoreMembers in onboarding, continue forward to AddMoreMembersMinimal + // Otherwise, go back to AddMoreMembers + if case .mainCanvas = coordinator.currentCanvasRoute { + // In onboarding flow, continue forward + coordinator.navigateInBottomSheet(.addMoreMembersMinimal) + } else { + // Not in onboarding, go back to invite screen if new member was created + if let (memberId, name) = newMemberInfo { + coordinator.navigateInBottomSheet(.wouldYouLikeToInvite(memberId: memberId, name: name)) + } else { + coordinator.navigateInBottomSheet(previousRoute) + } + } + memojiStore.previousRouteForGenerateAvatar = nil + } else { + coordinator.navigateInBottomSheet(previousRoute) + memojiStore.previousRouteForGenerateAvatar = nil + } + } else if case .home = coordinator.currentCanvasRoute { + coordinator.navigateInBottomSheet(.homeDefault) + } else if case .mainCanvas = coordinator.currentCanvasRoute { + // In onboarding flow without previous route, continue to AddMoreMembersMinimal + coordinator.navigateInBottomSheet(.addMoreMembersMinimal) + } else { + coordinator.navigateInBottomSheet(.addMoreMembers) + } + } + + case .yourCurrentAvatar: + YourCurrentAvatar { + // Ensure GenerateAvatar knows to go back to YourCurrentAvatar when launched from Home/Settings flows + memojiStore.previousRouteForGenerateAvatar = .yourCurrentAvatar + coordinator.navigateInBottomSheet(.generateAvatar) + } + + case .setUpAvatarFor: + SetUpAvatarFor { + coordinator.navigateInBottomSheet(.yourCurrentAvatar) + } + + case .dietaryPreferencesSheet(let isFamilyFlow): + DietaryPreferencesSheetContent(isFamilyFlow: isFamilyFlow) { + // Stop haptic feedback when "Let's Go" is pressed + NotificationCenter.default.post(name: PhysicsController.stopHapticsNotification, object: nil) + + // Get first step ID from JSON dynamically + let steps = DynamicStepsProvider.loadSteps() + if let firstStepId = steps.first?.id { + coordinator.navigateInBottomSheet(.onboardingStep(stepId: firstStepId)) + } + coordinator.showCanvas(.mainCanvas(flow: isFamilyFlow ? .family : .individual)) + } + .onAppear { + // If the user initiated family creation from Settings, skip the + // "Personalize your Choices" sheet and auto-advance directly + // into the first dynamic onboarding step. + if coordinator.isCreatingFamilyFromSettings { + // Stop haptics immediately to avoid lingering feedback + NotificationCenter.default.post(name: PhysicsController.stopHapticsNotification, object: nil) + + // Navigate to the first dynamic step and switch canvas + let steps = DynamicStepsProvider.loadSteps() + if let firstStepId = steps.first?.id { + coordinator.navigateInBottomSheet(.onboardingStep(stepId: firstStepId)) + } + coordinator.showCanvas(.mainCanvas(flow: isFamilyFlow ? .family : .individual)) + } + } + + case .allSetToJoinYourFamily: + PreferencesAddedSuccessSheet(title: "All set to join your family!") { + // Check if this was "add preferences for member" flow + if coordinator.isAddingPreferencesForMember { + let wasFromSettings = coordinator.addPreferencesOriginIsSettings + + // Reset the flags + coordinator.isAddingPreferencesForMember = false + coordinator.addPreferencesForMemberId = nil + coordinator.addPreferencesOriginIsSettings = false + familyStore.selectedMemberId = nil + + if wasFromSettings { + // Return to Manage Family screen + coordinator.showCanvas(.home) + coordinator.navigateInBottomSheet(.homeDefault) + Task { @MainActor in + try? await Task.sleep(nanoseconds: 250_000_000) + appState.navigate(to: .manageFamily) + } + } else { + // Return to HomeView + coordinator.showCanvas(.home) + coordinator.navigateInBottomSheet(.homeDefault) + } + return + } + + // Check if family creation was initiated from Settings + if coordinator.isCreatingFamilyFromSettings { + // Reset the flag + coordinator.isCreatingFamilyFromSettings = false + // Navigate back to Home and request a push to Settings + coordinator.showCanvas(.home) + Task { @MainActor in + try? await Task.sleep(nanoseconds: 250_000_000) + appState.navigateToSettings = true + } + } else { + // Normal flow (Add Family): show ScanningHelpSheet + coordinator.showCanvas(.seeHowScanningWorks) + coordinator.navigateInBottomSheet(.seeHowScanningWorks) + } + } + + case .fineTuneYourExperience: + FineTuneExperience( + allSetPressed: { + AnalyticsService.shared.trackOnboarding("Onboarding Fine Tune Choice", properties: [ + "flow_type": getOnboardingFlowType().rawValue, + "choice": "skip" + ]) + coordinator.navigateInBottomSheet(.workingOnSummary) + }, + addPreferencesPressed: { + AnalyticsService.shared.trackOnboarding("Onboarding Fine Tune Choice", properties: [ + "flow_type": getOnboardingFlowType().rawValue, + "choice": "continue" + ]) + // Check if there's a next step available before advancing + // If lifeStyle is the final step, clicking "Add Preferences" should complete onboarding + guard let nextStepId = store.nextStepId else { + // No next step available, mark as complete and show summary flow (stay on MainCanvasView) + store.next() + coordinator.navigateInBottomSheet(.workingOnSummary) + return + } + + // Advance logical onboarding progress (for progress bar & tag bar) + store.next() + + // Navigate to the next step + coordinator.navigateInBottomSheet(.onboardingStep(stepId: nextStepId)) + } + ) + + case .onboardingStep(let stepId): + // Dynamically load step from JSON using step ID + if let step = store.step(for: stepId) { + DynamicOnboardingStepView( + step: step, + flowType: getOnboardingFlowType(), + preferences: $store.preferences + ) + .padding(.top, 24) + .padding(.bottom, 80) + } + case .chatIntro: + IngrediBotView() + case .chatConversation: + NavigationStack { + IngrediBotChatView() + } + .tint(Color(hex: "#303030")) + + case .workingOnSummary: + IngrediBotWithText( + text: "Working on your personalized summary…", + showBackgroundImage: false, + viewDidAppear: { + // After 2 seconds, navigate to chat intro + coordinator.navigateInBottomSheet(.chatIntro) + }, + delay: 2.0 + ) + + case .homeDefault: + EmptyView() + + case .meetYourProfileIntro: + MeetYourProfileIntroView() + + case .meetYourProfile(let memberId): + MeetYourProfileView(memberId: memberId) { + // Check if user just joined via invite code - proceed to welcome screen + if coordinator.isJoiningViaInviteCode { + coordinator.isJoiningViaInviteCode = false + coordinator.showCanvas(.welcomeToYourFamily) + } else if coordinator.currentCanvasRoute == .letsMeetYourIngrediFam { + // If on family overview, just go back to the family overview bottom sheet + coordinator.navigateInBottomSheet(.letsMeetYourIngrediFam) + } else if coordinator.currentCanvasRoute == .home { + // If on home screen, close the bottom sheet + // If settings sheet was active, it will remain active (it's a separate sheet) + coordinator.navigateInBottomSheet(.homeDefault) + } else if coordinator.isCreatingFamilyFromSettings { + // Check if family creation was initiated from Settings + coordinator.isCreatingFamilyFromSettings = false + // Navigate to home first + coordinator.showCanvas(.home) + // Then reopen Settings sheet after a brief delay to allow home to load + Task { @MainActor in + try? await Task.sleep(nanoseconds: 300_000_000) // 0.3 seconds + appState.activeSheet = .settings + } + } else { + // Normal flow - in Just Me flow, show ScanningHelpSheet after Meet Your Profile. + // In family flow, skip this and go home. + if getOnboardingFlowType() == .individual { + coordinator.showCanvas(.seeHowScanningWorks) + coordinator.navigateInBottomSheet(.seeHowScanningWorks) + } else { + // Normal onboarding flow - navigate to home + AnalyticsService.shared.trackOnboarding("Onboarding Completed", properties: [ + "flow_type": getOnboardingFlowType().rawValue, + "completion_source": "family_complete", + "steps_with_selections": stepsWithSelections() + ]) + OnboardingPersistence.shared.markCompleted() + coordinator.showCanvas(.home) + } + } + } + + case .preferencesAddedSuccess: + PreferencesAddedSuccessSheet { + // Check if this was "add preferences for member" flow + if coordinator.isAddingPreferencesForMember { + let wasFromSettings = coordinator.addPreferencesOriginIsSettings + + // Reset the flags + coordinator.isAddingPreferencesForMember = false + coordinator.addPreferencesForMemberId = nil + coordinator.addPreferencesOriginIsSettings = false + familyStore.selectedMemberId = nil + + if wasFromSettings { + // Return to Manage Family screen + coordinator.showCanvas(.home) + coordinator.navigateInBottomSheet(.homeDefault) + Task { @MainActor in + try? await Task.sleep(nanoseconds: 250_000_000) + appState.navigate(to: .manageFamily) + } + } else { + // Return to HomeView + coordinator.showCanvas(.home) + coordinator.navigateInBottomSheet(.homeDefault) + } + return + } + + // If this success sheet was reached while creating family from Settings, + // return back to Settings instead of proceeding to Meet Your Profile/Home. + if coordinator.isCreatingFamilyFromSettings { + coordinator.isCreatingFamilyFromSettings = false + coordinator.showCanvas(.home) + Task { @MainActor in + try? await Task.sleep(nanoseconds: 250_000_000) + appState.navigateToSettings = true + } + } else { + // After preferences success: + // - Just Me flow: show Meet Your Profile + // - Add Family flow: go directly to ScanningHelpSheet + if getOnboardingFlowType() == .individual { + coordinator.navigateInBottomSheet(.meetYourProfile(memberId: nil)) + } else { + coordinator.showCanvas(.seeHowScanningWorks) + coordinator.navigateInBottomSheet(.seeHowScanningWorks) + } + } + } + + case .readyToScanFirstProduct: + // MARK: - ScanningHelpSheet Feature Flag + // Set to true to show ScanningHelpSheet in onboarding flow (requires screen recording) + // Set to false to skip ScanningHelpSheet and go directly to PermissionsCanvas + // When screen recording is available, change this to true to re-enable the help sheet + let showScanningHelpSheet = false + + ReadyToScanSheet( + onBack: { + if getOnboardingFlowType() == .individual { + coordinator.navigateInBottomSheet(.meetYourProfile(memberId: nil)) + } else { + coordinator.showCanvas(.summaryAddFamily) + coordinator.navigateInBottomSheet(.allSetToJoinYourFamily) + } + }, + onNotRightNow: { + if showScanningHelpSheet { + coordinator.showCanvas(.seeHowScanningWorks) + coordinator.navigateInBottomSheet(.seeHowScanningWorks) + } else { + // Skip ScanningHelpSheet and go directly to PermissionsCanvas + coordinator.showCanvas(.whyWeNeedThesePermissions) + coordinator.navigateInBottomSheet(.quickAccessNeeded) + } + }, + onHaveAProduct: { + AnalyticsService.shared.trackOnboarding("Onboarding Completed", properties: [ + "flow_type": getOnboardingFlowType().rawValue, + "completion_source": "scan_shortcut", + "steps_with_selections": stepsWithSelections() + ]) + OnboardingPersistence.shared.markCompleted() + coordinator.showCanvas(.home) + Task { @MainActor in + try? await Task.sleep(nanoseconds: 250_000_000) + appState.activeSheet = .scan + } + } + ) + + case .seeHowScanningWorks: + ScanningHelpSheet( + onBack: { + // Navigate back based on onboarding flow type + if getOnboardingFlowType() == .individual { + coordinator.showCanvas(.summaryJustMe) + coordinator.navigateInBottomSheet(.meetYourProfile(memberId: nil)) + } else { + coordinator.showCanvas(.summaryAddFamily) + coordinator.navigateInBottomSheet(.allSetToJoinYourFamily) + } + }, + onGotIt: { + coordinator.showCanvas(.whyWeNeedThesePermissions) + coordinator.navigateInBottomSheet(.quickAccessNeeded) + } + ) + + case .quickAccessNeeded: + // MARK: - ScanningHelpSheet Feature Flag + // Set to true to show ScanningHelpSheet in onboarding flow (requires screen recording) + // Set to false to skip ScanningHelpSheet and go directly to PermissionsCanvas + // When screen recording is available, change this to true to re-enable the help sheet + let showScanningHelpSheet = false + + QuickAccessSheet( + onBack: { + if showScanningHelpSheet { + coordinator.showCanvas(.seeHowScanningWorks) + coordinator.navigateInBottomSheet(.seeHowScanningWorks) + } else { + // Skip ScanningHelpSheet and go back to ReadyToScanFirstProduct + coordinator.showCanvas(.readyToScanFirstProduct) + coordinator.navigateInBottomSheet(.readyToScanFirstProduct) + } + }, + onGoToHome: { + AnalyticsService.shared.trackOnboarding("Onboarding Completed", properties: [ + "flow_type": getOnboardingFlowType().rawValue, + "completion_source": "permissions_complete", + "steps_with_selections": stepsWithSelections() + ]) + OnboardingPersistence.shared.markCompleted() + coordinator.showCanvas(.home) + } + ) + + case .loginToContinue: + // Show as alert when opened from PermissionsCanvas, otherwise show as sheet + let showAsAlert = coordinator.currentCanvasRoute == .whyWeNeedThesePermissions + LoginToContinueSheet( + onBack: { + if showAsAlert { + // Just dismiss the alert, keep quickAccessNeeded visible in bottom sheet + coordinator.navigateInBottomSheet(.quickAccessNeeded) + } else { + coordinator.navigateInBottomSheet(.quickAccessNeeded) + } + }, + onSignedIn: { + // Stay on the same canvas β€” just go back to quickAccessNeeded. + // The login toggle reflects isSignedIn automatically. + coordinator.navigateInBottomSheet(.quickAccessNeeded) + }, + showAsAlert: showAsAlert + ) + case .updateAvatar(memberId: let memberId): + UpdateAvatarSheet(memberId: memberId) { + // Navigate back to previous route (meetYourProfile or home) + if case .home = coordinator.currentCanvasRoute { + coordinator.navigateInBottomSheet(.homeDefault) + } else { + coordinator.navigateInBottomSheet(.meetYourProfile(memberId: memberId)) + } + } + } + } + + // MARK: - Onboarding Analytics + + private var isInOnboardingFlow: Bool { + switch coordinator.currentCanvasRoute { + case .heyThere, .blankScreen, .letsGetStarted, .letsMeetYourIngrediFam, + .dietaryPreferencesAndRestrictions, .mainCanvas, .seeHowScanningWorks, + .whyWeNeedThesePermissions, .welcomeToYourFamily, .summaryJustMe, .summaryAddFamily, + .readyToScanFirstProduct: + return true + default: + return coordinator.isAddingPreferencesForMember + } + } + + private func onboardingFlowTypeString() -> String { + if case .dietaryPreferencesSheet(let isFamilyFlow) = coordinator.currentBottomSheetRoute { + return isFamilyFlow ? "family" : "individual" + } + return getOnboardingFlowType().rawValue + } + + private func hasSelections(for step: DynamicStep) -> Bool { + guard let value = store.preferences.sections[step.header.name] else { return false } + switch value { + case .list(let items): return !items.isEmpty + case .nested(let dict): return dict.values.contains { !$0.isEmpty } + } + } + + private func stepsWithSelections() -> [String] { + store.dynamicSteps.compactMap { step in + hasSelections(for: step) ? step.header.name : nil + } + } + + private func trackOnboardingRouteChange(to route: BottomSheetRoute) { + guard isInOnboardingFlow else { return } + + let flowType = onboardingFlowTypeString() + + switch route { + case .dietaryPreferencesSheet(let isFamilyFlow): + AnalyticsService.shared.trackOnboarding("Onboarding Dietary Intro Viewed", properties: [ + "flow_type": isFamilyFlow ? "family" : "individual" + ]) + + case .onboardingStep(let stepId): + guard let stepIndex = store.dynamicSteps.firstIndex(where: { $0.id == stepId }), + let step = store.step(for: stepId) else { return } + AnalyticsService.shared.trackOnboarding("Onboarding Step: \(step.header.name)", properties: [ + "step_id": stepId, + "step_index": stepIndex, + "flow_type": flowType + ]) + + case .fineTuneYourExperience: + AnalyticsService.shared.trackOnboarding("Onboarding Fine Tune Viewed", properties: ["flow_type": flowType]) + + case .workingOnSummary: + AnalyticsService.shared.trackOnboarding("Onboarding Summary Loading", properties: ["flow_type": flowType]) + + case .chatIntro: + AnalyticsService.shared.trackOnboarding("Onboarding Chat Intro Viewed", properties: ["flow_type": flowType]) + + case .chatConversation: + AnalyticsService.shared.trackOnboarding("Onboarding Chat Started", properties: ["flow_type": flowType]) + + case .meetYourProfile: + AnalyticsService.shared.trackOnboarding("Onboarding Meet Profile", properties: ["flow_type": flowType]) + + case .allSetToJoinYourFamily: + AnalyticsService.shared.trackOnboarding("Onboarding Family Welcome", properties: ["flow_type": flowType]) + + case .readyToScanFirstProduct: + AnalyticsService.shared.trackOnboarding("Onboarding Ready To Scan", properties: ["flow_type": flowType]) + + case .seeHowScanningWorks: + AnalyticsService.shared.trackOnboarding("Onboarding Demo Video Viewed", properties: ["flow_type": flowType]) + + case .quickAccessNeeded: + AnalyticsService.shared.trackOnboarding("Onboarding Permissions Viewed", properties: ["flow_type": flowType]) + + case .loginToContinue: + AnalyticsService.shared.trackOnboarding("Onboarding Login Prompted", properties: ["flow_type": flowType]) + + case .meetYourAvatar: + AnalyticsService.shared.trackOnboarding("Onboarding Avatar Generated") + + default: + break + } + } + + private func getOnboardingFlowType() -> OnboardingFlowType { + // If we are in the main canvas onboarding flow, use the flow type from the route. + // This ensures that "Just Me" (individual flow) doesn't show family UI even if a family exists. + if case .mainCanvas(let flow) = coordinator.currentCanvasRoute { + return flow + } + + // If there are other members in the family, show the family selection carousel + if let family = familyStore.family, !family.otherMembers.isEmpty { + return .family + } + + return .individual + } + + // MARK: - Tutorial Animation + + private func startTutorialSwipeAnimation() { + // Animate the card swiping left repeatedly + Task { @MainActor in + try? await Task.sleep(nanoseconds: 500_000_000) // 0.5s initial delay + + while tutorialData?.show == true { + // Swipe left + withAnimation(.easeInOut(duration: 0.6)) { + tutorialCardSwipeOffset = -80 + } + try? await Task.sleep(nanoseconds: 700_000_000) + + // Return to center + withAnimation(.easeInOut(duration: 0.6)) { + tutorialCardSwipeOffset = 0 + } + try? await Task.sleep(nanoseconds: 800_000_000) + } + } + } + + // MARK: - INVITES / SHARE + + @MainActor + private func handleInviteShare(memberId: UUID, name: String) async { + guard !isGeneratingInviteCode else { return } + + isGeneratingInviteCode = true + defer { isGeneratingInviteCode = false } + + // Invite button pressed - mark member as pending so the UI reflects it + familyStore.setInvitePendingForPendingOtherMember(id: memberId, pending: true) + + await ensureFamilyExistsForInvitesIfNeeded() + + guard let code = await familyStore.invite(memberId: memberId) else { + return + } + + let message = inviteShareMessage(inviteCode: code) + let items = inviteShareItems(message: message) + presentShareSheet(items: items) + + routeAfterInviteShare(memberId: memberId, name: name) + } + + @MainActor + private func ensureFamilyExistsForInvitesIfNeeded() async { + // Ensure the family exists before creating invite codes (needed for onboarding flows). + guard familyStore.family == nil else { return } + + if coordinator.isCreatingFamilyFromSettings { + await familyStore.addPendingMembersToExistingFamily() + } else { + await familyStore.createFamilyFromPendingIfNeeded() + } + } + + private func inviteShareMessage(inviteCode: String) -> String { + let formattedCode = formattedInviteCode(inviteCode) + return "You've been invited to join my IngrediCheck family.\nSet up your food profile and get personalized ingredient guidance tailored just for you.\n\nπŸ“² Download from the App Store \(appStoreURL) and enter this invite code:\n\(formattedCode)" + } + + private func formattedInviteCode(_ inviteCode: String) -> String { + let spaced = inviteCode.map { String($0) }.joined(separator: " ") + return "**\(spaced)**" + } + + private func inviteShareItems(message: String) -> [Any] { + // NOTE: Some share targets (WhatsApp/Instagram, etc.) will drop the text entirely + // if we include an image in the activity items. To make sure the invite code + link + // always show, we share the message only. + [message] + } + + @MainActor + private func routeAfterInviteShare(memberId: UUID, name: String) { + // After sharing invite, show "Add preferences?" sheet if on home screen + if case .home = coordinator.currentCanvasRoute { + // Show "Add preferences?" sheet after invite + coordinator.navigateInBottomSheet(.addPreferencesForMember(memberId: memberId, name: name)) + } else { + coordinator.navigateInBottomSheet(.addMoreMembersMinimal) + } + } + + private func presentShareSheet(items: [Any]) { + let controller = UIActivityViewController(activityItems: items, applicationActivities: nil) + + if let popover = controller.popoverPresentationController { + popover.sourceView = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap { $0.windows } + .first { $0.isKeyWindow } + popover.sourceRect = CGRect( + x: UIScreen.main.bounds.midX, + y: UIScreen.main.bounds.maxY, + width: 0, + height: 0 + ) + popover.permittedArrowDirections = [] + } + + guard let windowScene = UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .first(where: { $0.activationState == .foregroundActive }), + let root = windowScene.windows.first(where: { $0.isKeyWindow })?.rootViewController else { + return + } + + root.present(controller, animated: true) + } +} + +// MARK: - Avatar Assignment Helpers + +@MainActor +private func handleAssignAvatar( + memojiStore: MemojiStore, + familyStore: FamilyStore, + webService: WebService +) async -> (memberId: UUID, name: String)? { + // CRITICAL: Capture all data immediately to prevent accessing deallocated memory. + // We no longer re-upload the PNG; instead we use the storage path inside the + // `memoji-images` bucket returned by the backend. + guard let storagePath = memojiStore.imageStoragePath, !storagePath.isEmpty else { + Log.debug("PersistentBottomSheet", "handleAssignAvatar: ⚠️ No memoji storage path available, skipping") + return nil + } + + // CRITICAL: Capture ALL data immediately to prevent accessing deallocated objects during async operations + let backgroundColorHex = memojiStore.backgroundColorHex + let displayName = memojiStore.displayName + let currentFamily = familyStore.family + let currentAvatarTargetMemberId = familyStore.avatarTargetMemberId + let currentPendingSelfMember = familyStore.pendingSelfMember + let currentPendingOtherMembers = familyStore.pendingOtherMembers + + // During onboarding, if avatarTargetMemberId is not set but we have displayName, + // it means the user generated an avatar without adding the member first. + // We need to add the member to the pending list first. + var targetMemberId: UUID? = currentAvatarTargetMemberId + + // Extract memberId from previousRouteForGenerateAvatar if available + let memberIdFromRoute: UUID? = { + if case .meetYourProfile(let memberId) = memojiStore.previousRouteForGenerateAvatar { + return memberId + } + return nil + }() + + // Check if we came from addMoreMembers route (adding a NEW member) + let isAddingNewMember: Bool = { + if case .addMoreMembers = memojiStore.previousRouteForGenerateAvatar { + return true + } + return false + }() + + // If no targetMemberId is set but we have a displayName, we need to create the member + if targetMemberId == nil, + let name = displayName, + !name.isEmpty { + + // If we came from MeetYourProfile, check if it's for self member (memberId is nil) + let isFromProfileForSelf: Bool = { + if case .meetYourProfile(let memberId) = memojiStore.previousRouteForGenerateAvatar { + return memberId == nil + } + return false + }() + + // When family exists and we have a memberId from route, try to use it + if let family = currentFamily, let routeMemberId = memberIdFromRoute { + // Family exists and we have a memberId from the route - use it directly + Log.debug("PersistentBottomSheet", "handleAssignAvatar: Recovering targetMemberId from route: \(routeMemberId)") + targetMemberId = routeMemberId + } else if isAddingNewMember { + // Adding a NEW member from AddMoreMembers flow - create new member + if currentFamily != nil { + // Family exists: add member immediately to family + Log.debug("PersistentBottomSheet", "handleAssignAvatar: Adding new member from AddMoreMembers: \(name)") + do { + let newMember = try await familyStore.addMemberImmediate( + name: name, + image: nil, // We'll use storagePath instead + storagePath: storagePath, + color: backgroundColorHex, + webService: webService + ) + targetMemberId = newMember.id + Log.debug("PersistentBottomSheet", "handleAssignAvatar: βœ… New member created: \(newMember.name)") + // Avatar is already assigned via storagePath in addMemberImmediate, so we can return + return (newMember.id, newMember.name) + } catch { + Log.debug("PersistentBottomSheet", "handleAssignAvatar: ❌ Failed to create new member: \(error.localizedDescription)") + ToastManager.shared.show(message: "Failed to add member: \(error.localizedDescription)", type: .error) + return nil + } + } else { + // Onboarding: add to pending + Log.debug("PersistentBottomSheet", "handleAssignAvatar: Adding pending other member from AddMoreMembers: \(name)") + familyStore.addPendingOtherMember(name: name) + let updatedPendingMembers = familyStore.pendingOtherMembers + if !updatedPendingMembers.isEmpty, let lastMember = updatedPendingMembers.last { + targetMemberId = lastMember.id + } + } + } else if isFromProfileForSelf { + // This is for the self member from MeetYourProfile with nil memberId + if currentFamily == nil { + // Onboarding: add to pending + if familyStore.pendingSelfMember == nil { + Log.debug("PersistentBottomSheet", "handleAssignAvatar: No targetMemberId, adding pending self member: \(name)") + familyStore.setPendingSelfMember(name: name) + } + // Re-capture after modification + if let newSelfMember = familyStore.pendingSelfMember { + targetMemberId = newSelfMember.id + } + } else { + // Family exists but came from MeetYourProfile for self: use selfMember.id + Log.debug("PersistentBottomSheet", "handleAssignAvatar: Family exists, using selfMember.id for profile update") + targetMemberId = currentFamily?.selfMember.id + } + } else if currentPendingSelfMember == nil && currentFamily == nil { + // No pending self member and no family - this must be for the self member during onboarding + Log.debug("PersistentBottomSheet", "handleAssignAvatar: No targetMemberId, adding pending self member: \(name)") + familyStore.setPendingSelfMember(name: name) + if let newSelfMember = familyStore.pendingSelfMember { + targetMemberId = newSelfMember.id + } + } else { + // This is for an other member + if currentFamily != nil { + // Family exists: add member immediately to family + Log.debug("PersistentBottomSheet", "handleAssignAvatar: Family exists, adding member immediately: \(name)") + do { + let newMember = try await familyStore.addMemberImmediate( + name: name, + image: nil, // We'll use storagePath instead + storagePath: storagePath, + color: backgroundColorHex, + webService: webService + ) + targetMemberId = newMember.id + Log.debug("PersistentBottomSheet", "handleAssignAvatar: βœ… Member created successfully: \(newMember.name)") + // Avatar is already assigned via storagePath in addMemberImmediate, so we can return + // But we should verify the avatar was set correctly + return nil + } catch { + Log.debug("PersistentBottomSheet", "handleAssignAvatar: ❌ Failed to create member: \(error.localizedDescription)") + ToastManager.shared.show(message: "Failed to add member: \(error.localizedDescription)", type: .error) + return nil + } + } else { + // Onboarding: add to pending + Log.debug("PersistentBottomSheet", "handleAssignAvatar: No targetMemberId, adding pending other member: \(name)") + familyStore.addPendingOtherMember(name: name) + // Re-capture after modification + let updatedPendingMembers = familyStore.pendingOtherMembers + if !updatedPendingMembers.isEmpty, let lastMember = updatedPendingMembers.last { + targetMemberId = lastMember.id + } + } + } + } + + guard let targetMemberId = targetMemberId else { + Log.debug("PersistentBottomSheet", "handleAssignAvatar: ⚠️ No avatarTargetMemberId set and couldn't create member, skipping upload") + ToastManager.shared.show(message: "Unable to assign avatar. Please enter a name and try again.", type: .error) + return nil + } + + Log.debug("PersistentBottomSheet", "handleAssignAvatar: Starting avatar upload for memberId=\(targetMemberId)") + + // CRITICAL: Re-check pending members AFTER potentially adding a new member + // We need to check the current state because we may have just added a member + // It's safe to access familyStore properties here since we're in an async function with familyStore as a parameter + + // 1. Check if this is a pending self member + // Check current state first (includes newly added members), fallback to captured state + if let pendingSelf = familyStore.pendingSelfMember ?? currentPendingSelfMember, + pendingSelf.id == targetMemberId { + // This is the pending self member - use setPendingSelfMemberAvatar + Log.debug("PersistentBottomSheet", "handleAssignAvatar: Assigning to pending self member: \(pendingSelf.name)") + // Set the memoji storage path as imageFileHash and update color to match memoji background. + await familyStore.setPendingSelfMemberAvatarFromMemoji( + storagePath: storagePath, + backgroundColorHex: backgroundColorHex + ) + Log.debug("PersistentBottomSheet", "handleAssignAvatar: βœ… Avatar assigned to pending self member") + return nil + } + + // 2. Check if this is a pending other member + // Check current state first (includes newly added members), fallback to captured state + let currentPendingOthers = familyStore.pendingOtherMembers + let pendingOthersToCheck = !currentPendingOthers.isEmpty ? currentPendingOthers : currentPendingOtherMembers + if let pendingOther = pendingOthersToCheck.first(where: { $0.id == targetMemberId }) { + // This is a pending other member + Log.debug("PersistentBottomSheet", "handleAssignAvatar: Assigning to pending other member: \(pendingOther.name)") + + // If family exists, add the member to the family first, then assign avatar + if let family = currentFamily { + Log.debug("PersistentBottomSheet", "handleAssignAvatar: Family exists, adding pending member to family") + do { + let newMember = try await familyStore.addMemberImmediate( + name: pendingOther.name, + image: nil, + storagePath: storagePath, + color: backgroundColorHex ?? pendingOther.color, + webService: webService + ) + Log.debug("PersistentBottomSheet", "handleAssignAvatar: βœ… Member added to family and avatar assigned: \(newMember.name)") + // Avatar is already assigned via storagePath in addMemberImmediate + return nil + } catch { + Log.debug("PersistentBottomSheet", "handleAssignAvatar: ❌ Failed to add pending member to family: \(error.localizedDescription)") + ToastManager.shared.show(message: "Failed to add member: \(error.localizedDescription)", type: .error) + return nil + } + } else { + // Onboarding: just assign avatar to pending member + await familyStore.setAvatarForPendingOtherMemberFromMemoji( + id: targetMemberId, + storagePath: storagePath, + backgroundColorHex: backgroundColorHex + ) + Log.debug("PersistentBottomSheet", "handleAssignAvatar: βœ… Avatar assigned to pending other member") + return nil + } + } + + // 3. Otherwise, this is an existing member (from home view) - update directly without re-uploading + do { + // 1. Get the member first to access their color for compositing - use captured data + guard let family = currentFamily else { + Log.debug("PersistentBottomSheet", "handleAssignAvatar: ⚠️ No family loaded, cannot update member") + ToastManager.shared.show(message: "Unable to assign avatar. Family not found.", type: .error) + return nil + } + + let allMembers = [family.selfMember] + family.otherMembers + guard let member = allMembers.first(where: { $0.id == targetMemberId }) else { + Log.debug("PersistentBottomSheet", "handleAssignAvatar: ⚠️ Member \(targetMemberId) not found in family") + ToastManager.shared.show(message: "Unable to assign avatar. Member not found.", type: .error) + return nil + } + + Log.debug("PersistentBottomSheet", "handleAssignAvatar: Updating existing member \(member.name) with new avatar...") + + // 2. Upload transparent PNG image directly (no compositing - background color stored separately in member.color) + // Use captured background color if available, otherwise member's existing color + let bgColor = backgroundColorHex ?? member.color + Log.debug("PersistentBottomSheet", "handleAssignAvatar: Assigning memoji from storagePath=\(storagePath) with background color: \(bgColor)") + + var updatedMember = member + // Use the memoji storage path as imageFileHash so we can load directly from + // the `memoji-images` bucket without duplicating the PNG in `productimages`. + updatedMember.imageFileHash = storagePath + + // Also persist the memoji background color as the member's color so + // small avatars (e.g. in HomeView) use the same color as the + // MeetYourAvatar sheet. + // Use captured backgroundColorHex to avoid accessing deallocated object + if let bgHex = backgroundColorHex, !bgHex.isEmpty { + // Ensure color has a # prefix (backend check constraint requires it) + let normalizedColor = bgHex.hasPrefix("#") ? bgHex : "#\(bgHex)" + Log.debug("PersistentBottomSheet", "handleAssignAvatar: Updating member color to memoji background \(normalizedColor) (from \(bgHex))") + updatedMember.color = normalizedColor + } + + Log.debug("PersistentBottomSheet", "handleAssignAvatar: Updating member \(member.name) with imageFileHash=\(storagePath) and color=\(updatedMember.color)") + + // 4. Persist the updated member via FamilyStore + await familyStore.editMember(updatedMember) + + // Check if editMember succeeded (it doesn't throw, but sets errorMessage on failure) + if let errorMsg = familyStore.errorMessage { + Log.debug("PersistentBottomSheet", "handleAssignAvatar: ⚠️ Failed to update member in backend: \(errorMsg)") + Log.debug("PersistentBottomSheet", "handleAssignAvatar: ⚠️ Avatar uploaded but member update failed - imageFileHash may not be persisted") + ToastManager.shared.show(message: "Failed to update member: \(errorMsg)", type: .error) + } else { + Log.debug("PersistentBottomSheet", "handleAssignAvatar: βœ… Avatar assigned and member updated successfully") + } + } catch { + Log.debug("PersistentBottomSheet", "handleAssignAvatar: ❌ Failed to assign avatar: \(error.localizedDescription)") + ToastManager.shared.show(message: "Failed to assign avatar: \(error.localizedDescription)", type: .error) + } + + return nil +} + +// MARK: - Tutorial Redacted Card + +/// A redacted/skeleton card view used in the swipe tutorial overlay +private struct TutorialRedactedCard: View { + var body: some View { + VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: 8) { + // Redacted title + HStack { + RoundedRectangle(cornerRadius: 4) + .fill(Color.black.opacity(0.15)) + .frame(width: 100, height: 20) + Spacer() + RoundedRectangle(cornerRadius: 4) + .fill(Color.black.opacity(0.15)) + .frame(width: 30, height: 14) + } + + // Redacted subtitle + VStack(alignment: .leading, spacing: 4) { + RoundedRectangle(cornerRadius: 4) + .fill(Color.black.opacity(0.12)) + .frame(height: 12) + RoundedRectangle(cornerRadius: 4) + .fill(Color.black.opacity(0.12)) + .frame(width: 200, height: 12) + } + } + + // Redacted chips + FlowLayout(horizontalSpacing: 4, verticalSpacing: 8) { + ForEach(0..<4, id: \.self) { index in + let widths: [CGFloat] = [80, 110, 95, 120] + RoundedRectangle(cornerRadius: 16) + .fill(Color.white.opacity(0.5)) + .frame(width: widths[index], height: 32) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(Color.black.opacity(0.08), lineWidth: 1) + ) + } + } + + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 20) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background( + ZStack { + Color(hex: "FFEB84") + + VStack { + Spacer() + HStack { + Spacer() + Image("leaf-recycle") + .opacity(0.3) + } + } + .padding(.trailing, 10) + .offset(y: 17) + } + .clipShape(RoundedRectangle(cornerRadius: 24)) + ) + .clipShape(RoundedRectangle(cornerRadius: 24)) + } +} + +/// Preview wrapper with animation state +private struct TutorialOverlayPreview: View { + @State private var swipeOffset: CGFloat = 0 + @State private var isShowing: Bool = true + + private let cardWidth = UIScreen.main.bounds.width - 40 + private let cardHeight = UIScreen.main.bounds.height * 0.33 + + var body: some View { + ZStack { + // Simulated original card underneath (what would show through cutout) + Color(hex: "FFEB84") + .frame(width: cardWidth, height: cardHeight) + .clipShape(RoundedRectangle(cornerRadius: 24)) + .overlay( + VStack(alignment: .leading, spacing: 4) { + Text("Oils & Fats") + .font(.system(size: 20, weight: .regular)) + Text("Mark oils you prefer to avoid...") + .font(.system(size: 12)) + .opacity(0.8) + } + .padding(12), + alignment: .topLeading + ) + + // Dark overlay with cutout + Color.black.opacity(0.63) + .ignoresSafeArea() + .mask( + ZStack { + Rectangle().fill(Color.black) + RoundedRectangle(cornerRadius: 24) + .frame(width: cardWidth, height: cardHeight) + .blendMode(.destinationOut) + } + .compositingGroup() + ) + + // Redacted card on top (swipeable) + TutorialRedactedCard() + .frame(width: cardWidth, height: cardHeight) + .offset(x: swipeOffset) + .rotationEffect(.degrees(swipeOffset / 30)) + + // Hand icon and text overlay + VStack(spacing: -1) { + Image("swipe-hand") + .resizable() + .scaledToFit() + .frame(width: 80, height: 80) + .foregroundStyle(.white) + .rotationEffect(.degrees(-10)) + .offset(x: swipeOffset * 0.4, y: 0) + + Text("Swipe cards to review each category") + .font(NunitoFont.bold.size(16)) + .foregroundStyle(.white) + } + .offset(y: UIScreen.main.bounds.height * 0.25) + } + .onAppear { + startSwipeAnimation() + } + } + + private func startSwipeAnimation() { + Task { @MainActor in + try? await Task.sleep(nanoseconds: 500_000_000) + + while isShowing { + // Swipe left + withAnimation(.easeInOut(duration: 0.6)) { + swipeOffset = -80 + } + try? await Task.sleep(nanoseconds: 700_000_000) + + // Return to center + withAnimation(.easeInOut(duration: 0.6)) { + swipeOffset = 0 + } + try? await Task.sleep(nanoseconds: 800_000_000) + } + } + } +} + +#Preview("Tutorial Overlay with Animation") { + TutorialOverlayPreview() +} + +#Preview("Redacted Card Only") { + TutorialRedactedCard() + .frame(width: UIScreen.main.bounds.width - 40, height: UIScreen.main.bounds.height * 0.33) + .padding() +} diff --git a/IngrediCheck/Components/ProfileCard.swift b/IngrediCheck/Components/ProfileCard.swift new file mode 100644 index 00000000..959ac274 --- /dev/null +++ b/IngrediCheck/Components/ProfileCard.swift @@ -0,0 +1,163 @@ +// +// ProfileCard.swift +// IngrediCheckPreview +// +// Created by Gunjan Haldar on 07/10/25. +// + +import SwiftUI + +struct ProfileCard: View { + @Environment(FamilyStore.self) private var familyStore + + @State var isProfileCompleted: Bool = true + + private var selfMember: FamilyMember? { + return familyStore.family?.selfMember + } + + var body: some View { + ZStack { + + if isProfileCompleted { + if let member = selfMember { + ProfileCardAvatarView(member: member, size: 55) + .overlay( + Circle().stroke(Color.white, lineWidth: 4) + ) + } else { + Image("memoji_4") + .resizable() + .frame(width: 55, height: 55) + .clipShape(Circle()) + .overlay( + Circle().stroke(Color.white, lineWidth: 4) + ) + } + } else { + // the below component is only for background shadow as this is in zstack so for shadow this component is placed on the back of the circle so that the shadow should not overlap the circle + Text("611") + .font(NunitoFont.regular.size(12)) + .frame(height: 9.86) + .foregroundStyle(.grayScale10) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 62) + .foregroundStyle( + .primary800.gradient.shadow( + .inner(color: Color(hex: "#DAFF67").opacity(0.25), radius: 4, x: 1, y: 2.5) + ) + .shadow( + .drop(color: Color(hex: "C5C5C5"), radius: 3.4, x: 0, y: 4) + ) + ) + ) + .padding(.top, 55) + + Circle() + .frame(width: 66, height: 66) + .foregroundStyle(.grayScale30) + .shadow(color: Color(hex: "ECECEC"), radius: 9, x: 0, y: 0) + .overlay( + Circle() + .stroke(lineWidth: 0.5) + .foregroundStyle(.grayScale80) + .overlay( + Circle() + .trim(from: 0, to: 0.6) + .stroke(style: StrokeStyle(lineWidth: 2.5, lineCap: .round)) + .foregroundStyle(.primary700) + .rotationEffect(.degrees(90)) + ) + ) + + Circle() + .foregroundColor(.grayScale10) + .frame(width: 47, height: 47) + .shadow(color: Color(hex: "FBFBFB"), radius: 9, x: 0, y: 0) + .overlay { + if let member = selfMember { + ProfileCardAvatarView(member: member, size: 38) + } else { + Image("profile-ritika") + .resizable() + .frame(width: 38, height: 38) + } + } + .clipShape(.circle) + + Text("60%") + .font(NunitoFont.regular.size(12)) + .frame(height: 9.86) + .foregroundStyle(.grayScale10) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 62) + .foregroundStyle( + .primary800.gradient.shadow( + .inner(color: Color(hex: "#DAFF67").opacity(0.5), radius: 2, x: 0, y: 3) + ) + ) + ) + .padding(.top, 60) + } + } + } +} + +// MARK: - Profile Card Avatar View + +/// Avatar view used in ProfileCard to show the self member's memoji avatar. +struct ProfileCardAvatarView: View { + let member: FamilyMember + let size: CGFloat + + var body: some View { + // Use centralized MemberAvatar component + MemberAvatar.custom(member: member, size: size, imagePadding: 0) + } +} + +#Preview("ProfileCardAvatarView") { + let sampleMember = FamilyMember( + id: UUID(), + name: "John Doe", + color: "#91B640", + joined: true, + imageFileHash: "memoji_4", + invitePending: nil + ) + + HStack(spacing: 20) { + ProfileCardAvatarView(member: sampleMember, size: 38) + .overlay( + Circle().stroke(Color.white, lineWidth: 4) + ) + + ProfileCardAvatarView(member: sampleMember, size: 55) + .overlay( + Circle().stroke(Color.white, lineWidth: 4) + ) + + ProfileCardAvatarView(member: sampleMember, size: 66) + .overlay( + Circle().stroke(Color.white, lineWidth: 4) + ) + + } + .padding() + .background(Color.grayScale30) + .environment(WebService()) +} + +#Preview { + ZStack { + + Color.grayScale30 + + ProfileCard() + .environment(FamilyStore()) + } +} diff --git a/IngrediCheck/Components/RecentScanCard.swift b/IngrediCheck/Components/RecentScanCard.swift new file mode 100644 index 00000000..ddbd2da4 --- /dev/null +++ b/IngrediCheck/Components/RecentScanCard.swift @@ -0,0 +1,630 @@ +// +// RecentScanCard.swift +// IngrediCheck +// +// Reusable card component for Recent Scans - used in both HomeView and Recent Scans Page +// + +import SwiftUI + +// MARK: - Card Style + +enum RecentScanCardStyle { + case compact // HomeView - smaller, no background (parent has container) + case full // Recent Scans Page - larger, with background & border +} + +struct RecentScanCard: View { + let scan: DTO.Scan + let style: RecentScanCardStyle + var onFavoriteToggle: (String, Bool) -> Void + var onScanUpdated: ((DTO.Scan) -> Void)? + + @Environment(WebService.self) private var webService + @State private var isFavorited: Bool + @State private var isTogglingFavorite: Bool = false + @State private var isReanalyzing: Bool = false + @State private var reanalysisRotation: Double = 0 + @State private var localScan: DTO.Scan? + + init( + scan: DTO.Scan, + style: RecentScanCardStyle = .full, + onFavoriteToggle: @escaping (String, Bool) -> Void, + onScanUpdated: ((DTO.Scan) -> Void)? = nil + ) { + self.scan = scan + self.style = style + self.onFavoriteToggle = onFavoriteToggle + self.onScanUpdated = onScanUpdated + _isFavorited = State(initialValue: scan.is_favorited ?? false) + } + + // MARK: - Style-based dimensions + + private var imageSize: CGSize { + switch style { + case .compact: + return CGSize(width: 65, height: 78) + case .full: + return CGSize(width: 82, height: 98) + } + } + + private var imageCornerRadius: CGFloat { + switch style { + case .compact: + return 12 + case .full: + return 12 + } + } + + private var cardPadding: CGFloat { + switch style { + case .compact: + return 0 + case .full: + return 12 + } + } + + private var spacing: CGFloat { + switch style { + case .compact: + return 12 + case .full: + return 12 + } + } + + // MARK: - Computed Properties + + private var currentScan: DTO.Scan { + localScan ?? scan + } + + private var product: DTO.Product { + currentScan.toProduct() + } + + private var matchStatus: DTO.ProductRecommendation { + currentScan.toProductRecommendation() + } + + private var isStale: Bool { + currentScan.analysis_result?.is_stale ?? false + } + + private var productName: String { + product.name ?? "Unknown Product" + } + + private var brandAndDescription: String? { + let brand = product.brand?.trimmingCharacters(in: .whitespacesAndNewlines) + // For now, just show the brand. Could add description if available in data model. + if let brand, !brand.isEmpty { + return brand + } + return nil + } + + private var imageLocations: [DTO.ImageLocationInfo] { + product.images + } + + // MARK: - Body + + var body: some View { + HStack(spacing: spacing) { + // Single product image (left) + productImageView + + // Content (product info + actions + meta) + VStack(alignment: .leading, spacing: 8) { + // Product info and action buttons in one HStack + HStack(alignment: .top, spacing: 8) { + VStack(alignment: .leading, spacing: 4) { + Text(productName) + .font(ManropeFont.bold.size(style == .compact ? 14 : 16)) + .foregroundStyle(.teritairy1000) + .lineLimit(2) + + if let subtitle = brandAndDescription { + Text(subtitle) + .font(ManropeFont.regular.size(12)) + .foregroundStyle(.grayScale100) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(spacing: 8) { + // Stale indicator / Reanalysis button (only show when stale or reanalyzing) + if isStale || isReanalyzing { + Button { + if !isReanalyzing { + performReanalysis() + } + } label: { + Image("rotate") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 14, height: 14) + .foregroundStyle(isReanalyzing ? .grayScale50 : Color(hex: "#FF8A00")) + .rotationEffect(.degrees(reanalysisRotation)) + } + .buttonStyle(.plain) + .frame(width: 24, height: 24) + .background( + Circle() + .fill(isReanalyzing ? Color.grayScale20 : Color(hex: "#FFF3E0")) + ) + .disabled(isReanalyzing) + } + + // Favorite heart button + Button { + toggleFavorite() + } label: { + Image(systemName: isFavorited ? "heart.fill" : "heart") + .font(.system(size: style == .compact ? 16 : 18, weight: .semibold)) + .foregroundStyle(isFavorited ? Color(hex: "#FF4D4D") : .grayScale80) + } + .buttonStyle(.plain) + .disabled(isTogglingFavorite) + } + } + + Spacer(minLength: style == .compact ? 0 : 8) + + // Match status badge and time in one HStack with Spacer + HStack(alignment: .bottom, spacing: 8) { + matchStatusBadge + + Spacer() + + timeBadge + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(cardPadding) + .background(cardBackground) + .onChange(of: scan.is_favorited) { _, newValue in + if let newValue = newValue, !isTogglingFavorite { + isFavorited = newValue + } + } + .onChange(of: scan.analysis_result?.is_stale) { _, _ in + // Reset local scan when the parent's scan updates + localScan = nil + } + } + + // MARK: - Card Background + + @ViewBuilder + private var cardBackground: some View { + switch style { + case .compact: + Color.clear + case .full: + RoundedRectangle(cornerRadius: 24) + .fill(Color.white) +// .overlay( +// RoundedRectangle(cornerRadius: 24) +// .stroke(Color.grayScale30, lineWidth: 1) +// ) + } + } + + // MARK: - Product Image View + + @ViewBuilder + private var productImageView: some View { + if let firstImage = imageLocations.first { + RecentScanImageThumbnail(imageLocation: firstImage) + .frame(width: imageSize.width, height: imageSize.height) + .clipShape(RoundedRectangle(cornerRadius: imageCornerRadius)) + } else { + // Placeholder when no images + ZStack { + RoundedRectangle(cornerRadius: imageCornerRadius) + .fill(Color.grayScale30) + .frame(width: imageSize.width, height: imageSize.height) + Image("imagenotfound1") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: imageSize.width * 0.4, height: imageSize.height * 0.4) + } + } + } + + // MARK: - Time Badge + + @ViewBuilder + private var timeBadge: some View { + HStack(spacing: 6) { + Image("time") + .renderingMode(.template) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 14, height: 14) + .foregroundStyle(.grayScale80) + Text(currentScan.shortRelativeTime()) + .font(ManropeFont.regular.size(12)) + .foregroundStyle(.grayScale80) + } + .padding(.horizontal, 12) + .padding(.vertical, 4) + .background( + Capsule() + .fill(Color.grayScale30) + ) + } + + // MARK: - Match Status Badge + + @ViewBuilder + private var matchStatusBadge: some View { + HStack(spacing: 4) { + Circle() + .fill(matchStatus.badgeDotColor) + .frame(width: 8, height: 8) + Text(matchStatus.displayText) + .font(ManropeFont.semiBold.size(12)) + .foregroundStyle(matchStatus.badgeTextColor) + } + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background( + Capsule() + .fill(matchStatus.badgeBackgroundColor) + ) + } + + // MARK: - Actions + + private func toggleFavorite() { + guard !isTogglingFavorite else { return } + let previous = isFavorited + let next = !previous + + isFavorited = next + isTogglingFavorite = true + onFavoriteToggle(scan.id, next) + + Task { + do { + let updated = try await webService.toggleFavorite(scanId: scan.id) + await MainActor.run { + isFavorited = updated + isTogglingFavorite = false + onFavoriteToggle(scan.id, updated) + } + } catch { + await MainActor.run { + isFavorited = previous + isTogglingFavorite = false + onFavoriteToggle(scan.id, previous) + } + } + } + } + + private func performReanalysis() { + guard !isReanalyzing else { return } + + Task { + await MainActor.run { + isReanalyzing = true + // Start spinning animation + withAnimation(.linear(duration: 1.0).repeatForever(autoreverses: false)) { + reanalysisRotation = 360 + } + } + + do { + Log.debug("RecentScanCard", "Triggering re-analysis for scan: \(scan.id)") + let updatedScan = try await webService.reanalyzeScan(scanId: scan.id) + + await MainActor.run { + localScan = updatedScan + isReanalyzing = false + withAnimation(.easeOut(duration: 0.3)) { + reanalysisRotation = 0 + } + onScanUpdated?(updatedScan) + Log.debug("RecentScanCard", "Re-analysis complete for scan: \(scan.id)") + } + } catch { + Log.error("RecentScanCard", "Re-analysis failed: \(error)") + await MainActor.run { + isReanalyzing = false + withAnimation(.easeOut(duration: 0.3)) { + reanalysisRotation = 0 + } + } + } + } + } +} + +// MARK: - Image Thumbnail Component + +private struct RecentScanImageThumbnail: View { + let imageLocation: DTO.ImageLocationInfo + + @Environment(WebService.self) private var webService + @State private var image: UIImage? + + var body: some View { + ZStack { + if let image { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + } else { + RoundedRectangle(cornerRadius: 12) + .fill(Color.grayScale30) + Image("imagenotfound1") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 24, height: 24) + } + } + .clipped() + .task(id: imageLocationKey) { + guard image == nil else { return } + do { + let uiImage = try await webService.fetchImage(imageLocation: imageLocation, imageSize: .small) + await MainActor.run { + image = uiImage + } + } catch { + Log.error("RecentScanCard", "Failed to fetch thumbnail: \(error)") + } + } + } + + private var imageLocationKey: String { + switch imageLocation { + case .url(let url): + return url.absoluteString + case .imageFileHash(let hash): + return hash + case .scanImagePath(let path): + return path + } + } +} + +// MARK: - ProductRecommendation Badge Colors Extension + +extension DTO.ProductRecommendation { + var badgeDotColor: Color { + switch self { + case .match: + return Color.primary600 + case .notMatch: + return Color(hex: "#FF1100") + case .needsReview: + return Color(hex: "#FCDE00") + case .unknown: + return Color(hex: "#9E9E9E") + } + } + + var badgeTextColor: Color { + switch self { + case .match: + return Color.primary600 + case .notMatch: + return Color(hex: "#FF1100") + case .needsReview: + return Color(hex: "#FF594E") + case .unknown: + return Color(hex: "#757575") + } + } + + var badgeBackgroundColor: Color { + switch self { + case .match: + return Color.primary200 + case .notMatch: + return Color(hex: "#FFE3E2") + case .needsReview: + return Color(hex: "#FFF9CE") + case .unknown: + return Color(hex: "#F5F5F5") + } + } +} + +// MARK: - Short Relative Time Extension + +extension DTO.Scan { + /// Returns a shorter relative time format (e.g., "30 min", "2 hr", "Yesterday") + func shortRelativeTime(now: Date = Date()) -> String { + let isoFormatter = ISO8601DateFormatter() + isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + guard let date = isoFormatter.date(from: created_at) else { + return "" + } + + let interval = now.timeIntervalSince(date) + let seconds = Int(interval) + + if seconds < 60 { + return "Now" + } + + let minutes = seconds / 60 + if minutes < 60 { + return "\(minutes) min" + } + + let hours = minutes / 60 + if hours < 24 { + return hours == 1 ? "1 hr" : "\(hours) hr" + } + + let days = hours / 24 + if days == 1 { + return "Yesterday" + } + if days < 7 { + return "\(days) days" + } + + // For older items, show date + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "MMM d" + return dateFormatter.string(from: date) + } +} + +// MARK: - Preview Helper + +#if DEBUG +private func makeSampleScan( + id: String = UUID().uuidString, + name: String = "Sample Product", + brand: String = "Sample Brand", + isFavorited: Bool = false, + overallMatch: String = "matched", + minutesAgo: Int = 30, + isStale: Bool = false +) -> DTO.Scan { + let productInfo = DTO.ScanProductInfo( + name: name, + brand: brand, + ingredients: [ + DTO.Ingredient(name: "Water", vegan: true, vegetarian: true, ingredients: []), + DTO.Ingredient(name: "Sugar", vegan: true, vegetarian: true, ingredients: []) + ], + images: nil + ) + + // Create a date minutesAgo minutes in the past + let createdAt: String = { + let date = Date().addingTimeInterval(-Double(minutesAgo * 60)) + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.string(from: date) + }() + + // Create analysis result from JSON for preview + let analysisJson = """ + { + "id": "\(UUID().uuidString)", + "overall_match": "\(overallMatch)", + "overall_analysis": "Product analysis complete", + "ingredient_analysis": [], + "is_stale": \(isStale) + } + """.data(using: .utf8)! + let analysisResult = try? JSONDecoder().decode(DTO.ScanAnalysisResult.self, from: analysisJson) + + return DTO.Scan( + id: id, + scan_type: "barcode", + barcode: "1234567890", + state: "done", + product_info: productInfo, + product_info_source: "openfoodfacts", + product_info_vote: nil, + analysis_result: analysisResult, + images: [], + latest_guidance: nil, + created_at: createdAt, + last_activity_at: createdAt, + is_favorited: isFavorited, + analysis_id: nil + ) +} +#endif + +//#Preview("Full Style") { +// ScrollView { +// VStack(spacing: 12) { +// RecentScanCard( +// scan: makeSampleScan( +// name: "Organic Oat Milk", +// brand: "Oatly", +// isFavorited: true, +// overallMatch: "matched", +// minutesAgo: 15 +// ), +// style: .full, +// onFavoriteToggle: { _, _ in } +// ) +// +// RecentScanCard( +// scan: makeSampleScan( +// name: "Chocolate Chip Cookies - Strawberry flavor", +// brand: "Chips Ahoy", +// isFavorited: false, +// overallMatch: "unmatched", +// minutesAgo: 45, +// isStale: true +// ), +// style: .full, +// onFavoriteToggle: { _, _ in } +// ) +// +// RecentScanCard( +// scan: makeSampleScan( +// name: "Protein Bar", +// brand: "Quest", +// isFavorited: false, +// overallMatch: "uncertain", +// minutesAgo: 120 +// ), +// style: .full, +// onFavoriteToggle: { _, _ in } +// ) +// } +// .padding(20) +// } +// .background(Color.pageBackground) +// .environment(WebService()) +//} + +//#Preview("Compact Style") { +// ScrollView { +// VStack(spacing: 0) { +// ForEach(0..<3, id: \.self) { index in +// RecentScanCard( +// scan: makeSampleScan( +// name: index == 0 ? "Organic Oat Milk" : index == 1 ? "Chocolate Chip Cookies" : "Protein Bar", +// brand: index == 0 ? "Oatly" : index == 1 ? "Chips Ahoy" : "Quest", +// isFavorited: index == 0, +// overallMatch: index == 0 ? "matched" : index == 1 ? "unmatched" : "uncertain", +// minutesAgo: index == 0 ? 15 : index == 1 ? 45 : 120, +// isStale: index == 1 +// ), +// style: .compact, +// onFavoriteToggle: { _, _ in } +// ) +// .padding(.vertical, 12) +// +// if index < 2 { +// Divider() +// .background(Color.grayScale30) +// } +// } +// } +// .padding(.horizontal, 16) +// .background( +// RoundedRectangle(cornerRadius: 24) +// .fill(Color.white) +// ) +// .padding(20) +// } +// .background(Color.pageBackground) +// .environment(WebService()) +//} diff --git a/IngrediCheck/Components/RecentScansRow.swift b/IngrediCheck/Components/RecentScansRow.swift new file mode 100644 index 00000000..9c3283bc --- /dev/null +++ b/IngrediCheck/Components/RecentScansRow.swift @@ -0,0 +1,242 @@ +// +// RecentScansRow.swift +// IngrediCheckPreview +// +// Created by Gunjan Haldar on 06/10/25. +// + +import SwiftUI + +struct RecentScansRow: View { + + @State var feedback: Bool? + + init(feedback: Bool? = nil) { + _feedback = State(initialValue: feedback) + } + + var body: some View { + HStack(spacing: 12) { + HStack{ + Image("imagenotfound1") + .resizable() + .scaledToFill() + .frame(width: 20, height:20) + + }.frame(width: 42, height: 42) + .background(Color.grayScale50) + .cornerRadius(8) + VStack(alignment: .leading, spacing: 4) { + Text("Kellogg's Corn Flakes") + .font(ManropeFont.bold.size(12)) + .foregroundStyle(.teritairy1000) + + Text("30 minutes ago") + .font(ManropeFont.regular.size(12)) + .foregroundStyle(.grayScale100) + } + + Spacer() + + HStack(spacing: 4) { + Circle() + .fill(feedback == nil ? Color(hex: "#FCDE00") : feedback == true ? .primary600 : Color(hex: "#FF1100")) + .frame(width: 10, height: 10) + + Text(feedback == nil ? "Uncertain" : feedback == true ? "Matched" : "Unmatched") + .font(ManropeFont.medium.size(12)) + .foregroundStyle(feedback == nil ? Color(hex: "#FAB222") : feedback == true ? .primary600 : Color(hex: "#FF1100")) + } + .padding(.vertical, 5) + .padding(.horizontal, 8) + .background(feedback == nil ? Color(hex: "#FFF9CE") : feedback == true ? .primary200 : Color(hex: "#FFE3E2"), in: RoundedRectangle(cornerRadius: 50)) + } + } +} +struct ScanRow: View { + let scan: DTO.Scan + + @State private var image: UIImage? = nil + @State private var isFavorited: Bool + @State private var isTogglingFavorite: Bool = false + @Environment(WebService.self) var webService + @Environment(AppState.self) var appState + @Environment(ScanHistoryStore.self) var scanHistoryStore + + init(scan: DTO.Scan) { + self.scan = scan + _isFavorited = State(initialValue: scan.is_favorited ?? false) + } + + private var feedback: Bool? { + switch scan.toProductRecommendation() { + case .match: + return true + case .needsReview: + return nil + case .notMatch: + return false + case .unknown: + return nil // Treat unknown as Uncertain (nil) + } + } + + private var titleText: String { + let brand = scan.product_info.brand?.trimmingCharacters(in: .whitespacesAndNewlines) + let name = scan.product_info.name?.trimmingCharacters(in: .whitespacesAndNewlines) + if let brand, !brand.isEmpty, let name, !name.isEmpty { + return "\(brand) \(name)" + } + return name ?? brand ?? "not available" + } + + var body: some View { + HStack(spacing: 12) { + if let image { + Image(uiImage: image) + .resizable() + .scaledToFill() + .frame(width: 55, height: 55) + .cornerRadius(8) + .clipped() + } else { + HStack{ + Image("imagenotfound1") + .resizable() + .scaledToFill() + .frame(width: 20, height:20) + } + .frame(width: 55, height: 55) + .background(Color.grayScale50) + .cornerRadius(8) + } + + VStack(alignment: .leading, spacing: 12) { + Text(titleText) + .font(ManropeFont.semiBold.size(14)) + .foregroundStyle(.teritairy1000) + .lineLimit(1) + + HStack(spacing: 4) { + Circle() + .fill(feedback == nil ? Color(hex: "#FCDE00") : feedback == true ? .primary600 : Color(hex: "#FF1100")) + .frame(width: 10, height: 10) + + Text(feedback == nil ? "Uncertain" : feedback == true ? "Matched" : "Unmatched") + .font(ManropeFont.medium.size(12)) + .foregroundStyle(feedback == nil ? Color(hex: "#FF594E") : feedback == true ? .primary600 : Color(hex: "#FF1100")) + } + .padding(.vertical, 5) + .padding(.horizontal, 8) + .background(feedback == nil ? Color(hex: "#FFF9CE") : feedback == true ? .primary200 : Color(hex: "#FFE3E2"), in: RoundedRectangle(cornerRadius: 25)) + } + + Spacer() + + VStack(alignment: .trailing, spacing: 12) { + Button { + guard !isTogglingFavorite else { return } + let previous = isFavorited + let next = !previous + + // Optimistic UI update + isFavorited = next + isTogglingFavorite = true + + // Update app state + appState.setHistoryItemFavorited(clientActivityId: scan.id, favorited: next) + + print("[ScanRow] favorite tap: scanId=\(scan.id), previous=\(previous), optimistic=\(isFavorited)") + + Task { + do { + // Use new toggleFavorite API + let updated = try await webService.toggleFavorite(scanId: scan.id) + + await MainActor.run { + print("[ScanRow] favorite success: scanId=\(scan.id), updated=\(updated)") + isFavorited = updated + appState.setHistoryItemFavorited(clientActivityId: scan.id, favorited: updated) + isTogglingFavorite = false + + // Update scan in store + let newScan = DTO.Scan( + id: scan.id, + scan_type: scan.scan_type, + barcode: scan.barcode, + state: scan.state, + product_info: scan.product_info, + product_info_source: scan.product_info_source, + product_info_vote: scan.product_info_vote, + analysis_result: scan.analysis_result, + images: scan.images, + latest_guidance: scan.latest_guidance, + created_at: scan.created_at, + last_activity_at: scan.last_activity_at, + is_favorited: updated, + analysis_id: scan.analysis_id + ) + scanHistoryStore.upsertScan(newScan) + + // Sync to AppState + if var scans = appState.listsTabState.scans { + if let idx = scans.firstIndex(where: { $0.id == scan.id }) { + scans[idx] = newScan + appState.listsTabState.scans = scans + } + } + } + + // Refresh favorites list + if let listItems = try? await webService.getFavorites() { + await MainActor.run { + appState.listsTabState.listItems = listItems + } + } + } catch { + await MainActor.run { + print("[ScanRow] favorite error: scanId=\(scan.id), error=\(error.localizedDescription)") + isFavorited = previous + appState.setHistoryItemFavorited(clientActivityId: scan.id, favorited: previous) + isTogglingFavorite = false + } + } + } + } label: { + Image("favoriate") + .renderingMode(.template) + .resizable() + .scaledToFill() + .frame(width: 18, height: 17) + .foregroundStyle(isFavorited ? Color(hex: "#FF1100") : .grayScale70) + } + .buttonStyle(.plain) + .disabled(isTogglingFavorite) + + Text(scan.relativeTimeDescription()) + .font(ManropeFont.regular.size(12)) + .foregroundStyle(.grayScale100) + } + } + .task { + // Get first image from scan using toProduct() logic which handles both inventory and user images + let product = scan.toProduct() + if let firstImage = product.images.first, + let loaded = try? await webService.fetchImage(imageLocation: firstImage, imageSize: .small) { + await MainActor.run { + image = loaded + } + } + } + .onChange(of: scan.is_favorited) { _, newValue in + if let newValue = newValue, !isTogglingFavorite { + isFavorited = newValue + } + } + } +} + +#Preview { + RecentScansRow() + .padding(.horizontal, 20) +} diff --git a/IngrediCheck/Components/ScannerResultCard.swift b/IngrediCheck/Components/ScannerResultCard.swift new file mode 100644 index 00000000..0127c5c2 --- /dev/null +++ b/IngrediCheck/Components/ScannerResultCard.swift @@ -0,0 +1,53 @@ +// +// ScannerResultCard.swift +// IngrediCheckPreview +// +// Created by Gunjan Haldar on 06/11/25. +// + +import SwiftUI + +enum ScannerResultOptions: String, CaseIterable, Identifiable { + case loading + case fetchingDetails + case analysing + case matched + case unmatched + case uncertain + case retry + case productNotFound + + var id: String { self.rawValue } + + var verdict: String? { + switch self { + case .loading, .productNotFound: return nil + case .fetchingDetails: return "Fetching details..." + case .analysing: return "Analysing..." + case .matched: return "Matched" + case .unmatched: return "Unmatched" + case .uncertain: return "Uncertain" + case .retry: return "Retry" + } + } + + var verdictMessage: String? { + switch self { + case .loading, .productNotFound, .fetchingDetails, .analysing: return nil + case .matched: return "No major allergens detected" + case .unmatched: return "Includes restricted items" + case .uncertain: return "Needs a quick review" + case .retry: return "Analysis failed !" + } + } +} + +struct ScannerResultCard: View { + var body: some View { + Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + } +} + +#Preview { + ScannerResultCard() +} diff --git a/IngrediCheck/Components/ScannerToCameraSlider.swift b/IngrediCheck/Components/ScannerToCameraSlider.swift new file mode 100644 index 00000000..a6b12774 --- /dev/null +++ b/IngrediCheck/Components/ScannerToCameraSlider.swift @@ -0,0 +1,18 @@ +// +// ScannerToCameraSlider.swift +// IngrediCheckPreview +// +// Created by Gunjan Haldar on 13/11/25. +// + +import SwiftUI + +struct ScannerToCameraSlider: View { + var body: some View { + Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + } +} + +#Preview { + ScannerToCameraSlider() +} diff --git a/IngrediCheck/Components/StackedCards.swift b/IngrediCheck/Components/StackedCards.swift new file mode 100644 index 00000000..2e298cd7 --- /dev/null +++ b/IngrediCheck/Components/StackedCards.swift @@ -0,0 +1,370 @@ +// +// Temp2.swift +// IngrediCheckPreview +// +// Created by Gunjan Haldar on 03/10/25. +// + +import SwiftUI + +struct Card: Identifiable, Equatable { + var id = UUID().uuidString + var title: String + var subTitle: String + var color: Color + var chips: [ChipsModel] + var isFallback: Bool = false +} + +struct StackedCards: View { + + var isChipSelected: (Card, ChipsModel) -> Bool + var onChipTap: (Card, ChipsModel) -> Void + var onSwipe: (() -> Void)? = nil + + @State private var cards: [Card] + @State private var currentIndex: Int = 0 + private let totalCardCount: Int + @State var dragOffset: CGSize = .zero + @State var dragValue: CGFloat = 0 + @State var tempCard: Card = Card(title: "", subTitle: "", color: .black, chips: []) + private var progressText: String { + totalCardCount > 0 ? "\(currentIndex)/\(totalCardCount)" : "" + } + + init( + cards: [Card], + isChipSelected: @escaping (Card, ChipsModel) -> Bool = { _, _ in false }, + onChipTap: @escaping (Card, ChipsModel) -> Void = { _, _ in }, + onSwipe: (() -> Void)? = nil + ) { + var augmentedCards = cards + let fallbackCard = Card( + title: "Did we miss something?", + subTitle: "No worries! You can share any preferences later, we’ve got you covered.", + color: Color(hex: "#D7EEB2"), + chips: [], + isFallback: true + ) + augmentedCards.append(fallbackCard) + + self._cards = State(initialValue: augmentedCards) + self._currentIndex = State(initialValue: augmentedCards.isEmpty ? 0 : 1) + self.totalCardCount = augmentedCards.count + self.isChipSelected = isChipSelected + self.onChipTap = onChipTap + self.onSwipe = onSwipe + } + + var body: some View { + ZStack { + ForEach(Array(cards.enumerated()).prefix(2).reversed(), id: \.element.id) { + idx, + card in + ZStack { + VStack(alignment: .leading, spacing: 20) { + if card.isFallback { + ZStack { + VStack(spacing: 6) { + Image("Questionmark-bot") + + .resizable() + .scaledToFit() + .frame(width: 110, height: 107) + + Text(card.title) + .font(ManropeFont.extraBold.size(18)) + .multilineTextAlignment(.center) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + + Text(card.subTitle) + .font(ManropeFont.regular.size(12)) + .opacity(0.8) + .multilineTextAlignment(.center) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + + VStack { + HStack { + Spacer() + Text(progressText) + .font(ManropeFont.regular.size(14)) + .foregroundColor(.grayScale140) + } + Spacer() + } + } + .opacity((idx == 0) ? 1 : 0) + } else { + VStack(alignment: .leading, spacing: 4) { + HStack(){ + Text(card.title) + .font(.system(size: 20, weight: .regular)) + .multilineTextAlignment(.leading) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + Spacer() + Text(progressText) + .font(ManropeFont.regular.size(14)) + .foregroundColor(.grayScale140) + } + + Text(card.subTitle) + .font(.system(size: 12, weight: .regular)) + .opacity(0.8) + .multilineTextAlignment(.leading) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + } + .opacity((idx == 0) ? 1 : 0) + + FlowLayout(horizontalSpacing: 4, verticalSpacing: 8) { + ForEach(card.chips, id: \.id) { chip in + IngredientsChipsForStackedCards( + title: chip.name, + bgColor: nil, + fontColor: "303030", + image: chip.icon ?? "", + onClick: { + onChipTap(card, chip) + }, + isSelected: isChipSelected(card, chip), + outlined: false + ) + } + } + .opacity((idx == 0) ? 1 : 0) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 20) + .frame(height: UIScreen.main.bounds.height * 0.33, alignment: .topLeading) + .background( + ZStack { + (idx == 0 ? card.color : tempCard.color) + + if idx == 0 { + if card.isFallback { + VStack { + HStack { + Image("circle-cards") + .resizable() + .scaledToFit() + .frame(width: 183, height: 248) + .opacity(0.85) + .offset(x: -51 , y: -77) + + Spacer() + } + Spacer() + } + .padding(.leading, 0) + .padding(.top, 0) + + VStack { + Spacer() + HStack { + Spacer() + Image("circle-cards") + .resizable() + .scaledToFit() + .frame(width: 197, height: 241) + .opacity(0.55) + .offset(x: 77, y: 51) + + } + } + .padding(.trailing, 0) + .padding(.bottom, 0) + } else { + VStack { + Spacer() + HStack { + Spacer() + Image("leaf-recycle") + .opacity(0.5) + } + } + .padding(.trailing, 10) + .offset(y: 17) + } + } + } + .clipShape(RoundedRectangle(cornerRadius: 24)) + ) + } + .blur(radius: (idx == 0) ? 0 : 4) + .opacity((idx == 0) ? 1 : 0.52) + .clipShape(RoundedRectangle(cornerRadius: 24)) + .rotationEffect(.degrees((idx == 0) ? 0 : 4)) + .offset(x: (idx == 0) ? dragOffset.width : 0, y: (idx == 0) ? dragOffset.height : 0) + .highPriorityGesture( + DragGesture() + .onChanged { value in + guard idx == 0 else { return } + + dragOffset.width = value.translation.width + dragOffset.height = 0 + + dragValue = value.translation.width + } + .onEnded { _ in + guard idx == 0 else { return } + + withAnimation(.smooth) { + dragOffset = .zero + + if dragValue > 80 { + right() + onSwipe?() + } + + if dragValue < -80 { + left() + onSwipe?() + } + + dragValue = 0 + } + } + ) + } + } + .onAppear() { + guard cards.indices.contains(1) else { return } + if cards.indices.contains(1) { + tempCard = cards[1] + + cards[1].title = cards[0].title + cards[1].subTitle = cards[0].subTitle + cards[1].chips = cards[0].chips + cards[1].color = cards[0].color + } + } + } + + func left() { + withAnimation(.smooth) { + dragOffset.width = -600 + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + resetCardPositionAndMove() + } + } + + func right() { + withAnimation(.smooth) { + dragOffset.width = 600 + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + resetCardPositionAndMove() + } + } + + func resetCardPositionAndMove() { + withAnimation(.smooth) { + dragOffset = .zero + addToLast() + if totalCardCount > 0 { + currentIndex = (currentIndex % totalCardCount) + 1 + } + + cards[0].title = tempCard.title + cards[0].subTitle = tempCard.subTitle + cards[0].chips = tempCard.chips + cards[0].color = tempCard.color + + tempCard.title = cards[1].title + tempCard.subTitle = cards[1].subTitle + tempCard.chips = cards[1].chips + tempCard.color = cards[1].color + + cards[1].title = cards[0].title + cards[1].subTitle = cards[0].subTitle + cards[1].chips = cards[0].chips + cards[1].color = cards[0].color + } + } + + func addToLast() { + guard !cards.isEmpty else { return } + let temp = cards.removeFirst() + cards.append(temp) + } +} + +#Preview { + StackedCards(cards: [ + Card( + title: "one", + subTitle: "This is the dummy sub-title, and this is the first text", + color: .purple, + chips: [ChipsModel(name: "High Protein", icon: "πŸ—"), + ChipsModel(name: "Low Carb", icon: "πŸ₯’"), + ChipsModel(name: "Low Fat", icon: "πŸ₯‘"), + ChipsModel(name: "Balanced Marcos", icon: "βš–οΈ")], + ), + Card( + title: "two", + subTitle: "This is the dummy sub-title, and this is the first text", + color: .yellow, + chips: [ChipsModel(name: "High Protein", icon: "πŸ—"), + ChipsModel(name: "Low Carb", icon: "πŸ₯’"), + ChipsModel(name: "Low Fat", icon: "πŸ₯‘"), + ChipsModel(name: "Balanced Marcos", icon: "βš–οΈ"), + ChipsModel(name: "High Protein", icon: "πŸ—"), + ChipsModel(name: "Low Carb", icon: "πŸ₯’"), + ChipsModel(name: "Low Fat", icon: "πŸ₯‘"), + ChipsModel(name: "Balanced Marcos", icon: "βš–οΈ")], + ), + Card( + title: "three", + subTitle: "This is the dummy sub-title, and this is the first text hughwrhugw oighwioghiowhgo woihgiowhgiow oigwhioghwiog owirhgiorwhgiowrh woighiowrhgiowrg oighrwioghiorwhgiohrwg", + color: .pink, + chips: [ChipsModel(name: "High Protein", icon: "πŸ—"), + ChipsModel(name: "Low Carb", icon: "πŸ₯’"), + ChipsModel(name: "Low Fat", icon: "πŸ₯‘"), + ChipsModel(name: "Balanced Marcos", icon: "βš–οΈ")], + ), + Card( + title: "four", + subTitle: "This is the dummy sub-title, and this is the first text", + color: .orange, + chips: [ChipsModel(name: "High Protein", icon: "πŸ—"), + ChipsModel(name: "Low Carb", icon: "πŸ₯’"), + ChipsModel(name: "Low Fat", icon: "πŸ₯‘"), + ChipsModel(name: "Balanced Marcos", icon: "βš–οΈ")], + ), + Card( + title: "five", + subTitle: "This is the dummy sub-title, and this is the first textThis is the dummy sub-title, and this is the first textThis is the dummy sub-title, and this is the first textThis is the dummy sub-title, and this is the first textThis is the dummy sub-title, and this is the first textThis is the dummy sub-title, and this is the first textThis is the dummy sub-title, and this is the first textThis is the dummy sub-title, and this is the first textThis is the dummy sub-title, and this is the first textThis is the dummy sub-title, and this is the first textThis is the dummy sub-title, and this is the first textThis is the dummy sub-title, and this is the first text", + color: .green, + chips: [ChipsModel(name: "High Protein", icon: "πŸ—"), + ChipsModel(name: "Low Carb", icon: "πŸ₯’"), + ChipsModel(name: "Low Fat", icon: "πŸ₯‘"), + ChipsModel(name: "Balanced Marcos", icon: "βš–οΈ")], + ), + Card( + title: "six", + subTitle: "This is the dummy sub-title, and this is the first text", + color: .blue, + chips: [ChipsModel(name: "High Protein", icon: "πŸ—"), + ChipsModel(name: "Low Carb", icon: "πŸ₯’"), + ChipsModel(name: "Low Fat", icon: "πŸ₯‘"), + ChipsModel(name: "Balanced Marcos", icon: "βš–οΈ"), + ChipsModel(name: "High Protein", icon: "πŸ—"), + ChipsModel(name: "Low Carb", icon: "πŸ₯’"), + ChipsModel(name: "Low Fat", icon: "πŸ₯‘"), + ChipsModel(name: "Balanced Marcos", icon: "βš–οΈ"), + ChipsModel(name: "High Protein", icon: "πŸ—"), + ChipsModel(name: "Low Carb", icon: "πŸ₯’"), + ChipsModel(name: "Low Fat", icon: "πŸ₯‘")], + ) + ]) + .padding() +} diff --git a/IngrediCheck/Components/TabBar.swift b/IngrediCheck/Components/TabBar.swift new file mode 100644 index 00000000..9bf75afa --- /dev/null +++ b/IngrediCheck/Components/TabBar.swift @@ -0,0 +1,214 @@ +// +// TabBar.swift +// IngrediCheckPreview +// +// Created by Gunjan Haldar on 09/10/25. +// + +import SwiftUI +import AVFoundation + +struct TabBar: View { + @Environment(AppNavigationCoordinator.self) private var coordinator + + @State var scale: CGFloat = 1.0 + @State var offsetY: CGFloat = 0 + @Binding var isExpanded: Bool + @State private var showCameraPermissionAlert = false + @Environment(ScanHistoryStore.self) var scanHistoryStore + @Environment(AppState.self) var appState + var onRecentScansTap: (() -> Void)? = nil + var onChatBotTap: (() -> Void)? = nil + + + var body: some View { +// ZStack { + ZStack(alignment: .bottom) { + HStack(alignment: .center) { + Button { + onRecentScansTap?() + } label: { + Image("tabBar-history") + .renderingMode(.template) + .resizable() + .foregroundColor(Color(hex: "676A64")) + .frame(width: 26, height: 26) + } + + + Spacer() + + Button { + if let onChatBotTap { + onChatBotTap() + } else { + coordinator.presentChatBot(startAtConversation: true) + } + } label: { + Image("tabBar-ingredibot") + .renderingMode(.template) + .resizable() + .foregroundColor(Color(hex: "676A64")) + .frame(width: 26, height: 26) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 22) + .padding(.vertical, 12.5) + .frame(width: 196) + .background( + Capsule() + .fill(.white) + .shadow(color: Color(hex: "E9E9E9"), radius: 13.6, x: 0, y: 12) + ) + .overlay( + Capsule() + .stroke(lineWidth: 0.25) + .foregroundStyle(.grayScale50) + ) + .scaleEffect(scale) + .offset(y: offsetY) + + Button { + handleScannerTap() + } label: { + ZStack { + Circle() + .frame(width: 60, height: 60) + .foregroundStyle( + LinearGradient( + gradient: Gradient(stops: [ + .init(color: Color(hex: "91C206"), location: 0.2), + .init(color: Color(hex: "6B8E06"), location: 0.7) + ]), + startPoint: .top, + endPoint: .bottom + ) + .shadow( + .inner(color: Color(hex: "99C712"), radius: 2.5, x: 4, y: -2.5) + ) + .shadow( + .drop(color: Color(hex: "606060").opacity(0.35), radius: 3.3, x: 0, y: 4) + ) + ) + .rotationEffect(.degrees(18)) + + Image("tabBar-scanner") + .resizable() + .frame(width: 32, height: 32) + } + } + .buttonStyle(.plain) + .padding(.bottom, 18) + } + .alert("Camera Access Required", isPresented: $showCameraPermissionAlert) { + Button("Later", role: .cancel) { } + Button("Open Settings") { + openAppSettings() + } + } message: { + Text("To scan products, please allow camera access in Settings.") + } + .onChange(of: isExpanded) { oldValue, newValue in + something() + } + } + + + // MARK: - Camera Permission Handling + + private func handleScannerTap() { + let status = AVCaptureDevice.authorizationStatus(for: .video) + + switch status { + case .authorized: + // Permission already granted, use push navigation + appState.navigate(to: .scanCamera(initialMode: nil, initialScanId: nil)) + + case .notDetermined: + // Request permission + AVCaptureDevice.requestAccess(for: .video) { granted in + DispatchQueue.main.async { + if granted { + appState.navigate(to: .scanCamera(initialMode: nil, initialScanId: nil)) + } else { + showCameraPermissionAlert = true + } + } + } + + case .denied, .restricted: + // Permission denied or restricted, show alert + showCameraPermissionAlert = true + + @unknown default: + showCameraPermissionAlert = true + } + } + + private func openAppSettings() { + guard let settingsURL = URL(string: UIApplication.openSettingsURLString) else { return } + if UIApplication.shared.canOpenURL(settingsURL) { + UIApplication.shared.open(settingsURL) + } + } + + // MARK: - Tab Bar Animation + + func something() { + withAnimation(.smooth) { + + if isExpanded { + withAnimation(.smooth) { + offsetY = 25 + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + withAnimation(.smooth) { + scale = 1 + } + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + withAnimation(.smooth) { + offsetY = 0 + } + } + + } else { + withAnimation(.smooth) { + offsetY = 25 + } + + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + withAnimation(.smooth) { + scale = 0.1 + } + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + withAnimation(.smooth) { + offsetY = 0 + } + } + } + + } + + } + +} + +#Preview { + VStack { + Image("Iphone-image") + .resizable() +// .aspectRatio(contentMode: .fill) + .opacity(0.1).ignoresSafeArea() + TabBar(isExpanded: .constant(true)) + .environment(AppState()) + .environment(ScanHistoryStore(webService: WebService())) + .environment(AppNavigationCoordinator(initialRoute: .home)) + } +} diff --git a/IngrediCheck/Components/ToastView.swift b/IngrediCheck/Components/ToastView.swift new file mode 100644 index 00000000..29debd32 --- /dev/null +++ b/IngrediCheck/Components/ToastView.swift @@ -0,0 +1,56 @@ +// +// ToastView.swift +// IngrediCheck +// +// Created by Auto-Agent on 09/01/26. +// + +import SwiftUI + +struct ToastView: View { + let data: ToastData + let onDismiss: () -> Void + + var body: some View { + HStack(spacing: 12) { + Image(systemName: data.type.icon) + .font(.system(size: 20)) + .foregroundColor(data.type.color) + + Text(data.message) + .font(ManropeFont.medium.size(14)) + .foregroundColor(.black) + .multilineTextAlignment(.leading) + + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(Color.white) + .cornerRadius(12) + .shadow(color: Color.black.opacity(0.1), radius: 10, x: 0, y: 5) + .padding(.horizontal, 20) + .onTapGesture { + onDismiss() + } + .transition(.move(edge: .top).combined(with: .opacity)) + } +} + +#Preview { + ZStack { + Color.gray.opacity(0.2).ignoresSafeArea() + + VStack { + ToastView( + data: ToastData(message: "Something went wrong. Please try again.", type: .error, duration: 3), + onDismiss: {} + ) + + ToastView( + data: ToastData(message: "Family created successfully!", type: .success, duration: 3), + onDismiss: {} + ) + } + } +} diff --git a/IngrediCheck/Components/UserFeedbackCard.swift b/IngrediCheck/Components/UserFeedbackCard.swift new file mode 100644 index 00000000..249c28e5 --- /dev/null +++ b/IngrediCheck/Components/UserFeedbackCard.swift @@ -0,0 +1,87 @@ +// +// UserFeedbackCard.swift +// IngrediCheckPreview +// +// Created by Gunjan Haldar on 08/10/25. +// + +import SwiftUI + +struct UserFeedbackCard: View { + + /// Current selected star rating (0–5). 0 means β€œnot rated yet”. + @State private var rating: Int = 0 + @Environment(\.openURL) private var openURL + + var body: some View { + VStack { + HStack(alignment: .bottom) { + VStack(alignment: .leading) { + Text("We’d love") + Text("Your") + Text("Feedback") + } + .font(ManropeFont.semiBold.size(16)) + Spacer() + Image("feedbackimg") + .frame(width: 55, height: 55) + } + + VStack(alignment: .leading) { + Text("Your feedback matters!") + .font(ManropeFont.light.size(12)) + .foregroundColor(Color(hex: "#A6A6A6")) + + Divider() + } + + // Star rating row + HStack(spacing: 8) { + ForEach(1...5, id: \.self) { index in + Button { + // Tapping a star sets the rating to that value. + // All stars up to this index become β€œactive”. + rating = index + if let writeURL = URL(string: "itms-apps://apps.apple.com/app/id6477521615?action=write-review") { + openURL(writeURL) { accepted in + if !accepted { + if let webURL = URL(string: "https://apps.apple.com/us/app/ingredicheck-grocery-scanner/id6477521615?see-all=reviews&platform=iphone") { + openURL(webURL) + } + } + } + } + } label: { + Image("star-rating") + .renderingMode(.template) + .foregroundColor( + index <= rating + ? Color(hex: "#FFD860") + : .grayScale90 // default / inactive color + ) + } + .buttonStyle(.plain) + } + } + } + .padding(16) + .frame(height: UIScreen.main.bounds.height * 0.18) + .background( + RoundedRectangle(cornerRadius: 24) + .foregroundStyle(.grayScale10) + .shadow(color: Color(hex: "ECECEC"), radius: 9, x: 0, y: 0) + ) + .overlay( + RoundedRectangle(cornerRadius: 24) + .stroke(lineWidth: 0.25) + .foregroundStyle(.grayScale60) + ) + } +} + +#Preview { + ZStack { + // Color(.gray).opacity(0.2).ignoresSafeArea() + UserFeedbackCard() + } +} diff --git a/IngrediCheck/Components/YourBarcodeScans.swift b/IngrediCheck/Components/YourBarcodeScans.swift new file mode 100644 index 00000000..59a47eee --- /dev/null +++ b/IngrediCheck/Components/YourBarcodeScans.swift @@ -0,0 +1,108 @@ +// +// YourBarcodeScans.swift +// IngrediCheckPreview +// +// Created by Gunjan Haldar on 08/10/25. +// + +import SwiftUI + +struct YourBarcodeScans: View { + @Environment(UserPreferences.self) var userPreferences + @Environment(ScanHistoryStore.self) var scanHistoryStore + @Environment(AppState.self) var appState + var barcodeScansCount: Int? = nil + + private var displayCount: Int { + barcodeScansCount ?? userPreferences.totalScanCount + } + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: 8) { + Text("Your Barcode Scans") + .font(ManropeFont.regular.size(14)) + .foregroundStyle(.grayScale110) + + Text("\(displayCount)") + .font(.system(size: 52, weight: .bold)) + .foregroundStyle(.grayScale150) + .frame(height: 40) + } + + Button { + appState.navigate(to: .scanCamera(initialMode: nil, initialScanId: nil)) + } label: { + HStack(spacing: 4) { + Image("your-barcode-scan") + .resizable() + .frame(width: 12, height: 12) + + Text("Scan") + .font(NunitoFont.semiBold.size(10)) + .foregroundStyle(.grayScale10) + } + .padding(.vertical, 8) + .padding(.horizontal, 13) + .background( + RoundedRectangle(cornerRadius: 22) + .foregroundStyle( + .primary800 + .gradient + .shadow( + .inner(color: Color(hex: "#DAFF67").opacity(0.25), radius: 7.3, x: 2, y: 9) + ) + .shadow( + .inner(color: Color(hex: "#A2D20C"), radius: 5.7, x: 0, y: 4) + ) + ) + .shadow(color: Color(hex: "#C5C5C5").opacity(0.57), radius: 11, x: 0, y: 4) + ) + .overlay( + RoundedRectangle(cornerRadius: 22) + .stroke(lineWidth: 1) + .foregroundStyle(.grayScale10) + ) + } + } + + Spacer() + } + .padding(16) + .frame(height: UIScreen.main.bounds.height * 0.18) + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: 24) + .foregroundStyle(.grayScale10) + .shadow(color: Color(hex: "ECECEC"), radius: 9, x: 0, y: 0) + ) + .overlay( + Image("scan-card-jar") + .clipShape(RoundedRectangle(cornerRadius: 24)) + , alignment: .bottomTrailing + ) + .overlay( + RoundedRectangle(cornerRadius: 24) + .stroke(lineWidth: 0.25) + .foregroundStyle(.grayScale60) + ) + .onAppear { + // Refresh count from UserDefaults when view appears to ensure it's up-to-date + // This handles cases where the count was updated while the view wasn't visible + userPreferences.refreshScanCount() + } + } +} + +#Preview { + let webService = WebService() + + ZStack { + YourBarcodeScans() + .environment(UserPreferences()) + .environment(webService) + .environment(ScanHistoryStore(webService: webService)) + .environment(AppState()) + } +} diff --git a/IngrediCheck/Config.swift.sample b/IngrediCheck/Config.swift.sample new file mode 100644 index 00000000..241ad17b --- /dev/null +++ b/IngrediCheck/Config.swift.sample @@ -0,0 +1,9 @@ +import Foundation + +struct Config { + static let supabaseURL = URL(string: "https://YOUR_PROJECT.supabase.co")! + static let supabaseKey = "YOUR_SUPABASE_ANON_KEY" + static let supabaseFunctionsURLBase = "https://YOUR_PROJECT.supabase.co/functions/v1/ingredicheck/" + static let flyIOBaseURL = "https://YOUR_FLY_APP.fly.dev" + static let usePreviewFlow = false +} diff --git a/IngrediCheck/DTO.swift b/IngrediCheck/DTO.swift index 6750f312..876e011c 100644 --- a/IngrediCheck/DTO.swift +++ b/IngrediCheck/DTO.swift @@ -1,15 +1,23 @@ import Foundation import SwiftUI +import os class DTO { + struct Vote: Codable, Hashable { + let id: String + let value: String // "up" or "down" + } + enum ImageLocationInfo: Codable, Equatable, Hashable { case url(URL) - case imageFileHash(String) + case imageFileHash(String) // For productimages bucket + case scanImagePath(String) // For scans bucket (user-uploaded scan images) enum CodingKeys: String, CodingKey { case url case imageFileHash + case scanImagePath } init(from decoder: Decoder) throws { @@ -17,6 +25,8 @@ class DTO { if let urlString = try container.decodeIfPresent(String.self, forKey: .url), let url = URL(string: urlString) { self = .url(url) + } else if let scanImagePath = try container.decodeIfPresent(String.self, forKey: .scanImagePath) { + self = .scanImagePath(scanImagePath) } else if let imageFileHash = try container.decodeIfPresent(String.self, forKey: .imageFileHash) { self = .imageFileHash(imageFileHash) } else { @@ -36,6 +46,15 @@ class DTO { case vegan case vegetarian case ingredients + case contains // API may use "contains" instead of "ingredients" + } + + // Convenience initializer for creating from string (for Scan API) + init(name: String, vegan: Bool?, vegetarian: Bool?, ingredients: [Ingredient]) { + self.name = name + self.vegan = vegan + self.vegetarian = vegetarian + self.ingredients = ingredients } init(from decoder: Decoder) throws { @@ -43,7 +62,38 @@ class DTO { name = try container.decode(String.self, forKey: .name) vegan = try Ingredient.decodeYesNoMaybe(from: container, forKey: .vegan) vegetarian = try Ingredient.decodeYesNoMaybe(from: container, forKey: .vegetarian) - ingredients = try container.decodeIfPresent([Ingredient].self, forKey: .ingredients) ?? [] + + // Handle both "ingredients" and "contains" fields + // API sends "contains" as array of Ingredient objects with nested contains + if let ingredientsArray = try? container.decode([Ingredient].self, forKey: .ingredients) { + ingredients = ingredientsArray + } else if let containsArray = try? container.decode([Ingredient].self, forKey: .contains) { + // API uses "contains" field with nested Ingredient objects + ingredients = containsArray + } else if let containsStrings = try? container.decode([String].self, forKey: .contains) { + // Fallback: "contains" as array of strings (legacy format) + ingredients = containsStrings.map { Ingredient.parseFromString($0) } + } else { + ingredients = [] + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(name, forKey: .name) + + // Encode vegan/vegetarian as "yes"/"no" strings if present + if let vegan = vegan { + try container.encode(vegan ? "yes" : "no", forKey: .vegan) + } + if let vegetarian = vegetarian { + try container.encode(vegetarian ? "yes" : "no", forKey: .vegetarian) + } + + // Encode ingredients (not contains) - use standard format + if !ingredients.isEmpty { + try container.encode(ingredients, forKey: .ingredients) + } } private static func decodeYesNoMaybe(from container: KeyedDecodingContainer, forKey key: CodingKeys) throws -> Bool? { @@ -60,6 +110,58 @@ class DTO { return nil } } + + /// Parse an ingredient from a string, handling nested format like "Chocolate (Sugar, Cocoa)" + static func parseFromString(_ text: String) -> Ingredient { + let trimmed = text.trimmingCharacters(in: .whitespaces) + + // Check for nested format: "Name (sub1, sub2, sub3)" + guard let openParen = trimmed.firstIndex(of: "("), + let closeParen = trimmed.lastIndex(of: ")"), + openParen < closeParen else { + // No nested ingredients - return simple ingredient + return Ingredient(name: trimmed, vegan: nil, vegetarian: nil, ingredients: []) + } + + let name = String(trimmed[.. [String] { + var result: [String] = [] + var current = "" + var parenDepth = 0 + + for char in text { + if char == "(" { + parenDepth += 1 + current.append(char) + } else if char == ")" { + parenDepth -= 1 + current.append(char) + } else if char == "," && parenDepth == 0 { + result.append(current) + current = "" + } else { + current.append(char) + } + } + + if !current.isEmpty { + result.append(current) + } + + return result + } } struct AnnotatedIngredient { @@ -87,7 +189,7 @@ class DTO { let images: [ImageLocationInfo] let ingredient_recommendations: [IngredientRecommendation] let rating: Int - let favorited: Bool + var favorited: Bool func calculateMatch() -> ProductRecommendation { var result: ProductRecommendation = .match @@ -114,6 +216,8 @@ class DTO { Color.fail100 case .needsReview: Color.warning100 + case .unknown: + Color.gray } } @@ -122,7 +226,7 @@ class DTO { isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] guard let date = isoFormatter.date(from: created_at) else { - print("Invalid date string") + Log.debug("DTO", "Invalid date string") return nil } @@ -133,6 +237,35 @@ class DTO { let localDateString = dateFormatter.string(from: date) return localDateString } + + func relativeTimeDescription(now: Date = Date()) -> String { + let isoFormatter = ISO8601DateFormatter() + isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + guard let date = isoFormatter.date(from: created_at) else { + return "" + } + + let interval = now.timeIntervalSince(date) + let seconds = Int(interval) + + if seconds < 60 { + return "Just now" + } + + let minutes = seconds / 60 + if minutes < 60 { + return "\(minutes) min ago" + } + + let hours = minutes / 60 + if hours < 24 { + return hours == 1 ? "1 hour ago" : "\(hours) hours ago" + } + + let days = hours / 24 + return days == 1 ? "1 day ago" : "\(days) days ago" + } } struct ListItem: Codable, Hashable, Equatable { @@ -150,7 +283,7 @@ class DTO { isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] guard let date = isoFormatter.date(from: created_at) else { - print("Invalid date string") + Log.debug("DTO", "Invalid date string") return nil } @@ -169,6 +302,7 @@ class DTO { let name: String? let ingredients: [Ingredient] let images: [ImageLocationInfo] + let claims: [String]? // Dietary claims/tags from product (e.g., "Gluten Free", "High in Protein") private func productHasIngredient(ingredientName: String) -> Bool { func inner(ingredients: [Ingredient]) -> Bool { @@ -192,7 +326,6 @@ class DTO { ingredientToString(i) } .joined(separator: ", ") - .capitalized + ")" } } @@ -239,12 +372,14 @@ class DTO { let safetyRecommendation: SafetyRecommendation let reasoning: String let preference: String + let memberIdentifiers: [String]? // Array of member IDs from members_affected } enum ProductRecommendation { case match case needsReview case notMatch + case unknown } struct ImageInfo: Codable { @@ -406,4 +541,588 @@ class DTO { let decoratedFragments = decoratedIngredientListFragments(annotatedIngredients: annotatedIngredients) return splitDecoratedFragmentsIfNeeded(decoratedFragments: decoratedFragments) } + + // MARK: - Scan API Models + + struct ScanProductInfo: Codable, Hashable { + let name: String? + let brand: String? + let ingredients: [Ingredient] + let images: [ScanImageInfo]? + let claims: [String]? // Dietary claims/tags from product + + // Convenience initializer for creating empty ScanProductInfo + init(name: String?, brand: String?, ingredients: [Ingredient], images: [ScanImageInfo]?, claims: [String]? = nil) { + self.name = name + self.brand = brand + self.ingredients = ingredients + self.images = images + self.claims = claims + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + name = try container.decodeIfPresent(String.self, forKey: .name) + brand = try container.decodeIfPresent(String.self, forKey: .brand) + images = try container.decodeIfPresent([ScanImageInfo].self, forKey: .images) + claims = try container.decodeIfPresent([String].self, forKey: .claims) + + // Handle ingredients - API returns string array, convert to Ingredient objects + if container.contains(.ingredients) { + // Backend now only returns string arrays for ingredients + if let stringArray = try? container.decode([String].self, forKey: .ingredients) { + // Parse nested format like "Dark Chocolate (Sugar, Cocoa, Vanilla)" + ingredients = stringArray.map { Ingredient.parseFromString($0) } + } else { + // Fallback: try decoding as Ingredient objects (for backward compatibility) + ingredients = try container.decodeIfPresent([Ingredient].self, forKey: .ingredients) ?? [] + } + } else { + ingredients = [] + } + } + + enum CodingKeys: String, CodingKey { + case name, brand, ingredients, images, claims + } + } + + struct ScanImageInfo: Codable, Hashable { + let url: String? + } + + struct ScanAnalysisResult: Codable, Hashable { + let id: String? // UUID - Analysis ID for feedback submission (required per API spec) + let overall_analysis: String? + let overall_match: String? // "matched", "uncertain", "unmatched" - optional as it may be missing in some responses + var ingredient_analysis: [ScanIngredientAnalysis] + let is_stale: Bool? + var vote: Vote? + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + // Decode id (required per API spec for feedback submission) + id = try container.decodeIfPresent(String.self, forKey: .id) + + // Try both camelCase (overallAnalysis) and snake_case (overall_analysis) + overall_analysis = try container.decodeIfPresent(String.self, forKey: .overall_analysis) + ?? container.decodeIfPresent(String.self, forKey: .overallAnalysis) + + // Try both camelCase (overallMatch) and snake_case (overall_match) for backend compatibility + overall_match = try container.decodeIfPresent(String.self, forKey: .overall_match) + ?? container.decodeIfPresent(String.self, forKey: .overallMatch) + + // Try both camelCase (flaggedIngredients) and snake_case (ingredient_analysis) + ingredient_analysis = try container.decodeIfPresent([ScanIngredientAnalysis].self, forKey: .ingredient_analysis) + ?? container.decodeIfPresent([ScanIngredientAnalysis].self, forKey: .flaggedIngredients) + ?? [] + + // Decode is_stale (snake_case from API) + is_stale = try container.decodeIfPresent(Bool.self, forKey: .is_stale) + + // Decode vote + vote = try container.decodeIfPresent(Vote.self, forKey: .vote) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(id, forKey: .id) + try container.encodeIfPresent(overall_analysis, forKey: .overall_analysis) + try container.encodeIfPresent(overall_match, forKey: .overall_match) + try container.encode(ingredient_analysis, forKey: .ingredient_analysis) + try container.encodeIfPresent(is_stale, forKey: .is_stale) + try container.encodeIfPresent(vote, forKey: .vote) + } + + enum CodingKeys: String, CodingKey { + case id + case overall_analysis + case overallAnalysis // Support camelCase from API + case overall_match + case overallMatch // Support camelCase from API + case ingredient_analysis + case flaggedIngredients // Support camelCase from API + case is_stale + case vote + } + } + + struct ScanIngredientAnalysis: Codable, Hashable { + let ingredient: String + let match: String // "unmatched", "uncertain" + let reasoning: String + let members_affected: [String] + var vote: Vote? + + // Note: API uses snake_case (members_affected), so we use default CodingKeys + } + + // SSE Event payloads + struct ScanProductInfoEvent: Codable { + let scan_id: String + let product_info: ScanProductInfo + let product_info_source: String + let images: [ScanImage] + } + + struct ScanAnalysisEvent: Codable { + let analysis_status: String + let analysis_result: ScanAnalysisResult? + + // Note: API uses snake_case throughout (analysis_status, analysis_result, overall_match, overall_analysis, ingredient_analysis) + } + + // Image types in scan response + enum ScanImage: Codable, Hashable { + case inventory(InventoryScanImage) + case user(UserScanImage) + + struct InventoryScanImage: Codable, Hashable { + let type: String // "inventory" + let url: String + var vote: Vote? + } + + struct UserScanImage: Codable, Hashable { + let type: String // "user" + let content_hash: String + let storage_path: String? + let status: String // "pending", "processing", "processed", "failed" + let extraction_error: String? + } + + private enum CodingKeys: String, CodingKey { + case type + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + + switch type { + case "inventory": + self = .inventory(try InventoryScanImage(from: decoder)) + case "user": + self = .user(try UserScanImage(from: decoder)) + default: + throw DecodingError.dataCorruptedError( + forKey: .type, + in: container, + debugDescription: "Unknown image type: \(type)" + ) + } + } + + func encode(to encoder: Encoder) throws { + switch self { + case .inventory(let img): + try img.encode(to: encoder) + case .user(let img): + try img.encode(to: encoder) + } + } + } + + // Full Scan object + struct Scan: Codable, Hashable { + let id: String + let scan_type: String // "barcode", "photo", or "barcode_plus_photo" + let barcode: String? + let state: String // "fetching_product_info", "processing_images", "analyzing", "done", "error" + let product_info: ScanProductInfo + let product_info_source: String? // "openfoodfacts", "extraction", "enriched" + var product_info_vote: Vote? + var analysis_result: ScanAnalysisResult? + var images: [ScanImage] + let latest_guidance: String? + let created_at: String + let last_activity_at: String + let error: String? // Error message when state is "error" + + // Additional fields that may be present in API response but not always used + let is_favorited: Bool? + let analysis_id: String? + + enum CodingKeys: String, CodingKey { + case id + case scan_type + case barcode + case state + case product_info + case product_info_source + case product_info_vote + case analysis_result + case images + case latest_guidance + case created_at + case last_activity_at + case error + case is_favorited + case analysis_id + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + scan_type = try container.decode(String.self, forKey: .scan_type) + barcode = try container.decodeIfPresent(String.self, forKey: .barcode) + state = try container.decode(String.self, forKey: .state) + + // Handle product_info - may be empty object {} + // Try to decode product_info, if it fails (e.g., empty object), create empty ScanProductInfo + do { + let productInfoDecoder = try container.superDecoder(forKey: .product_info) + product_info = try ScanProductInfo(from: productInfoDecoder) + } catch let error { + // If decoding fails (empty object or malformed), create empty ScanProductInfo + Log.error("SCAN_DECODE", "⚠️ Failed to decode product_info, using empty: \(error)") + // Create empty ScanProductInfo using the convenience initializer + product_info = ScanProductInfo( + name: nil, + brand: nil, + ingredients: [], + images: nil + ) + } + + product_info_source = try container.decodeIfPresent(String.self, forKey: .product_info_source) + product_info_vote = try container.decodeIfPresent(Vote.self, forKey: .product_info_vote) + analysis_result = try container.decodeIfPresent(ScanAnalysisResult.self, forKey: .analysis_result) + images = try container.decodeIfPresent([ScanImage].self, forKey: .images) ?? [] + latest_guidance = try container.decodeIfPresent(String.self, forKey: .latest_guidance) + created_at = try container.decode(String.self, forKey: .created_at) + last_activity_at = try container.decode(String.self, forKey: .last_activity_at) + error = try container.decodeIfPresent(String.self, forKey: .error) + is_favorited = try container.decodeIfPresent(Bool.self, forKey: .is_favorited) + analysis_id = try container.decodeIfPresent(String.self, forKey: .analysis_id) + } + + // Convenience initializer for constructing from SSE events + init( + id: String, + scan_type: String, + barcode: String?, + state: String, + product_info: ScanProductInfo, + product_info_source: String?, + product_info_vote: Vote? = nil, + analysis_result: ScanAnalysisResult?, + images: [ScanImage], + latest_guidance: String?, + created_at: String, + last_activity_at: String, + error: String? = nil, + is_favorited: Bool? = nil, + analysis_id: String? = nil + ) { + self.id = id + self.scan_type = scan_type + self.barcode = barcode + self.state = state + self.product_info = product_info + self.product_info_source = product_info_source + self.product_info_vote = product_info_vote + self.analysis_result = analysis_result + self.images = images + self.latest_guidance = latest_guidance + self.created_at = created_at + self.last_activity_at = last_activity_at + self.error = error + self.is_favorited = is_favorited + self.analysis_id = analysis_id + } + + } + + // Submit image response + struct SubmitImageResponse: Codable { + let queued: Bool + let queue_position: Int + let content_hash: String + } + + // Scan history response + struct ScanHistoryResponse: Codable { + let scans: [Scan] + let total: Int + let has_more: Bool + } + + // Feedback Request + struct FeedbackRequest: Codable { + let target: String // "product_info", "product_image", "analysis", "flagged_ingredient", "other" + let vote: String // "up", "down", "none" + let scan_id: String? + let analysis_id: String? + let image_url: String? + let ingredient_name: String? + let comment: String? + } + + struct FeedbackUpdateRequest: Codable { + let vote: String // "up", "down", "none" + } + + // MARK: - Stats + + struct StatsResponse: Codable { + let avgScans: Int + let barcodeScansCount: Int + let matchingStats: MatchingStats + let weeklyStats: [WeeklyStat]? + } + + struct MatchingStats: Codable { + let matched: Int + let unmatched: Int + let uncertain: Int + } + + struct WeeklyStat: Codable { + let day: String // "M", "T", "W", "T", "F", "S", "S" + let value: Int // Scan count for that day + let date: String // ISO date string + } + + // MARK: - Food Notes Summary + + struct FoodNotesSummaryResponse: Codable { + let summary: String + let generatedAt: String + let isCached: Bool + + enum CodingKeys: String, CodingKey { + case summary + case generatedAt = "generated_at" + case isCached = "is_cached" + } + } +} + +// MARK: - Scan API Extension Helpers + +extension DTO.ScanAnalysisResult { + func toIngredientRecommendations() -> [DTO.IngredientRecommendation] { + return ingredient_analysis.map { analysis in + let safetyRecommendation: DTO.SafetyRecommendation + switch analysis.match { + case "unmatched": + safetyRecommendation = .definitelyUnsafe + case "uncertain": + safetyRecommendation = .maybeUnsafe + default: + safetyRecommendation = .safe + } + + // Log raw members_affected data for debugging +// Log.debug("INGREDIENT_ANALYSIS", "ingredient: \(analysis.ingredient), match: \(analysis.match), members_affected: \(analysis.members_affected)") + + return DTO.IngredientRecommendation( + ingredientName: analysis.ingredient, + safetyRecommendation: safetyRecommendation, + reasoning: analysis.reasoning, + preference: analysis.members_affected.joined(separator: ", "), // Keep for backward compatibility + memberIdentifiers: analysis.members_affected // Preserve array of member IDs + ) + } + } +} + +extension String { + func toProductRecommendation() -> DTO.ProductRecommendation? { + switch self.lowercased() { + case "matched": + return .match + case "uncertain": + return .needsReview + case "unmatched": + return .notMatch + default: + return nil + } + } +} + +extension DTO.Scan { + func toProductRecommendation() -> DTO.ProductRecommendation { + guard let overallMatch = analysis_result?.overall_match else { + return .unknown // Default to unknown if no analysis data + } + return overallMatch.toProductRecommendation() ?? .unknown + } + + func relativeTimeDescription(now: Date = Date()) -> String { + let isoFormatter = ISO8601DateFormatter() + isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + guard let date = isoFormatter.date(from: created_at) else { + return "" + } + + let interval = now.timeIntervalSince(date) + let seconds = Int(interval) + + if seconds < 60 { + return "Just now" + } + + let minutes = seconds / 60 + if minutes < 60 { + return "\(minutes) min ago" + } + + let hours = minutes / 60 + if hours < 24 { + return hours == 1 ? "1 hour ago" : "\(hours) hours ago" + } + + let days = hours / 24 + return days == 1 ? "1 day ago" : "\(days) days ago" + } + + func toProduct() -> DTO.Product { + // Priority: Use top-level images array if available (supports both inventory and user images) + var imageLocations: [DTO.ImageLocationInfo] = [] + + if !images.isEmpty { + // Convert ScanImage to ImageLocationInfo + for scanImage in images { + switch scanImage { + case .inventory(let img): + // Inventory images use URL + if let url = URL(string: img.url) { + imageLocations.append(.url(url)) + } + case .user(let img): + // User-uploaded images use storage_path (fetched from "scan-images" bucket) + // storage_path format: "SCAN_ID/content_hash.jpg" + // Only include processed images with valid storage_path + if img.status == "processed", let storagePath = img.storage_path, !storagePath.isEmpty { + imageLocations.append(.scanImagePath(storagePath)) + } + } + } + } + + // Fallback: Use product_info.images if no top-level images + if imageLocations.isEmpty { + imageLocations = product_info.images?.compactMap { scanImageInfo in + guard let urlString = scanImageInfo.url, + let url = URL(string: urlString) else { + return nil + } + return .url(url) + } ?? [] + } + + return DTO.Product( + barcode: barcode, + brand: product_info.brand, + name: product_info.name, + ingredients: product_info.ingredients, + images: imageLocations, + claims: product_info.claims + ) + } +} + +// MARK: - ProductRecommendation Display Properties +extension DTO.ProductRecommendation { + var displayText: String { + switch self { + case .match: + return "Matched" + case .needsReview: + return "Uncertain" + case .notMatch: + return "Unmatched" + case .unknown: + return "Unknown" + } + } + + var iconAssetName: String { + switch self { + case .match: + return "safecircletick" + case .needsReview: + return "caution" + case .notMatch: + return "unsafe" + case .unknown: + return "questionmark.circle" + } + } + + var gradientColors: [Color] { + switch self { + case .match: + return [Color(hex: "#9DCF10"), Color(hex: "#6B8E06")] + case .needsReview: + return [Color(hex: "#FFC107"), Color(hex: "#FFA000")] + case .notMatch: + return [Color(hex: "#FF5252"), Color(hex: "#D32F2F")] + case .unknown: + return [Color(hex: "#9E9E9E"), Color(hex: "#757575")] + } + } +} + +// MARK: - Chat API DTOs + +extension DTO { + // Context types (discriminated union based on screen field) + struct HomeContext: Codable { + let screen: String // "home" + } + + struct ProductScanContext: Codable { + let screen: String // "product_scan" + let scan_id: String // UUID + } + + struct FoodNotesContext: Codable { + let screen: String // "food_notes" + } + + struct FeedbackContext: Codable { + let screen: String // "feedback" + let feedback_id: String? // UUID, optional for general feedback + } + + // SSE Events (event name: "turn" for thinking/done, "error" for errors) + struct TurnThinkingEvent: Codable { + let conversation_id: String // UUID + let turn_id: String // UUID + let state: String // "thinking" (const) + } + + struct TurnDoneEvent: Codable { + let conversation_id: String // UUID + let turn_id: String // UUID + let state: String // "done" (const) + let response: String + } + + struct ChatErrorEvent: Codable { + let error: String + let conversation_id: String? // UUID (optional) + let turn_id: String? // UUID (optional) + } + + // Conversation History + struct ConversationTurn: Codable { + let turn_id: String // UUID + let turn_number: Int + let user_message: String + let assistant_response: String? // nullable + let images: [String] // Array of signed URLs (format: uri, 1hr expiry) - will be ignored in UI + let created_at: String // ISO 8601 date-time + } + + struct ConversationResponse: Codable { + let conversation_id: String // UUID + let turns: [ConversationTurn] + } } diff --git a/IngrediCheck/Font Family/Manrope/Manrope-Bold.ttf b/IngrediCheck/Font Family/Manrope/Manrope-Bold.ttf new file mode 100644 index 00000000..62a61839 Binary files /dev/null and b/IngrediCheck/Font Family/Manrope/Manrope-Bold.ttf differ diff --git a/IngrediCheck/Font Family/Manrope/Manrope-ExtraBold.ttf b/IngrediCheck/Font Family/Manrope/Manrope-ExtraBold.ttf new file mode 100644 index 00000000..2fa671c2 Binary files /dev/null and b/IngrediCheck/Font Family/Manrope/Manrope-ExtraBold.ttf differ diff --git a/IngrediCheck/Font Family/Manrope/Manrope-ExtraLight.ttf b/IngrediCheck/Font Family/Manrope/Manrope-ExtraLight.ttf new file mode 100644 index 00000000..c55745a4 Binary files /dev/null and b/IngrediCheck/Font Family/Manrope/Manrope-ExtraLight.ttf differ diff --git a/IngrediCheck/Font Family/Manrope/Manrope-Light.ttf b/IngrediCheck/Font Family/Manrope/Manrope-Light.ttf new file mode 100644 index 00000000..8a771c26 Binary files /dev/null and b/IngrediCheck/Font Family/Manrope/Manrope-Light.ttf differ diff --git a/IngrediCheck/Font Family/Manrope/Manrope-Medium.ttf b/IngrediCheck/Font Family/Manrope/Manrope-Medium.ttf new file mode 100644 index 00000000..c6d28def Binary files /dev/null and b/IngrediCheck/Font Family/Manrope/Manrope-Medium.ttf differ diff --git a/IngrediCheck/Font Family/Manrope/Manrope-Regular.ttf b/IngrediCheck/Font Family/Manrope/Manrope-Regular.ttf new file mode 100644 index 00000000..9a108f1c Binary files /dev/null and b/IngrediCheck/Font Family/Manrope/Manrope-Regular.ttf differ diff --git a/IngrediCheck/Font Family/Manrope/Manrope-SemiBold.ttf b/IngrediCheck/Font Family/Manrope/Manrope-SemiBold.ttf new file mode 100644 index 00000000..46a13d61 Binary files /dev/null and b/IngrediCheck/Font Family/Manrope/Manrope-SemiBold.ttf differ diff --git a/IngrediCheck/Font Family/Nunito/Nunito-Black.ttf b/IngrediCheck/Font Family/Nunito/Nunito-Black.ttf new file mode 100644 index 00000000..99491f84 Binary files /dev/null and b/IngrediCheck/Font Family/Nunito/Nunito-Black.ttf differ diff --git a/IngrediCheck/Font Family/Nunito/Nunito-BlackItalic.ttf b/IngrediCheck/Font Family/Nunito/Nunito-BlackItalic.ttf new file mode 100644 index 00000000..6004938d Binary files /dev/null and b/IngrediCheck/Font Family/Nunito/Nunito-BlackItalic.ttf differ diff --git a/IngrediCheck/Font Family/Nunito/Nunito-Bold.ttf b/IngrediCheck/Font Family/Nunito/Nunito-Bold.ttf new file mode 100644 index 00000000..69096898 Binary files /dev/null and b/IngrediCheck/Font Family/Nunito/Nunito-Bold.ttf differ diff --git a/IngrediCheck/Font Family/Nunito/Nunito-BoldItalic.ttf b/IngrediCheck/Font Family/Nunito/Nunito-BoldItalic.ttf new file mode 100644 index 00000000..2479c36d Binary files /dev/null and b/IngrediCheck/Font Family/Nunito/Nunito-BoldItalic.ttf differ diff --git a/IngrediCheck/Font Family/Nunito/Nunito-ExtraBold.ttf b/IngrediCheck/Font Family/Nunito/Nunito-ExtraBold.ttf new file mode 100644 index 00000000..6f4ccde0 Binary files /dev/null and b/IngrediCheck/Font Family/Nunito/Nunito-ExtraBold.ttf differ diff --git a/IngrediCheck/Font Family/Nunito/Nunito-ExtraBoldItalic.ttf b/IngrediCheck/Font Family/Nunito/Nunito-ExtraBoldItalic.ttf new file mode 100644 index 00000000..a82e6a2a Binary files /dev/null and b/IngrediCheck/Font Family/Nunito/Nunito-ExtraBoldItalic.ttf differ diff --git a/IngrediCheck/Font Family/Nunito/Nunito-ExtraLight.ttf b/IngrediCheck/Font Family/Nunito/Nunito-ExtraLight.ttf new file mode 100644 index 00000000..96711f95 Binary files /dev/null and b/IngrediCheck/Font Family/Nunito/Nunito-ExtraLight.ttf differ diff --git a/IngrediCheck/Font Family/Nunito/Nunito-ExtraLightItalic.ttf b/IngrediCheck/Font Family/Nunito/Nunito-ExtraLightItalic.ttf new file mode 100644 index 00000000..ff043a4b Binary files /dev/null and b/IngrediCheck/Font Family/Nunito/Nunito-ExtraLightItalic.ttf differ diff --git a/IngrediCheck/Font Family/Nunito/Nunito-Italic.ttf b/IngrediCheck/Font Family/Nunito/Nunito-Italic.ttf new file mode 100644 index 00000000..97fd1696 Binary files /dev/null and b/IngrediCheck/Font Family/Nunito/Nunito-Italic.ttf differ diff --git a/IngrediCheck/Font Family/Nunito/Nunito-Light.ttf b/IngrediCheck/Font Family/Nunito/Nunito-Light.ttf new file mode 100644 index 00000000..fb050fcf Binary files /dev/null and b/IngrediCheck/Font Family/Nunito/Nunito-Light.ttf differ diff --git a/IngrediCheck/Font Family/Nunito/Nunito-LightItalic.ttf b/IngrediCheck/Font Family/Nunito/Nunito-LightItalic.ttf new file mode 100644 index 00000000..0914950a Binary files /dev/null and b/IngrediCheck/Font Family/Nunito/Nunito-LightItalic.ttf differ diff --git a/IngrediCheck/Font Family/Nunito/Nunito-Medium.ttf b/IngrediCheck/Font Family/Nunito/Nunito-Medium.ttf new file mode 100644 index 00000000..a6993ebb Binary files /dev/null and b/IngrediCheck/Font Family/Nunito/Nunito-Medium.ttf differ diff --git a/IngrediCheck/Font Family/Nunito/Nunito-MediumItalic.ttf b/IngrediCheck/Font Family/Nunito/Nunito-MediumItalic.ttf new file mode 100644 index 00000000..19136324 Binary files /dev/null and b/IngrediCheck/Font Family/Nunito/Nunito-MediumItalic.ttf differ diff --git a/IngrediCheck/Font Family/Nunito/Nunito-Regular.ttf b/IngrediCheck/Font Family/Nunito/Nunito-Regular.ttf new file mode 100644 index 00000000..be80c3f0 Binary files /dev/null and b/IngrediCheck/Font Family/Nunito/Nunito-Regular.ttf differ diff --git a/IngrediCheck/Font Family/Nunito/Nunito-SemiBold.ttf b/IngrediCheck/Font Family/Nunito/Nunito-SemiBold.ttf new file mode 100644 index 00000000..06f29ea7 Binary files /dev/null and b/IngrediCheck/Font Family/Nunito/Nunito-SemiBold.ttf differ diff --git a/IngrediCheck/Font Family/Nunito/Nunito-SemiBoldItalic.ttf b/IngrediCheck/Font Family/Nunito/Nunito-SemiBoldItalic.ttf new file mode 100644 index 00000000..5af81332 Binary files /dev/null and b/IngrediCheck/Font Family/Nunito/Nunito-SemiBoldItalic.ttf differ diff --git a/IngrediCheck/Info.plist b/IngrediCheck/Info.plist index a9cc38bf..a35d1952 100644 --- a/IngrediCheck/Info.plist +++ b/IngrediCheck/Info.plist @@ -15,7 +15,50 @@ GIDClientID 478832614549-fun4u6ep0fcv0dp8on6pd73fnaagdal5.apps.googleusercontent.com + NSCameraUsageDescription + IngrediCheck uses the camera to scan product barcodes. + NSPhotoLibraryUsageDescription + IngrediCheck uses your photo library to let you select product images for scanning. ITSAppUsesNonExemptEncryption + UIAppFonts + + Nunito-ExtraLight.ttf + Nunito-Light.ttf + Nunito-Regular.ttf + Nunito-Medium.ttf + Nunito-SemiBold.ttf + Nunito-Bold.ttf + Nunito-ExtraBold.ttf + Nunito-Black.ttf + Nunito-ExtraLightItalic.ttf + Nunito-LightItalic.ttf + Nunito-Italic.ttf + Nunito-MediumItalic.ttf + Nunito-SemiBoldItalic.ttf + Nunito-BoldItalic.ttf + Nunito-ExtraBoldItalic.ttf + Nunito-BlackItalic.ttf + Manrope-ExtraLight.ttf + Manrope-Light.ttf + Manrope-Medium.ttf + Manrope-Regular.ttf + Manrope-SemiBold.ttf + Manrope-Bold.ttf + Manrope-ExtraBold.ttf + + UIDesignRequiresCompatibility + + UIApplicationShortcutItems + + + UIApplicationShortcutItemType + SendFeedback + UIApplicationShortcutItemTitle + Send me Feedback + UIApplicationShortcutItemIconSymbolName + bubble.left.and.bubble.right + + diff --git a/IngrediCheck/IngrediCheckApp.swift b/IngrediCheck/IngrediCheckApp.swift index a10de572..631e563b 100644 --- a/IngrediCheck/IngrediCheckApp.swift +++ b/IngrediCheck/IngrediCheckApp.swift @@ -5,29 +5,11 @@ import PostHog struct IngrediCheckApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate - @State private var webService = WebService() - @State private var dietaryPreferences = DietaryPreferences() - @State private var userPreferences: UserPreferences = UserPreferences() - @State private var appState = AppState() - @State private var onboardingState = OnboardingState() - @State private var authController = AuthController() var body: some Scene { WindowGroup { - Splash { - Image("SplashScreen") - .resizable() - .scaledToFill() - } content: { - MainView() - .environment(authController) - .environment(webService) - .environment(userPreferences) - .environment(appState) - .environment(dietaryPreferences) - .environment(onboardingState) - } + AppFlowRouter() } } } @@ -67,9 +49,80 @@ struct MainView: View { } } -class AppDelegate: NSObject, UIApplicationDelegate { - func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { +class AppDelegate: NSObject, UIApplicationDelegate, ObservableObject { + /// Stores shortcut item from cold launch until the UI is ready to handle it + static var pendingShortcutItem: UIApplicationShortcutItem? + + /// Published flag to trigger feedback shortcut handling + @Published var shouldShowFeedbackShortcut = false + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { AnalyticsService.shared.configure() + + // Configure navigation bar appearance globally + let appearance = UINavigationBarAppearance() + appearance.configureWithOpaqueBackground() + appearance.backgroundColor = UIColor(red: 0xF2/255.0, green: 0xF2/255.0, blue: 0xF9/255.0, alpha: 1.0) // #F2F2F9 (pageBackground) + appearance.shadowColor = .clear // Remove bottom border/shadow + appearance.backButtonAppearance.normal.titleTextAttributes = [.foregroundColor: UIColor.clear] // Hide "Back" text + + // Create custom back indicator with 20px leading padding + let backImage = UIImage(systemName: "chevron.left")? + .withConfiguration(UIImage.SymbolConfiguration(pointSize: 17, weight: .semibold)) + .withTintColor(UIColor(red: 0x30/255.0, green: 0x30/255.0, blue: 0x30/255.0, alpha: 1.0), renderingMode: .alwaysOriginal) + .withAlignmentRectInsets(UIEdgeInsets(top: 0, left: -12, bottom: 0, right: 0)) // Add left padding + + appearance.setBackIndicatorImage(backImage, transitionMaskImage: backImage) + + UINavigationBar.appearance().standardAppearance = appearance + UINavigationBar.appearance().scrollEdgeAppearance = appearance + UINavigationBar.appearance().compactAppearance = appearance + + // Set back button color to #303030 (fallback for other elements) + UINavigationBar.appearance().tintColor = UIColor(red: 0x30/255.0, green: 0x30/255.0, blue: 0x30/255.0, alpha: 1.0) + return true } -} \ No newline at end of file + + func applicationDidBecomeActive(_: UIApplication) { + // Wake up backends on app start and foreground + WebService().pingFlyIO() + WebService().ping() + } + + // Configure scene delegate for handling shortcuts + func application( + _ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions + ) -> UISceneConfiguration { + // Check for shortcut from cold launch via scene connection + if let shortcutItem = options.shortcutItem { + AppDelegate.pendingShortcutItem = shortcutItem + } + + let config = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role) + config.delegateClass = ShortcutSceneDelegate.self + return config + } +} + +/// Scene delegate to handle quick action shortcuts +class ShortcutSceneDelegate: NSObject, UIWindowSceneDelegate { + func windowScene( + _ windowScene: UIWindowScene, + performActionFor shortcutItem: UIApplicationShortcutItem, + completionHandler: @escaping (Bool) -> Void + ) { + // Warm launch: app is in background, post notification + if shortcutItem.type == "SendFeedback" { + DispatchQueue.main.async { + NotificationCenter.default.post( + name: Notification.Name("ShowFeedbackFromShortcut"), + object: nil + ) + } + } + completionHandler(true) + } +} diff --git a/IngrediCheck/Models/AppRoute.swift b/IngrediCheck/Models/AppRoute.swift new file mode 100644 index 00000000..5c30821d --- /dev/null +++ b/IngrediCheck/Models/AppRoute.swift @@ -0,0 +1,95 @@ +import Foundation + +/// Unified navigation routes for the app's Single Root NavigationStack architecture. +/// All navigation throughout the app flows through these routes via `appState.navigate(to:)`. +enum AppRoute: Hashable { + + // MARK: - Product & Scan + + /// Navigate to product detail view + /// - Parameters: + /// - scanId: The unique scan identifier + /// - initialScan: Optional pre-loaded scan data (avoids refetch if available) + case productDetail(scanId: String, initialScan: DTO.Scan?) + + /// Navigate to scan camera + /// - Parameters: + /// - initialMode: Optional camera mode (.scanner or .photo) + /// - initialScanId: Optional scan ID to continue an existing scan + case scanCamera(initialMode: CameraMode?, initialScanId: String?) + + // MARK: - Lists & History + + /// Navigate to all favorites page + case favoritesAll + + /// Navigate to all recent scans page + case recentScansAll + + /// Navigate to favorite item detail + /// - Parameter item: The list item to display + case favoriteDetail(item: DTO.ListItem) + + // MARK: - Settings & Profile + + /// Navigate to settings screen + case settings + + /// Navigate to manage family screen + case manageFamily + + /// Navigate to editable canvas (memoji/avatar editor) + /// - Parameter targetSection: Optional section to scroll to + case editableCanvas(targetSection: String?) + + // MARK: - Hashable Conformance + + func hash(into hasher: inout Hasher) { + switch self { + case .productDetail(let scanId, _): + hasher.combine("productDetail") + hasher.combine(scanId) + case .scanCamera(let mode, let scanId): + hasher.combine("scanCamera") + hasher.combine(mode.map { $0 == .scanner ? "scanner" : "photo" }) + hasher.combine(scanId) + case .favoritesAll: + hasher.combine("favoritesAll") + case .recentScansAll: + hasher.combine("recentScansAll") + case .favoriteDetail(let item): + hasher.combine("favoriteDetail") + hasher.combine(item.list_item_id) + case .settings: + hasher.combine("settings") + case .manageFamily: + hasher.combine("manageFamily") + case .editableCanvas(let section): + hasher.combine("editableCanvas") + hasher.combine(section) + } + } + + static func == (lhs: AppRoute, rhs: AppRoute) -> Bool { + switch (lhs, rhs) { + case (.productDetail(let lId, _), .productDetail(let rId, _)): + return lId == rId + case (.scanCamera(let lMode, let lId), .scanCamera(let rMode, let rId)): + return lMode.map { $0 == .scanner ? "scanner" : "photo" } == rMode.map { $0 == .scanner ? "scanner" : "photo" } && lId == rId + case (.favoritesAll, .favoritesAll): + return true + case (.recentScansAll, .recentScansAll): + return true + case (.favoriteDetail(let lItem), .favoriteDetail(let rItem)): + return lItem.list_item_id == rItem.list_item_id + case (.settings, .settings): + return true + case (.manageFamily, .manageFamily): + return true + case (.editableCanvas(let lSection), .editableCanvas(let rSection)): + return lSection == rSection + default: + return false + } + } +} diff --git a/IngrediCheck/Resource/Videos/Confetti.json b/IngrediCheck/Resource/Videos/Confetti.json new file mode 100644 index 00000000..b4e1e23c --- /dev/null +++ b/IngrediCheck/Resource/Videos/Confetti.json @@ -0,0 +1 @@ +{"v":"5.5.7","meta":{"g":"LottieFiles AE 0.1.20","a":"","k":"","d":"","tc":""},"fr":25,"ip":0,"op":126,"w":1920,"h":1080,"nm":"ConfettiAnimation","ddd":1,"assets":[{"id":"comp_0","layers":[{"ddd":1,"ind":1,"ty":4,"nm":"Shape Layer 16","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"rx":{"a":0,"k":0,"ix":8,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 500);"},"ry":{"a":0,"k":0,"ix":9,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"rz":{"a":0,"k":0,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 100);"},"or":{"a":0,"k":[0,0,0],"ix":7},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":29,"s":[1664,-23,0],"to":[92,254.667,0],"ti":[168,-414.667,0]},{"t":104,"s":[1792,1185,0]}],"ix":2},"a":{"a":0,"k":[-248,-39,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-248,-52],[-247.885,-20.203]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.270588235294,0.909803981407,0.639215686275,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":10,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":29,"op":105,"st":29,"bm":0},{"ddd":1,"ind":2,"ty":4,"nm":"Shape Layer 15","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"rx":{"a":0,"k":0,"ix":8,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 500);"},"ry":{"a":0,"k":0,"ix":9,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"rz":{"a":0,"k":0,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 100);"},"or":{"a":0,"k":[0,0,0],"ix":7},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":12,"s":[1235,-59,0],"to":[96,526.667,0],"ti":[-104,-486.667,0]},{"t":87,"s":[1039,1237,0]}],"ix":2},"a":{"a":0,"k":[-248,-39,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-248,-52],[-247.885,-20.203]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.239215701234,0.419607873056,0.709803921569,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":10,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":12,"op":88,"st":12,"bm":0},{"ddd":1,"ind":3,"ty":4,"nm":"Shape Layer 14","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"rx":{"a":0,"k":0,"ix":8,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 500);"},"ry":{"a":0,"k":0,"ix":9,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"rz":{"a":0,"k":0,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 100);"},"or":{"a":0,"k":[0,0,0],"ix":7},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":18,"s":[528,-59,0],"to":[288,686.667,0],"ti":[-180,-406.667,0]},{"t":93,"s":[708,1377,0]}],"ix":2},"a":{"a":0,"k":[-248,-39,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-248,-52],[-247.885,-20.203]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.270588235294,0.909803981407,0.639215686275,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":10,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":18,"op":94,"st":18,"bm":0},{"ddd":1,"ind":4,"ty":4,"nm":"Shape Layer 13","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"rx":{"a":0,"k":0,"ix":8,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 500);"},"ry":{"a":0,"k":0,"ix":9,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"rz":{"a":0,"k":0,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 100);"},"or":{"a":0,"k":[0,0,0],"ix":7},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":15,"s":[292,-147,0],"to":[-90,314.667,0],"ti":[194,-532.667,0]},{"t":90,"s":[292,1213,0]}],"ix":2},"a":{"a":0,"k":[-248,-39,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-248,-52],[-248,-26]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.098039223166,0.694117647059,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":10,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":15,"op":91,"st":15,"bm":0},{"ddd":1,"ind":5,"ty":4,"nm":"Shape Layer 12","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"rx":{"a":0,"k":0,"ix":8,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 500);"},"ry":{"a":0,"k":0,"ix":9,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"rz":{"a":0,"k":0,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 100);"},"or":{"a":0,"k":[0,0,0],"ix":7},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":28,"s":[1128,-59,0],"to":[180,310.667,0],"ti":[-112,-490.667,0]},{"t":103,"s":[1088,1137,0]}],"ix":2},"a":{"a":0,"k":[-248,-39,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-248,-52],[-247.885,-20.203]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.290196078431,0.952941236309,0.968627510819,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":10,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":28,"op":104,"st":28,"bm":0},{"ddd":1,"ind":6,"ty":4,"nm":"Shape Layer 11","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"rx":{"a":0,"k":0,"ix":8,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 500);"},"ry":{"a":0,"k":0,"ix":9,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"rz":{"a":0,"k":0,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 100);"},"or":{"a":0,"k":[0,0,0],"ix":7},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":19,"s":[1455,-59,0],"to":[-184,406.667,0],"ti":[112,-438.667,0]},{"t":94,"s":[1459,1213,0]}],"ix":2},"a":{"a":0,"k":[-248,-39,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-248,-52],[-247.885,-20.203]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.705882352941,0,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":10,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":19,"op":95,"st":19,"bm":0},{"ddd":1,"ind":7,"ty":4,"nm":"Shape Layer 10","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"rx":{"a":0,"k":0,"ix":8,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 500);"},"ry":{"a":0,"k":0,"ix":9,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"rz":{"a":0,"k":0,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 100);"},"or":{"a":0,"k":[0,0,0],"ix":7},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":24,"s":[728,-59,0],"to":[388,434.667,0],"ti":[-584,-762.667,0]},{"t":99,"s":[732,1413,0]}],"ix":2},"a":{"a":0,"k":[-248,-39,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-248,-52],[-247.885,-20.203]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.282352941176,0.282352941176,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":10,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":24,"op":100,"st":24,"bm":0},{"ddd":1,"ind":8,"ty":4,"nm":"Shape Layer 9","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"rx":{"a":0,"k":0,"ix":8,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 500);"},"ry":{"a":0,"k":0,"ix":9,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"rz":{"a":0,"k":0,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 100);"},"or":{"a":0,"k":[0,0,0],"ix":7},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":19,"s":[232,-147,0],"to":[-200,554.667,0],"ti":[560,-378.667,0]},{"t":94,"s":[232,1213,0]}],"ix":2},"a":{"a":0,"k":[-248,-39,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-248,-52],[-248,-26]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.156862745098,0.215686289469,0.470588265213,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":10,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":19,"op":95,"st":19,"bm":0},{"ddd":1,"ind":9,"ty":4,"nm":"Shape Layer 8","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"rx":{"a":0,"k":0,"ix":8,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 500);"},"ry":{"a":0,"k":0,"ix":9,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"rz":{"a":0,"k":0,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 100);"},"or":{"a":0,"k":[0,0,0],"ix":7},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":21,"s":[1664,-23,0],"to":[108,234.667,0],"ti":[-336,-306.667,0]},{"t":96,"s":[1772,1177,0]}],"ix":2},"a":{"a":0,"k":[-248,-39,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-248,-52],[-247.885,-20.203]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.490196108351,0.231372563979,0.752941236309,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":10,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":21,"op":97,"st":21,"bm":0},{"ddd":1,"ind":10,"ty":4,"nm":"Shape Layer 7","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"rx":{"a":0,"k":0,"ix":8,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 500);"},"ry":{"a":0,"k":0,"ix":9,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"rz":{"a":0,"k":0,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 100);"},"or":{"a":0,"k":[0,0,0],"ix":7},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":0,"s":[1235,-59,0],"to":[96,526.667,0],"ti":[-104,-486.667,0]},{"t":75,"s":[1039,1237,0]}],"ix":2},"a":{"a":0,"k":[-248,-39,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-248,-52],[-247.885,-20.203]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.239215701234,0.419607873056,0.709803921569,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":10,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":76,"st":0,"bm":0},{"ddd":1,"ind":11,"ty":4,"nm":"Shape Layer 6","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"rx":{"a":0,"k":0,"ix":8,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 500);"},"ry":{"a":0,"k":0,"ix":9,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"rz":{"a":0,"k":0,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 100);"},"or":{"a":0,"k":[0,0,0],"ix":7},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":6,"s":[528,-59,0],"to":[-208,742.667,0],"ti":[216,-566.667,0]},{"t":81,"s":[532,1413,0]}],"ix":2},"a":{"a":0,"k":[-248,-39,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-248,-52],[-247.885,-20.203]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.270588235294,0.909803981407,0.639215686275,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":10,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":6,"op":82,"st":6,"bm":0},{"ddd":1,"ind":12,"ty":4,"nm":"Shape Layer 5","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"rx":{"a":0,"k":0,"ix":8,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 500);"},"ry":{"a":0,"k":0,"ix":9,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"rz":{"a":0,"k":0,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 100);"},"or":{"a":0,"k":[0,0,0],"ix":7},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":2,"s":[92,-147,0],"to":[-90,314.667,0],"ti":[194,-532.667,0]},{"t":77,"s":[92,1213,0]}],"ix":2},"a":{"a":0,"k":[-248,-39,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-248,-52],[-248,-26]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.098039223166,0.694117647059,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":10,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":2,"op":78,"st":2,"bm":0},{"ddd":1,"ind":13,"ty":4,"nm":"Shape Layer 4","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"rx":{"a":0,"k":0,"ix":8,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 500);"},"ry":{"a":0,"k":0,"ix":9,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"rz":{"a":0,"k":0,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 100);"},"or":{"a":0,"k":[0,0,0],"ix":7},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":16,"s":[1128,-59,0],"to":[-208,358.667,0],"ti":[-112,-490.667,0]},{"t":91,"s":[1088,1137,0]}],"ix":2},"a":{"a":0,"k":[-248,-39,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-248,-52],[-247.885,-20.203]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.490196108351,0.231372563979,0.752941236309,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":10,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":16,"op":92,"st":16,"bm":0},{"ddd":1,"ind":14,"ty":4,"nm":"Shape Layer 3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"rx":{"a":0,"k":0,"ix":8,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 500);"},"ry":{"a":0,"k":0,"ix":9,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"rz":{"a":0,"k":0,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 100);"},"or":{"a":0,"k":[0,0,0],"ix":7},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":7,"s":[1455,-59,0],"to":[-184,406.667,0],"ti":[112,-438.667,0]},{"t":82,"s":[1459,1213,0]}],"ix":2},"a":{"a":0,"k":[-248,-39,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-248,-52],[-247.885,-20.203]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.705882352941,0,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":10,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":7,"op":83,"st":7,"bm":0},{"ddd":1,"ind":15,"ty":4,"nm":"Shape Layer 2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"rx":{"a":0,"k":0,"ix":8,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 500);"},"ry":{"a":0,"k":0,"ix":9,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"rz":{"a":0,"k":0,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 100);"},"or":{"a":0,"k":[0,0,0],"ix":7},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":13,"s":[728,-59,0],"to":[388,434.667,0],"ti":[-584,-762.667,0]},{"t":88,"s":[732,1413,0]}],"ix":2},"a":{"a":0,"k":[-248,-39,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-248,-52],[-247.885,-20.203]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.282352941176,0.282352941176,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":10,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":13,"op":89,"st":13,"bm":0},{"ddd":1,"ind":16,"ty":4,"nm":"Shape Layer 1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"rx":{"a":0,"k":0,"ix":8,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 500);"},"ry":{"a":0,"k":0,"ix":9,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"rz":{"a":0,"k":0,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 100);"},"or":{"a":0,"k":[0,0,0],"ix":7},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":9,"s":[232,-147,0],"to":[368,422.667,0],"ti":[20,-326.667,0]},{"t":84,"s":[232,1213,0]}],"ix":2},"a":{"a":0,"k":[-248,-39,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-248,-52],[-248,-26]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.156862745098,0.215686289469,0.470588265213,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":10,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":9,"op":85,"st":9,"bm":0}]},{"id":"comp_1","layers":[{"ddd":0,"ind":1,"ty":4,"nm":"blueCircle03","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":32,"s":[1453,-59,0],"to":[-116,296,0],"ti":[168,-388,0]},{"t":93,"s":[1413,1213,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[45.849,45.849,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.098039223166,0.694117647059,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":32,"op":93,"st":32,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"redCircle04","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":35,"s":[1132,-305,0],"to":[244,436,0],"ti":[-304,-272,0]},{"t":101,"s":[1200,1211,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[48.744,48.744,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.282352941176,0.282352941176,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":35,"op":101,"st":35,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"yellowCircle03","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":31,"s":[600,-110,0],"to":[-200,428,0],"ti":[244,-308,0]},{"t":101,"s":[600,1210,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[68.326,68.326,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.705882352941,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":31,"op":101,"st":31,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"purpleCircle04","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":28,"s":[926,-55,0],"to":[184,256,0],"ti":[-344,-344,0]},{"t":78,"s":[1054,1253,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[48,48,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.490196078431,0.23137254902,0.752941176471,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":28,"op":79,"st":28,"bm":0},{"ddd":0,"ind":5,"ty":0,"nm":"triangleComp01","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":27,"s":[1654,-58,0],"to":[-124,373.333,0],"ti":[168,-557.333,0]},{"t":99,"s":[1654,1222,0]}],"ix":2},"a":{"a":0,"k":[50,50,0],"ix":1},"s":{"a":0,"k":[60,60,100],"ix":6}},"ao":0,"ef":[{"ty":21,"nm":"Fill","np":9,"mn":"ADBE Fill","ix":1,"en":1,"ef":[{"ty":10,"nm":"Fill Mask","mn":"ADBE Fill-0001","ix":1,"v":{"a":0,"k":0,"ix":1}},{"ty":7,"nm":"All Masks","mn":"ADBE Fill-0007","ix":2,"v":{"a":0,"k":0,"ix":2}},{"ty":2,"nm":"Color","mn":"ADBE Fill-0002","ix":3,"v":{"a":0,"k":[1,0.705882370472,0,1],"ix":3}},{"ty":7,"nm":"Invert","mn":"ADBE Fill-0006","ix":4,"v":{"a":0,"k":0,"ix":4}},{"ty":0,"nm":"Horizontal Feather","mn":"ADBE Fill-0003","ix":5,"v":{"a":0,"k":0,"ix":5}},{"ty":0,"nm":"Vertical Feather","mn":"ADBE Fill-0004","ix":6,"v":{"a":0,"k":0,"ix":6}},{"ty":0,"nm":"Opacity","mn":"ADBE Fill-0005","ix":7,"v":{"a":0,"k":1,"ix":7}}]}],"w":100,"h":100,"ip":27,"op":115,"st":27,"bm":0},{"ddd":0,"ind":6,"ty":0,"nm":"triangleComp01","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":23,"s":[1414,-58,0],"to":[248,409.333,0],"ti":[-236,-493.333,0]},{"t":100,"s":[1414,1222,0]}],"ix":2},"a":{"a":0,"k":[50,50,0],"ix":1},"s":{"a":0,"k":[60,60,100],"ix":6}},"ao":0,"ef":[{"ty":21,"nm":"Fill","np":9,"mn":"ADBE Fill","ix":1,"en":1,"ef":[{"ty":10,"nm":"Fill Mask","mn":"ADBE Fill-0001","ix":1,"v":{"a":0,"k":0,"ix":1}},{"ty":7,"nm":"All Masks","mn":"ADBE Fill-0007","ix":2,"v":{"a":0,"k":0,"ix":2}},{"ty":2,"nm":"Color","mn":"ADBE Fill-0002","ix":3,"v":{"a":0,"k":[0.490196108818,0.231372565031,0.752941250801,1],"ix":3}},{"ty":7,"nm":"Invert","mn":"ADBE Fill-0006","ix":4,"v":{"a":0,"k":0,"ix":4}},{"ty":0,"nm":"Horizontal Feather","mn":"ADBE Fill-0003","ix":5,"v":{"a":0,"k":0,"ix":5}},{"ty":0,"nm":"Vertical Feather","mn":"ADBE Fill-0004","ix":6,"v":{"a":0,"k":0,"ix":6}},{"ty":0,"nm":"Opacity","mn":"ADBE Fill-0005","ix":7,"v":{"a":0,"k":1,"ix":7}}]}],"w":100,"h":100,"ip":23,"op":111,"st":23,"bm":0},{"ddd":0,"ind":7,"ty":0,"nm":"triangleComp01","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":8,"s":[1924,1216,0],"to":[0,0,0],"ti":[396,250.667,0]},{"t":18,"s":[1416,-248,0]}],"ix":2},"a":{"a":0,"k":[50,50,0],"ix":1},"s":{"a":0,"k":[90.321,90.321,100],"ix":6}},"ao":0,"ef":[{"ty":21,"nm":"Fill","np":9,"mn":"ADBE Fill","ix":1,"en":1,"ef":[{"ty":10,"nm":"Fill Mask","mn":"ADBE Fill-0001","ix":1,"v":{"a":0,"k":0,"ix":1}},{"ty":7,"nm":"All Masks","mn":"ADBE Fill-0007","ix":2,"v":{"a":0,"k":0,"ix":2}},{"ty":2,"nm":"Color","mn":"ADBE Fill-0002","ix":3,"v":{"a":0,"k":[0.098039224744,0.694117665291,1,1],"ix":3}},{"ty":7,"nm":"Invert","mn":"ADBE Fill-0006","ix":4,"v":{"a":0,"k":0,"ix":4}},{"ty":0,"nm":"Horizontal Feather","mn":"ADBE Fill-0003","ix":5,"v":{"a":0,"k":0,"ix":5}},{"ty":0,"nm":"Vertical Feather","mn":"ADBE Fill-0004","ix":6,"v":{"a":0,"k":0,"ix":6}},{"ty":0,"nm":"Opacity","mn":"ADBE Fill-0005","ix":7,"v":{"a":0,"k":1,"ix":7}}]}],"w":100,"h":100,"ip":8,"op":19,"st":8,"bm":0},{"ddd":0,"ind":8,"ty":0,"nm":"triangleComp01","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.17,"y":0.772},"o":{"x":0.167,"y":0.167},"t":26,"s":[1930,1130,0],"to":[-52.667,-198,0],"ti":[122.667,-28.333,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.72,"y":0.278},"t":51,"s":[1496,196,0],"to":[-122.667,28.333,0],"ti":[29.333,-462.667,0]},{"t":90,"s":[1194,1300,0]}],"ix":2},"a":{"a":0,"k":[50,50,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,1.111]},"t":26,"s":[30,30,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.695,0.695,0.333],"y":[0,0,0]},"t":51,"s":[50,50,100]},{"t":101,"s":[15,15,100]}],"ix":6}},"ao":0,"ef":[{"ty":21,"nm":"Fill","np":9,"mn":"ADBE Fill","ix":1,"en":1,"ef":[{"ty":10,"nm":"Fill Mask","mn":"ADBE Fill-0001","ix":1,"v":{"a":0,"k":0,"ix":1}},{"ty":7,"nm":"All Masks","mn":"ADBE Fill-0007","ix":2,"v":{"a":0,"k":0,"ix":2}},{"ty":2,"nm":"Color","mn":"ADBE Fill-0002","ix":3,"v":{"a":0,"k":[0.098039224744,0.694117665291,1,1],"ix":3}},{"ty":7,"nm":"Invert","mn":"ADBE Fill-0006","ix":4,"v":{"a":0,"k":0,"ix":4}},{"ty":0,"nm":"Horizontal Feather","mn":"ADBE Fill-0003","ix":5,"v":{"a":0,"k":0,"ix":5}},{"ty":0,"nm":"Vertical Feather","mn":"ADBE Fill-0004","ix":6,"v":{"a":0,"k":0,"ix":6}},{"ty":0,"nm":"Opacity","mn":"ADBE Fill-0005","ix":7,"v":{"a":0,"k":1,"ix":7}}]}],"w":100,"h":100,"ip":26,"op":101,"st":26,"bm":0},{"ddd":0,"ind":9,"ty":0,"nm":"triangleComp01","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.17,"y":0.801},"o":{"x":0.167,"y":0.167},"t":12,"s":[1940,1124,0],"to":[-108.333,-166,0],"ti":[149.333,-4,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.72,"y":0.297},"t":37,"s":[1290,128,0],"to":[-149.333,4,0],"ti":[-48.667,-412.667,0]},{"t":76,"s":[1044,1148,0]}],"ix":2},"a":{"a":0,"k":[50,50,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,-10]},"t":12,"s":[80,80,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.695,0.695,0.333],"y":[0,0,0]},"t":37,"s":[100,100,100]},{"t":87,"s":[50,50,100]}],"ix":6}},"ao":0,"ef":[{"ty":21,"nm":"Fill","np":9,"mn":"ADBE Fill","ix":1,"en":1,"ef":[{"ty":10,"nm":"Fill Mask","mn":"ADBE Fill-0001","ix":1,"v":{"a":0,"k":0,"ix":1}},{"ty":7,"nm":"All Masks","mn":"ADBE Fill-0007","ix":2,"v":{"a":0,"k":0,"ix":2}},{"ty":2,"nm":"Color","mn":"ADBE Fill-0002","ix":3,"v":{"a":0,"k":[1,0.705882370472,0,1],"ix":3}},{"ty":7,"nm":"Invert","mn":"ADBE Fill-0006","ix":4,"v":{"a":0,"k":0,"ix":4}},{"ty":0,"nm":"Horizontal Feather","mn":"ADBE Fill-0003","ix":5,"v":{"a":0,"k":0,"ix":5}},{"ty":0,"nm":"Vertical Feather","mn":"ADBE Fill-0004","ix":6,"v":{"a":0,"k":0,"ix":6}},{"ty":0,"nm":"Opacity","mn":"ADBE Fill-0005","ix":7,"v":{"a":0,"k":1,"ix":7}}]}],"w":100,"h":100,"ip":12,"op":88,"st":12,"bm":0},{"ddd":0,"ind":10,"ty":0,"nm":"triangleComp01","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":0,"s":[2004,1152,0],"to":[0,0,0],"ti":[98.667,749.333,0]},{"t":10,"s":[1060,-120,0]}],"ix":2},"a":{"a":0,"k":[50,50,0],"ix":1},"s":{"a":0,"k":[80,80,100],"ix":6}},"ao":0,"ef":[{"ty":21,"nm":"Fill","np":9,"mn":"ADBE Fill","ix":1,"en":1,"ef":[{"ty":10,"nm":"Fill Mask","mn":"ADBE Fill-0001","ix":1,"v":{"a":0,"k":0,"ix":1}},{"ty":7,"nm":"All Masks","mn":"ADBE Fill-0007","ix":2,"v":{"a":0,"k":0,"ix":2}},{"ty":2,"nm":"Color","mn":"ADBE Fill-0002","ix":3,"v":{"a":0,"k":[0.098039224744,0.694117665291,1,1],"ix":3}},{"ty":7,"nm":"Invert","mn":"ADBE Fill-0006","ix":4,"v":{"a":0,"k":0,"ix":4}},{"ty":0,"nm":"Horizontal Feather","mn":"ADBE Fill-0003","ix":5,"v":{"a":0,"k":0,"ix":5}},{"ty":0,"nm":"Vertical Feather","mn":"ADBE Fill-0004","ix":6,"v":{"a":0,"k":0,"ix":6}},{"ty":0,"nm":"Opacity","mn":"ADBE Fill-0005","ix":7,"v":{"a":0,"k":1,"ix":7}}]}],"w":100,"h":100,"ip":0,"op":11,"st":0,"bm":0},{"ddd":0,"ind":11,"ty":0,"nm":"triangleComp01","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.17,"y":0.756},"o":{"x":0.167,"y":0.167},"t":13,"s":[1930,1130,0],"to":[-52.667,-198,0],"ti":[140,-6.333,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.72,"y":0.386},"t":38,"s":[1308,398,0],"to":[-140,6.333,0],"ti":[13.333,-434.667,0]},{"t":77,"s":[1090,1168,0]}],"ix":2},"a":{"a":0,"k":[50,50,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,2.083]},"t":13,"s":[50,50,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.695,0.695,0.333],"y":[0,0,0]},"t":38,"s":[75,75,100]},{"t":88,"s":[30,30,100]}],"ix":6}},"ao":0,"ef":[{"ty":21,"nm":"Fill","np":9,"mn":"ADBE Fill","ix":1,"en":1,"ef":[{"ty":10,"nm":"Fill Mask","mn":"ADBE Fill-0001","ix":1,"v":{"a":0,"k":0,"ix":1}},{"ty":7,"nm":"All Masks","mn":"ADBE Fill-0007","ix":2,"v":{"a":0,"k":0,"ix":2}},{"ty":2,"nm":"Color","mn":"ADBE Fill-0002","ix":3,"v":{"a":0,"k":[0.490196108818,0.231372565031,0.752941250801,1],"ix":3}},{"ty":7,"nm":"Invert","mn":"ADBE Fill-0006","ix":4,"v":{"a":0,"k":0,"ix":4}},{"ty":0,"nm":"Horizontal Feather","mn":"ADBE Fill-0003","ix":5,"v":{"a":0,"k":0,"ix":5}},{"ty":0,"nm":"Vertical Feather","mn":"ADBE Fill-0004","ix":6,"v":{"a":0,"k":0,"ix":6}},{"ty":0,"nm":"Opacity","mn":"ADBE Fill-0005","ix":7,"v":{"a":0,"k":1,"ix":7}}]}],"w":100,"h":100,"ip":13,"op":89,"st":13,"bm":0},{"ddd":0,"ind":12,"ty":0,"nm":"triangleComp01","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.17,"y":0.819},"o":{"x":0.167,"y":0.167},"t":3,"s":[1924,1148,0],"to":[-72.667,-550,0],"ti":[137.333,-0.667,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.72,"y":0.293},"t":28,"s":[1216,80,0],"to":[-137.333,0.667,0],"ti":[-12.667,-380.667,0]},{"t":67,"s":[1100,1152,0]}],"ix":2},"a":{"a":0,"k":[50,50,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.833]},"t":3,"s":[60,60,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.695,0.695,0.333],"y":[0,0,0]},"t":28,"s":[80,80,100]},{"t":78,"s":[20,20,100]}],"ix":6}},"ao":0,"ef":[{"ty":21,"nm":"Fill","np":9,"mn":"ADBE Fill","ix":1,"en":1,"ef":[{"ty":10,"nm":"Fill Mask","mn":"ADBE Fill-0001","ix":1,"v":{"a":0,"k":0,"ix":1}},{"ty":7,"nm":"All Masks","mn":"ADBE Fill-0007","ix":2,"v":{"a":0,"k":0,"ix":2}},{"ty":2,"nm":"Color","mn":"ADBE Fill-0002","ix":3,"v":{"a":0,"k":[1,0.282352954149,0.282352954149,1],"ix":3}},{"ty":7,"nm":"Invert","mn":"ADBE Fill-0006","ix":4,"v":{"a":0,"k":0,"ix":4}},{"ty":0,"nm":"Horizontal Feather","mn":"ADBE Fill-0003","ix":5,"v":{"a":0,"k":0,"ix":5}},{"ty":0,"nm":"Vertical Feather","mn":"ADBE Fill-0004","ix":6,"v":{"a":0,"k":0,"ix":6}},{"ty":0,"nm":"Opacity","mn":"ADBE Fill-0005","ix":7,"v":{"a":0,"k":1,"ix":7}}]}],"w":100,"h":100,"ip":3,"op":79,"st":3,"bm":0},{"ddd":0,"ind":13,"ty":4,"nm":"redCircle01","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":2,"s":[1920,1124,0],"to":[-156.667,-332.667,0],"ti":[0,0,0]},{"t":14,"s":[1652,-32,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.282352941176,0.282352941176,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":2,"op":15,"st":2,"bm":0},{"ddd":0,"ind":14,"ty":4,"nm":"blueCircle02","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.17,"y":0.783},"o":{"x":0.167,"y":0.167},"t":6,"s":[1840,1196,0],"to":[-77.333,-182,0],"ti":[133.798,40.438,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.72,"y":0.359},"t":20,"s":[1410,770,0],"to":[-137.2,-41.466,0],"ti":[19.333,-100.667,0]},{"t":41,"s":[1208,1168,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,1.667]},"t":6,"s":[20,20,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.695,0.695,0.333],"y":[0,0,0]},"t":20,"s":[50,50,100]},{"t":41,"s":[30,30,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.098039223166,0.694117647059,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":6,"op":42,"st":6,"bm":0},{"ddd":0,"ind":15,"ty":4,"nm":"redCircle03","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.17,"y":0.782},"o":{"x":0.167,"y":0.167},"t":15,"s":[1940,1168,0],"to":[-23.667,-194.667,0],"ti":[128.339,4.912,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.72,"y":0.29},"t":36,"s":[1606,328,0],"to":[-69.797,-2.671,0],"ti":[19.333,-210.667,0]},{"t":67,"s":[1410,1186,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.794]},"t":15,"s":[20,20,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.695,0.695,0.333],"y":[0,0,0]},"t":36,"s":[50,50,100]},{"t":67,"s":[20,20,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.282352941176,0.282352941176,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":15,"op":68,"st":15,"bm":0},{"ddd":0,"ind":16,"ty":4,"nm":"yellowCircle02","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.17,"y":0.674},"o":{"x":0.167,"y":0.167},"t":11,"s":[1904,1140,0],"to":[3.333,-94,0],"ti":[163,8,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.72,"y":0.443},"t":32,"s":[1660,608,0],"to":[-71.027,-3.486,0],"ti":[-12.667,-150.667,0]},{"t":63,"s":[1530,1160,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,1.667]},"t":11,"s":[30,30,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.695,0.695,0.333],"y":[0,0,0]},"t":32,"s":[70,70,100]},{"t":63,"s":[50,50,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.490196108351,0.231372563979,0.752941236309,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":11,"op":64,"st":11,"bm":0},{"ddd":0,"ind":17,"ty":4,"nm":"purpleCircle03","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.17,"y":0.77},"o":{"x":0.167,"y":0.167},"t":6,"s":[1908,1116,0],"to":[-9.333,-174,0],"ti":[154.699,0.945,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.72,"y":0.302},"t":31,"s":[1588,152,0],"to":[-126.052,-0.77,0],"ti":[3.333,-368.667,0]},{"t":70,"s":[1392,1184,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,8.333]},"t":6,"s":[50,50,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.695,0.695,0.333],"y":[0,0,0]},"t":31,"s":[100,100,100]},{"t":81,"s":[50,50,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[20,20],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.705882352941,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":6,"op":71,"st":6,"bm":0},{"ddd":0,"ind":18,"ty":4,"nm":"blueCircle01","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.17,"y":0.796},"o":{"x":0.167,"y":0.167},"t":0,"s":[1920,1160,0],"to":[6.667,-266,0],"ti":[131.904,-46.241,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.72,"y":0.326},"t":25,"s":[1300,212,0],"to":[-201.2,70.534,0],"ti":[-4.667,-388.667,0]},{"t":64,"s":[1144,1156,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,1.667]},"t":0,"s":[20,20,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.695,0.695,0.333],"y":[0,0,0]},"t":25,"s":[50,50,100]},{"t":64,"s":[30,30,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.098039223166,0.694117647059,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":65,"st":0,"bm":0},{"ddd":0,"ind":19,"ty":4,"nm":"redCircle02","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.17,"y":0.79},"o":{"x":0.167,"y":0.167},"t":7,"s":[2072,1216,0],"to":[-99,-124.667,0],"ti":[111.667,12.667,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.72,"y":0.368},"t":28,"s":[1478,468,0],"to":[-111.667,-12.667,0],"ti":[-0.667,-122.667,0]},{"t":59,"s":[1402,1140,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.794]},"t":7,"s":[20,20,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.695,0.695,0.333],"y":[0,0,0]},"t":28,"s":[50,50,100]},{"t":59,"s":[20,20,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.282352941176,0.282352941176,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":7,"op":72,"st":7,"bm":0},{"ddd":0,"ind":20,"ty":4,"nm":"yellowCircle01","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.17,"y":0.716},"o":{"x":0.167,"y":0.167},"t":3,"s":[1972,1216,0],"to":[-72,-110.667,0],"ti":[136.12,-24.434,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.72,"y":0.457},"t":21.8,"s":[1540,552,0],"to":[-163.485,29.346,0],"ti":[-20.667,-116.667,0]},{"t":50,"s":[1336,1140,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,1.667]},"t":3,"s":[30,30,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.695,0.695,0.333],"y":[0,0,0]},"t":21.8,"s":[70,70,100]},{"t":50,"s":[50,50,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.705882352941,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":3,"op":51,"st":3,"bm":0},{"ddd":0,"ind":21,"ty":4,"nm":"purpleCircle01","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.17,"y":0.777},"o":{"x":0.167,"y":0.167},"t":0,"s":[1640,1140,0],"to":[-32.667,-164,0],"ti":[117.335,-17.843,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.72,"y":0.342},"t":25,"s":[1104,228,0],"to":[-144.667,22,0],"ti":[-24.667,-180.667,0]},{"t":64,"s":[1032,1148,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,-5.556]},"t":0,"s":[80,80,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.695,0.695,0.333],"y":[0,0,0]},"t":25,"s":[100,100,100]},{"t":75,"s":[50,50,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[15,15],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.490196078431,0.23137254902,0.752941176471,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":65,"st":0,"bm":0}]},{"id":"comp_2","layers":[{"ddd":1,"ind":1,"ty":4,"nm":"triangle","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"rx":{"a":0,"k":0,"ix":8,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 150);"},"ry":{"a":0,"k":0,"ix":9,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 50);"},"rz":{"a":0,"k":0,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 10);"},"or":{"a":0,"k":[0,0,0],"ix":7},"p":{"a":0,"k":[50,57,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[80,80,80],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sr","sy":2,"d":1,"pt":{"a":0,"k":3,"ix":3},"p":{"a":0,"k":[0,0],"ix":4},"r":{"a":0,"k":0,"ix":5},"or":{"a":0,"k":30,"ix":7},"os":{"a":0,"k":0,"ix":9},"ix":1,"nm":"Polystar Path 1","mn":"ADBE Vector Shape - Star","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Polystar 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":375,"st":0,"bm":0}]},{"id":"comp_3","layers":[{"ddd":0,"ind":1,"ty":4,"nm":"blueCircle03","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":40,"s":[1773,-59,0],"to":[-116,296,0],"ti":[168,-388,0]},{"t":101,"s":[1733,1213,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[45.849,45.849,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.098039223166,0.694117647059,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":40,"op":101,"st":40,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"redCircle04","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":35,"s":[1452,-305,0],"to":[244,436,0],"ti":[-304,-272,0]},{"t":101,"s":[1520,1211,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[48.744,48.744,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.282352941176,0.282352941176,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":35,"op":101,"st":35,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"yellowCircle03","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":31,"s":[920,-110,0],"to":[-200,428,0],"ti":[244,-308,0]},{"t":101,"s":[920,1210,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[68.326,68.326,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.705882352941,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":31,"op":101,"st":31,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"purpleCircle04","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":26,"s":[1246,-55,0],"to":[184,256,0],"ti":[-344,-344,0]},{"t":76,"s":[1374,1253,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[48,48,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.490196078431,0.23137254902,0.752941176471,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":26,"op":77,"st":26,"bm":0},{"ddd":0,"ind":5,"ty":0,"nm":"triangleComp01","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":27,"s":[1654,-58,0],"to":[-124,373.333,0],"ti":[168,-557.333,0]},{"t":99,"s":[1654,1222,0]}],"ix":2},"a":{"a":0,"k":[50,50,0],"ix":1},"s":{"a":0,"k":[60,60,100],"ix":6}},"ao":0,"ef":[{"ty":21,"nm":"Fill","np":9,"mn":"ADBE Fill","ix":1,"en":1,"ef":[{"ty":10,"nm":"Fill Mask","mn":"ADBE Fill-0001","ix":1,"v":{"a":0,"k":0,"ix":1}},{"ty":7,"nm":"All Masks","mn":"ADBE Fill-0007","ix":2,"v":{"a":0,"k":0,"ix":2}},{"ty":2,"nm":"Color","mn":"ADBE Fill-0002","ix":3,"v":{"a":0,"k":[1,0.705882370472,0,1],"ix":3}},{"ty":7,"nm":"Invert","mn":"ADBE Fill-0006","ix":4,"v":{"a":0,"k":0,"ix":4}},{"ty":0,"nm":"Horizontal Feather","mn":"ADBE Fill-0003","ix":5,"v":{"a":0,"k":0,"ix":5}},{"ty":0,"nm":"Vertical Feather","mn":"ADBE Fill-0004","ix":6,"v":{"a":0,"k":0,"ix":6}},{"ty":0,"nm":"Opacity","mn":"ADBE Fill-0005","ix":7,"v":{"a":0,"k":1,"ix":7}}]}],"w":100,"h":100,"ip":27,"op":115,"st":27,"bm":0},{"ddd":0,"ind":6,"ty":0,"nm":"triangleComp01","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":23,"s":[1414,-58,0],"to":[248,409.333,0],"ti":[-236,-493.333,0]},{"t":100,"s":[1414,1222,0]}],"ix":2},"a":{"a":0,"k":[50,50,0],"ix":1},"s":{"a":0,"k":[60,60,100],"ix":6}},"ao":0,"ef":[{"ty":21,"nm":"Fill","np":9,"mn":"ADBE Fill","ix":1,"en":1,"ef":[{"ty":10,"nm":"Fill Mask","mn":"ADBE Fill-0001","ix":1,"v":{"a":0,"k":0,"ix":1}},{"ty":7,"nm":"All Masks","mn":"ADBE Fill-0007","ix":2,"v":{"a":0,"k":0,"ix":2}},{"ty":2,"nm":"Color","mn":"ADBE Fill-0002","ix":3,"v":{"a":0,"k":[0.490196108818,0.231372565031,0.752941250801,1],"ix":3}},{"ty":7,"nm":"Invert","mn":"ADBE Fill-0006","ix":4,"v":{"a":0,"k":0,"ix":4}},{"ty":0,"nm":"Horizontal Feather","mn":"ADBE Fill-0003","ix":5,"v":{"a":0,"k":0,"ix":5}},{"ty":0,"nm":"Vertical Feather","mn":"ADBE Fill-0004","ix":6,"v":{"a":0,"k":0,"ix":6}},{"ty":0,"nm":"Opacity","mn":"ADBE Fill-0005","ix":7,"v":{"a":0,"k":1,"ix":7}}]}],"w":100,"h":100,"ip":23,"op":111,"st":23,"bm":0},{"ddd":0,"ind":7,"ty":0,"nm":"triangleComp01","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":13,"s":[1988,1208,0],"to":[0,0,0],"ti":[95.333,242.667,0]},{"t":23,"s":[1416,-248,0]}],"ix":2},"a":{"a":0,"k":[50,50,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,-10]},"t":13,"s":[80,80,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.695,0.695,0.333],"y":[0,0,0]},"t":38,"s":[100,100,100]},{"t":88,"s":[50,50,100]}],"ix":6}},"ao":0,"ef":[{"ty":21,"nm":"Fill","np":9,"mn":"ADBE Fill","ix":1,"en":1,"ef":[{"ty":10,"nm":"Fill Mask","mn":"ADBE Fill-0001","ix":1,"v":{"a":0,"k":0,"ix":1}},{"ty":7,"nm":"All Masks","mn":"ADBE Fill-0007","ix":2,"v":{"a":0,"k":0,"ix":2}},{"ty":2,"nm":"Color","mn":"ADBE Fill-0002","ix":3,"v":{"a":0,"k":[0.098039224744,0.694117665291,1,1],"ix":3}},{"ty":7,"nm":"Invert","mn":"ADBE Fill-0006","ix":4,"v":{"a":0,"k":0,"ix":4}},{"ty":0,"nm":"Horizontal Feather","mn":"ADBE Fill-0003","ix":5,"v":{"a":0,"k":0,"ix":5}},{"ty":0,"nm":"Vertical Feather","mn":"ADBE Fill-0004","ix":6,"v":{"a":0,"k":0,"ix":6}},{"ty":0,"nm":"Opacity","mn":"ADBE Fill-0005","ix":7,"v":{"a":0,"k":1,"ix":7}}]}],"w":100,"h":100,"ip":13,"op":101,"st":13,"bm":0},{"ddd":0,"ind":8,"ty":0,"nm":"triangleComp01","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.17,"y":0.81},"o":{"x":0.167,"y":0.167},"t":26,"s":[1930,1130,0],"to":[-52.667,-198,0],"ti":[171.333,-3,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.72,"y":0.262},"t":51,"s":[1360,32,0],"to":[-171.333,3,0],"ti":[61.333,-430.667,0]},{"t":90,"s":[902,1148,0]}],"ix":2},"a":{"a":0,"k":[50,50,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,1.111]},"t":26,"s":[30,30,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.695,0.695,0.333],"y":[0,0,0]},"t":51,"s":[50,50,100]},{"t":101,"s":[15,15,100]}],"ix":6}},"ao":0,"ef":[{"ty":21,"nm":"Fill","np":9,"mn":"ADBE Fill","ix":1,"en":1,"ef":[{"ty":10,"nm":"Fill Mask","mn":"ADBE Fill-0001","ix":1,"v":{"a":0,"k":0,"ix":1}},{"ty":7,"nm":"All Masks","mn":"ADBE Fill-0007","ix":2,"v":{"a":0,"k":0,"ix":2}},{"ty":2,"nm":"Color","mn":"ADBE Fill-0002","ix":3,"v":{"a":0,"k":[0.098039224744,0.694117665291,1,1],"ix":3}},{"ty":7,"nm":"Invert","mn":"ADBE Fill-0006","ix":4,"v":{"a":0,"k":0,"ix":4}},{"ty":0,"nm":"Horizontal Feather","mn":"ADBE Fill-0003","ix":5,"v":{"a":0,"k":0,"ix":5}},{"ty":0,"nm":"Vertical Feather","mn":"ADBE Fill-0004","ix":6,"v":{"a":0,"k":0,"ix":6}},{"ty":0,"nm":"Opacity","mn":"ADBE Fill-0005","ix":7,"v":{"a":0,"k":1,"ix":7}}]}],"w":100,"h":100,"ip":26,"op":101,"st":26,"bm":0},{"ddd":0,"ind":9,"ty":0,"nm":"triangleComp01","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.17,"y":0.801},"o":{"x":0.167,"y":0.167},"t":16,"s":[1940,1124,0],"to":[-108.333,-166,0],"ti":[149.333,-4,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.72,"y":0.297},"t":41,"s":[1290,128,0],"to":[-149.333,4,0],"ti":[-48.667,-412.667,0]},{"t":80,"s":[1044,1148,0]}],"ix":2},"a":{"a":0,"k":[50,50,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,-10]},"t":16,"s":[80,80,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.695,0.695,0.333],"y":[0,0,0]},"t":41,"s":[100,100,100]},{"t":91,"s":[50,50,100]}],"ix":6}},"ao":0,"ef":[{"ty":21,"nm":"Fill","np":9,"mn":"ADBE Fill","ix":1,"en":1,"ef":[{"ty":10,"nm":"Fill Mask","mn":"ADBE Fill-0001","ix":1,"v":{"a":0,"k":0,"ix":1}},{"ty":7,"nm":"All Masks","mn":"ADBE Fill-0007","ix":2,"v":{"a":0,"k":0,"ix":2}},{"ty":2,"nm":"Color","mn":"ADBE Fill-0002","ix":3,"v":{"a":0,"k":[1,0.705882370472,0,1],"ix":3}},{"ty":7,"nm":"Invert","mn":"ADBE Fill-0006","ix":4,"v":{"a":0,"k":0,"ix":4}},{"ty":0,"nm":"Horizontal Feather","mn":"ADBE Fill-0003","ix":5,"v":{"a":0,"k":0,"ix":5}},{"ty":0,"nm":"Vertical Feather","mn":"ADBE Fill-0004","ix":6,"v":{"a":0,"k":0,"ix":6}},{"ty":0,"nm":"Opacity","mn":"ADBE Fill-0005","ix":7,"v":{"a":0,"k":1,"ix":7}}]}],"w":100,"h":100,"ip":16,"op":101,"st":16,"bm":0},{"ddd":0,"ind":10,"ty":0,"nm":"triangleComp01","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":0,"s":[1988,1208,0],"to":[0,0,0],"ti":[154.667,221.333,0]},{"t":10,"s":[1060,-120,0]}],"ix":2},"a":{"a":0,"k":[50,50,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,-10]},"t":0,"s":[80,80,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.695,0.695,0.333],"y":[0,0,0]},"t":25,"s":[100,100,100]},{"t":75,"s":[50,50,100]}],"ix":6}},"ao":0,"ef":[{"ty":21,"nm":"Fill","np":9,"mn":"ADBE Fill","ix":1,"en":1,"ef":[{"ty":10,"nm":"Fill Mask","mn":"ADBE Fill-0001","ix":1,"v":{"a":0,"k":0,"ix":1}},{"ty":7,"nm":"All Masks","mn":"ADBE Fill-0007","ix":2,"v":{"a":0,"k":0,"ix":2}},{"ty":2,"nm":"Color","mn":"ADBE Fill-0002","ix":3,"v":{"a":0,"k":[0.098039224744,0.694117665291,1,1],"ix":3}},{"ty":7,"nm":"Invert","mn":"ADBE Fill-0006","ix":4,"v":{"a":0,"k":0,"ix":4}},{"ty":0,"nm":"Horizontal Feather","mn":"ADBE Fill-0003","ix":5,"v":{"a":0,"k":0,"ix":5}},{"ty":0,"nm":"Vertical Feather","mn":"ADBE Fill-0004","ix":6,"v":{"a":0,"k":0,"ix":6}},{"ty":0,"nm":"Opacity","mn":"ADBE Fill-0005","ix":7,"v":{"a":0,"k":1,"ix":7}}]}],"w":100,"h":100,"ip":0,"op":101,"st":0,"bm":0},{"ddd":0,"ind":11,"ty":0,"nm":"triangleComp01","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.17,"y":0.754},"o":{"x":0.167,"y":0.167},"t":13,"s":[1930,1130,0],"to":[-52.667,-198,0],"ti":[171.333,-3,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.72,"y":0.328},"t":38,"s":[1396,346,0],"to":[-171.333,3,0],"ti":[61.333,-430.667,0]},{"t":77,"s":[902,1148,0]}],"ix":2},"a":{"a":0,"k":[50,50,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,2.083]},"t":13,"s":[50,50,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.695,0.695,0.333],"y":[0,0,0]},"t":38,"s":[75,75,100]},{"t":88,"s":[30,30,100]}],"ix":6}},"ao":0,"ef":[{"ty":21,"nm":"Fill","np":9,"mn":"ADBE Fill","ix":1,"en":1,"ef":[{"ty":10,"nm":"Fill Mask","mn":"ADBE Fill-0001","ix":1,"v":{"a":0,"k":0,"ix":1}},{"ty":7,"nm":"All Masks","mn":"ADBE Fill-0007","ix":2,"v":{"a":0,"k":0,"ix":2}},{"ty":2,"nm":"Color","mn":"ADBE Fill-0002","ix":3,"v":{"a":0,"k":[0.490196108818,0.231372565031,0.752941250801,1],"ix":3}},{"ty":7,"nm":"Invert","mn":"ADBE Fill-0006","ix":4,"v":{"a":0,"k":0,"ix":4}},{"ty":0,"nm":"Horizontal Feather","mn":"ADBE Fill-0003","ix":5,"v":{"a":0,"k":0,"ix":5}},{"ty":0,"nm":"Vertical Feather","mn":"ADBE Fill-0004","ix":6,"v":{"a":0,"k":0,"ix":6}},{"ty":0,"nm":"Opacity","mn":"ADBE Fill-0005","ix":7,"v":{"a":0,"k":1,"ix":7}}]}],"w":100,"h":100,"ip":13,"op":101,"st":13,"bm":0},{"ddd":0,"ind":12,"ty":0,"nm":"triangleComp01","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.17,"y":0.816},"o":{"x":0.167,"y":0.167},"t":3,"s":[1940,1124,0],"to":[-120.667,-178,0],"ti":[149.333,-4,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.72,"y":0.284},"t":28,"s":[1216,56,0],"to":[-149.333,4,0],"ti":[-48.667,-412.667,0]},{"t":67,"s":[1044,1148,0]}],"ix":2},"a":{"a":0,"k":[50,50,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.833]},"t":3,"s":[60,60,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.695,0.695,0.333],"y":[0,0,0]},"t":28,"s":[80,80,100]},{"t":78,"s":[20,20,100]}],"ix":6}},"ao":0,"ef":[{"ty":21,"nm":"Fill","np":9,"mn":"ADBE Fill","ix":1,"en":1,"ef":[{"ty":10,"nm":"Fill Mask","mn":"ADBE Fill-0001","ix":1,"v":{"a":0,"k":0,"ix":1}},{"ty":7,"nm":"All Masks","mn":"ADBE Fill-0007","ix":2,"v":{"a":0,"k":0,"ix":2}},{"ty":2,"nm":"Color","mn":"ADBE Fill-0002","ix":3,"v":{"a":0,"k":[1,0.282352954149,0.282352954149,1],"ix":3}},{"ty":7,"nm":"Invert","mn":"ADBE Fill-0006","ix":4,"v":{"a":0,"k":0,"ix":4}},{"ty":0,"nm":"Horizontal Feather","mn":"ADBE Fill-0003","ix":5,"v":{"a":0,"k":0,"ix":5}},{"ty":0,"nm":"Vertical Feather","mn":"ADBE Fill-0004","ix":6,"v":{"a":0,"k":0,"ix":6}},{"ty":0,"nm":"Opacity","mn":"ADBE Fill-0005","ix":7,"v":{"a":0,"k":1,"ix":7}}]}],"w":100,"h":100,"ip":3,"op":79,"st":3,"bm":0},{"ddd":0,"ind":13,"ty":4,"nm":"redCircle01","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":1,"s":[1920,1124,0],"to":[-121.333,-216.667,0],"ti":[0,0,0]},{"t":13,"s":[1192,-176,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.282352941176,0.282352941176,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":1,"op":14,"st":1,"bm":0},{"ddd":0,"ind":14,"ty":4,"nm":"blueCircle02","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.17,"y":0.779},"o":{"x":0.167,"y":0.167},"t":6,"s":[1920,1160,0],"to":[6.667,-266,0],"ti":[131.904,-46.241,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.72,"y":0.326},"t":31,"s":[1434,234,0],"to":[-201.2,70.534,0],"ti":[39.333,-396.667,0]},{"t":70,"s":[1208,1168,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,1.667]},"t":6,"s":[20,20,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.695,0.695,0.333],"y":[0,0,0]},"t":31,"s":[50,50,100]},{"t":70,"s":[30,30,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.098039223166,0.694117647059,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":6,"op":71,"st":6,"bm":0},{"ddd":0,"ind":15,"ty":4,"nm":"redCircle03","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.17,"y":0.761},"o":{"x":0.167,"y":0.167},"t":15,"s":[1992,1216,0],"to":[-89,-107.333,0],"ti":[106.333,12.333,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.72,"y":0.427},"t":36,"s":[1458,572,0],"to":[-106.333,-12.333,0],"ti":[-0.667,-122.667,0]},{"t":67,"s":[1354,1142,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.794]},"t":15,"s":[20,20,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.695,0.695,0.333],"y":[0,0,0]},"t":36,"s":[50,50,100]},{"t":67,"s":[20,20,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.282352941176,0.282352941176,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":15,"op":80,"st":15,"bm":0},{"ddd":0,"ind":16,"ty":4,"nm":"yellowCircle02","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.17,"y":0.711},"o":{"x":0.167,"y":0.167},"t":11,"s":[1820,1152,0],"to":[-69.333,-91.333,0],"ti":[97,-0.667,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.72,"y":0.436},"t":32,"s":[1404,604,0],"to":[-97,0.667,0],"ti":[27.333,-98.667,0]},{"t":63,"s":[1238,1156,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,1.667]},"t":11,"s":[30,30,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.695,0.695,0.333],"y":[0,0,0]},"t":32,"s":[70,70,100]},{"t":63,"s":[50,50,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.490196108351,0.231372563979,0.752941236309,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":11,"op":76,"st":11,"bm":0},{"ddd":0,"ind":17,"ty":4,"nm":"purpleCircle03","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.17,"y":0.824},"o":{"x":0.167,"y":0.167},"t":6,"s":[1944,1104,0],"to":[-154,-164.667,0],"ti":[169.333,-8,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.72,"y":0.302},"t":31,"s":[1020,116,0],"to":[-169.333,8,0],"ti":[175.333,-340.667,0]},{"t":70,"s":[928,1152,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,8.333]},"t":6,"s":[50,50,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.695,0.695,0.333],"y":[0,0,0]},"t":31,"s":[100,100,100]},{"t":81,"s":[50,50,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[20,20],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.705882352941,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":6,"op":71,"st":6,"bm":0},{"ddd":0,"ind":18,"ty":4,"nm":"blueCircle01","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.17,"y":0.796},"o":{"x":0.167,"y":0.167},"t":0,"s":[1920,1160,0],"to":[6.667,-266,0],"ti":[131.904,-46.241,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.72,"y":0.326},"t":25,"s":[1300,212,0],"to":[-201.2,70.534,0],"ti":[-4.667,-388.667,0]},{"t":64,"s":[1144,1156,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,1.667]},"t":0,"s":[20,20,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.695,0.695,0.333],"y":[0,0,0]},"t":25,"s":[50,50,100]},{"t":64,"s":[30,30,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.098039223166,0.694117647059,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":65,"st":0,"bm":0},{"ddd":0,"ind":19,"ty":4,"nm":"redCircle02","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.17,"y":0.79},"o":{"x":0.167,"y":0.167},"t":9,"s":[1992,1216,0],"to":[-99,-124.667,0],"ti":[111.667,12.667,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.72,"y":0.368},"t":30,"s":[1398,468,0],"to":[-111.667,-12.667,0],"ti":[-0.667,-122.667,0]},{"t":61,"s":[1322,1140,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.794]},"t":9,"s":[20,20,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.695,0.695,0.333],"y":[0,0,0]},"t":30,"s":[50,50,100]},{"t":61,"s":[20,20,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.282352941176,0.282352941176,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":9,"op":74,"st":9,"bm":0},{"ddd":0,"ind":20,"ty":4,"nm":"yellowCircle01","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.17,"y":0.748},"o":{"x":0.167,"y":0.167},"t":5,"s":[1732,1216,0],"to":[-72,-110.667,0],"ti":[106,12.667,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.72,"y":0.398},"t":26,"s":[1300,552,0],"to":[-106,-12.667,0],"ti":[-20.667,-116.667,0]},{"t":57,"s":[1096,1140,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,1.667]},"t":5,"s":[30,30,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.695,0.695,0.333],"y":[0,0,0]},"t":26,"s":[70,70,100]},{"t":57,"s":[50,50,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.705882352941,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":5,"op":70,"st":5,"bm":0},{"ddd":0,"ind":21,"ty":4,"nm":"purpleCircle01","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.17,"y":0.824},"o":{"x":0.167,"y":0.167},"t":0,"s":[1944,1104,0],"to":[-154,-164.667,0],"ti":[169.333,-8,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.72,"y":0.302},"t":25,"s":[1020,116,0],"to":[-169.333,8,0],"ti":[175.333,-340.667,0]},{"t":64,"s":[928,1152,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,8.333]},"t":0,"s":[50,50,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.695,0.695,0.333],"y":[0,0,0]},"t":25,"s":[100,100,100]},{"t":75,"s":[50,50,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[15,15],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.490196078431,0.23137254902,0.752941176471,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":65,"st":0,"bm":0}]},{"id":"comp_4","layers":[{"ddd":0,"ind":1,"ty":4,"nm":"ribbonRed02","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":8.9,"ix":10},"p":{"a":0,"k":[655.204,920.201,0],"ix":2},"a":{"a":0,"k":[787.204,316.201,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[65.331,101.626],[52.275,60.987],[18,-36],[-30,-2],[100.811,137.103],[-12,136],[-54,80]],"o":[[0,0],[-54,-84],[-24,-28],[-26.593,53.186],[147.726,9.848],[-100,-136],[10.968,-124.305],[51.52,-76.326]],"v":[[736,520],[810,346],[830,138],[716,140],[780,226],[836,-92],[656,-334],[887.095,-676.42]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.156862745098,0.215686289469,0.470588265213,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":15,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.18],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":11,"s":[0]},{"t":44,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.18],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":4,"s":[0]},{"t":37,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":4,"op":45,"st":4,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"ribbonPurple02","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[960,540,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-7.674,145.812],[-56.367,71.74],[-43.907,167.129],[8,194],[-62,210]],"o":[[0,0],[8,-151.999],[110,-140.001],[62,-236],[-13.292,-322.34],[26.075,-88.318]],"v":[[524,624],[370,412],[544,236.001],[462,-40],[48,-256],[-174,-752]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.270588235294,0.909803981407,0.639215686275,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[3.615]},"t":10,"s":[20]},{"t":39,"s":[15]}],"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.18],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":10,"s":[0]},{"t":43,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.18],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":3,"s":[0]},{"t":36,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":3,"op":44,"st":3,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"ribbonRed01","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[960,540,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[65.331,101.626],[-26,76],[32,-58],[-148,4],[100.811,137.103],[16,156],[-92,4]],"o":[[0,0],[-54,-84],[36.079,-105.462],[-42.346,76.752],[148,-4],[-100,-136],[-16,-156],[92,-4]],"v":[[1006,496],[688,340],[704,96],[568,46],[738,200],[832,-158],[600,-414],[832,-664]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.282352911257,0.282352911257,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":15,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.18],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":9,"s":[0]},{"t":42,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.18],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":2,"s":[0]},{"t":35,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":2,"op":43,"st":2,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"ribbonBlue01","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[960,540,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-140,200],[-170.503,113.669],[-133.881,0],[41.726,121.7],[-224,52]],"o":[[0,0],[140,-200],[138,-92],[178,0],[-24,-70],[224,-52]],"v":[[-1072,420],[-688,316],[-484,-136],[18,-62],[178,-370],[308,-692]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.239215701234,0.823529471603,0.976470648074,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[2]},"t":12,"s":[25]},{"t":28,"s":[15]}],"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.18],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":8,"s":[0]},{"t":41,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.18],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":1,"s":[0]},{"t":34,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":1,"op":42,"st":1,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"ribbonPurple01","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[960,540,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-92,162],[-114.383,228.764],[-106,-10],[56.871,70.411],[-98,116],[-28.331,59.644],[-90,44],[182,50]],"o":[[112.375,-197.878],[56,-111.999],[155.323,14.654],[-42,-52],[79.909,-94.586],[38,-80],[92.11,-45.031],[-107.186,-29.447]],"v":[[-596,612],[-704,247.999],[-446,120],[-298,-16],[-430,-158],[-482,-330],[-246,-366],[-308,-652]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.488552078546,0.230311404957,0.752941176471,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":1,"k":[{"i":{"x":[0.17],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":8,"s":[35]},{"t":38,"s":[10]}],"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.18],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":8,"s":[0]},{"t":41,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.18],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":5,"s":[0]},{"t":38,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":42,"st":0,"bm":0},{"ddd":0,"ind":6,"ty":4,"nm":"ribbonYellow01","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[960,540,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-15.347,130.161],[-186.562,101.072],[112.01,0.14],[-68.075,42.572],[-51.603,62.412],[43.211,79.208],[219.412,-10.282]],"o":[[0,0],[15.347,-130.162],[180.162,-97.604],[-112.01,-0.142],[88.184,-55.147],[57.188,-69.166],[-43.211,-79.208],[-190.794,8.941]],"v":[[-996.479,553.827],[-814.281,359.159],[-768.857,64.753],[-717.951,160.649],[-716.405,-79.549],[-503.734,-159.43],[-491.038,-358.543],[-963.412,-597.718]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.706519751455,0,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":1,"k":[{"i":{"x":[0.17],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[25]},{"t":33,"s":[10]}],"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.18],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":7,"s":[0]},{"t":40,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.18],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[0]},{"t":33,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":41,"st":0,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":0,"nm":"frills","refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[960,540,0],"ix":2},"a":{"a":0,"k":[960,540,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":1920,"h":1080,"ip":16,"op":166,"st":16,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"blueCircle04","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":32,"s":[1186.352,-230.499,0],"to":[-144,723.333,0],"ti":[272,-583.333,0]},{"t":83,"s":[1186.352,1205.501,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[45.849,45.849,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.098039223166,0.694117647059,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":32,"op":83,"st":32,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"redCircle05","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":19,"s":[794.727,-24.984,0],"to":[224,403.333,0],"ti":[-284,-435.333,0]},{"t":76,"s":[794.727,1411.016,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[48.744,48.744,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.282352941176,0.282352941176,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":19,"op":77,"st":19,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"yellowCircle04","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":26,"s":[108.041,-39.458,0],"to":[-76,411.333,0],"ti":[184,-591.333,0]},{"t":71,"s":[108.041,1396.542,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[68.326,68.326,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.705882352941,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":26,"op":73,"st":26,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"purpleCircle05","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":32,"s":[448.767,-228.23,0],"to":[236,683.333,0],"ti":[-160,-439.333,0]},{"t":89,"s":[448.767,1207.77,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[48,48,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.490196078431,0.23137254902,0.752941176471,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":32,"op":90,"st":32,"bm":0},{"ddd":0,"ind":6,"ty":4,"nm":"blueCircle03","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":39,"s":[1430.352,-242.499,0],"to":[-144,723.333,0],"ti":[272,-583.333,0]},{"t":88,"s":[1430.352,1193.501,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[45.849,45.849,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.098039223166,0.694117647059,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":39,"op":90,"st":39,"bm":0},{"ddd":0,"ind":7,"ty":4,"nm":"redCircle04","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":16,"s":[1038.727,-36.984,0],"to":[224,403.333,0],"ti":[-284,-435.333,0]},{"t":73,"s":[1038.727,1399.016,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[48.744,48.744,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.282352941176,0.282352941176,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":16,"op":74,"st":16,"bm":0},{"ddd":0,"ind":8,"ty":4,"nm":"yellowCircle03","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":30,"s":[352.041,-51.458,0],"to":[-76,411.333,0],"ti":[184,-591.333,0]},{"t":87,"s":[352.041,1384.542,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[68.326,68.326,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.705882352941,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":30,"op":88,"st":30,"bm":0},{"ddd":0,"ind":9,"ty":4,"nm":"purpleCircle04","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":23,"s":[692.767,-240.23,0],"to":[236,683.333,0],"ti":[-160,-439.333,0]},{"t":80,"s":[692.767,1195.77,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[48,48,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.490196078431,0.23137254902,0.752941176471,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":23,"op":81,"st":23,"bm":0},{"ddd":0,"ind":10,"ty":0,"nm":"Particles02","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[960,540,0],"ix":2},"a":{"a":0,"k":[960,540,0],"ix":1},"s":{"a":0,"k":[-100,100,100],"ix":6}},"ao":0,"w":1920,"h":1080,"ip":0,"op":101,"st":0,"bm":0},{"ddd":0,"ind":11,"ty":0,"nm":"Particles02","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[960,540,0],"ix":2},"a":{"a":0,"k":[960,540,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":1920,"h":1080,"ip":3,"op":104,"st":3,"bm":0},{"ddd":0,"ind":12,"ty":0,"nm":"Particles01","refId":"comp_3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[960,540,0],"ix":2},"a":{"a":0,"k":[960,540,0],"ix":1},"s":{"a":0,"k":[-100,100,100],"ix":6}},"ao":0,"w":1920,"h":1080,"ip":7,"op":108,"st":7,"bm":0},{"ddd":0,"ind":13,"ty":0,"nm":"Particles01","refId":"comp_3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[960,540,0],"ix":2},"a":{"a":0,"k":[960,540,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":1920,"h":1080,"ip":14,"op":115,"st":14,"bm":0},{"ddd":0,"ind":14,"ty":0,"nm":"ribbons","refId":"comp_4","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[960,540,0],"ix":2},"a":{"a":0,"k":[960,540,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":1920,"h":1080,"ip":0,"op":44,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/IngrediCheck/Resources/Confetti.json b/IngrediCheck/Resources/Confetti.json new file mode 100644 index 00000000..b4e1e23c --- /dev/null +++ b/IngrediCheck/Resources/Confetti.json @@ -0,0 +1 @@ +{"v":"5.5.7","meta":{"g":"LottieFiles AE 0.1.20","a":"","k":"","d":"","tc":""},"fr":25,"ip":0,"op":126,"w":1920,"h":1080,"nm":"ConfettiAnimation","ddd":1,"assets":[{"id":"comp_0","layers":[{"ddd":1,"ind":1,"ty":4,"nm":"Shape Layer 16","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"rx":{"a":0,"k":0,"ix":8,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 500);"},"ry":{"a":0,"k":0,"ix":9,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"rz":{"a":0,"k":0,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 100);"},"or":{"a":0,"k":[0,0,0],"ix":7},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":29,"s":[1664,-23,0],"to":[92,254.667,0],"ti":[168,-414.667,0]},{"t":104,"s":[1792,1185,0]}],"ix":2},"a":{"a":0,"k":[-248,-39,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-248,-52],[-247.885,-20.203]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.270588235294,0.909803981407,0.639215686275,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":10,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":29,"op":105,"st":29,"bm":0},{"ddd":1,"ind":2,"ty":4,"nm":"Shape Layer 15","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"rx":{"a":0,"k":0,"ix":8,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 500);"},"ry":{"a":0,"k":0,"ix":9,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"rz":{"a":0,"k":0,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 100);"},"or":{"a":0,"k":[0,0,0],"ix":7},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":12,"s":[1235,-59,0],"to":[96,526.667,0],"ti":[-104,-486.667,0]},{"t":87,"s":[1039,1237,0]}],"ix":2},"a":{"a":0,"k":[-248,-39,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-248,-52],[-247.885,-20.203]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.239215701234,0.419607873056,0.709803921569,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":10,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":12,"op":88,"st":12,"bm":0},{"ddd":1,"ind":3,"ty":4,"nm":"Shape Layer 14","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"rx":{"a":0,"k":0,"ix":8,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 500);"},"ry":{"a":0,"k":0,"ix":9,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"rz":{"a":0,"k":0,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 100);"},"or":{"a":0,"k":[0,0,0],"ix":7},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":18,"s":[528,-59,0],"to":[288,686.667,0],"ti":[-180,-406.667,0]},{"t":93,"s":[708,1377,0]}],"ix":2},"a":{"a":0,"k":[-248,-39,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-248,-52],[-247.885,-20.203]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.270588235294,0.909803981407,0.639215686275,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":10,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":18,"op":94,"st":18,"bm":0},{"ddd":1,"ind":4,"ty":4,"nm":"Shape Layer 13","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"rx":{"a":0,"k":0,"ix":8,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 500);"},"ry":{"a":0,"k":0,"ix":9,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"rz":{"a":0,"k":0,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 100);"},"or":{"a":0,"k":[0,0,0],"ix":7},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":15,"s":[292,-147,0],"to":[-90,314.667,0],"ti":[194,-532.667,0]},{"t":90,"s":[292,1213,0]}],"ix":2},"a":{"a":0,"k":[-248,-39,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-248,-52],[-248,-26]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.098039223166,0.694117647059,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":10,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":15,"op":91,"st":15,"bm":0},{"ddd":1,"ind":5,"ty":4,"nm":"Shape Layer 12","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"rx":{"a":0,"k":0,"ix":8,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 500);"},"ry":{"a":0,"k":0,"ix":9,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"rz":{"a":0,"k":0,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 100);"},"or":{"a":0,"k":[0,0,0],"ix":7},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":28,"s":[1128,-59,0],"to":[180,310.667,0],"ti":[-112,-490.667,0]},{"t":103,"s":[1088,1137,0]}],"ix":2},"a":{"a":0,"k":[-248,-39,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-248,-52],[-247.885,-20.203]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.290196078431,0.952941236309,0.968627510819,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":10,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":28,"op":104,"st":28,"bm":0},{"ddd":1,"ind":6,"ty":4,"nm":"Shape Layer 11","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"rx":{"a":0,"k":0,"ix":8,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 500);"},"ry":{"a":0,"k":0,"ix":9,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"rz":{"a":0,"k":0,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 100);"},"or":{"a":0,"k":[0,0,0],"ix":7},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":19,"s":[1455,-59,0],"to":[-184,406.667,0],"ti":[112,-438.667,0]},{"t":94,"s":[1459,1213,0]}],"ix":2},"a":{"a":0,"k":[-248,-39,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-248,-52],[-247.885,-20.203]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.705882352941,0,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":10,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":19,"op":95,"st":19,"bm":0},{"ddd":1,"ind":7,"ty":4,"nm":"Shape Layer 10","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"rx":{"a":0,"k":0,"ix":8,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 500);"},"ry":{"a":0,"k":0,"ix":9,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"rz":{"a":0,"k":0,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 100);"},"or":{"a":0,"k":[0,0,0],"ix":7},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":24,"s":[728,-59,0],"to":[388,434.667,0],"ti":[-584,-762.667,0]},{"t":99,"s":[732,1413,0]}],"ix":2},"a":{"a":0,"k":[-248,-39,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-248,-52],[-247.885,-20.203]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.282352941176,0.282352941176,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":10,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":24,"op":100,"st":24,"bm":0},{"ddd":1,"ind":8,"ty":4,"nm":"Shape Layer 9","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"rx":{"a":0,"k":0,"ix":8,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 500);"},"ry":{"a":0,"k":0,"ix":9,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"rz":{"a":0,"k":0,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 100);"},"or":{"a":0,"k":[0,0,0],"ix":7},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":19,"s":[232,-147,0],"to":[-200,554.667,0],"ti":[560,-378.667,0]},{"t":94,"s":[232,1213,0]}],"ix":2},"a":{"a":0,"k":[-248,-39,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-248,-52],[-248,-26]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.156862745098,0.215686289469,0.470588265213,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":10,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":19,"op":95,"st":19,"bm":0},{"ddd":1,"ind":9,"ty":4,"nm":"Shape Layer 8","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"rx":{"a":0,"k":0,"ix":8,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 500);"},"ry":{"a":0,"k":0,"ix":9,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"rz":{"a":0,"k":0,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 100);"},"or":{"a":0,"k":[0,0,0],"ix":7},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":21,"s":[1664,-23,0],"to":[108,234.667,0],"ti":[-336,-306.667,0]},{"t":96,"s":[1772,1177,0]}],"ix":2},"a":{"a":0,"k":[-248,-39,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-248,-52],[-247.885,-20.203]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.490196108351,0.231372563979,0.752941236309,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":10,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":21,"op":97,"st":21,"bm":0},{"ddd":1,"ind":10,"ty":4,"nm":"Shape Layer 7","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"rx":{"a":0,"k":0,"ix":8,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 500);"},"ry":{"a":0,"k":0,"ix":9,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"rz":{"a":0,"k":0,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 100);"},"or":{"a":0,"k":[0,0,0],"ix":7},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":0,"s":[1235,-59,0],"to":[96,526.667,0],"ti":[-104,-486.667,0]},{"t":75,"s":[1039,1237,0]}],"ix":2},"a":{"a":0,"k":[-248,-39,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-248,-52],[-247.885,-20.203]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.239215701234,0.419607873056,0.709803921569,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":10,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":76,"st":0,"bm":0},{"ddd":1,"ind":11,"ty":4,"nm":"Shape Layer 6","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"rx":{"a":0,"k":0,"ix":8,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 500);"},"ry":{"a":0,"k":0,"ix":9,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"rz":{"a":0,"k":0,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 100);"},"or":{"a":0,"k":[0,0,0],"ix":7},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":6,"s":[528,-59,0],"to":[-208,742.667,0],"ti":[216,-566.667,0]},{"t":81,"s":[532,1413,0]}],"ix":2},"a":{"a":0,"k":[-248,-39,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-248,-52],[-247.885,-20.203]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.270588235294,0.909803981407,0.639215686275,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":10,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":6,"op":82,"st":6,"bm":0},{"ddd":1,"ind":12,"ty":4,"nm":"Shape Layer 5","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"rx":{"a":0,"k":0,"ix":8,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 500);"},"ry":{"a":0,"k":0,"ix":9,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"rz":{"a":0,"k":0,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 100);"},"or":{"a":0,"k":[0,0,0],"ix":7},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":2,"s":[92,-147,0],"to":[-90,314.667,0],"ti":[194,-532.667,0]},{"t":77,"s":[92,1213,0]}],"ix":2},"a":{"a":0,"k":[-248,-39,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-248,-52],[-248,-26]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.098039223166,0.694117647059,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":10,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":2,"op":78,"st":2,"bm":0},{"ddd":1,"ind":13,"ty":4,"nm":"Shape Layer 4","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"rx":{"a":0,"k":0,"ix":8,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 500);"},"ry":{"a":0,"k":0,"ix":9,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"rz":{"a":0,"k":0,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 100);"},"or":{"a":0,"k":[0,0,0],"ix":7},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":16,"s":[1128,-59,0],"to":[-208,358.667,0],"ti":[-112,-490.667,0]},{"t":91,"s":[1088,1137,0]}],"ix":2},"a":{"a":0,"k":[-248,-39,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-248,-52],[-247.885,-20.203]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.490196108351,0.231372563979,0.752941236309,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":10,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":16,"op":92,"st":16,"bm":0},{"ddd":1,"ind":14,"ty":4,"nm":"Shape Layer 3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"rx":{"a":0,"k":0,"ix":8,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 500);"},"ry":{"a":0,"k":0,"ix":9,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"rz":{"a":0,"k":0,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 100);"},"or":{"a":0,"k":[0,0,0],"ix":7},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":7,"s":[1455,-59,0],"to":[-184,406.667,0],"ti":[112,-438.667,0]},{"t":82,"s":[1459,1213,0]}],"ix":2},"a":{"a":0,"k":[-248,-39,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-248,-52],[-247.885,-20.203]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.705882352941,0,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":10,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":7,"op":83,"st":7,"bm":0},{"ddd":1,"ind":15,"ty":4,"nm":"Shape Layer 2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"rx":{"a":0,"k":0,"ix":8,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 500);"},"ry":{"a":0,"k":0,"ix":9,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"rz":{"a":0,"k":0,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 100);"},"or":{"a":0,"k":[0,0,0],"ix":7},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":13,"s":[728,-59,0],"to":[388,434.667,0],"ti":[-584,-762.667,0]},{"t":88,"s":[732,1413,0]}],"ix":2},"a":{"a":0,"k":[-248,-39,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-248,-52],[-247.885,-20.203]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.282352941176,0.282352941176,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":10,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":13,"op":89,"st":13,"bm":0},{"ddd":1,"ind":16,"ty":4,"nm":"Shape Layer 1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"rx":{"a":0,"k":0,"ix":8,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 500);"},"ry":{"a":0,"k":0,"ix":9,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 300);"},"rz":{"a":0,"k":0,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 100);"},"or":{"a":0,"k":[0,0,0],"ix":7},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":9,"s":[232,-147,0],"to":[368,422.667,0],"ti":[20,-326.667,0]},{"t":84,"s":[232,1213,0]}],"ix":2},"a":{"a":0,"k":[-248,-39,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-248,-52],[-248,-26]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.156862745098,0.215686289469,0.470588265213,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":10,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":9,"op":85,"st":9,"bm":0}]},{"id":"comp_1","layers":[{"ddd":0,"ind":1,"ty":4,"nm":"blueCircle03","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":32,"s":[1453,-59,0],"to":[-116,296,0],"ti":[168,-388,0]},{"t":93,"s":[1413,1213,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[45.849,45.849,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.098039223166,0.694117647059,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":32,"op":93,"st":32,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"redCircle04","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":35,"s":[1132,-305,0],"to":[244,436,0],"ti":[-304,-272,0]},{"t":101,"s":[1200,1211,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[48.744,48.744,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.282352941176,0.282352941176,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":35,"op":101,"st":35,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"yellowCircle03","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":31,"s":[600,-110,0],"to":[-200,428,0],"ti":[244,-308,0]},{"t":101,"s":[600,1210,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[68.326,68.326,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.705882352941,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":31,"op":101,"st":31,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"purpleCircle04","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":28,"s":[926,-55,0],"to":[184,256,0],"ti":[-344,-344,0]},{"t":78,"s":[1054,1253,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[48,48,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.490196078431,0.23137254902,0.752941176471,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":28,"op":79,"st":28,"bm":0},{"ddd":0,"ind":5,"ty":0,"nm":"triangleComp01","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":27,"s":[1654,-58,0],"to":[-124,373.333,0],"ti":[168,-557.333,0]},{"t":99,"s":[1654,1222,0]}],"ix":2},"a":{"a":0,"k":[50,50,0],"ix":1},"s":{"a":0,"k":[60,60,100],"ix":6}},"ao":0,"ef":[{"ty":21,"nm":"Fill","np":9,"mn":"ADBE Fill","ix":1,"en":1,"ef":[{"ty":10,"nm":"Fill Mask","mn":"ADBE Fill-0001","ix":1,"v":{"a":0,"k":0,"ix":1}},{"ty":7,"nm":"All Masks","mn":"ADBE Fill-0007","ix":2,"v":{"a":0,"k":0,"ix":2}},{"ty":2,"nm":"Color","mn":"ADBE Fill-0002","ix":3,"v":{"a":0,"k":[1,0.705882370472,0,1],"ix":3}},{"ty":7,"nm":"Invert","mn":"ADBE Fill-0006","ix":4,"v":{"a":0,"k":0,"ix":4}},{"ty":0,"nm":"Horizontal Feather","mn":"ADBE Fill-0003","ix":5,"v":{"a":0,"k":0,"ix":5}},{"ty":0,"nm":"Vertical Feather","mn":"ADBE Fill-0004","ix":6,"v":{"a":0,"k":0,"ix":6}},{"ty":0,"nm":"Opacity","mn":"ADBE Fill-0005","ix":7,"v":{"a":0,"k":1,"ix":7}}]}],"w":100,"h":100,"ip":27,"op":115,"st":27,"bm":0},{"ddd":0,"ind":6,"ty":0,"nm":"triangleComp01","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":23,"s":[1414,-58,0],"to":[248,409.333,0],"ti":[-236,-493.333,0]},{"t":100,"s":[1414,1222,0]}],"ix":2},"a":{"a":0,"k":[50,50,0],"ix":1},"s":{"a":0,"k":[60,60,100],"ix":6}},"ao":0,"ef":[{"ty":21,"nm":"Fill","np":9,"mn":"ADBE Fill","ix":1,"en":1,"ef":[{"ty":10,"nm":"Fill Mask","mn":"ADBE Fill-0001","ix":1,"v":{"a":0,"k":0,"ix":1}},{"ty":7,"nm":"All Masks","mn":"ADBE Fill-0007","ix":2,"v":{"a":0,"k":0,"ix":2}},{"ty":2,"nm":"Color","mn":"ADBE Fill-0002","ix":3,"v":{"a":0,"k":[0.490196108818,0.231372565031,0.752941250801,1],"ix":3}},{"ty":7,"nm":"Invert","mn":"ADBE Fill-0006","ix":4,"v":{"a":0,"k":0,"ix":4}},{"ty":0,"nm":"Horizontal Feather","mn":"ADBE Fill-0003","ix":5,"v":{"a":0,"k":0,"ix":5}},{"ty":0,"nm":"Vertical Feather","mn":"ADBE Fill-0004","ix":6,"v":{"a":0,"k":0,"ix":6}},{"ty":0,"nm":"Opacity","mn":"ADBE Fill-0005","ix":7,"v":{"a":0,"k":1,"ix":7}}]}],"w":100,"h":100,"ip":23,"op":111,"st":23,"bm":0},{"ddd":0,"ind":7,"ty":0,"nm":"triangleComp01","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":8,"s":[1924,1216,0],"to":[0,0,0],"ti":[396,250.667,0]},{"t":18,"s":[1416,-248,0]}],"ix":2},"a":{"a":0,"k":[50,50,0],"ix":1},"s":{"a":0,"k":[90.321,90.321,100],"ix":6}},"ao":0,"ef":[{"ty":21,"nm":"Fill","np":9,"mn":"ADBE Fill","ix":1,"en":1,"ef":[{"ty":10,"nm":"Fill Mask","mn":"ADBE Fill-0001","ix":1,"v":{"a":0,"k":0,"ix":1}},{"ty":7,"nm":"All Masks","mn":"ADBE Fill-0007","ix":2,"v":{"a":0,"k":0,"ix":2}},{"ty":2,"nm":"Color","mn":"ADBE Fill-0002","ix":3,"v":{"a":0,"k":[0.098039224744,0.694117665291,1,1],"ix":3}},{"ty":7,"nm":"Invert","mn":"ADBE Fill-0006","ix":4,"v":{"a":0,"k":0,"ix":4}},{"ty":0,"nm":"Horizontal Feather","mn":"ADBE Fill-0003","ix":5,"v":{"a":0,"k":0,"ix":5}},{"ty":0,"nm":"Vertical Feather","mn":"ADBE Fill-0004","ix":6,"v":{"a":0,"k":0,"ix":6}},{"ty":0,"nm":"Opacity","mn":"ADBE Fill-0005","ix":7,"v":{"a":0,"k":1,"ix":7}}]}],"w":100,"h":100,"ip":8,"op":19,"st":8,"bm":0},{"ddd":0,"ind":8,"ty":0,"nm":"triangleComp01","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.17,"y":0.772},"o":{"x":0.167,"y":0.167},"t":26,"s":[1930,1130,0],"to":[-52.667,-198,0],"ti":[122.667,-28.333,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.72,"y":0.278},"t":51,"s":[1496,196,0],"to":[-122.667,28.333,0],"ti":[29.333,-462.667,0]},{"t":90,"s":[1194,1300,0]}],"ix":2},"a":{"a":0,"k":[50,50,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,1.111]},"t":26,"s":[30,30,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.695,0.695,0.333],"y":[0,0,0]},"t":51,"s":[50,50,100]},{"t":101,"s":[15,15,100]}],"ix":6}},"ao":0,"ef":[{"ty":21,"nm":"Fill","np":9,"mn":"ADBE Fill","ix":1,"en":1,"ef":[{"ty":10,"nm":"Fill Mask","mn":"ADBE Fill-0001","ix":1,"v":{"a":0,"k":0,"ix":1}},{"ty":7,"nm":"All Masks","mn":"ADBE Fill-0007","ix":2,"v":{"a":0,"k":0,"ix":2}},{"ty":2,"nm":"Color","mn":"ADBE Fill-0002","ix":3,"v":{"a":0,"k":[0.098039224744,0.694117665291,1,1],"ix":3}},{"ty":7,"nm":"Invert","mn":"ADBE Fill-0006","ix":4,"v":{"a":0,"k":0,"ix":4}},{"ty":0,"nm":"Horizontal Feather","mn":"ADBE Fill-0003","ix":5,"v":{"a":0,"k":0,"ix":5}},{"ty":0,"nm":"Vertical Feather","mn":"ADBE Fill-0004","ix":6,"v":{"a":0,"k":0,"ix":6}},{"ty":0,"nm":"Opacity","mn":"ADBE Fill-0005","ix":7,"v":{"a":0,"k":1,"ix":7}}]}],"w":100,"h":100,"ip":26,"op":101,"st":26,"bm":0},{"ddd":0,"ind":9,"ty":0,"nm":"triangleComp01","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.17,"y":0.801},"o":{"x":0.167,"y":0.167},"t":12,"s":[1940,1124,0],"to":[-108.333,-166,0],"ti":[149.333,-4,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.72,"y":0.297},"t":37,"s":[1290,128,0],"to":[-149.333,4,0],"ti":[-48.667,-412.667,0]},{"t":76,"s":[1044,1148,0]}],"ix":2},"a":{"a":0,"k":[50,50,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,-10]},"t":12,"s":[80,80,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.695,0.695,0.333],"y":[0,0,0]},"t":37,"s":[100,100,100]},{"t":87,"s":[50,50,100]}],"ix":6}},"ao":0,"ef":[{"ty":21,"nm":"Fill","np":9,"mn":"ADBE Fill","ix":1,"en":1,"ef":[{"ty":10,"nm":"Fill Mask","mn":"ADBE Fill-0001","ix":1,"v":{"a":0,"k":0,"ix":1}},{"ty":7,"nm":"All Masks","mn":"ADBE Fill-0007","ix":2,"v":{"a":0,"k":0,"ix":2}},{"ty":2,"nm":"Color","mn":"ADBE Fill-0002","ix":3,"v":{"a":0,"k":[1,0.705882370472,0,1],"ix":3}},{"ty":7,"nm":"Invert","mn":"ADBE Fill-0006","ix":4,"v":{"a":0,"k":0,"ix":4}},{"ty":0,"nm":"Horizontal Feather","mn":"ADBE Fill-0003","ix":5,"v":{"a":0,"k":0,"ix":5}},{"ty":0,"nm":"Vertical Feather","mn":"ADBE Fill-0004","ix":6,"v":{"a":0,"k":0,"ix":6}},{"ty":0,"nm":"Opacity","mn":"ADBE Fill-0005","ix":7,"v":{"a":0,"k":1,"ix":7}}]}],"w":100,"h":100,"ip":12,"op":88,"st":12,"bm":0},{"ddd":0,"ind":10,"ty":0,"nm":"triangleComp01","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":0,"s":[2004,1152,0],"to":[0,0,0],"ti":[98.667,749.333,0]},{"t":10,"s":[1060,-120,0]}],"ix":2},"a":{"a":0,"k":[50,50,0],"ix":1},"s":{"a":0,"k":[80,80,100],"ix":6}},"ao":0,"ef":[{"ty":21,"nm":"Fill","np":9,"mn":"ADBE Fill","ix":1,"en":1,"ef":[{"ty":10,"nm":"Fill Mask","mn":"ADBE Fill-0001","ix":1,"v":{"a":0,"k":0,"ix":1}},{"ty":7,"nm":"All Masks","mn":"ADBE Fill-0007","ix":2,"v":{"a":0,"k":0,"ix":2}},{"ty":2,"nm":"Color","mn":"ADBE Fill-0002","ix":3,"v":{"a":0,"k":[0.098039224744,0.694117665291,1,1],"ix":3}},{"ty":7,"nm":"Invert","mn":"ADBE Fill-0006","ix":4,"v":{"a":0,"k":0,"ix":4}},{"ty":0,"nm":"Horizontal Feather","mn":"ADBE Fill-0003","ix":5,"v":{"a":0,"k":0,"ix":5}},{"ty":0,"nm":"Vertical Feather","mn":"ADBE Fill-0004","ix":6,"v":{"a":0,"k":0,"ix":6}},{"ty":0,"nm":"Opacity","mn":"ADBE Fill-0005","ix":7,"v":{"a":0,"k":1,"ix":7}}]}],"w":100,"h":100,"ip":0,"op":11,"st":0,"bm":0},{"ddd":0,"ind":11,"ty":0,"nm":"triangleComp01","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.17,"y":0.756},"o":{"x":0.167,"y":0.167},"t":13,"s":[1930,1130,0],"to":[-52.667,-198,0],"ti":[140,-6.333,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.72,"y":0.386},"t":38,"s":[1308,398,0],"to":[-140,6.333,0],"ti":[13.333,-434.667,0]},{"t":77,"s":[1090,1168,0]}],"ix":2},"a":{"a":0,"k":[50,50,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,2.083]},"t":13,"s":[50,50,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.695,0.695,0.333],"y":[0,0,0]},"t":38,"s":[75,75,100]},{"t":88,"s":[30,30,100]}],"ix":6}},"ao":0,"ef":[{"ty":21,"nm":"Fill","np":9,"mn":"ADBE Fill","ix":1,"en":1,"ef":[{"ty":10,"nm":"Fill Mask","mn":"ADBE Fill-0001","ix":1,"v":{"a":0,"k":0,"ix":1}},{"ty":7,"nm":"All Masks","mn":"ADBE Fill-0007","ix":2,"v":{"a":0,"k":0,"ix":2}},{"ty":2,"nm":"Color","mn":"ADBE Fill-0002","ix":3,"v":{"a":0,"k":[0.490196108818,0.231372565031,0.752941250801,1],"ix":3}},{"ty":7,"nm":"Invert","mn":"ADBE Fill-0006","ix":4,"v":{"a":0,"k":0,"ix":4}},{"ty":0,"nm":"Horizontal Feather","mn":"ADBE Fill-0003","ix":5,"v":{"a":0,"k":0,"ix":5}},{"ty":0,"nm":"Vertical Feather","mn":"ADBE Fill-0004","ix":6,"v":{"a":0,"k":0,"ix":6}},{"ty":0,"nm":"Opacity","mn":"ADBE Fill-0005","ix":7,"v":{"a":0,"k":1,"ix":7}}]}],"w":100,"h":100,"ip":13,"op":89,"st":13,"bm":0},{"ddd":0,"ind":12,"ty":0,"nm":"triangleComp01","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.17,"y":0.819},"o":{"x":0.167,"y":0.167},"t":3,"s":[1924,1148,0],"to":[-72.667,-550,0],"ti":[137.333,-0.667,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.72,"y":0.293},"t":28,"s":[1216,80,0],"to":[-137.333,0.667,0],"ti":[-12.667,-380.667,0]},{"t":67,"s":[1100,1152,0]}],"ix":2},"a":{"a":0,"k":[50,50,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.833]},"t":3,"s":[60,60,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.695,0.695,0.333],"y":[0,0,0]},"t":28,"s":[80,80,100]},{"t":78,"s":[20,20,100]}],"ix":6}},"ao":0,"ef":[{"ty":21,"nm":"Fill","np":9,"mn":"ADBE Fill","ix":1,"en":1,"ef":[{"ty":10,"nm":"Fill Mask","mn":"ADBE Fill-0001","ix":1,"v":{"a":0,"k":0,"ix":1}},{"ty":7,"nm":"All Masks","mn":"ADBE Fill-0007","ix":2,"v":{"a":0,"k":0,"ix":2}},{"ty":2,"nm":"Color","mn":"ADBE Fill-0002","ix":3,"v":{"a":0,"k":[1,0.282352954149,0.282352954149,1],"ix":3}},{"ty":7,"nm":"Invert","mn":"ADBE Fill-0006","ix":4,"v":{"a":0,"k":0,"ix":4}},{"ty":0,"nm":"Horizontal Feather","mn":"ADBE Fill-0003","ix":5,"v":{"a":0,"k":0,"ix":5}},{"ty":0,"nm":"Vertical Feather","mn":"ADBE Fill-0004","ix":6,"v":{"a":0,"k":0,"ix":6}},{"ty":0,"nm":"Opacity","mn":"ADBE Fill-0005","ix":7,"v":{"a":0,"k":1,"ix":7}}]}],"w":100,"h":100,"ip":3,"op":79,"st":3,"bm":0},{"ddd":0,"ind":13,"ty":4,"nm":"redCircle01","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":2,"s":[1920,1124,0],"to":[-156.667,-332.667,0],"ti":[0,0,0]},{"t":14,"s":[1652,-32,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.282352941176,0.282352941176,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":2,"op":15,"st":2,"bm":0},{"ddd":0,"ind":14,"ty":4,"nm":"blueCircle02","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.17,"y":0.783},"o":{"x":0.167,"y":0.167},"t":6,"s":[1840,1196,0],"to":[-77.333,-182,0],"ti":[133.798,40.438,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.72,"y":0.359},"t":20,"s":[1410,770,0],"to":[-137.2,-41.466,0],"ti":[19.333,-100.667,0]},{"t":41,"s":[1208,1168,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,1.667]},"t":6,"s":[20,20,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.695,0.695,0.333],"y":[0,0,0]},"t":20,"s":[50,50,100]},{"t":41,"s":[30,30,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.098039223166,0.694117647059,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":6,"op":42,"st":6,"bm":0},{"ddd":0,"ind":15,"ty":4,"nm":"redCircle03","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.17,"y":0.782},"o":{"x":0.167,"y":0.167},"t":15,"s":[1940,1168,0],"to":[-23.667,-194.667,0],"ti":[128.339,4.912,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.72,"y":0.29},"t":36,"s":[1606,328,0],"to":[-69.797,-2.671,0],"ti":[19.333,-210.667,0]},{"t":67,"s":[1410,1186,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.794]},"t":15,"s":[20,20,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.695,0.695,0.333],"y":[0,0,0]},"t":36,"s":[50,50,100]},{"t":67,"s":[20,20,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.282352941176,0.282352941176,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":15,"op":68,"st":15,"bm":0},{"ddd":0,"ind":16,"ty":4,"nm":"yellowCircle02","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.17,"y":0.674},"o":{"x":0.167,"y":0.167},"t":11,"s":[1904,1140,0],"to":[3.333,-94,0],"ti":[163,8,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.72,"y":0.443},"t":32,"s":[1660,608,0],"to":[-71.027,-3.486,0],"ti":[-12.667,-150.667,0]},{"t":63,"s":[1530,1160,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,1.667]},"t":11,"s":[30,30,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.695,0.695,0.333],"y":[0,0,0]},"t":32,"s":[70,70,100]},{"t":63,"s":[50,50,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.490196108351,0.231372563979,0.752941236309,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":11,"op":64,"st":11,"bm":0},{"ddd":0,"ind":17,"ty":4,"nm":"purpleCircle03","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.17,"y":0.77},"o":{"x":0.167,"y":0.167},"t":6,"s":[1908,1116,0],"to":[-9.333,-174,0],"ti":[154.699,0.945,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.72,"y":0.302},"t":31,"s":[1588,152,0],"to":[-126.052,-0.77,0],"ti":[3.333,-368.667,0]},{"t":70,"s":[1392,1184,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,8.333]},"t":6,"s":[50,50,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.695,0.695,0.333],"y":[0,0,0]},"t":31,"s":[100,100,100]},{"t":81,"s":[50,50,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[20,20],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.705882352941,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":6,"op":71,"st":6,"bm":0},{"ddd":0,"ind":18,"ty":4,"nm":"blueCircle01","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.17,"y":0.796},"o":{"x":0.167,"y":0.167},"t":0,"s":[1920,1160,0],"to":[6.667,-266,0],"ti":[131.904,-46.241,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.72,"y":0.326},"t":25,"s":[1300,212,0],"to":[-201.2,70.534,0],"ti":[-4.667,-388.667,0]},{"t":64,"s":[1144,1156,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,1.667]},"t":0,"s":[20,20,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.695,0.695,0.333],"y":[0,0,0]},"t":25,"s":[50,50,100]},{"t":64,"s":[30,30,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.098039223166,0.694117647059,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":65,"st":0,"bm":0},{"ddd":0,"ind":19,"ty":4,"nm":"redCircle02","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.17,"y":0.79},"o":{"x":0.167,"y":0.167},"t":7,"s":[2072,1216,0],"to":[-99,-124.667,0],"ti":[111.667,12.667,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.72,"y":0.368},"t":28,"s":[1478,468,0],"to":[-111.667,-12.667,0],"ti":[-0.667,-122.667,0]},{"t":59,"s":[1402,1140,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.794]},"t":7,"s":[20,20,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.695,0.695,0.333],"y":[0,0,0]},"t":28,"s":[50,50,100]},{"t":59,"s":[20,20,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.282352941176,0.282352941176,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":7,"op":72,"st":7,"bm":0},{"ddd":0,"ind":20,"ty":4,"nm":"yellowCircle01","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.17,"y":0.716},"o":{"x":0.167,"y":0.167},"t":3,"s":[1972,1216,0],"to":[-72,-110.667,0],"ti":[136.12,-24.434,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.72,"y":0.457},"t":21.8,"s":[1540,552,0],"to":[-163.485,29.346,0],"ti":[-20.667,-116.667,0]},{"t":50,"s":[1336,1140,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,1.667]},"t":3,"s":[30,30,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.695,0.695,0.333],"y":[0,0,0]},"t":21.8,"s":[70,70,100]},{"t":50,"s":[50,50,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.705882352941,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":3,"op":51,"st":3,"bm":0},{"ddd":0,"ind":21,"ty":4,"nm":"purpleCircle01","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.17,"y":0.777},"o":{"x":0.167,"y":0.167},"t":0,"s":[1640,1140,0],"to":[-32.667,-164,0],"ti":[117.335,-17.843,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.72,"y":0.342},"t":25,"s":[1104,228,0],"to":[-144.667,22,0],"ti":[-24.667,-180.667,0]},{"t":64,"s":[1032,1148,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,-5.556]},"t":0,"s":[80,80,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.695,0.695,0.333],"y":[0,0,0]},"t":25,"s":[100,100,100]},{"t":75,"s":[50,50,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[15,15],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.490196078431,0.23137254902,0.752941176471,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":65,"st":0,"bm":0}]},{"id":"comp_2","layers":[{"ddd":1,"ind":1,"ty":4,"nm":"triangle","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"rx":{"a":0,"k":0,"ix":8,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 150);"},"ry":{"a":0,"k":0,"ix":9,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 50);"},"rz":{"a":0,"k":0,"ix":10,"x":"var $bm_rt;\n$bm_rt = $bm_mul(time, 10);"},"or":{"a":0,"k":[0,0,0],"ix":7},"p":{"a":0,"k":[50,57,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[80,80,80],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sr","sy":2,"d":1,"pt":{"a":0,"k":3,"ix":3},"p":{"a":0,"k":[0,0],"ix":4},"r":{"a":0,"k":0,"ix":5},"or":{"a":0,"k":30,"ix":7},"os":{"a":0,"k":0,"ix":9},"ix":1,"nm":"Polystar Path 1","mn":"ADBE Vector Shape - Star","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Polystar 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":375,"st":0,"bm":0}]},{"id":"comp_3","layers":[{"ddd":0,"ind":1,"ty":4,"nm":"blueCircle03","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":40,"s":[1773,-59,0],"to":[-116,296,0],"ti":[168,-388,0]},{"t":101,"s":[1733,1213,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[45.849,45.849,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.098039223166,0.694117647059,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":40,"op":101,"st":40,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"redCircle04","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":35,"s":[1452,-305,0],"to":[244,436,0],"ti":[-304,-272,0]},{"t":101,"s":[1520,1211,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[48.744,48.744,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.282352941176,0.282352941176,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":35,"op":101,"st":35,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"yellowCircle03","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":31,"s":[920,-110,0],"to":[-200,428,0],"ti":[244,-308,0]},{"t":101,"s":[920,1210,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[68.326,68.326,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.705882352941,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":31,"op":101,"st":31,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"purpleCircle04","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":26,"s":[1246,-55,0],"to":[184,256,0],"ti":[-344,-344,0]},{"t":76,"s":[1374,1253,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[48,48,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.490196078431,0.23137254902,0.752941176471,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":26,"op":77,"st":26,"bm":0},{"ddd":0,"ind":5,"ty":0,"nm":"triangleComp01","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":27,"s":[1654,-58,0],"to":[-124,373.333,0],"ti":[168,-557.333,0]},{"t":99,"s":[1654,1222,0]}],"ix":2},"a":{"a":0,"k":[50,50,0],"ix":1},"s":{"a":0,"k":[60,60,100],"ix":6}},"ao":0,"ef":[{"ty":21,"nm":"Fill","np":9,"mn":"ADBE Fill","ix":1,"en":1,"ef":[{"ty":10,"nm":"Fill Mask","mn":"ADBE Fill-0001","ix":1,"v":{"a":0,"k":0,"ix":1}},{"ty":7,"nm":"All Masks","mn":"ADBE Fill-0007","ix":2,"v":{"a":0,"k":0,"ix":2}},{"ty":2,"nm":"Color","mn":"ADBE Fill-0002","ix":3,"v":{"a":0,"k":[1,0.705882370472,0,1],"ix":3}},{"ty":7,"nm":"Invert","mn":"ADBE Fill-0006","ix":4,"v":{"a":0,"k":0,"ix":4}},{"ty":0,"nm":"Horizontal Feather","mn":"ADBE Fill-0003","ix":5,"v":{"a":0,"k":0,"ix":5}},{"ty":0,"nm":"Vertical Feather","mn":"ADBE Fill-0004","ix":6,"v":{"a":0,"k":0,"ix":6}},{"ty":0,"nm":"Opacity","mn":"ADBE Fill-0005","ix":7,"v":{"a":0,"k":1,"ix":7}}]}],"w":100,"h":100,"ip":27,"op":115,"st":27,"bm":0},{"ddd":0,"ind":6,"ty":0,"nm":"triangleComp01","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":23,"s":[1414,-58,0],"to":[248,409.333,0],"ti":[-236,-493.333,0]},{"t":100,"s":[1414,1222,0]}],"ix":2},"a":{"a":0,"k":[50,50,0],"ix":1},"s":{"a":0,"k":[60,60,100],"ix":6}},"ao":0,"ef":[{"ty":21,"nm":"Fill","np":9,"mn":"ADBE Fill","ix":1,"en":1,"ef":[{"ty":10,"nm":"Fill Mask","mn":"ADBE Fill-0001","ix":1,"v":{"a":0,"k":0,"ix":1}},{"ty":7,"nm":"All Masks","mn":"ADBE Fill-0007","ix":2,"v":{"a":0,"k":0,"ix":2}},{"ty":2,"nm":"Color","mn":"ADBE Fill-0002","ix":3,"v":{"a":0,"k":[0.490196108818,0.231372565031,0.752941250801,1],"ix":3}},{"ty":7,"nm":"Invert","mn":"ADBE Fill-0006","ix":4,"v":{"a":0,"k":0,"ix":4}},{"ty":0,"nm":"Horizontal Feather","mn":"ADBE Fill-0003","ix":5,"v":{"a":0,"k":0,"ix":5}},{"ty":0,"nm":"Vertical Feather","mn":"ADBE Fill-0004","ix":6,"v":{"a":0,"k":0,"ix":6}},{"ty":0,"nm":"Opacity","mn":"ADBE Fill-0005","ix":7,"v":{"a":0,"k":1,"ix":7}}]}],"w":100,"h":100,"ip":23,"op":111,"st":23,"bm":0},{"ddd":0,"ind":7,"ty":0,"nm":"triangleComp01","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":13,"s":[1988,1208,0],"to":[0,0,0],"ti":[95.333,242.667,0]},{"t":23,"s":[1416,-248,0]}],"ix":2},"a":{"a":0,"k":[50,50,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,-10]},"t":13,"s":[80,80,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.695,0.695,0.333],"y":[0,0,0]},"t":38,"s":[100,100,100]},{"t":88,"s":[50,50,100]}],"ix":6}},"ao":0,"ef":[{"ty":21,"nm":"Fill","np":9,"mn":"ADBE Fill","ix":1,"en":1,"ef":[{"ty":10,"nm":"Fill Mask","mn":"ADBE Fill-0001","ix":1,"v":{"a":0,"k":0,"ix":1}},{"ty":7,"nm":"All Masks","mn":"ADBE Fill-0007","ix":2,"v":{"a":0,"k":0,"ix":2}},{"ty":2,"nm":"Color","mn":"ADBE Fill-0002","ix":3,"v":{"a":0,"k":[0.098039224744,0.694117665291,1,1],"ix":3}},{"ty":7,"nm":"Invert","mn":"ADBE Fill-0006","ix":4,"v":{"a":0,"k":0,"ix":4}},{"ty":0,"nm":"Horizontal Feather","mn":"ADBE Fill-0003","ix":5,"v":{"a":0,"k":0,"ix":5}},{"ty":0,"nm":"Vertical Feather","mn":"ADBE Fill-0004","ix":6,"v":{"a":0,"k":0,"ix":6}},{"ty":0,"nm":"Opacity","mn":"ADBE Fill-0005","ix":7,"v":{"a":0,"k":1,"ix":7}}]}],"w":100,"h":100,"ip":13,"op":101,"st":13,"bm":0},{"ddd":0,"ind":8,"ty":0,"nm":"triangleComp01","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.17,"y":0.81},"o":{"x":0.167,"y":0.167},"t":26,"s":[1930,1130,0],"to":[-52.667,-198,0],"ti":[171.333,-3,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.72,"y":0.262},"t":51,"s":[1360,32,0],"to":[-171.333,3,0],"ti":[61.333,-430.667,0]},{"t":90,"s":[902,1148,0]}],"ix":2},"a":{"a":0,"k":[50,50,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,1.111]},"t":26,"s":[30,30,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.695,0.695,0.333],"y":[0,0,0]},"t":51,"s":[50,50,100]},{"t":101,"s":[15,15,100]}],"ix":6}},"ao":0,"ef":[{"ty":21,"nm":"Fill","np":9,"mn":"ADBE Fill","ix":1,"en":1,"ef":[{"ty":10,"nm":"Fill Mask","mn":"ADBE Fill-0001","ix":1,"v":{"a":0,"k":0,"ix":1}},{"ty":7,"nm":"All Masks","mn":"ADBE Fill-0007","ix":2,"v":{"a":0,"k":0,"ix":2}},{"ty":2,"nm":"Color","mn":"ADBE Fill-0002","ix":3,"v":{"a":0,"k":[0.098039224744,0.694117665291,1,1],"ix":3}},{"ty":7,"nm":"Invert","mn":"ADBE Fill-0006","ix":4,"v":{"a":0,"k":0,"ix":4}},{"ty":0,"nm":"Horizontal Feather","mn":"ADBE Fill-0003","ix":5,"v":{"a":0,"k":0,"ix":5}},{"ty":0,"nm":"Vertical Feather","mn":"ADBE Fill-0004","ix":6,"v":{"a":0,"k":0,"ix":6}},{"ty":0,"nm":"Opacity","mn":"ADBE Fill-0005","ix":7,"v":{"a":0,"k":1,"ix":7}}]}],"w":100,"h":100,"ip":26,"op":101,"st":26,"bm":0},{"ddd":0,"ind":9,"ty":0,"nm":"triangleComp01","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.17,"y":0.801},"o":{"x":0.167,"y":0.167},"t":16,"s":[1940,1124,0],"to":[-108.333,-166,0],"ti":[149.333,-4,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.72,"y":0.297},"t":41,"s":[1290,128,0],"to":[-149.333,4,0],"ti":[-48.667,-412.667,0]},{"t":80,"s":[1044,1148,0]}],"ix":2},"a":{"a":0,"k":[50,50,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,-10]},"t":16,"s":[80,80,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.695,0.695,0.333],"y":[0,0,0]},"t":41,"s":[100,100,100]},{"t":91,"s":[50,50,100]}],"ix":6}},"ao":0,"ef":[{"ty":21,"nm":"Fill","np":9,"mn":"ADBE Fill","ix":1,"en":1,"ef":[{"ty":10,"nm":"Fill Mask","mn":"ADBE Fill-0001","ix":1,"v":{"a":0,"k":0,"ix":1}},{"ty":7,"nm":"All Masks","mn":"ADBE Fill-0007","ix":2,"v":{"a":0,"k":0,"ix":2}},{"ty":2,"nm":"Color","mn":"ADBE Fill-0002","ix":3,"v":{"a":0,"k":[1,0.705882370472,0,1],"ix":3}},{"ty":7,"nm":"Invert","mn":"ADBE Fill-0006","ix":4,"v":{"a":0,"k":0,"ix":4}},{"ty":0,"nm":"Horizontal Feather","mn":"ADBE Fill-0003","ix":5,"v":{"a":0,"k":0,"ix":5}},{"ty":0,"nm":"Vertical Feather","mn":"ADBE Fill-0004","ix":6,"v":{"a":0,"k":0,"ix":6}},{"ty":0,"nm":"Opacity","mn":"ADBE Fill-0005","ix":7,"v":{"a":0,"k":1,"ix":7}}]}],"w":100,"h":100,"ip":16,"op":101,"st":16,"bm":0},{"ddd":0,"ind":10,"ty":0,"nm":"triangleComp01","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":0,"s":[1988,1208,0],"to":[0,0,0],"ti":[154.667,221.333,0]},{"t":10,"s":[1060,-120,0]}],"ix":2},"a":{"a":0,"k":[50,50,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,-10]},"t":0,"s":[80,80,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.695,0.695,0.333],"y":[0,0,0]},"t":25,"s":[100,100,100]},{"t":75,"s":[50,50,100]}],"ix":6}},"ao":0,"ef":[{"ty":21,"nm":"Fill","np":9,"mn":"ADBE Fill","ix":1,"en":1,"ef":[{"ty":10,"nm":"Fill Mask","mn":"ADBE Fill-0001","ix":1,"v":{"a":0,"k":0,"ix":1}},{"ty":7,"nm":"All Masks","mn":"ADBE Fill-0007","ix":2,"v":{"a":0,"k":0,"ix":2}},{"ty":2,"nm":"Color","mn":"ADBE Fill-0002","ix":3,"v":{"a":0,"k":[0.098039224744,0.694117665291,1,1],"ix":3}},{"ty":7,"nm":"Invert","mn":"ADBE Fill-0006","ix":4,"v":{"a":0,"k":0,"ix":4}},{"ty":0,"nm":"Horizontal Feather","mn":"ADBE Fill-0003","ix":5,"v":{"a":0,"k":0,"ix":5}},{"ty":0,"nm":"Vertical Feather","mn":"ADBE Fill-0004","ix":6,"v":{"a":0,"k":0,"ix":6}},{"ty":0,"nm":"Opacity","mn":"ADBE Fill-0005","ix":7,"v":{"a":0,"k":1,"ix":7}}]}],"w":100,"h":100,"ip":0,"op":101,"st":0,"bm":0},{"ddd":0,"ind":11,"ty":0,"nm":"triangleComp01","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.17,"y":0.754},"o":{"x":0.167,"y":0.167},"t":13,"s":[1930,1130,0],"to":[-52.667,-198,0],"ti":[171.333,-3,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.72,"y":0.328},"t":38,"s":[1396,346,0],"to":[-171.333,3,0],"ti":[61.333,-430.667,0]},{"t":77,"s":[902,1148,0]}],"ix":2},"a":{"a":0,"k":[50,50,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,2.083]},"t":13,"s":[50,50,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.695,0.695,0.333],"y":[0,0,0]},"t":38,"s":[75,75,100]},{"t":88,"s":[30,30,100]}],"ix":6}},"ao":0,"ef":[{"ty":21,"nm":"Fill","np":9,"mn":"ADBE Fill","ix":1,"en":1,"ef":[{"ty":10,"nm":"Fill Mask","mn":"ADBE Fill-0001","ix":1,"v":{"a":0,"k":0,"ix":1}},{"ty":7,"nm":"All Masks","mn":"ADBE Fill-0007","ix":2,"v":{"a":0,"k":0,"ix":2}},{"ty":2,"nm":"Color","mn":"ADBE Fill-0002","ix":3,"v":{"a":0,"k":[0.490196108818,0.231372565031,0.752941250801,1],"ix":3}},{"ty":7,"nm":"Invert","mn":"ADBE Fill-0006","ix":4,"v":{"a":0,"k":0,"ix":4}},{"ty":0,"nm":"Horizontal Feather","mn":"ADBE Fill-0003","ix":5,"v":{"a":0,"k":0,"ix":5}},{"ty":0,"nm":"Vertical Feather","mn":"ADBE Fill-0004","ix":6,"v":{"a":0,"k":0,"ix":6}},{"ty":0,"nm":"Opacity","mn":"ADBE Fill-0005","ix":7,"v":{"a":0,"k":1,"ix":7}}]}],"w":100,"h":100,"ip":13,"op":101,"st":13,"bm":0},{"ddd":0,"ind":12,"ty":0,"nm":"triangleComp01","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.17,"y":0.816},"o":{"x":0.167,"y":0.167},"t":3,"s":[1940,1124,0],"to":[-120.667,-178,0],"ti":[149.333,-4,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.72,"y":0.284},"t":28,"s":[1216,56,0],"to":[-149.333,4,0],"ti":[-48.667,-412.667,0]},{"t":67,"s":[1044,1148,0]}],"ix":2},"a":{"a":0,"k":[50,50,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.833]},"t":3,"s":[60,60,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.695,0.695,0.333],"y":[0,0,0]},"t":28,"s":[80,80,100]},{"t":78,"s":[20,20,100]}],"ix":6}},"ao":0,"ef":[{"ty":21,"nm":"Fill","np":9,"mn":"ADBE Fill","ix":1,"en":1,"ef":[{"ty":10,"nm":"Fill Mask","mn":"ADBE Fill-0001","ix":1,"v":{"a":0,"k":0,"ix":1}},{"ty":7,"nm":"All Masks","mn":"ADBE Fill-0007","ix":2,"v":{"a":0,"k":0,"ix":2}},{"ty":2,"nm":"Color","mn":"ADBE Fill-0002","ix":3,"v":{"a":0,"k":[1,0.282352954149,0.282352954149,1],"ix":3}},{"ty":7,"nm":"Invert","mn":"ADBE Fill-0006","ix":4,"v":{"a":0,"k":0,"ix":4}},{"ty":0,"nm":"Horizontal Feather","mn":"ADBE Fill-0003","ix":5,"v":{"a":0,"k":0,"ix":5}},{"ty":0,"nm":"Vertical Feather","mn":"ADBE Fill-0004","ix":6,"v":{"a":0,"k":0,"ix":6}},{"ty":0,"nm":"Opacity","mn":"ADBE Fill-0005","ix":7,"v":{"a":0,"k":1,"ix":7}}]}],"w":100,"h":100,"ip":3,"op":79,"st":3,"bm":0},{"ddd":0,"ind":13,"ty":4,"nm":"redCircle01","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":1,"s":[1920,1124,0],"to":[-121.333,-216.667,0],"ti":[0,0,0]},{"t":13,"s":[1192,-176,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.282352941176,0.282352941176,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":1,"op":14,"st":1,"bm":0},{"ddd":0,"ind":14,"ty":4,"nm":"blueCircle02","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.17,"y":0.779},"o":{"x":0.167,"y":0.167},"t":6,"s":[1920,1160,0],"to":[6.667,-266,0],"ti":[131.904,-46.241,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.72,"y":0.326},"t":31,"s":[1434,234,0],"to":[-201.2,70.534,0],"ti":[39.333,-396.667,0]},{"t":70,"s":[1208,1168,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,1.667]},"t":6,"s":[20,20,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.695,0.695,0.333],"y":[0,0,0]},"t":31,"s":[50,50,100]},{"t":70,"s":[30,30,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.098039223166,0.694117647059,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":6,"op":71,"st":6,"bm":0},{"ddd":0,"ind":15,"ty":4,"nm":"redCircle03","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.17,"y":0.761},"o":{"x":0.167,"y":0.167},"t":15,"s":[1992,1216,0],"to":[-89,-107.333,0],"ti":[106.333,12.333,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.72,"y":0.427},"t":36,"s":[1458,572,0],"to":[-106.333,-12.333,0],"ti":[-0.667,-122.667,0]},{"t":67,"s":[1354,1142,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.794]},"t":15,"s":[20,20,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.695,0.695,0.333],"y":[0,0,0]},"t":36,"s":[50,50,100]},{"t":67,"s":[20,20,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.282352941176,0.282352941176,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":15,"op":80,"st":15,"bm":0},{"ddd":0,"ind":16,"ty":4,"nm":"yellowCircle02","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.17,"y":0.711},"o":{"x":0.167,"y":0.167},"t":11,"s":[1820,1152,0],"to":[-69.333,-91.333,0],"ti":[97,-0.667,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.72,"y":0.436},"t":32,"s":[1404,604,0],"to":[-97,0.667,0],"ti":[27.333,-98.667,0]},{"t":63,"s":[1238,1156,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,1.667]},"t":11,"s":[30,30,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.695,0.695,0.333],"y":[0,0,0]},"t":32,"s":[70,70,100]},{"t":63,"s":[50,50,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.490196108351,0.231372563979,0.752941236309,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":11,"op":76,"st":11,"bm":0},{"ddd":0,"ind":17,"ty":4,"nm":"purpleCircle03","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.17,"y":0.824},"o":{"x":0.167,"y":0.167},"t":6,"s":[1944,1104,0],"to":[-154,-164.667,0],"ti":[169.333,-8,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.72,"y":0.302},"t":31,"s":[1020,116,0],"to":[-169.333,8,0],"ti":[175.333,-340.667,0]},{"t":70,"s":[928,1152,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,8.333]},"t":6,"s":[50,50,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.695,0.695,0.333],"y":[0,0,0]},"t":31,"s":[100,100,100]},{"t":81,"s":[50,50,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[20,20],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.705882352941,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":6,"op":71,"st":6,"bm":0},{"ddd":0,"ind":18,"ty":4,"nm":"blueCircle01","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.17,"y":0.796},"o":{"x":0.167,"y":0.167},"t":0,"s":[1920,1160,0],"to":[6.667,-266,0],"ti":[131.904,-46.241,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.72,"y":0.326},"t":25,"s":[1300,212,0],"to":[-201.2,70.534,0],"ti":[-4.667,-388.667,0]},{"t":64,"s":[1144,1156,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,1.667]},"t":0,"s":[20,20,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.695,0.695,0.333],"y":[0,0,0]},"t":25,"s":[50,50,100]},{"t":64,"s":[30,30,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.098039223166,0.694117647059,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":65,"st":0,"bm":0},{"ddd":0,"ind":19,"ty":4,"nm":"redCircle02","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.17,"y":0.79},"o":{"x":0.167,"y":0.167},"t":9,"s":[1992,1216,0],"to":[-99,-124.667,0],"ti":[111.667,12.667,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.72,"y":0.368},"t":30,"s":[1398,468,0],"to":[-111.667,-12.667,0],"ti":[-0.667,-122.667,0]},{"t":61,"s":[1322,1140,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.794]},"t":9,"s":[20,20,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.695,0.695,0.333],"y":[0,0,0]},"t":30,"s":[50,50,100]},{"t":61,"s":[20,20,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.282352941176,0.282352941176,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":9,"op":74,"st":9,"bm":0},{"ddd":0,"ind":20,"ty":4,"nm":"yellowCircle01","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.17,"y":0.748},"o":{"x":0.167,"y":0.167},"t":5,"s":[1732,1216,0],"to":[-72,-110.667,0],"ti":[106,12.667,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.72,"y":0.398},"t":26,"s":[1300,552,0],"to":[-106,-12.667,0],"ti":[-20.667,-116.667,0]},{"t":57,"s":[1096,1140,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,1.667]},"t":5,"s":[30,30,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.695,0.695,0.333],"y":[0,0,0]},"t":26,"s":[70,70,100]},{"t":57,"s":[50,50,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.705882352941,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":5,"op":70,"st":5,"bm":0},{"ddd":0,"ind":21,"ty":4,"nm":"purpleCircle01","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.17,"y":0.824},"o":{"x":0.167,"y":0.167},"t":0,"s":[1944,1104,0],"to":[-154,-164.667,0],"ti":[169.333,-8,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.72,"y":0.302},"t":25,"s":[1020,116,0],"to":[-169.333,8,0],"ti":[175.333,-340.667,0]},{"t":64,"s":[928,1152,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,8.333]},"t":0,"s":[50,50,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.695,0.695,0.333],"y":[0,0,0]},"t":25,"s":[100,100,100]},{"t":75,"s":[50,50,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[15,15],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.490196078431,0.23137254902,0.752941176471,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":65,"st":0,"bm":0}]},{"id":"comp_4","layers":[{"ddd":0,"ind":1,"ty":4,"nm":"ribbonRed02","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":8.9,"ix":10},"p":{"a":0,"k":[655.204,920.201,0],"ix":2},"a":{"a":0,"k":[787.204,316.201,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[65.331,101.626],[52.275,60.987],[18,-36],[-30,-2],[100.811,137.103],[-12,136],[-54,80]],"o":[[0,0],[-54,-84],[-24,-28],[-26.593,53.186],[147.726,9.848],[-100,-136],[10.968,-124.305],[51.52,-76.326]],"v":[[736,520],[810,346],[830,138],[716,140],[780,226],[836,-92],[656,-334],[887.095,-676.42]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.156862745098,0.215686289469,0.470588265213,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":15,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.18],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":11,"s":[0]},{"t":44,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.18],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":4,"s":[0]},{"t":37,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":4,"op":45,"st":4,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"ribbonPurple02","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[960,540,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-7.674,145.812],[-56.367,71.74],[-43.907,167.129],[8,194],[-62,210]],"o":[[0,0],[8,-151.999],[110,-140.001],[62,-236],[-13.292,-322.34],[26.075,-88.318]],"v":[[524,624],[370,412],[544,236.001],[462,-40],[48,-256],[-174,-752]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.270588235294,0.909803981407,0.639215686275,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[3.615]},"t":10,"s":[20]},{"t":39,"s":[15]}],"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.18],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":10,"s":[0]},{"t":43,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.18],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":3,"s":[0]},{"t":36,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":3,"op":44,"st":3,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"ribbonRed01","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[960,540,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[65.331,101.626],[-26,76],[32,-58],[-148,4],[100.811,137.103],[16,156],[-92,4]],"o":[[0,0],[-54,-84],[36.079,-105.462],[-42.346,76.752],[148,-4],[-100,-136],[-16,-156],[92,-4]],"v":[[1006,496],[688,340],[704,96],[568,46],[738,200],[832,-158],[600,-414],[832,-664]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.282352911257,0.282352911257,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":15,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.18],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":9,"s":[0]},{"t":42,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.18],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":2,"s":[0]},{"t":35,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":2,"op":43,"st":2,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"ribbonBlue01","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[960,540,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-140,200],[-170.503,113.669],[-133.881,0],[41.726,121.7],[-224,52]],"o":[[0,0],[140,-200],[138,-92],[178,0],[-24,-70],[224,-52]],"v":[[-1072,420],[-688,316],[-484,-136],[18,-62],[178,-370],[308,-692]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.239215701234,0.823529471603,0.976470648074,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[2]},"t":12,"s":[25]},{"t":28,"s":[15]}],"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.18],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":8,"s":[0]},{"t":41,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.18],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":1,"s":[0]},{"t":34,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":1,"op":42,"st":1,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"ribbonPurple01","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[960,540,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-92,162],[-114.383,228.764],[-106,-10],[56.871,70.411],[-98,116],[-28.331,59.644],[-90,44],[182,50]],"o":[[112.375,-197.878],[56,-111.999],[155.323,14.654],[-42,-52],[79.909,-94.586],[38,-80],[92.11,-45.031],[-107.186,-29.447]],"v":[[-596,612],[-704,247.999],[-446,120],[-298,-16],[-430,-158],[-482,-330],[-246,-366],[-308,-652]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.488552078546,0.230311404957,0.752941176471,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":1,"k":[{"i":{"x":[0.17],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":8,"s":[35]},{"t":38,"s":[10]}],"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.18],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":8,"s":[0]},{"t":41,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.18],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":5,"s":[0]},{"t":38,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":42,"st":0,"bm":0},{"ddd":0,"ind":6,"ty":4,"nm":"ribbonYellow01","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[960,540,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-15.347,130.161],[-186.562,101.072],[112.01,0.14],[-68.075,42.572],[-51.603,62.412],[43.211,79.208],[219.412,-10.282]],"o":[[0,0],[15.347,-130.162],[180.162,-97.604],[-112.01,-0.142],[88.184,-55.147],[57.188,-69.166],[-43.211,-79.208],[-190.794,8.941]],"v":[[-996.479,553.827],[-814.281,359.159],[-768.857,64.753],[-717.951,160.649],[-716.405,-79.549],[-503.734,-159.43],[-491.038,-358.543],[-963.412,-597.718]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.706519751455,0,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":1,"k":[{"i":{"x":[0.17],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[25]},{"t":33,"s":[10]}],"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.18],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":7,"s":[0]},{"t":40,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.18],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[0]},{"t":33,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":41,"st":0,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":0,"nm":"frills","refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[960,540,0],"ix":2},"a":{"a":0,"k":[960,540,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":1920,"h":1080,"ip":16,"op":166,"st":16,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"blueCircle04","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":32,"s":[1186.352,-230.499,0],"to":[-144,723.333,0],"ti":[272,-583.333,0]},{"t":83,"s":[1186.352,1205.501,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[45.849,45.849,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.098039223166,0.694117647059,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":32,"op":83,"st":32,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"redCircle05","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":19,"s":[794.727,-24.984,0],"to":[224,403.333,0],"ti":[-284,-435.333,0]},{"t":76,"s":[794.727,1411.016,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[48.744,48.744,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.282352941176,0.282352941176,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":19,"op":77,"st":19,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"yellowCircle04","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":26,"s":[108.041,-39.458,0],"to":[-76,411.333,0],"ti":[184,-591.333,0]},{"t":71,"s":[108.041,1396.542,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[68.326,68.326,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.705882352941,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":26,"op":73,"st":26,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"purpleCircle05","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":32,"s":[448.767,-228.23,0],"to":[236,683.333,0],"ti":[-160,-439.333,0]},{"t":89,"s":[448.767,1207.77,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[48,48,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.490196078431,0.23137254902,0.752941176471,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":32,"op":90,"st":32,"bm":0},{"ddd":0,"ind":6,"ty":4,"nm":"blueCircle03","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":39,"s":[1430.352,-242.499,0],"to":[-144,723.333,0],"ti":[272,-583.333,0]},{"t":88,"s":[1430.352,1193.501,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[45.849,45.849,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.098039223166,0.694117647059,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":39,"op":90,"st":39,"bm":0},{"ddd":0,"ind":7,"ty":4,"nm":"redCircle04","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":16,"s":[1038.727,-36.984,0],"to":[224,403.333,0],"ti":[-284,-435.333,0]},{"t":73,"s":[1038.727,1399.016,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[48.744,48.744,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.282352941176,0.282352941176,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":16,"op":74,"st":16,"bm":0},{"ddd":0,"ind":8,"ty":4,"nm":"yellowCircle03","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":30,"s":[352.041,-51.458,0],"to":[-76,411.333,0],"ti":[184,-591.333,0]},{"t":87,"s":[352.041,1384.542,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[68.326,68.326,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.705882352941,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":30,"op":88,"st":30,"bm":0},{"ddd":0,"ind":9,"ty":4,"nm":"purpleCircle04","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0},"t":23,"s":[692.767,-240.23,0],"to":[236,683.333,0],"ti":[-160,-439.333,0]},{"t":80,"s":[692.767,1195.77,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[48,48,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.490196078431,0.23137254902,0.752941176471,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,-0.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":23,"op":81,"st":23,"bm":0},{"ddd":0,"ind":10,"ty":0,"nm":"Particles02","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[960,540,0],"ix":2},"a":{"a":0,"k":[960,540,0],"ix":1},"s":{"a":0,"k":[-100,100,100],"ix":6}},"ao":0,"w":1920,"h":1080,"ip":0,"op":101,"st":0,"bm":0},{"ddd":0,"ind":11,"ty":0,"nm":"Particles02","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[960,540,0],"ix":2},"a":{"a":0,"k":[960,540,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":1920,"h":1080,"ip":3,"op":104,"st":3,"bm":0},{"ddd":0,"ind":12,"ty":0,"nm":"Particles01","refId":"comp_3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[960,540,0],"ix":2},"a":{"a":0,"k":[960,540,0],"ix":1},"s":{"a":0,"k":[-100,100,100],"ix":6}},"ao":0,"w":1920,"h":1080,"ip":7,"op":108,"st":7,"bm":0},{"ddd":0,"ind":13,"ty":0,"nm":"Particles01","refId":"comp_3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[960,540,0],"ix":2},"a":{"a":0,"k":[960,540,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":1920,"h":1080,"ip":14,"op":115,"st":14,"bm":0},{"ddd":0,"ind":14,"ty":0,"nm":"ribbons","refId":"comp_4","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[960,540,0],"ix":2},"a":{"a":0,"k":[960,540,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":1920,"h":1080,"ip":0,"op":44,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/IngrediCheck/Resources/ingridecheck.riv b/IngrediCheck/Resources/ingridecheck.riv new file mode 100644 index 00000000..192ea762 Binary files /dev/null and b/IngrediCheck/Resources/ingridecheck.riv differ diff --git a/IngrediCheck/Store/AIMemojiService.swift b/IngrediCheck/Store/AIMemojiService.swift new file mode 100644 index 00000000..17def9fc --- /dev/null +++ b/IngrediCheck/Store/AIMemojiService.swift @@ -0,0 +1,220 @@ +// +// AIMemojiService.swift +// IngrediCheck +// +// Created by Gunjan Haldar on 06/11/25. +// + +import UIKit +import os +import PostHog + +/// Container for a generated memoji image and its storage location. +struct GeneratedMemoji { + /// The rendered memoji image as a transparent PNG. + let image: UIImage + /// The storage path inside the `memoji-images` bucket, e.g. `2025/01/.png`. + /// Used as `imageFileHash` when assigning avatars so we can load directly from Supabase + /// without re-uploading the PNG. + let storagePath: String +} + +enum AIMemojiError: LocalizedError { + case notAuthenticated + case invalidResponse(String) + case missingImage + + var errorDescription: String? { + switch self { + case .notAuthenticated: + return "Please sign in to generate a memoji." + case .invalidResponse(let message): + return "Memoji request failed: \(message)" + case .missingImage: + return "Memoji response did not include an image." + } + } +} + +/// Extract the internal storage path from a memoji public URL. +/// - Parameter urlString: Full public URL returned by the memoji API. +/// - Returns: Path inside the `memoji-images` bucket, e.g. `2025/01/.png`. +/// If the URL doesn't match the expected pattern (e.g. test mode), +/// falls back to returning the input string. +private func extractMemojiStoragePath(from urlString: String) -> String { + // Expected format: + // https://.supabase.co/storage/v1/object/public/memoji-images/2025/01/.png + if let range = urlString.range(of: "/memoji-images/") { + let path = urlString[range.upperBound...] + return String(path) + } + // Fallback for test URLs like test://memoji/.png or unexpected formats. + return urlString +} + +/// Calls the memoji edge function, returning both the rendered image and its +/// storage path inside the `memoji-images` bucket. +func generateMemojiImage(requestBody: MemojiRequest) async throws -> GeneratedMemoji { + let startTime = Date().timeIntervalSince1970 + + guard let token = try? await supabaseClient.auth.session.accessToken else { + throw AIMemojiError.notAuthenticated + } + + let bodyData = try JSONEncoder().encode(requestBody) + + // DEBUG: Print the JSON being sent to API + if let jsonString = String(data: bodyData, encoding: .utf8) { + Log.debug("Memoji API", "Request JSON: \(jsonString)") + + // Also pretty print for better readability + if let jsonObject = try? JSONSerialization.jsonObject(with: bodyData, options: []), + let prettyData = try? JSONSerialization.data(withJSONObject: jsonObject, options: [.prettyPrinted]), + let prettyString = String(data: prettyData, encoding: .utf8) { + Log.debug("Memoji API", "Request JSON (pretty):\n\(prettyString)") + } + } + + var request = SupabaseRequestBuilder(endpoint: .memoji) + .setAuthorization(with: token) + .setMethod(to: "POST") + .setJsonBody(to: bodyData) + .build() + + // Configure timeout to prevent connection loss + request.timeoutInterval = 60.0 // 60 seconds for memoji generation (longer than family API) + + Log.debug("AIMemojiService", "πŸ”΅ Sending memoji generation request...") + + // Retry logic for connection loss errors + var lastError: Error? + let maxRetries = 3 + var data: Data? + var response: URLResponse? + + for attempt in 1...maxRetries { + do { + Log.debug("AIMemojiService", "⏳ Request attempt \(attempt)/\(maxRetries)...") + let result = try await URLSession.shared.data(for: request) + data = result.0 + response = result.1 + lastError = nil // Clear error on success + break // Exit retry loop on success + } catch { + lastError = error + Log.error("AIMemojiService", "❌ Network error on attempt \(attempt): \(error.localizedDescription)") + + if let urlError = error as? URLError { + Log.error("AIMemojiService", "❌ URLError code: \(urlError.code.rawValue), description: \(urlError.localizedDescription)") + + // Retry on connection loss errors (-1005, -1001 timeout, -1009 no internet) + let retryableErrors: [URLError.Code] = [.networkConnectionLost, .timedOut, .notConnectedToInternet] + + if retryableErrors.contains(urlError.code) && attempt < maxRetries { + let delay = UInt64(1_000_000_000 * UInt64(attempt)) // 1s, 2s, 3s + Log.debug("AIMemojiService", "πŸ”„ Retrying after \(attempt) second(s)...") + try? await Task.sleep(nanoseconds: delay) + continue // Retry the request + } + } + + // If not retryable or max retries reached, throw the error + if attempt == maxRetries { + Log.error("AIMemojiService", "❌ Max retries reached, throwing error") + throw error + } + } + } + + // Ensure we have data and response + guard let responseData = data, let urlResponse = response else { + if let error = lastError { + throw error + } + Log.error("AIMemojiService", "❌ No data or response received") + throw AIMemojiError.invalidResponse("No response received.") + } + + guard let httpResponse = urlResponse as? HTTPURLResponse else { + Log.error("AIMemojiService", "❌ No HTTP Response received") + throw AIMemojiError.invalidResponse("No HTTP response.") + } + + Log.debug("AIMemojiService", "βœ… Response received - Status: \(httpResponse.statusCode), Body length: \(responseData.count) bytes") + + guard httpResponse.statusCode == 200 else { + let message = String(data: responseData, encoding: .utf8) ?? "status \(httpResponse.statusCode)" + Log.error("AIMemojiService", "❌ Request failed with message: \(message)") + + let latency = (Date().timeIntervalSince1970 - startTime) * 1000 + PostHogSDK.shared.capture("Memoji Image Generation Failed", properties: [ + "total_latency_ms": latency, + "status_code": httpResponse.statusCode + ]) + + // Try to parse error details from backend response + if let errorData = try? JSONDecoder().decode(MemojiErrorResponse.self, from: responseData) { + let errorMessage = errorData.error.message + let errorDetails = errorData.error.details ?? "" + Log.error("AIMemojiService", "❌ Backend error message: \(errorMessage)") + if !errorDetails.isEmpty { + Log.error("AIMemojiService", "❌ Backend error details: \(errorDetails)") + } + // Use the detailed error message if available + let fullMessage = errorDetails.isEmpty ? errorMessage : "\(errorMessage): \(errorDetails)" + throw AIMemojiError.invalidResponse(fullMessage) + } + + throw AIMemojiError.invalidResponse(message) + } + + let decoded: MemojiResponse + do { + decoded = try JSONDecoder().decode(MemojiResponse.self, from: responseData) + Log.debug("AIMemojiService", "βœ… Successfully decoded MemojiResponse") + } catch { + let rawDataString = String(data: responseData, encoding: .utf8) ?? "Unable to convert data to string" + Log.error("AIMemojiService", "❌ Decoding failed: \(error.localizedDescription)") + Log.error("AIMemojiService", "❌ Raw Data that failed to decode: \(rawDataString)") + throw error + } + + guard let urlString = decoded.imageUrl, let url = URL(string: urlString) else { + Log.error("AIMemojiService", "❌ decoded.imageUrl is NIL or invalid") + throw AIMemojiError.missingImage + } + + Log.debug("AIMemojiService", "ℹ️ Image URL: \(urlString)") + + // Download image with timeout configuration + var imageRequest = URLRequest(url: url) + imageRequest.timeoutInterval = 30.0 // 30 seconds for image download + Log.debug("AIMemojiService", "πŸ”΅ Downloading memoji image...") + + let (pngData, _) = try await URLSession.shared.data(for: imageRequest) + Log.debug("AIMemojiService", "βœ… Downloaded PNG Data size: \(pngData.count) bytes") + Log.debug("AIMemojiService", "generateMemojiImage: Before UIImage(data:) - Thread.isMainThread=\(Thread.isMainThread)") + // CRITICAL: UIImage(data:) must be called on main thread - UIImage operations are not thread-safe + let image = await MainActor.run { + let isMainThread = Thread.isMainThread + Log.debug("AIMemojiService", "generateMemojiImage: Inside MainActor.run - Thread.isMainThread=\(isMainThread)") + let img = UIImage(data: pngData) + Log.debug("AIMemojiService", "generateMemojiImage: UIImage(data:) created - image=\(img != nil ? ")βœ…" : "❌")") + return img + } + Log.debug("AIMemojiService", "generateMemojiImage: After MainActor.run - Thread.isMainThread=\(Thread.isMainThread)") + guard let image = image else { + throw AIMemojiError.missingImage + } + + let storagePath = extractMemojiStoragePath(from: urlString) + Log.debug("AIMemojiService", "generateMemojiImage: Using storagePath=\(storagePath)") + + let latency = (Date().timeIntervalSince1970 - startTime) * 1000 + PostHogSDK.shared.capture("Memoji Image Generated", properties: [ + "total_latency_ms": latency + ]) + + return GeneratedMemoji(image: image, storagePath: storagePath) +} + diff --git a/IngrediCheck/Store/AnalyticsService.swift b/IngrediCheck/Store/AnalyticsService.swift index 5ca7620c..df6fcc83 100644 --- a/IngrediCheck/Store/AnalyticsService.swift +++ b/IngrediCheck/Store/AnalyticsService.swift @@ -41,14 +41,27 @@ final class AnalyticsService { } } - func refreshAnalyticsIdentity(session: Session, isInternalUser: Bool) { + func trackOnboarding(_ event: String, properties: [String: Any] = [:]) { + Task.detached { + PostHogSDK.shared.capture(event, properties: properties) + } + } + + func refreshAnalyticsIdentity(session: Session, isInternalUser: Bool, authProvider: String) { Task.detached { var properties: [String: Any] = [:] - + // Only add is_internal when it's true (from API responses) if isInternalUser { properties["is_internal"] = true } + + if let email = session.user.email { + properties["email"] = email + } + + properties["auth_provider"] = authProvider + let distinctId = session.user.id.uuidString PostHogSDK.shared.identify(distinctId, userProperties: properties) } diff --git a/IngrediCheck/Store/AppNavigationCoordinator.swift b/IngrediCheck/Store/AppNavigationCoordinator.swift new file mode 100644 index 00000000..b7a0a45a --- /dev/null +++ b/IngrediCheck/Store/AppNavigationCoordinator.swift @@ -0,0 +1,586 @@ +// +// AppNavigationCoordinator.swift +// IngrediCheckPreview +// +// Created on 13/11/25. +// + +import SwiftUI +import Observation + +/** + # AppNavigationCoordinator + + A single source of truth that keeps the large β€œcanvas” presentation + (Splash β†’ Onboarding β†’ Home) and the persistent bottom sheet in sync. + The coordinator is created once in `RootContainerView`, injected via + Swift Observation’s `.environment(_:)`, and every view reads or mutates + shared navigation state through that instance. + + ## Responsibilities + - Tracks the current canvas route (`currentCanvasRoute`). + - Resolves and exposes the matching bottom sheet (`currentBottomSheetRoute`). + - Stores the active onboarding flow so sheet sizing/content can react. + - Provides `setCanvasRoute`, `showCanvas`, `navigateInBottomSheet`, and + reset helpers, each wrapped in `withAnimation(.easeInOut)` to give the + default Apple transition between destinations. + + ## Usage + ```swift + struct RootContainerView: View { + @State private var coordinator = AppNavigationCoordinator() + + var body: some View { + @Bindable var coordinator = coordinator + + ZStack(alignment: .bottom) { + canvasContent(for: coordinator.currentCanvasRoute) + PersistentBottomSheet() + } + .environment(coordinator) // makes coordinator available via @Environment + } + } + + struct HeyThereScreen: View { + @Environment(AppNavigationCoordinator.self) private var coordinator + + var body: some View { + Button("Get Started") { + coordinator.showCanvas(.blankScreen) + } + } + } + ``` + */ +@Observable +@MainActor +class AppNavigationCoordinator { + private(set) var currentCanvasRoute: CanvasRoute + private(set) var currentBottomSheetRoute: BottomSheetRoute + private(set) var onboardingFlow: OnboardingFlowType = .individual + private var previousBottomSheetRoute: BottomSheetRoute? + // Track if family creation was initiated from Settings + var isCreatingFamilyFromSettings: Bool = false + + // Track if user just joined a family via invite code + var isJoiningViaInviteCode: Bool = false + + // Track if we're in "add preferences for member" flow + var isAddingPreferencesForMember: Bool = false + var addPreferencesForMemberId: UUID? = nil + var addPreferencesOriginIsSettings: Bool = false // True if started from Settings β†’ Manage Family + + // Global state for secondary edit sheet + var editingStepId: String? = nil + var isEditSheetPresented: Bool = false + var currentEditingSectionIndex: Int = 0 + var editingMemberId: UUID? = nil // Track which member is being edited + + // Global state for AI Bot sheet (post-login) + var isAIBotSheetPresented: Bool = false + + // MARK: - AIBot Context Properties + var aibotContextScanId: String? = nil + var aibotContextAnalysisId: String? = nil + var aibotContextIngredientName: String? = nil + var aibotContextFeedbackId: String? = nil + var aibotContextKeyOverride: String? = nil // Explicit context key (e.g., "food_notes") + + // MARK: - Feedback Prompt Bubble State + var showFeedbackPromptBubble: Bool = false + var pendingFeedbackId: String? = nil + + /// Optional callback invoked after navigation changes to sync state to Supabase + var onNavigationChange: (() async -> Void)? + + init(initialRoute: CanvasRoute = .heyThere) { + // PRIORITY: Check local persistence first. + // If the user has completed onboarding previously, FORCE start at .home + // regardless of what the caller passed (unless we want to support deep linking to specific onboarding steps, + // but for app launch .heyThere is usually passed). + if OnboardingPersistence.shared.isLocallyCompleted { + self.currentCanvasRoute = .home + self.onboardingFlow = .individual // Default, will be updated if needed or doesn't matter for Home + self.currentBottomSheetRoute = .homeDefault + } else { + self.currentCanvasRoute = initialRoute + if case .mainCanvas(let flow) = initialRoute { + self.onboardingFlow = flow + } + self.currentBottomSheetRoute = AppNavigationCoordinator.bottomSheetRoute(for: initialRoute) + } + } + + func setCanvasRoute(_ route: CanvasRoute) { + withAnimation(.easeInOut) { + currentCanvasRoute = route + + // Explicitly update onboardingFlow based on the route + switch route { + case .mainCanvas(let flow): + onboardingFlow = flow + case .letsMeetYourIngrediFam, .welcomeToYourFamily: + onboardingFlow = .family + case .dietaryPreferencesAndRestrictions(let isFamilyFlow): + onboardingFlow = isFamilyFlow ? .family : .individual + default: + break + } + + currentBottomSheetRoute = AppNavigationCoordinator.bottomSheetRoute(for: route) + } + + // Sync to Supabase after navigation change + Task { + await onNavigationChange?() + } + } + + func showCanvas(_ route: CanvasRoute) { + setCanvasRoute(route) + } + + // Navigate bottom sheet + func navigateInBottomSheet(_ route: BottomSheetRoute) { + withAnimation(.easeInOut) { + // When navigating back to the early onboarding sheets that live on the HeyThere canvas, + // ensure the canvas is reset to .heyThere so the correct background imagery shows. + // BUT: Only do this if onboarding is NOT completed, otherwise we'll reset users back to Get Started screen + switch route { + case .alreadyHaveAnAccount, .welcomeBack, .doYouHaveAnInviteCode, .enterInviteCode, .whosThisFor: + // Only reset to .heyThere if onboarding is not completed + if !OnboardingPersistence.shared.isLocallyCompleted && currentCanvasRoute != .heyThere { + currentCanvasRoute = .heyThere + } + default: + break + } + currentBottomSheetRoute = route + } + // Sync to Supabase after navigation change + Task { + await onNavigationChange?() + } + } + + func resetBottomSheet() { + withAnimation(.easeInOut) { + currentBottomSheetRoute = AppNavigationCoordinator.bottomSheetRoute(for: currentCanvasRoute) + } + } + + // MARK: - ChatBot Presentation + private var isChatRoute: Bool { + switch currentBottomSheetRoute { + case .chatIntro, .chatConversation: + return true + default: + return false + } + } + + func presentChatBot(startAtConversation: Bool = false) { + if !isChatRoute { + previousBottomSheetRoute = currentBottomSheetRoute + } + + withAnimation(.easeInOut) { + currentBottomSheetRoute = startAtConversation ? .chatConversation : .chatIntro + } + } + + func showChatConversation() { + withAnimation(.easeInOut) { + currentBottomSheetRoute = .chatConversation + } + } + + func dismissChatBot() { + withAnimation(.easeInOut) { + if let previous = previousBottomSheetRoute { + currentBottomSheetRoute = previous + } else { + currentBottomSheetRoute = AppNavigationCoordinator.bottomSheetRoute(for: currentCanvasRoute) + } + } + previousBottomSheetRoute = nil + } + + // MARK: - Global AI Bot Sheet (Post-Login) + + func showAIBotSheet() { + isAIBotSheetPresented = true + } + + func showAIBotSheetWithContext( + scanId: String? = nil, + analysisId: String? = nil, + ingredientName: String? = nil, + feedbackId: String? = nil, + contextKeyOverride: String? = nil + ) { + aibotContextScanId = scanId + aibotContextAnalysisId = analysisId + aibotContextIngredientName = ingredientName + aibotContextFeedbackId = feedbackId + aibotContextKeyOverride = contextKeyOverride + // Delay sheet presentation to ensure context properties are observed first + DispatchQueue.main.async { [weak self] in + self?.isAIBotSheetPresented = true + } + } + + func dismissAIBotSheet() { + isAIBotSheetPresented = false + aibotContextScanId = nil + aibotContextAnalysisId = nil + aibotContextIngredientName = nil + aibotContextFeedbackId = nil + aibotContextKeyOverride = nil + } + + // MARK: - Feedback Prompt Bubble + + func showFeedbackPrompt(feedbackId: String) { + pendingFeedbackId = feedbackId + withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) { + showFeedbackPromptBubble = true + } + } + + func dismissFeedbackPrompt(openChat: Bool = false) { + if openChat, let feedbackId = pendingFeedbackId { + showAIBotSheetWithContext(feedbackId: feedbackId) + } + withAnimation { + showFeedbackPromptBubble = false + } + pendingFeedbackId = nil + } + + // Get bottom sheet route for current canvas route + private static func bottomSheetRoute(for canvasRoute: CanvasRoute) -> BottomSheetRoute { + switch canvasRoute { + case .heyThere: + return .alreadyHaveAnAccount + case .blankScreen: + return .doYouHaveAnInviteCode + case .letsGetStarted: + return .whosThisFor + case .letsMeetYourIngrediFam: + return .letsMeetYourIngrediFam + case .dietaryPreferencesAndRestrictions(let isFamilyFlow): + return .dietaryPreferencesSheet(isFamilyFlow: isFamilyFlow) + case .welcomeToYourFamily: + return .allSetToJoinYourFamily + case .mainCanvas: + // Get first step ID from JSON dynamically + let steps = DynamicStepsProvider.loadSteps() + if let firstStepId = steps.first?.id { + return .onboardingStep(stepId: firstStepId) + } + // Fallback (should not happen if JSON is valid) + return .homeDefault + case .home: + return .homeDefault + case .summaryJustMe: + return .preferencesAddedSuccess + case .summaryAddFamily: + return .allSetToJoinYourFamily + case .readyToScanFirstProduct: + return .readyToScanFirstProduct + case .seeHowScanningWorks: + return .seeHowScanningWorks + case .whyWeNeedThesePermissions: + return .quickAccessNeeded + } + } + + // MARK: - Remote Onboarding Metadata Helpers + + /// Derives the high-level onboarding stage from current canvas route and bottom sheet + var remoteOnboardingStage: RemoteOnboardingStage { + // Check bottom sheet first for more specific stage detection + switch currentBottomSheetRoute { + case .whosThisFor: + // whosThisFor is part of choosing flow, even though canvas might be .heyThere + return .choosingFlow + case .letsMeetYourIngrediFam, .whatsYourName, .addMoreMembers, .addMoreMembersMinimal: + return .choosingFlow + default: + break + } + + // Fall back to canvas route-based stage + switch currentCanvasRoute { + case .heyThere, .blankScreen, .letsGetStarted: + return .preOnboarding + + case .letsMeetYourIngrediFam, .welcomeToYourFamily: + return .choosingFlow + + case .dietaryPreferencesAndRestrictions: + return .dietaryIntro + + case .mainCanvas: + // Check if we're on fineTuneYourExperience bottom sheet + if case .fineTuneYourExperience = currentBottomSheetRoute { + return .fineTune + } + return .dynamicOnboarding + + case .home, .summaryJustMe, .summaryAddFamily, .readyToScanFirstProduct, .seeHowScanningWorks, .whyWeNeedThesePermissions: + return .completed + } + } + + /// Extracts the current onboarding step ID from bottom sheet route, if available + var currentOnboardingStepId: String? { + if case .onboardingStep(let stepId) = currentBottomSheetRoute { + return stepId + } + return nil + } + + /// Converts current bottom sheet route to identifier + param for serialization + var bottomSheetRouteIdentifier: (identifier: BottomSheetRouteIdentifier, param: String?)? { + switch currentBottomSheetRoute { + case .alreadyHaveAnAccount: + return (.alreadyHaveAnAccount, nil) + case .welcomeBack: + return (.welcomeBack, nil) + case .doYouHaveAnInviteCode: + return (.doYouHaveAnInviteCode, nil) + case .enterInviteCode: + return (.enterInviteCode, nil) + case .whosThisFor: + return (.whosThisFor, nil) + case .letsMeetYourIngrediFam: + return (.letsMeetYourIngrediFam, nil) + case .whatsYourName: + return (.whatsYourName, nil) + case .addMoreMembers: + return (.addMoreMembers, nil) + case .addMoreMembersMinimal: + return (.addMoreMembersMinimal, nil) + case .editMember(let memberId, let isSelf): + return (.editMember, "\(memberId.uuidString)|\(isSelf)") + case .wouldYouLikeToInvite(let memberId, let name): + return (.wouldYouLikeToInvite, "\(memberId.uuidString)|\(name)") + case .addPreferencesForMember(let memberId, let name): + return (.addPreferencesForMember, "\(memberId.uuidString)|\(name)") + case .generateAvatar: + return (.generateAvatar, nil) + case .bringingYourAvatar: + return (.bringingYourAvatar, nil) + case .meetYourAvatar: + return (.meetYourAvatar, nil) + case .yourCurrentAvatar: + return (.yourCurrentAvatar, nil) + case .setUpAvatarFor: + return (.setUpAvatarFor, nil) + case .dietaryPreferencesSheet(let isFamilyFlow): + return (.dietaryPreferencesSheet, isFamilyFlow ? "true" : "false") + case .allSetToJoinYourFamily: + return (.allSetToJoinYourFamily, nil) + case .onboardingStep(let stepId): + return (.onboardingStep, stepId) + case .fineTuneYourExperience: + return (.fineTuneYourExperience, nil) + case .homeDefault: + return (.homeDefault, nil) + case .chatIntro: + return (.chatIntro, nil) + case .chatConversation: + return (.chatConversation, nil) + case .workingOnSummary: + return (.workingOnSummary, nil) + case .meetYourProfileIntro: + return (.meetYourProfileIntro, nil) + case .meetYourProfile(let memberId): + if let memberId = memberId { + return (.meetYourProfile, memberId.uuidString) + } + return (.meetYourProfile, nil) + case .preferencesAddedSuccess: + return (.preferencesAddedSuccess, nil) + case .readyToScanFirstProduct: + return (.readyToScanFirstProduct, nil) + case .seeHowScanningWorks: + return (.seeHowScanningWorks, nil) + case .quickAccessNeeded: + return (.quickAccessNeeded, nil) + case .loginToContinue: + return (.loginToContinue, nil) + case .updateAvatar(memberId: let memberId): + return (.updateAvatar, nil) + } + } + + /// Builds the metadata snapshot for persistence + func buildOnboardingMetadata() -> RemoteOnboardingMetadata { + let (routeId, routeParam) = bottomSheetRouteIdentifier ?? (nil, nil) + return RemoteOnboardingMetadata( + flowType: onboardingFlow, + stage: remoteOnboardingStage, + currentStepId: currentOnboardingStepId, + bottomSheetRoute: routeId, + bottomSheetRouteParam: routeParam + ) + } + + /// Reconstructs BottomSheetRoute from identifier + param + static func restoreBottomSheetRoute(from identifier: BottomSheetRouteIdentifier, param: String?) -> BottomSheetRoute { + switch identifier { + case .alreadyHaveAnAccount: + return .alreadyHaveAnAccount + case .welcomeBack: + return .welcomeBack + case .doYouHaveAnInviteCode: + return .doYouHaveAnInviteCode + case .enterInviteCode: + return .enterInviteCode + case .whosThisFor: + return .whosThisFor + case .letsMeetYourIngrediFam: + return .letsMeetYourIngrediFam + case .whatsYourName: + return .whatsYourName + case .addMoreMembers: + return .addMoreMembers + case .addMoreMembersMinimal: + return .addMoreMembersMinimal + case .editMember: + let parts = (param ?? "").split(separator: "|") + if parts.count >= 2, let id = UUID(uuidString: String(parts[0])), let isSelf = Bool(String(parts[1])) { + return .editMember(memberId: id, isSelf: isSelf) + } + return .homeDefault + case .wouldYouLikeToInvite: + let parts = (param ?? "").split(separator: "|") + if parts.count >= 2, let id = UUID(uuidString: String(parts[0])) { + let name = String(parts[1]) + return .wouldYouLikeToInvite(memberId: id, name: name) + } + return .homeDefault + case .addPreferencesForMember: + let parts = (param ?? "").split(separator: "|") + if parts.count >= 2, let id = UUID(uuidString: String(parts[0])) { + let name = String(parts[1]) + return .addPreferencesForMember(memberId: id, name: name) + } + return .homeDefault + case .generateAvatar: + return .generateAvatar + case .bringingYourAvatar: + return .bringingYourAvatar + case .meetYourAvatar: + return .meetYourAvatar + case .yourCurrentAvatar: + return .yourCurrentAvatar + case .setUpAvatarFor: + return .setUpAvatarFor + case .dietaryPreferencesSheet: + let isFamilyFlow = param == "true" + return .dietaryPreferencesSheet(isFamilyFlow: isFamilyFlow) + case .allSetToJoinYourFamily: + return .allSetToJoinYourFamily + case .onboardingStep: + return .onboardingStep(stepId: param ?? "") + case .fineTuneYourExperience: + return .fineTuneYourExperience + case .homeDefault: + return .homeDefault + case .chatIntro: + return .chatIntro + case .chatConversation: + return .chatConversation + case .workingOnSummary: + return .workingOnSummary + case .meetYourProfileIntro: + return .meetYourProfileIntro + case .meetYourProfile: + if let param = param, let memberId = UUID(uuidString: param) { + return .meetYourProfile(memberId: memberId) + } + return .meetYourProfile(memberId: nil) + case .preferencesAddedSuccess: + return .preferencesAddedSuccess + case .readyToScanFirstProduct: + return .readyToScanFirstProduct + case .seeHowScanningWorks: + return .seeHowScanningWorks + case .quickAccessNeeded: + return .quickAccessNeeded + case .loginToContinue: + return .loginToContinue + case .updateAvatar: + return .generateAvatar + } + } + static func restoreState(from metadata: RemoteOnboardingMetadata) -> (canvas: CanvasRoute, sheet: BottomSheetRoute) { + // 1. Restore Sheet + let sheetId = metadata.bottomSheetRoute ?? .homeDefault + let sheet = restoreBottomSheetRoute(from: sheetId, param: metadata.bottomSheetRouteParam) + + // 2. Restore Canvas based on Stage + Flow + var canvas: CanvasRoute + let flow = metadata.flowType ?? .individual + + switch metadata.stage ?? .none { + case .none, .preOnboarding: + canvas = .heyThere + case .choosingFlow: + canvas = .letsMeetYourIngrediFam + case .dietaryIntro: + canvas = .dietaryPreferencesAndRestrictions(isFamilyFlow: flow == .family) + case .dynamicOnboarding: + canvas = .mainCanvas(flow: flow) + case .fineTune: + canvas = .mainCanvas(flow: flow) + case .completed: + // summaryJustMe uses .completed stage too, but we distinguish by sheet + canvas = .home + } + + // 3. Refine Canvas based on specific Sheets that map to specific Canvases + // This overrides the broader 'stage' based guess for accuracy + switch sheet { + case .alreadyHaveAnAccount, .welcomeBack, .doYouHaveAnInviteCode, .enterInviteCode, .whosThisFor: + // These sheets all appear on .heyThere canvas (consistent with navigateInBottomSheet logic) + canvas = .heyThere + case .letsMeetYourIngrediFam, .whatsYourName, .addMoreMembers, .addMoreMembersMinimal, .editMember, .wouldYouLikeToInvite, .addPreferencesForMember, .generateAvatar, .bringingYourAvatar, .meetYourAvatar, .yourCurrentAvatar, .setUpAvatarFor: + canvas = .letsMeetYourIngrediFam + case .dietaryPreferencesSheet: + // handled by stage .dietaryIntro usually, but enforce correct canvas + canvas = .dietaryPreferencesAndRestrictions(isFamilyFlow: flow == .family) + case .allSetToJoinYourFamily: + canvas = .summaryAddFamily + case .onboardingStep: + canvas = .mainCanvas(flow: flow) + case .fineTuneYourExperience: + canvas = .mainCanvas(flow: flow) + case .chatIntro, .chatConversation: + // Chat can be presented anywhere, usually preserves background. + // If we are restoring fresh, we might not know background. + // Defaulting to home or based on stage is safe. + break + case .meetYourProfile: + canvas = .home + case .preferencesAddedSuccess: + canvas = .summaryJustMe + case .readyToScanFirstProduct: + canvas = .readyToScanFirstProduct + case .seeHowScanningWorks: + canvas = .seeHowScanningWorks + case .quickAccessNeeded: + canvas = .whyWeNeedThesePermissions + case .loginToContinue: + canvas = .whyWeNeedThesePermissions + default: + break + } + + return (canvas, sheet) + } +} diff --git a/IngrediCheck/Store/AuthController.swift b/IngrediCheck/Store/AuthController.swift index 6b8c16bc..dd64d3e3 100644 --- a/IngrediCheck/Store/AuthController.swift +++ b/IngrediCheck/Store/AuthController.swift @@ -7,6 +7,7 @@ import GoogleSignIn import GoogleSignInSwift import CryptoKit import PostHog +import os enum AuthControllerError: Error, LocalizedError { case rootViewControllerNotFound @@ -55,10 +56,6 @@ private final class AppleSignInCoordinator: NSObject, return } - if let currentUserName = appleIDCredential.fullName?.formatted(), !currentUserName.isEmpty { - keychain.set(currentUserName, forKey: "currentUserName") - } - guard let identityTokenData = appleIDCredential.identityToken else { continuation?.resume(throwing: AuthControllerError.idTokenIsNil) continuation = nil @@ -136,7 +133,6 @@ private enum AuthFlowMode { private static let anonPasswordKey = "anonPassword" private static let deviceIdKey = "deviceId" private static var hasRegisteredDevice = false - private static var hasPinged = false @MainActor init() { authChangeWatcher() @@ -154,35 +150,67 @@ private enum AuthFlowMode { } @MainActor var signedInWithApple: Bool { - if let provider = self.session?.user.appMetadata["provider"] { - return provider == "apple" + guard let session = session else { return false } + // Check identities first + if let identities = session.user.identities { + if identities.contains(where: { $0.provider.lowercased() == "apple" }) { + return true + } + } + // Fallback to appMetadata + if let provider = session.user.appMetadata["provider"] as? String, + provider.lowercased() == "apple" { + return true + } + return false + } + + @MainActor var signedInWithGoogle: Bool { + guard let session = session else { return false } + // Check identities first + if let identities = session.user.identities { + if identities.contains(where: { $0.provider.lowercased() == "google" }) { + return true + } + } + // Fallback to appMetadata + if let provider = session.user.appMetadata["provider"] as? String, + provider.lowercased() == "google" { + return true } return false } @MainActor var signedInAsGuest: Bool { - if let provider = self.session?.user.appMetadata["provider"] as? String { - return provider == "email" || provider == "anonymous" + guard let session = session else { return false } + + // If we have an explicit anonymous identity + if let identities = session.user.identities { + if identities.contains(where: { $0.provider == "anonymous" }) { + return true + } } - if self.session?.user.isAnonymous == true { + // If appMetadata says anonymous (or email for legacy guest) + if let provider = session.user.appMetadata["provider"] as? String { + if provider == "email" || provider == "anonymous" { + return true + } + } + + // Specific flag on user object + if session.user.isAnonymous == true { return true } - if let email = self.session?.user.email { + // Fallback: check email pattern + if let email = session.user.email { return email.hasPrefix("anon-") && email.hasSuffix("@example.com") } return false } - @MainActor var signedInWithGoogle: Bool { - if let provider = self.session?.user.appMetadata["provider"] { - return provider == "google" - } - return false - } - @MainActor var currentUserEmail: String? { return session?.user.email } @@ -205,14 +233,19 @@ private enum AuthFlowMode { } @MainActor var currentSignInProviderDisplay: (icon: String, text: String)? { + if signedInWithGoogle { + return ("g.circle", "Signed in with Google") + } + if signedInWithApple { return ("applelogo", "Signed in with Apple") } - - if signedInWithGoogle { - return ("g.circle", "Signed in with Google") + + // Fallback for valid non-guest sessions where provider is missing + if session != nil && !signedInAsGuest { + return ("person.circle", "Signed in") } - + return nil } @@ -220,7 +253,7 @@ private enum AuthFlowMode { Task { for await authStateChange in supabaseClient.auth.authStateChanges { await MainActor.run { - print("Auth change Event: \(authStateChange.event)") + Log.debug("AuthController", "Auth change Event: \(authStateChange.event)") self.handleSessionChange( event: authStateChange.event, session: authStateChange.session @@ -232,25 +265,41 @@ private enum AuthFlowMode { public func signOut() async { do { - print("Signing Out") + Log.debug("AuthController", "Signing Out") + // Clear onboarding state before sign-out + await MainActor.run { + OnboardingPersistence.shared.setStage(.none) + } _ = try await supabaseClient.auth.signOut() } catch AuthError.sessionMissing { - print("Already signed out, nothing to revoke.") + Log.debug("AuthController", "Already signed out, nothing to revoke.") } catch let error as NSError { if error.domain == NSURLErrorDomain && error.code == -1009 { - print("Internet connection appears to be offline.") + Log.debug("AuthController", "Internet connection appears to be offline.") return } - print("Signout failed: \(error)") + Log.error("AuthController", "Signout failed: \(error)") + } + } + + public func resetForAppReset() async { + // Ensure we sign out of Supabase and clear all onboarding state. + await signOut() + // Also clear local onboarding caches even if there was no active session. + + await MainActor.run { + OnboardingPersistence.shared.reset() + clearAnonymousCredentials() + Self.hasRegisteredDevice = false } } func signIn() async { - print("signIn()") + Log.debug("AuthController", "signIn()") guard await signInState != .signedIn else { - print("Already Signed In, so not Signing in again") + Log.debug("AuthController", "Already Signed In, so not Signing in again") return } @@ -266,12 +315,12 @@ private enum AuthFlowMode { @MainActor public func upgradeCurrentAccount(to provider: AccountUpgradeProvider) async { guard signedInAsGuest else { - print("Upgrade skipped: user is not signed in as guest.") + Log.debug("AuthController", "Upgrade skipped: user is not signed in as guest.") return } guard isUpgradingAccount == false else { - print("Upgrade already in progress.") + Log.debug("AuthController", "Upgrade already in progress.") return } @@ -294,7 +343,7 @@ private enum AuthFlowMode { } catch { isUpgradingAccount = false accountUpgradeError = error - print("Account upgrade failed: \(error)") + Log.error("AuthController", "Account upgrade failed: \(error)") } } @@ -311,7 +360,7 @@ private enum AuthFlowMode { _ = try await supabaseClient.auth.signIn(email: email, password: password) return true } catch { - print("Anonymous signin failed for stored credentials: \(error)") + Log.error("AuthController", "Anonymous signin failed for stored credentials: \(error)") keychain.delete(AuthController.anonUserNameKey) keychain.delete(AuthController.anonPasswordKey) return false @@ -322,7 +371,7 @@ private enum AuthFlowMode { do { _ = try await supabaseClient.auth.signInAnonymously() } catch { - print("signInAnonymously failed: \(error)") + Log.error("AuthController", "signInAnonymously failed: \(error)") } } @@ -335,7 +384,7 @@ private enum AuthFlowMode { self.session = session } } catch { - print("Apple sign-in failed: \(error)") + Log.error("AuthController", "Apple sign-in failed: \(error)") } } } @@ -383,10 +432,6 @@ private enum AuthFlowMode { throw AuthControllerError.unsupportedCredentialType } - if let currentUserName = appleIDCredential.fullName?.formatted(), !currentUserName.isEmpty { - keychain.set(currentUserName, forKey: "currentUserName") - } - guard let identityTokenData = appleIDCredential.identityToken else { throw AuthControllerError.idTokenIsNil } @@ -498,7 +543,25 @@ private enum AuthFlowMode { completion?(.success(())) } } catch { - print("Google sign-in failed: \(error)") + Log.error("AuthController", "Google sign-in failed: \(error)") + await MainActor.run { + completion?(.failure(error)) + } + } + } + } + + public func signInWithApple(completion: ((Result) -> Void)? = nil) { + Task { + do { + let credentials = try await requestAppleIDToken() + let session = try await finalizeAuth(with: credentials, mode: .signIn) + await MainActor.run { + self.session = session + completion?(.success(())) + } + } catch { + Log.error("AuthController", "Apple sign-in failed: \(error)") await MainActor.run { completion?(.failure(error)) } @@ -506,6 +569,31 @@ private enum AuthFlowMode { } } + private func authProvider(for session: Session) -> String { + if let identities = session.user.identities { + if identities.contains(where: { $0.provider.lowercased() == "apple" }) { + return "Social Login (Apple)" + } else if identities.contains(where: { $0.provider.lowercased() == "google" }) { + return "Social Login (Google)" + } else if identities.contains(where: { $0.provider == "anonymous" }) { + return "Guest Login" + } + } + if let provider = session.user.appMetadata["provider"] as? String { + if provider.lowercased() == "apple" { + return "Social Login (Apple)" + } else if provider.lowercased() == "google" { + return "Social Login (Google)" + } else if provider == "email" || provider == "anonymous" { + return "Guest Login" + } + } + if session.user.isAnonymous == true { + return "Guest Login" + } + return "Unknown" + } + @MainActor private func handleSessionChange(event: AuthChangeEvent, session: Session?) { self.session = session @@ -513,57 +601,65 @@ private enum AuthFlowMode { if let session { signInState = .signedIn - registerDeviceAfterLogin(session: session) - pingAfterLogin() - AnalyticsService.shared.refreshAnalyticsIdentity(session: session, isInternalUser: isInternalUser) + + // Log user ID and login type + let userId = session.user.id + + let loginType = authProvider(for: session) + + Log.debug("AUTH", "βœ… User logged in - User ID: \(userId), Login Type: \(loginType)") + + registerDeviceAfterLogin(session: session, authProvider: loginType) + AnalyticsService.shared.refreshAnalyticsIdentity(session: session, isInternalUser: isInternalUser, authProvider: loginType) } else { signInState = .signedOut let shouldReset = event == .signedOut || event == .userDeleted if shouldReset { + Log.debug("AUTH", "πŸ”΄ User signed out") AnalyticsService.shared.resetAnalytics() - // Reset ping flag on sign out so it can run again on next login - Self.hasPinged = false } } } @MainActor - private func registerDeviceAfterLogin(session: Session) { + private func registerDeviceAfterLogin(session: Session, authProvider: String) { guard !Self.hasRegisteredDevice else { return } Self.hasRegisteredDevice = true - + WebService().registerDeviceAfterLogin(deviceId: deviceId) { [weak self] isInternal in guard let self = self, let isInternal = isInternal else { return } - + Task { @MainActor in if isInternal != self.isInternalUser { self.isInternalUser = isInternal - AnalyticsService.shared.refreshAnalyticsIdentity(session: session, isInternalUser: isInternal) + AnalyticsService.shared.refreshAnalyticsIdentity(session: session, isInternalUser: isInternal, authProvider: authProvider) } } } } - - @MainActor - private func pingAfterLogin() { - guard !Self.hasPinged else { - return - } - Self.hasPinged = true - - // Fire-and-forget ping call - WebService().ping() - } - + @MainActor func setInternalUser(_ value: Bool) { guard value != isInternalUser else { return } isInternalUser = value if let session = session { - AnalyticsService.shared.refreshAnalyticsIdentity(session: session, isInternalUser: value) + AnalyticsService.shared.refreshAnalyticsIdentity(session: session, isInternalUser: value, authProvider: authProvider(for: session)) + } + } + + + + /// Restores navigation state from Supabase metadata + /// Call this on app launch after session is available + @MainActor + func restoreOnboardingPosition(into coordinator: AppNavigationCoordinator) { + // Delegate restoration to our single source of truth. + // It handles checking remote vs local and resolving conflicts. + Task { + await OnboardingPersistence.shared.restore(into: coordinator) } } } diff --git a/IngrediCheck/Store/ChatStore.swift b/IngrediCheck/Store/ChatStore.swift new file mode 100644 index 00000000..60369c48 --- /dev/null +++ b/IngrediCheck/Store/ChatStore.swift @@ -0,0 +1,38 @@ +// +// ChatStore.swift +// IngrediCheck +// + +import SwiftUI +import Observation + +// Chat message model +struct ChatMessage: Identifiable { + let id: String + let isUser: Bool + let text: String + let timestamp: Date +} + +@Observable +@MainActor +final class ChatStore { + /// Keyed by context string (e.g. "home", "product_scan:abc-123") + private var conversations: [String: Conversation] = [:] + + struct Conversation { + var messages: [ChatMessage] = [] + var conversationId: String? = nil + var visibleMessageIds: Set = [] + } + + func conversation(for contextKey: String) -> Conversation { + conversations[contextKey] ?? Conversation() + } + + func update(for contextKey: String, _ mutate: (inout Conversation) -> Void) { + var conv = conversations[contextKey] ?? Conversation() + mutate(&conv) + conversations[contextKey] = conv + } +} diff --git a/IngrediCheck/Store/DietaryPreferences.swift b/IngrediCheck/Store/DietaryPreferences.swift index 625dd018..838bd3a2 100644 --- a/IngrediCheck/Store/DietaryPreferences.swift +++ b/IngrediCheck/Store/DietaryPreferences.swift @@ -1,5 +1,6 @@ import SwiftUI import Foundation +import os fileprivate let DietaryPreferencesKey = "DietaryPreferences" @@ -195,7 +196,7 @@ extension UserDefaults { } } catch { if error is CancellationError { - print("Task was cancelled") + Log.debug("DietaryPreferences", "Task was cancelled") } else { DispatchQueue.main.async { withAnimation { diff --git a/IngrediCheck/Store/DynamicSteps.swift b/IngrediCheck/Store/DynamicSteps.swift new file mode 100644 index 00000000..09bc1e02 --- /dev/null +++ b/IngrediCheck/Store/DynamicSteps.swift @@ -0,0 +1,142 @@ +import Foundation +import SwiftUI + +// MARK: - Dynamic Steps Root + +struct DynamicStepsPayload: Codable { + let steps: [DynamicStep] +} + +// MARK: - Step + +struct DynamicStep: Codable, Identifiable { + let id: String + let type: DynamicStepType + let header: DynamicStepHeader + let content: DynamicStepContent +} + +// Using a simple string-backed enum keeps the JSON flexible if the backend +// adds a new type – unknown values will be decoded as `.unknown`. +enum DynamicStepType: String, Codable { + case type1 = "type-1" + case type2 = "type-2" + case type3 = "type-3" + case unknown + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = (try? container.decode(String.self)) ?? "" + self = DynamicStepType(rawValue: rawValue) ?? .unknown + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .type1: + try container.encode("type-1") + case .type2: + try container.encode("type-2") + case .type3: + try container.encode("type-3") + case .unknown: + // Persist as-is for round-tripping; you can also choose to omit or + // encode a fallback string here depending on your needs. + try container.encode("unknown") + } + } +} + +// MARK: - Header + +struct DynamicStepHeader: Codable { + let iconURL: String? + let name: String + let individual: DynamicHeaderVariant + let family: DynamicHeaderVariant + let singleMember: DynamicHeaderVariant? + + enum CodingKeys: String, CodingKey { + case iconURL = "iconUrl" + case name + case individual + case family + case singleMember + } +} + +struct DynamicHeaderVariant: Codable { + let question: String + let description: String? +} + +// MARK: - Content + +/// A single content wrapper that can represent all three shapes used in the JSON: +/// - `options` for simple chip lists (type-1) +/// - `subSteps` for card-based layouts (type-2) +/// - `regions` / `subRegions` for hierarchical groupings (type-3) +/// +/// Only one of these will typically be non-nil per `DynamicStep`, but the model +/// is flexible enough if that ever changes. +struct DynamicStepContent: Codable { + let options: [DynamicOption]? + let subSteps: [DynamicSubStep]? + let regions: [DynamicRegion]? +} + +// MARK: - Reusable Leaf Types + +struct DynamicOption: Codable, Identifiable { + let id = UUID() + let name: String + let icon: String +} + +struct DynamicSubStep: Codable, Identifiable { + let id: String + let title: String + let description: String + let colorHex: String? + let backgroundImageURL: String? + let options: [DynamicOption]? + + enum CodingKeys: String, CodingKey { + case id + case title + case description + case colorHex = "color" + case backgroundImageURL = "bgImageUrl" + case options + } +} + +struct DynamicRegion: Codable, Identifiable { + let id = UUID() + let name: String + let subRegions: [DynamicOption] +} + +// MARK: - Loader + +/// Helper for loading the dynamic onboarding configuration from the bundled JSON. +enum DynamicStepsProvider { + static func loadSteps() -> [DynamicStep] { + // When this runs inside the preview target, the JSON should be part of + // the IngrediCheckPreview app bundle with the same filename. + guard let url = Bundle.main.url(forResource: "dynamicJsonData", withExtension: "json") else { + assertionFailure("dynamicJsonData.json not found in bundle") + return [] + } + + do { + let data = try Data(contentsOf: url) + let payload = try JSONDecoder().decode(DynamicStepsPayload.self, from: data) + return payload.steps + } catch { + assertionFailure("Failed to decode dynamicJsonData.json: \(error)") + return [] + } + } +} + diff --git a/IngrediCheck/Store/FamilyModels.swift b/IngrediCheck/Store/FamilyModels.swift new file mode 100644 index 00000000..1be15355 --- /dev/null +++ b/IngrediCheck/Store/FamilyModels.swift @@ -0,0 +1,70 @@ +import Foundation + +/// Top-level representation of a household returned by the family RPCs. +/// +/// Matches the JSON shape produced by the `get_family` and `join_family` +/// Postgres functions (`012_family_functions.sql`), which looks like: +/// +/// ```json +/// { +/// "name": "Team Alpha", +/// "selfMember": { ... }, +/// "otherMembers": [ { ... } ], +/// "version": 1732736400 +/// } +/// ``` +struct Family: Codable, Equatable { + let name: String + let selfMember: FamilyMember + var otherMembers: [FamilyMember] + /// Monotonic version derived from updated_at timestamps in the backend. + /// Represented as a Unix epoch seconds BIGINT in SQL. + let version: Int64 +} + +/// A single member within a family. +/// +/// This mirrors the JSON objects built in `get_family` for both +/// `selfMember` and entries in `otherMembers`: +/// +/// ```json +/// { +/// "id": "uuid", +/// "name": "Alex Shaw", +/// "color": "#264653", +/// "imageFileHash": "abc123.png", +/// "joined": true +/// } +/// ``` +struct FamilyMember: Codable, Identifiable, Hashable { + let id: UUID + var name: String + var color: String + /// Indicates whether this member already has a user attached + /// (`user_id IS NOT NULL` in the backend). + var joined: Bool + /// Optional hash of the member's avatar image file, if present. + var imageFileHash: String? + /// Indicates whether an invite was initiated but deferred ("Maybe later"). + /// Local-only during onboarding; may not be present in backend payloads. + var invitePending: Bool? + + private enum CodingKeys: String, CodingKey { + case id + case name + case color + case joined + case imageFileHash + case invitePending + } +} + +/// Response returned from `POST /ingredicheck/family/invite`. +/// +/// The edge function wraps the raw invite code in: +/// `{ "inviteCode": "abc123" }` +struct InviteResponse: Codable, Equatable { + let inviteCode: String +} + + diff --git a/IngrediCheck/Store/FamilyService.swift b/IngrediCheck/Store/FamilyService.swift new file mode 100644 index 00000000..25453c0f --- /dev/null +++ b/IngrediCheck/Store/FamilyService.swift @@ -0,0 +1,404 @@ +import Foundation + +final class FamilyService { + + private let baseURL: String + private let apiKey: String + + init( + baseURL: String = Config.supabaseFunctionsURLBase, + apiKey: String = Config.supabaseKey + ) { + self.baseURL = baseURL + self.apiKey = apiKey + } + + // MARK: - Helpers + + private func currentJWT() async throws -> String { + guard let token = try? await supabaseClient.auth.session.accessToken else { + throw NetworkError.authError + } + return token + } + + private func decodeFamily(from body: String) throws -> Family { + guard let data = body.data(using: .utf8) else { + Log.debug("FamilyService", "decodeFamily error: cannot convert body to UTF-8 data") + throw NetworkError.decodingError + } + do { + return try JSONDecoder().decode(Family.self, from: data) + } catch { + Log.debug("FamilyService", "decodeFamily JSON error: \(error)") + if let decodingError = error as? DecodingError { + switch decodingError { + case .dataCorrupted(let context): + Log.debug("FamilyService", "decodeFamily dataCorrupted: \(context.debugDescription)") + case .keyNotFound(let key, let context): + Log.debug("FamilyService", "decodeFamily keyNotFound: \(key.stringValue) in \(context.debugDescription)") + case .typeMismatch(let type, let context): + Log.debug("FamilyService", "decodeFamily typeMismatch: \(type) in \(context.debugDescription)") + case .valueNotFound(let type, let context): + Log.debug("FamilyService", "decodeFamily valueNotFound: \(type) in \(context.debugDescription)") + @unknown default: + Log.debug("FamilyService", "decodeFamily unknown decoding error") + } + } + throw error + } + } + + private func decodeInviteCode(from body: String) throws -> String { + guard let data = body.data(using: .utf8) else { + throw NetworkError.decodingError + } + let response = try JSONDecoder().decode(InviteResponse.self, from: data) + return response.inviteCode + } + + // MARK: - Public API + + func createFamily( + name: String, + selfMember: FamilyMember, + otherMembers: [FamilyMember]? + ) async throws -> Family { + let otherNames = otherMembers?.map { $0.name } ?? [] + Log.debug("FamilyService", "πŸ”΅ createFamily called") + Log.debug("FamilyService", "πŸ“ Parameters - name: \(name), self: \(selfMember.name) (id: \(selfMember.id)), others: \(otherNames)") + + do { + let jwt = try await currentJWT() + Log.debug("FamilyService", "βœ… JWT obtained (length: \(jwt.count) chars)") + + func memberDict(_ member: FamilyMember) -> [String: Any] { + var dict: [String: Any] = [ + "id": member.id.uuidString, + "name": member.name, + "color": member.color + ] + if let imageFileHash = member.imageFileHash { + dict["imageFileHash"] = imageFileHash + Log.debug("FamilyService", "πŸ“Έ Member \(member.name) has imageFileHash: \(imageFileHash)") + } + return dict + } + + let selfMemberDict = memberDict(selfMember) + let otherMembersDict = otherMembers?.map(memberDict) + + Log.debug("FamilyService", "πŸ“‘ API Configuration - baseURL: \(baseURL), full path: \(baseURL)family") + Log.debug("FamilyService", "πŸ“¦ Request - name: \(name), selfMember keys: \(selfMemberDict.keys), otherMembers count: \(otherMembersDict?.count ?? 0)") + + let result = try await FamilyAPI.createFamily( + baseURL: baseURL, + apiKey: apiKey, + jwt: jwt, + name: name, + selfMember: selfMemberDict, + otherMembers: otherMembersDict + ) + + Log.debug("FamilyService", "πŸ“₯ Response received - status: \(result.statusCode), body length: \(result.body.count) chars") + + if !result.body.isEmpty { + Log.debug("FamilyService", "πŸ“„ Response body (first 1000 chars): \(String(result.body.prefix(1000)))") + } else { + Log.debug("FamilyService", "⚠️ Response body is empty") + } + + guard (200 ..< 300).contains(result.statusCode) else { + Log.debug("FamilyService", "❌ Error response - status: \(result.statusCode)") + Log.debug("FamilyService", "πŸ“„ Error body: \(result.body)") + throw NetworkError.invalidResponse(result.statusCode) + } + + let family = try decodeFamily(from: result.body) + Log.debug("FamilyService", "βœ… Successfully decoded family - name: \(family.name), selfMember: \(family.selfMember.name), otherMembers: \(family.otherMembers.map { $0.name })") + return family + } catch { + Log.debug("FamilyService", "❌ createFamily failed with error: \(error)") + if let networkError = error as? NetworkError { + Log.debug("FamilyService", "❌ NetworkError type: \(networkError)") + } else if let urlError = error as? URLError { + Log.debug("FamilyService", "❌ URLError code: \(urlError.code.rawValue), description: \(urlError.localizedDescription)") + } + throw error + } + } + + func updateFamily(name: String) async throws -> Family { + Log.debug("FamilyService", "πŸ”΅ updateFamily called") + Log.debug("FamilyService", "πŸ“ Parameters - name: \(name)") + + let jwt = try await currentJWT() + Log.debug("FamilyService", "βœ… JWT obtained (length: \(jwt.count) chars)") + + let result = try await FamilyAPI.updateFamily( + baseURL: baseURL, + apiKey: apiKey, + jwt: jwt, + name: name + ) + + Log.debug("FamilyService", "πŸ“₯ Response received - status: \(result.statusCode), body length: \(result.body.count) chars") + + if !result.body.isEmpty { + Log.debug("FamilyService", "πŸ“„ Response body (first 1000 chars): \(String(result.body.prefix(1000)))") + } else { + Log.debug("FamilyService", "⚠️ Response body is empty") + } + + guard (200 ..< 300).contains(result.statusCode) else { + Log.debug("FamilyService", "❌ Error response - status: \(result.statusCode)") + Log.debug("FamilyService", "πŸ“„ Error body: \(result.body)") + throw NetworkError.invalidResponse(result.statusCode) + } + + let family = try decodeFamily(from: result.body) + Log.debug("FamilyService", "βœ… Successfully decoded family - name: \(family.name), selfMember: \(family.selfMember.name), otherMembers: \(family.otherMembers.map { $0.name })") + return family + } + + func fetchFamily() async throws -> Family { + Log.debug("FamilyService", "fetchFamily request") + let jwt = try await currentJWT() + + let result = try await FamilyAPI.getFamily( + baseURL: baseURL, + apiKey: apiKey, + jwt: jwt + ) + + guard result.statusCode == 200 else { + Log.debug("FamilyService", "fetchFamily bad status: \(result.statusCode), body=\(result.body)") + throw NetworkError.invalidResponse(result.statusCode) + } + + let family = try decodeFamily(from: result.body) + Log.debug("FamilyService", "fetchFamily decoded family name=\(family.name)") + Log.debug("FamilyService", "fetchFamily selfMember.imageFileHash=\(family.selfMember.imageFileHash ?? "nil")") + for (index, member) in family.otherMembers.enumerated() { + Log.debug("FamilyService", "fetchFamily otherMembers[\(index)].imageFileHash=\(member.imageFileHash ?? "nil")") + } + return family + } + + func createInvite(for memberId: UUID) async throws -> String { + Log.debug("FamilyService", "createInvite for memberId=\(memberId)") + let jwt = try await currentJWT() + + let result = try await FamilyAPI.createInvite( + baseURL: baseURL, + apiKey: apiKey, + jwt: jwt, + memberID: memberId.uuidString + ) + + guard result.statusCode == 201 else { + Log.debug("FamilyService", "createInvite bad status: \(result.statusCode), body=\(result.body)") + throw NetworkError.invalidResponse(result.statusCode) + } + + // Handle empty response body - retry the createInvite request since we need the invite code + if result.body.isEmpty { + print("[FamilyService] ⚠️ Empty response body for createInvite, retrying request") + // Add initial delay to ensure backend has finished processing + try? await Task.sleep(nanoseconds: 1_000_000_000) // 1 second + + // Retry the createInvite request up to 2 times (to avoid creating duplicate invites) + var lastError: Error? + for attempt in 1...2 { + do { + print("[FamilyService] πŸ”„ Retry attempt \(attempt)/2 for createInvite") + let retryResult = try await FamilyAPI.createInvite( + baseURL: baseURL, + apiKey: apiKey, + jwt: jwt, + memberID: memberId.uuidString + ) + + guard retryResult.statusCode == 201 else { + print("[FamilyService] ❌ Retry createInvite bad status: \(retryResult.statusCode)") + throw NetworkError.invalidResponse(retryResult.statusCode) + } + + if !retryResult.body.isEmpty { + let code = try decodeInviteCode(from: retryResult.body) + print("[FamilyService] βœ… Successfully got invite code on retry: \(code)") + return code + } + + // If still empty, continue to next retry + print("[FamilyService] ⚠️ Retry attempt \(attempt) still returned empty body") + if attempt < 2 { + let delay = UInt64(1_000_000_000 * UInt64(attempt)) // 1s, 2s + try? await Task.sleep(nanoseconds: delay) + } + } catch { + lastError = error + print("[FamilyService] ⚠️ Retry attempt \(attempt) failed: \(error.localizedDescription)") + if attempt < 2 { + let delay = UInt64(1_000_000_000 * UInt64(attempt)) // 1s, 2s + try? await Task.sleep(nanoseconds: delay) + } + } + } + + // If all retries failed, throw a user-friendly error + print("[FamilyService] ❌ All retry attempts failed for createInvite") + throw NetworkError.decodingError + } + + let code = try decodeInviteCode(from: result.body) + Log.debug("FamilyService", "createInvite decoded code=\(code)") + return code + } + + func joinFamily(inviteCode: String) async throws -> Family { + Log.debug("FamilyService", "joinFamily request code=\(inviteCode)") + let jwt = try await currentJWT() + + let result = try await FamilyAPI.joinFamily( + baseURL: baseURL, + apiKey: apiKey, + jwt: jwt, + inviteCode: inviteCode + ) + + guard result.statusCode == 201 else { + Log.debug("FamilyService", "joinFamily bad status: \(result.statusCode), body=\(result.body)") + throw NetworkError.invalidResponse(result.statusCode) + } + + let family = try decodeFamily(from: result.body) + Log.debug("FamilyService", "joinFamily decoded family name=\(family.name)") + return family + } + + func leaveFamily() async throws { + Log.debug("FamilyService", "leaveFamily request") + let jwt = try await currentJWT() + + let result = try await FamilyAPI.leaveFamily( + baseURL: baseURL, + apiKey: apiKey, + jwt: jwt + ) + + guard result.statusCode == 200 else { + Log.debug("FamilyService", "leaveFamily bad status: \(result.statusCode), body=\(result.body)") + throw NetworkError.invalidResponse(result.statusCode) + } + } + + func addMember(_ member: FamilyMember) async throws -> Family { + Log.debug("FamilyService", "addMember request id=\(member.id)") + let jwt = try await currentJWT() + + var body: [String: Any] = [ + "id": member.id.uuidString, + "name": member.name, + "color": member.color + ] + if let imageFileHash = member.imageFileHash { + body["imageFileHash"] = imageFileHash + } + + let result = try await FamilyAPI.addMember( + baseURL: baseURL, + apiKey: apiKey, + jwt: jwt, + member: body + ) + + guard result.statusCode == 201 else { + Log.debug("FamilyService", "addMember bad status: \(result.statusCode), body=\(result.body)") + throw NetworkError.invalidResponse(result.statusCode) + } + + let family = try decodeFamily(from: result.body) + Log.debug("FamilyService", "addMember decoded family name=\(family.name)") + return family + } + + func editMember(_ member: FamilyMember) async throws -> Family { + Log.debug("FamilyService", "editMember request id=\(member.id)") + let jwt = try await currentJWT() + + var body: [String: Any] = [ + "name": member.name, + "color": member.color + ] + if let imageFileHash = member.imageFileHash { + body["imageFileHash"] = imageFileHash + } + + let result = try await FamilyAPI.editMember( + baseURL: baseURL, + apiKey: apiKey, + jwt: jwt, + memberID: member.id.uuidString, + member: body + ) + + guard result.statusCode == 200 else { + Log.debug("FamilyService", "editMember bad status: \(result.statusCode), body=\(result.body)") + throw NetworkError.invalidResponse(result.statusCode) + } + + let family = try decodeFamily(from: result.body) + Log.debug("FamilyService", "editMember decoded family name=\(family.name)") + Log.debug("FamilyService", "editMember selfMember.imageFileHash=\(family.selfMember.imageFileHash ?? "nil")") + for (index, member) in family.otherMembers.enumerated() { + Log.debug("FamilyService", "editMember otherMembers[\(index)].name=\(member.name), imageFileHash=\(member.imageFileHash ?? "nil")") + } + return family + } + + func deleteMember(id: UUID) async throws -> Family { + Log.debug("FamilyService", "deleteMember request id=\(id)") + let jwt = try await currentJWT() + + let result = try await FamilyAPI.deleteMember( + baseURL: baseURL, + apiKey: apiKey, + jwt: jwt, + memberID: id.uuidString + ) + + guard result.statusCode == 200 else { + Log.debug("FamilyService", "deleteMember bad status: \(result.statusCode), body=\(result.body)") + throw NetworkError.invalidResponse(result.statusCode) + } + + let family = try decodeFamily(from: result.body) + Log.debug("FamilyService", "deleteMember decoded family name=\(family.name)") + return family + } + + func createPersonalFamily(name: String, memberID: String) async throws -> Family { + Log.debug("FamilyService", "createPersonalFamily request name=\(name), memberID=\(memberID)") + let jwt = try await currentJWT() + + let result = try await FamilyAPI.createPersonalFamily( + baseURL: baseURL, + apiKey: apiKey, + jwt: jwt, + name: name, + memberID: memberID + ) + + guard (200 ..< 300).contains(result.statusCode) else { + Log.debug("FamilyService", "createPersonalFamily bad status: \(result.statusCode), body=\(result.body)") + throw NetworkError.invalidResponse(result.statusCode) + } + + let family = try decodeFamily(from: result.body) + Log.debug("FamilyService", "createPersonalFamily decoded family name=\(family.name)") + return family + } +} + + diff --git a/IngrediCheck/Store/FamilyStore.swift b/IngrediCheck/Store/FamilyStore.swift new file mode 100644 index 00000000..60249acf --- /dev/null +++ b/IngrediCheck/Store/FamilyStore.swift @@ -0,0 +1,988 @@ +import Foundation +import Observation +import UIKit +import os + +@Observable +@MainActor +final class FamilyStore { + + private let service: FamilyService + + // MARK: - State + + private(set) var family: Family? + private(set) var isLoading = false + private(set) var isJoining = false + private(set) var isInviting = false + private(set) var errorMessage: String? + + /// Tracks the number of pending avatar uploads + private(set) var pendingUploadCount: Int = 0 + + // Temporary in-memory builder used by the preview onboarding flow + // before the family is actually created on the backend. + private(set) var pendingSelfMember: FamilyMember? + private(set) var pendingOtherMembers: [FamilyMember] = [] + + /// Currently selected member in the family preferences UI. + /// `nil` means "Everyone" (family-level). + var selectedMemberId: UUID? = nil + + /// Target member for avatar assignment from the MeetYourAvatar flow. + /// When non-nil, this is the member whose avatar should be updated. + var avatarTargetMemberId: UUID? = nil + + private let pendingInviteIdsKey = "ingredicheck_pending_invite_ids" + private var pendingInviteIds: Set { + get { + let array = UserDefaults.standard.stringArray(forKey: pendingInviteIdsKey) ?? [] + return Set(array) + } + set { + UserDefaults.standard.set(Array(newValue), forKey: pendingInviteIdsKey) + } + } + + init(service: FamilyService = FamilyService()) { + self.service = service + } + + // MARK: - Pending members (preview flow helpers) + + /// Generates a random pastel hex color for a new family member. + private func randomColor() -> String { + // Curated palette of soft pastel colors + let pastelColors = [ + "#FFB3BA", // Pastel Pink + "#FFDFBA", // Pastel Peach + "#FFFFBA", // Pastel Yellow + "#BAFFC9", // Pastel Mint + "#BAE1FF", // Pastel Blue + "#E0BBE4", // Pastel Lavender + "#FFCCCB", // Light Pink + "#B4E4FF", // Sky Blue + "#C7CEEA", // Periwinkle + "#F0E6FF", // Lavender + "#FFE5B4", // Peach + "#E8F5E9", // Light Green + "#FFF9C4", // Light Yellow + "#F8BBD0", // Pink + "#B2EBF2", // Cyan + "#D1C4E9", // Light Purple + "#FFE0B2", // Apricot + "#C5E1A5", // Light Lime + "#BBDEFB", // Light Blue + "#F1F8E9" // Very Light Green + ] + return pastelColors.randomElement() ?? "#FFB3BA" + } + + /// Set the primary (self) member from the onboarding flow. + func setPendingSelfMember(name: String) { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + Log.debug("FamilyStore", "Ignoring empty self member name") + return + } + let color = randomColor() + Log.debug("FamilyStore", "Setting pending self member name: \(trimmed), color: \(color)") + + pendingSelfMember = FamilyMember( + id: UUID(), + name: trimmed, + color: color, + joined: true, + imageFileHash: nil + ) + } + + /// Set the pending self member from an existing FamilyMember (used when creating family from Settings) + func setPendingSelfMemberFromExisting(_ member: FamilyMember) { + Log.debug("FamilyStore", "Setting pending self member from existing: \(member.name)") + pendingSelfMember = member + } + + /// Set or update the avatar image for the pending self member. + /// This method accepts an asset name string (for backward compatibility). + /// For immediate upload, use setPendingSelfMemberAvatar(image:webService:) instead. + func setPendingSelfMemberAvatar(imageName: String?) { + guard let imageName else { return } + guard var member = pendingSelfMember else { return } + member.imageFileHash = imageName + pendingSelfMember = member + } + + /// Set the avatar for the pending self member using a memoji storage path + /// from the `memoji-images` bucket, without re-uploading the PNG. Also + /// updates the member color to match the memoji background if provided. + func setPendingSelfMemberAvatarFromMemoji(storagePath: String, backgroundColorHex: String? = nil) async { + guard !storagePath.isEmpty else { + Log.debug("FamilyStore", "setPendingSelfMemberAvatarFromMemoji: Empty storagePath, skipping") + return + } + guard var member = pendingSelfMember else { + Log.debug("FamilyStore", "setPendingSelfMemberAvatarFromMemoji: No pending self member, skipping") + return + } + + member.imageFileHash = storagePath + + if let bgHex = backgroundColorHex, !bgHex.isEmpty { + let normalizedColor = bgHex.hasPrefix("#") ? bgHex : "#\(bgHex)" + member.color = normalizedColor + } + + pendingSelfMember = member + Log.debug("FamilyStore", "setPendingSelfMemberAvatarFromMemoji: βœ… Assigned memoji path=\(storagePath) to pending self member \(member.name)") + } + + /// Upload and set the avatar image for the pending self member immediately. + /// This uploads the image to Supabase and sets the imageFileHash to the uploaded hash. + /// The image is uploaded as a transparent PNG; background color is stored separately in member.color. + /// - Parameters: + /// - image: The image to upload (transparent PNG) + /// - webService: WebService instance for uploading + /// - backgroundColorHex: Optional background color hex. If provided, updates member.color + func setPendingSelfMemberAvatar(image: UIImage, webService: WebService, backgroundColorHex: String? = nil) async { + // CRITICAL: Capture member data immediately to prevent accessing deallocated objects + guard var member = pendingSelfMember else { + Log.debug("FamilyStore", "setPendingSelfMemberAvatar: No pending self member, skipping upload") + return + } + + // Capture member properties immediately + let memberName = member.name + + // CRITICAL: UIImage.size access must be on main thread - wrap in MainActor.run + Log.debug("FamilyStore", "setPendingSelfMemberAvatar: Before image.size access - Thread.isMainThread=\(Thread.isMainThread)") + let isValid = await MainActor.run { + let isMainThread = Thread.isMainThread + Log.debug("FamilyStore", "setPendingSelfMemberAvatar: Inside MainActor.run - Thread.isMainThread=\(isMainThread)") + let width = image.size.width + let height = image.size.height + Log.debug("FamilyStore", "setPendingSelfMemberAvatar: image.size accessed - width=\(width), height=\(height)") + return width > 0 && height > 0 && width.isFinite && height.isFinite + } + Log.debug("FamilyStore", "setPendingSelfMemberAvatar: After MainActor.run - Thread.isMainThread=\(Thread.isMainThread), isValid=\(isValid)") + guard isValid else { + Log.debug("FamilyStore", "setPendingSelfMemberAvatar: Image is invalid, skipping upload") + return + } + + pendingUploadCount += 1 + defer { pendingUploadCount = max(0, pendingUploadCount - 1) } + + do { + Log.debug("FamilyStore", "setPendingSelfMemberAvatar: Uploading avatar image for \(memberName)") + // Upload transparent PNG image directly (no compositing needed) + let imageFileHash = try await webService.uploadImage(image: image) + Log.debug("FamilyStore", "setPendingSelfMemberAvatar: βœ… Uploaded avatar, imageFileHash=\(imageFileHash)") + member.imageFileHash = imageFileHash + + // Update member color if provided + if let bgHex = backgroundColorHex, !bgHex.isEmpty { + let normalizedColor = bgHex.hasPrefix("#") ? bgHex : "#\(bgHex)" + member.color = normalizedColor + } + + pendingSelfMember = member + } catch { + Log.error("FamilyStore", "setPendingSelfMemberAvatar: ❌ Failed to upload avatar: \(error.localizedDescription)") + // Don't set imageFileHash if upload fails - user can retry later + } + } + + /// Update the name for the pending self member. + func updatePendingSelfMemberName(_ name: String) { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + guard var member = pendingSelfMember else { return } + member.name = trimmed + pendingSelfMember = member + } + + /// Add an additional family member to the pending list. + func addPendingOtherMember(name: String) { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + Log.debug("FamilyStore", "Ignoring empty other member name") + return + } + let color = randomColor() + Log.debug("FamilyStore", "Adding pending other member: \(trimmed), color: \(color)") + + let member = FamilyMember( + id: UUID(), + name: trimmed, + color: color, + joined: false, + imageFileHash: nil + ) + pendingOtherMembers.append(member) + } + + /// Set avatar for the last pending other member. + /// This method accepts an asset name string (for backward compatibility). + /// For immediate upload, use setAvatarForLastPendingOtherMember(image:webService:) instead. + func setAvatarForLastPendingOtherMember(imageName: String?) { + guard let imageName else { return } + guard !pendingOtherMembers.isEmpty else { return } + var last = pendingOtherMembers.removeLast() + last.imageFileHash = imageName + pendingOtherMembers.append(last) + } + + /// Set the avatar for the last pending other member using a memoji storage path + /// from the `memoji-images` bucket, without re-uploading the PNG. Also updates + /// the member color to match the memoji background if provided. + func setAvatarForLastPendingOtherMemberFromMemoji(storagePath: String, backgroundColorHex: String? = nil) async { + guard !pendingOtherMembers.isEmpty else { + Log.debug("FamilyStore", "setAvatarForLastPendingOtherMemberFromMemoji: No pending other members, skipping") + return + } + guard !storagePath.isEmpty else { + Log.debug("FamilyStore", "setAvatarForLastPendingOtherMemberFromMemoji: Empty storagePath, skipping") + return + } + + var last = pendingOtherMembers.removeLast() + last.imageFileHash = storagePath + + if let bgHex = backgroundColorHex, !bgHex.isEmpty { + let normalizedColor = bgHex.hasPrefix("#") ? bgHex : "#\(bgHex)" + last.color = normalizedColor + } + + pendingOtherMembers.append(last) + Log.debug("FamilyStore", "setAvatarForLastPendingOtherMemberFromMemoji: βœ… Assigned memoji path=\(storagePath) to pending other member \(last.name)") + } + + /// Upload and set the avatar image for the last pending other member immediately. + /// This uploads the image to Supabase and sets the imageFileHash to the uploaded hash. + /// The image is uploaded as a transparent PNG; background color is stored separately in member.color. + /// - Parameters: + /// - image: The image to upload (transparent PNG) + /// - webService: WebService instance for uploading + /// - backgroundColorHex: Optional background color hex. If provided, updates member.color + func setAvatarForLastPendingOtherMember(image: UIImage, webService: WebService, backgroundColorHex: String? = nil) async { + guard !pendingOtherMembers.isEmpty else { + Log.debug("FamilyStore", "setAvatarForLastPendingOtherMember: No pending other members, skipping upload") + return + } + + var last = pendingOtherMembers.removeLast() + + // CRITICAL: UIImage.size access must be on main thread - wrap in MainActor.run + Log.debug("FamilyStore", "setAvatarForLastPendingOtherMember: Before image.size access - Thread.isMainThread=\(Thread.isMainThread)") + let isValid = await MainActor.run { + let isMainThread = Thread.isMainThread + Log.debug("FamilyStore", "setAvatarForLastPendingOtherMember: Inside MainActor.run - Thread.isMainThread=\(isMainThread)") + let width = image.size.width + let height = image.size.height + Log.debug("FamilyStore", "setAvatarForLastPendingOtherMember: image.size accessed - width=\(width), height=\(height)") + return width > 0 && height > 0 && width.isFinite && height.isFinite + } + Log.debug("FamilyStore", "setAvatarForLastPendingOtherMember: After MainActor.run - Thread.isMainThread=\(Thread.isMainThread), isValid=\(isValid)") + guard isValid else { + Log.debug("FamilyStore", "setAvatarForLastPendingOtherMember: Image is invalid, skipping upload") + pendingOtherMembers.append(last) + return + } + + pendingUploadCount += 1 + defer { pendingUploadCount = max(0, pendingUploadCount - 1) } + + do { + Log.debug("FamilyStore", "setAvatarForLastPendingOtherMember: Uploading avatar image for \(last.name)") + // Upload transparent PNG image directly (no compositing needed) + let imageFileHash = try await webService.uploadImage(image: image) + Log.debug("FamilyStore", "setAvatarForLastPendingOtherMember: βœ… Uploaded avatar, imageFileHash=\(imageFileHash)") + last.imageFileHash = imageFileHash + + // Update member color if provided + if let bgHex = backgroundColorHex, !bgHex.isEmpty { + let normalizedColor = bgHex.hasPrefix("#") ? bgHex : "#\(bgHex)" + last.color = normalizedColor + } + + pendingOtherMembers.append(last) + } catch { + Log.error("FamilyStore", "setAvatarForLastPendingOtherMember: ❌ Failed to upload avatar: \(error.localizedDescription)") + // Restore member without imageFileHash if upload fails + pendingOtherMembers.append(last) + } + } + + func updatePendingOtherMemberName(id: UUID, name: String) { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + if let idx = pendingOtherMembers.firstIndex(where: { $0.id == id }) { + pendingOtherMembers[idx].name = trimmed + } + } + + /// Set avatar for a specific pending other member. + /// This method accepts an asset name string (for backward compatibility). + /// For immediate upload, use setAvatarForPendingOtherMember(id:image:webService:) instead. + func setAvatarForPendingOtherMember(id: UUID, imageName: String?) { + guard let imageName else { return } + if let idx = pendingOtherMembers.firstIndex(where: { $0.id == id }) { + pendingOtherMembers[idx].imageFileHash = imageName + } + } + + /// Set the avatar for a specific pending other member using a memoji storage + /// path from the `memoji-images` bucket, without re-uploading the PNG. Also + /// updates the member color to match the memoji background if provided. + func setAvatarForPendingOtherMemberFromMemoji(id: UUID, storagePath: String, backgroundColorHex: String? = nil) async { + guard !storagePath.isEmpty else { + Log.debug("FamilyStore", "setAvatarForPendingOtherMemberFromMemoji: Empty storagePath, skipping") + return + } + + guard let idx = pendingOtherMembers.firstIndex(where: { $0.id == id }) else { + Log.debug("FamilyStore", "setAvatarForPendingOtherMemberFromMemoji: Member not found for id=\(id), skipping") + return + } + + pendingOtherMembers[idx].imageFileHash = storagePath + + if let bgHex = backgroundColorHex, !bgHex.isEmpty { + let normalizedColor = bgHex.hasPrefix("#") ? bgHex : "#\(bgHex)" + pendingOtherMembers[idx].color = normalizedColor + } + + Log.debug("FamilyStore", "setAvatarForPendingOtherMemberFromMemoji: βœ… Assigned memoji path=\(storagePath) to pending member \(pendingOtherMembers[idx].name)") + } + + /// Upload and set the avatar image for a specific pending other member immediately. + /// This uploads the image to Supabase and sets the imageFileHash to the uploaded hash. + /// The image is uploaded as a transparent PNG; background color is stored separately in member.color. + /// - Parameters: + /// - id: Member ID + /// - image: The image to upload (transparent PNG) + /// - webService: WebService instance for uploading + /// - backgroundColorHex: Optional background color hex. If provided, updates member.color + func setAvatarForPendingOtherMember(id: UUID, image: UIImage, webService: WebService, backgroundColorHex: String? = nil) async { + // CRITICAL: Capture member data immediately to prevent accessing deallocated objects + guard let idx = pendingOtherMembers.firstIndex(where: { $0.id == id }) else { + Log.debug("FamilyStore", "setAvatarForPendingOtherMember: Member not found for id=\(id), skipping upload") + return + } + + // Capture member properties immediately + let memberName = pendingOtherMembers[idx].name + + // CRITICAL: UIImage.size access must be on main thread - wrap in MainActor.run + Log.debug("FamilyStore", "setAvatarForPendingOtherMember: Before image.size access - Thread.isMainThread=\(Thread.isMainThread)") + let isValid = await MainActor.run { + let isMainThread = Thread.isMainThread + Log.debug("FamilyStore", "setAvatarForPendingOtherMember: Inside MainActor.run - Thread.isMainThread=\(isMainThread)") + let width = image.size.width + let height = image.size.height + Log.debug("FamilyStore", "setAvatarForPendingOtherMember: image.size accessed - width=\(width), height=\(height)") + return width > 0 && height > 0 && width.isFinite && height.isFinite + } + Log.debug("FamilyStore", "setAvatarForPendingOtherMember: After MainActor.run - Thread.isMainThread=\(Thread.isMainThread), isValid=\(isValid)") + guard isValid else { + Log.debug("FamilyStore", "setAvatarForPendingOtherMember: Image is invalid, skipping upload") + return + } + + pendingUploadCount += 1 + defer { pendingUploadCount = max(0, pendingUploadCount - 1) } + + do { + Log.debug("FamilyStore", "setAvatarForPendingOtherMember: Uploading avatar image for \(memberName)") + // Upload transparent PNG image directly (no compositing needed) + let imageFileHash = try await webService.uploadImage(image: image) + Log.debug("FamilyStore", "setAvatarForPendingOtherMember: βœ… Uploaded avatar, imageFileHash=\(imageFileHash)") + pendingOtherMembers[idx].imageFileHash = imageFileHash + + // Update member color if provided + if let bgHex = backgroundColorHex, !bgHex.isEmpty { + let normalizedColor = bgHex.hasPrefix("#") ? bgHex : "#\(bgHex)" + pendingOtherMembers[idx].color = normalizedColor + } + } catch { + Log.error("FamilyStore", "setAvatarForPendingOtherMember: ❌ Failed to upload avatar: \(error.localizedDescription)") + // Don't set imageFileHash if upload fails - user can retry later + } + } + + func setInvitePendingForPendingOtherMember(id: UUID, pending: Bool = true) { + if let idx = pendingOtherMembers.firstIndex(where: { $0.id == id }) { + pendingOtherMembers[idx].invitePending = pending + } + + var ids = pendingInviteIds + if pending { + ids.insert(id.uuidString) + } else { + ids.remove(id.uuidString) + } + pendingInviteIds = ids + + if var currentFamily = family { + if let idx = currentFamily.otherMembers.firstIndex(where: { $0.id == id }) { + currentFamily.otherMembers[idx].invitePending = pending + self.family = currentFamily + } + } + } + + func removePendingOtherMember(id: UUID) { + pendingOtherMembers.removeAll { $0.id == id } + var ids = pendingInviteIds + ids.remove(id.uuidString) + pendingInviteIds = ids + } + + /// Creates the family on the backend using any pending members, if present. + func createFamilyFromPendingIfNeeded() async { + guard let selfMember = pendingSelfMember else { + Log.debug("FamilyStore", "createFamilyFromPendingIfNeeded: no pending self member, skipping") + return + } + + let others = pendingOtherMembers + let familyName = "\(selfMember.name)'s Family" + Log.debug("FamilyStore", "Creating family from pending. name=\(familyName), self=\(selfMember.name), others=\(others.map { $0.name })") + + await createOrUpdateFamily( + name: familyName, + selfMember: selfMember, + otherMembers: others + ) + + // Clear the pending builder after a successful attempt. + if family != nil { + pendingSelfMember = nil + pendingOtherMembers = [] + } + } + + /// Adds pending other members to the existing family (used when creating family from Settings) + func addPendingMembersToExistingFamily() async { + guard family != nil else { + Log.debug("FamilyStore", "addPendingMembersToExistingFamily: no existing family, falling back to createFamilyFromPendingIfNeeded") + await createFamilyFromPendingIfNeeded() + return + } + + let others = pendingOtherMembers + Log.debug("FamilyStore", "Adding pending members to existing family: \(others.map { $0.name })") + + // Add each pending member individually + for member in others { + await addMember(member) + } + + // Clear the pending builder after successful addition + if family != nil { + pendingSelfMember = nil + pendingOtherMembers = [] + } + } + + /// Waits for all pending avatar uploads to complete before allowing navigation. + /// This prevents users from navigating away while uploads are in progress. + func waitForPendingUploads() async { + while pendingUploadCount > 0 { + // Poll every 100ms to check if uploads are complete + try? await Task.sleep(nanoseconds: 100_000_000) // 100ms + } + Log.debug("FamilyStore", "waitForPendingUploads: All uploads completed") + } + + // MARK: - Loading + + func loadCurrentFamily() async { + Log.debug("FamilyStore", "loadCurrentFamily() called") + isLoading = true + errorMessage = nil + defer { isLoading = false } + + do { + family = try await service.fetchFamily() + + // Sync pending invite status from local persistence + syncPendingInviteStatus() + + Log.debug("FamilyStore", "loadCurrentFamily success: family=\(String(describing: family))") + } catch { + // Not being in a family is a valid state; treat errors as UI feedback only. + errorMessage = (error as NSError).localizedDescription + Log.error("FamilyStore", "loadCurrentFamily error: \(error)") + } + } + + // MARK: - Create / Update + + func createOrUpdateFamily( + name: String, + selfMember: FamilyMember, + otherMembers: [FamilyMember] + ) async { + Log.debug("FamilyStore", "πŸ”΅ createOrUpdateFamily called") + Log.debug("FamilyStore", "πŸ“ Parameters - name: \(name), self: \(selfMember.name) (id: \(selfMember.id)), others: \(otherMembers.map { $0.name })") + isLoading = true + errorMessage = nil + defer { + isLoading = false + Log.debug("FamilyStore", "βœ… createOrUpdateFamily completed - isLoading: false") + } + + do { + Log.debug("FamilyStore", "⏳ Calling service.createFamily...") + family = try await service.createFamily( + name: name, + selfMember: selfMember, + otherMembers: otherMembers.isEmpty ? nil : otherMembers + ) + + // Sync pending invite status after update + syncPendingInviteStatus() + + Log.debug("FamilyStore", "βœ… createOrUpdateFamily success - family name: \(family?.name ?? "nil")") + } catch { + errorMessage = (error as NSError).localizedDescription + Log.error("FamilyStore", "❌ createOrUpdateFamily error: \(error)") + Log.error("FamilyStore", "❌ Error message: \(errorMessage ?? "nil")") + if let networkError = error as? NetworkError { + Log.error("FamilyStore", "❌ NetworkError details: \(networkError)") + } else if let urlError = error as? URLError { + Log.error("FamilyStore", "❌ URLError code: \(urlError.code.rawValue), description: \(urlError.localizedDescription)") + } + } + } + + /// Updates an existing family's name + func updateFamily(name: String) async { + Log.debug("FamilyStore", "πŸ”΅ updateFamily called") + Log.debug("FamilyStore", "πŸ“ Parameters - name: \(name)") + isLoading = true + errorMessage = nil + defer { + isLoading = false + Log.debug("FamilyStore", "βœ… updateFamily completed - isLoading: false") + } + + do { + Log.debug("FamilyStore", "⏳ Calling service.updateFamily...") + family = try await service.updateFamily(name: name) + + // Sync pending invite status after update + syncPendingInviteStatus() + + Log.debug("FamilyStore", "βœ… updateFamily success - family name: \(family?.name ?? "nil")") + } catch { + errorMessage = (error as NSError).localizedDescription + Log.error("FamilyStore", "❌ updateFamily error: \(error)") + Log.error("FamilyStore", "❌ Error message: \(errorMessage ?? "nil")") + if let networkError = error as? NetworkError { + Log.error("FamilyStore", "❌ NetworkError details: \(networkError)") + } else if let urlError = error as? URLError { + Log.error("FamilyStore", "❌ URLError code: \(urlError.code.rawValue), description: \(urlError.localizedDescription)") + } + } + } + + // MARK: - Members + + func addMember(_ member: FamilyMember) async { + Log.debug("FamilyStore", "addMember called for \(member.name)") + isLoading = true + errorMessage = nil + defer { isLoading = false } + + do { + family = try await service.addMember(member) + syncPendingInviteStatus() + Log.debug("FamilyStore", "addMember success, family name=\(family?.name ?? "nil")") + } catch { + errorMessage = (error as NSError).localizedDescription + Log.error("FamilyStore", "addMember error: \(error)") + } + } + + func editMember(_ member: FamilyMember) async { + Log.debug("FamilyStore", "editMember called for \(member.id)") + isLoading = true + errorMessage = nil + defer { isLoading = false } + + do { + let updatedFamily = try await service.editMember(member) + + // Granular update: Only update the specific member that changed + // This prevents unnecessary re-renders of other member avatars + if var currentFamily = family { + // Find the updated member in the API response + let updatedMember: FamilyMember? + if updatedFamily.selfMember.id == member.id { + updatedMember = updatedFamily.selfMember + } else { + updatedMember = updatedFamily.otherMembers.first(where: { $0.id == member.id }) + } + + if let updatedMember = updatedMember { + // Check if this is the self member + if currentFamily.selfMember.id == member.id { + // Create new family with updated selfMember only + family = Family( + name: currentFamily.name, + selfMember: updatedMember, + otherMembers: currentFamily.otherMembers, + version: updatedFamily.version + ) + } else if let idx = currentFamily.otherMembers.firstIndex(where: { $0.id == member.id }) { + // Update only the specific other member + currentFamily.otherMembers[idx] = updatedMember + family = Family( + name: currentFamily.name, + selfMember: currentFamily.selfMember, + otherMembers: currentFamily.otherMembers, + version: updatedFamily.version + ) + } else { + // Member not found in current family, fall back to full replacement + family = updatedFamily + } + Log.debug("FamilyStore", "editMember success (granular update) for \(member.id), imageFileHash=\(updatedMember.imageFileHash ?? "nil")") + } else { + // Updated member not found in response, fall back to full replacement + family = updatedFamily + Log.debug("FamilyStore", "editMember success (full replacement) for \(member.id)") + } + } else { + // No current family, use the response directly + family = updatedFamily + Log.debug("FamilyStore", "editMember success (no current family) for \(member.id)") + } + } catch { + errorMessage = (error as NSError).localizedDescription + Log.error("FamilyStore", "editMember error: \(error)") + } + } + + /// Updates a member's avatar (imageFileHash and color) + func updateMemberAvatar(memberId: UUID, imageFileHash: String?, color: String?) async throws { + Log.debug("FamilyStore", "updateMemberAvatar called for \(memberId), hash=\(imageFileHash ?? "nil"), color=\(color ?? "nil")") + + guard let family = family else { + throw NSError(domain: "FamilyStore", code: -1, userInfo: [NSLocalizedDescriptionKey: "No family found"]) + } + + // Find the member to update + var memberToUpdate: FamilyMember? + if family.selfMember.id == memberId { + memberToUpdate = family.selfMember + } else { + memberToUpdate = family.otherMembers.first(where: { $0.id == memberId }) + } + + guard var member = memberToUpdate else { + throw NSError(domain: "FamilyStore", code: -1, userInfo: [NSLocalizedDescriptionKey: "Member not found"]) + } + + // Create updated member with new avatar + let updatedMember = FamilyMember( + id: member.id, + name: member.name, + color: color ?? member.color, + joined: member.joined, + imageFileHash: imageFileHash, + invitePending: member.invitePending + ) + + // Call editMember to persist the change + await editMember(updatedMember) + } + + func deleteMember(id: UUID) async { + Log.debug("FamilyStore", "deleteMember called for id=\(id)") + isLoading = true + errorMessage = nil + defer { isLoading = false } + + do { + family = try await service.deleteMember(id: id) + Log.debug("FamilyStore", "deleteMember success, family name=\(family?.name ?? "nil")") + } catch { + errorMessage = (error as NSError).localizedDescription + Log.error("FamilyStore", "deleteMember error: \(error)") + } + } + + // MARK: - Invites + + func invite(memberId: UUID) async -> String? { + Log.debug("FamilyStore", "invite called for memberId=\(memberId)") + isInviting = true + errorMessage = nil + defer { isInviting = false } + + do { + let code = try await service.createInvite(for: memberId) + Log.debug("FamilyStore", "invite success, code=\(code)") + setInvitePendingForPendingOtherMember(id: memberId, pending: true) + return code + } catch { + errorMessage = (error as NSError).localizedDescription + Log.error("FamilyStore", "invite error: \(error)") + return nil + } + } + + func join(inviteCode: String) async { + Log.debug("FamilyStore", "join called with code=\(inviteCode)") + isJoining = true + errorMessage = nil + defer { isJoining = false } + + do { + family = try await service.joinFamily(inviteCode: inviteCode) + errorMessage = nil // Clear any error set by concurrent operations + Log.debug("FamilyStore", "join success, family name=\(family?.name ?? "nil")") + } catch { + errorMessage = (error as NSError).localizedDescription + Log.error("FamilyStore", "join error: \(error)") + } + } + + func leave() async { + Log.debug("FamilyStore", "leave called") + isLoading = true + errorMessage = nil + defer { isLoading = false } + + do { + try await service.leaveFamily() + family = nil + Log.debug("FamilyStore", "leave success, family cleared") + } catch { + errorMessage = (error as NSError).localizedDescription + Log.error("FamilyStore", "leave error: \(error)") + } + } + + /// Creates a default family named "Bite Buddy" for the "Just Me" flow using the standard family endpoint. + /// Uses a default avatar (memoji_3) for guest users to match the handling in other onboarding flows. + /// Throws error for UI handling (navigation blocking). + func createBiteBuddyFamily() async throws { + Log.debug("FamilyStore", "createBiteBuddyFamily called") + isLoading = true + errorMessage = nil + defer { isLoading = false } + + // Use memoji_3 as default avatar for guest users (consistent with other flows) + // Color "#FFFFBA" is the associated background color for memoji_3 + let selfMember = FamilyMember( + id: UUID(), + name: "Bite Buddy", + color: "#FFFFBA", + joined: true, + imageFileHash: "memoji_3" + ) + + family = try await service.createFamily( + name: "Bite Buddy", + selfMember: selfMember, + otherMembers: nil + ) + Log.debug("FamilyStore", "createBiteBuddyFamily success, family name=\(family?.name ?? "nil"), selectedMemberId=\(selectedMemberId?.uuidString ?? "nil")") + } + + // MARK: - Immediate Actions (Throwing) + + /// Creates a family immediately with the given self name. + /// Throws error for UI handling (Toast/Navigation blocking). + func createFamilyImmediate(selfName: String) async throws { + Log.debug("FamilyStore", "πŸ”΅ createFamilyImmediate called") + Log.debug("FamilyStore", "πŸ“ Parameter - selfName: \(selfName)") + isLoading = true + errorMessage = nil + defer { + isLoading = false + Log.debug("FamilyStore", "βœ… createFamilyImmediate completed - isLoading: false") + } + + let trimmed = selfName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + Log.error("FamilyStore", "❌ Validation failed - name is empty after trimming") + throw NSError(domain: "FamilyStore", code: -1, userInfo: [NSLocalizedDescriptionKey: "Name cannot be empty"]) + } + + Log.debug("FamilyStore", "βœ… Name validation passed - trimmed name: \(trimmed)") + + // Check if we have a pending self member logic we should respect? + // Or just create fresh? The flow "WhatsYourName" usually sets pending self member. + // We will respect pending color/avatar if available, otherwise new. + + let selfMember: FamilyMember + if let pending = pendingSelfMember { + Log.debug("FamilyStore", "πŸ“‹ Using pending self member - id: \(pending.id), color: \(pending.color), hasImage: \(pending.imageFileHash != nil)") + selfMember = FamilyMember( + id: pending.id, + name: trimmed, + color: pending.color, + joined: true, + imageFileHash: pending.imageFileHash + ) + } else { + let newColor = randomColor() + Log.debug("FamilyStore", "πŸ†• Creating new self member - id: \(UUID()), color: \(newColor)") + selfMember = FamilyMember( + id: UUID(), + name: trimmed, + color: newColor, + joined: true, + imageFileHash: nil + ) + } + + let familyName = "\(trimmed)'s Family" + Log.debug("FamilyStore", "πŸ“ Family name: \(familyName)") + + do { + Log.debug("FamilyStore", "⏳ Calling service.createFamily...") + family = try await service.createFamily( + name: familyName, + selfMember: selfMember, + otherMembers: nil + ) + + if let family = family { + Log.debug("FamilyStore", "βœ… Family created successfully - name: \(family.name)") + // Clear pending self member as it is now persisted + pendingSelfMember = nil + Log.debug("FamilyStore", "βœ… Cleared pending self member") + } else { + Log.debug("FamilyStore", "⚠️ Family is nil after creation") + } + } catch { + Log.error("FamilyStore", "❌ createFamilyImmediate failed with error: \(error)") + if let networkError = error as? NetworkError { + Log.error("FamilyStore", "❌ NetworkError details: \(networkError)") + } else if let urlError = error as? URLError { + Log.error("FamilyStore", "❌ URLError code: \(urlError.code.rawValue), description: \(urlError.localizedDescription)") + } + throw error + } + } + + /// Adds a member immediately with the given name and optional avatar. + /// Uploads image if provided. + /// Throws error for UI handling. + /// Returns the added member. + @discardableResult + func addMemberImmediate( + name: String, + image: UIImage? = nil, + storagePath: String? = nil, + color: String? = nil, + webService: WebService + ) async throws -> FamilyMember { + Log.debug("FamilyStore", "addMemberImmediate called with name=\(name)") + isLoading = true + errorMessage = nil + defer { isLoading = false } + + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw NSError(domain: "FamilyStore", code: -1, userInfo: [NSLocalizedDescriptionKey: "Name cannot be empty"]) + } + + var avatarHash: String? = storagePath + + if let image = image { + pendingUploadCount += 1 + // Using task priority to ensure upload logic runs + do { + Log.debug("FamilyStore", "addMemberImmediate: Uploading avatar image for \(trimmed)") + let hash = try await webService.uploadImage(image: image) + Log.debug("FamilyStore", "addMemberImmediate: βœ… Uploaded avatar hash=\(hash)") + avatarHash = hash + pendingUploadCount = max(0, pendingUploadCount - 1) + } catch { + pendingUploadCount = max(0, pendingUploadCount - 1) + Log.error("FamilyStore", "addMemberImmediate: ❌ Failed to upload avatar: \(error.localizedDescription)") + // Fail soft: Proceed without avatar if upload fails, instead of blocking member creation + avatarHash = nil + } + } + + // Use provided color or generate random + let memberColor = color ?? randomColor() + + let member = FamilyMember( + id: UUID(), + name: trimmed, + color: memberColor, + joined: false, // Other members are not joined by default (requires invite) + imageFileHash: avatarHash + ) + + family = try await service.addMember(member) + + // Update pending invite status + syncPendingInviteStatus() + + if let found = family?.otherMembers.first(where: { $0.id == member.id }) { + return found + } + return member + } + + func resetLocalState() { + family = nil + isLoading = false + isJoining = false + isInviting = false + errorMessage = nil + pendingSelfMember = nil + pendingOtherMembers = [] + } + + private func syncPendingInviteStatus() { + guard var f = family else { return } + + var currentPendingIds = pendingInviteIds + var changed = false + + // Check other members + for i in 0.. Data { - let data = try await supabaseClient.storage - .from(supabaseFile.bucket) - .download(path: supabaseFile.name) //, options: TransformOptions(width: 0, height: 0)) - return await resizeIfNeeded(data) + Log.debug("FileCache", "πŸ“₯ [FileCache] Downloading from Supabase - bucket: \(supabaseFile.bucket), path: \(supabaseFile.name)") + do { + let data = try await supabaseClient.storage + .from(supabaseFile.bucket) + .download(path: supabaseFile.name) + Log.debug("FileCache", "βœ… [FileCache] Download success - bucket: \(supabaseFile.bucket), path: \(supabaseFile.name), bytes: \(data.count)") + return await resizeIfNeeded(data) + } catch { + Log.error("FileCache", "❌ [FileCache] Download FAILED - bucket: \(supabaseFile.bucket), path: \(supabaseFile.name)") + Log.error("FileCache", "❌ [FileCache] Error: \(error)") + throw error + } } private func resizeIfNeeded(_ data: Data) async -> Data { guard let resize else { return data } - let uiImage = UIImage(data: data)! + + guard let uiImage = UIImage(data: data) else { + return data + } + let resizedImage = await withCheckedContinuation { continuation in uiImage.prepareThumbnail(of: resize) { thumbnail in continuation.resume(returning: thumbnail) } } - return (resizedImage!.jpegData(compressionQuality: 1.0))! + + guard let resizedImage = resizedImage else { + return data + } + + // Check if the original image has transparency (alpha channel) + // If it does, preserve PNG format; otherwise use JPEG for smaller size + if let cgImage = uiImage.cgImage, + cgImage.alphaInfo != .none && + cgImage.alphaInfo != .noneSkipFirst && + cgImage.alphaInfo != .noneSkipLast { + // Has transparency - preserve as PNG + if let pngData = resizedImage.pngData() { + return pngData + } + } + + // No transparency or PNG conversion failed - use JPEG for smaller file size + if let jpegData = resizedImage.jpegData(compressionQuality: 1.0) { + return jpegData + } + + // Fallback: return original data if both conversions fail + return data } } @@ -156,7 +192,7 @@ actor FileCache: FileStore { return destinationUrl } } catch { - print("Copy file error: \(error)") + Log.error("FileCache", "Copy file error: \(error)") } return nil } @@ -168,13 +204,13 @@ actor FileCache: FileStore { for key in sortedKeys where currentDiskUsage > maxDiskUsageInBytes { if let cacheEntry = inMemoryStore[key] { do { - print("FileCache: Deleting file") + Log.debug("FileCache", "FileCache: Deleting file") try FileManager.default.removeItem(at: cacheEntry.localFileUrl) currentDiskUsage -= cacheEntry.fileSizeOnDisk inMemoryStore.removeValue(forKey: key) persistInMemoryStore() } catch { - print("Error deleting file: \(error)") + Log.error("FileCache", "Error deleting file: \(error)") } } } @@ -188,7 +224,7 @@ actor FileCache: FileStore { let hash = SHA256.hash(data: data) return hash.compactMap { String(format: "%02x", $0) }.joined() case .supabase(let supabaseFile): - return supabaseFile.name + return supabaseFile.name.replacingOccurrences(of: "/", with: "_") } } @@ -215,7 +251,7 @@ actor FileCache: FileStore { self.cacheHit += 1 return data } catch { - print("Error reading file: \(error)") + Log.error("FileCache", "Error reading file: \(error)") throw error } } @@ -239,7 +275,7 @@ actor FileCache: FileStore { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 inMemoryStore = (try? decoder.decode([FileLocation: FileCacheEntry].self, from: data)) ?? [:] - print("FileCache: Loaded \(inMemoryStore.count) entries") + Log.debug("FileCache", "FileCache: Loaded \(inMemoryStore.count) entries") // This bizarre behavior happens when deploying a debug build to my phone. // The dictionary file exists, but all other cache files have been deleted. diff --git a/IngrediCheck/Store/FoodNotesStore.swift b/IngrediCheck/Store/FoodNotesStore.swift new file mode 100644 index 00000000..53fddf3c --- /dev/null +++ b/IngrediCheck/Store/FoodNotesStore.swift @@ -0,0 +1,489 @@ +// +// FoodNotesStore.swift +// IngrediCheck +// +// Created to centralize food notes/chip selection logic for reuse across the app. +// + +import SwiftUI +import Observation +import os + +@Observable +@MainActor +final class FoodNotesStore { + private let webService: WebService + private let onboardingStore: Onboarding + + // MARK: - State + + /// Current version for family-level optimistic updates + private var familyVersion: Int = 0 + + /// Current versions for member-specific optimistic updates, keyed by memberId (UUID string) + private var memberVersions: [String: Int] = [:] + + /// Tracks which entity currently owns the `onboardingStore.preferences`. + /// "Everyone" for family-level, or a member UUID string for member-level. + private var currentPreferencesOwnerKey: String? = nil + + /// Master cache of preferences for each member. + /// This is the source of truth for switching users. + /// Key: Member UUID string or "Everyone" + private var memberPreferencesCache: [String: Preferences] = [:] + + /// Union view preferences used for canvas/background cards (Everyone + all members) + /// This does not change when switching members in the edit sheet. + var canvasPreferences: Preferences = Preferences() + + /// Tracks which members have which items: [sectionName: [itemName: [memberIds]]] + /// Member IDs are UUID strings or "Everyone" for family-level items. + var itemMemberAssociations: [String: [String: [String]]] = [:] + + /// Loading state for food notes operations + var isLoadingFoodNotes: Bool = false + + /// Indicates if food notes have been loaded at least once (prevents showing loading on subsequent navigations) + private(set) var hasLoadedFoodNotes: Bool = false + + /// Misc notes set by IngrediBot, keyed by member UUID or "Everyone" + private(set) var memberMiscNotes: [String: [String]] = [:] + + /// Sync management - exposed for UI to show sync indicator + private(set) var isSyncing: Bool = false + private var syncDebounceTask: Task? = nil + private var pendingSyncMembers: Set = [] + + // MARK: - Summary State + + /// Cached summary of food notes from API + var foodNotesSummary: String? = nil + + /// Loading state for summary + private(set) var isLoadingSummary: Bool = false + + /// Debounce task for summary refresh + private var summaryRefreshTask: Task? = nil + + init(webService: WebService, onboardingStore: Onboarding) { + self.webService = webService + self.onboardingStore = onboardingStore + } + + // MARK: - Summary + + /// Public method to refresh summary - debounced to avoid rapid-fire calls + func refreshSummary() { + summaryRefreshTask?.cancel() + summaryRefreshTask = Task { + try? await Task.sleep(for: .milliseconds(500)) // Debounce + guard !Task.isCancelled else { return } + await loadFoodNotesSummaryInternal() + } + } + + /// Loads summary directly without debounce. Use from coordinated initial loads (e.g. HomeView). + func loadSummaryIfNeeded() async { + if foodNotesSummary == nil { + await loadFoodNotesSummaryInternal() + } + } + + private func loadFoodNotesSummaryInternal() async { + isLoadingSummary = true + defer { isLoadingSummary = false } + + Log.debug("FoodNotesStore", "Loading food notes summary...") + do { + let response = try await webService.fetchFoodNotesSummary() + foodNotesSummary = response?.summary + Log.debug("FoodNotesStore", "Summary loaded: \(foodNotesSummary ?? "nil")") + } catch { + Log.error("FoodNotesStore", "Failed to load summary: \(error)") + } + } + + // MARK: - Loading Food Notes + + /// Loads the union view (family + all members) from GET /ingredicheck/family/food-notes/all. + func loadFoodNotesAll() async { + Log.debug("FoodNotesStore", "loadFoodNotesAll: Starting to load food notes from backend") + + isLoadingFoodNotes = true + defer { isLoadingFoodNotes = false } + + do { + if let response = try await webService.fetchFoodNotesAll() { + Log.debug("FoodNotesStore", "loadFoodNotesAll: βœ… Received food notes data") + + // 1. Parse Family Note ("Everyone") + if let familyNote = response.familyNote { + familyVersion = familyNote.version + let prefs = convertContentToPreferences(content: familyNote.content, dynamicSteps: onboardingStore.dynamicSteps) + memberPreferencesCache["Everyone"] = prefs + memberMiscNotes["Everyone"] = extractMiscNotes(from: familyNote.content) + } else { + memberPreferencesCache["Everyone"] = Preferences() + memberMiscNotes["Everyone"] = [] + } + + // 2. Parse Member Notes + for (memberId, memberNote) in response.memberNotes { + let normalizedId = memberId.lowercased() + memberVersions[normalizedId] = memberNote.version + let prefs = convertContentToPreferences(content: memberNote.content, dynamicSteps: onboardingStore.dynamicSteps) + memberPreferencesCache[normalizedId] = prefs + memberMiscNotes[normalizedId] = extractMiscNotes(from: memberNote.content) + } + + // 3. Rebuild Associations and Canvas from the cache + rebuildAssociationsAndCanvasFromCache() + + Log.debug("FoodNotesStore", "loadFoodNotesAll: βœ… Successfully loaded and cached data") + hasLoadedFoodNotes = true + } else { + // No data, init empty + familyVersion = 0 + memberVersions = [:] + memberPreferencesCache = [:] + memberMiscNotes = [:] + itemMemberAssociations = [:] + canvasPreferences = Preferences() + hasLoadedFoodNotes = true + } + } catch { + Log.debug("FoodNotesStore", "loadFoodNotesAll: ❌ Failed to load food notes: \(error.localizedDescription)") + // Init empty on error to prevent crash + memberPreferencesCache = [:] + memberMiscNotes = [:] + itemMemberAssociations = [:] + canvasPreferences = Preferences() + } + } + + // MARK: - Member Switching + + /// Clears the current preferences owner key without saving. + /// Call before Onboarding.reset() to prevent stale state from corrupting the cache. + func clearCurrentPreferencesOwner() { + currentPreferencesOwnerKey = nil + } + + /// Clears cached preferences for a specific member so they start with a clean slate. + func clearMemberCache(for memberId: UUID) { + memberPreferencesCache[memberId.uuidString.lowercased()] = nil + } + + /// Switches the active preferences to the specified member. + /// Saves the current member's state to cache before switching. + func preparePreferencesForMember(selectedMemberId: UUID?) { + let newMemberKey = selectedMemberId?.uuidString.lowercased() ?? "Everyone" + + // 1. Save current preferences to cache for the OLD member + if let currentKey = currentPreferencesOwnerKey { + Log.debug("FoodNotesStore", "preparePreferencesForMember: Caching preferences for \(currentKey)") + memberPreferencesCache[currentKey] = onboardingStore.preferences + } + + // 2. Load preferences from cache for the NEW member + Log.debug("FoodNotesStore", "preparePreferencesForMember: Switching to \(newMemberKey)") + if let cachedPrefs = memberPreferencesCache[newMemberKey] { + onboardingStore.preferences = cachedPrefs + } else { + // If not in cache (e.g. new member), start empty + onboardingStore.preferences = Preferences() + memberPreferencesCache[newMemberKey] = Preferences() + } + + onboardingStore.updateSectionCompletionStatus() + currentPreferencesOwnerKey = newMemberKey + } + + // MARK: - Updates & Sync + + /// Called when the user makes a change in the UI. + /// Updates local cache, associations, canvas, and schedules sync. + func handleLocalPreferenceChange() { + guard let currentKey = currentPreferencesOwnerKey else { return } + + Log.debug("FoodNotesStore", "handleLocalPreferenceChange: Updating for \(currentKey)") + + // 1. Update Cache + memberPreferencesCache[currentKey] = onboardingStore.preferences + + // 2. Optimistically update Associations and Canvas + // We can do this by rebuilding or by diffing. + // For robustness, let's use the diff logic from applyLocalPreferencesOptimistic + // but adapted to use the cache as the source of truth. + updateAssociationsAndCanvas(for: currentKey, with: onboardingStore.preferences) + + // 3. Schedule Sync + scheduleSync(for: currentKey) + } + + // Kept for compatibility with View calls, but delegates to handleLocalPreferenceChange + func applyLocalPreferencesOptimistic() { + handleLocalPreferenceChange() + } + + // Kept for compatibility with View calls + func updateFoodNotes() { + // No-op, handled by handleLocalPreferenceChange + } + + // MARK: - Internal Logic + + private func updateAssociationsAndCanvas(for memberKey: String, with newPrefs: Preferences) { + // This is the same logic as before, but we know exactly who we are updating. + var newAssociations = itemMemberAssociations + var newCanvas = canvasPreferences + + for step in onboardingStore.dynamicSteps { + let sectionName = step.header.name + let localPreference = newPrefs.sections[sectionName] + + // 1. Identify what the member currently has in associations + let serverItemsForSection = newAssociations[sectionName] ?? [:] + let serverSelectedItems = serverItemsForSection.filter { $0.value.contains(memberKey) }.map { $0.key } + let serverSelectedSet = Set(serverSelectedItems) + + // 2. Identify what the member has in newPrefs + var localSelectedSet = Set() + if case .list(let items) = localPreference { + localSelectedSet = Set(items) + } else if case .nested(let nestedDict) = localPreference { + localSelectedSet = Set(nestedDict.values.flatMap { $0 }) + } + + // 3. Compute diffs + let toAdd = localSelectedSet.subtracting(serverSelectedSet) + let toRemove = serverSelectedSet.subtracting(localSelectedSet) + + // 4. Handle Removals + for item in toRemove { + if var members = newAssociations[sectionName]?[item] { + members.removeAll { $0 == memberKey } + if members.isEmpty { + newAssociations[sectionName]?[item] = nil + // Remove from canvas + removeFromCanvas(canvas: &newCanvas, section: sectionName, item: item) + } else { + newAssociations[sectionName]?[item] = members + } + } + } + + // 5. Handle Additions + for item in toAdd { + if !newAssociations[sectionName, default: [:]][item, default: []].contains(memberKey) { + newAssociations[sectionName, default: [:]][item, default: []].append(memberKey) + } + // Add to canvas + addToCanvas(canvas: &newCanvas, section: sectionName, item: item, localPref: localPreference) + } + } + + itemMemberAssociations = newAssociations + canvasPreferences = newCanvas + } + + private func removeFromCanvas(canvas: inout Preferences, section: String, item: String) { + switch canvas.sections[section] { + case .list(var items): + items.removeAll { $0 == item } + canvas.sections[section] = items.isEmpty ? nil : .list(items) + case .nested(var nestedDict): + for (nestedKey, var items) in nestedDict { + items.removeAll { $0 == item } + nestedDict[nestedKey] = items.isEmpty ? nil : items + } + let cleaned = nestedDict.compactMapValues { $0 } + canvas.sections[section] = cleaned.isEmpty ? nil : .nested(cleaned) + case nil: + break + } + } + + private func addToCanvas(canvas: inout Preferences, section: String, item: String, localPref: PreferenceValue?) { + if case .nested(let localNested) = localPref { + if let nestedKey = localNested.first(where: { $0.value.contains(item) })?.key { + if case .nested(var existingNested) = canvas.sections[section] { + var items = existingNested[nestedKey] ?? [] + if !items.contains(item) { + items.append(item) + existingNested[nestedKey] = items + } + canvas.sections[section] = .nested(existingNested) + } else { + canvas.sections[section] = .nested([nestedKey: [item]]) + } + } + } else { + if case .list(var existingItems) = canvas.sections[section] { + if !existingItems.contains(item) { + existingItems.append(item) + canvas.sections[section] = .list(existingItems) + } + } else { + canvas.sections[section] = .list([item]) + } + } + } + + private func rebuildAssociationsAndCanvasFromCache() { + var associations: [String: [String: [String]]] = [:] + var unifiedContent: [String: Any] = [:] // We can reuse the buildContent logic if we want, or just manual + + // We need to merge all cached preferences into one canvas view + // And build associations + + var newCanvas = Preferences() + + for (memberKey, prefs) in memberPreferencesCache { + updateAssociationsAndCanvas(for: memberKey, with: prefs) + } + } + + // MARK: - Sync + + private func scheduleSync(for memberKey: String) { + pendingSyncMembers.insert(memberKey) + syncDebounceTask?.cancel() + + syncDebounceTask = Task { + try? await Task.sleep(nanoseconds: 1_500_000_000) // 1.5s + if Task.isCancelled { return } + await performPendingSyncs() + } + } + + private func performPendingSyncs() async { + guard !isSyncing else { return } + let members = pendingSyncMembers + pendingSyncMembers.removeAll() + + isSyncing = true + defer { isSyncing = false } + + for memberKey in members { + await syncMember(memberKey) + } + + // After all syncs complete, refresh the summary + refreshSummary() + } + + private func syncMember(_ memberKey: String) async { + guard let prefs = memberPreferencesCache[memberKey] else { return } + + Log.debug("FoodNotesStore", "πŸ“€ [FoodNotesStore] syncMember: Syncing \(memberKey)") + + var content = buildContentFromPreferences(preferences: prefs, dynamicSteps: onboardingStore.dynamicSteps) + + // Preserve misc notes that were set by IngrediBot + if let miscNotes = memberMiscNotes[memberKey], !miscNotes.isEmpty { + var preferencesDict = content["preferences"] as? [String: Any] ?? [:] + preferencesDict["misc"] = miscNotes + content["preferences"] = preferencesDict + } + + let isEveryone = (memberKey == "Everyone") + var version = isEveryone ? familyVersion : (memberVersions[memberKey] ?? 0) + + do { + let response: WebService.FoodNotesResponse + if !isEveryone { + response = try await webService.updateMemberFoodNotes( + memberId: memberKey, + content: content, + version: version + ) + memberVersions[memberKey] = response.version + } else { + response = try await webService.updateFoodNotes(content: content, version: version) + familyVersion = response.version + } + Log.debug("FoodNotesStore", "βœ… [FoodNotesStore] syncMember: Success \(memberKey) v\(response.version)") + } catch let error as WebService.VersionMismatchError { + Log.warning("FoodNotesStore", "⚠️ [FoodNotesStore] syncMember: Version mismatch \(memberKey)") + // Re-extract misc from server's current content before retrying + memberMiscNotes[memberKey] = extractMiscNotes(from: error.currentNote.content) + if isEveryone { familyVersion = error.currentNote.version } + else { memberVersions[memberKey] = error.currentNote.version } + await syncMember(memberKey) // Retry + } catch { + Log.error("FoodNotesStore", "❌ [FoodNotesStore] syncMember: Failed \(error)") + } + } + + // MARK: - Helpers + + func convertContentToPreferences(content: [String: Any], dynamicSteps: [DynamicStep]) -> Preferences { + var preferences = Preferences() + for (stepId, stepContent) in content { + guard let step = dynamicSteps.first(where: { $0.id == stepId }) else { continue } + let sectionName = step.header.name + + if let itemsArray = stepContent as? [[String: Any]] { + let itemNames = itemsArray.compactMap { $0["name"] as? String } + if !itemNames.isEmpty { preferences.sections[sectionName] = .list(itemNames) } + } else if let nestedDict = stepContent as? [String: Any] { + var prefNested: [String: [String]] = [:] + for (key, val) in nestedDict { + if let arr = val as? [[String: Any]] { + let names = arr.compactMap { $0["name"] as? String } + if !names.isEmpty { prefNested[key] = names } + } + } + if !prefNested.isEmpty { preferences.sections[sectionName] = .nested(prefNested) } + } + } + return preferences + } + + func buildContentFromPreferences(preferences: Preferences, dynamicSteps: [DynamicStep]) -> [String: Any] { + var content: [String: Any] = [:] + for step in dynamicSteps { + let sectionName = step.header.name + guard let val = preferences.sections[sectionName] else { + // Empty section + content[step.id] = (step.type == .type1) ? [[String:Any]]() : [String:Any]() + continue + } + + switch val { + case .list(let items): + let arr = items.map { name in + let icon = step.content.options?.first(where: { $0.name == name })?.icon ?? "" + return ["name": name, "iconName": icon] + } + content[step.id] = arr + case .nested(let nested): + var nestedContent: [String: Any] = [:] + // Logic to map nested items to their structure (subSteps or regions) + // Simplified for brevity, assuming structure matches keys + for (key, items) in nested { + let arr = items.map { name in + // Icon lookup would go here + return ["name": name, "iconName": ""] + } + nestedContent[key] = arr + } + content[step.id] = nestedContent + } + } + return content + } + + private func extractMiscNotes(from content: [String: Any]) -> [String] { + guard let preferences = content["preferences"] as? [String: Any], + let misc = preferences["misc"] as? [String] else { + return [] + } + return misc + } + + // Stub for loadFoodNotesForMember/Family if views still call them (they shouldn't with new logic) + func loadFoodNotesForMember(memberId: String) async {} + func loadFoodNotesForFamily() async {} +} diff --git a/IngrediCheck/Store/MemojiModels.swift b/IngrediCheck/Store/MemojiModels.swift new file mode 100644 index 00000000..48f2f4ac --- /dev/null +++ b/IngrediCheck/Store/MemojiModels.swift @@ -0,0 +1,238 @@ +import Foundation + +struct MemojiRequest: Encodable { + let familyType: String + let gesture: String + let hair: String + let skinTone: String + let accessories: [String] + let background: String + let size: String + let model: String + let subscriptionTier: String + let colorTheme: String? // Color theme for clothing and background style + let mood: String? // Visual description of facial expression and body language - TODO: Re-enable when backend supports it + + // Custom encoding to omit mood field when nil (not ready on backend yet) + enum CodingKeys: String, CodingKey { + case familyType, gesture, hair, skinTone, accessories, background, size, model, subscriptionTier, colorTheme, mood + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(familyType, forKey: .familyType) + try container.encode(gesture, forKey: .gesture) + try container.encode(hair, forKey: .hair) + try container.encode(skinTone, forKey: .skinTone) + try container.encode(accessories, forKey: .accessories) + try container.encode(background, forKey: .background) + try container.encode(size, forKey: .size) + try container.encode(model, forKey: .model) + try container.encode(subscriptionTier, forKey: .subscriptionTier) + try container.encodeIfPresent(colorTheme, forKey: .colorTheme) + // Omit mood field when nil - will re-enable when backend is ready + // try container.encodeIfPresent(mood, forKey: .mood) + } +} + +struct MemojiResponse: Decodable { + let success: Bool + let cached: Bool? + let imageUrl: String? + + private enum CodingKeys: String, CodingKey { + case success + case cached + case imageUrl + case image_url + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + success = try container.decode(Bool.self, forKey: .success) + cached = try container.decodeIfPresent(Bool.self, forKey: .cached) + // Support both camelCase (`imageUrl`) and snake_case (`image_url`) from the API + imageUrl = + (try? container.decodeIfPresent(String.self, forKey: .imageUrl)) ?? + (try? container.decodeIfPresent(String.self, forKey: .image_url)) + } +} + +struct MemojiErrorResponse: Decodable { + let error: MemojiError + + struct MemojiError: Decodable { + let message: String + let details: String? + } +} + +struct MemojiSelection { + var familyType: String + var gesture: String + var hair: String + var skinTone: String + var accessory: String? + var colorThemeIcon: String? + + var backgroundHex: String? { + MemojiSelection.colorHexMap[colorThemeIcon ?? ""] + } + + // Map hair icon to API format + private func mapHairToAPIFormat(_ hairIcon: String) -> String { + switch hairIcon.lowercased() { + case "short-hair": return "short" + case "long-hair": return "long" + case "curly-hair": return "curly" + case "medium-curely": return "curly" + case "short spiky": return "short spiky" + case "braided": return "braided" + case "ponytail": return "ponytail" + case "bun": return "bun" + case "bald": return "bald" + default: return hairIcon.lowercased() + } + } + + // Map color theme icon to API format + private func mapColorThemeToAPIFormat(_ colorThemeIcon: String?) -> String? { + guard let icon = colorThemeIcon else { return nil } + switch icon.lowercased() { + case "pastel-blue": return "pastel-blue" + case "warm-pink": return "warm-pink" + case "soft-green": return "soft-green" + case "lavender": return "lavender" + case "cream": return "cream" + case "mint": return "mint" + case "transparent": return "transparent" + default: return icon.lowercased() + } + } + + // Generate visual description of facial expression and body language + private func generateVisualMood() -> String { + // TEST: Simple test to verify mood parameter is working - always return "angry" +// return "β€œStern expression with furrowed brows and narrowed eyes.Firm, slightly clenched jaw with tight lips. Intense focused gaze, controlled and serious." + + //\ COMMENTED OUT - Original mood generation logic + let familyTypeLower = familyType.lowercased() + + // Check category: baby, young, adult, or older + let isBaby = familyTypeLower.contains("baby") + let isYoung = familyTypeLower.contains("young") + let isOlder = familyTypeLower.contains("grandfather") || familyTypeLower.contains("grandmother") + + if isBaby { + // Baby (0-4) - very young with one tooth showing + let babyVisualMoodStrings = [ + "smiling widely with one tooth showing in the middle and bright, cheerful eyes", + "grinning with open mouth showing one front tooth in the middle and crinkled eyes", + "laughing with one tooth visible in the middle, head slightly tilted, and joyful expression", + "beaming with wide smile, one tooth showing in the middle, and sparkling eyes", + "smiling with one front tooth visible in the middle and bright, friendly eyes", + "grinning ear to ear with one tooth showing in the middle and raised eyebrows", + "laughing with open mouth, one tooth in the middle, crinkled eyes, and joyful expression", + "smiling broadly with one tooth showing in the middle and cheerful, energetic expression", + "grinning widely with one front tooth visible in the middle and happy, bright eyes", + "beaming with one tooth showing in the middle, radiant smile, and cheerful eyes", + "smiling with one tooth in the middle, warm eyes, and genuine, joyful expression", + "laughing with head back, one tooth visible in the middle, and very happy expression", + "grinning with one tooth showing in the middle, wide smile, raised cheeks, and joyful expression", + "smiling widely with one front tooth in the middle and bright, cheerful expression", + "laughing with one tooth visible in the middle, open mouth, and joyful, energetic expression" + ] + return babyVisualMoodStrings.randomElement() ?? "smiling widely with one tooth showing in the middle and bright, cheerful eyes" + } else if isYoung { + // Young/Teenager (4-25) - cool, energetic, stylish + let youngVisualMoodStrings = [ + "smiling with cool, confident expression and bright, energetic eyes", + "grinning with stylish smile, head slightly tilted, and trendy, youthful expression", + "laughing with confident, cool expression and bright, playful eyes", + "beaming with hip, modern smile and energetic, fashionable expression", + "smiling with cool, relaxed expression and bright, confident eyes", + "grinning with trendy smile, raised eyebrows, and stylish, youthful expression", + "laughing with cool, open smile and bright, energetic, modern expression", + "smiling broadly with confident, stylish expression and positive, cool energy", + "grinning widely with hip smile and happy, trendy, youthful expression", + "beaming with cool, radiant smile and bright, fashionable eyes", + "smiling with confident, modern expression and genuine, cool eyes", + "laughing with head back, stylish smile, and very happy, energetic expression", + "grinning with cool, wide smile, raised cheeks, and trendy, joyful expression", + "smiling widely with confident, modern expression and bright, cool eyes", + "laughing with open mouth, stylish expression, and joyful, fashionable energy" + ] + return youngVisualMoodStrings.randomElement() ?? "smiling with cool, confident expression and bright, energetic eyes" + } else if isOlder { + // Older adult (grandfather/grandmother) - gentle, wise + let olderVisualMoodStrings = [ + "smiling warmly with gentle, wise eyes and kind expression", + "grinning with soft smile showing gentle wrinkles around eyes and content expression", + "laughing with head slightly tilted, warm eyes, and joyful, peaceful expression", + "beaming with gentle smile, bright eyes, and serene, happy expression", + "smiling with warm, friendly eyes and relaxed, content expression", + "grinning with soft laugh, gentle expression, and kind, cheerful eyes", + "smiling with closed mouth, wise eyes, and peaceful, content expression", + "laughing with gentle smile, crinkled eyes showing wisdom, and joyful expression", + "smiling broadly with warm, kind eyes and positive, serene energy", + "grinning widely with gentle smile, bright eyes, and happy, peaceful expression", + "smiling with warm, wise eyes and genuine, content expression", + "laughing with head back, gentle smile, and very happy, peaceful expression", + "beaming with radiant, gentle smile and bright, kind eyes", + "smiling with relaxed, wise expression and friendly, content eyes", + "grinning with gentle smile, raised cheeks showing wrinkles, and joyful, peaceful expression" + ] + return olderVisualMoodStrings.randomElement() ?? "smiling warmly with gentle, wise eyes and kind expression" + } else { + // Adult (father/mother) - standard cheerful + let adultVisualMoodStrings = [ + "smiling widely with bright eyes and cheerful expression", + "grinning with open mouth showing teeth and crinkled eyes", + "laughing with head slightly tilted back and joyful expression", + "beaming with wide smile and sparkling eyes", + "smiling warmly with gentle eyes and relaxed expression", + "grinning ear to ear with raised eyebrows and happy expression", + "smiling with closed mouth and bright, friendly eyes", + "laughing with open mouth, crinkled eyes, and joyful expression", + "smiling broadly with cheerful face and positive energy", + "grinning widely with bright smile and happy, energetic expression", + "smiling with warm eyes and genuine, joyful expression", + "laughing with head back, open mouth, and very happy expression", + "beaming with radiant smile and bright, cheerful eyes", + "smiling with relaxed, content expression and friendly eyes", + "grinning with wide smile, raised cheeks, and joyful expression" + ] + return adultVisualMoodStrings.randomElement() ?? "smiling widely with bright eyes and cheerful expression" + } + + } + + func toMemojiRequest() -> MemojiRequest { + MemojiRequest( + familyType: familyType, + gesture: gesture, + hair: mapHairToAPIFormat(hair), + skinTone: skinTone, + accessories: accessory.map { [$0] } ?? [], + background: "transparent", // user color applied in UI + size: "1024x1024", + model: "gpt-image-1", + subscriptionTier: "monthly_basic", + colorTheme: mapColorThemeToAPIFormat(colorThemeIcon), + // TODO: Re-enable when backend supports mood field + // mood: generateVisualMood() + mood: nil // Temporarily disabled - backend not ready yet + ) + } + + private static let colorHexMap: [String: String] = [ + "pastel-blue": "A7C7E7", + "warm-pink": "F6B0C3", + "soft-green": "A8E6A1", + "lavender": "C7B7E5", + "cream": "F5E6C8", + "mint": "B8F2E6", + "transparent": "FFFFFF00" + ] +} + diff --git a/IngrediCheck/Store/MemojiStore.swift b/IngrediCheck/Store/MemojiStore.swift new file mode 100644 index 00000000..b11191c1 --- /dev/null +++ b/IngrediCheck/Store/MemojiStore.swift @@ -0,0 +1,153 @@ +import SwiftUI +import Observation +import UIKit + +@Observable +@MainActor +final class MemojiStore { + var image: UIImage? + /// Storage path inside the `memoji-images` bucket for the last generated memoji. + /// Example: `2025/01/.png`. Used as `imageFileHash` when assigning avatars + /// so we can load directly from Supabase without re-uploading the PNG. + var imageStoragePath: String? + var backgroundColorHex: String? + var isGenerating = false + + // Display context for UI (e.g., show typed name in Generate Avatar header) + var displayName: String? = nil + + // Track where GenerateAvatar was navigated from for back button navigation + var previousRouteForGenerateAvatar: BottomSheetRoute? = nil + + // Store avatar generation selections to preserve state when navigating back + var selectedFamilyMemberName: String = "young-son" + var selectedFamilyMemberImage: String = "memoji_1" + var selectedTool: String = "family-member" + var selectedGestureIcon: String? = nil + var selectedHairStyleIcon: String? = nil + var selectedSkinToneIcon: String? = nil + var selectedAccessoriesIcon: String? = nil + var selectedColorThemeIcon: String? = nil + var currentToolIndex: Int = 0 + + deinit { + Log.debug("MemojiStore", "❌ MemojiStore deallocated") + } + + func generate(selection: MemojiSelection, coordinator: AppNavigationCoordinator) async { + Log.debug("MemojiStore", "═══════════════════════════════════════════════════") + Log.debug("MemojiStore", "generate() called - Thread.isMainThread=\(Thread.isMainThread)") + isGenerating = true + image = nil + imageStoragePath = nil + Log.debug("MemojiStore", "image set to nil, backgroundColorHex=\(selection.backgroundHex ?? "nil")") + backgroundColorHex = selection.backgroundHex + coordinator.navigateInBottomSheet(.bringingYourAvatar) + Log.debug("MemojiStore", "Navigated to .bringingYourAvatar") + + do { + Log.debug("MemojiStore", "Calling generateMemojiImage...") + let generated = try await generateMemojiImage(requestBody: selection.toMemojiRequest()) + + // CRITICAL: Check for cancellation before updating state + guard !Task.isCancelled else { + Log.debug("MemojiStore", "⚠️ Task cancelled before assigning image") + isGenerating = false + return + } + + Log.debug("MemojiStore", "generateMemojiImage returned - Thread.isMainThread=\(Thread.isMainThread)") + + // Check for cancellation before processing + guard !Task.isCancelled else { + Log.debug("MemojiStore", "⚠️ Task cancelled before image assignment") + isGenerating = false + return + } + + // CRITICAL: Assign image and storage path first to ensure they're properly stored + // before any access. Assignment is on main thread since MemojiStore is @MainActor. + image = generated.image + imageStoragePath = generated.storagePath + Log.debug("MemojiStore", "βœ… image assigned to memojiStore.image - Thread.isMainThread=\(Thread.isMainThread)") + + // Access size for logging only, wrapped in MainActor.run for thread safety + // This ensures UIImage internal state is accessed on main thread + let (width, height) = await MainActor.run { + let w = generated.image.size.width + let h = generated.image.size.height + Log.debug("MemojiStore", "image.size - width=\(w), height=\(h), Thread.isMainThread=\(Thread.isMainThread)") + return (w, h) + } + + // Check again before navigation + guard !Task.isCancelled else { + Log.debug("MemojiStore", "⚠️ Task cancelled before navigation") + isGenerating = false + return + } + + // Ensure one render cycle completes before navigation to prevent transition crashes + // This gives SwiftUI time to properly commit the image assignment + try? await Task.sleep(nanoseconds: 16_000_000) // ~1 frame at 60fps + + guard !Task.isCancelled else { + Log.debug("MemojiStore", "⚠️ Task cancelled during navigation delay") + isGenerating = false + return + } + + Log.debug("MemojiStore", "About to navigate to .meetYourAvatar") + coordinator.navigateInBottomSheet(.meetYourAvatar) + Log.debug("MemojiStore", "βœ… Navigated to .meetYourAvatar") + } catch { + // Log error so we can debug why memoji generation failed + Log.debug("MemojiStore", "❌ Memoji generation failed: \(error.localizedDescription)") + Log.debug("MemojiStore", "❌ Error debugging info: \(error)") + + // Check for cancellation before updating state + guard !Task.isCancelled else { + Log.debug("MemojiStore", "⚠️ Task cancelled during error handling") + isGenerating = false + return + } + + // Show toast error message + let errorMessage: String + if let memojiError = error as? AIMemojiError { + errorMessage = memojiError.localizedDescription + } else { + errorMessage = "Failed to generate avatar. Please try again." + } + + ToastManager.shared.show(message: errorMessage, type: .error, duration: 4.0) + Log.debug("MemojiStore", "βœ… Toast error shown: \(errorMessage)") + + // Navigate back to the previous screen instead of showing avatar + // Check if we have a previous route to go back to + if let previousRoute = previousRouteForGenerateAvatar { + Log.debug("MemojiStore", "Navigating back to previous route: \(previousRoute)") + coordinator.navigateInBottomSheet(previousRoute) + } else { + // If no previous route, go back to generateAvatar screen + Log.debug("MemojiStore", "No previous route, navigating back to .generateAvatar") + coordinator.navigateInBottomSheet(.generateAvatar) + } + + // Don't set fallback image - keep it nil so user can try again + image = nil + imageStoragePath = nil + } + + // Final cancellation check before clearing the generating flag + guard !Task.isCancelled else { + Log.debug("MemojiStore", "⚠️ Task cancelled before clearing isGenerating") + return + } + + isGenerating = false + Log.debug("MemojiStore", "generate() completed - isGenerating=false") + Log.debug("MemojiStore", "═══════════════════════════════════════════════════") + } +} + diff --git a/IngrediCheck/Store/NetworkState.swift b/IngrediCheck/Store/NetworkState.swift index 582cbf32..ece35459 100644 --- a/IngrediCheck/Store/NetworkState.swift +++ b/IngrediCheck/Store/NetworkState.swift @@ -1,5 +1,6 @@ import Network import Foundation +import os @Observable final class NetworkState { @@ -11,7 +12,7 @@ import Foundation monitor = NWPathMonitor() monitor?.pathUpdateHandler = { path in DispatchQueue.main.async { - print("NetworkMonitor: \(path)") + Log.debug("NetworkState", "NetworkMonitor: \(path)") if path.status == .satisfied { self.connected = true } else { diff --git a/IngrediCheck/Store/Onboarding.swift b/IngrediCheck/Store/Onboarding.swift new file mode 100644 index 00000000..8d732a47 --- /dev/null +++ b/IngrediCheck/Store/Onboarding.swift @@ -0,0 +1,279 @@ +// +// OnboardingModel.swift +// IngrediCheckPreview +// +// Created by Gunjan Haldar on 14/10/25. +// + +import Foundation +import SwiftUI +import Combine + + +struct ChipsModel: Identifiable, Equatable { + let id = UUID().uuidString + var name: String + var icon: String? +} + +struct SectionedChipModel: Identifiable { + var id = UUID().uuidString + var title: String + var subtitle: String? + var chips: [ChipsModel] +} + +enum OnboardingFlowType: String, Codable { + case individual + case family + case singleMember // For adding a specific family member from home +} + +enum OnboardingScreenId: String { + case allergies + case intolerances + case healthConditions + case lifeStage + case region + case avoid + case lifeStyle + case nutrition + case ethical + case taste +} + +struct OnboardingScreen: Identifiable { + var id = UUID() + var stepId: String // Use step ID from JSON instead of enum + var buildView: (OnboardingFlowType, Binding) -> AnyView +} + +struct OnboardingSection: Identifiable { + var id = UUID() + var name: String + var screens: [OnboardingScreen] + var isComplete: Bool = false +} + +@MainActor +class Onboarding: ObservableObject { + private static let preferencesCacheKey = "onboardingPreferencesCache" + + @Published var isOnboardingCompleted: Bool = false + @Published var onboardingFlowtype: OnboardingFlowType = .individual + @Published var sections: [OnboardingSection] = [] + /// Dynamic step configuration loaded from `dynamicJsonData.json`. Used as + /// the single source of truth for ordering / titles / icons. + @Published var dynamicSteps: [DynamicStep] = [] + @Published var currentSectionIndex: Int = 0 + @Published var currentScreenIndex: Int = 0 + @Published var maxVisitedSectionIndex: Int = 0 + @Published var memberName: String? = nil + @Published var memberColor: String? = nil + @Published var isUploading: Bool = false + @Published var uploadError: String? + @Published var preferences: Preferences = Preferences() { + didSet { + Self.writeCachedPreferences(preferences) + } + } + + + var progress: Double { + guard !sections.isEmpty else { return 0 } + let complete = sections.filter{ $0.isComplete }.count + return Double(complete) / Double(sections.count) + } + + init( + onboardingFlowtype: OnboardingFlowType, + ) { + self.onboardingFlowtype = onboardingFlowtype + + // Load dynamic steps from JSON and derive sections from them so that + // tag bar, progress, and canvas summary always stay in sync with the + // configuration file instead of hard-coded arrays. + let steps = DynamicStepsProvider.loadSteps() + self.dynamicSteps = steps + self.sections = steps.map { step in + let screen = OnboardingScreen( + stepId: step.id, // Use step ID directly from JSON + // The legacy `buildView` is no longer used for rendering – the + // bottom sheet now drives UI via `DynamicOnboardingStepView`. + // We keep this closure only so existing code remains compile-safe. + buildView: { _, _ in AnyView(EmptyView()) } + ) + + return OnboardingSection( + name: step.header.name, + screens: [screen] + ) + } + + if let cached = Self.readCachedPreferences() { + self.preferences = cached + } + } + + var currentSection: OnboardingSection { + sections[currentSectionIndex] + } + + var currentScreen: OnboardingScreen { + currentSection.screens[currentScreenIndex] + } + + // MARK: - Dynamic steps helpers + + /// Get step by step ID (from JSON) + func step(for stepId: String) -> DynamicStep? { + dynamicSteps.first { $0.id == stepId } + } + + /// Get step by index + func step(at index: Int) -> DynamicStep? { + guard dynamicSteps.indices.contains(index) else { return nil } + return dynamicSteps[index] + } + + /// Get current step + var currentStep: DynamicStep? { + guard dynamicSteps.indices.contains(currentSectionIndex) else { return nil } + return dynamicSteps[currentSectionIndex] + } + + /// Get current step ID + var currentStepId: String? { + currentStep?.id + } + + /// Get next step ID (returns nil if at last step) + var nextStepId: String? { + guard currentSectionIndex < dynamicSteps.count - 1 else { return nil } + return dynamicSteps[currentSectionIndex + 1].id + } + + /// Check if current step is the last one + var isLastStep: Bool { + currentSectionIndex >= dynamicSteps.count - 1 + } + + /// Get first step ID + var firstStepId: String? { + dynamicSteps.first?.id + } + + func next() { + // this func will have upload logic to supabase. + moveToNextStep() + } + + func moveToNextStep() { + if currentScreenIndex < currentSection.screens.count - 1 { + currentScreenIndex += 1 + } else { + var section = sections[currentSectionIndex] + section.isComplete = true + sections[currentSectionIndex] = section + if currentSectionIndex < sections.count - 1 { + currentSectionIndex += 1 + if currentSectionIndex > maxVisitedSectionIndex { + maxVisitedSectionIndex = currentSectionIndex + } + currentScreenIndex = 0 + } + } + } + + /// Reset onboarding state to start from the beginning + func reset(flowType: OnboardingFlowType, memberName: String? = nil, memberColor: String? = nil) { + onboardingFlowtype = flowType + self.memberName = memberName + self.memberColor = memberColor + currentSectionIndex = 0 + currentScreenIndex = 0 + maxVisitedSectionIndex = 0 + preferences = Preferences() + isOnboardingCompleted = false + + UserDefaults.standard.removeObject(forKey: Self.preferencesCacheKey) + + // Reset all sections to incomplete + sections = sections.map { section in + var updatedSection = section + updatedSection.isComplete = false + return updatedSection + } + } + + /// Update section completion status based on whether they have data in preferences + func updateSectionCompletionStatus() { + for (index, section) in sections.enumerated() { + guard let stepId = section.screens.first?.stepId, + let step = step(for: stepId) else { continue } + + let sectionName = step.header.name + + // Check if section has data in preferences + var hasData = false + if let value = preferences.sections[sectionName] { + switch value { + case .list(let items): + hasData = !items.isEmpty + case .nested(let nestedDict): + // Check if any nested section has data + hasData = nestedDict.values.contains { !$0.isEmpty } + } + } + + // Update completion status if it differs + if sections[index].isComplete != hasData { + var updatedSection = sections[index] + updatedSection.isComplete = hasData + sections[index] = updatedSection + } + } + } + func restoreState(forStepId stepId: String) { + guard let stepIndex = dynamicSteps.firstIndex(where: { $0.id == stepId }) else { return } + + // Find which section this step belongs to + var accumulatedSteps = 0 + for (secIndex, section) in sections.enumerated() { + let screenCount = section.screens.count + if stepIndex < accumulatedSteps + screenCount { + // Found the section + currentSectionIndex = secIndex + if currentSectionIndex > maxVisitedSectionIndex { + maxVisitedSectionIndex = currentSectionIndex + } + currentScreenIndex = stepIndex - accumulatedSteps + return + } + accumulatedSteps += screenCount + } + } + + /// Set state to the very last step (used for restoration when user is at Fine Tune or Summary) + func restoreToLastStep() { + if !sections.isEmpty { + currentSectionIndex = sections.count - 1 + if let lastSection = sections.last, !lastSection.screens.isEmpty { + currentScreenIndex = lastSection.screens.count - 1 + } + } + } + + private static func readCachedPreferences() -> Preferences? { + guard let rawValue = UserDefaults.standard.data(forKey: Onboarding.preferencesCacheKey), + let data = try? JSONDecoder().decode(Preferences.self, from: rawValue) else { + return nil + } + return data + } + + private static func writeCachedPreferences(_ preferences: Preferences) { + guard let rawData = try? JSONEncoder().encode(preferences) else { return } + UserDefaults.standard.set(rawData, forKey: Onboarding.preferencesCacheKey) + } +} diff --git a/IngrediCheck/Store/OnboardingState.swift b/IngrediCheck/Store/OnboardingState.swift index 321e0597..6202b785 100644 --- a/IngrediCheck/Store/OnboardingState.swift +++ b/IngrediCheck/Store/OnboardingState.swift @@ -1,5 +1,6 @@ import SwiftUI import Foundation +import os @Observable class OnboardingState { @@ -21,7 +22,7 @@ import Foundation disclaimerShown: false ) } - print("On Start OnboardingState: \(data)") + Log.debug("OnboardingState", "On Start OnboardingState: \(data)") return data } diff --git a/IngrediCheck/Store/RemoteOnboardingMetadata.swift b/IngrediCheck/Store/RemoteOnboardingMetadata.swift new file mode 100644 index 00000000..cc6a7488 --- /dev/null +++ b/IngrediCheck/Store/RemoteOnboardingMetadata.swift @@ -0,0 +1,66 @@ +// +// RemoteOnboardingMetadata.swift +// IngrediCheck +// +// Created to persist onboarding state in Supabase raw_user_meta_data +// + +import Foundation + +/// High-level "where is the user in onboarding?" - covers all flows +enum RemoteOnboardingStage: String, Codable { + case none + case preOnboarding // heyThere / blank / letsGetStarted / invite code flow + case choosingFlow // whosThisFor, letsMeetYourIngrediFam, etc. + case dietaryIntro // dietaryPreferencesAndRestrictions + dietaryPreferencesSheet + case dynamicOnboarding // MainCanvas + onboardingStep(stepId:) + case fineTune // fineTuneYourExperience + case completed // home / summary +} + +/// Bottom sheet route identifier for serialization +enum BottomSheetRouteIdentifier: String, Codable { + case alreadyHaveAnAccount + case welcomeBack + case doYouHaveAnInviteCode + case enterInviteCode + case whosThisFor + case letsMeetYourIngrediFam + case whatsYourName + case addMoreMembers + case addMoreMembersMinimal + case wouldYouLikeToInvite + case addPreferencesForMember + case generateAvatar + case bringingYourAvatar + case meetYourAvatar + case yourCurrentAvatar + case setUpAvatarFor + case updateAvatar + case dietaryPreferencesSheet + case allSetToJoinYourFamily + case onboardingStep + case fineTuneYourExperience + case homeDefault + case chatIntro + case chatConversation + case workingOnSummary + case editMember + case meetYourProfileIntro + case meetYourProfile + case preferencesAddedSuccess + case readyToScanFirstProduct + case seeHowScanningWorks + case quickAccessNeeded + case loginToContinue +} + +/// Full snapshot of onboarding position stored in raw_user_meta_data +struct RemoteOnboardingMetadata: Codable { + var flowType: OnboardingFlowType? + var stage: RemoteOnboardingStage? + var currentStepId: String? // from BottomSheetRoute.onboardingStep(stepId:) + var bottomSheetRoute: BottomSheetRouteIdentifier? // which bottom sheet route + var bottomSheetRouteParam: String? // associated value (name, isFamilyFlow as string, etc.) +} + diff --git a/IngrediCheck/Store/ScanHistoryStore.swift b/IngrediCheck/Store/ScanHistoryStore.swift new file mode 100644 index 00000000..0cb3c77a --- /dev/null +++ b/IngrediCheck/Store/ScanHistoryStore.swift @@ -0,0 +1,218 @@ +import SwiftUI + +/// Centralized store for managing scan history across the app +/// Provides single source of truth for scan data, caching, and barcode mapping +@Observable final class ScanHistoryStore { + + // MARK: - Public State + + /// All loaded scans (ordered by most recent first) + @MainActor private(set) var scans: [DTO.Scan] = [] + + /// Cache of scans by scanId for quick lookup + @MainActor private(set) var scanCache: [String: DTO.Scan] = [:] + + /// Map of barcode -> scanId for quick barcode lookups + @MainActor private(set) var barcodeToScanIdMap: [String: String] = [:] + + /// Loading state + @MainActor private(set) var isLoading: Bool = false + + /// Last fetch error (if any) + @MainActor private(set) var lastError: Error? + + /// Whether there are more scans to load + @MainActor private(set) var hasMore: Bool = true + + // MARK: - Dependencies + + private let webService: WebService + + // MARK: - Init + + init(webService: WebService) { + self.webService = webService + } + + // MARK: - Public Methods + + /// Load next page of history if available + @MainActor + func loadMore() async { + guard hasMore && !isLoading else { return } + await loadHistory(limit: 20, offset: scans.count) + } + + /// Load scan history from API + /// - Parameters: + /// - limit: Maximum number of scans to fetch + /// - offset: Offset for pagination + /// - forceRefresh: If true, bypasses loading state check + @MainActor + func loadHistory(limit: Int = 20, offset: Int = 0, forceRefresh: Bool = false) async { + Log.debug("ScanHistoryStore", "πŸ”΅ loadHistory called - limit: \(limit), offset: \(offset), forceRefresh: \(forceRefresh)") + guard !isLoading || forceRefresh else { + Log.debug("ScanHistoryStore", "⏸️ Already loading, skipping") + return + } + + isLoading = true + lastError = nil + defer { isLoading = false } + + Log.debug("ScanHistoryStore", "πŸ”΅ Loading scan history - limit: \(limit), offset: \(offset)") + + do { + let response = try await webService.fetchScanHistory(limit: limit, offset: offset) + + // Check if we reached the end + hasMore = response.scans.count >= limit + + // Update scans array + if offset == 0 { + // Fresh load - replace all scans + scans = response.scans + Log.debug("ScanHistoryStore", "βœ… Loaded \(response.scans.count) scans (fresh)") + } else { + // Pagination - append new scans (filtering duplicates just in case) + let newScans = response.scans.filter { newScan in + !scans.contains(where: { $0.id == newScan.id }) + } + scans.append(contentsOf: newScans) + Log.debug("ScanHistoryStore", "βœ… Loaded \(newScans.count) new scans (pagination)") + } + + // Update caches + for scan in response.scans { + scanCache[scan.id] = scan + + // Map barcode to scanId for quick lookups + if let barcode = scan.barcode, !barcode.isEmpty { + barcodeToScanIdMap[barcode] = scan.id + } + } + + Log.debug("ScanHistoryStore", "πŸ’Ύ Cache updated - total scans: \(scans.count), cache size: \(scanCache.count), barcode mappings: \(barcodeToScanIdMap.count)") + + } catch { + Log.debug("ScanHistoryStore", "❌ Failed to load scan history - error: \(error.localizedDescription)") + lastError = error + } + } + + /// Get a scan by ID from cache + /// - Parameter id: The scan ID + /// - Returns: The cached scan, or nil if not found + @MainActor + func getScan(id: String) -> DTO.Scan? { + return scanCache[id] + } + + /// Get a scan by barcode from cache + /// - Parameter barcode: The barcode string + /// - Returns: The cached scan, or nil if not found + @MainActor + func getScanByBarcode(_ barcode: String) -> DTO.Scan? { + guard let scanId = barcodeToScanIdMap[barcode] else { return nil } + return scanCache[scanId] + } + + /// Add or update a scan in the store (e.g., from real-time updates) + /// - Parameter scan: The scan to add/update + @MainActor + func upsertScan(_ scan: DTO.Scan) { + Log.debug("ScanHistoryStore", "πŸ”„ Upserting scan - scanId: \(scan.id)") + + // Update or add to scans array + if let existingIndex = scans.firstIndex(where: { $0.id == scan.id }) { + scans[existingIndex] = scan + Log.debug("ScanHistoryStore", "βœ… Updated existing scan at index \(existingIndex)") + } else { + scans.insert(scan, at: 0) // Add to front (most recent) + Log.debug("ScanHistoryStore", "βœ… Added new scan to front") + } + + // Update cache + scanCache[scan.id] = scan + + // Update barcode mapping + if let barcode = scan.barcode, !barcode.isEmpty { + barcodeToScanIdMap[barcode] = scan.id + } + } + + /// Update an existing scan's favorite status + /// - Parameters: + /// - scanId: The scan ID + /// - isFavorited: New favorite status + @MainActor + func updateFavoriteStatus(scanId: String, isFavorited: Bool) { + Log.debug("ScanHistoryStore", "⭐️ Updating favorite status - scanId: \(scanId), isFavorited: \(isFavorited)") + + // Update in scans array + if let index = scans.firstIndex(where: { $0.id == scanId }) { + let existingScan = scans[index] + let updatedScan = DTO.Scan( + id: existingScan.id, + scan_type: existingScan.scan_type, + barcode: existingScan.barcode, + state: existingScan.state, + product_info: existingScan.product_info, + product_info_source: existingScan.product_info_source, + analysis_result: existingScan.analysis_result, + images: existingScan.images, + latest_guidance: existingScan.latest_guidance, + created_at: existingScan.created_at, + last_activity_at: existingScan.last_activity_at, + is_favorited: isFavorited, + analysis_id: existingScan.analysis_id + ) + scans[index] = updatedScan + scanCache[scanId] = updatedScan + } + } + + /// Remove a scan from the store + /// - Parameter scanId: The scan ID to remove + @MainActor + func removeScan(scanId: String) { + Log.debug("ScanHistoryStore", "πŸ—‘οΈ Removing scan - scanId: \(scanId)") + + // Remove from scans array + scans.removeAll { $0.id == scanId } + + // Remove from cache + if let scan = scanCache[scanId] { + scanCache.removeValue(forKey: scanId) + + // Remove barcode mapping + if let barcode = scan.barcode { + barcodeToScanIdMap.removeValue(forKey: barcode) + } + } + } + + /// Clear all cached data + @MainActor + func clearAll() { + Log.debug("ScanHistoryStore", "🧹 Clearing all data") + scans.removeAll() + scanCache.removeAll() + barcodeToScanIdMap.removeAll() + lastError = nil + } + + /// Get scans filtered by criteria + /// - Parameter predicate: Filter predicate + /// - Returns: Filtered scans + @MainActor + func getScans(where predicate: (DTO.Scan) -> Bool) -> [DTO.Scan] { + return scans.filter(predicate) + } + + /// Get favorite scans only + @MainActor + func getFavoriteScans() -> [DTO.Scan] { + return scans.filter { $0.is_favorited == true } + } +} diff --git a/IngrediCheck/Store/TempStore.swift b/IngrediCheck/Store/TempStore.swift new file mode 100644 index 00000000..06d2bfec --- /dev/null +++ b/IngrediCheck/Store/TempStore.swift @@ -0,0 +1,36 @@ +// +// TempStore.swift +// IngrediCheckPreview +// +// Created by Gunjan Haldar on 14/10/25. +// + +import SwiftUI +import os + +struct TempStore: View { + @StateObject var store = Onboarding(onboardingFlowtype: .individual) + @State var preferences: Preferences = Preferences() + var body: some View { + VStack { +// store.currentScreen.buildView(store.onboardingFlowtype, $preferences) +// +// let _ = Log.debug("TempStore", "render") +// +// Text("Progress: \(store.progress * 100)") +// Text("Tag: \(store.currentScreen.screenId.rawValue)") +// +// Button("Next") { +// store.next() +// } +// +// Button("change") { +// store.onboardingFlowtype = .family +// } + } + } +} + +#Preview { + TempStore() +} diff --git a/IngrediCheck/Store/TutorialVideoManager.swift b/IngrediCheck/Store/TutorialVideoManager.swift new file mode 100644 index 00000000..35f65278 --- /dev/null +++ b/IngrediCheck/Store/TutorialVideoManager.swift @@ -0,0 +1,47 @@ +import Foundation + +final class TutorialVideoManager { + + static let shared = TutorialVideoManager() + + private let bucket = "assets-client" + private let fileName = "app-tutorial.mov" + private var isDownloading = false + + private init() {} + + var videoFileURL: URL { + let documentsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + return documentsDir.appendingPathComponent(fileName) + } + + var isVideoAvailable: Bool { + FileManager.default.fileExists(atPath: videoFileURL.path) + } + + func downloadIfNeeded() async { + guard !isVideoAvailable, !isDownloading else { return } + isDownloading = true + defer { isDownloading = false } + + do { + let data = try await supabaseClient.storage + .from(bucket) + .download(path: fileName) + try data.write(to: videoFileURL, options: Data.WritingOptions.atomic) + Log.debug("TutorialVideoManager", "Downloaded tutorial video (\(data.count) bytes)") + } catch { + Log.error("TutorialVideoManager", "Failed to download tutorial video: \(error)") + } + } + + func removeVideo() { + guard isVideoAvailable else { return } + do { + try FileManager.default.removeItem(at: videoFileURL) + Log.debug("TutorialVideoManager", "Removed tutorial video from documents") + } catch { + Log.error("TutorialVideoManager", "Failed to remove tutorial video: \(error)") + } + } +} diff --git a/IngrediCheck/Store/User.swift b/IngrediCheck/Store/User.swift new file mode 100644 index 00000000..788a9561 --- /dev/null +++ b/IngrediCheck/Store/User.swift @@ -0,0 +1,81 @@ +// +// User.swift +// IngrediCheckPreview +// +// Created by Gunjan Haldar on 14/11/25. +// + +import Foundation +import SwiftUI + +struct UserModel: Identifiable { + var id = UUID().uuidString + var name: String + var image: String + var backgroundColor: Color? + var allergies: [String] = [] + var intolerances: [String] = [] + var healthConditions: [String] = [] + var lifeStage: [String] = [] + var region: [String] = [] + var avoid: [String] = [] + var lifestyle: [String] = [] + var nutrition: [String] = [] + var ethical: [String] = [] + var taste: [String] = [] + + init(id: String = UUID().uuidString, familyMemberName: String, familyMemberImage: String, backgroundColor: Color? = nil) { + self.id = id + self.name = familyMemberName + self.image = familyMemberImage + self.backgroundColor = backgroundColor + } +} + + +struct Preferences: Codable, Equatable { + var sections: [String: PreferenceValue] = [:] + + init() { + self.sections = [:] + } + + init(sections: [String: PreferenceValue]) { + self.sections = sections + } +} + +enum PreferenceValue: Codable, Equatable { + case list([String]) + case nested([String: [String]]) + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if let array = try? container.decode([String].self) { + self = .list(array) + return + } + + if let dict = try? container.decode([String: [String]].self) { + self = .nested(dict) + return + } + + throw DecodingError.typeMismatch(PreferenceValue.self, + DecodingError.Context(codingPath: decoder.codingPath, + debugDescription: "Unknown format")) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + switch self { + case .list(let arr): + try container.encode(arr) + + case .nested(let dict): + try container.encode(dict) + } + } +} diff --git a/IngrediCheck/Store/UserPreferences.swift b/IngrediCheck/Store/UserPreferences.swift index cff2417b..dd1269b0 100644 --- a/IngrediCheck/Store/UserPreferences.swift +++ b/IngrediCheck/Store/UserPreferences.swift @@ -30,6 +30,7 @@ enum HistoryType: String { // Reset rating prompt tracking properties successfulScanCount = 0 + totalScanCount = 0 lastRatingPromptDate = nil ratingPromptCount = 0 ratingPromptYearStart = nil @@ -81,6 +82,20 @@ enum HistoryType: String { ) } } + + // Cards Swipe Tutorial + + public static let cardsSwipeTutorialShownKey = "config.cardsSwipeTutorialShown" + + private static func readCardsSwipeTutorialShown() -> Bool { + return UserDefaults.standard.bool(forKey: UserPreferences.cardsSwipeTutorialShownKey) + } + + @ObservationIgnored var cardsSwipeTutorialShown: Bool = UserPreferences.readCardsSwipeTutorialShown() { + didSet { + UserDefaults.standard.set(cardsSwipeTutorialShown, forKey: UserPreferences.cardsSwipeTutorialShownKey) + } + } // MARK: - Rating Prompt Tracking @@ -95,6 +110,12 @@ enum HistoryType: String { return UserDefaults.standard.integer(forKey: successfulScanCountKey) } + /// Refresh the total scan count from UserDefaults + /// Useful when the view appears to ensure the count is up-to-date + func refreshScanCount() { + totalScanCount = UserPreferences.readSuccessfulScanCount() + } + private static func readLastRatingPromptDate() -> Date? { return UserDefaults.standard.object(forKey: lastRatingPromptDateKey) as? Date } @@ -122,9 +143,19 @@ enum HistoryType: String { @ObservationIgnored private var fibonacciIndex: Int = UserPreferences.readFibonacciIndex() @ObservationIgnored private var lastPromptDismissTime: Date? = UserPreferences.readLastPromptDismissTime() + /// Public observable property for total scan count - triggers view updates when changed + /// This property is synced with UserDefaults and updates in real-time + var totalScanCount: Int = UserPreferences.readSuccessfulScanCount() { + didSet { + // Keep UserDefaults in sync when set externally (though it shouldn't be) + UserDefaults.standard.set(totalScanCount, forKey: UserPreferences.successfulScanCountKey) + } + } + /// Increment the successful scan counter func incrementScanCount() { successfulScanCount += 1 + totalScanCount = successfulScanCount UserDefaults.standard.set(successfulScanCount, forKey: UserPreferences.successfulScanCountKey) } @@ -221,6 +252,7 @@ enum HistoryType: String { // Reset scan counter after showing prompt successfulScanCount = 0 + totalScanCount = 0 UserDefaults.standard.set(successfulScanCount, forKey: UserPreferences.successfulScanCountKey) // Set up dismissal tracking (we'll detect cancellation based on timing) diff --git a/IngrediCheck/Store/dynamicJsonData.json b/IngrediCheck/Store/dynamicJsonData.json new file mode 100644 index 00000000..229cc0a0 --- /dev/null +++ b/IngrediCheck/Store/dynamicJsonData.json @@ -0,0 +1,491 @@ +{ + "steps": [ + { + "id": "allergies", + "type": "type-1", + "header": { + "iconUrl": "allergies", + "name": "Allergies", + "individual": { + "question": "Got any allergies we should keep in mind?", + "description": "Choose all that apply so we can give you smarter food tips." + }, + "family": { + "question": "Does anyone in your IngrediFam have allergies we should know ?", + "description": "Select all that apply to keep meals worry-free." + }, + "singleMember": { + "question": "Does {name} have any allergies we should keep in mind?", + "description": "Choose all that apply so we can give smarter food tips." + } + }, + "content": { + "options": [ + { "name": "Peanuts", "icon": "πŸ₯œ" }, + { "name": "Tree nuts", "icon": "🌰" }, + { "name": "Dairy", "icon": "πŸ₯›" }, + { "name": "Eggs", "icon": "πŸ₯š" }, + { "name": "Soy", "icon": "🌱" }, + { "name": "Wheat", "icon": "🌾" }, + { "name": "Fish", "icon": "🐟" }, + { "name": "Shellfish", "icon": "🦐" }, + { "name": "Sesame", "icon": "βšͺ" }, + { "name": "Celery", "icon": "πŸ₯¬" }, + { "name": "Lupin", "icon": "🫘" }, + { "name": "Sulphites", "icon": "πŸ§‚" }, + { "name": "Mustard", "icon": "🟑" }, + { "name": "Molluscs", "icon": "🐚" }, + { "name": "Other", "icon": "✏" } + ] + } + }, + { + "id": "intolerances", + "type": "type-1", + "header": { + "iconUrl": "mingcute_alert-line", + "name": "Intolerances", + "individual": { + "question": "Any sensitivities that make eating tricky?", + "description": "We’ll make sure your food suggestions avoid these." + }, + "family": { + "question": "Any sensitivities or intolerances in your IngrediFam?", + "description": "We'll avoid foods that cause discomfort." + }, + "singleMember": { + "question": "Does {name} have any food sensitivities?", + "description": "We'll make sure food suggestions avoid these." + } + }, + "content": { + "options": [ + { "name": "Lactose", "icon": "πŸ₯›" }, + { "name": "Fructose", "icon": "πŸ“" }, + { "name": "Histamine", "icon": "🍷" }, + { "name": "Gluten / wheat", "icon": "🌾" }, + { "name": "Fodmap", "icon": "πŸ§„" }, + { "name": "Other", "icon": "✏️" } + ] + } + }, + { + "id": "healthConditions", + "type": "type-1", + "header": { + "iconUrl": "lucide_stethoscope", + "name": "Health Conditions", + "individual": { + "question": "Do you follow any special diets or have health conditions?", + "description": "This helps us recommend meals that work for you." + }, + "family": { + "question": "Any doctor diets or health conditions in your IngrediFam?", + "description": "This helps us tailor recommendations better." + }, + "singleMember": { + "question": "Does {name} follow any special diets or have any health conditions?", + "description": "This helps us recommend meals that work better." + } + }, + "content": { + "options": [ + { "name": "Diabetes", "icon": "🍭" }, + { "name": "Hypertension", "icon": "πŸ’Š" }, + { "name": "Kidney Disease", "icon": "🩺" }, + { "name": "Heart Health", "icon": "πŸ«€" }, + { "name": "PKU (phenyalanine-sensitive)", "icon": "🧬" }, + { "name": "Anti-inflammatory medical diet", "icon": "πŸ₯—" }, + { "name": "Celiac disease", "icon": "πŸ₯–" }, + { "name": "Other", "icon": "✏️" } + ] + } + }, + { + "id": "lifeStage", + "type": "type-1", + "header": { + "iconUrl": "lucide_baby", + "name": "Life Stage", + "individual": { + "question": "Do you have special needs we should keep in mind?", + "description": "Select all that apply, this helps us tailor tips for you." + }, + "family": { + "question": "Does anyone in your IngrediFam have special life stage needs?", + "description": "Select all that apply so tips match every life stage." + }, + "singleMember": { + "question": "Any specific needs for {name}?", + "description": "Select all that apply so tips match their life stage." + } + }, + "content": { + "options": [ + { "name": "Kids Baby-friendly foods", "icon": "πŸ‘Ά" }, + { "name": "Toddler pickey-eating adaptations", "icon": "πŸ™„" }, + { "name": "Pregnancy Prenatal nutrition", "icon": "🀰" }, + { "name": "Breastfeeding diets", "icon": "🍼" }, + { "name": "Senior-friendly", "icon": "πŸ‘΄" }, + { "name": "None of these apply", "icon": "βœ…" } + ] + } + }, + { + "id": "region", + "type": "type-3", + "header": { + "iconUrl": "nrk_globe", + "name": "Region", + "individual": { + "question": "Where are you from? This helps us customize your experience!", + "description": "Pick your region(s) or cultural practices." + }, + "family": { + "question": "Where does your IngrediFam draw its food traditions from?", + "description": "Select your region or cultural roots." + }, + "singleMember": { + "question": "Where is {name} from? It helps us personalize the experience.", + "description": "Pick region(s) or cultural practices." + } + }, + "content": { + "regions": [ + { + "name": "India & South Asia", + "subRegions": [ + { "name": "Ayurveda", "icon": "🌿" }, + { "name": "Hindu food traditions", "icon": "πŸ•‰" }, + { "name": "Jain diet", "icon": "πŸ§˜β€β™‚οΈ" }, + { "name": "Other", "icon": "✏️" } + ] + }, + { + "name": "Africa", + "subRegions": [ + { "name": "Rastafarian Ital diet", "icon": "πŸ₯—" }, + { "name": "Ethiopian Orthodox fasting", "icon": "πŸ₯–" }, + { "name": "Other", "icon": "✏️" } + ] + }, + { + "name": "Middle East & Mediterranean", + "subRegions": [ + { "name": "Halal (Islamic dietary laws)", "icon": "β˜ͺ️" }, + { "name": "Kosher (Jewish dietary laws)", "icon": "✑️" }, + { "name": "Greek / Mediterranean diets", "icon": "πŸ«’" }, + { "name": "Other", "icon": "✏️" } + ] + }, + { + "name": "East Asia", + "subRegions": [ + { "name": "Traditional Chinese Medicine (TCM)", "icon": "🧧" }, + { "name": "Buddhist food rules", "icon": "🧘" }, + { "name": "Japanese Macrobiotic diet", "icon": "πŸ™" }, + { "name": "Other", "icon": "✏️" } + ] + }, + { + "name": "Western / Native traditions", + "subRegions": [ + { "name": "Native American traditions", "icon": "πŸͺΆ" }, + { "name": "Christian traditions", "icon": "✝️" }, + { "name": "Other", "icon": "✏️" } + ] + }, + { + "name": "Seventh-day Adventist", + "subRegions": [ + { "name": "Seventh-day Adventist", "icon": "✝️" } + ] + }, + { + "name": "Other", + "subRegions": [ + { "name": "Other", "icon": "✏️" } + ] + } + ] + } + }, + { + "id": "avoid", + "type": "type-2", + "header": { + "iconUrl": "charm_circle-cross", + "name": "Avoid", + "individual": { + "question": "Anything you avoid in your diet?" + }, + "family": { + "question": "Anything your IngrediFam avoids?" + }, + "singleMember": { + "question": "Anything to avoid in {name}'s diet?" + } + }, + "content": { + "subSteps": [ + { + "id": "avoid_oils_fats", + "title": "Oils & Fats", + "description": "In fats or oils, what do you avoid?", + "color": "#FFF6B3", + "bgImageUrl": "leaf-recycle", + "options": [ + { "name": "Hydrogenated oils / Trans fats", "icon": "🧈" }, + { "name": "Canola / Seed oils", "icon": "🌾" }, + { "name": "Palm oil", "icon": "🌴" }, + { "name": "Corn / High-frectose corn syrup", "icon": "🌽" } + ] + }, + { + "id": "avoid_animal_based", + "title": "Animal-Based", + "description": "Any animal products you don't consume?", + "color": "#DCC7F6", + "bgImageUrl": "leaf-recycle", + "options": [ + { "name": "Pork", "icon": "πŸ–" }, + { "name": "Beef", "icon": "πŸ„" }, + { "name": "Honey", "icon": "🍯" }, + { "name": "Gelatin / Rennet", "icon": "πŸ§‚" }, + { "name": "Shellfish", "icon": "🦐" }, + { "name": "Insects", "icon": "🐜" }, + { "name": "Seafood (fish)", "icon": "🐟" }, + { "name": "Lard / Animal fat", "icon": "πŸ–" } + ] + }, + { + "id": "avoid_stimulants_substances", + "title": "Stimulants & Substances", + "description": "Do you avoid these?", + "color": "#BFF0D4", + "bgImageUrl": "leaf-recycle", + "options": [ + { "name": "Alcohol", "icon": "🍷" }, + { "name": "Caffeine", "icon": "β˜•" } + ] + }, + { + "id": "avoid_additives_sweeteners", + "title": "Additives & Sweeteners", + "description": "Do you stay away from processed ingredients?", + "color": "#FFD9B5", + "bgImageUrl": "leaf-recycle", + "options": [ + { "name": "MSG", "icon": "βš—οΈ" }, + { "name": "Artificial sweeteners", "icon": "🍬" }, + { "name": "Preservatives", "icon": "πŸ§‚" }, + { "name": "Refined sugar", "icon": "🍚" }, + { "name": "Corn syrup / HFCS", "icon": "🌽" }, + { "name": "Stevia ? Monk fruit", "icon": "🍈" } + ] + }, + { + "id": "avoid_plant_based_restrictions", + "title": "Plant-Based Restrictions", + "description": "Any plant foods you avoid?", + "color": "#F9C6D0", + "bgImageUrl": "leaf-recycle", + "options": [ + { "name": "Nightshades (paprika, peppers, etc.)", "icon": "πŸ…" }, + { "name": "Garlic / Onion", "icon": "πŸ§„" } + ] + } + ] + } + }, + { + "id": "lifeStyle", + "type": "type-2", + "header": { + "iconUrl": "hugeicons_plant-01", + "name": "Life Style", + "individual": { + "question": "What’s your way of eating?" + }, + "family": { + "question": "What's your IngrediFam's food lifestyle?" + }, + "singleMember": { + "question": "How does {name} prefer to eat?" + } + }, + "content": { + "subSteps": [ + { + "id": "lifestyle_plant_balance", + "title": "Plant & Balance", + "description": "Do you follow a plant-forward or flexible eating style?", + "color": "#FFF6B3", + "bgImageUrl": "leaf-recycle", + "options": [ + { "name": "Vegetarian", "icon": "πŸ₯¦" }, + { "name": "Vegan", "icon": "🌱" }, + { "name": "Flexitarian", "icon": "πŸ”„" }, + { "name": "Reducetarian", "icon": "βž–" }, + { "name": "Pescatarian", "icon": "🐟" }, + { "name": "Other", "icon": "✏️" } + ] + }, + { + "id": "lifestyle_quality_source", + "title": "Quality & Source", + "description": "Do you care about where your food comes from and how it’s grown?", + "color": "#DCC7F6", + "bgImageUrl": "leaf-recycle", + "options": [ + { "name": "Organic Only", "icon": "🌱" }, + { "name": "Non-GMO", "icon": "🧬" }, + { "name": "Locally Sourced", "icon": "πŸ“" }, + { "name": "Seasonal Eater", "icon": "πŸ•°οΈ" } + ] + }, + { + "id": "lifestyle_sustainable_living", + "title": "Sustainable Living", + "description": "Are you mindful of waste, packaging, and ingredient transparency?", + "color": "#D7EEB2", + "bgImageUrl": "leaf-recycle", + "options": [ + { "name": "Zero-Waste / Minimal Packing", "icon": "🌍" }, + { "name": "Clean Label", "icon": "βœ…" } + ] + } + ] + } + }, + { + "id": "nutrition", + "type": "type-2", + "header": { + "iconUrl": "fluent-emoji-high-contrast_fork-and-knife-with-plate", + "name": "Nutrition", + "individual": { + "question": "What’s your nutrition focus right now?" + }, + "family": { + "question": "What's your IngrediFam's nutrition focus?" + }, + "singleMember": { + "question": "What's {name}'s nutrition focus right now?" + } + }, + "content": { + "subSteps": [ + { + "id": "nutrition_macronutrient_goals", + "title": "Macronutrient Goals", + "description": "Do you want to balance your proteins, carbs, and fats or focus on one?", + "color": "#F9C6D0", + "bgImageUrl": "leaf-recycle", + "options": [ + { "name": "High Protein", "icon": "πŸ—" }, + { "name": "Low Carb", "icon": "πŸ₯’" }, + { "name": "Low Fat", "icon": "πŸ₯‘" }, + { "name": "Balanced Macros", "icon": "βš–οΈ" } + ] + }, + { + "id": "nutrition_sugar_fiber", + "title": "Sugar & Fiber", + "description": "Do you prefer low sugar or high-fiber foods for better digestion and energy?", + "color": "#A7D8F0", + "bgImageUrl": "leaf-recycle", + "options": [ + { "name": "Low Sugar", "icon": "πŸ“" }, + { "name": "Sugar-Free", "icon": "🍭" }, + { "name": "High Fiber", "icon": "🌾" } + ] + }, + { + "id": "nutrition_diet_frameworks_patterns", + "title": "Diet Frameworks & Patterns", + "description": "Do you follow a structured eating pan or experiment with fasting?", + "color": "#FFD9B5", + "bgImageUrl": "leaf-recycle", + "options": [ + { "name": "Keto", "icon": "πŸ₯‘" }, + { "name": "DASH", "icon": "πŸ’§" }, + { "name": "Paleo", "icon": "πŸ₯©" }, + { "name": "Mediterranean", "icon": "πŸ«’" }, + { "name": "Whole30", "icon": "πŸ₯—" }, + { "name": "Fasting", "icon": "πŸ•‘" }, + { "name": "Other", "icon": "✏️" } + ] + } + ] + } + }, + { + "id": "ethical", + "type": "type-1", + "header": { + "iconUrl": "streamline_recycle-1-solid", + "name": "Ethical", + "individual": { + "question": "What ethical or environmental values are important to you?", + "description": "Select the causes that matter most when it comes to the food you eat." + }, + "family": { + "question": "What ethical or environmental values matter to your IngrediFam?", + "description": "Select causes that shape your food choices." + }, + "singleMember": { + "question": "What ethical or environmental priorities does {name} have?", + "description": "Select causes that shape food choices." + } + }, + "content": { + "options": [ + { "name": "Animal welfare focused", "icon": "πŸ„" }, + { "name": "Fair trade", "icon": "🀝" }, + { "name": "Sustainable fishing / no overfished species", "icon": "🐟" }, + { "name": "Low carbon footprint foods", "icon": "♻️" }, + { "name": "Water footprint concerns", "icon": "πŸ’§" }, + { "name": "Palm-oil free", "icon": "🌴" }, + { "name": "Plastic-free packaging", "icon": "🚫" }, + { "name": "Other", "icon": "✏️" } + ] + } + }, + { + "id": "taste", + "type": "type-1", + "header": { + "iconUrl": "iconoir_chocolate", + "name": "Taste", + "individual": { + "question": "What are your taste and texture preferences?", + "description": "Choose what you love or avoid when it comes to flavors and textures." + }, + "family": { + "question": "What tastes and textures does your family prefer?", + "description": "Customize tastes so every plate feels just right." + }, + "singleMember": { + "question": "What tastes and textures does {name} prefer?", + "description": "Customize tastes so every plate feels just right." + } + }, + "content": { + "options": [ + { "name": "Spicy lover", "icon": "🌢️" }, + { "name": "Avoid Spicy", "icon": "🚫" }, + { "name": "Sweet tooth", "icon": "🍰" }, + { "name": "Avoid slimy textures", "icon": "πŸ₯’" }, + { "name": "Avoid bitter foods", "icon": "🍡" }, + { "name": "Other", "icon": "✏️" }, + { "name": "Crunchy / Soft preferences", "icon": "πŸͺ" }, + { "name": "Low-sweet preference", "icon": "🍯" } + ] + } + } + + + + ] +} diff --git a/IngrediCheck/SupabaseRequestBuilder.swift b/IngrediCheck/SupabaseRequestBuilder.swift index 576adca1..c2330494 100644 --- a/IngrediCheck/SupabaseRequestBuilder.swift +++ b/IngrediCheck/SupabaseRequestBuilder.swift @@ -1,12 +1,15 @@ import Foundation +import os enum SafeEatsEndpoint: String { case deleteme = "deleteme" case ingredicheck_analyze_stream = "analyze-stream" case ingredicheck_ping = "ping" case feedback = "feedback" + case memoji = "memoji" case history = "history" +// case scan_favorite = "scan/%@/favorite" case list_items = "lists/%@" case list_items_item = "lists/%@/%@" case preference_lists_grandfathered = "preferencelists/grandfathered" @@ -15,6 +18,25 @@ enum SafeEatsEndpoint: String { case devices_register = "devices/register" case devices_mark_internal = "devices/mark-internal" case devices_is_internal = "devices/%@/is-internal" + case family_food_notes = "family/food-notes" + case family_food_notes_all = "family/food-notes/all" + case family_food_notes_summary = "family/food-notes/summary" + case family_member_food_notes = "family/members/%@/food-notes" + + // Scan API endpoints + case scan_barcode = "v2/scan/barcode" + case scan_image = "v2/scan/%@/image" + case scan_get = "v2/scan/%@" + case scan_history = "v2/scan/history" + case scan_favorite = "v2/scan/%@/favorite" // POST to toggle favorite + case scan_reanalyze = "v2/scan/%@/reanalyze" // POST to re-analyze scan + case scan_feedback = "v2/scan/feedback" // POST to submit feedback + case scan_feedback_update = "v2/scan/feedback/%@" // PATCH to update feedback + case stats_v2 = "v2/stats" // GET to fetch stats + + // Chat API endpoints + case chat_send = "v2/chat" + case chat_get = "v2/chat/%@" } class SupabaseRequestBuilder { @@ -26,16 +48,29 @@ class SupabaseRequestBuilder { private let endpoint: SafeEatsEndpoint private let url: URL + private static func baseURL(for endpoint: SafeEatsEndpoint) -> String { + switch endpoint { + case .scan_barcode, .scan_image, .scan_reanalyze, .family_food_notes_summary, .chat_send, .chat_get: + return Config.flyIOBaseURL + "/" + case .scan_history, .scan_get, .scan_favorite, .scan_feedback, .scan_feedback_update, .stats_v2: + return Config.supabaseFunctionsURLBase + default: + return Config.supabaseFunctionsURLBase + } + } + init(endpoint: SafeEatsEndpoint) { self.endpoint = endpoint - self.url = URL(string: (Config.supabaseFunctionsURLBase + endpoint.rawValue))! + let baseURL = Self.baseURL(for: endpoint) + self.url = URL(string: (baseURL + endpoint.rawValue))! self.request = URLRequest(url: self.url) } init(endpoint: SafeEatsEndpoint, itemId: String, subItemId: String? = nil) { func formattedUrlString() -> String { - let urlFormat = Config.supabaseFunctionsURLBase + endpoint.rawValue + let baseURL = Self.baseURL(for: endpoint) + let urlFormat = baseURL + endpoint.rawValue if let subItemId = subItemId { return String(format: urlFormat, itemId, subItemId) } else { @@ -98,7 +133,12 @@ class SupabaseRequestBuilder { } private func setAPIKey() { - request.setValue(Config.supabaseKey, forHTTPHeaderField: "apikey") + // Only set API key for Supabase endpoints, not for scan/chat API endpoints + // Scan and Chat APIs use Bearer token authentication only + let bearerAuthEndpoints: [SafeEatsEndpoint] = [.scan_barcode, .scan_image, .scan_get, .scan_favorite, .scan_reanalyze, .scan_feedback, .scan_feedback_update, .stats_v2, .family_food_notes_summary, .chat_send, .chat_get] + if !bearerAuthEndpoints.contains(endpoint) { + request.setValue(Config.supabaseKey, forHTTPHeaderField: "apikey") + } } private func finishMultipartFormDataIfNeeded() { @@ -115,7 +155,7 @@ class SupabaseRequestBuilder { finishMultipartFormDataIfNeeded() if hasMultipartFormData { - print("Size of Supabase \(endpoint.rawValue) request body is: \(request.httpBody?.count ?? 0)") + Log.debug("SupabaseRequestBuilder", "Size of Supabase \(endpoint.rawValue) request body is: \(request.httpBody?.count ?? 0)") } return request diff --git a/IngrediCheck/Utilities/BottomSheetRoute.swift b/IngrediCheck/Utilities/BottomSheetRoute.swift new file mode 100644 index 00000000..ccd9c58d --- /dev/null +++ b/IngrediCheck/Utilities/BottomSheetRoute.swift @@ -0,0 +1,67 @@ +// +// BottomSheetRoute.swift +// IngrediCheckPreview +// +// Created on 13/11/25. +// + +import Foundation + +enum BottomSheetRoute: Hashable { + // HeyThereScreen routes + case alreadyHaveAnAccount + case welcomeBack + + // BlankScreen routes + case doYouHaveAnInviteCode + case enterInviteCode + + // LetsGetStartedView route + case whosThisFor + + // LetsMeetYourIngrediFamView routes + case letsMeetYourIngrediFam + case whatsYourName + case addMoreMembers + case addMoreMembersMinimal + case editMember(memberId: UUID, isSelf: Bool) + case wouldYouLikeToInvite(memberId: UUID, name: String) + case addPreferencesForMember(memberId: UUID, name: String) + case generateAvatar + case bringingYourAvatar + case meetYourAvatar + case yourCurrentAvatar + case setUpAvatarFor + case updateAvatar(memberId: UUID) + + // DietaryPreferencesAndRestrictions route + case dietaryPreferencesSheet(isFamilyFlow: Bool) + + // WelcomeToYourFamilyView route + case allSetToJoinYourFamily + + // MainCanvasView routes (onboarding) - dynamic from JSON + case onboardingStep(stepId: String) + + // FineTuneYourExperience route + case fineTuneYourExperience + + // HomeView route (empty or default state) + case homeDefault + + // AI ChatBot + case chatIntro + case chatConversation + + // Onboarding completion flow + case workingOnSummary + case meetYourProfileIntro + case meetYourProfile(memberId: UUID? = nil) + case preferencesAddedSuccess + case readyToScanFirstProduct + case seeHowScanningWorks + case quickAccessNeeded + case loginToContinue +} + + diff --git a/IngrediCheck/Utilities/CanvasRoute.swift b/IngrediCheck/Utilities/CanvasRoute.swift new file mode 100644 index 00000000..f72ffd19 --- /dev/null +++ b/IngrediCheck/Utilities/CanvasRoute.swift @@ -0,0 +1,26 @@ +// +// CanvasRoute.swift +// IngrediCheckPreview +// +// Created on 13/11/25. +// + +import Foundation + +enum CanvasRoute: Hashable { + case heyThere + case blankScreen + case letsGetStarted + case letsMeetYourIngrediFam + case dietaryPreferencesAndRestrictions(isFamilyFlow: Bool) + case welcomeToYourFamily + case mainCanvas(flow: OnboardingFlowType) + case home + case summaryJustMe + case summaryAddFamily + case readyToScanFirstProduct + case seeHowScanningWorks + case whyWeNeedThesePermissions +} + + diff --git a/IngrediCheck/Utilities/ChatContextBuilder.swift b/IngrediCheck/Utilities/ChatContextBuilder.swift new file mode 100644 index 00000000..1c0fab0f --- /dev/null +++ b/IngrediCheck/Utilities/ChatContextBuilder.swift @@ -0,0 +1,52 @@ +// +// ChatContextBuilder.swift +// IngrediCheck +// +// Created to build chat context based on current screen state +// + +import Foundation +import SwiftUI + +class ChatContextBuilder { + // Build context objects (returns specific context type, not union) + static func buildHomeContext() -> DTO.HomeContext { + return DTO.HomeContext(screen: "home") + } + + static func buildProductScanContext(scanId: String) -> DTO.ProductScanContext { + return DTO.ProductScanContext(screen: "product_scan", scan_id: scanId) + } + + static func buildFoodNotesContext() -> DTO.FoodNotesContext { + return DTO.FoodNotesContext(screen: "food_notes") + } + + static func buildFeedbackContext(feedbackId: String? = nil) -> DTO.FeedbackContext { + return DTO.FeedbackContext(screen: "feedback", feedback_id: feedbackId) + } + + // Infer context from current navigation state + // This is a helper that can be called from views to determine context + static func contextFromCurrentScreen( + coordinator: AppNavigationCoordinator, + currentScan: DTO.Scan? = nil, + analysisId: String? = nil, + ingredientName: String? = nil + ) -> any Codable { + // Default to home context + // Views should pass specific context when they know the screen type + // This is a fallback for general cases + return buildHomeContext() + } + + // Encode context to JSON string for API + static func encodeContext(_ context: any Codable) throws -> String { + let encoder = JSONEncoder() + let data = try encoder.encode(context) + guard let jsonString = String(data: data, encoding: .utf8) else { + throw NSError(domain: "ChatContextBuilder", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to encode context to JSON string"]) + } + return jsonString + } +} diff --git a/IngrediCheck/Utilities/CustomSheet.swift b/IngrediCheck/Utilities/CustomSheet.swift new file mode 100644 index 00000000..18074737 --- /dev/null +++ b/IngrediCheck/Utilities/CustomSheet.swift @@ -0,0 +1,270 @@ +import SwiftUI +import UIKit + +struct CustomSheet: UIViewControllerRepresentable { + @Binding var item: Item? + let cornerRadius: CGFloat + let content: (Item) -> Content + + @Environment(AppNavigationCoordinator.self) private var coordinator + @Environment(FamilyStore.self) private var familyStore + @Environment(MemojiStore.self) private var memojiStore + @Environment(WebService.self) private var webService + @Environment(AuthController.self) private var authController + @Environment(AppState.self) private var appState + @Environment(UserPreferences.self) private var userPreferences + + init( + item: Binding, + cornerRadius: CGFloat = 16, + @ViewBuilder content: @escaping (Item) -> Content + ) { + self._item = item + self.cornerRadius = cornerRadius + self.content = content + } + + func makeUIViewController(context: Context) -> UIViewController { + let vc = UIViewController() + vc.view.backgroundColor = .clear + return vc + } + + func updateUIViewController(_ parent: UIViewController, context: Context) { + guard let newItem = item else { + // dismiss if item is nil + if let presented = parent.presentedViewController { + presented.dismiss(animated: true) { + context.coordinator.presentedID = nil + } + } + return + } + + // Skip if same sheet already visible + if context.coordinator.presentedID == newItem.id { return } + + let presentSheet = { + let hosting = UIHostingController(rootView: + AnyView( + ZStack { + Color.white.ignoresSafeArea() + content(newItem) + .environment(coordinator) + .environment(familyStore) + .environment(memojiStore) + .environment(webService) + .environment(authController) + .environment(appState) + .environment(userPreferences) + } + ) + ) + hosting.view.backgroundColor = .white + hosting.modalPresentationStyle = .pageSheet + + parent.present(hosting, animated: true) + context.coordinator.presentedID = newItem.id + + configureSheet(for: hosting, parent: parent, cornerRadius: cornerRadius, context: context) + } + + // If a different sheet is open, dismiss first + if parent.presentedViewController != nil { + parent.presentedViewController?.dismiss(animated: true) { + context.coordinator.presentedID = nil + presentSheet() + } + } else { + presentSheet() + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(itemBinding: _item) + } + + class Coordinator: NSObject, UIAdaptivePresentationControllerDelegate { + var presentedID: Item.ID? + private var itemBinding: Binding + + init(itemBinding: Binding) { + self.itemBinding = itemBinding + } + + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + // reset when user dismisses by swipe + itemBinding.wrappedValue = nil + presentedID = nil + } + } + + private func configureSheet( + for hosting: UIHostingController, + parent: UIViewController, + cornerRadius: CGFloat, + context: Context + ) { + DispatchQueue.main.async { + hosting.view.layoutIfNeeded() + + guard let sheet = hosting.sheetPresentationController else { return } + + let targetWidth = parent.view.bounds.width + let fittingSize = CGSize(width: targetWidth, height: UIView.layoutFittingCompressedSize.height) + var measuredHeight = hosting.sizeThatFits(in: fittingSize).height + + let maxHeight = parent.view.bounds.height * 0.92 + let minHeight = parent.view.bounds.height * 0.4 + measuredHeight = min(max(measuredHeight, minHeight), maxHeight) + + let detentID = UISheetPresentationController.Detent.Identifier("dynamic.\(Int(measuredHeight))") + let detent = UISheetPresentationController.Detent.custom(identifier: detentID) { _ in measuredHeight } + + sheet.detents = [detent] + sheet.selectedDetentIdentifier = detentID + sheet.prefersGrabberVisible = false + sheet.prefersScrollingExpandsWhenScrolledToEdge = false + sheet.preferredCornerRadius = cornerRadius + + hosting.presentationController?.delegate = context.coordinator + } + } +} + + +// MARK: - Boolean-based variant with same behavior + +struct CustomBoolSheet: UIViewControllerRepresentable { + @Binding var isPresented: Bool + let cornerRadius: CGFloat + let heightsProvider: () -> (min: CGFloat, max: CGFloat) + let content: () -> Content + + @Environment(AppNavigationCoordinator.self) private var coordinator + @Environment(FamilyStore.self) private var familyStore + @Environment(MemojiStore.self) private var memojiStore + @Environment(WebService.self) private var webService + @Environment(AuthController.self) private var authController + @Environment(AppState.self) private var appState + @Environment(UserPreferences.self) private var userPreferences + + init( + isPresented: Binding, + cornerRadius: CGFloat = 16, + heights: @escaping () -> (min: CGFloat, max: CGFloat), + @ViewBuilder content: @escaping () -> Content + ) { + self._isPresented = isPresented + self.cornerRadius = cornerRadius + self.heightsProvider = heights + self.content = content + } + + init( + isPresented: Binding, + cornerRadius: CGFloat = 16, + heights: (min: CGFloat, max: CGFloat), + @ViewBuilder content: @escaping () -> Content + ) { + self._isPresented = isPresented + self.cornerRadius = cornerRadius + self.heightsProvider = { heights } + self.content = content + } + + func makeUIViewController(context: Context) -> UIViewController { + let vc = UIViewController() + vc.view.backgroundColor = .clear + return vc + } + + func updateUIViewController(_ parent: UIViewController, context: Context) { + // Dismiss when toggled off + if !isPresented { + if let presented = parent.presentedViewController { + presented.dismiss(animated: true) { + context.coordinator.isPresenting = false + } + } + return + } + + // Skip if already visible + if context.coordinator.isPresenting { return } + + let presentSheet = { + let hosting = UIHostingController(rootView: + AnyView( + ZStack { + Color.white.ignoresSafeArea() + content() + .environment(coordinator) + .environment(familyStore) + .environment(memojiStore) + .environment(webService) + .environment(authController) + .environment(appState) + .environment(userPreferences) + } + ) + ) + hosting.view.backgroundColor = .white + hosting.modalPresentationStyle = .pageSheet + + parent.present(hosting, animated: true) + context.coordinator.isPresenting = true + + DispatchQueue.main.async { + if let sheet = hosting.sheetPresentationController { + let (minH, maxH) = heightsProvider() + + let minID = UISheetPresentationController.Detent.Identifier("custom.min.\(Int(minH))") + let maxID = UISheetPresentationController.Detent.Identifier("custom.max.\(Int(maxH))") + + let minDetent = UISheetPresentationController.Detent.custom(identifier: minID) { _ in minH } + let maxDetent = UISheetPresentationController.Detent.custom(identifier: maxID) { _ in maxH } + + sheet.detents = [minDetent, maxDetent] + sheet.selectedDetentIdentifier = minID + sheet.largestUndimmedDetentIdentifier = maxID + sheet.prefersGrabberVisible = false + sheet.prefersScrollingExpandsWhenScrolledToEdge = false + sheet.preferredCornerRadius = cornerRadius + + hosting.presentationController?.delegate = context.coordinator + } + } + } + + // If a different sheet is open, dismiss first + if parent.presentedViewController != nil { + parent.presentedViewController?.dismiss(animated: true) { + context.coordinator.isPresenting = false + presentSheet() + } + } else { + presentSheet() + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(isPresented: _isPresented) + } + + class Coordinator: NSObject, UIAdaptivePresentationControllerDelegate { + var isPresenting: Bool = false + private var isPresented: Binding + + init(isPresented: Binding) { + self.isPresented = isPresented + } + + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + // reset when user dismisses by swipe + isPresented.wrappedValue = false + isPresenting = false + } + } +} + diff --git a/IngrediCheck/Utilities/FlowLayout.swift b/IngrediCheck/Utilities/FlowLayout.swift new file mode 100644 index 00000000..f912ce70 --- /dev/null +++ b/IngrediCheck/Utilities/FlowLayout.swift @@ -0,0 +1,66 @@ +// +// FlowLayout.swift +// IngrediCheckPreview +// +// Created by Gunjan Haider on 30/09/25. +// + +import SwiftUI + +// Custom flow layout with separate horizontal and vertical spacing +struct FlowLayout: Layout { + var horizontalSpacing: CGFloat = 4 + var verticalSpacing: CGFloat = 8 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let maxWidth = proposal.width ?? .infinity + var x: CGFloat = 0 + var y: CGFloat = 0 + var rowHeight: CGFloat = 0 + + for subview in subviews { + let size = subview.sizeThatFits(.init(width: maxWidth, height: nil)) + + if x + size.width > maxWidth { // move to next line + x = 0 + y += rowHeight + verticalSpacing + rowHeight = 0 + } + + rowHeight = max(rowHeight, size.height) + x += size.width + horizontalSpacing + } + + return CGSize(width: maxWidth, height: y + rowHeight) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + var x: CGFloat = 0 + var y: CGFloat = 0 + var rowHeight: CGFloat = 0 + + for subview in subviews { + let size = subview.sizeThatFits(.init(width: bounds.width, height: nil)) + + if x + size.width > bounds.width { // new line + x = 0 + y += rowHeight + verticalSpacing + rowHeight = 0 + } + + subview.place( + at: CGPoint(x: bounds.minX + x, y: bounds.minY + y), + proposal: ProposedViewSize(size) + ) + + x += size.width + horizontalSpacing + rowHeight = max(rowHeight, size.height) + } + } +} + + + +//#Preview { +// FlowLayout() +//} diff --git a/IngrediCheck/Utilities/HexColorExtension.swift b/IngrediCheck/Utilities/HexColorExtension.swift new file mode 100644 index 00000000..d1fa9248 --- /dev/null +++ b/IngrediCheck/Utilities/HexColorExtension.swift @@ -0,0 +1,44 @@ +// +// HexColorExtension.swift +// IngrediCheckPreview +// +// Created by Gunjan Haider on 30/09/25. +// + +import SwiftUI + +extension Color { + init(hex: String) { + let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int: UInt64 = 0 + Scanner(string: hex).scanHexInt64(&int) + let a, r, g, b: UInt64 + switch hex.count { + case 3: // RGB (12-bit) + (a, r, g, b) = (255, + (int >> 8) * 17, + (int >> 4 & 0xF) * 17, + (int & 0xF) * 17) + case 6: // RGB (24-bit) + (a, r, g, b) = (255, + int >> 16, + int >> 8 & 0xFF, + int & 0xFF) + case 8: // ARGB (32-bit) + (a, r, g, b) = (int >> 24, + int >> 16 & 0xFF, + int >> 8 & 0xFF, + int & 0xFF) + default: + (a, r, g, b) = (255, 0, 0, 0) + } + self.init( + .sRGB, + red: Double(r) / 255, + green: Double(g) / 255, + blue: Double(b) / 255, + opacity: Double(a) / 255 + ) + } +} + diff --git a/IngrediCheck/Utilities/ManropeFontEnum.swift b/IngrediCheck/Utilities/ManropeFontEnum.swift new file mode 100644 index 00000000..b8a9a0b1 --- /dev/null +++ b/IngrediCheck/Utilities/ManropeFontEnum.swift @@ -0,0 +1,25 @@ +// +// ManropeFontEnum.swift +// IngrediCheckPreview +// +// Created by Gunjan Haldar on 06/10/25. +// + +import Foundation +import SwiftUI + +enum ManropeFont: String { + case extraLight = "Manrope-ExtraLight" + case light = "Manrope-Light" + case regular = "Manrope-Regular" + case medium = "Manrope-Medium" + case semiBold = "Manrope-SemiBold" + case bold = "Manrope-Bold" + case extraBold = "Manrope-ExtraBold" + + var fontName: String { rawValue } + + func size(_ size: CGFloat) -> Font { + return .custom(self.fontName, size: size) + } +} diff --git a/IngrediCheck/Utilities/NunitoFontEnum.swift b/IngrediCheck/Utilities/NunitoFontEnum.swift new file mode 100644 index 00000000..5ca64a30 --- /dev/null +++ b/IngrediCheck/Utilities/NunitoFontEnum.swift @@ -0,0 +1,35 @@ +// +// NunitoFontEnum.swift +// IngrediCheckPreview +// +// Created by Gunjan Haldar on 08/10/25. +// + +import Foundation +import SwiftUI + +enum NunitoFont: String { + case extraLight = "Nunito-ExtraLight" + case light = "Nunito-Light" + case regular = "Nunito-Regular" + case medium = "Nunito-Medium" + case semiBold = "Nunito-SemiBold" + case bold = "Nunito-Bold" + case extraBold = "Nunito-ExtraBold" + case black = "Nunito-Black" + + case extraLightItalic = "Nunito-ExtraLightItalic" + case lightItalic = "Nunito-LightItalic" + case italic = "Nunito-Italic" + case mediumItalic = "Nunito-MediumItalic" + case semiBoldItalic = "Nunito-SemiBoldItalic" + case boldItalic = "Nunito-BoldItalic" + case extraBoldItalic = "Nunito-ExtraBoldItalic" + case blackItalic = "Nunito-BlackItalic" + + var fontName: String { rawValue } + + func size(_ size: CGFloat) -> Font { + return .custom(self.fontName, size: size) + } +} diff --git a/IngrediCheck/Utilities/OnboardingPersistence.swift b/IngrediCheck/Utilities/OnboardingPersistence.swift new file mode 100644 index 00000000..7750c9b1 --- /dev/null +++ b/IngrediCheck/Utilities/OnboardingPersistence.swift @@ -0,0 +1,285 @@ +// +// OnboardingPersistence.swift +// IngrediCheck +// +// Created by Vishal Paliwal on 09/01/26. +// + +import Foundation +import Supabase +import os + +// MARK: - Centralized Logging Utility + +/// Centralized logging utility using Apple's unified logging (os_log). +/// Logs appear in Console.app, idevicesyslog, and Xcode debugger. +/// +/// Usage: +/// Log.debug("FamilyStore", "Loading family data...") +/// Log.error("WebService", "❌ Failed to fetch: \(error)") +struct Log { + /// Uses NSLog for idevicesyslog compatibility (os_log doesn't appear in idevicesyslog) + static func debug(_ category: String, _ message: String) { + NSLog("[%@] %@", category, message) + } + + static func info(_ category: String, _ message: String) { + NSLog("[%@] %@", category, message) + } + + static func warning(_ category: String, _ message: String) { + NSLog("[%@] ⚠️ %@", category, message) + } + + static func error(_ category: String, _ message: String) { + NSLog("[%@] ❌ %@", category, message) + } +} + +/// A helper class to manage the onboarding state (Stage-based) both locally and remotely. +/// It prioritizes local UserDefaults for immediate synchronous access during app launch, +/// but also handles syncing that state to Supabase. +@MainActor +final class OnboardingPersistence { + static let shared = OnboardingPersistence() + + // MARK: - Local Persistence + private let stageKey = "onboarding_local_stage" + + /// Access the global SupabaseClient defined in AuthController.swift + private var client: SupabaseClient { + return supabaseClient + } + + /// The current onboarding stage, backed by UserDefaults. + /// Defaults to .none (start over) if nothing is saved. + var localStage: RemoteOnboardingStage { + get { + guard let raw = UserDefaults.standard.string(forKey: stageKey), + let stage = RemoteOnboardingStage(rawValue: raw) else { + return .none + } + return stage + } + set { + UserDefaults.standard.set(newValue.rawValue, forKey: stageKey) + } + } + + /// Checks if onboarding is locally marked as completed. + /// This is the fast path for app launch. + var isLocallyCompleted: Bool { + return localStage == .completed + } + + // MARK: - State Management + + /// Sets the stage locally AND triggers a remote sync. + /// Use this instead of modifying `localStage` directly when application logic changes the stage. + func setStage(_ stage: RemoteOnboardingStage, coordinator: AppNavigationCoordinator? = nil) { + Log.debug("OnboardingPersistence", "Setting local stage to: \(stage.rawValue)") + localStage = stage + + // Trigger remote sync + Task { + await syncRemote(stage: stage, coordinator: coordinator) + } + } + + /// Marks the onboarding as completed locally and synced remotely. + /// Call this at the definitive end of any onboarding flow (e.g. Success sheet, Login). + func markCompleted() { + Log.debug("OnboardingPersistence", "Marking onboarding as completed.") + setStage(.completed) + } + + /// Syncs the current state from the coordinator to both local storage and remote Supabase. + /// Call this whenever navigation changes. + func sync(from coordinator: AppNavigationCoordinator) async { + // Crucial: Only sync if we have a valid session (Guest or User). + // This prevents persisting "Get Started" state for new users who haven't performed Guest Login yet. + guard let _ = try? await client.auth.session else { + Log.debug("OnboardingPersistence", "sync skipped: No active session.") + return + } + + // CRITICAL: If onboarding is already completed locally, don't overwrite it. + // This prevents regression when navigation temporarily goes to early onboarding screens. + if isLocallyCompleted { + Log.debug("OnboardingPersistence", "sync skipped: Onboarding already completed locally. Preventing regression.") + return + } + + let metadata = coordinator.buildOnboardingMetadata() + if let stage = metadata.stage { + // Update local stage to match what the coordinator thinks we are in + // This ensures we don't drift if the coordinator changes state logic + Log.debug("OnboardingPersistence", "sync: Updating local stage to match coordinator: \(stage.rawValue)") + localStage = stage + await syncRemote(stage: stage, coordinator: coordinator) + } + } + + /// Resets the local completion flag (e.g. for Logout). + func reset() { + Log.debug("OnboardingPersistence", "Resetting local onboarding state.") + UserDefaults.standard.removeObject(forKey: stageKey) + } + + // MARK: - Remote Sync + + /// Syncs the specific stage (and detailed metadata if coordinator is provided) to Supabase. + private func syncRemote(stage: RemoteOnboardingStage, coordinator: AppNavigationCoordinator?) async { + guard let session = try? await client.auth.session else { + Log.debug("OnboardingPersistence", "Remote sync skipped: no active session.") + return + } + + // Build metadata. If coordinator is missing, we at least save the stage. + var metadata: RemoteOnboardingMetadata + if let coordinator = coordinator { + metadata = coordinator.buildOnboardingMetadata() + // Force override stage with what was passed, to ensure consistency + metadata.stage = stage + } else { + // Minimal metadata just to save stage + metadata = RemoteOnboardingMetadata(flowType: nil, stage: stage, currentStepId: nil, bottomSheetRoute: nil, bottomSheetRouteParam: nil) + } + + // Encode and Update + do { + if let anyJSONDict = encodeMetadataToAnyJSON(metadata) { + // UserAttributes requires [String: AnyJSON] + let attrs = UserAttributes(data: anyJSONDict) + try await client.auth.update(user: attrs) + Log.debug("OnboardingPersistence", "βœ… [OnboardingPersistence] Synced remote stage: \(stage.rawValue)") + } + } catch { + Log.error("OnboardingPersistence", "❌ [OnboardingPersistence] Failed to sync remote stage: \(error)") + } + } + + /// Reads remote metadata and updates local state if remote is "ahead" (e.g. completed). + /// Returns the fetched metadata for specific navigation restoration. + func restore(into coordinator: AppNavigationCoordinator) async { + // 1. Fetch Remote Metadata + guard let metadata = await fetchRemoteMetadata() else { + Log.debug("OnboardingPersistence", "No remote metadata found.") + // Falling back to whatever local state we have or default + return + } + + guard let remoteStage = metadata.stage else { return } + Log.debug("OnboardingPersistence", "Remote stage is: \(remoteStage.rawValue)") + + // 2. Conflict Resolution + // If remote says completed, update local immediately. + if remoteStage == .completed { + if localStage != .completed { + Log.debug("OnboardingPersistence", "Remote is completed but local wasn't. Updating local -> completed.") + localStage = .completed + } + coordinator.showCanvas(.home) + return + } + + // If local says completed, TRUST LOCAL (prevent regression). + if isLocallyCompleted { + Log.debug("OnboardingPersistence", "Local is completed. Ignoring non-completed remote state.") + coordinator.showCanvas(.home) + return + } + + // 3. Apply Detailed Restoration + // If neither is completed, we restore the specific state from metadata + await applyRestoration(metadata: metadata, into: coordinator) + } + + func fetchRemoteMetadata() async -> RemoteOnboardingMetadata? { + // Fetch fresh user object from server to ensure metadata is up-to-date + guard let user = try? await client.auth.user() else { + Log.debug("OnboardingPersistence", "fetchRemoteMetadata: Failed to fetch user.") + return nil + } + + let userUserMeta = user.userMetadata + + // Extract fields using keys that match RemoteOnboardingMetadata properties + let flowTypeRaw = extractString(from: userUserMeta["flowType"]) + let stageRaw = extractString(from: userUserMeta["stage"]) + let stepId = extractString(from: userUserMeta["currentStepId"]) + let bottomRouteRaw = extractString(from: userUserMeta["bottomSheetRoute"]) + let bottomRouteParam = extractString(from: userUserMeta["bottomSheetRouteParam"]) + + guard flowTypeRaw != nil || stageRaw != nil || stepId != nil || bottomRouteRaw != nil else { + return nil + } + + return RemoteOnboardingMetadata( + flowType: flowTypeRaw.flatMap { OnboardingFlowType(rawValue: $0) }, + stage: stageRaw.flatMap { RemoteOnboardingStage(rawValue: $0) }, + currentStepId: stepId, + bottomSheetRoute: bottomRouteRaw.flatMap { BottomSheetRouteIdentifier(rawValue: $0) }, + bottomSheetRouteParam: bottomRouteParam + ) + } + + /// Applies metadata to coordinator to visually restore state + private func applyRestoration(metadata: RemoteOnboardingMetadata, into coordinator: AppNavigationCoordinator) async { + let (canvas, sheet) = AppNavigationCoordinator.restoreState(from: metadata) + + await MainActor.run { + coordinator.showCanvas(canvas) + coordinator.navigateInBottomSheet(sheet) + } + } + + // MARK: - Private Helpers + + /// Encodes metadata to [String: AnyJSON] + private func encodeMetadataToAnyJSON(_ metadata: RemoteOnboardingMetadata) -> [String: AnyJSON]? { + guard let data = try? JSONEncoder().encode(metadata), + let jsonObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + + var result = [String: AnyJSON]() + for (key, value) in jsonObject { + result[key] = toAnyJSON(value) + } + return result + } + + /// Recursively converts Any -> AnyJSON + private func toAnyJSON(_ value: Any) -> AnyJSON { + if let s = value as? String { return .string(s) } + if let i = value as? Int { return .integer(i) } + if let d = value as? Double { return .double(d) } + if let b = value as? Bool { return .bool(b) } + if let a = value as? [Any] { return .array(a.map { toAnyJSON($0) }) } + if let d = value as? [String: Any] { + var dict = [String: AnyJSON]() + d.forEach { dict[$0] = toAnyJSON($1) } + return .object(dict) + } + return .null + } + + /// Safely extracts String from AnyJSON + private func extractString(from json: AnyJSON?) -> String? { + guard let json = json else { return nil } + + switch json { + case .string(let s): + return s + case .null: + return nil + default: + // Fallback for primitive types + if case .integer(let i) = json { return String(i) } + if case .double(let d) = json { return String(d) } + if case .bool(let b) = json { return String(b) } + return nil + } + } +} diff --git a/IngrediCheck/Utilities/ShimmerModifier.swift b/IngrediCheck/Utilities/ShimmerModifier.swift new file mode 100644 index 00000000..535be236 --- /dev/null +++ b/IngrediCheck/Utilities/ShimmerModifier.swift @@ -0,0 +1,43 @@ +// +// ShimmerModifier.swift +// IngrediCheck +// +// Shared shimmer effect for redacted placeholder views. +// + +import SwiftUI + +struct ShimmerModifier: ViewModifier { + @State private var phase: CGFloat = 0 + + func body(content: Content) -> some View { + content + .overlay( + GeometryReader { geometry in + LinearGradient( + gradient: Gradient(colors: [ + Color.white.opacity(0), + Color.white.opacity(0.5), + Color.white.opacity(0) + ]), + startPoint: .leading, + endPoint: .trailing + ) + .frame(width: geometry.size.width * 2) + .offset(x: -geometry.size.width + (geometry.size.width * 2 * phase)) + } + ) + .clipped() + .onAppear { + withAnimation(.linear(duration: 1.5).repeatForever(autoreverses: false)) { + phase = 1 + } + } + } +} + +extension View { + func shimmering() -> some View { + modifier(ShimmerModifier()) + } +} diff --git a/IngrediCheck/Utilities/Temp2.swift b/IngrediCheck/Utilities/Temp2.swift new file mode 100644 index 00000000..2bca824b --- /dev/null +++ b/IngrediCheck/Utilities/Temp2.swift @@ -0,0 +1,68 @@ +// +// Temp2.swift +// IngrediCheckPreview +// +// Created by Gunjan Haldar on 10/10/25. +// + +import SwiftUI + +struct Temp2: View { + + @State private var scrollY: CGFloat = 0 + @State private var isExpanded: Bool = true + @State private var prevValue: CGFloat = 0 + + var body: some View { + ZStack(alignment: .bottom) { + VStack { +// Rectangle() +// .fill(.white) +// .overlay( +// VStack { +// Text("\(scrollY)") +// +// Text(isExpanded ? "Big" : "small") +// } +// ) + + ScrollView { + VStack { + ForEach(0..<50) { _ in + RecentScansRow() + } + + } + .padding() + .background( + GeometryReader { geo in + Color.clear + .onAppear { + scrollY = geo.frame(in: .named("scroll")).minY + } + .onChange(of: geo.frame(in: .named("scroll")).minY) { newValue in + scrollY = newValue + + if scrollY < 0 && newValue < prevValue { + isExpanded = false + } else { + isExpanded = true + } + + prevValue = newValue + + } + } + ) + } + .coordinateSpace(name: "scroll") + } + + TabBar(isExpanded: $isExpanded) + } + } +} + +#Preview { + Temp2() +} diff --git a/IngrediCheck/Utilities/Temp3.swift b/IngrediCheck/Utilities/Temp3.swift new file mode 100644 index 00000000..110c9b47 --- /dev/null +++ b/IngrediCheck/Utilities/Temp3.swift @@ -0,0 +1,26 @@ +// +// Temp3.swift +// IngrediCheckPreview +// +// Created by Gunjan Haldar on 03/10/25. +// + +import SwiftUI + +struct Temp3: View { + + var body: some View { + Circle() + .foregroundStyle( + .blue + .gradient + .shadow(.inner(color: .black, radius: 10, x: 0, y: 14)) + .shadow(.inner(color: .black, radius: 10, x: 0, y: -14)) + ) + } +} + +#Preview { + Temp3() + .padding() +} diff --git a/IngrediCheck/Utilities/Temp4.swift b/IngrediCheck/Utilities/Temp4.swift new file mode 100644 index 00000000..d72e568c --- /dev/null +++ b/IngrediCheck/Utilities/Temp4.swift @@ -0,0 +1,83 @@ +// +// Temp4.swift +// IngrediCheckPreview +// +// Created by Gunjan Haldar on 12/11/25. +// + +import SwiftUI + +struct CustomRoundedRectangle: Shape { + var cornerRadius: CGFloat = 24 + var cutoutRadius: CGFloat = 40 + + // Animate changes to either radius smoothly + var animatableData: AnimatablePair { + get { AnimatablePair(cornerRadius, cutoutRadius) } + set { + cornerRadius = newValue.first + cutoutRadius = newValue.second + } + } + + func path(in rect: CGRect) -> Path { + var path = Path() + + // Clamp radii to safe values + let cr = max(0, min(cornerRadius, min(rect.width, rect.height) / 2)) + let cutR = max(0, min(cutoutRadius, min(rect.width, rect.height) / 2)) + + // Start at top-left + path.move(to: CGPoint(x: rect.minX + cr, y: rect.minY)) + + // Top edge and top-right rounded corner + path.addLine(to: CGPoint(x: rect.maxX - cr, y: rect.minY)) + path.addQuadCurve( + to: CGPoint(x: rect.maxX, y: rect.minY + cr), + control: CGPoint(x: rect.maxX, y: rect.minY) + ) + + if cutR > 0 { + // Right edge down to the start of the inward cutout + path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - cutR)) + + // Inward circular cutout at bottom-right (quarter circle, clockwise) + path.addRelativeArc( + center: CGPoint(x: rect.maxX - cutR, y: rect.maxY - cutR), + radius: cutR, + startAngle: .degrees(0), + delta: .degrees(-90) + ) + } else { + // Regular bottom-right rounded corner when cutout is disabled + path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - cr)) + path.addQuadCurve( + to: CGPoint(x: rect.maxX - cr, y: rect.maxY), + control: CGPoint(x: rect.maxX, y: rect.maxY) + ) + } + + // Bottom edge and bottom-left rounded corner + path.addLine(to: CGPoint(x: rect.minX + cr, y: rect.maxY)) + path.addQuadCurve( + to: CGPoint(x: rect.minX, y: rect.maxY - cr), + control: CGPoint(x: rect.minX, y: rect.maxY) + ) + + // Left edge and top-left rounded corner + path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + cr)) + path.addQuadCurve( + to: CGPoint(x: rect.minX + cr, y: rect.minY), + control: CGPoint(x: rect.minX, y: rect.minY) + ) + + path.closeSubpath() + return path + } +} + +#Preview { + CustomRoundedRectangle(cornerRadius: 24, cutoutRadius: 40) + .fill(Color.green.opacity(0.3)) + .frame(width: 240, height: 180) +} diff --git a/IngrediCheck/Utilities/ToastManager.swift b/IngrediCheck/Utilities/ToastManager.swift new file mode 100644 index 00000000..91ca0550 --- /dev/null +++ b/IngrediCheck/Utilities/ToastManager.swift @@ -0,0 +1,88 @@ +// +// ToastManager.swift +// IngrediCheck +// +// Created by Auto-Agent on 09/01/26. +// + +import SwiftUI +import Observation + +public enum ToastType { + case info + case success + case error + case warning + + public var color: Color { + switch self { + case .info: return .blue + case .success: return .green + case .error: return .red + case .warning: return .orange + } + } + + public var icon: String { + switch self { + case .info: return "info.circle.fill" + case .success: return "checkmark.circle.fill" + case .error: return "exclamationmark.triangle.fill" + case .warning: return "exclamationmark.triangle.fill" + } + } +} + +public struct ToastData: Equatable { + public let message: String + public let type: ToastType + public let duration: TimeInterval + + public init(message: String, type: ToastType, duration: TimeInterval) { + self.message = message + self.type = type + self.duration = duration + } + + public static func == (lhs: ToastData, rhs: ToastData) -> Bool { + return lhs.message == rhs.message && lhs.type == rhs.type + } +} + +@Observable +@MainActor +final class ToastManager { + // Singleton instance + static let shared = ToastManager() + + var toast: ToastData? + var isPresented: Bool = false + + // Private initializer to prevent external instantiation + private init() {} + + func show(message: String, type: ToastType = .info, duration: TimeInterval = 3.0) { + self.toast = ToastData(message: message, type: type, duration: duration) + withAnimation(.spring()) { + self.isPresented = true + } + + if duration > 0 { + Task { @MainActor in + try? await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000)) + dismiss() + } + } + } + + func dismiss() { + withAnimation(.spring()) { + self.isPresented = false + } + // Small delay to allow animation to finish before clearing data + Task { @MainActor in + try? await Task.sleep(nanoseconds: 300_000_000) + self.toast = nil + } + } +} diff --git a/IngrediCheck/Utilities/UIImageExtensions.swift b/IngrediCheck/Utilities/UIImageExtensions.swift new file mode 100644 index 00000000..e37ebdfe --- /dev/null +++ b/IngrediCheck/Utilities/UIImageExtensions.swift @@ -0,0 +1,14 @@ +// +// UIImageExtensions.swift +// IngrediCheck +// +// Created on 13/12/25. +// + +import UIKit + +extension UIImage { + // Image extensions - keeping simple, no complex processing + // Removed copy() method - @State maintains strong reference, no need to copy +} + diff --git a/IngrediCheck/Utilities/ViewExtensions.swift b/IngrediCheck/Utilities/ViewExtensions.swift new file mode 100644 index 00000000..f753b4e4 --- /dev/null +++ b/IngrediCheck/Utilities/ViewExtensions.swift @@ -0,0 +1,90 @@ +// +// ViewExtensions.swift +// IngrediCheck +// +// Created for reusable view modifiers +// + +import SwiftUI + +extension View { + /// Adds a bottom gradient overlay and TabBar to a view + /// - Parameters: + /// - gradientColors: Colors for the bottom gradient (default: transparent to #FCFCFE) + /// - gradientHeight: Height of the gradient overlay (default: 132) + /// - bar: The TabBar view to display at the bottom + /// - Returns: A view with bottom gradient and TabBar overlay + func withBottomTabBar( + gradientColors: [Color] = [ + Color.white.opacity(0), + Color(hex: "#FCFCFE") + ], + gradientHeight: CGFloat = 132, + @ViewBuilder bar: () -> Bar + ) -> some View { + self + .overlay( + LinearGradient( + colors: gradientColors, + startPoint: .top, + endPoint: .bottom + ) + .frame(height: gradientHeight) + .frame(maxWidth: .infinity) + .allowsHitTesting(false), + alignment: .bottom + ) + .ignoresSafeArea(edges: .bottom) + .overlay( + bar(), + alignment: .bottom + ) + } +} + +/// View modifier for conditionally applying bottom tab bar with gradient +struct ConditionalBottomTabBar: ViewModifier { + let isEnabled: Bool + let gradientColors: [Color] + let gradientHeight: CGFloat + @ViewBuilder let bar: () -> Bar + + init( + isEnabled: Bool, + gradientColors: [Color] = [ + Color.white.opacity(0), + Color(hex: "#FCFCFE") + ], + gradientHeight: CGFloat = 132, + @ViewBuilder bar: @escaping () -> Bar + ) { + self.isEnabled = isEnabled + self.gradientColors = gradientColors + self.gradientHeight = gradientHeight + self.bar = bar + } + + func body(content: Content) -> some View { + if isEnabled { + content + .overlay( + LinearGradient( + colors: gradientColors, + startPoint: .top, + endPoint: .bottom + ) + .frame(height: gradientHeight) + .frame(maxWidth: .infinity) + .allowsHitTesting(false), + alignment: .bottom + ) + .ignoresSafeArea(edges: .bottom) + .overlay( + bar(), + alignment: .bottom + ) + } else { + content + } + } +} diff --git a/IngrediCheck/Views/AI Summary/DetailedAISummary.swift b/IngrediCheck/Views/AI Summary/DetailedAISummary.swift new file mode 100644 index 00000000..74d79d39 --- /dev/null +++ b/IngrediCheck/Views/AI Summary/DetailedAISummary.swift @@ -0,0 +1,417 @@ +// +// DetailedAISummary.swift +// IngrediCheckPreview +// +// Created by Gunjan Haldar on 31/10/25. +// +// TEMPORARILY COMMENTED OUT - UI not needed in current project + +/* +import SwiftUI +import UIKit + +struct DashedLine: View { + var color: Color = .gray.opacity(0.4) + var dash: [CGFloat] = [8] + var lineWidth: CGFloat = 1 + + var body: some View { + GeometryReader { geometry in + Path { path in + path.move(to: .zero) + path.addLine(to: CGPoint(x: geometry.size.width, y: 0)) + } + .stroke(color, style: StrokeStyle(lineWidth: lineWidth, dash: dash)) + } + .frame(height: lineWidth) + } +} + + + +struct DetailedAISummary: View { + @Environment(AppNavigationCoordinator.self) private var coordinator + @Environment(\.dismiss) private var dismiss + private let sections: [AISummarySectionItem] = AISummarySectionItem.sample + @State private var scrollOffset: CGFloat = 0 + @State private var contentHeight: CGFloat = 0 + @State private var scrollViewHeight: CGFloat = 0 + private let fadeDistance: CGFloat = 30 + private var remainingToBottom: CGFloat { max(0, (contentHeight - scrollViewHeight) - scrollOffset) } + private var topOverlayOpacity: Double { Double(min(1, max(0, scrollOffset / fadeDistance))) } + private var bottomOverlayOpacity: Double { + if contentHeight <= scrollViewHeight { return 0 } + return Double(min(1, remainingToBottom / fadeDistance)) + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + + AIPill() + .padding(.top, 24) + .padding(.bottom, 14) + + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 14) { + SnapshotCard() + + HStack { + Circle() + .fill(Color(hex: "FDF6E7")) + .frame(width: 40, height: 40) + + DashedLine() + + Circle() + .fill(Color(hex: "FDF6E7")) + .frame(width: 40, height: 40) + } + .padding(.horizontal, -20) + + ForEach(sections) { section in + AISummarySectionRow(section: section, isLast: section == sections.last!) + } + .padding(.leading, 20) + .padding(.trailing, 18) + .padding(.bottom, 20) + } + .background(.white, in: RoundedRectangle(cornerRadius: 28)) + // Track content height and scroll offset + .background( + GeometryReader { geo in + let minY = geo.frame(in: .named("detailedAIScroll")).minY + Color.clear + .onAppear { + contentHeight = geo.size.height + scrollOffset = max(0, -minY) + } + .onChange(of: geo.size.height) { newVal in + contentHeight = newVal + } + .onChange(of: minY) { newVal in + scrollOffset = max(0, -newVal) + } + } + ) + } + // Name coordinate space and measure viewport height + .coordinateSpace(name: "detailedAIScroll") + .background( + GeometryReader { proxy in + Color.clear + .onAppear { scrollViewHeight = proxy.size.height } + .onChange(of: proxy.size.height) { newVal in + scrollViewHeight = newVal + } + } + ) + .overlay( + Rectangle() + .frame(height: 30) + .foregroundStyle( + LinearGradient( + colors: [ + Color(hex: "FDF6E7"), Color(hex: "FDF6E7").opacity(0.2), Color(hex: "FDF6E7").opacity(0.2), Color(hex: "FDF6E7").opacity(0.1) + ], + startPoint: .top, + endPoint: .bottom + ) + ) + .opacity(topOverlayOpacity) + , alignment: .top + ) + .overlay( + Rectangle() + .frame(height: 30) + .foregroundStyle( + LinearGradient( + colors: [ + Color(hex: "FDF6E7"), Color(hex: "FDF6E7").opacity(0.2), Color(hex: "FDF6E7").opacity(0.2), Color(hex: "FDF6E7").opacity(0.1) + ], + startPoint: .bottom, + endPoint: .top + ) + ) + .opacity(bottomOverlayOpacity) + , alignment: .bottom + ) + .padding(.bottom, 14) + + + Button { + dismiss() + coordinator.showCanvas(.home) + coordinator.navigateInBottomSheet(.homeDefault) + } label: { + GreenCapsule(title: "Next") + } + .buttonStyle(.plain) + } + .padding(20) + .background(Color(hex: "FDF6E7")) + } + + private var header: some View { + VStack(alignment: .leading, spacing: 4) { + Text("All set! Here's Your Personal\nFood Map") + .font(NunitoFont.bold.size(22)) + .foregroundStyle(.grayScale150) + + Text("A quick peek into what makes your eating style unique!") + .font(ManropeFont.regular.size(14)) + .foregroundStyle(.grayScale140) + } + } +} + +#Preview { + DetailedAISummary() +} + +// MARK: - Models + +struct AISummarySectionItem: Identifiable, Hashable { + let id = UUID() + let title: String + let iconAssetName: String? + let bulletPoints: [String] +} + +extension AISummarySectionItem { + static let sample: [AISummarySectionItem] = [ + .init( + title: "Allergy Alerts", + iconAssetName: "allergy-alerts", + bulletPoints: [ + "Avoids Peanuts, Shellfish, Eggs, Wheat, Molluscs, and Artificial Flavours", + "Meals should be free from common allergens and any artificial flavor enhancers." + ] + ), + .init( + title: "Intolerances", + iconAssetName: "intolerances", + bulletPoints: [ + "Sensitive to Lactose, Fructose, Gluten, and FODMAPs", + "Prefers easily digestible, gut‑friendly food options with minimal irritants." + ] + ), + .init( + title: "Health Conditions", + iconAssetName: "health-conditions", + bulletPoints: [ + "Focused on managing Diabetes, Hypertension, PKU, Heart Health, and Celiac Disease", + "Seeks nutrient‑balanced meals with controlled sugars, sodium, and saturated fats." + ] + ), + .init( + title: "Life Stage", + iconAssetName: "life-stage", + bulletPoints: [ + "Chooses options suitable for Kids/Babies and Seniors", + "Prefers gentle, nutrient‑rich, and age‑appropriate foods for all life stages." + ] + ), + .init( + title: "Region & Traditions", + iconAssetName: "region-traditions", + bulletPoints: [ + "Inspired by Indian & South Asian and Hindu food traditions", + "Likely prefers vegetarian‑friendly and culturally aligned meal patterns." + ] + ), + .init( + title: "Avoid", + iconAssetName: "avoid", + bulletPoints: [ + "Oils & Fats: No hydrogenated oils, trans fats, corn syrup, or palm oil", + "Animal‑Based: Avoids pork, beef, seafood, gelatin, and lard", + "Stimulants & Substances: Avoids alcohol", + "Additives & Sweeteners: No MSG or artificial sweeteners (even stevia/monk fruit)", + "Plant‑Based Restrictions: Avoids garlic and onion", + "Meals should be clean‑label, natural, and free from animal‑based or processed components." + ] + ), + .init( + title: "Lifestyle", + iconAssetName: "lifestyle", + bulletPoints: [ + "Plant & Balance: Prefers vegetarian or reducetarian approach", + "Quality & Source: Chooses organic and seasonal produce", + "Sustainable Living: Practices zero‑waste and avoids excessive packaging" + ] + ), + .init( + title: "Nutrition", + iconAssetName: "nutritions", + bulletPoints: [ + "Prioritizes high‑protein, low‑fat foods", + "Reinforces organic, seasonal, and sustainable sourcing principles for optimal nutrition." + ] + ), + .init( + title: "Ethical", + iconAssetName: "ethical", + bulletPoints: [ + "Focused on animal welfare and low‑carbon footprint foods", + "Ethically aligned dietary patterns that respect both nature and life." + ] + ), + .init( + title: "Taste", + iconAssetName: "taste", + bulletPoints: [ + "Prefers less sweet foods", + "Avoids slimy textures, favoring balanced flavors and satisfying textures." + ] + ), + .init( + title: "Other preferences you've shared", + iconAssetName: "other-preferences", + bulletPoints: [ + "Prefer home‑cooked meals and try to avoid overly processed foods", + "Exploring more plant‑based options but still enjoy flexibility when eating out", + "Focus on gut‑friendly meals and natural ingredients", + "Like dishes that are simple, quick to make, yet nutritious and satisfying", + "Enjoy trying new ingredients or regional flavors that fit your diet." + ] + ) + ] +} + +// MARK: - Components + +private struct AIPill: View { + var body: some View { + HStack(spacing: 8) { + Image(.summarise) + .resizable() + .frame(width: 28, height: 28) + + + Text("Summarized with AI") + .font(ManropeFont.semiBold.size(14)) + .foregroundStyle(.grayScale150) + .background( + RoundedRectangle(cornerRadius: 20) + .foregroundStyle( + LinearGradient( + gradient: Gradient(stops: [ + .init(color: Color(hex: "91B640"), location: 0.0), + .init(color: Color(hex: "FFFAED"), location: 1.25) + ]), + startPoint: .leading, + endPoint: .trailing) + ) + .frame(height: 1.7) + .offset(y: 2) + + , alignment: .bottom + ) + } + } +} + +private struct SnapshotCard: View { + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Your Dietary Snapshot") + .font(ManropeFont.bold.size(16)) + .foregroundStyle(.grayScale150) + + Group { + Text("You eat with intention and care, balancing health, ethics, and the planet beautifully. Your meals stay free from major allergens and additives, supporting overall wellness and heart health.") + Text("You lean toward a plant‑forward, balanced lifestyle, choosing organic, seasonal, and low‑waste foods that feel good for both you and the Earth.") + Text("With a taste for natural, less‑sweet flavors, you enjoy clean, wholesome meals that nourish you at every stage of life.") + } + .font(ManropeFont.regular.size(14)) + .foregroundStyle(.grayScale140) + + } + .font(.system(size: 14)) + .foregroundStyle(Color(.label)) + .padding(16) + } +} + +private struct AISummarySectionRow: View { + + let section: AISummarySectionItem + let isLast: Bool + + var body: some View { + HStack(alignment: .top, spacing: 9) { + VStack { + SectionIconView(assetName: section.iconAssetName ?? "") + + Spacer() + } + .background( + RoundedRectangle(cornerRadius: 5) + .foregroundStyle( + LinearGradient( + colors: [Color(hex: "F3F3F3").opacity(0), Color(hex: "F3F3F3"), Color(hex: "F3F3F3").opacity(0)], + startPoint: .top, + endPoint: .bottom) + ) + .frame(width: 3) + .offset(y: 45) + .opacity(isLast ? 0 : 1) + ) + + VStack(alignment: .leading, spacing: 8) { + Text(section.title) + .font(ManropeFont.extraBold.size(16)) + .foregroundStyle(.grayScale150) + + BulletedPoints(points: section.bulletPoints) + } + .padding(.top, 10) + } + } +} + +private struct SectionIconView: View { + let assetName: String + + var body: some View { + Circle() + .foregroundStyle( + LinearGradient(colors: [Color(hex: "FFF4E3"), Color(hex: "FFE9BE")], startPoint: .top, endPoint: .bottom) + ) + .frame(width: 54, height: 54) + .overlay( + Image(assetName) + .resizable() + .frame(width: 28, height: 28) + ) + } +} + +private struct BulletedPoints: View { + let points: [String] + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + ForEach(points, id: \.self) { point in + bulletRow(text: point) + } + } + } + + @ViewBuilder + private func bulletRow(text: String) -> some View { + HStack(alignment: .top, spacing: 6) { + Text("β€’") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(Color(.secondaryLabel)) + .padding(.top, -2) + + Text(text) + .font(ManropeFont.regular.size(12)) + .foregroundStyle(Color(.secondaryLabel)) + .fixedSize(horizontal: false, vertical: true) + } + } +} +*/ diff --git a/IngrediCheck/Views/Add Family Members/AddMoreMembers.swift b/IngrediCheck/Views/Add Family Members/AddMoreMembers.swift new file mode 100644 index 00000000..2cf6721d --- /dev/null +++ b/IngrediCheck/Views/Add Family Members/AddMoreMembers.swift @@ -0,0 +1,351 @@ +// +// AddMoreMembers.swift +// IngrediCheck +// +// Created by Gunjan Haldar on 28/10/25. +// + +import SwiftUI + +struct AddMoreMembers: View { + @Environment(FamilyStore.self) private var familyStore + @Environment(WebService.self) private var webService + @Environment(MemojiStore.self) private var memojiStore + @Environment(AppNavigationCoordinator.self) private var coordinator + @State var name: String = "" + @State var showError: Bool = false + @State var isLoading: Bool = false + @State var familyMembersList: [UserModel] = [ + UserModel(familyMemberName: "Memoji 1", familyMemberImage: "memoji_1", backgroundColor: Color(hex: "FFB3BA")), + UserModel(familyMemberName: "Memoji 2", familyMemberImage: "memoji_2", backgroundColor: Color(hex: "FFDFBA")), + UserModel(familyMemberName: "Memoji 3", familyMemberImage: "memoji_3", backgroundColor: Color(hex: "FFFFBA")), + UserModel(familyMemberName: "Memoji 4", familyMemberImage: "memoji_4", backgroundColor: Color(hex: "BAFFC9")), + UserModel(familyMemberName: "Memoji 5", familyMemberImage: "memoji_5", backgroundColor: Color(hex: "BAE1FF")), + UserModel(familyMemberName: "Memoji 6", familyMemberImage: "memoji_6", backgroundColor: Color(hex: "E0BBE4")), + UserModel(familyMemberName: "Memoji 7", familyMemberImage: "memoji_7", backgroundColor: Color(hex: "FFCCCB")), + UserModel(familyMemberName: "Memoji 8", familyMemberImage: "memoji_8", backgroundColor: Color(hex: "B4E4FF")), + UserModel(familyMemberName: "Memoji 9", familyMemberImage: "memoji_9", backgroundColor: Color(hex: "C7CEEA")), + UserModel(familyMemberName: "Memoji 10", familyMemberImage: "memoji_10", backgroundColor: Color(hex: "F0E6FF")), + UserModel(familyMemberName: "Memoji 11", familyMemberImage: "memoji_11", backgroundColor: Color(hex: "FFE5B4")), + UserModel(familyMemberName: "Memoji 12", familyMemberImage: "memoji_12", backgroundColor: Color(hex: "E8F5E9")), + UserModel(familyMemberName: "Memoji 13", familyMemberImage: "memoji_13", backgroundColor: Color(hex: "FFF9C4")), + UserModel(familyMemberName: "Memoji 14", familyMemberImage: "memoji_14", backgroundColor: Color(hex: "F8BBD0")) + ] + @State var selectedFamilyMember: UserModel? = nil + private let continuePressed: (String, UIImage?, String?, String?) async throws -> Void + + init(continuePressed: @escaping (String, UIImage?, String?, String?) async throws -> Void = { _, _, _, _ in }) { + self.continuePressed = continuePressed + } + + var body: some View { + VStack { + + VStack(spacing: 24) { + VStack(spacing: 12) { + HStack { + Text("Add more members?") + .font(NunitoFont.bold.size(22)) + .foregroundStyle(.grayScale150) + .frame(maxWidth: .infinity, alignment: .center) + } + .frame(maxWidth: .infinity) + .overlay(alignment: .leading) { + // No back button when opened from home screen - allow drag down to dismiss + if case .home = coordinator.currentCanvasRoute { + // Back button removed - sheet can be dragged down to dismiss + } else if !familyStore.pendingOtherMembers.isEmpty { + Button { + coordinator.navigateInBottomSheet(.addMoreMembersMinimal) + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(.black) + .frame(width: 24, height: 24) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + + Text("Start by adding their name and a fun avatarβ€”it’ll help us personalize food tips just for them.") + .font(ManropeFont.medium.size(12)) + .foregroundStyle(.grayScale120) + .multilineTextAlignment(.center) + } + .padding(.horizontal, 20) + + + VStack(alignment: .leading, spacing: 8) { + TextField("Enter member's name", text: $name) + .padding(16) + .background(.grayScale10) + .cornerRadius(16) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(lineWidth: showError ? 2 : 0.5) + .foregroundStyle(showError ? .red : .grayScale60) + ) + .onChange(of: name) { oldValue, newValue in + if showError && !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + showError = false + } + + // Filter to letters and spaces only + let filtered = newValue.filter { $0.isLetter || $0.isWhitespace } + var finalized = filtered + + // Limit to 25 characters + if finalized.count > 25 { + finalized = String(finalized.prefix(25)) + } + + // Limit to max 3 words (max 2 spaces) + let components = finalized.components(separatedBy: .whitespaces) + if components.count > 3 { + finalized = components.prefix(3).joined(separator: " ") + } + + if finalized != newValue { + name = finalized + } + } + + if showError { + Text("Enter a name.") + .font(ManropeFont.medium.size(12)) + .foregroundStyle(.red) + .padding(.leading, 4) + } + } + .padding(.horizontal, 20) + + VStack(alignment: .leading, spacing: 12) { + Text("Choose Avatar (Optional)") + .font(ManropeFont.bold.size(14)) + .foregroundStyle(.grayScale150) + .padding(.leading, 20) + + HStack(spacing: 16) { + // Fixed plus button (does not scroll) + Button { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + // Show error if textfield is empty + showError = true + } else { + // Set display name before navigating + memojiStore.displayName = trimmed + // Ensure GenerateAvatar back button returns to AddMoreMembers, not onboarding + memojiStore.previousRouteForGenerateAvatar = .addMoreMembers + // Proceed to generate avatar + coordinator.navigateInBottomSheet(.generateAvatar) + } + } label: { + ZStack { + Circle() + .stroke(lineWidth: 2) + .foregroundStyle(.grayScale60) + .frame(width: 48, height: 48) + + Image(systemName: "plus") + .resizable() + .frame(width: 20, height: 20) + .foregroundStyle(.grayScale60) + } + } + .buttonStyle(.plain) + + // Vertical divider + Rectangle() + .fill(.grayScale60) + .frame(width: 1, height: 48) + + // Scrollable memojis list + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 16) { + ForEach(familyMembersList, id: \.id) { ele in + ZStack(alignment: .topTrailing) { + Image(ele.image) + .resizable() + .frame(width: 50, height: 50) + + if selectedFamilyMember?.id == ele.id { + Circle() + .fill(Color(hex: "2C9C3D")) + .frame(width: 16, height: 16) + .padding(.top, 1) + .overlay( + Circle() + .stroke(lineWidth: 1) + .foregroundStyle(.white) + .padding(.top, 1) + .overlay( + Image("white-rounded-checkmark") + ) + ) + } + } + .onTapGesture { + selectedFamilyMember = ele + } + } + } + } + } + .padding(.horizontal, 20) + } + } + .padding(.bottom, 40) + + Button { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + showError = true + } else { + Task { + await handleAddMember(trimmed: trimmed) + } + } + } label: { + GreenCapsule( + title: "Add Member", + width: 159, + isLoading: isLoading, + isDisabled: name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ) + } + .disabled(isLoading || name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .padding(.horizontal, 20) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .dismissKeyboardOnTap() + .overlay( + // Only show drag indicator when sheet is draggable (opened from home screen) + Group { + if case .home = coordinator.currentCanvasRoute { + RoundedRectangle(cornerRadius: 4) + .fill(.neutral500) + .frame(width: 60, height: 4) + .padding(.top, 11) + } + } + , alignment: .top + ) + .onAppear { + // Check if returning from generate avatar flow + if memojiStore.previousRouteForGenerateAvatar == .addMoreMembers { + // Restore the name from memojiStore if coming back from avatar generation + if let savedName = memojiStore.displayName, !savedName.isEmpty { + name = savedName + } + // Don't reset - preserve the avatar selection state + } else { + // Fresh start - reset all local state when adding a new member + name = "" + selectedFamilyMember = nil + showError = false + resetMemojiSelectionState() + } + } + } + + // Reset all memoji selection state to start fresh for new member + private func resetMemojiSelectionState() { + // Set to empty string so restoreState() treats it as fresh start + memojiStore.selectedFamilyMemberName = "" + memojiStore.selectedFamilyMemberImage = "" + memojiStore.selectedTool = "family-member" + memojiStore.currentToolIndex = 0 + memojiStore.selectedGestureIcon = nil + memojiStore.selectedHairStyleIcon = nil + memojiStore.selectedSkinToneIcon = nil + memojiStore.selectedAccessoriesIcon = nil + memojiStore.selectedColorThemeIcon = nil + // Clear displayName to prevent previous member's name from persisting + memojiStore.displayName = nil + // Clear previous route so back button works correctly for new flow + memojiStore.previousRouteForGenerateAvatar = nil + } + + @MainActor + private func handleAddMember(trimmed: String) async { + print("[AddMoreMembers] Continue tapped with name=\(trimmed)") + isLoading = true + defer { isLoading = false } + + var uploadImage: UIImage? = nil + var storagePath: String? = nil + var colorHex: String? = nil + + // Handle avatar selection + if let selectedImageName = selectedFamilyMember?.image { + // Check if it's a local memoji (starts with "memoji_") + if selectedImageName.hasPrefix("memoji_") { + // Local memoji selected - use storagePath, no upload needed + storagePath = selectedImageName + uploadImage = nil + // Extract background color if available + if let color = selectedFamilyMember?.backgroundColor { + colorHex = color.toHex() + } + } else { + // Legacy predefined avatar (shouldn't happen after migration, but handle gracefully) + if let assetImage = UIImage(named: selectedImageName) { + uploadImage = assetImage + if let color = selectedFamilyMember?.backgroundColor { + colorHex = color.toHex() + } + } else { + // Fallback + storagePath = selectedImageName + } + } + } else if let customImage = memojiStore.image { + // Custom avatar from memojiStore (user-generated memoji) + uploadImage = customImage + colorHex = memojiStore.backgroundColorHex + } + + // Call continue callback with all data + do { + try await continuePressed(trimmed, uploadImage, storagePath, colorHex) + + // On success, clean up UI state + name = "" + showError = false + selectedFamilyMember = nil + // Reset memojiStore state so next member starts fresh + resetMemojiSelectionState() + } catch { + print("[AddMoreMembers] Error adding member: \(error)") + ToastManager.shared.show(message: "Failed to add member: \(error.localizedDescription)", type: .error) + } + } +} + +extension Color { + func toHex() -> String? { + // Platform agnostic way to get hex from SwiftUI Color + // Convert to UIColor/NSColor first + let uiColor = UIColor(self) + guard let components = uiColor.cgColor.components, components.count >= 3 else { + return nil + } + + let r = Float(components[0]) + let g = Float(components[1]) + let b = Float(components[2]) + var a = Float(1.0) + + if components.count >= 4 { + a = Float(components[3]) + } + + if a != 1.0 { + return String(format: "#%02lX%02lX%02lX%02lX", lroundf(r * 255), lroundf(g * 255), lroundf(b * 255), lroundf(a * 255)) + } else { + return String(format: "#%02lX%02lX%02lX", lroundf(r * 255), lroundf(g * 255), lroundf(b * 255)) + } + } +} diff --git a/IngrediCheck/Views/Add Family Members/AllSet.swift b/IngrediCheck/Views/Add Family Members/AllSet.swift new file mode 100644 index 00000000..f28751e9 --- /dev/null +++ b/IngrediCheck/Views/Add Family Members/AllSet.swift @@ -0,0 +1,52 @@ +// +// AllSet.swift +// IngrediCheck +// +// Created by Gunjan Haldar on 28/10/25. +// + +import SwiftUI + +struct AllSet: View { + var body: some View { + VStack { + VStack(spacing: 12) { + Text("Add more members?") + .font(NunitoFont.bold.size(22)) + .foregroundStyle(.grayScale150) + + Text("Start by adding their name and a fun avatarβ€”it’ll help us personalize food tips just for them.") + .font(ManropeFont.medium.size(12)) + .foregroundStyle(.grayScale120) + .multilineTextAlignment(.center) + } + .padding(.bottom, 40) + + HStack(spacing: 16) { + Button { + + } label: { + Text("All Set!") + .font(NunitoFont.semiBold.size(16)) + .foregroundStyle(.grayScale110) + .frame(width: 160, height: 52) + .background( + .grayScale40, in: RoundedRectangle(cornerRadius: 28) + ) + } + + + GreenCapsule(title: "Add Member", width: 160, height: 52) + } + .padding(.horizontal, 20) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay( + RoundedRectangle(cornerRadius: 4) + .fill(.neutral500) + .frame(width: 60, height: 4) + .padding(.top, 11) + , alignment: .top + ) + } +} diff --git a/IngrediCheck/Views/Add Family Members/EditMember.swift b/IngrediCheck/Views/Add Family Members/EditMember.swift new file mode 100644 index 00000000..98f2095a --- /dev/null +++ b/IngrediCheck/Views/Add Family Members/EditMember.swift @@ -0,0 +1,287 @@ +import SwiftUI +import UIKit + +struct EditMember: View { + @Environment(FamilyStore.self) private var familyStore + @Environment(WebService.self) private var webService + @Environment(MemojiStore.self) private var memojiStore + @Environment(AppNavigationCoordinator.self) private var coordinator + + let memberId: UUID + let isSelf: Bool + var onSave: () -> Void = { } + + @State private var name: String = "" + @State private var showError: Bool = false + + @State private var avatarChoices: [UserModel] = [ + UserModel(familyMemberName: "Memoji 1", familyMemberImage: "memoji_1", backgroundColor: Color(hex: "FFB3BA")), + UserModel(familyMemberName: "Memoji 2", familyMemberImage: "memoji_2", backgroundColor: Color(hex: "FFDFBA")), + UserModel(familyMemberName: "Memoji 3", familyMemberImage: "memoji_3", backgroundColor: Color(hex: "FFFFBA")), + UserModel(familyMemberName: "Memoji 4", familyMemberImage: "memoji_4", backgroundColor: Color(hex: "BAFFC9")), + UserModel(familyMemberName: "Memoji 5", familyMemberImage: "memoji_5", backgroundColor: Color(hex: "BAE1FF")), + UserModel(familyMemberName: "Memoji 6", familyMemberImage: "memoji_6", backgroundColor: Color(hex: "E0BBE4")), + UserModel(familyMemberName: "Memoji 7", familyMemberImage: "memoji_7", backgroundColor: Color(hex: "FFCCCB")), + UserModel(familyMemberName: "Memoji 8", familyMemberImage: "memoji_8", backgroundColor: Color(hex: "B4E4FF")), + UserModel(familyMemberName: "Memoji 9", familyMemberImage: "memoji_9", backgroundColor: Color(hex: "C7CEEA")), + UserModel(familyMemberName: "Memoji 10", familyMemberImage: "memoji_10", backgroundColor: Color(hex: "F0E6FF")), + UserModel(familyMemberName: "Memoji 11", familyMemberImage: "memoji_11", backgroundColor: Color(hex: "FFE5B4")), + UserModel(familyMemberName: "Memoji 12", familyMemberImage: "memoji_12", backgroundColor: Color(hex: "E8F5E9")), + UserModel(familyMemberName: "Memoji 13", familyMemberImage: "memoji_13", backgroundColor: Color(hex: "FFF9C4")), + UserModel(familyMemberName: "Memoji 14", familyMemberImage: "memoji_14", backgroundColor: Color(hex: "F8BBD0")) + ] + @State private var selectedAvatar: UserModel? = nil + + var body: some View { + VStack { + VStack(spacing: 24) { + VStack(spacing: 12) { + HStack { + Text("Update the name & avatar?") + .font(NunitoFont.bold.size(22)) + .foregroundStyle(.grayScale150) + .frame(maxWidth: .infinity, alignment: .center) + } + .frame(maxWidth: .infinity) + .overlay(alignment: .leading) { + Button { + // Context-aware back: from Home/Manage Family, dismiss; from onboarding, return to minimal list + if case .home = coordinator.currentCanvasRoute { + coordinator.navigateInBottomSheet(.homeDefault) + } else { + coordinator.navigateInBottomSheet(.addMoreMembersMinimal) + } + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(.black) + .frame(width: 24, height: 24) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + Text("Update the name and give the avatar a look that truly matches their personality") + .font(ManropeFont.medium.size(12)) + .foregroundStyle(.grayScale120) + .multilineTextAlignment(.center) + } + .padding(.horizontal, 20) + + VStack(alignment: .leading, spacing: 8) { + TextField("Enter Name", text: $name) + .padding(16) + .background(.grayScale10) + .cornerRadius(16) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(lineWidth: showError ? 2 : 0.5) + .foregroundStyle(showError ? .red : .grayScale60) + ) + .autocorrectionDisabled(true) + .onChange(of: name) { _, newValue in + if showError && !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + showError = false + } + } + } + .padding(.horizontal, 20) + + VStack(alignment: .leading, spacing: 12) { + Text("Choose Avatar (Optional)") + .font(ManropeFont.bold.size(14)) + .foregroundStyle(.grayScale150) + .padding(.leading, 20) + + HStack(spacing: 16) { + // Fixed plus button (does not scroll) + Button { + // Track that we came from EditMember - need to get the actual route + if case .editMember(let memberId, let isSelf) = coordinator.currentBottomSheetRoute { + memojiStore.previousRouteForGenerateAvatar = .editMember(memberId: memberId, isSelf: isSelf) + } else { + // Fallback to addMoreMembersMinimal if we can't determine the route + memojiStore.previousRouteForGenerateAvatar = .addMoreMembersMinimal + } + coordinator.navigateInBottomSheet(.generateAvatar) + } label: { + ZStack { + Circle() + .stroke(lineWidth: 2) + .foregroundStyle(.grayScale60) + .frame(width: 48, height: 48) + + Image(systemName: "plus") + .resizable() + .frame(width: 20, height: 20) + .foregroundStyle(.grayScale60) + } + } + .buttonStyle(.plain) + + // Vertical divider + Rectangle() + .fill(.grayScale60) + .frame(width: 1, height: 48) + + // Scrollable memojis list + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 16) { + ForEach(avatarChoices, id: \.id) { ele in + ZStack(alignment: .topTrailing) { + Image(ele.image) + .resizable() + .frame(width: 50, height: 50) + + if selectedAvatar?.id == ele.id { + Circle() + .fill(Color(hex: "2C9C3D")) + .frame(width: 16, height: 16) + .padding(.top, 1) + .overlay( + Circle() + .stroke(lineWidth: 1) + .foregroundStyle(.white) + .padding(.top, 1) + .overlay( + Image("white-rounded-checkmark") + ) + ) + } + } + .onTapGesture { + selectedAvatar = ele + } + } + } + } + } + .padding(.horizontal, 20) + } + } + .padding(.bottom, 40) + + Button { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + showError = true + } else { + handleSave(trimmed: trimmed) + } + } label: { + GreenCapsule(title: "Save") + .frame(width: 180) + } + .opacity(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? 0.6 : 1.0) + .padding(.horizontal, 20) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .dismissKeyboardOnTap() + .background(Color.pageBackground) + .overlay( + RoundedRectangle(cornerRadius: 4) + .fill(.neutral500) + .frame(width: 60, height: 4) + .padding(.top, 11) + , alignment: .top + ) + .onAppear { + // Seed initial values from store + if isSelf { + if let me = familyStore.pendingSelfMember { + name = me.name + if let imageName = me.imageFileHash { + selectedAvatar = UserModel(familyMemberName: me.name, familyMemberImage: imageName) + } + } + } else { + if let member = familyStore.pendingOtherMembers.first(where: { $0.id == memberId }) { + name = member.name + if let imageName = member.imageFileHash { + selectedAvatar = UserModel(familyMemberName: member.name, familyMemberImage: imageName) + } + } + } + } + } + + private func handleSave(trimmed: String) { + if isSelf { + familyStore.updatePendingSelfMemberName(trimmed) + // Handle avatar assignment - upload in background without blocking UI + // Priority: + // 1. Selected local memoji (use storagePath, no upload) + // 2. Custom memoji (use memoji-images storage path, no re-upload) + if let selectedImageName = selectedAvatar?.image { + // Check if it's a local memoji (starts with "memoji_") + if selectedImageName.hasPrefix("memoji_") { + // Local memoji selected - use storagePath, no upload needed + Task { + let colorHex = selectedAvatar?.backgroundColor?.toHex() + await familyStore.setPendingSelfMemberAvatarFromMemoji( + storagePath: selectedImageName, + backgroundColorHex: colorHex + ) + } + } else { + // Legacy predefined avatar (shouldn't happen after migration, but handle gracefully) + if let assetImage = UIImage(named: selectedImageName) { + Task { + await familyStore.setPendingSelfMemberAvatar(image: assetImage, webService: webService) + } + } else { + familyStore.setPendingSelfMemberAvatar(imageName: selectedImageName) + } + } + } else if let storagePath = memojiStore.imageStoragePath, !storagePath.isEmpty { + // Custom avatar from memojiStore - use memoji storage path directly + Task { + await familyStore.setPendingSelfMemberAvatarFromMemoji( + storagePath: storagePath, + backgroundColorHex: memojiStore.backgroundColorHex + ) + } + } + } else { + familyStore.updatePendingOtherMemberName(id: memberId, name: trimmed) + // Handle avatar assignment - upload in background without blocking UI + // Priority: + // 1. Selected local memoji (use storagePath, no upload) + // 2. Custom memoji (use memoji-images storage path, no re-upload) + if let selectedImageName = selectedAvatar?.image { + // Check if it's a local memoji (starts with "memoji_") + if selectedImageName.hasPrefix("memoji_") { + // Local memoji selected - use storagePath, no upload needed + Task { + let colorHex = selectedAvatar?.backgroundColor?.toHex() + await familyStore.setAvatarForPendingOtherMemberFromMemoji( + id: memberId, + storagePath: selectedImageName, + backgroundColorHex: colorHex + ) + } + } else { + // Legacy predefined avatar (shouldn't happen after migration, but handle gracefully) + if let assetImage = UIImage(named: selectedImageName) { + Task { + await familyStore.setAvatarForPendingOtherMember(id: memberId, image: assetImage, webService: webService) + } + } else { + familyStore.setAvatarForPendingOtherMember(id: memberId, imageName: selectedImageName) + } + } + } else if let storagePath = memojiStore.imageStoragePath, !storagePath.isEmpty { + // Custom avatar from memojiStore - use memoji storage path directly + Task { + await familyStore.setAvatarForPendingOtherMemberFromMemoji( + id: memberId, + storagePath: storagePath, + backgroundColorHex: memojiStore.backgroundColorHex + ) + } + } + } + // Call onSave immediately so sheet closes + onSave() + } +} diff --git a/IngrediCheck/Views/Add Family Members/FineTuneExperience.swift b/IngrediCheck/Views/Add Family Members/FineTuneExperience.swift new file mode 100644 index 00000000..612ea43e --- /dev/null +++ b/IngrediCheck/Views/Add Family Members/FineTuneExperience.swift @@ -0,0 +1,117 @@ +// +// FineTuneYourExperience.swift +// IngrediCheck +// +// Created by Gunjan Haldar on 02/12/25. +// + +import SwiftUI + +struct FineTuneExperience: View { + @Environment(FamilyStore.self) private var familyStore + var allSetPressed: () -> Void + var addPreferencesPressed: () -> Void + + @State private var isWaitingForUploads = false + + init( + allSetPressed: @escaping () -> Void = {}, + addPreferencesPressed: @escaping () -> Void = {} + ) { + self.allSetPressed = allSetPressed + self.addPreferencesPressed = addPreferencesPressed + } + + var body: some View { + VStack { + VStack(spacing: 12) { + Text("Want to fine-tune your experience?") + .font(NunitoFont.bold.size(20)) + .foregroundStyle(.grayScale150) + + Text("Add extra preferences to tailor your experience.\n Jump in or skip!") + .font(ManropeFont.medium.size(12)) + .foregroundStyle(.grayScale120) + .multilineTextAlignment(.center) + } + .padding(.bottom, 40) + + HStack(spacing: 16) { + SecondaryButton( + title: "All Set!", + width: 160, + takeFullWidth: false, + isLoading: isWaitingForUploads || familyStore.pendingUploadCount > 0, + isDisabled: isWaitingForUploads || familyStore.pendingUploadCount > 0, + action: { + Task { + await handleAllSet() + } + } + ) + + + Button { + Task { + await handleAddPreferences() + } + } label: { + if isWaitingForUploads || familyStore.pendingUploadCount > 0 { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .frame(width: 160, height: 52) + .background( + LinearGradient( + colors: [Color(hex: "4CAF50"), Color(hex: "8BC34A")], + startPoint: .leading, + endPoint: .trailing + ), + in: RoundedRectangle(cornerRadius: 28) + ) + } else { + GreenCapsule(title: "Add Preferences", width: 160, height: 52) + } + } + .disabled(isWaitingForUploads || familyStore.pendingUploadCount > 0) + } + } + .padding(.horizontal, 20) + .padding(.top, 24) + .padding(.bottom, 20) +// .overlay( +// RoundedRectangle(cornerRadius: 4) +// .fill(.neutral500) +// .frame(width: 60, height: 4) +// .padding(.top, 11) +// , alignment: .top +// ) + } + + @MainActor + private func handleAllSet() async { + guard !isWaitingForUploads else { return } + isWaitingForUploads = true + + // Wait for all pending uploads to complete + await familyStore.waitForPendingUploads() + + isWaitingForUploads = false + allSetPressed() + } + + @MainActor + private func handleAddPreferences() async { + guard !isWaitingForUploads else { return } + isWaitingForUploads = true + + // Wait for all pending uploads to complete + await familyStore.waitForPendingUploads() + + isWaitingForUploads = false + addPreferencesPressed() + } +} + +#Preview { + FineTuneExperience() +} diff --git a/IngrediCheck/Views/Add Family Members/IngrediFamCanvasView.swift b/IngrediCheck/Views/Add Family Members/IngrediFamCanvasView.swift new file mode 100644 index 00000000..caa2978c --- /dev/null +++ b/IngrediCheck/Views/Add Family Members/IngrediFamCanvasView.swift @@ -0,0 +1,168 @@ +// +// IngrediFamCanvasView.swift +// IngrediCheckPreview +// +// Created by Gunjan Haldar on 16/10/25. +// + +import SwiftUI + +struct GenerateAvatarTools: Identifiable { + let id = UUID().uuidString + var title: String + var icon: String + var tools: [ChipsModel] +} + +enum AddFamilyMemberSheetOption: String, Identifiable { + case meetYourIngrediFam + case whatsYourName + case addMoreMember + case addMoreMembersMinimal + case allSet + case alreadyHaveAnAccount + case doYouHaveAnInviteCode + case generateAvatar + case bringingYourAvatar + case meetYourAvatar + case letsScanSmarter + case accessDenied + case stayUpdated + case preferenceAreReady + case welcomeBack + case whosThisFor + case allSetToJoinYourFamily + case enterYourInviteCode + + var id: String { self.rawValue } +} + +struct IngrediFamCanvasView: View { + + @State var addFamilyMemberSheetOption: AddFamilyMemberSheetOption? = .allSet + @State var isExpandedMinimal: Bool = false + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 24) + .fill(.grayScale10) + .overlay( + RoundedRectangle(cornerRadius: 24) + .stroke(lineWidth: 0.5) + .foregroundStyle(.grayScale60) + ) + .padding(.horizontal, 20) + .padding(.top, 16) + .padding(.bottom, 10) + .frame(height: 700) + + VStack(spacing: 20) { + Button("welcomeBack") { + addFamilyMemberSheetOption = .welcomeBack + } + Button("whosThisFor") { + addFamilyMemberSheetOption = .whosThisFor + } + Button("allSetToJoinYourFamily") { + addFamilyMemberSheetOption = .allSetToJoinYourFamily + } + Button("enterYourInviteCode") { + addFamilyMemberSheetOption = .enterYourInviteCode + } + Button("addMoreMember") { + addFamilyMemberSheetOption = .addMoreMember + } + Button("addMoreMembersMinimal") { + addFamilyMemberSheetOption = .addMoreMembersMinimal + } + Button("allSet") { + addFamilyMemberSheetOption = .allSet + } + Button("alreadyHaveAnAccount") { + addFamilyMemberSheetOption = .alreadyHaveAnAccount + } + Button("doYouHaveAnInviteCode") { + addFamilyMemberSheetOption = .doYouHaveAnInviteCode + } + Button("meetYourIngrediFam") { + addFamilyMemberSheetOption = .meetYourIngrediFam + } + Button("whatsYourName") { + addFamilyMemberSheetOption = .whatsYourName + } + Button("generateAvatar") { + addFamilyMemberSheetOption = .generateAvatar + } + Button("meetYourAvatar") { + addFamilyMemberSheetOption = .meetYourAvatar + } + Button("letsScanSmarter") { + addFamilyMemberSheetOption = .letsScanSmarter + } + Button("accessDenied") { + addFamilyMemberSheetOption = .accessDenied + } + Button("stayUpdated") { + addFamilyMemberSheetOption = .stayUpdated + } + Button("preferenceAreReady") { + addFamilyMemberSheetOption = .preferenceAreReady + } + Button("bringYourAvatar") { + addFamilyMemberSheetOption = .bringingYourAvatar + } + } + } + CustomSheet(item: $addFamilyMemberSheetOption, + cornerRadius: 34) { sheet in + switch sheet { + case .addMoreMember: + AddMoreMembers() + case .addMoreMembersMinimal: + AddMoreMembersMinimal() + case .allSet: + AllSet() + case .alreadyHaveAnAccount: + AlreadyHaveAnAccount() + case .doYouHaveAnInviteCode: + DoYouHaveAnInviteCode() + case .meetYourIngrediFam: + MeetYourIngrediFam() + case .whatsYourName: + WhatsYourName() + case .generateAvatar: + GenerateAvatar( + isExpandedMinimal: $isExpandedMinimal, + randomPressed: { _ in }, + generatePressed: { _ in } + ) + case .bringingYourAvatar: + IngrediBotWithText(text: "Bringing your avatar to life... it's going to be awesome!") + case .meetYourAvatar: + MeetYourAvatar() + case .letsScanSmarter: + LetsScanSmarter() + case .accessDenied: + AccessDenied() + case .stayUpdated: + StayUpdated() + case .preferenceAreReady: + PreferenceAreReady() + case .welcomeBack: + WelcomeBack() + case .whosThisFor: + WhosThisFor() + case .allSetToJoinYourFamily: + AllSetToJoinYourFamily() + case .enterYourInviteCode: + EnterYourInviteCode() + } + } + } +} + + + +#Preview { + SetUpAvatarFor() +} diff --git a/IngrediCheck/Views/Add Family Members/MeetYourIngrediFam.swift b/IngrediCheck/Views/Add Family Members/MeetYourIngrediFam.swift new file mode 100644 index 00000000..00e52b7a --- /dev/null +++ b/IngrediCheck/Views/Add Family Members/MeetYourIngrediFam.swift @@ -0,0 +1,74 @@ +// +// MeetYourIngrediFam.swift +// IngrediCheck +// +// Created by Gunjan Haldar on 28/10/25. +// + +import SwiftUI + +struct MeetYourIngrediFam: View { + @State var addMemberPressed: () -> Void = { } + @Environment(AppNavigationCoordinator.self) private var coordinator + @Environment(AppState.self) private var appState + var body: some View { + VStack(spacing: 20) { + HStack{ + Image("IngrediFamGroup") + .resizable() + .frame(width: 295, height: 146) + } + .frame(maxWidth: .infinity) + .overlay(alignment: .topLeading) { + Button { + if coordinator.isCreatingFamilyFromSettings { + coordinator.isCreatingFamilyFromSettings = false + coordinator.showCanvas(.home) + Task { @MainActor in + try? await Task.sleep(nanoseconds: 250_000_000) + appState.navigateToSettings = true + } + } else { + coordinator.navigateInBottomSheet(.whosThisFor) + } + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(.black) + .frame(width: 24, height: 24) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + VStack(spacing: 16) { + Text("Let's meet your IngrediFam!") + .font(NunitoFont.bold.size(22)) + .foregroundStyle(.grayScale150) + + Text("Add everyone’s name and a fun avatar so we can tailor tips and scans just for them.") + .font(ManropeFont.medium.size(12)) + .foregroundStyle(.grayScale120) + .frame(width : 338) + .multilineTextAlignment(.center) + + Button { + addMemberPressed() + } label: { + GreenCapsule(title: "Continue") + .frame(width: 156) + .padding(.bottom ,30) + } + } + } + .padding(.horizontal, 20) + .frame(maxWidth: .infinity, maxHeight: .infinity) +// .overlay( +// RoundedRectangle(cornerRadius: 4) +// .fill(.neutral500) +// .frame(width: 60, height: 4) +// .padding(.top, 11) +// , alignment: .top +// ) + } +} diff --git a/IngrediCheck/Views/Add Family Members/WhatsYourName.swift b/IngrediCheck/Views/Add Family Members/WhatsYourName.swift new file mode 100644 index 00000000..c41c3f39 --- /dev/null +++ b/IngrediCheck/Views/Add Family Members/WhatsYourName.swift @@ -0,0 +1,296 @@ +// +// WhatsYourName.swift +// IngrediCheck +// +// Created by Gunjan Haldar on 28/10/25. +// + +import SwiftUI + +struct WhatsYourName: View { + + @Environment(MemojiStore.self) private var memojiStore + @Environment(FamilyStore.self) private var familyStore + @Environment(WebService.self) private var webService + @Environment(AppNavigationCoordinator.self) private var coordinator + @State var name: String = "" + @State var showError: Bool = false + @State var isLoading: Bool = false + @State var familyMembersList: [UserModel] = [ + UserModel(familyMemberName: "Memoji 1", familyMemberImage: "memoji_1", backgroundColor: Color(hex: "FFB3BA")), + UserModel(familyMemberName: "Memoji 2", familyMemberImage: "memoji_2", backgroundColor: Color(hex: "FFDFBA")), + UserModel(familyMemberName: "Memoji 3", familyMemberImage: "memoji_3", backgroundColor: Color(hex: "FFFFBA")), + UserModel(familyMemberName: "Memoji 4", familyMemberImage: "memoji_4", backgroundColor: Color(hex: "BAFFC9")), + UserModel(familyMemberName: "Memoji 5", familyMemberImage: "memoji_5", backgroundColor: Color(hex: "BAE1FF")), + UserModel(familyMemberName: "Memoji 6", familyMemberImage: "memoji_6", backgroundColor: Color(hex: "E0BBE4")), + UserModel(familyMemberName: "Memoji 7", familyMemberImage: "memoji_7", backgroundColor: Color(hex: "FFCCCB")), + UserModel(familyMemberName: "Memoji 8", familyMemberImage: "memoji_8", backgroundColor: Color(hex: "B4E4FF")), + UserModel(familyMemberName: "Memoji 9", familyMemberImage: "memoji_9", backgroundColor: Color(hex: "C7CEEA")), + UserModel(familyMemberName: "Memoji 10", familyMemberImage: "memoji_10", backgroundColor: Color(hex: "F0E6FF")), + UserModel(familyMemberName: "Memoji 11", familyMemberImage: "memoji_11", backgroundColor: Color(hex: "FFE5B4")), + UserModel(familyMemberName: "Memoji 12", familyMemberImage: "memoji_12", backgroundColor: Color(hex: "E8F5E9")), + UserModel(familyMemberName: "Memoji 13", familyMemberImage: "memoji_13", backgroundColor: Color(hex: "FFF9C4")), + UserModel(familyMemberName: "Memoji 14", familyMemberImage: "memoji_14", backgroundColor: Color(hex: "F8BBD0")) + ] + @State var selectedFamilyMember: UserModel? = nil + + var continuePressed: (String) async throws -> Void = { _ in } + + init(continuePressed: @escaping (String) async throws -> Void = { _ in }) { + self.continuePressed = continuePressed + } + + var body: some View { + VStack { + + VStack(spacing: 24) { + VStack(spacing: 12) { + HStack { + Text("What's your name?") + .font(NunitoFont.bold.size(22)) + .foregroundStyle(.grayScale150) + .frame(maxWidth: .infinity, alignment: .center) + } + .frame(maxWidth: .infinity) + .overlay(alignment: .leading) { + Button { + coordinator.navigateInBottomSheet(.letsMeetYourIngrediFam) + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(.black) + .frame(width: 24, height: 24) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + Text("This helps us personalize your experience and scan tipsβ€”just for you!") + .font(ManropeFont.medium.size(12)) + .foregroundStyle(.grayScale120) + .multilineTextAlignment(.center) + } + .padding(.horizontal, 20) + + + VStack(alignment: .leading, spacing: 8) { + TextField("Enter your Name", text: $name) + .padding(16) + .background(.grayScale10) + .cornerRadius(16) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(lineWidth: showError ? 2 : 0.5) + .foregroundStyle(showError ? .red : .grayScale60) + ) + .autocorrectionDisabled(true) // βœ… stops autocorrect + .onChange(of: name) { oldValue, newValue in + if showError && !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + showError = false + } + + // Filter to letters and spaces only + let filtered = newValue.filter { $0.isLetter || $0.isWhitespace } + var finalized = filtered + + // Limit to 25 characters + if finalized.count > 25 { + finalized = String(finalized.prefix(25)) + } + + // Limit to max 3 words (max 2 spaces) + let components = finalized.components(separatedBy: .whitespaces) + if components.count > 3 { + finalized = components.prefix(3).joined(separator: " ") + } + + if finalized != newValue { + name = finalized + } + } + + if showError { + Text("Enter a name.") + .font(ManropeFont.medium.size(12)) + .foregroundStyle(.red) + .padding(.leading, 4) + } + } + .padding(.horizontal, 20) + + VStack(alignment: .leading, spacing: 12) { + Text("Choose Avatar (Optional)") + .font(ManropeFont.bold.size(14)) + .foregroundStyle(.grayScale150) + .padding(.leading, 20) + + HStack(spacing: 16) { + // Fixed plus button (does not scroll) + Button { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + // Show error if textfield is empty + showError = true + } else { + // Proceed to generate avatar + memojiStore.displayName = trimmed + memojiStore.previousRouteForGenerateAvatar = .whatsYourName + coordinator.navigateInBottomSheet(.generateAvatar) + } + } label: { + ZStack { + Circle() + .stroke(lineWidth: 2) + .foregroundStyle(.grayScale60) + .frame(width: 48, height: 48) + + Image(systemName: "plus") + .resizable() + .frame(width: 20, height: 20) + .foregroundStyle(.grayScale60) + } + } + .buttonStyle(.plain) + + // Vertical divider + Rectangle() + .fill(.grayScale60) + .frame(width: 1, height: 48) + + // Scrollable memojis list + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 16) { + ForEach(familyMembersList, id: \.id) { ele in + ZStack(alignment: .topTrailing) { + Image(ele.image) + .resizable() + .frame(width: 50, height: 50) + + if selectedFamilyMember?.id == ele.id { + Circle() + .fill(Color(hex: "2C9C3D")) + .frame(width: 16, height: 16) + .padding(.top, 1) + .overlay( + Circle() + .stroke(lineWidth: 1) + .foregroundStyle(.white) + .padding(.top, 1) + .overlay( + Image("white-rounded-checkmark") + ) + ) + } + } + .onTapGesture { + selectedFamilyMember = ele + } + } + } + } + } + .padding(.horizontal, 20) + } + + } + .padding(.bottom, 40) + + Button { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + showError = true + } else { + Task { + await handleContinue(trimmed: trimmed) + } + } + } label: { + GreenCapsule( + title: "Continue", + width: 159, + isLoading: isLoading, + isDisabled: name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ) + .frame(width: 159) + } + .disabled(isLoading || name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .padding(.horizontal, 20) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .dismissKeyboardOnTap() +// .overlay( +// RoundedRectangle(cornerRadius: 4) +// .fill(.neutral500) +// .frame(width: 60, height: 4) +// .padding(.top, 11) +// , alignment: .top +// ) + .onAppear { + // Only restore name if returning from generate avatar flow + if memojiStore.previousRouteForGenerateAvatar == .whatsYourName { + if let savedName = memojiStore.displayName, !savedName.isEmpty { + name = savedName + } + } else { + // Fresh start - reset local state + name = "" + selectedFamilyMember = nil + showError = false + memojiStore.displayName = nil + } + } + } + + @MainActor + private func handleContinue(trimmed: String) async { + print("[WhatsYourName] Continue tapped with name=\(trimmed)") + isLoading = true + defer { isLoading = false } + + familyStore.setPendingSelfMember(name: trimmed) + + // Handle avatar assignment - upload in background without blocking UI + // Priority: + // 1. Selected local memoji (use storagePath, no upload) + // 2. Custom memoji (use memoji-images storage path, no re-upload) + if let selectedImageName = selectedFamilyMember?.image { + // Check if it's a local memoji (starts with "memoji_") + if selectedImageName.hasPrefix("memoji_") { + // Local memoji selected - use storagePath, no upload needed + if let color = selectedFamilyMember?.backgroundColor { + let colorHex = color.toHex() + await familyStore.setPendingSelfMemberAvatarFromMemoji( + storagePath: selectedImageName, + backgroundColorHex: colorHex + ) + } else { + // Fallback if color extraction fails + await familyStore.setPendingSelfMemberAvatarFromMemoji( + storagePath: selectedImageName, + backgroundColorHex: nil + ) + } + } else { + // Legacy predefined avatar (shouldn't happen after migration, but handle gracefully) + if let assetImage = UIImage(named: selectedImageName) { + await familyStore.setPendingSelfMemberAvatar(image: assetImage, webService: webService) + } else { + familyStore.setPendingSelfMemberAvatar(imageName: selectedImageName) + } + } + } else if let storagePath = memojiStore.imageStoragePath, !storagePath.isEmpty { + // Custom avatar from memojiStore - use memoji storage path directly + await familyStore.setPendingSelfMemberAvatarFromMemoji( + storagePath: storagePath, + backgroundColorHex: memojiStore.backgroundColorHex + ) + } + + do { + try await continuePressed(trimmed) + } catch { + print("[WhatsYourName] Error creating family: \(error)") + ToastManager.shared.show(message: "Failed to create family: \(error.localizedDescription)", type: .error) + } + } +} diff --git a/IngrediCheck/Views/AnalysisResultView.swift b/IngrediCheck/Views/AnalysisResultView.swift index 681cbd8a..02f1b9b2 100644 --- a/IngrediCheck/Views/AnalysisResultView.swift +++ b/IngrediCheck/Views/AnalysisResultView.swift @@ -38,6 +38,12 @@ struct AnalysisResultView: View { .onAppear { checkAndPromptForRating() } + case .unknown: + CapsuleWithDivider(state: .fail) + .onAppear { + checkAndPromptForRating() + } + } } else { CapsuleWithDivider(state: .analyzing) diff --git a/IngrediCheck/Views/AppFlowRouter.swift b/IngrediCheck/Views/AppFlowRouter.swift new file mode 100644 index 00000000..a248e2b1 --- /dev/null +++ b/IngrediCheck/Views/AppFlowRouter.swift @@ -0,0 +1,92 @@ +import SwiftUI + +/// Routes between production and preview flows based on configuration +struct AppFlowRouter: View { + @State private var webService = WebService() + @State private var scanHistoryStore: ScanHistoryStore + @State private var dietaryPreferences = DietaryPreferences() + @State private var userPreferences: UserPreferences = UserPreferences() + @State private var appState = AppState() + @State private var onboardingState = OnboardingState() + @State private var authController = AuthController() + @State private var familyStore = FamilyStore() + @State private var coordinator = AppNavigationCoordinator(initialRoute: .heyThere) + @State private var memojiStore = MemojiStore() + @State private var appResetID = UUID() + + init() { + let ws = WebService() + _webService = State(initialValue: ws) + _scanHistoryStore = State(initialValue: ScanHistoryStore(webService: ws)) + } + + var body: some View { + Group { + if Config.usePreviewFlow { + PreviewFlowView() + .environment(authController) + .environment(webService) + .environment(scanHistoryStore) + .environment(userPreferences) + .environment(appState) + .environment(dietaryPreferences) + .environment(onboardingState) + .environment(familyStore) + .environment(coordinator) + .environment(memojiStore) + } else { + ProductionFlowView() + .environment(authController) + .environment(webService) + .environment(scanHistoryStore) + .environment(userPreferences) + .environment(appState) + .environment(dietaryPreferences) + .environment(onboardingState) + .environment(familyStore) + .environment(coordinator) + .environment(memojiStore) + } + } + .id(appResetID) + .onReceive(NotificationCenter.default.publisher(for: Notification.Name("AppDidReset"))) { _ in + appResetID = UUID() + let ws = WebService() + webService = ws + scanHistoryStore = ScanHistoryStore(webService: ws) + dietaryPreferences = DietaryPreferences() + userPreferences = UserPreferences() + appState = AppState() + onboardingState = OnboardingState() + authController = AuthController() + familyStore = FamilyStore() + coordinator = AppNavigationCoordinator(initialRoute: .heyThere) + memojiStore = MemojiStore() + } + } +} + +// MARK: - Production Flow +private struct ProductionFlowView: View { + var body: some View { + Splash { + Image("SplashScreen") + .resizable() + .scaledToFill() + } content: { + MainView() + } + } +} + +// MARK: - Preview/Testing Flow +private struct PreviewFlowView: View { + var body: some View { + SplashScreen() + .preferredColorScheme(.light) + } +} + +#Preview { + AppFlowRouter() +} diff --git a/IngrediCheck/Views/BarcodeAnalysisView.swift b/IngrediCheck/Views/BarcodeAnalysisView.swift index f18a3c32..55a62a84 100644 --- a/IngrediCheck/Views/BarcodeAnalysisView.swift +++ b/IngrediCheck/Views/BarcodeAnalysisView.swift @@ -10,19 +10,28 @@ struct HeaderImage: View { @Environment(WebService.self) var webService var body: some View { - if let image { - Image(uiImage: image) - .resizable() - .aspectRatio(contentMode: .fit) - } else { - ProgressView() - .task { - if let image = try? await webService.fetchImage(imageLocation: imageLocation, imageSize: .medium) { - DispatchQueue.main.async { - self.image = image - } - } + Group { + if let image { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + } else { + ProgressView() + } + } + .task(id: imageLocation) { + // Reset before loading a new image when imageLocation changes + image = nil + do { + print("[HeaderImage] Fetching image for location: \(imageLocation)") + let loaded = try await webService.fetchImage(imageLocation: imageLocation, imageSize: .medium) + await MainActor.run { + self.image = loaded + print("[HeaderImage] βœ… Image loaded successfully") } + } catch { + print("[HeaderImage] ❌ Failed to fetch image: \(error)") + } } } } @@ -79,6 +88,7 @@ struct StarButton: View { @MainActor var errorMessage: String? @MainActor var ingredientRecommendations: [DTO.IngredientRecommendation]? @MainActor var feedbackData = FeedbackData() + @MainActor var scanId: String? let clientActivityId = UUID().uuidString func impactOccurred() { @@ -92,6 +102,8 @@ struct StarButton: View { let userPreferenceText = dietaryPreferences.asString var streamErrorHandled = false + print("[BARCODE_SCAN] πŸ”΅ BarcodeAnalysisViewModel.analyze() started - barcode: \(barcode), client_activity_id: \(clientActivityId)") + PostHogSDK.shared.capture("Barcode Analysis Started", properties: [ "request_id": requestId, "client_activity_id": clientActivityId, @@ -100,11 +112,29 @@ struct StarButton: View { ]) do { - try await webService.streamUnifiedAnalysis( - input: .barcode(barcode), - clientActivityId: clientActivityId, - userPreferenceText: userPreferenceText, - onProduct: { product in + try await webService.streamBarcodeScan( + barcode: barcode, + onProductInfo: { productInfo, scanId, productInfoSource, images in + self.scanId = scanId + + // Convert ScanProductInfo to Product + let imageLocations: [DTO.ImageLocationInfo] = productInfo.images?.compactMap { scanImageInfo in + guard let urlString = scanImageInfo.url, + let url = URL(string: urlString) else { + return nil + } + return .url(url) + } ?? [] + + let product = DTO.Product( + barcode: self.barcode, + brand: productInfo.brand, + name: productInfo.name, + ingredients: productInfo.ingredients, + images: imageLocations, + claims: productInfo.claims + ) + withAnimation { self.product = product } @@ -114,11 +144,14 @@ struct StarButton: View { "request_id": requestId, "client_activity_id": self.clientActivityId, "barcode": self.barcode, + "scan_id": scanId, "product_name": product.name ?? "Unknown", "latency_ms": (Date().timeIntervalSince1970 - startTime) * 1000 ]) }, - onAnalysis: { recommendations in + onAnalysis: { analysisResult in + let recommendations = analysisResult.toIngredientRecommendations() + withAnimation { self.ingredientRecommendations = recommendations } @@ -130,6 +163,7 @@ struct StarButton: View { "request_id": requestId, "client_activity_id": self.clientActivityId, "barcode": self.barcode, + "scan_id": self.scanId ?? "unknown", "product_name": self.product?.name ?? "Unknown", "recommendations_count": recommendations.count, "total_latency_ms": totalLatency @@ -138,10 +172,11 @@ struct StarButton: View { // Track successful scan for rating prompt - only when analysis is fully complete self.userPreferences.incrementScanCount() }, - onError: { streamError in + onError: { streamError, scanId in streamErrorHandled = true + self.scanId = scanId - if streamError.statusCode == 404 { + if streamError.message.lowercased().contains("not found") { self.notFound = true } else { self.errorMessage = streamError.message @@ -150,11 +185,12 @@ struct StarButton: View { let endTime = Date().timeIntervalSince1970 let totalLatency = (endTime - startTime) * 1000 - if streamError.statusCode == 404 { + if streamError.message.lowercased().contains("not found") { PostHogSDK.shared.capture("Barcode Analysis Failed - Product Not Found", properties: [ "request_id": requestId, "client_activity_id": self.clientActivityId, "barcode": self.barcode, + "scan_id": scanId ?? "unknown", "total_latency_ms": totalLatency ]) } else { @@ -162,6 +198,7 @@ struct StarButton: View { "request_id": requestId, "client_activity_id": self.clientActivityId, "barcode": self.barcode, + "scan_id": scanId ?? "unknown", "error": streamError.message, "total_latency_ms": totalLatency ]) @@ -304,7 +341,7 @@ struct BarcodeAnalysisView: View { } if product.ingredients.isEmpty { - Text("Help! Our Product Database is missing an Ingredient List for this Product. Submit Product Images and Earn IngrediPoiints\u{00A9}!") + Text("Help! Our Product Database is missing an Ingredient List for this Product. Submit Product Images and Earn IngrediPoints\u{00A9}!") .font(.subheadline) .padding() .multilineTextAlignment(.center) @@ -393,8 +430,13 @@ struct IngredientsText: View { ingredients: ingredients, ingredientRecommendations: ingredientRecommendations ) - FlowLayout(mode: .scrollable, items: decoratedFragments, itemSpacing: 0) { fragment in - TappableTextFragment(fragment: fragment) + ScrollView { + FlowLayout(horizontalSpacing: 0, verticalSpacing: 0) { + ForEach(Array(decoratedFragments.enumerated()), id: \.offset) { _, fragment in + TappableTextFragment(fragment: fragment) + } + } + .padding() } } } diff --git a/IngrediCheck/Views/BarcodeScan/Components/Cards/ScanCardsCarousel.swift b/IngrediCheck/Views/BarcodeScan/Components/Cards/ScanCardsCarousel.swift new file mode 100644 index 00000000..35af1f54 --- /dev/null +++ b/IngrediCheck/Views/BarcodeScan/Components/Cards/ScanCardsCarousel.swift @@ -0,0 +1,136 @@ +import SwiftUI + +struct CardCenterPreferenceData: Equatable { + let code: String + let center: CGFloat +} + +struct CardCenterPreferenceKey: PreferenceKey { + static var defaultValue: [CardCenterPreferenceData] = [] + + static func reduce(value: inout [CardCenterPreferenceData], nextValue: () -> [CardCenterPreferenceData]) { + value.append(contentsOf: nextValue()) + } +} + +struct ScanCardsCarousel: View { + let items: [String] // IDs for items (barcodes or scanIds) + @ViewBuilder let cardContent: (String) -> CardContent + var scrollTargetId: String? = nil + var onCardCenterChanged: ((String?) -> Void)? = nil + + @Binding var cardCenterData: [CardCenterPreferenceData] + + private let screenCenterX = UIScreen.main.bounds.width / 2 + private let maxDistance: CGFloat = 220 + private let minScale: CGFloat = 97.0 / 120.0 + + var body: some View { + ScrollViewReader { proxy in + // Always show at least one placeholder card if items are empty + let displayItems = items.isEmpty ? [""] : items + + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack(spacing: 8) { + ForEach(displayItems, id: \.self) { itemId in + cardContent(itemId) + .frame(width: 300, height: 120) + .background( + GeometryReader { geo in + Color.clear.preference( + key: CardCenterPreferenceKey.self, + value: [CardCenterPreferenceData(code: itemId, center: geo.frame(in: .global).midX)] + ) + } + ) + .scrollTransition(.interactive.threshold(.visible(0.5))) { content, phase in + content + .scaleEffect( + x: 1.0, + y: phase.isIdentity ? 1.0 : max(minScale, 1.0 - (1.0 - minScale) * abs(phase.value)), + anchor: .center + ) + } + .id(itemId) + } + } + .animation(.none, value: displayItems) + .scrollTargetLayout() + .padding(.horizontal, max((UIScreen.main.bounds.width - 300) / 2, 0)) + } + .scrollTargetBehavior(.viewAligned) // Enable snap-to-center behavior + .onChange(of: scrollTargetId) { target in + guard let target else { return } + withAnimation(.easeInOut) { + proxy.scrollTo(target, anchor: .center) + } + } + .onPreferenceChange(CardCenterPreferenceKey.self) { values in + cardCenterData = values + let centerX = UIScreen.main.bounds.width / 2 + if let nearest = nearestCenteredCode(to: centerX, in: values) { + onCardCenterChanged?(nearest) + } + } + } + } + + private func nearestCenteredCode(to centerX: CGFloat, in values: [CardCenterPreferenceData]) -> String? { + guard !values.isEmpty else { return nil } + let nearest = values.min(by: { abs($0.center - centerX) < abs($1.center - centerX) })?.code + // Filter out empty string (used for placeholder when items is empty) + return nearest?.isEmpty == true ? nil : nearest + } +} + +// MARK: - Previews + +#if DEBUG +struct ScanCardsCarouselPreview: View { + @State private var cardCenterData: [CardCenterPreferenceData] = [] + @State private var centeredId: String? = nil + + let items: [String] + + var body: some View { + VStack { + Text("Centered: \(centeredId ?? "none")") + .font(.caption) + .foregroundStyle(.gray) + + ScanCardsCarousel( + items: items, + cardContent: { itemId in + RoundedRectangle(cornerRadius: 16) + .fill(Color.gray.opacity(0.2)) + .overlay( + VStack { + Text(itemId.isEmpty ? "Placeholder" : "Card: \(itemId)") + .font(.headline) + Text("Sample card content") + .font(.caption) + .foregroundStyle(.gray) + } + ) + }, + onCardCenterChanged: { id in + centeredId = id + }, + cardCenterData: $cardCenterData + ) + } + } +} + +#Preview("Empty State") { + ScanCardsCarouselPreview(items: []) +} + +#Preview("Single Card") { + ScanCardsCarouselPreview(items: ["item-1"]) +} + +#Preview("Multiple Cards") { + ScanCardsCarouselPreview(items: ["item-1", "item-2", "item-3", "item-4", "item-5"]) +} +#endif diff --git a/IngrediCheck/Views/BarcodeScan/Components/Cards/ScanDataCard.swift b/IngrediCheck/Views/BarcodeScan/Components/Cards/ScanDataCard.swift new file mode 100644 index 00000000..f9deac3d --- /dev/null +++ b/IngrediCheck/Views/BarcodeScan/Components/Cards/ScanDataCard.swift @@ -0,0 +1,1088 @@ +import SwiftUI +import os + +/// Unified card component that displays scan data for both barcode and photo scans +/// Takes a scanId and optionally initialScan data (from SSE events or cache) +/// If initialScan is provided, uses it directly (no API call, no polling) - for barcode scans +/// If initialScan is nil, fetches via getScan and polls if needed - for photo scans +struct ScanDataCard: View { + let scanId: String + var initialScan: DTO.Scan? = nil // Optional initial scan data (from SSE or cache) + var isSubmitting: Bool = false // If true, image is being submitted to API (prevents premature getScan) + var localImages: [(image: UIImage, hash: String)]? = nil // Locally captured images with hash (shown before API response, hash used for matching) + var cameraModeType: String = "barcode" // Current camera mode type ("barcode" or "photo") for skeleton/pending states + var onRetryShown: (() -> Void)? = nil + var onRetryHidden: (() -> Void)? = nil + var onResultUpdated: (() -> Void)? = nil + var onScanUpdated: ((DTO.Scan) -> Void)? = nil // NEW: Called when scan data updates (for cache sync) + var onFavoriteToggle: ((String, Bool) -> Void)? = nil // NEW: Callback for favorite toggle + var onTap: ((DTO.Product, DTO.ProductRecommendation?, [DTO.IngredientRecommendation]?, String?, String) -> Void)? = nil // Added scanId parameter + + @Environment(WebService.self) private var webService + @Environment(AppState.self) private var appState + @Environment(UserPreferences.self) private var userPreferences + + @State private var scan: DTO.Scan? + @State private var cachedInitialScan: DTO.Scan? // Store initialScan as state to watch for changes + @State private var isLoading = false + @State private var isPolling = false + @State private var errorState: String? + @State private var pollingTask: Task? + + private var product: DTO.Product? { + scan?.toProduct() + } + + private var matchStatus: DTO.ProductRecommendation? { + scan?.toProductRecommendation() + } + + private var ingredientRecommendations: [DTO.IngredientRecommendation]? { + scan?.analysis_result?.toIngredientRecommendations() + } + + private var overallAnalysis: String? { + scan?.analysis_result?.overall_analysis + } + + private var isAnalyzing: Bool { + // Show analyzing if: + // 1. Scan state indicates processing/analyzing, OR + // 2. Currently polling for updates (e.g., 2nd photo being processed) + (scan?.state == "analyzing" || scan?.state == "processing_images") || isPolling + } + + private var notFoundState: Bool { + scan?.product_info.name == nil && scan?.product_info.brand == nil && scan?.product_info.ingredients.isEmpty == true + } + + // Skeleton mode: show redacted placeholders when scanId is "skeleton" (initial empty state) + private var isSkeletonMode: Bool { + scanId == "skeleton" + } + + // Pending mode: show "Fetching details" loading state for pending scans + private var isPendingMode: Bool { + scanId.hasPrefix("pending_") + } + + // Determine scan type for placeholder image (barcode vs photo) + // Uses cameraModeType for skeleton/pending modes, otherwise uses scan data + private var scanType: String { + // For skeleton/pending modes, use the passed cameraModeType + if isSkeletonMode || isPendingMode { + return cameraModeType + } + // For actual scans, use the scan's type or fall back to cameraModeType + return scan?.scan_type ?? cameraModeType + } + + // Computed property to determine which images to display + // Returns separate arrays for inventory images, user images, and pending local images + // Stack order (front to back): pending local β†’ user β†’ inventory (reversed) + private var imagesToDisplay: ( + inventoryImages: [DTO.ImageLocationInfo], + userImages: [DTO.ImageLocationInfo], + pendingLocalImages: [UIImage] + ) { + var inventoryImages: [DTO.ImageLocationInfo] = [] + var userImages: [DTO.ImageLocationInfo] = [] + var processedHashes: Set = [] // Track which hashes are already processed + + // Extract images from scan.images (has type information) + if let scan = scan { + for scanImage in scan.images { + switch scanImage { + case .inventory(let img): + if let url = URL(string: img.url) { + inventoryImages.append(.url(url)) + } + case .user(let img): + if img.status == "processed", let storagePath = img.storage_path { + userImages.append(.scanImagePath(storagePath)) + processedHashes.insert(img.content_hash) // Mark as processed + } + } + } + } + + // Filter local images - only show those NOT yet processed by API + // This prevents duplicate display of the same image + var pendingLocalImages: [UIImage] = [] + if let locals = localImages { + for (image, hash) in locals { + if !processedHashes.contains(hash) { + pendingLocalImages.append(image) + } + } + } + + return (inventoryImages: inventoryImages, userImages: userImages, pendingLocalImages: pendingLocalImages) + } + + var body: some View { + HStack(spacing: 12) { + // Left-side visual: product images or placeholder + productImageView + .frame(width: 68, height: 92) + .padding(.leading, 14) + .layoutPriority(1) + + // Right-side: product info and status + productInfoView + .frame(maxWidth: .infinity, + minHeight: 92, + maxHeight: 92, + alignment: .leading + ) + .padding(.trailing, 14) + .onChange(of: errorState) { newErrorState in + if newErrorState == nil && product != nil { + onRetryHidden?() + } + } + .onChange(of: matchStatus) { newMatchStatus in + if newMatchStatus != nil { + onRetryHidden?() + } + } + } + .frame(width: 300, height: 120) + .contentShape(Rectangle()) + .onTapGesture { + if let product = product { + onTap?(product, matchStatus, ingredientRecommendations, overallAnalysis, scanId) + } + } + .background( + RoundedRectangle(cornerRadius: 24) + .fill(.bar) + .opacity(0.4) + ) + .clipped() + .task(id: scanId) { + guard !scanId.isEmpty else { return } + + // If scanId starts with "pending_", show skeleton/loading state + // Don't fetch from API for placeholder IDs + if scanId.hasPrefix("pending_") { + isLoading = true + return + } + + // Don't fetch from API for skeleton card (UI-only placeholder) + if scanId == "skeleton" { + isLoading = false + return + } + + // If isSubmitting, wait for submission to complete before fetching + // This prevents premature getScan calls for photo mode + if isSubmitting { + Log.debug("SCAN_CARD", "⏳ Waiting for submission to complete - scan_id: \(scanId)") + return + } + + // If initialScan is provided (from SSE or cache), use it directly + // No API call, no polling - this is for barcode scans + if let initialScan = initialScan { + Log.debug("SCAN_CARD", "πŸ“¦ Using initialScan (SSE/cache) - scan_id: \(scanId), scan_type: \(initialScan.scan_type)") + await MainActor.run { + self.scan = initialScan + cachedInitialScan = initialScan + self.isLoading = false + onResultUpdated?() + + // Barcode scans are complete via SSE - no polling needed + if initialScan.state == "done" { + userPreferences.incrementScanCount() + } + } + return + } + + // No initialScan - fetch via API (for photo scans and history items) + await fetchScan() + } + .task(id: isSubmitting) { + // Watch for submission completion (isSubmitting: true β†’ false) + // When submission completes, trigger getScan and re-poll + // This happens for EVERY photo submission (not just first photo) + // because each new photo may reveal additional product information + Log.debug("SCAN_CARD", "🎬 task(id: isSubmitting) triggered - isSubmitting: \(isSubmitting), scan_id: \(scanId)") + + if !isSubmitting && !scanId.isEmpty && !scanId.hasPrefix("pending_") && scanId != "skeleton" { + Log.debug("SCAN_CARD", "πŸ”„ Submission completed, starting fetch/re-poll - scan_id: \(scanId)") + + // Cancel existing polling if running (for subsequent photos) + // Set isPolling to false to allow new polling to start + if isPolling { + Log.debug("SCAN_CARD", "⏹️ Cancelling existing polling before starting new fetch - scan_id: \(scanId)") + pollingTask?.cancel() + pollingTask = nil + await MainActor.run { + isPolling = false + } + } + + await fetchScan() + } else { + Log.debug("SCAN_CARD", "⏭️ Skipping fetch - isSubmitting: \(isSubmitting), scanId: \(scanId)") + } + } + .task(id: initialScan) { + // Watch for changes in initialScan (e.g., when analysis arrives via SSE) + // Since DTO.Scan is Hashable, this will trigger whenever the scan content changes + if let initialScan = initialScan, initialScan.id == scanId { + await MainActor.run { + // Only update if the scan data has actually changed + if cachedInitialScan != initialScan { + self.scan = initialScan + cachedInitialScan = initialScan + onResultUpdated?() + Log.debug("SCAN_CARD", "πŸ”„ Updated scan from initialScan change - scan_id: \(scanId), state: \(initialScan.state)") + } + } + } + } + .onDisappear { + pollingTask?.cancel() + pollingTask = nil + } + } + + // MARK: - Product Image View + @ViewBuilder + private var productImageView: some View { + ZStack { + let images = imagesToDisplay + let hasAnyImages = !images.inventoryImages.isEmpty || + !images.userImages.isEmpty || + !images.pendingLocalImages.isEmpty + + if isSkeletonMode { + // Skeleton mode: show empty state placeholder image + let placeholderImage = scanType == "photo" ? "PhotoScanEmptyState" : "Barcodelinecorners" + Image(placeholderImage) + .resizable() + .scaledToFit() + .frame(width: 64, height: 88) + .clipped() + } else if isPendingMode || (isLoading && scan == nil && images.pendingLocalImages.isEmpty) { + // Loading state: show placeholder (only if no local images to show) + ProgressView() + .tint(.white.opacity(0.6)) + } else if hasAnyImages { + // Combined images stack: local (with loader) β†’ user β†’ inventory (reversed) + combinedImagesStackView( + inventoryImages: images.inventoryImages, + userImages: images.userImages, + localImages: images.pendingLocalImages + ) + } else if product != nil { + // Product found but no images + Image("imagenotfound1") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 39, height: 34) + } else { + // No product yet: show placeholder based on scan type + let placeholderImage = (scan?.scan_type == "photo" || scan?.scan_type == "barcode_plus_photo") ? "PhotoScanEmptyState" : "Barcodelinecorners" + Image(placeholderImage) + .resizable() + .scaledToFit() + .frame(width: 64, height: 88) + .clipped() + } + } + } + + // MARK: - API Images Stack View + @ViewBuilder + private func apiImagesStackView(images: [DTO.ImageLocationInfo]) -> some View { + // Reverse the images array so the last API image appears at the front of the stack + let reversedImages = Array(images.reversed()) + let displayedImages = Array(reversedImages.prefix(3)) + let totalCount = images.count + let stackOffset: CGFloat = 6 + let sizeReduction: CGFloat = 4 + + ZStack(alignment: .topTrailing) { + ZStack(alignment: .leading) { + ForEach(Array(displayedImages.enumerated()), id: \.offset) { index, location in + let reverseIndex = displayedImages.count - 1 - index + let imageWidth = 68 - CGFloat(reverseIndex) * sizeReduction + let imageHeight = 92 - CGFloat(reverseIndex) * sizeReduction + + ScanProductImageThumbnail(imageLocation: location, isAnalyzing: isAnalyzing) + .frame(width: imageWidth, height: imageHeight) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(Color.white, lineWidth: 0.4) + ) + .shadow(radius: 4) + .offset(x: CGFloat(index) * stackOffset) + .zIndex(Double(index)) + } + } + .frame(width: 68 + CGFloat(max(displayedImages.count - 1, 0)) * stackOffset, + height: 92, + alignment: .leading) + + if totalCount > 3 { + totalCountBadge(count: totalCount) + } + } + } + + // MARK: - Local Images Stack View + @ViewBuilder + private func localImagesStackView(images: [UIImage]) -> some View { + let displayedImages = Array(images.prefix(3)) + let totalCount = images.count + let stackOffset: CGFloat = 6 + let sizeReduction: CGFloat = 4 + + ZStack(alignment: .topTrailing) { + ZStack(alignment: .leading) { + ForEach(Array(displayedImages.enumerated()), id: \.offset) { index, image in + let reverseIndex = displayedImages.count - 1 - index + let imageWidth = 68 - CGFloat(reverseIndex) * sizeReduction + let imageHeight = 92 - CGFloat(reverseIndex) * sizeReduction + + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: imageWidth, height: imageHeight) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(Color.white, lineWidth: 0.4) + ) + .shadow(radius: 4) + .offset(x: CGFloat(index) * stackOffset) + .zIndex(Double(index)) + } + } + .frame(width: 68 + CGFloat(max(displayedImages.count - 1, 0)) * stackOffset, + height: 92, + alignment: .leading) + + if totalCount > 3 { + totalCountBadge(count: totalCount) + } + } + } + + // MARK: - Combined Images Stack View + /// Displays images in priority order: local (with loader) β†’ user β†’ inventory (reversed) + @ViewBuilder + private func combinedImagesStackView( + inventoryImages: [DTO.ImageLocationInfo], + userImages: [DTO.ImageLocationInfo], + localImages: [UIImage] + ) -> some View { + // Reverse inventory images so last appears at front of its section + let reversedInventory = Array(inventoryImages.reversed()) + + // Build combined display array + // Order (front to back): localImages β†’ userImages β†’ reversedInventory + let totalCount = localImages.count + userImages.count + inventoryImages.count + let displayLimit = 3 + let stackOffset: CGFloat = 6 + let sizeReduction: CGFloat = 4 + + // Build display items using helper function + let displayedItems = Array(buildDisplayItems( + localImages: localImages, + userImages: userImages, + reversedInventory: reversedInventory + ).prefix(displayLimit)) + + ZStack(alignment: .topTrailing) { + ZStack(alignment: .leading) { + ForEach(Array(displayedItems.enumerated()), id: \.element.id) { index, item in + let reverseIndex = displayedItems.count - 1 - index + let imageWidth = 68 - CGFloat(reverseIndex) * sizeReduction + let imageHeight = 92 - CGFloat(reverseIndex) * sizeReduction + + switch item.content { + case .local(let image): + // Local image with loader overlay + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: imageWidth, height: imageHeight) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .overlay( + // Loader overlay for uploading images + ZStack { + Color.black.opacity(0.25) + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + .tint(.white) + } + .clipShape(RoundedRectangle(cornerRadius: 16)) + ) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(Color.white, lineWidth: 0.4) + ) + .shadow(radius: 4) + .offset(x: CGFloat(index) * stackOffset) + .zIndex(Double(index)) + + case .api(let location): + // API image (user or inventory) + ScanProductImageThumbnail(imageLocation: location, isAnalyzing: isAnalyzing) + .frame(width: imageWidth, height: imageHeight) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(Color.white, lineWidth: 0.4) + ) + .shadow(radius: 4) + .offset(x: CGFloat(index) * stackOffset) + .zIndex(Double(index)) + } + } + } + .frame(width: 68 + CGFloat(max(displayedItems.count - 1, 0)) * stackOffset, + height: 92, + alignment: .leading) + + if totalCount > 3 { + totalCountBadge(count: totalCount) + } + } + } + + /// Helper to build display items array (moved outside @ViewBuilder) + private func buildDisplayItems( + localImages: [UIImage], + userImages: [DTO.ImageLocationInfo], + reversedInventory: [DTO.ImageLocationInfo] + ) -> [CombinedImageDisplayItem] { + var displayItems: [CombinedImageDisplayItem] = [] + + // First: local images (with loader) + for image in localImages { + displayItems.append(CombinedImageDisplayItem(content: .local(image), showLoader: true)) + } + + // Second: user images (processed, no loader) + for location in userImages { + displayItems.append(CombinedImageDisplayItem(content: .api(location), showLoader: false)) + } + + // Third: inventory images (reversed, no loader) + for location in reversedInventory { + displayItems.append(CombinedImageDisplayItem(content: .api(location), showLoader: false)) + } + + return displayItems + } + + // MARK: - Total Count Badge + @ViewBuilder + private func totalCountBadge(count: Int) -> some View { + ZStack { + Circle() + .fill(Color.white) + .overlay( + Circle() + .stroke(Color.gray.opacity(0.3), lineWidth: 1) + ) + Text("\(count)") + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(.black) + } + .frame(width: 22, height: 22) + .offset(x: 2, y: -3) + } + + // MARK: - Product Info View + @ViewBuilder + private var productInfoView: some View { + VStack(alignment: .leading) { + if isSkeletonMode { + skeletonPlaceholders + } else if isSubmitting { + submittingStateView + } else if isPendingMode || (isLoading && scan == nil) { + loadingStateView + } else if let product = product { + productDetailsView(product: product) + } else if notFoundState { + notFoundView + } else if let error = errorState, product == nil { + errorView(error: error) + } + } + } + + // MARK: - Skeleton Placeholders + @ViewBuilder + private var skeletonPlaceholders: some View { + RoundedRectangle(cornerRadius: 4) + .fill(.bar) + .opacity(0.4) + .frame(width: 185, height: 25) + .padding(.bottom, 4) + RoundedRectangle(cornerRadius: 4) + .fill(.bar) + .opacity(0.4) + .frame(width: 132, height: 20) + .padding(.bottom, 6) + RoundedRectangle(cornerRadius: 52) + .fill(.bar) + .opacity(0.4) + .frame(width: 79, height: 24) + } + + // MARK: - Submitting State View + @ViewBuilder + private var submittingStateView: some View { + VStack(alignment: .leading) { + Text("Submitting your photo…") + .font(ManropeFont.bold.size(12)) + .foregroundColor(Color.white) + .padding(.bottom, 2) + Text("We're uploading the image to analyze") + .multilineTextAlignment(.leading) + .lineLimit(2) + .font(ManropeFont.semiBold.size(10)) + .foregroundColor(Color.white) + Spacer(minLength: 8) + statusBadge(text: "Submitting", colors: [Color(hex: "#A6A6A6"), Color(hex: "#818181")], width: 110) + } + } + + // MARK: - Loading State View + @ViewBuilder + private var loadingStateView: some View { + VStack(alignment: .leading) { + Text("Looking up this product…") + .font(ManropeFont.bold.size(12)) + .foregroundColor(Color.white) + .padding(.bottom, 2) + Text("We're checking our database for this Product") + .multilineTextAlignment(.leading) + .lineLimit(2) + .font(ManropeFont.semiBold.size(10)) + .foregroundColor(Color.white) + Spacer(minLength: 8) + statusBadge(text: "Fetching details", colors: [Color(hex: "#A6A6A6"), Color(hex: "#818181")], width: 130) + } + } + + // MARK: - Status Badge + @ViewBuilder + private func statusBadge(text: String, colors: [Color], width: CGFloat) -> some View { + HStack(spacing: 8) { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + .tint( + LinearGradient( + colors: colors, + startPoint: .leading, + endPoint: .trailing + ) + ) + .scaleEffect(1) + .frame(width: 16, height: 16) + Text(text) + .font(NunitoFont.semiBold.size(12)) + .foregroundStyle( + LinearGradient( + colors: colors, + startPoint: .leading, + endPoint: .trailing + ) + ) + } + .frame(width: width, height: 22) + .padding(4) + .background( + Capsule() + .fill(.bar) + ) + } + + // MARK: - Product Details View + @ViewBuilder + private func productDetailsView(product: DTO.Product) -> some View { + VStack(alignment: .leading, spacing: 4) { + // Top row: Name + Heart button + HStack(alignment: .top, spacing: 8) { + VStack(alignment: .leading, spacing: 2) { + if let productName = product.name, !productName.isEmpty { + Text(productName) + .font(NunitoFont.semiBold.size(14)) + .foregroundColor(Color.white.opacity(0.85)) + .lineLimit(2) + } + if let brand = product.brand, !brand.isEmpty { + Text(brand) + .font(ManropeFont.regular.size(12)) + .foregroundColor(.white.opacity(0.7)) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + // Heart button (top right) + heartButton(isFavorited: scan?.is_favorited ?? false) + } + + Spacer(minLength: 4) + + // Bottom row: Status badge + Chevron + HStack { + if isAnalyzing && ingredientRecommendations == nil { + analyzingBadge + } else if let matchStatus = matchStatus { + matchStatusBadge(matchStatus: matchStatus) + } else if errorState != nil { + retryButton + } + + Spacer() + + // Chevron disclosure icon (bottom right) + chevronIcon + } + } + } + + // MARK: - Chevron Icon + @ViewBuilder + private var chevronIcon: some View { + Image(systemName: "chevron.right") + .font(.system(size: 11, weight: .regular)) + .foregroundColor(.white) + } + + // MARK: - Heart Button + @ViewBuilder + private func heartButton(isFavorited: Bool) -> some View { + Button(action: { + onFavoriteToggle?(scanId, !isFavorited) + }) { + Circle() + .fill(.ultraThinMaterial) + .frame(width: 32, height: 32) + .overlay( + Image(systemName: isFavorited ? "heart.fill" : "heart") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(isFavorited ? Color(hex: "#FF4D4D") : .white) + ) + } + .buttonStyle(.plain) + } + + // MARK: - Analyzing Badge + @ViewBuilder + private var analyzingBadge: some View { + HStack(spacing: 4) { + Image("analysisicon") + .frame(width: 18, height: 18) + Text("Analyzing") + .font(.system(size: 14, weight: .bold)) + .foregroundColor(.white) + } + .padding(.leading, 8) + .padding(.trailing, 12) + .padding(.vertical, 6) + .background( + Capsule() + .fill( + LinearGradient( + colors: [Color(hex: "#3DA8F5"), Color(hex: "#3DACFB")], + startPoint: .leading, + endPoint: .trailing + ) + ) + ) + } + + // MARK: - Match Status Badge + @ViewBuilder + private func matchStatusBadge(matchStatus: DTO.ProductRecommendation) -> some View { + HStack(spacing: 4) { + Image(matchStatus.iconAssetName) + .frame(width: 18, height: 18) + Text(matchStatus.displayText) + .font(NunitoFont.bold.size(14)) + .foregroundColor(.white) + } + .padding(.leading, 8) + .padding(.trailing, 12) + .padding(.vertical, 6) + .background( + Capsule() + .fill( + LinearGradient( + colors: matchStatus.gradientColors, + startPoint: .leading, + endPoint: .trailing + ) + ) + ) + } + + // MARK: - Retry Button + @ViewBuilder + private var retryButton: some View { + Button(action: { + retryPolling() + }) { + HStack(spacing: 4) { + Image("stasharrow-retry") + .frame(width: 18, height: 18) + Text("Retry") + .font(NunitoFont.bold.size(12)) + .foregroundColor(.white) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + Capsule() + .fill( + LinearGradient( + colors: [Color(hex: "#B5B5B5"), Color(hex: "#D3D3D3")], + startPoint: .leading, + endPoint: .trailing + ) + ) + ) + } + .buttonStyle(.plain) + .onAppear { + onRetryShown?() + } + } + + // MARK: - Not Found View + @ViewBuilder + private var notFoundView: some View { + VStack(alignment: .leading, spacing: 4) { + Spacer(minLength: 0) + Text("We couldn't identify this product") + .font(ManropeFont.bold.size(11)) + .foregroundColor(Color.white) + .lineLimit(1) + .minimumScaleFactor(0.85) + + Text("Help us identify it, add a few photos of the product.") + .font(ManropeFont.semiBold.size(10)) + .foregroundColor(Color.white.opacity(0.9)) + .lineLimit(2) + Spacer(minLength: 0) + } + } + + // MARK: - Error View + @ViewBuilder + + private func errorView(error: String) -> some View { + VStack(spacing: 4) { + Spacer(minLength: 0) + Text("Something went wrong") + .font(.system(size: 12, weight: .regular)) + .foregroundColor(Color.white) + Text(error) + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(Color.white.opacity(0.9)) + .lineLimit(2) + Spacer(minLength: 0) + } + } + + private func fetchScan() async { + // Photo scans may need to fetch even if initialScan is provided (for re-polling after subsequent photos) + // Barcode scans with initialScan should not reach here (they use SSE) + + isLoading = true + errorState = nil + + // Wait 2 seconds before first fetch for photo scans + // This gives the server time to process the image after submission + Log.debug("SCAN_CARD", "⏳ Waiting 2 seconds before first fetch for photo scan - scan_id: \(scanId)") + try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds + + do { + Log.debug("SCAN_CARD", "πŸ”΅ Fetching scan via API - scan_id: \(scanId) (photo scan or history)") + let fetchedScan = try await webService.getScan(scanId: scanId) + + await MainActor.run { + self.scan = fetchedScan + self.isLoading = false + onResultUpdated?() + onScanUpdated?(fetchedScan) // Update parent cache with fetched data + + // Only start polling for photo scans (not barcode scans) + // Barcode scans use SSE and don't need polling + if fetchedScan.scan_type == "photo" || fetchedScan.scan_type == "barcode_plus_photo" { + // Start polling if scan is not done yet + if fetchedScan.state != "done" { + Log.debug("SCAN_CARD", "⏳ Starting polling for photo scan - scan_id: \(scanId), state: \(fetchedScan.state)") + startPolling() + } else { + // Scan is complete, increment scan count + Log.debug("SCAN_CARD", "βœ… Scan is done - scan_id: \(scanId)") + userPreferences.incrementScanCount() + } + } else { + // Barcode scan - should not reach here if initialScan was provided + // But if it does, don't poll (analysis comes via SSE) + Log.warning("SCAN_CARD", "⚠️ Barcode scan fetched via API - this should use initialScan instead") + if fetchedScan.state == "done" { + userPreferences.incrementScanCount() + } + } + } + } catch { + await MainActor.run { + self.errorState = error.localizedDescription + self.isLoading = false + Log.error("SCAN_CARD", "❌ Failed to fetch scan - scan_id: \(scanId), error: \(error.localizedDescription)") + } + } + } + + private func startPolling() { + // Prevent duplicate polling sessions + guard pollingTask == nil && !isPolling else { + Log.warning("SCAN_CARD", "⚠️ Polling already active - skipping startPolling() - scan_id: \(scanId)") + return + } + + // Only poll for photo scans and barcode_plus_photo scans - barcode scans use SSE + guard let currentScan = scan, (currentScan.scan_type == "photo" || currentScan.scan_type == "barcode_plus_photo") else { + Log.warning("SCAN_CARD", "⚠️ startPolling() called but scan is not a photo or barcode_plus_photo scan - skipping") + return + } + + Log.debug("SCAN_CARD", "⏳ Starting polling for \(currentScan.scan_type) scan - scan_id: \(scanId)") + isPolling = true + + pollingTask = Task { + var pollCount = 0 + + + while !Task.isCancelled { + pollCount += 1 + + do { + try await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds + + Log.debug("SCAN_CARD", "πŸ”„ Poll #\(pollCount) - scan_id: \(scanId) (photo scan)") + let fetchedScan = try await webService.getScan(scanId: scanId) + + await MainActor.run { + self.scan = fetchedScan + onResultUpdated?() + onScanUpdated?(fetchedScan) // Update parent cache with latest data + + // Stop polling if scan is done + if fetchedScan.state == "done" { + Log.debug("SCAN_CARD", "βœ… Polling complete - scan_id: \(scanId), state: done") + pollingTask?.cancel() + pollingTask = nil + isPolling = false + userPreferences.incrementScanCount() + } + } + } catch { + if !Task.isCancelled { + await MainActor.run { + self.errorState = error.localizedDescription + Log.error("SCAN_CARD", "❌ Poll error - scan_id: \(scanId), error: \(error.localizedDescription)") + pollingTask?.cancel() + pollingTask = nil + isPolling = false + } + } + break + } + } + } + } + + private func retryPolling() { + errorState = nil + pollingTask?.cancel() + pollingTask = nil + Task { + await fetchScan() + } + } +} + +// MARK: - Combined Image Display Item +/// Helper struct for combined image stack display +private struct CombinedImageDisplayItem: Identifiable { + let id = UUID() + enum Content { + case local(UIImage) + case api(DTO.ImageLocationInfo) + } + let content: Content + let showLoader: Bool +} + +// MARK: - Product Image Thumbnail Component +private struct ScanProductImageThumbnail: View { + let imageLocation: DTO.ImageLocationInfo + let isAnalyzing: Bool + + @Environment(WebService.self) private var webService + @State private var image: UIImage? + + var body: some View { + ZStack { + if let image { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + } else { + // When the image cannot be loaded from the server, fall back to placeholder + ZStack { + RoundedRectangle(cornerRadius: 16) + .fill(.bar.opacity(0.4)) + .frame(width: 68, height: 92) + Image("imagenotfound1") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 39, height: 34) + } + } + + if isAnalyzing { + Color.black.opacity(0.25) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .frame(width: 68, height: 92) + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + .tint(.white) + } + } + .clipped() + .task(id: imageLocationKey) { + guard image == nil else { return } + do { + Log.debug("ScanDataCard", "Fetching thumbnail: \(imageLocationKey)") + let uiImage = try await webService.fetchImage(imageLocation: imageLocation, imageSize: .small) + await MainActor.run { + image = uiImage + } + } catch { + Log.error("ScanDataCard", "❌ Thumbnail fetch failed: \(error)") + } + } + } + + private var imageLocationKey: String { + switch imageLocation { + case .url(let url): + return url.absoluteString + case .imageFileHash(let hash): + return hash + case .scanImagePath(let path): + return path + } + } +} + +// MARK: - Previews + +#if DEBUG +// Sample scan data for previews (without analysis result since it requires Decoder) +private func makeSampleScan( + state: String = "done", + name: String? = "Sample Product", + brand: String? = "Sample Brand", + ingredients: [DTO.Ingredient] = [ + DTO.Ingredient(name: "Water", vegan: true, vegetarian: true, ingredients: []), + DTO.Ingredient(name: "Sugar", vegan: true, vegetarian: true, ingredients: []) + ] +) -> DTO.Scan { + let productInfo = DTO.ScanProductInfo( + name: name, + brand: brand, + ingredients: ingredients, + images: nil, + claims: ["Vegan"] + ) + + return DTO.Scan( + id: "preview-scan-id", + scan_type: "barcode", + barcode: "1234567890", + state: state, + product_info: productInfo, + product_info_source: "openfoodfacts", + analysis_result: nil, // Can't create ScanAnalysisResult without Decoder + images: [], + latest_guidance: nil, + created_at: "2025-01-05T10:00:00Z", + last_activity_at: "2025-01-05T10:00:00Z" + ) +} + +#Preview("Skeleton Loading") { + ScanDataCard(scanId: "skeleton") + .environment(WebService()) + .environment(AppState()) + .environment(UserPreferences()) + .frame(height: 120) + .padding() +} + +#Preview("Analyzing State") { + ScanDataCard( + scanId: "preview-1", + initialScan: makeSampleScan(state: "analyzing") + ) + .environment(WebService()) + .environment(AppState()) + .environment(UserPreferences()) + .frame(height: 120) + .padding() +} + +#Preview("Product with Ingredients") { + ScanDataCard( + scanId: "preview-2", + initialScan: makeSampleScan() + ) + .environment(WebService()) + .environment(AppState()) + .environment(UserPreferences()) + .frame(height: 120) + .padding() +} + +#Preview("Missing Ingredients") { + ScanDataCard( + scanId: "preview-3", + initialScan: makeSampleScan(ingredients: []) + ) + .environment(WebService()) + .environment(AppState()) + .environment(UserPreferences()) + .frame(height: 120) + .padding() +} + +#Preview("Pending Mode") { + ScanDataCard(scanId: "pending_12345") + .environment(WebService()) + .environment(AppState()) + .environment(UserPreferences()) + .frame(height: 120) + .padding() +} +#endif diff --git a/IngrediCheck/Views/BarcodeScan/Components/Cards/SkeletonScanCard.swift b/IngrediCheck/Views/BarcodeScan/Components/Cards/SkeletonScanCard.swift new file mode 100644 index 00000000..ec9cf02c --- /dev/null +++ b/IngrediCheck/Views/BarcodeScan/Components/Cards/SkeletonScanCard.swift @@ -0,0 +1,58 @@ +import SwiftUI + +/// Skeleton/redacted card shown as the first card when camera opens or no active scans +struct SkeletonScanCard: View { + var scanType: String = "barcode" // "barcode" or "photo" + + var body: some View { + HStack(spacing: 14) { + // Left-side: skeleton image placeholder + ZStack { + // Placeholder icon based on scan type + let placeholderImage = scanType == "photo" ? "PhotoScanEmptyState" : "Barcodelinecorners" + Image(placeholderImage) + .resizable() + .scaledToFit() + .frame(width: 64, height: 88) + .clipped() + } + .frame(width: 68, height: 92) + .padding(.leading, 14) + .layoutPriority(1) + + // Right-side: skeleton text placeholders + VStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 4) + .fill(.bar) + .opacity(0.4) + .frame(width: 185, height: 25) + .padding(.bottom, 4) + RoundedRectangle(cornerRadius: 4) + .fill(.bar) + .opacity(0.4) + .frame(width: 132, height: 20) + .padding(.bottom, 6) + RoundedRectangle(cornerRadius: 52) + .fill(.bar) + .opacity(0.4) + .frame(width: 79, height: 24) + } + .frame(maxWidth: .infinity, + minHeight: 92, + maxHeight: 92, + alignment: .leading + ) + + Spacer() + .frame(width: 14) + } + .frame(width: 300, height: 120) + .background( + RoundedRectangle(cornerRadius: 24) + .fill(.bar) + .opacity(0.4) + ) + .clipped() + } +} + diff --git a/IngrediCheck/Views/BarcodeScan/Components/Overlays/BarcodeScannerOverlay.swift b/IngrediCheck/Views/BarcodeScan/Components/Overlays/BarcodeScannerOverlay.swift new file mode 100644 index 00000000..cbd9c978 --- /dev/null +++ b/IngrediCheck/Views/BarcodeScan/Components/Overlays/BarcodeScannerOverlay.swift @@ -0,0 +1,73 @@ +import SwiftUI +import UIKit + +/// Overlay view that displays the barcode scanning frame with animated scanning line and hint text +struct BarcodeScannerOverlay: View { + @State private var scanY: CGFloat = 0 + var onRectChange: ((CGRect, CGSize) -> Void)? = nil + + var body: some View { + GeometryReader { geo in + let rect = centerRect(in: geo) + ZStack { + ZStack { + // Dark overlay with a rounded-rect cutout + CutoutOverlay(rect: rect) + + // Animated yellow scanning line (clipped to scanner frame) + Rectangle() + .fill(Color.yellow) + .frame(width: rect.width - 4, height: 3) + .shadow( + color: Color.yellow.opacity(1), + radius: 12, + x: 0, + y: 8 + ) + .offset(y: scanY) + .frame(width: rect.width, height: rect.height) + .clipped() + .position(x: rect.midX, y: rect.midY) + .onAppear { + scanY = (-rect.height / 2) + 6 + withAnimation(.easeInOut(duration: 1.8).repeatForever(autoreverses: true)) { + scanY = (rect.height / 2) - 6 + } + } + + // Scanner border frame + Image("Scannerborderframe") + .resizable() + .frame(width: rect.width, height: rect.height) + .position(x: rect.midX, y: rect.midY) + } + + VStack { + // Hint text below scanner frame + Text("Align the barcode within the frame to scan") + .frame(width: 220, height: 42) + .multilineTextAlignment(.center) + .lineLimit(2) + .font(ManropeFont.medium.size(14)) + .foregroundColor(Color.grayScale10) + .position(x: rect.midX, y: rect.maxY + 28) + }.padding(.top, 24) + } + .onAppear { onRectChange?(rect, geo.size) } + .onChange(of: geo.size) { newSize in onRectChange?(rect, newSize) } + } + .ignoresSafeArea() + } + + /// Calculate the center rect for the scanner frame + func centerRect(in geo: GeometryProxy) -> CGRect { + let width: CGFloat = 286 + let height: CGFloat = 121 + return CGRect( + x: (geo.size.width - width) / 2, + y: 209, + width: width, + height: height + ) + } +} diff --git a/IngrediCheck/Views/BarcodeScan/Components/Overlays/OverlayShapes.swift b/IngrediCheck/Views/BarcodeScan/Components/Overlays/OverlayShapes.swift new file mode 100644 index 00000000..ac727575 --- /dev/null +++ b/IngrediCheck/Views/BarcodeScan/Components/Overlays/OverlayShapes.swift @@ -0,0 +1,38 @@ +import SwiftUI +import UIKit + +/// Overlay view that creates a dark semi-transparent mask with a rounded cutout +struct CutoutOverlay: View { + var rect: CGRect + + var body: some View { + Color.black.opacity(0.5) + .mask( + CutoutShape(rect: rect) + .fill(style: FillStyle(eoFill: true)) + ) + .ignoresSafeArea() + } +} + +/// Shape that creates a full-screen rectangle with a rounded cutout hole +struct CutoutShape: Shape { + let rect: CGRect + let cornerRadius: CGFloat = 12 + + func path(in bounds: CGRect) -> Path { + var path = Path() + + // Full dark overlay + path.addRect(bounds) + + // Rounded transparent hole + let rounded = UIBezierPath( + roundedRect: rect, + cornerRadius: cornerRadius + ) + path.addPath(Path(rounded.cgPath)) + + return path + } +} diff --git a/IngrediCheck/Views/BarcodeScan/Components/UI/FlashToggleButton.swift b/IngrediCheck/Views/BarcodeScan/Components/UI/FlashToggleButton.swift new file mode 100644 index 00000000..7f959233 --- /dev/null +++ b/IngrediCheck/Views/BarcodeScan/Components/UI/FlashToggleButton.swift @@ -0,0 +1,55 @@ +import SwiftUI + +/// Flash toggle button for scanner and photo modes +struct FlashToggleButton: View { + @State private var isFlashon = false + /// When `true`, show the system torch icon; when `false`, show the custom flash asset. + var isScannerMode: Bool + /// Optional callback used in photo mode so the parent can decide what to do + /// with the armed flash state when a picture is taken. + var onTogglePhotoFlash: ((Bool) -> Void)? = nil + + var body: some View { + HStack(spacing: 4) { + if isScannerMode { + // Scanner mode: show torch icon only (no background, matches nav back button) + Image(systemName: isFlashon ? "flashlight.on.fill" : "flashlight.off.fill") + .resizable() + .scaledToFit() + .frame(width: 22, height: 22) + .foregroundColor(.white) + } else { + // Photo mode: show custom flash asset + ZStack { + Circle() + .fill(.bar.opacity(0.4)) + .frame(width: 48, height: 48) + Image(isFlashon ? "flashon" : "flashoff") + .resizable() + .frame(width: 28, height: 24) + .foregroundColor(.white) + } + } + } + .onTapGesture { + withAnimation(.easeInOut) { + if isScannerMode { + // In live scanner mode, toggle the device torch immediately. + FlashManager.shared.toggleFlash { on in + self.isFlashon = on + } + } else { + // In photo capture mode, only arm/disarm flash for the next shot. + isFlashon.toggle() + onTogglePhotoFlash?(isFlashon) + } + } + } + .onAppear { + // Keep UI in sync with the current hardware state for scanner mode. + if isScannerMode { + isFlashon = FlashManager.shared.isFlashOn() + } + } + } +} diff --git a/IngrediCheck/Views/BarcodeScan/Components/UI/ScanBackButton.swift b/IngrediCheck/Views/BarcodeScan/Components/UI/ScanBackButton.swift new file mode 100644 index 00000000..9d611152 --- /dev/null +++ b/IngrediCheck/Views/BarcodeScan/Components/UI/ScanBackButton.swift @@ -0,0 +1,23 @@ +import SwiftUI + +/// Back button for dismissing the camera screen +struct ScanBackButton: View { + @Environment(\.dismiss) private var dismiss + + var body: some View { + Button { + dismiss() + } label: { + ZStack { + Image("angle-left-arrow") + .frame(width: 24, height: 24) + .foregroundColor(.white) + } + .frame(width: 33, height: 33) + .background( + .bar.opacity(0.4), in: .capsule + ) + } + .buttonStyle(.plain) + } +} diff --git a/IngrediCheck/Views/BarcodeScan/Components/UI/ScanStatusToast.swift b/IngrediCheck/Views/BarcodeScan/Components/UI/ScanStatusToast.swift new file mode 100644 index 00000000..c93c1059 --- /dev/null +++ b/IngrediCheck/Views/BarcodeScan/Components/UI/ScanStatusToast.swift @@ -0,0 +1,121 @@ +import SwiftUI + +/// Toast message view displaying scan status with animated shimmer effect +struct ScanStatusToast: View { + let state: ToastScanState + + @State private var shimmerPhase: CGFloat = -1 + // Wider and slower shimmer so it's very visible to the eye. + private let shimmerGradientWidth: CGFloat = 110 + private let animationDuration: Double = 1.8 + + private var iconName: String { + switch state { + case .scanning: + return "ic_round-tips-and-updates" + default: + // For all non-scanning states we use the analysis icon + return "analysisicon" + } + } + + private var message: String { + switch state { + case .scanning: + return "Ensure good lighting and steady hands" + case .extractionSuccess: + return "Scanning successful. Fetching data…" + case .notIdentified: + return "Scan again or add photos for better results." + case .analyzing: + return "Product detected, reading ingredients." + case .match: + return "Good news! This product matches your preferences." + case .notMatch: + return "This product might not align with your choices." + case .uncertain: + return "Some ingredients need verification." + case .retry: + return "Let's try again for a clearer scan." + case .photoGuide: + return "Capture clear photos of the product label" + case .dynamicGuidance(let guidance): + return guidance // Use dynamic message from API + } + } + + var body: some View { + labelContent + .frame(height: 36) + .background( + RoundedRectangle(cornerRadius: 20) + .fill(.bar) + .opacity(0.4) + ) + .overlay( + // Shimmer effect - moves left to right across the dynamic content width + GeometryReader { geo in + let width = geo.size.width + LinearGradient( + gradient: Gradient(colors: [ + Color.white.opacity(0.0), + Color.white.opacity(0.9), + Color.white.opacity(1.0), + Color.white.opacity(0.9), + Color.white.opacity(0.0) + ]), + startPoint: .leading, + endPoint: .trailing + ) + .frame(width: shimmerGradientWidth) + // Move the shimmer a bit farther so it fully covers + // the last characters instead of stopping short. + .offset(x: shimmerPhase * (width + shimmerGradientWidth)) + // Screen blend mode makes the highlight much more visible + // over the dimmed base text/icon. + .blendMode(.screen) + } + .mask( + labelContent + .frame(height: 36) + ) + ) + .onAppear { + startShimmer() + } + .onChange(of: state) { _ in + startShimmer() + } + .animation(.easeInOut(duration: 0.2), value: state) + } + + @ViewBuilder + private var labelContent: some View { + HStack(spacing: 8) { + Image(iconName) + .resizable() + .renderingMode(.template) + .frame(width: 19, height: 19) + .foregroundColor(.white.opacity(0.6)) + + Text(message) + .font(ManropeFont.medium.size(12)) + .foregroundColor(.white.opacity(0.6)) + .lineLimit(2) + .multilineTextAlignment(.leading) + } + .padding(.horizontal, 16) // Max 16px horizontal padding + } + + private func startShimmer() { + shimmerPhase = -1 + withAnimation( + Animation + .linear(duration: animationDuration) + .delay(0.1) + .repeatForever(autoreverses: false) + ) { + shimmerPhase = 1 + } + } +} diff --git a/IngrediCheck/Views/BarcodeScan/Core/BarcodeCameraPreview.swift b/IngrediCheck/Views/BarcodeScan/Core/BarcodeCameraPreview.swift new file mode 100644 index 00000000..e01d56b6 --- /dev/null +++ b/IngrediCheck/Views/BarcodeScan/Core/BarcodeCameraPreview.swift @@ -0,0 +1,32 @@ +import SwiftUI +import AVFoundation + +/// UIViewRepresentable wrapper for AVCaptureVideoPreviewLayer to display camera feed +struct BarcodeCameraPreview: UIViewRepresentable { + + @ObservedObject var cameraManager: BarcodeCameraManager + + func makeUIView(context: Context) -> UIView { + let view = UIView(frame: .zero) + view.backgroundColor = .black + + if let previewLayer = cameraManager.previewLayer { + previewLayer.frame = UIScreen.main.bounds + view.layer.addSublayer(previewLayer) + } + + return view + } + + func updateUIView(_ uiView: UIView, context: Context) { + if let previewLayer = cameraManager.previewLayer { + if previewLayer.superlayer !== uiView.layer { + uiView.layer.addSublayer(previewLayer) + } + if let connection = previewLayer.connection, connection.isVideoOrientationSupported { + connection.videoOrientation = .portrait + } + previewLayer.frame = uiView.bounds + } + } +} diff --git a/IngrediCheck/Views/BarcodeScan/Core/ScanCameraView.swift b/IngrediCheck/Views/BarcodeScan/Core/ScanCameraView.swift new file mode 100644 index 00000000..78aef570 --- /dev/null +++ b/IngrediCheck/Views/BarcodeScan/Core/ScanCameraView.swift @@ -0,0 +1,1450 @@ +import SwiftUI +import AVFoundation +import UIKit +import Combine +import PhotosUI +import CryptoKit +import os +import StoreKit + +enum CameraPresentationSource { + case homeView + case productDetailView + case pushNavigation // Used when navigating via AppRoute (Single Root NavigationStack) +} + +struct ScanCameraView: View { + + @StateObject var camera = BarcodeCameraManager() + @State private var cameraStatus: AVAuthorizationStatus = .notDetermined + @Environment(\.scenePhase) var scenePhase + @Environment(WebService.self) var webService + @Environment(ScanHistoryStore.self) var scanHistoryStore + @Environment(UserPreferences.self) var userPreferences + @Environment(AppState.self) var appState: AppState? // Optional - available when in root NavigationStack + @Environment(\.dismiss) private var dismiss + @State private var isCaptured: Bool = false + @State private var overlayRect: CGRect = .zero + @State private var overlayContainerSize: CGSize = .zero + @State private var codes: [String] = [] // Keep for barcode detection tracking + @State private var scanIds: [String] = [] // Unified scanIds array for both modes + @State private var scrollTargetScanId: String? + @State private var isUserDragging: Bool = false + @State private var lastUserDragAt: Date? = nil + @State private var currentCenteredScanId: String? = nil + @State private var cardCenterData: [CardCenterPreferenceData] = [] + @State private var mode: CameraMode = .scanner + @State private var capturedPhoto: UIImage? = nil + @State private var capturedPhotoHistory: [UIImage] = [] + @State private var galleryLimitHit: Bool = false + @State private var isShowingPhotoPicker: Bool = false + @State private var isShowingPhotoModeGuide: Bool = false + @State private var showRetryCallout: Bool = false + @State private var toastState: ToastScanState = .scanning + @State private var selectedProduct: DTO.Product? = nil + @State private var selectedMatchStatus: DTO.ProductRecommendation? = nil + @State private var selectedIngredientRecommendations: [DTO.IngredientRecommendation]? = nil + @State private var selectedOverallAnalysis: String? = nil + @State private var selectedScanId: String? = nil // Track which scan was tapped (for local images) + @State private var isProductDetailPresented: Bool = false + @State private var photoFlashEnabled: Bool = false + @State private var scanId: String? = nil // Current active scanId for photo scans + @State private var barcodeToScanIdMap: [String: String] = [:] // Map barcode -> scanId for scanner mode (includes active + history) + @State private var pendingBarcodes: Set = [] // Track barcodes that are being scanned (waiting for scanId) + @State private var historyScanIds: [String] = [] // Scan IDs from history (shown after active scans) + @State private var scanDataCache: [String: DTO.Scan] = [:] // Cache scan data from SSE events (barcode scans) and history + @State private var capturedImagesPerScanId: [String: [(image: UIImage, hash: String)]] = [:] // Track captured images per scanId with hash + @State private var submittingScanIds: Set = [] // Track scanIds currently submitting images (prevents premature getScan) + private let skeletonCardId = "skeleton" // Constant ID for skeleton card + @State private var completedHapticScanIds: Set = [] // Track scans we've already fired haptics for + + // MARK: - Rating Prompt State + @State private var awaitingRatingOutcome = false + @State private var ratingPromptPresentedAt: Date? + @State private var dismissalFallbackTask: Task? + + // MARK: - Presentation Source Tracking + @State private var presentationSource: CameraPresentationSource = .homeView + @State private var isProgrammaticModeChange: Bool = false + @State private var targetScanIdFromProductDetail: String? = nil + + // MARK: - Image Hash Helper + private func calculateImageHash(image: UIImage) -> String { + guard let imageData = image.jpegData(compressionQuality: 1.0) else { return "" } + return SHA256.hash(data: imageData).compactMap { String(format: "%02x", $0) }.joined() + } + + private func updateToastState() { + // When in photo mode, check for dynamic guidance from scan data + if mode == .photo { + // Try to get the latest_guidance from the current centered scan + if let activeScanId = currentCenteredScanId, + !activeScanId.isEmpty, + activeScanId != "skeleton", + !activeScanId.hasPrefix("pending_"), + let scan = scanDataCache[activeScanId], + let guidance = scan.latest_guidance, + !guidance.isEmpty { + // Use dynamic guidance from API + toastState = .dynamicGuidance(guidance) + return + } + + // No dynamic guidance available, use default photo guide + toastState = .photoGuide + return + } + + // Only show these scan-related toasts in scanner mode + guard mode == .scanner else { + toastState = .scanning + return + } + + // No scanIds yet: user is aligning/scanning + guard let activeScanId = currentCenteredScanId, !activeScanId.isEmpty else { + toastState = .scanning + return + } + + // Check for pending placeholder (fetching details) + if activeScanId.hasPrefix("pending_") { + toastState = .extractionSuccess + return + } + + // Check for skeleton card + if activeScanId == skeletonCardId { + toastState = .scanning + return + } + + // Fetch scan data from cache and derive toast state + guard let scan = scanDataCache[activeScanId] else { + // No scan data available yet + toastState = .scanning + return + } + + // Check scan state + if scan.state == "analyzing" || scan.state == "processing_images" { + toastState = .analyzing + return + } + + // Check if analysis is complete + if scan.state == "done", let analysisResult = scan.analysis_result { + // Check overall match status + switch analysisResult.overall_match { + case "match": + toastState = .match + case "not_match": + toastState = .notMatch + case "uncertain": + toastState = .uncertain + default: + toastState = .uncertain + } + return + } + + // Check if scan has error (empty product info and no analysis) + // Error state: product_info has no name/brand/ingredients and no analysis result + if scan.product_info.name == nil && + scan.product_info.brand == nil && + scan.product_info.ingredients.isEmpty && + scan.analysis_result == nil { + toastState = .notIdentified + return + } + + // Default to scanning + toastState = .scanning + } + + // MARK: - Barcode Scan + private func startBarcodeScan(barcode: String) async { + Log.debug("BARCODE_SCAN", "πŸ”΅ CameraScreen: Starting barcode scan - barcode: \(barcode)") + + let placeholderScanId = "pending_\(barcode)" + + // Helper to generate ISO8601 timestamp + let iso8601Formatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + func getCurrentTimestamp() -> String { + return iso8601Formatter.string(from: Date()) + } + + do { + try await webService.streamBarcodeScan( + barcode: barcode, + onProductInfo: { productInfo, scanId, productInfoSource, images in + // Construct DTO.Scan from SSE product_info event + Task { @MainActor in + // Remove placeholder if it exists + if let placeholderIndex = scanIds.firstIndex(of: placeholderScanId) { + scanIds.remove(at: placeholderIndex) + } + + // Remove skeleton card if it exists (first scan) + if let skeletonIndex = scanIds.firstIndex(of: skeletonCardId) { + scanIds.remove(at: skeletonIndex) + } + + // Remove from history if it's there (shouldn't happen, but just in case) + if let historyIndex = historyScanIds.firstIndex(of: scanId) { + historyScanIds.remove(at: historyIndex) + } + + // Construct partial DTO.Scan from product_info event + let partialScan = DTO.Scan( + id: scanId, + scan_type: "barcode", + barcode: barcode, + state: "analyzing", // Analysis is in progress + product_info: productInfo, + product_info_source: productInfoSource, + analysis_result: nil, + images: images, + latest_guidance: nil, + created_at: getCurrentTimestamp(), + last_activity_at: getCurrentTimestamp(), + is_favorited: nil, + analysis_id: nil + ) + + // Store in cache AND store + scanDataCache[scanId] = partialScan + scanHistoryStore.upsertScan(partialScan) + Log.debug("BARCODE_SCAN", "πŸ’Ύ CameraScreen: Stored partial scan in cache and store - scanId: \(scanId), product_name: \(productInfo.name ?? "nil")") + + // Add real scanId at the beginning (newest first) + if !scanIds.contains(scanId) { + withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) { + scanIds.insert(scanId, at: 0) + scrollTargetScanId = scanId + } + barcodeToScanIdMap[barcode] = scanId + pendingBarcodes.remove(barcode) + Log.debug("BARCODE_SCAN", "βœ… CameraScreen: scanId received - barcode: \(barcode), scanId: \(scanId), replaced placeholder/skeleton") + } + updateToastState() + } + }, + onAnalysis: { analysisResult in + // Update existing scan in cache with analysis results + Task { @MainActor in + // Find the scanId for this barcode (should be in barcodeToScanIdMap) + if let scanId = barcodeToScanIdMap[barcode], let existingScan = scanDataCache[scanId] { + // Update scan with analysis results + let updatedScan = DTO.Scan( + id: existingScan.id, + scan_type: existingScan.scan_type, + barcode: existingScan.barcode, + state: "done", // Analysis complete + product_info: existingScan.product_info, + product_info_source: existingScan.product_info_source, + analysis_result: analysisResult, + images: existingScan.images, + latest_guidance: existingScan.latest_guidance, + created_at: existingScan.created_at, + last_activity_at: getCurrentTimestamp(), + is_favorited: existingScan.is_favorited, + analysis_id: existingScan.analysis_id + ) + + // Update cache AND store + scanDataCache[scanId] = updatedScan + scanHistoryStore.upsertScan(updatedScan) + Log.debug("BARCODE_SCAN", "πŸ’Ύ CameraScreen: Updated scan in cache and store with analysis - scanId: \(scanId), overall_match: \(analysisResult.overall_match ?? "nil")") + + // Trigger UI update (ScanDataCard will refresh via scanDataCache) + updateToastState() + } else { + Log.warning("BARCODE_SCAN", "⚠️ CameraScreen: Received analysis but scanId not found in cache - barcode: \(barcode)") + } + } + }, + onError: { error, scanId in + // Remove placeholder on error + Task { @MainActor in + Log.error("BARCODE_SCAN", "❌ CameraScreen: Barcode scan error - barcode: \(barcode), error: \(error.localizedDescription), scanId: \(scanId ?? "nil")") + + if let placeholderIndex = scanIds.firstIndex(of: placeholderScanId) { + scanIds.remove(at: placeholderIndex) + } + pendingBarcodes.remove(barcode) + + // If scanId is available from error, store error state in cache and add to array + if let scanId = scanId { + // Create empty product info for error state + let emptyProductInfo = DTO.ScanProductInfo( + name: nil, + brand: nil, + ingredients: [], + images: nil + ) + + // Create error scan with minimal data to show error state + let errorScan = DTO.Scan( + id: scanId, + scan_type: "barcode", + barcode: barcode, + state: "done", // Mark as done but with no results (indicates error) + product_info: emptyProductInfo, // Empty product info = error state + product_info_source: nil, + analysis_result: nil, // No analysis = error state + images: [], // Empty images array + latest_guidance: nil, + created_at: getCurrentTimestamp(), + last_activity_at: getCurrentTimestamp(), + is_favorited: nil, + analysis_id: nil + ) + + // Store error scan in cache AND store + scanDataCache[scanId] = errorScan + scanHistoryStore.upsertScan(errorScan) + Log.debug("BARCODE_SCAN", "πŸ’Ύ CameraScreen: Stored error scan in cache and store - scanId: \(scanId)") + + // Remove skeleton if adding error scan + if let skeletonIndex = scanIds.firstIndex(of: skeletonCardId) { + scanIds.remove(at: skeletonIndex) + } + + // Add error scanId to array + if !scanIds.contains(scanId) { + withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) { + scanIds.insert(scanId, at: 0) + scrollTargetScanId = scanId + } + barcodeToScanIdMap[barcode] = scanId + Log.debug("BARCODE_SCAN", "βœ… CameraScreen: Added error scanId to scanIds - scanId: \(scanId)") + } + } else { + // No scanId from error - keep skeleton card if no active scans remain + if scanIds.isEmpty { + scanIds.append(skeletonCardId) + } + } + + updateToastState() + } + } + ) + } catch { + Log.error("BARCODE_SCAN", "❌ CameraScreen: Barcode scan failed - barcode: \(barcode), error: \(error.localizedDescription)") + // Remove placeholder on error + await MainActor.run { + if let placeholderIndex = scanIds.firstIndex(of: placeholderScanId) { + scanIds.remove(at: placeholderIndex) + } + // Keep skeleton card if no active scans remain + if scanIds.isEmpty || (scanIds.count == 1 && scanIds.first == skeletonCardId) { + if !scanIds.contains(skeletonCardId) { + scanIds.append(skeletonCardId) + } + } + pendingBarcodes.remove(barcode) + updateToastState() + } + } + } + + // MARK: - Haptics + private func triggerAnalysisCompletedHaptic(for scanId: String) { + // Ensure we only fire once per scan + guard !completedHapticScanIds.contains(scanId) else { return } + completedHapticScanIds.insert(scanId) + + let generator = UINotificationFeedbackGenerator() + generator.notificationOccurred(.success) + } + + // MARK: - Rating Prompt + private func checkAndPromptForRating() { + if userPreferences.canPromptForRating() { + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + userPreferences.recordRatingPrompt() + ratingPromptPresentedAt = Date() + awaitingRatingOutcome = true + scheduleDismissalFallback() + + let foregroundScene = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first { $0.activationState == .foregroundActive } + if let windowScene = foregroundScene { + SKStoreReviewController.requestReview(in: windowScene) + } + } + } + } + + private func scheduleDismissalFallback() { + dismissalFallbackTask?.cancel() + dismissalFallbackTask = Task { + try? await Task.sleep(nanoseconds: 10 * 1_000_000_000) + await MainActor.run { + guard awaitingRatingOutcome else { return } + guard scenePhase == .active else { return } + handleRatingPromptFinished(recordDismissal: true) + } + } + } + + private func handleRatingPromptFinished(recordDismissal: Bool) { + dismissalFallbackTask?.cancel() + dismissalFallbackTask = nil + defer { + awaitingRatingOutcome = false + ratingPromptPresentedAt = nil + } + if recordDismissal { + userPreferences.recordPromptDismissal() + } + } + + private func handleRatingScenePhaseChange(_ newPhase: ScenePhase) { + guard awaitingRatingOutcome else { return } + switch newPhase { + case .active: + if let start = ratingPromptPresentedAt { + let elapsed = Date().timeIntervalSince(start) + if elapsed < 5.0 { + handleRatingPromptFinished(recordDismissal: true) + } else { + handleRatingPromptFinished(recordDismissal: false) + } + } else { + handleRatingPromptFinished(recordDismissal: true) + } + case .background: + handleRatingPromptFinished(recordDismissal: false) + default: + break + } + } + + // MARK: - Scan History + private func loadScanHistory() async { + Log.debug("SCAN_HISTORY", "πŸ”΅ CameraScreen: Loading scan history from store") + + // If store is currently loading, wait for it to complete + // If store already has data (loaded by HomeView), skip the API call + if scanHistoryStore.isLoading { + Log.debug("SCAN_HISTORY", "⏳ CameraScreen: Store is loading, waiting...") + // Wait for loading to complete (poll with short delay) + while scanHistoryStore.isLoading { + try? await Task.sleep(nanoseconds: 100_000_000) // 100ms + } + Log.debug("SCAN_HISTORY", "βœ… CameraScreen: Store finished loading") + } else if scanHistoryStore.scans.isEmpty { + // Only load from API if store has no data + await scanHistoryStore.loadHistory(limit: 20, offset: 0) + } else { + Log.debug("SCAN_HISTORY", "πŸ“¦ CameraScreen: Using existing store data (\(scanHistoryStore.scans.count) scans)") + } + + await syncHistoryFromStore() + } + + /// Syncs local state with data from ScanHistoryStore + private func syncHistoryFromStore() async { + await MainActor.run { + // Sync store data to local cache for immediate access + for scan in scanHistoryStore.scans { + scanDataCache[scan.id] = scan + } + + // Use store's barcode mapping + barcodeToScanIdMap = scanHistoryStore.barcodeToScanIdMap + + // Extract scan IDs from store history (excluding any that are already in active scans) + let activeScanIdsSet = Set(scanIds.filter { !$0.hasPrefix("pending_") && $0 != skeletonCardId }) + let historyIds = scanHistoryStore.scans + .map { $0.id } + .filter { !activeScanIdsSet.contains($0) } // Don't duplicate active scans + + historyScanIds = historyIds + Log.debug("SCAN_HISTORY", "βœ… CameraScreen: Synced \(historyIds.count) history scan IDs from store") + } + } + + /// Handles initial scroll to scanId when opened from ProductDetailView + /// Does NOT move the scanId from its original position - just scrolls to it + private func handleInitialScrollToScanId(_ targetId: String) async { + await MainActor.run { + Log.debug("SCAN_SCROLL", "πŸ”΅ CameraScreen: Handling initial scroll to scanId: \(targetId)") + + // Update scanId state to match target (for photo capture association) + scanId = targetId + + // Wait for the carousel items to update and ensure targetId is in allCarouselItems + // Use a polling approach to wait for the item to appear in the carousel + Task { @MainActor in + var attempts = 0 + let maxAttempts = 20 // Wait up to 2 seconds (20 * 0.1s) + + while attempts < maxAttempts { + // Check if targetId is in allCarouselItems + if allCarouselItems.contains(targetId) { + Log.debug("SCAN_SCROLL", "βœ… CameraScreen: Target scanId found in carousel items, scrolling...") + + // Wait for layout to settle before scrolling + try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds + + // Clear scrollTargetScanId first to ensure onChange fires + scrollTargetScanId = nil + + // Wait a frame to ensure the clear is processed + try? await Task.sleep(nanoseconds: 50_000_000) // ~3 frames at 60fps + + // Now set it to trigger the scroll + scrollTargetScanId = targetId + Log.debug("SCAN_SCROLL", "βœ… CameraScreen: Set scrollTargetScanId to: \(targetId)") + return + } + + // Wait a bit before checking again + try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds + attempts += 1 + } + + // Fallback: set it anyway after max attempts + Log.warning("SCAN_SCROLL", "⚠️ CameraScreen: Target scanId not found in carousel after \(maxAttempts) attempts, setting scroll target anyway") + scrollTargetScanId = nil + try? await Task.sleep(nanoseconds: 16_666_666) + scrollTargetScanId = targetId + } + } + } + + + // Computed property to combine active scans and history + private var allCarouselItems: [String] { + var items: [String] = [] + + // Active scans (including pending placeholders to show "Fetching details" state) + let activeScans = scanIds + + // Include skeleton cards from scanIds if they exist, regardless of active scans + let skeletonCards = activeScans.filter { $0 == skeletonCardId } + let pendingScans = activeScans.filter { $0.hasPrefix("pending_") } + let nonSkeletonNonPendingScans = activeScans.filter { $0 != skeletonCardId && !$0.hasPrefix("pending_") } + + // Add skeleton cards first if present + items.append(contentsOf: skeletonCards) + + // Add pending scans (showing "Fetching details" state) + items.append(contentsOf: pendingScans) + + // Add completed active scans (newest first) + items.append(contentsOf: nonSkeletonNonPendingScans) + + // Append history cards (excluding any that are in active scans) + let activeScanIdsSet = Set(activeScans) + let filteredHistory = historyScanIds.filter { !activeScanIdsSet.contains($0) } + items.append(contentsOf: filteredHistory) + + return items + } + + // MARK: - New Product Session (Photo Mode) + private func addNewProductScanSession() { + Log.debug("PHOTO_SCAN", "βž• CameraScreen: Adding new product scan session") + + // Clear capturedImagesPerScanId for old scanId if it exists + if let oldScanId = scanId { + capturedImagesPerScanId[oldScanId] = nil + Log.debug("PHOTO_SCAN", "πŸ—‘οΈ CameraScreen: Cleared capturedImagesPerScanId for old scanId: \(oldScanId)") + } + + // Reset scanId for new product session + scanId = nil + + // Clear captured photo history for new session + capturedPhotoHistory = [] + + // Remove existing skeleton if it exists + if let existingSkeletonIndex = scanIds.firstIndex(of: skeletonCardId) { + scanIds.remove(at: existingSkeletonIndex) + } + + // Clear scroll target first so carousel's onChange always fires + scrollTargetScanId = nil + + // Add new skeleton card at the beginning (will be replaced when first image is captured) + withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) { + scanIds.insert(skeletonCardId, at: 0) + } + + // Set scroll target on next runloop tick to ensure items are updated + DispatchQueue.main.async { + scrollTargetScanId = skeletonCardId + } + + Log.debug("PHOTO_SCAN", "βœ… CameraScreen: New product session started - skeleton card added") + } + + // MARK: - Photo Image Submission + private func submitImage(image: UIImage, scanId: String, imageIndex: Int) async { + Log.debug("PHOTO_SCAN", "πŸ”΅ CameraScreen: submitImage() called - scanId: \(scanId), imageIndex: \(imageIndex)") + + guard let imageData = image.jpegData(compressionQuality: 0.8) else { + Log.error("PHOTO_SCAN", "❌ CameraScreen: Failed to convert image to JPEG data - image_index: \(imageIndex)") + return + } + + let imageSizeKB = imageData.count / 1024 + Log.debug("PHOTO_SCAN", "πŸ“€ CameraScreen: Submitting image - scan_id: \(scanId), image_index: \(imageIndex), image_size: \(imageSizeKB)KB") + do { + let response = try await webService.submitScanImage(scanId: scanId, imageData: imageData) + Log.debug("PHOTO_SCAN", "βœ… CameraScreen: Image submitted successfully - scan_id: \(scanId), image_index: \(imageIndex), queued: \(response.queued), queue_position: \(response.queue_position)") + + // Remove from submitting set after successful submission + // This allows ScanDataCard to start polling + await MainActor.run { + submittingScanIds.remove(scanId) + Log.debug("PHOTO_SCAN", "βœ… CameraScreen: Removed scanId from submittingScanIds - scanId: \(scanId)") + } + } catch { + Log.error("PHOTO_SCAN", "❌ CameraScreen: Failed to submit image - scan_id: \(scanId), image_index: \(imageIndex), error: \(error.localizedDescription)") + + // Remove from submitting set on error too + await MainActor.run { + submittingScanIds.remove(scanId) + } + } + } + + // MARK: - Photo Processing + /// Processes a photo image (from camera or gallery) through the complete flow: + /// scanId determination, hash calculation, storage, UI updates, and API submission + private func processPhoto(image: UIImage) async { + Log.debug("PHOTO_SCAN", "πŸ“Έ CameraScreen: processPhoto() called") + + // Determine which scanId to use based on centered card + let (scanIdToUse, isUsingCenteredCard) = await MainActor.run { () -> (String, Bool) in + // If there's a centered card that's not skeleton/pending/empty, use that + if let centeredId = currentCenteredScanId, + !centeredId.isEmpty, + centeredId != skeletonCardId, + !centeredId.hasPrefix("pending_") { + scanId = centeredId // Update state to match + Log.debug("PHOTO_SCAN", "🎯 CameraScreen: Using centered card's scanId - scanId: \(centeredId)") + return (centeredId, true) + } else { + // Generate new scanId or reuse existing one for new scan + if scanId == nil { + scanId = UUID().uuidString + Log.debug("PHOTO_SCAN", "πŸ†” CameraScreen: Generated new scan_id: \(scanId!)") + } else { + Log.debug("PHOTO_SCAN", "πŸ†” CameraScreen: Using existing scan_id: \(scanId!)") + } + return (scanId!, false) + } + } + + // Calculate image hash + let imageHash = calculateImageHash(image: image) + Log.debug("PHOTO_SCAN", "πŸ” CameraScreen: Calculated image hash - hash: \(imageHash)") + + // Calculate image index and store image + let imageIndex = await MainActor.run { () -> Int in + // Calculate image index BEFORE appending (0-based index) + let imageIndex = capturedImagesPerScanId[scanIdToUse]?.count ?? 0 + Log.debug("PHOTO_SCAN", "πŸ“Έ CameraScreen: Photo processed - imageIndex: \(imageIndex)") + + // Store image and hash in capturedImagesPerScanId + if capturedImagesPerScanId[scanIdToUse] == nil { + capturedImagesPerScanId[scanIdToUse] = [] + } + capturedImagesPerScanId[scanIdToUse]?.append((image: image, hash: imageHash)) + Log.debug("PHOTO_SCAN", "πŸ’Ύ CameraScreen: Stored image in capturedImagesPerScanId - scanId: \(scanIdToUse), imageIndex: \(imageIndex), totalImages: \(capturedImagesPerScanId[scanIdToUse]?.count ?? 0)") + + // Add to capturedPhotoHistory (limit to 10) + capturedPhoto = image + capturedPhotoHistory.insert(image, at: 0) + if capturedPhotoHistory.count > 10 { + capturedPhotoHistory.removeLast(capturedPhotoHistory.count - 10) + } + + // Add scanId to scanIds immediately (for first photo of this product) + // Subsequent photos for same scanId will just update the localImages + let isInActiveScanIds = scanIds.contains(scanIdToUse) + let isInHistoryScanIds = historyScanIds.contains(scanIdToUse) + let isFirstPhotoForThisScan = !isInActiveScanIds && !isInHistoryScanIds + + if isFirstPhotoForThisScan { + // Truly new scan - add to carousel + withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) { + if let skeletonIndex = scanIds.firstIndex(of: skeletonCardId) { + // Replace skeleton with new scanId + scanIds[skeletonIndex] = scanIdToUse + // Only scroll if not using centered card (skeleton replacement always scrolls) + if !isUsingCenteredCard { + scrollTargetScanId = scanIdToUse + } + } else { + // Insert at beginning (before history) + scanIds.insert(scanIdToUse, at: 0) + // Only scroll if not using centered card + if !isUsingCenteredCard { + scrollTargetScanId = scanIdToUse + } + } + } + + // Remove from history if it's there + if let historyIndex = historyScanIds.firstIndex(of: scanIdToUse) { + historyScanIds.remove(at: historyIndex) + } + + if isUsingCenteredCard { + Log.debug("PHOTO_SCAN", "βœ… CameraScreen: Added centered card to active scans (no scroll) - scanId: \(scanIdToUse)") + } else { + Log.debug("PHOTO_SCAN", "βœ… CameraScreen: Added scanId to scanIds immediately (first photo) - scanId: \(scanIdToUse)") + } + } else if isInHistoryScanIds { + // Adding photo to existing history scan - keep it in place + Log.debug("PHOTO_SCAN", "πŸ”„ CameraScreen: Adding photo to history scan (keeping position) - scanId: \(scanIdToUse)") + } else { + Log.debug("PHOTO_SCAN", "πŸ”„ CameraScreen: Reusing existing active scanId (subsequent photo) - scanId: \(scanIdToUse)") + } + + // Mark as submitting for EVERY photo (not just first) + // This ensures we re-poll after each new image is submitted + // because each new photo may reveal additional product information + submittingScanIds.insert(scanIdToUse) + Log.debug("PHOTO_SCAN", "πŸ“ CameraScreen: Marked scanId as submitting - scanId: \(scanIdToUse), imageIndex: \(imageIndex)") + + return imageIndex + } + + // Submit image to scan API + // After 200 response, scanId will be removed from submittingScanIds + // This triggers task(id: isSubmitting) in ScanDataCard to re-fetch and re-poll + Log.debug("PHOTO_SCAN", "πŸš€ CameraScreen: Starting Task to submit image - scanId: \(scanIdToUse), imageIndex: \(imageIndex)") + await submitImage(image: image, scanId: scanIdToUse, imageIndex: imageIndex) + } + + var body: some View { + ZStack { +#if targetEnvironment(simulator) + Color(.systemGray5) + .ignoresSafeArea() + .overlay( + VStack(spacing: 8) { + Image(systemName: "camera") + .font(.system(size: 44, weight: .medium)) + .foregroundColor(.secondary) + Text("Camera not available in Preview/Simulator") + .font(.callout) + .foregroundColor(.secondary) + } + ) +#endif + BarcodeCameraPreview(cameraManager: camera) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .ignoresSafeArea() + .onAppear { + let status = AVCaptureDevice.authorizationStatus(for: .video) + cameraStatus = status + switch status { + case .authorized: + camera.startSession() + case .notDetermined: + requestCameraAccess { granted in + cameraStatus = granted ? .authorized : .denied + if granted { camera.startSession() } + } + case .denied, .restricted: + break + @unknown default: + break + } + camera.scanningEnabled = (mode == .scanner) + isCaptured = UIScreen.main.isCaptured + updateToastState() + + // Initialize with skeleton card if empty + if scanIds.isEmpty { + scanIds = [skeletonCardId] + } + + // Fetch scan history on appear + Task { + await loadScanHistory() + + // If opened with initial scroll target (from ProductDetailView or push navigation), handle scrolling + // This handles the case when view is opened to add more photos to an existing scan + if presentationSource == .productDetailView || presentationSource == .pushNavigation { + // Check if we have an initial scroll target from the initializer + // Store it before it might get cleared + let initialTarget = scrollTargetScanId + if let target = initialTarget, !target.isEmpty, target != skeletonCardId { + Log.debug("SCAN_SCROLL", "πŸ”΅ CameraScreen: onAppear - Found initial scroll target: \(target)") + await handleInitialScrollToScanId(target) + } + } + } + } + .onDisappear { camera.stopSession() } + .onChange(of: scenePhase) { newPhase in + if newPhase == .active { + if cameraStatus == .authorized { camera.startSession() } + } else if newPhase == .background { + camera.stopSession() + } + handleRatingScenePhaseChange(newPhase) + } + .onChange(of: mode) { newMode in + camera.scanningEnabled = (newMode == .scanner) + if newMode == .photo { + let key = "hasShownPhotoModeGuide" + let hasShown = UserDefaults.standard.bool(forKey: key) + if !hasShown { + isShowingPhotoModeGuide = true + UserDefaults.standard.set(true, forKey: key) + } + } + + // Only reset scan state when: + // 1. Opened from HomeView (presentationSource == .homeView), OR + // 2. Manual toggle (isProgrammaticModeChange == false) + // Do NOT reset when: + // - Programmatically returning from ProductDetailView + // - Opened with initial scroll target (adding photos to existing scan) + let hasInitialScrollTarget = scrollTargetScanId != nil && scrollTargetScanId != skeletonCardId + let shouldReset = (presentationSource == .homeView || !isProgrammaticModeChange) && !hasInitialScrollTarget + + if shouldReset { + // When toggling between scanner/photo from HomeView or manual toggle, start a fresh session + scanId = nil + pendingBarcodes.removeAll() + // Clear target first so ScanCardsCarousel's onChange(of:scrollTargetId) always fires + scrollTargetScanId = nil + withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) { + scanIds = [skeletonCardId] + } + // Set scroll target on next runloop tick to ensure items are updated + DispatchQueue.main.async { + scrollTargetScanId = skeletonCardId + } + } else if isProgrammaticModeChange, let targetId = targetScanIdFromProductDetail { + // Returning from ProductDetailView - preserve state and scroll to target + // Use the helper function to handle scrolling + Task { + await handleInitialScrollToScanId(targetId) + } + } + + // Reset flags after handling + isProgrammaticModeChange = false + targetScanIdFromProductDetail = nil + + updateToastState() + + // Reload history when mode changes so history cards appear to the right + Task { + await loadScanHistory() + } + } + .onChange(of: camera.isSessionRunning) { running in + if running { + camera.updateRectOfInterest(overlayRect: overlayRect, containerSize: overlayContainerSize) + } + } + .onReceive(camera.$scannedBarcode.compactMap { $0 }) { code in + // Check if this barcode was already scanned in the CURRENT SESSION (in scanIds) + // Only scroll to existing card if it's in the current session, not from history + if let existingScanId = barcodeToScanIdMap[code], scanIds.contains(existingScanId) { + // Barcode already scanned in this session - scroll to existing card + Log.debug("BARCODE_SCAN", "πŸ”„ CameraScreen: Barcode already scanned in this session - scrolling to existing card - barcode: \(code), scanId: \(existingScanId)") + withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) { + scrollTargetScanId = existingScanId + } + updateToastState() + return + } + + // Check if this barcode is already being scanned (pending) + let placeholderScanId = "pending_\(code)" + if pendingBarcodes.contains(code) { + Log.debug("BARCODE_SCAN", "⏳ CameraScreen: Barcode already being scanned - barcode: \(code)") + // Scroll to the pending card if it exists + if scanIds.contains(placeholderScanId) { + withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) { + scrollTargetScanId = placeholderScanId + } + } + return + } + + // Track barcode for detection + withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) { + if !codes.contains(code) { + codes.insert(code, at: 0) + } + } + + // Mark barcode as pending and immediately show skeleton card + pendingBarcodes.insert(code) + if !scanIds.contains(placeholderScanId) { + withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) { + // Remove skeleton card if it's the first scan + if let skeletonIndex = scanIds.firstIndex(of: skeletonCardId) { + scanIds.remove(at: skeletonIndex) + } + + scanIds.insert(placeholderScanId, at: 0) + scrollTargetScanId = placeholderScanId + } + Log.debug("BARCODE_SCAN", "πŸ“‹ CameraScreen: Added placeholder card for barcode - barcode: \(code), placeholderScanId: \(placeholderScanId)") + } + + // Start barcode scan and get scanId (will replace placeholder when received) + Task { @MainActor in + await startBarcodeScan(barcode: code) + } + + updateToastState() + } + .onReceive(NotificationCenter.default.publisher(for: UIScreen.capturedDidChangeNotification)) { _ in + isCaptured = UIScreen.main.isCaptured + } + .onChange(of: isProductDetailPresented) { presented in + // When the Product Detail sheet is shown, pause the camera + // session to reduce memory pressure. Resume when the sheet + // is dismissed. + if presented { + camera.stopSession() + } else if cameraStatus == .authorized && scenePhase == .active { + camera.startSession() + } + } + .onAppear { + // Check if we need to scroll to a specific card (coming back from ProductDetail) + if let targetId = appState?.scrollToScanId, !targetId.isEmpty { + Log.debug("SCAN_SCROLL", "πŸ”΅ CameraScreen: onAppear - scrollToScanId from AppState: \(targetId)") + scrollTargetScanId = targetId + // Clear the scroll target after using it + appState?.scrollToScanId = nil + } + } + + if mode == .scanner { + BarcodeScannerOverlay(onRectChange: { rect, size in + overlayRect = rect + overlayContainerSize = size + camera.updateRectOfInterest(overlayRect: rect, containerSize: size) + }) + .environmentObject(camera) + } else { + // Photo mode overlay: capture guide frame + GeometryReader { geo in + let centerX = geo.size.width / 2 + let guideTop: CGFloat = 126 + let guideSize: CGFloat = 244 + let guideCenterY = guideTop + guideSize / 2 + + // Capture guide frame + Image("imagecaptureUI") + .resizable() + .frame(width: guideSize, height: guideSize) + .position(x: centerX, y: guideCenterY) + } + } + + VStack { + ScanStatusToast(state: toastState) + .padding(.top, 40) // Account for navigation bar space + .onAppear { + updateToastState() + } + Spacer() + if mode == .photo { + HStack { + FlashToggleButton( + isScannerMode: false, + onTogglePhotoFlash: { enabled in + photoFlashEnabled = enabled + } + ) + + Spacer() + + // MARK: - Image Capturing Button + // Center: Capture photo button - captures a photo from the camera and adds it to the photo history + Button(action: { + Log.debug("PHOTO_SCAN", "πŸ”΅ CameraScreen: capturePhoto button tapped") + camera.capturePhoto(useFlash: photoFlashEnabled) { image in + if let image = image { + Log.debug("PHOTO_SCAN", "πŸ“Έ CameraScreen: Camera callback received - hasImage: true") + + // Process photo through the same flow as gallery selection + Task { + await processPhoto(image: image) + } + } else { + Log.error("PHOTO_SCAN", "❌ CameraScreen: Camera callback returned nil image") + } + } + }) { + ZStack { + Circle() + .fill(Color.white.opacity(0.9)) + .frame(width: 50, height: 50) + Circle() + .stroke(Color.white.opacity(0.4), lineWidth: 3) + .frame(width: 63, height: 63) + } + } + + Spacer() + + // Right: Gallery button + Button(action: { + isShowingPhotoPicker = true + }) { + ZStack { + Circle() + .fill(.thinMaterial.opacity(0.4)) + .frame(width: 48, height: 48) + Image("gallary1") + .resizable() + .frame(width: 24.27, height: 21.19) + .padding(.top ,4) + } + } + } + .padding(.horizontal, 32) + .padding(.top, 16) + .padding(.bottom ,16) + } + CameraSwipeButton(mode: $mode, showRetryCallout: $showRetryCallout) + .padding(.bottom ,20) + } + .zIndex(2) + + // Unified carousel for both scanner and photo modes + // Structure: [skeleton/active scans] [history scans] + VStack { + Spacer() + + ZStack(alignment: .leading) { + // Carousel cards + ScanCardsCarousel( + items: allCarouselItems, + cardContent: { itemId in + // Use ScanDataCard for all cases (skeleton, pending, and actual scans) + // ScanDataCard will detect skeleton mode based on scanId + let localImagesArray = capturedImagesPerScanId[itemId] + let isSubmitting = submittingScanIds.contains(itemId) + ScanDataCard( + scanId: itemId, + initialScan: scanDataCache[itemId], // nil for skeleton/pending/photo scans + isSubmitting: isSubmitting, // Track if image is currently being submitted + localImages: localImagesArray, // Pass locally captured images + cameraModeType: mode == .photo ? "photo" : "barcode", // Pass current camera mode for skeleton/pending states + onRetryShown: { + showRetryCallout = true + }, + onRetryHidden: { + showRetryCallout = false + }, + onResultUpdated: { + // When card updates, refresh toast state + updateToastState() + }, + onScanUpdated: { updatedScan in + // Update cache when scan data changes (e.g., from polling) + // This enables toast to reflect latest_guidance changes + scanDataCache[itemId] = updatedScan + + // Trigger haptic feedback and rating prompt once when analysis is completed + if updatedScan.state == "done", updatedScan.analysis_result != nil { + triggerAnalysisCompletedHaptic(for: updatedScan.id) + checkAndPromptForRating() + } + + updateToastState() + }, + onFavoriteToggle: { scanId, isFavorited in + // Toggle favorite status via API + Task { + do { + // Use new toggleFavorite API which returns actual state + let newFavoriteState = try await webService.toggleFavorite(scanId: scanId) + Log.debug("FAVORITE", "βœ… Toggled favorite - scanId: \(scanId), is_favorited: \(newFavoriteState)") + + // Update cache with new favorite status from API response + if let cachedScan = scanDataCache[scanId] { + // Create updated scan with new favorite status + let updatedScan = DTO.Scan( + id: cachedScan.id, + scan_type: cachedScan.scan_type, + barcode: cachedScan.barcode, + state: cachedScan.state, + product_info: cachedScan.product_info, + product_info_source: cachedScan.product_info_source, + analysis_result: cachedScan.analysis_result, + images: cachedScan.images, + latest_guidance: cachedScan.latest_guidance, + created_at: cachedScan.created_at, + last_activity_at: cachedScan.last_activity_at, + is_favorited: newFavoriteState, + analysis_id: cachedScan.analysis_id + ) + + // Update cache AND store + await MainActor.run { + scanDataCache[scanId] = updatedScan + scanHistoryStore.updateFavoriteStatus(scanId: scanId, isFavorited: newFavoriteState) + } + } + } catch { + Log.error("FAVORITE", "❌ Failed to toggle favorite - scanId: \(scanId), error: \(error.localizedDescription)") + } + } + }, + onTap: { product, matchStatus, ingredientRecommendations, overallAnalysis, tappedScanId in + selectedProduct = product + selectedMatchStatus = matchStatus + selectedIngredientRecommendations = ingredientRecommendations + selectedOverallAnalysis = overallAnalysis + selectedScanId = tappedScanId + + // Use push navigation ONLY when opened via AppRoute (pushNavigation) + // Fall back to fullScreenCover when in sheet/cover context + if presentationSource == .pushNavigation, let appState = appState { + let initialScan = scanDataCache[tappedScanId] + appState.navigate(to: .productDetail(scanId: tappedScanId, initialScan: initialScan)) + } else { + isProductDetailPresented = true + } + } + ) + }, + scrollTargetId: scrollTargetScanId, + onCardCenterChanged: { nearestScanId in + currentCenteredScanId = nearestScanId + updateToastState() + + // Check for pagination trigger + if let nearestScanId, + let index = allCarouselItems.firstIndex(of: nearestScanId), + index >= allCarouselItems.count - 3 { + Task { + if scanHistoryStore.hasMore && !scanHistoryStore.isLoading { + Log.debug("SCAN_HISTORY", "πŸ”„ CameraScreen: Reached end of carousel, loading more history...") + await scanHistoryStore.loadMore() + await syncHistoryFromStore() + } + } + } + }, + cardCenterData: $cardCenterData + ) + + // Add New Product button (photo mode only) - left side, center-aligned with carousel cards + // Hide when skeleton card is already present (no need to add another) + if mode == .photo && !scanIds.contains(skeletonCardId) { + VStack { + Spacer() + HStack(spacing: 0) { + Button(action: { + addNewProductScanSession() + }) { + ZStack { + // Background with blur effect + // #E8E8E833 = rgba(232, 232, 232, 0.2) + Rectangle() + .fill(Color(hex: "#E8E8E8").opacity(0.2)) + .background(.ultraThinMaterial) + .frame(width: 27, height: 120) + .clipShape(RoundedCorner(radius: 30, corners: [.topRight, .bottomRight])) + + // Icon + Image("addNewProductCaptures") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + } + } + .buttonStyle(.plain) + Spacer() + } + .frame(height: 120) + Spacer() + } + .zIndex(10) // Ensure button is in front of carousel cards + } + } + } + .padding(.top, 203) + + if cameraStatus == .denied || cameraStatus == .restricted { + VStack(spacing: 12) { + Text("Camera access is required") + .font(.headline) + Button("Open Settings") { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } + } + .padding() + .background(.ultraThinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + + if isShowingPhotoModeGuide { + ZStack { + Color.black.opacity(0.35) + .ignoresSafeArea() + .onTapGesture { + withAnimation(.easeInOut) { + isShowingPhotoModeGuide = false + } + } + + VStack { + Spacer() + + CaptureYourProductSheet { + withAnimation(.easeInOut) { + isShowingPhotoModeGuide = false + } + } + } + } + .transition(.move(edge: .bottom).combined(with: .opacity)) + .animation(.easeInOut, value: isShowingPhotoModeGuide) + .zIndex(3) + .ignoresSafeArea(edges: .bottom) + } + } + .sheet(isPresented: $isShowingPhotoPicker) { + PhotoPicker(images: $capturedPhotoHistory, + didHitLimit: $galleryLimitHit, + maxTotalCount: 10, + onImageSelected: { image in + await processPhoto(image: image) + }) + } + .navigationBarBackButtonHidden(true) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button { + dismiss() + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 17, weight: .semibold)) + .foregroundColor(.white) + } + } + ToolbarItem(placement: .topBarTrailing) { + if mode == .scanner { + FlashToggleButton(isScannerMode: true) + } + } + } + .toolbarBackground(.hidden, for: .navigationBar) + .navigationBarTitleDisplayMode(.inline) + .onAppear { + // Track that camera is in navigation stack (for ProductDetail "Add Photo" navigation) + appState?.hasCameraInStack = true + // Track that camera is currently visible (for AIBot FAB visibility) + appState?.isInScanCameraView = true + } + .onDisappear { + // Camera is being removed from navigation stack + appState?.hasCameraInStack = false + // Camera is no longer visible + appState?.isInScanCameraView = false + } + } +} + +// MARK: - ScanCameraView with Initial State + +/// Wrapper for ScanCameraView that accepts initial mode and scanId +struct ScanCameraViewWithInitialState: View { + let initialScanId: String? + let initialMode: CameraMode + let presentationSource: CameraPresentationSource + + var body: some View { + ScanCameraViewInternal( + initialScanId: initialScanId, + initialMode: initialMode, + presentationSource: presentationSource + ) + } +} + +/// Internal view that accepts initial state parameters +private struct ScanCameraViewInternal: View { + let initialScanId: String? + let initialMode: CameraMode + let presentationSource: CameraPresentationSource + + var body: some View { + ScanCameraView( + initialMode: initialMode, + initialScrollTarget: initialScanId, + presentationSource: presentationSource + ) + } +} + +extension ScanCameraView { + + // MARK: - Initializer with initial state + + init(initialMode: CameraMode? = nil, initialScrollTarget: String? = nil, presentationSource: CameraPresentationSource = .homeView) { + // Set initial mode if provided + if let initialMode = initialMode { + self._mode = State(initialValue: initialMode) + } + + // Set initial scroll target if provided + if let initialScrollTarget = initialScrollTarget { + self._scrollTargetScanId = State(initialValue: initialScrollTarget) + } + + // Set presentation source + self._presentationSource = State(initialValue: presentationSource) + } + + + // MARK: - Photo Picker for gallery selection + + struct PhotoPicker: UIViewControllerRepresentable { + + @Environment(\.presentationMode) var presentationMode + @Binding var images: [UIImage] + @Binding var didHitLimit: Bool + var maxTotalCount: Int = 10 + var onImageSelected: ((UIImage) async -> Void)? = nil + + func makeUIViewController(context: Context) -> PHPickerViewController { + var configuration = PHPickerConfiguration() + configuration.filter = .images + configuration.selectionLimit = maxTotalCount + + let picker = PHPickerViewController(configuration: configuration) + picker.delegate = context.coordinator + return picker + } + + func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) { + // no-op + } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, PHPickerViewControllerDelegate { + let parent: PhotoPicker + + init(_ parent: PhotoPicker) { + self.parent = parent + } + + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + parent.presentationMode.wrappedValue.dismiss() + + guard !results.isEmpty else { return } + + // Process images sequentially to maintain scanId consistency + Task { + for result in results { + let provider = result.itemProvider + guard provider.canLoadObject(ofClass: UIImage.self) else { continue } + + // Load image asynchronously + if let uiImage = try? await withCheckedThrowingContinuation({ (continuation: CheckedContinuation) in + provider.loadObject(ofClass: UIImage.self) { object, error in + if let error = error { + continuation.resume(throwing: error) + } else if let uiImage = object as? UIImage { + continuation.resume(returning: uiImage) + } else { + continuation.resume(throwing: NSError(domain: "PhotoPicker", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to load image"])) + } + } + }) { + // Process image through the same flow as captured photos + if let processImage = parent.onImageSelected { + await processImage(uiImage) + } else { + // Fallback: add to history if no processor provided + await MainActor.run { + if self.parent.images.count < self.parent.maxTotalCount { + self.parent.images.insert(uiImage, at: 0) + } else { + self.parent.didHitLimit = true + } + } + } + } + } + } + } + } + + // MARK: - Photo card matching ContentView4 style + + struct PhotoContentView4: View { + let image: UIImage + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 20) + .fill(.thinMaterial.opacity(0.2)) + .frame(width: 300, height: 120) + + HStack { + HStack(spacing: 47) { + ZStack { + RoundedRectangle(cornerRadius: 16) + .fill(.thinMaterial.opacity(0.4)) + .frame(width: 68, height: 92) + + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 64, height: 88) + .clipShape(RoundedRectangle(cornerRadius: 14)) + } + } + + VStack(alignment: .leading, spacing: 8) { + RoundedRectangle(cornerRadius: 4) + .fill(.thinMaterial.opacity(0.4)) + .frame(width: 185, height: 25) + .opacity(0.3) + + RoundedRectangle(cornerRadius: 4) + .fill(.thinMaterial.opacity(0.4)) + .frame(width: 132, height: 20) + .padding(.bottom, 7) + + RoundedRectangle(cornerRadius: 52) + .fill(.thinMaterial.opacity(0.4)) + .frame(width: 79, height: 24) + } + } + } + } + } + } +} + diff --git a/IngrediCheck/Views/BarcodeScan/Models/DietaryPreferenceConfig.swift b/IngrediCheck/Views/BarcodeScan/Models/DietaryPreferenceConfig.swift new file mode 100644 index 00000000..3f409766 --- /dev/null +++ b/IngrediCheck/Views/BarcodeScan/Models/DietaryPreferenceConfig.swift @@ -0,0 +1,5 @@ +struct DietaryPreferenceConfig { + // Update this string to manually control the dietary preference text + // used during analysis in the new barcode flow. + static var defaultText: String = "no Sugar , no milk , no gluten , no High-fructose corn syrup,Cane sugar,Maltodextrin,Dextrose, fructose, glucoseAspartame ,Sucralose ,Acesulfame-" +} diff --git a/IngrediCheck/Views/BarcodeScan/Models/ScanModels.swift b/IngrediCheck/Views/BarcodeScan/Models/ScanModels.swift new file mode 100644 index 00000000..d7d311be --- /dev/null +++ b/IngrediCheck/Views/BarcodeScan/Models/ScanModels.swift @@ -0,0 +1,21 @@ +import SwiftUI + +/// Represents the current scanning mode (barcode scanner or photo capture) +enum CameraMode { + case scanner + case photo +} + +/// Represents different toast message states during scanning +enum ToastScanState: Equatable { + case scanning // user is scanning / live camera + case extractionSuccess // barcode extracted successfully + case notIdentified // product could not be identified + case analyzing // product detected, reading ingredients + case match // product matches preferences + case notMatch // product does not match preferences + case uncertain // some ingredients are unclear + case retry // retry / retake photo + case photoGuide // camera/photo mode guidance + case dynamicGuidance(String) // dynamic guidance from API (photo mode) +} diff --git a/IngrediCheck/Views/BarcodeScan/Services/BarcodeScanAnalysisService.swift b/IngrediCheck/Views/BarcodeScan/Services/BarcodeScanAnalysisService.swift new file mode 100644 index 00000000..58241744 --- /dev/null +++ b/IngrediCheck/Views/BarcodeScan/Services/BarcodeScanAnalysisService.swift @@ -0,0 +1,33 @@ +import Foundation + +@MainActor +struct BarcodeScanAnalysisResult { + let product: DTO.Product? + let ingredientRecommendations: [DTO.IngredientRecommendation]? + let matchStatus: DTO.ProductRecommendation? + let notFound: Bool + let errorMessage: String? + let barcode: String + let clientActivityId: String +} + +@MainActor +final class BarcodeScanAnalysisService { + + // Simple in-memory cache so we don't re-hit the network when the user + // swipes back to a barcode card that's already been analyzed. + private static var resultCache: [String: BarcodeScanAnalysisResult] = [:] + + static func cachedResult(for barcode: String) -> BarcodeScanAnalysisResult? { + resultCache[barcode] + } + + static func storeResult(_ result: BarcodeScanAnalysisResult) { + resultCache[result.barcode] = result + } + + static func clearResult(for barcode: String) { + resultCache.removeValue(forKey: barcode) + } +} + diff --git a/IngrediCheck/Views/BarcodeScan/Services/CameraManager.swift b/IngrediCheck/Views/BarcodeScan/Services/CameraManager.swift new file mode 100644 index 00000000..46a72bd3 --- /dev/null +++ b/IngrediCheck/Views/BarcodeScan/Services/CameraManager.swift @@ -0,0 +1,341 @@ +import AVFoundation +import UIKit +import Combine +import os + + +class BarcodeCameraManager: NSObject, ObservableObject, AVCaptureMetadataOutputObjectsDelegate, AVCapturePhotoCaptureDelegate { + let session = AVCaptureSession() + private let sessionQueue = DispatchQueue(label: "camera.queue", qos: .userInitiated) + + @Published var previewLayer: AVCaptureVideoPreviewLayer? + @Published var scannedBarcode: String? + @Published var isSessionRunning: Bool = false + @Published var debugInfo: String = "" + @Published var scanningEnabled: Bool = true + var onBarcodeScanned: ((String) -> Void)? + + // Keep pipeline minimal for faster startup + private let metadataOutput = AVCaptureMetadataOutput() + private let photoOutput = AVCapturePhotoOutput() + private var outputsConfigured = false + private var lastEmittedBarcode: String? + private var lastHapticAt: Date? + private var photoCaptureCompletion: ((UIImage?) -> Void)? + + + override init() { + super.init() + sessionQueue.async { [weak self] in + self?.configureSession() + } + NotificationCenter.default.addObserver(self, + selector: #selector(handleRuntimeError(_:)), + name: .AVCaptureSessionRuntimeError, + object: session) + NotificationCenter.default.addObserver(self, + selector: #selector(handleSessionDidStartRunning(_:)), + name: .AVCaptureSessionDidStartRunning, + object: session) + NotificationCenter.default.addObserver(self, + selector: #selector(handleSessionDidStopRunning(_:)), + name: .AVCaptureSessionDidStopRunning, + object: session) + NotificationCenter.default.addObserver(self, + selector: #selector(handleSessionWasInterrupted(_:)), + name: .AVCaptureSessionWasInterrupted, + object: session) + NotificationCenter.default.addObserver(self, + selector: #selector(handleSessionInterruptionEnded(_:)), + name: .AVCaptureSessionInterruptionEnded, + object: session) + } + + // MARK: - AVCapturePhotoCaptureDelegate + func photoOutput(_ output: AVCapturePhotoOutput, + didFinishProcessingPhoto photo: AVCapturePhoto, + error: Error?) { + let completion = self.photoCaptureCompletion + self.photoCaptureCompletion = nil + guard error == nil else { + Log.debug("BarcodeCameraManager", "photoOutput error: \(error!)") + DispatchQueue.main.async { completion?(nil) } + return + } + guard let data = photo.fileDataRepresentation(), + let image = UIImage(data: data) else { + DispatchQueue.main.async { completion?(nil) } + return + } + DispatchQueue.main.async { completion?(image) } + } + private func selectBackCamera() -> AVCaptureDevice? { + // Use the most compatible and fastest default + AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) + } + + private func configureSession() { + session.beginConfiguration() + // Always balance begin/commit, even if we early-return. + defer { + session.commitConfiguration() + } + session.sessionPreset = .medium + guard let device = selectBackCamera(), + let input = try? AVCaptureDeviceInput(device: device), + session.canAddInput(input) else { + Log.error("CameraManager", "Camera input error") + return + } + session.addInput(input) + + // Improve perceived quality: enable continuous AF/AE/WB + do { + try device.lockForConfiguration() + if device.isFocusModeSupported(.continuousAutoFocus) { + device.focusMode = .continuousAutoFocus + } + if device.isExposureModeSupported(.continuousAutoExposure) { + device.exposureMode = .continuousAutoExposure + } + if device.isWhiteBalanceModeSupported(.continuousAutoWhiteBalance) { + device.whiteBalanceMode = .continuousAutoWhiteBalance + } + device.unlockForConfiguration() + } catch { + Log.debug("BarcodeCameraManager", "Could not lock device for configuration: \(error)") + } + // Provide active capture device + queue to FlashManager for safe torch control + FlashManager.shared.configure(with: device, queue: sessionQueue) + + // Configure metadata output once during initial setup to avoid reconfiguration later. + if session.canAddOutput(metadataOutput) { + session.addOutput(metadataOutput) + metadataOutput.setMetadataObjectsDelegate(self, queue: sessionQueue) + let requested: [AVMetadataObject.ObjectType] = [ .ean13, + .ean8, + .code128, + .code39, + .code93, + .upce, + .qr + ] + let available = metadataOutput.availableMetadataObjectTypes + let supported = requested.filter { available.contains($0) } + metadataOutput.metadataObjectTypes = supported + outputsConfigured = true + } + + // Configure photo output for still capture (used in photo mode). + if session.canAddOutput(photoOutput) { + session.addOutput(photoOutput) + photoOutput.isHighResolutionCaptureEnabled = true + } + Log.debug("BarcodeCameraManager", "Before commit: inputs=\(session.inputs.count) outputs=\(session.outputs.count)") + let preview = AVCaptureVideoPreviewLayer(session: session) + preview.videoGravity = .resizeAspectFill + preview.needsDisplayOnBoundsChange = true + DispatchQueue.main.async { self.previewLayer = preview } + } + + private func bumpPresetToHighIfPossible() { + sessionQueue.async { + guard self.session.canSetSessionPreset(.high) else { return } + self.session.beginConfiguration() + self.session.sessionPreset = .high + self.session.commitConfiguration() + Log.debug("BarcodeCameraManager", "Bumped session preset to .high") + } + } + + func updateRectOfInterest(overlayRect: CGRect, containerSize: CGSize) { + // Use full-frame recognition so barcodes can be detected anywhere + sessionQueue.async { + self.metadataOutput.rectOfInterest = CGRect(x: 0, y: 0, width: 1, height: 1) + Log.debug("BarcodeCameraManager", "rectOfInterest=full") + } + } + + func startSession() { + sessionQueue.async { + if !self.session.isRunning { + self.session.startRunning() + } + // isRunning may not reflect immediately. We'll rely on notifications + // but also set a delayed check as a fallback for UI feedback. + let delay = DispatchTime.now() + 0.1 + self.sessionQueue.asyncAfter(deadline: delay) { + let running = self.session.isRunning + DispatchQueue.main.async { + self.isSessionRunning = running + if let connection = self.previewLayer?.connection, connection.isVideoOrientationSupported { + connection.videoOrientation = .portrait + } + self.previewLayer?.videoGravity = .resizeAspectFill + let info = "inputs=\(self.session.inputs.count) outputs=\(self.session.outputs.count) running=\(running)" + self.debugInfo = info + Log.debug("BarcodeCameraManager", "\(info)") + if !running { Log.debug("BarcodeCameraManager", "Session failed to start (timeout)") } + } + } + } + } + + func stopSession() { + sessionQueue.async { + if self.session.isRunning { + self.session.stopRunning() + } + DispatchQueue.main.async { self.isSessionRunning = false } + } + } + + // MARK: - Photo capture + /// Captures a still image from the active camera session. + /// - Parameters: + /// - useFlash: When `true`, a photo flash will be used for this capture + /// if the device supports it. When `false`, the capture + /// happens without flash. + /// - completion: Called on the main thread with the resulting image, or + /// `nil` if capture failed. + func capturePhoto(useFlash: Bool = false, completion: @escaping (UIImage?) -> Void) { + sessionQueue.async { + guard self.session.isRunning else { + DispatchQueue.main.async { completion(nil) } + return + } + self.photoCaptureCompletion = completion + + let settings = AVCapturePhotoSettings() + // Configure one-shot flash behaviour separate from the continuous torch + // that is controlled by `FlashManager` in scanner mode. + if self.photoOutput.supportedFlashModes.contains(.on) { + settings.flashMode = useFlash ? .on : .off + } + + self.photoOutput.capturePhoto(with: settings, delegate: self) + } + } + + // MARK: - Barcode Delegate + func metadataOutput(_ output: AVCaptureMetadataOutput, + didOutput metadataObjects: [AVMetadataObject], + from connection: AVCaptureConnection) { + // If scanning is temporarily disabled (e.g., in photo mode), ignore detections + if !scanningEnabled { + return + } + // If nothing is detected this frame, allow the same code to be recognized again next time it re-enters the frame + if metadataObjects.isEmpty { + self.lastEmittedBarcode = nil + return + } + if let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject, + let value = object.stringValue { + // Only emit when the value changes; prevents repeated triggers for the same visible code + guard value != self.lastEmittedBarcode else { return } + self.lastEmittedBarcode = value + DispatchQueue.main.async { + self.scannedBarcode = value + self.onBarcodeScanned?(value) + let generator = UINotificationFeedbackGenerator() + generator.prepare() + generator.notificationOccurred(.success) + } + } + } + + @objc private func handleRuntimeError(_ notification: Notification) { + if let error = notification.userInfo?[AVCaptureSessionErrorKey] as? AVError { + Log.error("CameraManager", "AVCaptureSessionRuntimeError: \(error)") + } else { + Log.error("CameraManager", "AVCaptureSessionRuntimeError occurred") + } + } + + @objc private func handleSessionDidStartRunning(_ notification: Notification) { + DispatchQueue.main.async { + self.isSessionRunning = true + let info = "inputs=\(self.session.inputs.count) outputs=\(self.session.outputs.count) running=true" + self.debugInfo = info + } + Log.debug("BarcodeCameraManager", "Session did start running") + bumpPresetToHighIfPossible() + } + + @objc private func handleSessionDidStopRunning(_ notification: Notification) { + DispatchQueue.main.async { + self.isSessionRunning = false + } + Log.debug("BarcodeCameraManager", "Session did stop running") + } + + @objc private func handleSessionWasInterrupted(_ notification: Notification) { + Log.debug("BarcodeCameraManager", "Session was interrupted: \(notification.userInfo ?? [:])") + } + + @objc private func handleSessionInterruptionEnded(_ notification: Notification) { + Log.debug("BarcodeCameraManager", "Session interruption ended") + } + + deinit { + NotificationCenter.default.removeObserver(self) + } +} + +class FlashManager { + static let shared = FlashManager() + private init (){} + private var device: AVCaptureDevice? + private var queue: DispatchQueue? + + func configure(with device: AVCaptureDevice, queue: DispatchQueue) { + self.device = device + self.queue = queue + } + + func toggleFlash(completion: ((Bool) -> Void)? = nil) { + let act: (AVCaptureDevice) -> Void = { dev in + guard dev.hasTorch else { return } + do { + try dev.lockForConfiguration() + if dev.torchMode == .on { + dev.torchMode = .off + } else { + try dev.setTorchModeOn(level: 1.0) + } + dev.unlockForConfiguration() + } catch { + Log.error("CameraManager", "Flash toggle failed: \(error)") + } + } + if let dev = device, let q = queue { + q.async { + act(dev) + let state = dev.hasTorch && dev.torchMode == .on + if let completion = completion { + DispatchQueue.main.async { completion(state) } + } + } + } else if let fallback = AVCaptureDevice.default(for: .video) { + DispatchQueue.global(qos: .userInitiated).async { + act(fallback) + let state = fallback.hasTorch && fallback.torchMode == .on + if let completion = completion { + DispatchQueue.main.async { completion(state) } + } + } + } + } + + func isFlashOn() -> Bool { + let dev = device ?? AVCaptureDevice.default(for: .video) + guard let d = dev, d.hasTorch else { return false } + return d.torchMode == .on + } +} + + + + + diff --git a/IngrediCheck/Views/BarcodeScan/Utilities/CameraPermissions.swift b/IngrediCheck/Views/BarcodeScan/Utilities/CameraPermissions.swift new file mode 100644 index 00000000..643d7052 --- /dev/null +++ b/IngrediCheck/Views/BarcodeScan/Utilities/CameraPermissions.swift @@ -0,0 +1,9 @@ +import AVFoundation + +func requestCameraAccess(completion: @escaping (Bool) -> Void) { + AVCaptureDevice.requestAccess(for: .video) { granted in + DispatchQueue.main.async { + completion(granted) + } + } +} diff --git a/IngrediCheck/Views/BarcodeScan/Utilities/ScanCameraControls.swift b/IngrediCheck/Views/BarcodeScan/Utilities/ScanCameraControls.swift new file mode 100644 index 00000000..03cff30d --- /dev/null +++ b/IngrediCheck/Views/BarcodeScan/Utilities/ScanCameraControls.swift @@ -0,0 +1,333 @@ + +import SwiftUI + + +struct CameraSwipeButton: View { + @Binding var mode: CameraMode + @Binding var showRetryCallout: Bool + @State private var isTapped = false + @State private var isTapped1 = false + @GestureState private var dragOffset: CGFloat = 0 + + var body: some View { + VStack{ + ZStack { + // Background Card + RoundedRectangle(cornerRadius: 41) + .fill(.thinMaterial) + .opacity(0.4) + .frame(width: 229, height: 66) + + // Inner content + HStack { + + // MARK: Left circle (Barcode) + Button(action: { + withAnimation(.easeInOut(duration: 0.18)) { + isTapped = true + mode = .scanner + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.18) { + withAnimation(.easeInOut(duration: 0.18)) { + isTapped = false + } + } + }) { + ZStack { + Circle() + .fill( + mode == .scanner ? + AnyShapeStyle(LinearGradient( + colors: [Color(hex: "#9DCF10"), Color(hex: "#6B8E06")], + startPoint: .topLeading, + endPoint: .bottomTrailing + )) : + AnyShapeStyle(Color.white.opacity(0.15)) + ) + .frame(width: 58.60, height: 58.60,) + .scaleEffect(isTapped ? 0.9 : 1.0) + Image("iconoir_scan-barcode") + .foregroundColor(.white) + .font(.system(size: 28)) + + Text("Scanner") + .font(ManropeFont.regular.size(11)) + .foregroundStyle(.white) + .offset(y: UIScreen.main.bounds.height * 0.05) + } +// .background(.red) + } + .buttonStyle(.plain) + + Spacer( +// minLength: 12 + ) + + // MARK: Middle icons (shimmer + independent rotation) + ArrowSwipeShimmer(mode: mode) + + Spacer( +// minLength: 12 + ) + + // MARK: Right circle (Camera) + ZStack { + Button(action: { + withAnimation(.smooth(duration: 0.18)) { + isTapped1 = true + mode = .photo + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.18) { + withAnimation(.smooth(duration: 0.18)) { + isTapped1 = false + } + } + }) { + ZStack() { + Circle() + .fill( + mode == .photo ? + AnyShapeStyle(LinearGradient( + colors: [Color(hex: "#9DCF10"), Color(hex: "#6B8E06")], + startPoint: .topLeading, + endPoint: .bottomTrailing + )) : + AnyShapeStyle(.thinMaterial.opacity(0.5)) + ) + .frame(width: 58.60, height: 58.60) + .scaleEffect(isTapped1 ? 0.9 : 1.0) + Image("cameracapture") + .foregroundColor(.white) + .font(.system(size: 20)) + + Text("Photo") + .font(ManropeFont.regular.size(11)) + .foregroundStyle(.white) + .offset(y: UIScreen.main.bounds.height * 0.05) + } +// .background(.red) + } + .buttonStyle(.plain) + + // Callout bubble above the photo circle when retry is shown + if showRetryCallout { + VStack(spacing: 0) { + CalloutBubble(text: "Try again or switch\n to photo mode .") + .onTapGesture { + withAnimation(.easeInOut) { + showRetryCallout = false + } + } + Spacer() + .frame(height: 0) + } + .offset(y: -80) // Position above the circle + .transition(.opacity.combined(with: .scale(scale: 0.8))) + .animation(.spring(response: 0.3, dampingFraction: 0.7), value: showRetryCallout) + } + } + + } + .padding(.horizontal, 3.5) + .frame(width: 229) + // Keep circles visually centered; drag is used only to switch modes. + .animation(.spring(response: 0.25, dampingFraction: 0.8), value: dragOffset) + + } + .gesture( + DragGesture() + .updating($dragOffset) { value, state, _ in + // live drag translation, tightly clamped so content stays visually centered + let translation = value.translation.width + let clamped = max(-15, min(15, translation)) + state = clamped + } + .onEnded { value in + let threshold: CGFloat = 30 + let translation = value.translation.width + + if translation > threshold { + // swipe right -> go to photo mode (move selection to the right circle) + withAnimation(.spring(response: 0.25, dampingFraction: 0.8)) { + mode = .photo + } + } else if translation < -threshold { + // swipe left -> go to scanner mode (move selection to the left circle) + withAnimation(.spring(response: 0.25, dampingFraction: 0.8)) { + mode = .scanner + } + } + } + ) + // keep circles visually inside the rounded pill while sliding +// .clipShape(RoundedRectangle(cornerRadius: 46)) + // .swipeShimmer() + + +// HStack(){ +// Text("Scanner") +// Spacer() +// Text("Photo") +// } +// +// .frame(maxWidth:229) +// .foregroundColor(Color.white) +// .font(.system(size: 11)) +// .fontWeight(.regular) +// .padding(.leading , 40) +// .padding(.trailing, 40) + + + + + } + } +} + +struct ArrowSwipeShimmer: View { + @State private var phase: CGFloat = -1 + private let animationDuration: Double = 1.4 + var mode: CameraMode + var baseOpacity: Double = 0.35 + + var body: some View { + HStack(spacing: 8) { + ForEach(0..<3, id: \.self) { index in + Image("right-arrow") + .renderingMode(.template) + .resizable() + .frame(width: 11, height: 21) + .foregroundColor(.white.opacity(baseOpacity)) + +// .opacity(0.4) // base arrow look + .rotationEffect(.degrees(mode == .photo ? 180 : 0)) + .animation( + .easeInOut(duration: 0.24) + .delay(0.04 * Double(index)), + value: mode + ) + } + } + .overlay( + LinearGradient( + gradient: Gradient(colors: [ + Color.white.opacity(0.0), + Color.white.opacity(0.6), + Color.white.opacity(0.0) + ]), + startPoint: .leading, + endPoint: .trailing + ) + .frame(width: 42) + .offset(x: phase * 42 * (mode == .photo ? -1 : 1)) + .mask( + HStack(spacing: 8) { + ForEach(0..<3, id: \.self) { index in + Image("right-arrow1") + .renderingMode(.template) + .resizable() + .frame(width: 11, height: 21) + .rotationEffect(.degrees(mode == .photo ? 180 : 0)) + + .animation( + .easeInOut(duration: 0.24) + .delay(0.04 * Double(index)), + value: mode + ) + } + } + ) + ) + .onAppear { startShimmer(withDelay: 0.4) } + .onChange(of: mode) { _ in + // Restart shimmer immediately when direction flips + startShimmer(withDelay: 0.0) + } + } + + private func startShimmer(withDelay delay: Double) { + phase = -1 + withAnimation( + Animation + .linear(duration: animationDuration) + .delay(delay) + .repeatForever(autoreverses: false) + ) { + phase = 1 + } + } +} + +struct SwipeShimmer: ViewModifier { + @State private var phase: CGFloat = -1 + + func body(content: Content) -> some View { + content + .overlay( + LinearGradient( + gradient: Gradient(colors: [ + Color.grayScale70.opacity(0.0), + Color.grayScale70.opacity(0.3), + Color.grayScale70.opacity(0.3), + Color.grayScale70.opacity(0.8), + + ]), + startPoint: .leading, + endPoint: .trailing + ) + .scaleEffect(x: 1.5) // wider highlight + .offset(x: phase * 180) // shimmer movement + ) + .mask(content) + .onAppear { + withAnimation( + Animation + .linear(duration: 1.8) + .repeatForever(autoreverses: false) // <β€” IMPORTANT (left-right-left) + ) { + phase = 1 + } + } + } +} + +extension View { + func swipeShimmer() -> some View { + self.modifier(SwipeShimmer()) + } +} +struct CalloutBubble: View { + var text: String + + var body: some View { + VStack(spacing: 0) { + Text(text) + .font(ManropeFont.medium.size(12)) + + .foregroundColor(.white) + .padding(.horizontal, 12) + .padding(.vertical, 12) + + .background(Color(hex: "#75990E")) + .cornerRadius(12) + + Triangle() + .fill(Color(hex: "#75990E")) + .frame(width: 20, height: 14.5) + } +// .frame(width: 131, height: 60) // full bubble size + } +} + +// Triangle shape +struct Triangle: Shape { + func path(in rect: CGRect) -> Path { + Path { path in + path.move(to: CGPoint(x: rect.midX, y: rect.maxY)) + path.addLine(to: CGPoint(x: rect.minX, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY)) + path.closeSubpath() + } + } +} + diff --git a/IngrediCheck/Views/BarcodeScannerView.swift b/IngrediCheck/Views/BarcodeScannerView.swift index 11ed0db7..1daabf21 100644 --- a/IngrediCheck/Views/BarcodeScannerView.swift +++ b/IngrediCheck/Views/BarcodeScannerView.swift @@ -3,6 +3,7 @@ import Foundation import SwiftUI import VisionKit import PostHog +import os enum DataScannerAccessStatusType { case notDetermined @@ -300,12 +301,12 @@ struct DataScannerView: UIViewControllerRepresentable { } func dataScanner(_ dataScanner: DataScannerViewController, didRemove removedItems: [RecognizedItem], allItems: [RecognizedItem]) { -// print("dataScanner: didRemove") +// Log.debug("BarcodeScannerView", "dataScanner: didRemove") // print(removedItems) } func dataScanner(_ dataScanner: DataScannerViewController, becameUnavailableWithError error: DataScannerViewController.ScanningUnavailable) { - print("became unavailable with error \(error.localizedDescription)") + Log.error("BarcodeScannerView", "became unavailable with error \(error.localizedDescription)") } } diff --git a/IngrediCheck/Views/BlankScreen.swift b/IngrediCheck/Views/BlankScreen.swift new file mode 100644 index 00000000..9e1af541 --- /dev/null +++ b/IngrediCheck/Views/BlankScreen.swift @@ -0,0 +1,21 @@ +// +// BlankScreen.swift +// IngrediCheckPreview +// +// Created by Gunjan Haldar on 10/11/25. +// + +import SwiftUI + +struct BlankScreen: View { + + var body: some View { + VStack { + // Empty view for layout + } + } +} + +#Preview { + BlankScreen() +} diff --git a/IngrediCheck/Views/Canvas/CanvasMode.swift b/IngrediCheck/Views/Canvas/CanvasMode.swift new file mode 100644 index 00000000..4fdfe05a --- /dev/null +++ b/IngrediCheck/Views/Canvas/CanvasMode.swift @@ -0,0 +1,73 @@ +// +// CanvasMode.swift +// IngrediCheck +// +// Unified canvas mode configuration for onboarding and editing contexts. +// + +import Foundation + +/// Defines the mode of the UnifiedCanvasView, determining which UI elements are shown. +enum CanvasMode: Equatable { + /// Onboarding flow with progress bar and tag navigation + case onboarding(flow: OnboardingFlowType) + + /// Editing mode from Home/Profile with edit buttons and member filtering + case editing + + // MARK: - UI Configuration Properties + + /// Show progress bar at top (onboarding only) + var showProgressBar: Bool { + if case .onboarding = self { return true } + return false + } + + /// Show tag bar for section navigation (onboarding only) + var showTagBar: Bool { + if case .onboarding = self { return true } + return false + } + + /// Show edit buttons on each card (editing only) + var showEditButtons: Bool { + if case .editing = self { return true } + return false + } + + /// Show family member filter capsules (editing only) + var showMemberFilter: Bool { + if case .editing = self { return true } + return false + } + + /// Show all sections including empty ones (editing shows all, onboarding shows only non-empty) + var showAllSections: Bool { + if case .editing = self { return true } + return false + } + + /// Show tab bar at bottom (editing only) + var showTabBar: Bool { + if case .editing = self { return true } + return false + } + + /// Show family icons on chips + var showFamilyIcons: Bool { + switch self { + case .onboarding(let flow): + return flow == .family || flow == .singleMember + case .editing: + return true // Always show in editing mode (if family exists) + } + } + + /// Get the onboarding flow type if in onboarding mode + var onboardingFlow: OnboardingFlowType? { + if case .onboarding(let flow) = self { + return flow + } + return nil + } +} diff --git a/IngrediCheck/Views/Canvas/Components/CanvasCardBuilder.swift b/IngrediCheck/Views/Canvas/Components/CanvasCardBuilder.swift new file mode 100644 index 00000000..c4958d71 --- /dev/null +++ b/IngrediCheck/Views/Canvas/Components/CanvasCardBuilder.swift @@ -0,0 +1,265 @@ +// +// CanvasCardBuilder.swift +// IngrediCheck +// +// Shared utility for building canvas cards and chips. +// Consolidates common logic from MainCanvasView and EditableCanvasView. +// + +import Foundation + +/// Utility struct for building canvas cards and chips +@MainActor +struct CanvasCardBuilder { + + // MARK: - Icon + + /// Get icon for a step + static func icon(for stepId: String, store: Onboarding) -> String { + if let step = store.step(for: stepId), + let icon = step.header.iconURL, + !icon.isEmpty { + return icon + } + return "allergies" + } + + // MARK: - Flat Chips (Type-1 steps) + + /// Build flat chips for a section (Type-1 steps like Allergies, Intolerances) + /// - Parameters: + /// - stepId: The step ID to build chips for + /// - sectionKey: The section name key for item associations lookup + /// - foodNotesStore: The food notes store containing preferences + /// - store: The onboarding store with step definitions + /// - filterMemberId: Optional member ID to filter items by + /// - Returns: Array of ChipsModel or nil if no items + static func chips( + for stepId: String, + sectionKey: String, + foodNotesStore: FoodNotesStore, + store: Onboarding, + filterMemberId: UUID? = nil + ) -> [ChipsModel]? { + guard let step = store.step(for: stepId) else { return nil } + let sectionName = step.header.name + + // Use canvasPreferences for union view (Everyone + all members) + guard let value = foodNotesStore.canvasPreferences.sections[sectionName], + case .list(let items) = value else { + return nil + } + + // Filter items by selected member if one is selected + let filteredItems: [String] + if let filterMemberId = filterMemberId { + let memberIdString = filterMemberId.uuidString.lowercased() + filteredItems = items.filter { itemName in + if let memberIds = foodNotesStore.itemMemberAssociations[sectionKey]?[itemName] { + // Show items explicitly associated with this member + return memberIds.contains(memberIdString) + } + return false + } + } else { + // No member selected, show all items (union view) + filteredItems = items + } + + guard !filteredItems.isEmpty else { return nil } + + // Get icons from step options + let options = step.content.options ?? [] + return filteredItems.compactMap { itemName -> ChipsModel? in + if let option = options.first(where: { $0.name == itemName }) { + return ChipsModel(name: option.name, icon: option.icon) + } + return ChipsModel(name: itemName, icon: nil) + } + } + + // MARK: - Sectioned Chips (Type-2 and Type-3 steps) + + /// Build sectioned chips for Type-2 (subSteps) and Type-3 (regions) steps + /// - Parameters: + /// - stepId: The step ID to build chips for + /// - sectionKey: The section name key for item associations lookup + /// - foodNotesStore: The food notes store containing preferences + /// - store: The onboarding store with step definitions + /// - filterMemberId: Optional member ID to filter items by + /// - Returns: Array of SectionedChipModel or nil if no items + static func sectionedChips( + for stepId: String, + sectionKey: String, + foodNotesStore: FoodNotesStore, + store: Onboarding, + filterMemberId: UUID? = nil + ) -> [SectionedChipModel]? { + guard let step = store.step(for: stepId) else { return nil } + let sectionName = step.header.name + + // Use canvasPreferences for union view + guard let value = foodNotesStore.canvasPreferences.sections[sectionName], + case .nested(let nestedDict) = value else { + return nil + } + + // Helper to filter items by selected member + let filterItems: ([String]) -> [String] = { items in + guard let filterMemberId = filterMemberId else { return items } + let memberIdString = filterMemberId.uuidString.lowercased() + return items.filter { itemName in + if let memberIds = foodNotesStore.itemMemberAssociations[sectionKey]?[itemName] { + // Show items explicitly associated with this member + return memberIds.contains(memberIdString) + } + return false + } + } + + var sections: [SectionedChipModel] = [] + + if let subSteps = step.content.subSteps { + // MARK: Type-2 (Avoid / Lifestyle / Nutrition-style) + for subStep in subSteps { + guard let selectedItems = nestedDict[subStep.title], + !selectedItems.isEmpty else { + continue + } + + // Filter items by selected member + let filteredItems = filterItems(selectedItems) + guard !filteredItems.isEmpty else { continue } + + // Map selected items to ChipsModel with icons + let selectedChips: [ChipsModel] = filteredItems.compactMap { itemName in + if let option = subStep.options?.first(where: { $0.name == itemName }) { + return ChipsModel(name: option.name, icon: option.icon) + } + return ChipsModel(name: itemName, icon: nil) + } + + if !selectedChips.isEmpty { + sections.append( + SectionedChipModel( + title: subStep.title, + subtitle: subStep.description, + chips: selectedChips + ) + ) + } + } + } else if let regions = step.content.regions { + // MARK: Type-3 (Region-style) + for region in regions { + guard let selectedItems = nestedDict[region.name], + !selectedItems.isEmpty else { + continue + } + + // Filter items by selected member + let filteredItems = filterItems(selectedItems) + guard !filteredItems.isEmpty else { continue } + + let selectedChips: [ChipsModel] = filteredItems.compactMap { itemName in + if let option = region.subRegions.first(where: { $0.name == itemName }) { + return ChipsModel(name: option.name, icon: option.icon) + } + return ChipsModel(name: itemName, icon: nil) + } + + if !selectedChips.isEmpty { + sections.append( + SectionedChipModel( + title: region.name, + subtitle: nil, + chips: selectedChips + ) + ) + } + } + } + + return sections.isEmpty ? nil : sections + } + + // MARK: - Section Selection Check + + /// Check if a section has any selections + /// - Parameters: + /// - section: The onboarding section to check + /// - foodNotesStore: The food notes store containing preferences + /// - store: The onboarding store with step definitions + /// - filterMemberId: Optional member ID to filter items by + /// - Returns: True if the section has any selected items + static func hasSelections( + for section: OnboardingSection, + foodNotesStore: FoodNotesStore, + store: Onboarding, + filterMemberId: UUID? = nil + ) -> Bool { + guard let stepId = section.screens.first?.stepId else { return false } + let flatChips = chips(for: stepId, sectionKey: section.name, foodNotesStore: foodNotesStore, store: store, filterMemberId: filterMemberId) + let groupedChips = sectionedChips(for: stepId, sectionKey: section.name, foodNotesStore: foodNotesStore, store: store, filterMemberId: filterMemberId) + return (flatChips?.isEmpty == false) || (groupedChips?.isEmpty == false) + } + + // MARK: - Build Cards + + /// Build canvas cards for display, with optional sorting by selection status + /// - Parameters: + /// - store: The onboarding store with sections + /// - foodNotesStore: The food notes store containing preferences + /// - filterMemberId: Optional member ID to filter items by + /// - showAllSections: Whether to include empty sections (editing mode) + /// - sortBySelection: Whether to sort sections with selections first + /// - Returns: Array of CanvasCardModel + static func buildCards( + store: Onboarding, + foodNotesStore: FoodNotesStore, + filterMemberId: UUID? = nil, + showAllSections: Bool = false, + sortBySelection: Bool = true + ) -> [CanvasCardModel] { + var cards: [CanvasCardModel] = [] + + // Sort sections: those with selections first, maintain original order within groups + let sortedSections: [OnboardingSection] + if sortBySelection { + sortedSections = store.sections.enumerated().sorted { (a, b) in + let hasA = hasSelections(for: a.element, foodNotesStore: foodNotesStore, store: store, filterMemberId: filterMemberId) + let hasB = hasSelections(for: b.element, foodNotesStore: foodNotesStore, store: store, filterMemberId: filterMemberId) + if hasA != hasB { return hasA } + return a.offset < b.offset + }.map { $0.element } + } else { + sortedSections = store.sections + } + + for section in sortedSections { + guard let stepId = section.screens.first?.stepId else { continue } + + let flatChips = chips(for: stepId, sectionKey: section.name, foodNotesStore: foodNotesStore, store: store, filterMemberId: filterMemberId) + let groupedChips = sectionedChips(for: stepId, sectionKey: section.name, foodNotesStore: foodNotesStore, store: store, filterMemberId: filterMemberId) + + let hasContent = (flatChips?.isEmpty == false) || (groupedChips?.isEmpty == false) + + // In editing mode (showAllSections), show all sections + // In onboarding mode, only show non-empty sections + if showAllSections || hasContent { + cards.append( + CanvasCardModel( + id: section.id, + title: section.name, + icon: icon(for: stepId, store: store), + stepId: stepId, + chips: flatChips, + sectionedChips: groupedChips + ) + ) + } + } + + return cards + } +} diff --git a/IngrediCheck/Views/Canvas/UnifiedCanvasView.swift b/IngrediCheck/Views/Canvas/UnifiedCanvasView.swift new file mode 100644 index 00000000..21903387 --- /dev/null +++ b/IngrediCheck/Views/Canvas/UnifiedCanvasView.swift @@ -0,0 +1,702 @@ +// +// UnifiedCanvasView.swift +// IngrediCheck +// +// Unified canvas view that consolidates MainCanvasView and EditableCanvasView. +// Supports both onboarding flow and editing mode through CanvasMode configuration. +// + +import SwiftUI + +struct UnifiedCanvasView: View { + + // MARK: - Configuration + + let mode: CanvasMode + var targetSectionName: String? = nil + var titleOverride: String? = nil + var showBackButton: Bool = true + var onDismiss: (() -> Void)? = nil + + // MARK: - Environment + + @EnvironmentObject private var store: Onboarding + @Environment(\.dismiss) private var dismiss + @Environment(AppNavigationCoordinator.self) private var coordinator + @Environment(WebService.self) private var webService + @Environment(AppState.self) private var appState + @Environment(FamilyStore.self) private var familyStore + @Environment(FoodNotesStore.self) private var foodNotesStore + + // MARK: - Shared State + + @State private var cardScrollTarget: UUID? = nil + @State private var tagBarScrollTarget: UUID? = nil + @State private var isLoadingMemberPreferences: Bool = false + @State private var didFinishInitialLoad: Bool = false + + // MARK: - Onboarding-specific State + + @State private var previousSectionIndex: Int = 0 + + // MARK: - Editing-specific State + + @State private var selectedMemberId: UUID? = nil + @State private var isTabBarExpanded: Bool = true + @State private var scrollY: CGFloat = 0 + @State private var prevValue: CGFloat = 0 + @State private var maxScrollOffset: CGFloat = 0 + @State private var hasScrolledToTarget: Bool = false + @State private var headroomCollapsed: Bool = false + @State private var scrollToEditedSection: String? = nil + + // MARK: - Computed Properties + + private var shouldCenterLifestyleNutrition: Bool { + guard let targetSectionName, !targetSectionName.isEmpty else { return false } + let t = targetSectionName.lowercased().replacingOccurrences(of: " ", with: "") + return t.contains("lifestyle") || t.contains("nutrition") + } + + private var showFamilyIconsOnChips: Bool { + switch mode { + case .onboarding(let flow): + return flow == .family || flow == .singleMember + case .editing: + return familyStore.family?.otherMembers.isEmpty == false + } + } + + // MARK: - Body + + var body: some View { + ZStack(alignment: .bottom) { + mainContent + + // Edit sheet overlay removed - RootContainerView handles it globally + // This prevents double-sheet issue when UnifiedCanvasView is embedded in HomeView + } + .modifier( + ConditionalBottomTabBar( + isEnabled: mode.showTabBar && !coordinator.isEditSheetPresented, + gradientColors: [ + Color.pageBackground.opacity(0), + Color.pageBackground + ] + ) { + TabBar( + isExpanded: $isTabBarExpanded, + onRecentScansTap: { + // Navigate to Recent Scans view + appState.navigationPath.append(HistoryRouteItem.recentScansAll) + }, + onChatBotTap: mode == .editing ? { + // Open AI Bot with food_notes context when in editing mode (Food Notes screen) + coordinator.showAIBotSheetWithContext(contextKeyOverride: "food_notes") + } : nil + ) + .fixedSize(horizontal: false, vertical: true) + } + ) + .background(Color.pageBackground) + .navigationTitle(mode == .editing ? (titleOverride ?? "Food Notes") : "") + .navigationBarTitleDisplayMode(.inline) + // Sync indicator removed - using redacted loading for initial load only + .onAppear { + handleOnAppear() + } + .onDisappear { + handleOnDisappear() + } + .task { + await handleFoodNotesLoad() + } + .onChange(of: store.currentSectionIndex) { newIndex in + handleSectionIndexChange(newIndex) + } + .onChange(of: store.preferences) { _ in + handlePreferencesChange() + } + .onChange(of: familyStore.selectedMemberId) { newValue in + handleMemberSwitch(newValue) + } + .onChange(of: coordinator.isEditSheetPresented) { oldValue, newValue in + // When edit sheet is dismissed, scroll to the edited section and refresh canvas + if oldValue == true && newValue == false, let stepId = coordinator.editingStepId { + scrollToEditedSection = stepId + // Force refresh of canvas cards to show updated selections (only in editing mode and after initial load) + if mode == .editing && didFinishInitialLoad { + foodNotesStore.preparePreferencesForMember(selectedMemberId: selectedMemberId) + } + } + } + .onChange(of: coordinator.isAIBotSheetPresented) { oldValue, newValue in + // When AI bot sheet is dismissed, reload food notes to pick up misc notes changes + if oldValue == true && newValue == false && mode == .editing { + Task { + await foodNotesStore.loadFoodNotesAll() + } + } + } + } + + // MARK: - Main Content + + @ViewBuilder + private var mainContent: some View { + VStack(spacing: 0) { + // Top bar (mode-dependent) + topBar + .zIndex(10) + + // Tag bar (onboarding only) + if mode.showTagBar { + CanvasTagBar( + store: store, + onTapCurrentSection: { + scheduleScrollToCurrentSectionViews() + }, + scrollTarget: $tagBarScrollTarget, + currentBottomSheetRoute: coordinator.currentBottomSheetRoute + ) + .padding(.bottom, 16) + } + + // Family member filter (editing only) + if mode.showMemberFilter, let family = familyStore.family, !family.otherMembers.isEmpty { + familyCapsulesRow(members: [family.selfMember] + family.otherMembers) + .padding(.top, 22) + .padding(.bottom, 16) + .padding(.horizontal, 16) + } + + // Cards content + cardsContent + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } + + // MARK: - Top Bar + + @ViewBuilder + private var topBar: some View { + switch mode { + case .onboarding: + CustomIngrediCheckProgressBar(progress: CGFloat(store.progress * 100)) + .animation(.smooth, value: store.progress) + case .editing: + EmptyView() // Uses default navigation bar with title and back button + } + } + + // MARK: - Cards Content + + @ViewBuilder + private var cardsContent: some View { + let cards = buildCards() + if mode.showTagBar { + // Onboarding mode: use CanvasSummaryScrollView + CanvasSummaryScrollView( + cards: cards, + scrollTarget: $cardScrollTarget, + showPlaceholder: cards.isEmpty, + itemMemberAssociations: foodNotesStore.itemMemberAssociations ?? [:], + showFamilyIcons: showFamilyIconsOnChips + ) + } else { + // Editing mode: use custom scroll view with edit buttons + // Data is already available from FoodNotesStore, refresh happens in background + editableCardsScrollView(cards: cards) + } + } + + // MARK: - Build Cards (with sorting) + + private func buildCards() -> [CanvasCardModel] { + return CanvasCardBuilder.buildCards( + store: store, + foodNotesStore: foodNotesStore, + filterMemberId: mode == .editing ? selectedMemberId : nil, + showAllSections: mode.showAllSections, + sortBySelection: true + ) + } + + // MARK: - Editable Cards Scroll View + + private func editableCardsScrollView(cards: [CanvasCardModel]) -> some View { + ScrollViewReader { proxy in + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: 12) { + // Show redacted loading skeleton only when store has no data yet (true initial load) + // Once store has data, never show redacted again (background refresh is silent) + let hasStoreData = foodNotesStore.hasLoadedFoodNotes + if !hasStoreData && !didFinishInitialLoad { + redactedLoadingContent + } else { + // AI Summary Card at top (only show if we have a summary and no member filter applied) + let hasSummary = selectedMemberId == nil && + foodNotesStore.foodNotesSummary != nil && + !foodNotesStore.foodNotesSummary!.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + foodNotesStore.foodNotesSummary != "No Food Notes yet." + + if hasSummary { + AISummaryCard( + summary: foodNotesStore.foodNotesSummary!, + dynamicSteps: store.dynamicSteps + ) + .padding(.top, 16) + } + + // Misc notes card (free-text notes from IngrediBot) + // Shown at top (after AI summary) when non-empty, similar to how + // sections with selections are sorted to the top. + let miscNotes: [String] = { + if let selectedId = selectedMemberId { + // Single member selected - show their notes without prefix + return foodNotesStore.memberMiscNotes[selectedId.uuidString.lowercased()] ?? [] + } else { + // Aggregate all misc notes from all members + family + // Prefix member-level notes with member name for clarity + var allNotes: [String] = [] + for (key, notes) in foodNotesStore.memberMiscNotes { + if key == "Everyone" { + // Family-level notes - no prefix needed + allNotes.append(contentsOf: notes) + } else { + // Member-level notes - prefix with member name + let memberName = memberName(for: key) + for note in notes { + allNotes.append("\(memberName): \(note)") + } + } + } + return allNotes + } + }() + let hasMiscNotes = !miscNotes.isEmpty + + if hasMiscNotes { + MiscNotesCard(notes: miscNotes) { + coordinator.showAIBotSheetWithContext(contextKeyOverride: "food_notes") + } + .padding(.top, hasSummary ? 0 : 16) + } + + ForEach(Array(cards.enumerated()), id: \.element.id) { index, card in + EditableCanvasCard( + chips: card.chips, + sectionedChips: card.sectionedChips, + title: card.title, + iconName: card.icon, + onEdit: { openEdit(for: card) }, + itemMemberAssociations: foodNotesStore.itemMemberAssociations ?? [:], + showFamilyIcons: showFamilyIconsOnChips, + activeMemberId: selectedMemberId + ) + .padding(.top, index == 0 && !hasSummary && !hasMiscNotes ? 16 : 0) + .id(card.id) + } + } + } + .padding(.horizontal, 16) + .padding(.bottom, coordinator.isEditSheetPresented ? UIScreen.main.bounds.height * 0.5 : 80) + .background(scrollTrackingBackground) + } + .coordinateSpace(name: "editableCanvasScroll") + .onAppear { + scrollToTargetSectionIfNeeded(cards: cards, proxy: proxy) + } + .onChange(of: didFinishInitialLoad) { _ in + scrollToTargetSectionIfNeeded(cards: cards, proxy: proxy) + } + .onChange(of: scrollToEditedSection) { _, stepId in + guard let stepId = stepId else { return } + // Find the card with matching stepId and scroll to it + if let card = cards.first(where: { $0.stepId == stepId }) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + withAnimation(.easeInOut(duration: 0.4)) { + proxy.scrollTo(card.id, anchor: .center) + } + scrollToEditedSection = nil + } + } + } + } + } + + // MARK: - Redacted Loading Content + + @ViewBuilder + private var redactedLoadingContent: some View { + VStack(spacing: 12) { + // Skeleton AI Summary Card + RedactedSummaryCard() + .padding(.top, 16) + + // Skeleton Cards (show 4 placeholder cards) + ForEach(0..<4, id: \.self) { _ in + RedactedCanvasCard() + } + } + } + + // MARK: - Family Capsules Row + + @ViewBuilder + private func familyCapsulesRow(members: [FamilyMember]) -> some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(members, id: \.id) { member in + let isSelected = selectedMemberId == member.id + + HStack(spacing: 8) { + MemberAvatar.custom(member: member, size: 24, imagePadding: 0) + + Text(member.name) + .font(ManropeFont.medium.size(14)) + .foregroundStyle(isSelected ? .white : .grayScale150) + .lineLimit(1) + } + .padding(.horizontal, 8) + .frame(height: 36, alignment: .leading) + .background( + Capsule() + .fill(isSelected ? Color(hex: "#91B640") : Color(hex: "#F8F8F8")) + ) + .onTapGesture { + // Toggle selection: tap again to deselect and show all + if selectedMemberId == member.id { + selectedMemberId = nil + } else { + selectedMemberId = member.id + } + } + } + } + } + } + + + // MARK: - Scroll Tracking Background (Editing only) + + private var scrollTrackingBackground: some View { + GeometryReader { geo in + Color.clear + .onAppear { + scrollY = geo.frame(in: .named("editableCanvasScroll")).minY + prevValue = scrollY + maxScrollOffset = scrollY < 0 ? scrollY : 0 + } + .onChange(of: geo.frame(in: .named("editableCanvasScroll")).minY) { newValue in + scrollY = newValue + + if scrollY < 0 { + maxScrollOffset = min(maxScrollOffset, newValue) + + let scrollDelta = newValue - prevValue + let minScrollDelta: CGFloat = 5 + + if scrollDelta < -minScrollDelta { + isTabBarExpanded = false + } else if scrollDelta > minScrollDelta { + let bottomThreshold: CGFloat = 100 + if newValue > (maxScrollOffset + bottomThreshold) { + isTabBarExpanded = true + } + } + } else { + maxScrollOffset = 0 + } + + prevValue = newValue + } + } + } + + // MARK: - Event Handlers + + private func handleOnAppear() { + if case .onboarding(let flow) = mode { + store.onboardingFlowtype = flow + } + store.updateSectionCompletionStatus() + previousSectionIndex = store.currentSectionIndex + } + + private func handleOnDisappear() { + onDismiss?() + if mode == .editing && coordinator.isEditSheetPresented { + withAnimation(.easeInOut(duration: 0.2)) { + coordinator.isEditSheetPresented = false + } + } + } + + private func handleFoodNotesLoad() async { + Log.debug("UnifiedCanvasView", "Food notes load task triggered") + + await foodNotesStore.loadFoodNotesAll() + foodNotesStore.preparePreferencesForMember(selectedMemberId: familyStore.selectedMemberId) + didFinishInitialLoad = true + } + + private func handleSectionIndexChange(_ newIndex: Int) { + previousSectionIndex = newIndex + if mode.showTagBar { + scheduleScrollToCurrentSectionViews() + syncBottomSheetWithCurrentSection() + } + } + + private func handlePreferencesChange() { + store.updateSectionCompletionStatus() + + if isLoadingMemberPreferences { + Log.debug("UnifiedCanvasView", "Preferences updated during load, skipping save") + return + } + + guard !store.preferences.sections.isEmpty else { + Log.debug("UnifiedCanvasView", "Skipping save - preferences are empty") + return + } + + let changedSectionName = store.currentSection.name + Log.debug("UnifiedCanvasView", "Preferences changed, saving section \(changedSectionName)") + + Task { + guard !isLoadingMemberPreferences, !store.preferences.sections.isEmpty else { + return + } + + foodNotesStore.applyLocalPreferencesOptimistic() + foodNotesStore.updateFoodNotes() + + // Trigger scan history refresh when user navigates back to HomeView + await MainActor.run { + appState.needsScanHistoryRefresh = true + } + } + } + + private func handleMemberSwitch(_ newValue: UUID?) { + // Don't switch members before initial load completes - it would clear preferences + guard didFinishInitialLoad else { + Log.debug("UnifiedCanvasView", "Member switch ignored - initial load not complete") + return + } + Log.debug("UnifiedCanvasView", "Member switched to \(newValue?.uuidString ?? "Everyone")") + isLoadingMemberPreferences = true + foodNotesStore.preparePreferencesForMember(selectedMemberId: newValue) + isLoadingMemberPreferences = false + } + + private func handleBackButton() { + if coordinator.isEditSheetPresented { + withAnimation(.easeInOut(duration: 0.2)) { + coordinator.isEditSheetPresented = false + } + } + dismiss() + } + + // MARK: - Scroll Helpers (Onboarding) + + private func scheduleScrollToCurrentSectionViews() { + let currentSectionId = store.currentSection.id + cardScrollTarget = currentSectionId + tagBarScrollTarget = currentSectionId + } + + private func syncBottomSheetWithCurrentSection() { + guard case .mainCanvas = coordinator.currentCanvasRoute else { return } + guard let stepId = store.currentSection.screens.first?.stepId else { return } + + let targetRoute = BottomSheetRoute.onboardingStep(stepId: stepId) + if targetRoute != coordinator.currentBottomSheetRoute { + coordinator.navigateInBottomSheet(targetRoute) + } + } + + // MARK: - Scroll Helpers (Editing) + + private func scrollToTargetSectionIfNeeded(cards: [CanvasCardModel], proxy: ScrollViewProxy) { + guard !hasScrolledToTarget else { return } + guard let targetSectionName, !targetSectionName.isEmpty else { return } + guard didFinishInitialLoad else { return } + guard let targetCard = findTargetCard(cards: cards, targetSectionName: targetSectionName) else { return } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + withAnimation(.easeInOut(duration: 0.45)) { + let anchorPoint: UnitPoint = shouldCenterLifestyleNutrition ? .top : .center + proxy.scrollTo(targetCard.id, anchor: anchorPoint) + } + hasScrolledToTarget = true + } + } + + private func findTargetCard(cards: [CanvasCardModel], targetSectionName: String) -> CanvasCardModel? { + func norm(_ s: String) -> String { + s.lowercased() + .replacingOccurrences(of: " ", with: "") + .replacingOccurrences(of: "-", with: "") + .replacingOccurrences(of: "_", with: "") + } + + let target = norm(targetSectionName) + + if let match = cards.first(where: { norm($0.title) == target }) { + return match + } + + if let nutrition = cards.first(where: { norm($0.title).contains("nutrition") }) { + return nutrition + } + + return nil + } + + // MARK: - Member Name Helper + + private func memberName(for memberKey: String) -> String { + guard let family = familyStore.family else { return "Unknown" } + + // Check selfMember + if family.selfMember.id.uuidString.lowercased() == memberKey { + return family.selfMember.name + } + + // Check otherMembers + if let member = family.otherMembers.first(where: { $0.id.uuidString.lowercased() == memberKey }) { + return member.name + } + + return "Unknown" + } + + // MARK: - Edit Helpers (Editing) + + private func openEdit(for card: CanvasCardModel) { + if let sectionIndex = store.sections.firstIndex(where: { section in + section.screens.first?.stepId == card.stepId + }) { + coordinator.currentEditingSectionIndex = sectionIndex + store.currentSectionIndex = sectionIndex + } + coordinator.editingStepId = card.stepId + coordinator.editingMemberId = selectedMemberId // Pass selected member to edit sheet + withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { + coordinator.isEditSheetPresented = true + } + } +} + +// MARK: - Redacted Loading Components + +private struct RedactedSummaryCard: View { + var body: some View { + VStack(alignment: .leading, spacing: 12) { + // Header placeholder + HStack { + RoundedRectangle(cornerRadius: 4) + .fill(Color.grayScale40) + .frame(width: 100, height: 16) + Spacer() + } + + // Summary text placeholder lines + VStack(alignment: .leading, spacing: 8) { + RoundedRectangle(cornerRadius: 4) + .fill(Color.grayScale40) + .frame(height: 14) + RoundedRectangle(cornerRadius: 4) + .fill(Color.grayScale40) + .frame(width: 200, height: 14) + } + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(Color.white) + ) + .redacted(reason: .placeholder) + .shimmering() + } +} + +private struct RedactedCanvasCard: View { + var body: some View { + VStack(alignment: .leading, spacing: 16) { + // Title row placeholder + HStack { + Circle() + .fill(Color.grayScale40) + .frame(width: 24, height: 24) + + RoundedRectangle(cornerRadius: 4) + .fill(Color.grayScale40) + .frame(width: 120, height: 18) + + Spacer() + + RoundedRectangle(cornerRadius: 8) + .fill(Color.grayScale40) + .frame(width: 50, height: 28) + } + + // Chips placeholder + HStack(spacing: 8) { + ForEach(0..<3, id: \.self) { _ in + RoundedRectangle(cornerRadius: 12) + .fill(Color.grayScale40) + .frame(width: CGFloat.random(in: 60...100), height: 28) + } + } + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(Color.white) + ) + .redacted(reason: .placeholder) + .shimmering() + } +} + +// MARK: - Shimmer Effect +// ShimmerModifier is now in Utilities/ShimmerModifier.swift + +// MARK: - Preview + +//#Preview("Onboarding") { +// let webService = WebService() +// let onboarding = Onboarding(onboardingFlowtype: .individual) +// let foodNotesStore = FoodNotesStore(webService: webService, onboardingStore: onboarding) +// +// UnifiedCanvasView(mode: .onboarding(flow: .individual)) +// .environmentObject(onboarding) +// .environment(webService) +// .environment(foodNotesStore) +//} + +//#Preview("Editing") { +// let webService = WebService() +// let onboarding = Onboarding(onboardingFlowtype: .individual) +// let foodNotesStore = FoodNotesStore(webService: webService, onboardingStore: onboarding) +// +// UnifiedCanvasView(mode: .editing) +// .environmentObject(onboarding) +// .environment(webService) +// .environment(foodNotesStore) +//} + +#Preview("Redacted Loading") { + VStack(spacing: 16) { + RedactedSummaryCard() + RedactedCanvasCard() + RedactedCanvasCard() + } + .padding() + .background(Color.pageBackground) +} diff --git a/IngrediCheck/Views/CaptureView.swift b/IngrediCheck/Views/CaptureView.swift index 7a93b36f..8827becd 100644 --- a/IngrediCheck/Views/CaptureView.swift +++ b/IngrediCheck/Views/CaptureView.swift @@ -22,7 +22,10 @@ struct CaptureView: View { ImageCaptureView( capturedImages: $checkTabState.capturedImages, onSubmit: { - checkTabState.routes.append(.productImages(checkTabState.capturedImages)) + // Navigate to LabelAnalysisView with scanId + if let scanId = checkTabState.scanId { + checkTabState.routes.append(.productImages(scanId)) + } }, showClearButton: true, showTitle: false, diff --git a/IngrediCheck/Views/ChatBot/IngrediBotChatView.swift b/IngrediCheck/Views/ChatBot/IngrediBotChatView.swift new file mode 100644 index 00000000..4c653d7b --- /dev/null +++ b/IngrediCheck/Views/ChatBot/IngrediBotChatView.swift @@ -0,0 +1,749 @@ +// +// IngrediBotChatView.swift +// IngrediCheckPreview +// +// Created on 18/11/25. +// +// + +import SwiftUI + +struct IngrediBotChatView: View { + @Environment(AppNavigationCoordinator.self) private var coordinator + @Environment(AppState.self) private var appState + @Environment(WebService.self) private var webService + @Environment(ChatStore.self) private var chatStore + + // Optional parameters for context-aware chat + var scanId: String? = nil + var analysisId: String? = nil + var ingredientName: String? = nil + var feedbackId: String? = nil // For feedback follow-up context + var contextKeyOverride: String? = nil // Explicit context key (e.g., "food_notes" from editing screen) + + var onDismiss: (() -> Void)? = nil + + @State private var message: String = "" + @State private var messages: [ChatMessage] = [] + @State private var conversationId: String? = nil + @State private var visibleMessageIds: Set = [] + @State private var isStreaming: Bool = false + @State private var currentTurnId: String? = nil + @State private var isLoadingHistory: Bool = false + @State private var errorMessage: String? = nil + @FocusState private var isInputFocused: Bool + + /// Check if user is in onboarding flow (not on home or summary screens) + private var isOnboardingFlow: Bool { + coordinator.currentCanvasRoute != .home && + coordinator.currentCanvasRoute != .summaryJustMe && + coordinator.currentCanvasRoute != .summaryAddFamily + } + + /// Get the effective context key override - prefer coordinator's value over init parameter + private var effectiveContextKeyOverride: String? { + coordinator.aibotContextKeyOverride ?? contextKeyOverride + } + + private var contextKey: String { + // Check explicit context override first (e.g., "food_notes" from editing screen, "general_feedback" from quick action) + if let override = effectiveContextKeyOverride { return override } + if let feedbackId { return "feedback:\(feedbackId)" } + if let scanId { return "product_scan:\(scanId)" } + let isFoodNotes = coordinator.currentCanvasRoute == .summaryJustMe || + coordinator.currentCanvasRoute == .summaryAddFamily || + coordinator.currentCanvasRoute == .welcomeToYourFamily || + isOnboardingFlow + if isFoodNotes { return "food_notes" } + return "home" + } + + private func syncToStore() { + chatStore.update(for: contextKey) { + $0.messages = messages + $0.conversationId = conversationId + $0.visibleMessageIds = visibleMessageIds + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { +// HStack { +// +// Spacer() +// +// VStack(alignment: .center, spacing: 5) { +// Image("ai-magic") +// .resizable() +// .frame(width: 28, height: 28) +// +// Text("Asking with AI suggestions") +// .font(ManropeFont.medium.size(14)) +// .foregroundStyle(.grayScale110) +// } +// +// Spacer() +// +// } +// .padding(.top, 16) + + ScrollViewReader { proxy in + ScrollView { + VStack(alignment: .leading, spacing: 20) { + if isLoadingHistory { + HStack { + Spacer() + ProgressView() + .padding() + Spacer() + } + } + + // Display messages with animation + ForEach(messages) { msg in + if visibleMessageIds.contains(msg.id) { + ConversationBubble( + text: msg.text, + alignment: msg.isUser ? .trailing : .leading, + bubbleColor: msg.isUser ? Color(hex: "F4F4F4") : Color(hex: "75990E"), + textColor: msg.isUser ? .grayScale140 : .white, + isFirstForSide: false, + leadingIconName: msg.isUser ? nil : "ingrediBot" + ) + .id(msg.id) + .transition(.asymmetric( + insertion: .scale(scale: 0.8, anchor: msg.isUser ? .bottomTrailing : .bottomLeading) + .combined(with: .opacity) + .combined(with: .offset(y: 20)), + removal: .opacity + )) + } + } + + // Show typing indicator when streaming + if isStreaming { + TypingBubble(side: .bot) + } + + // Bottom anchor for reliable scrolling + Color.clear + .frame(height: 1) + .id("bottomAnchor") + + // Show error message if any + if let error = errorMessage { + ConversationBubble( + text: "❌ Error: \(error)", + isFirstForSide: true, + leadingIconName: "ingrediBot" + ) + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 24) + .contentShape(Rectangle()) + .onTapGesture { + // Dismiss keyboard when tapping on chat area + isInputFocused = false + } + } + .onChange(of: messages.count) { _ in + // Scroll to bottom when new messages arrive + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + withAnimation { + proxy.scrollTo("bottomAnchor", anchor: .bottom) + } + } + } + .onChange(of: isStreaming) { streaming in + // Scroll to show typing indicator when streaming starts + if streaming { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + withAnimation { + proxy.scrollTo("bottomAnchor", anchor: .bottom) + } + } + } + } + .onChange(of: visibleMessageIds.count) { _ in + // Scroll when messages become visible (after animation) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + withAnimation { + proxy.scrollTo("bottomAnchor", anchor: .bottom) + } + } + } + } + + HStack(alignment: .bottom, spacing: 12) { + TextField("\"Type your answer...\"", text: $message, axis: .vertical) + .focused($isInputFocused) + .textFieldStyle(.plain) + .padding(.vertical, 12) + .padding(.horizontal, 16) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 20) + .stroke(Color.gray.opacity(0.2)) + ) + + Button { + sendMessage() + } label: { + Image(systemName: "paperplane.fill") + .resizable() + .scaledToFit() + .frame(width: 22, height: 22) + .foregroundStyle(.white) + .padding() + .background( + Circle().fill( + LinearGradient( + colors: [Color(hex: "9DCF10"), Color(hex: "6B8E06")], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .shadow(.inner(color: Color(hex: "EDEDED").opacity(0.25), radius: 7.5, x: 2, y: 9)) + .shadow(.inner(color: Color(hex: "72930A"), radius: 5.7, x: 0, y: 4)) + .shadow( + .drop(color: Color(hex: "C5C5C5").opacity(0.57), radius: 11, x: 0, y: 4) + ) + ) + ) + } + } + } + .padding(.horizontal, 20) + .padding(.top, 8) + .padding(.bottom, 20) + .onAppear { + let conv = chatStore.conversation(for: contextKey) + if !conv.messages.isEmpty { + // Restore conversation from store (reopening after dismiss) + messages = conv.messages + conversationId = conv.conversationId + visibleMessageIds = conv.visibleMessageIds + } else { + // First open for this context β€” generate & animate greetings + let greetings = generateInitialGreeting() + messages = greetings + + // Animate each greeting bubble with staggered delay + for (index, greeting) in greetings.enumerated() { + DispatchQueue.main.asyncAfter(deadline: .now() + Double(index) * 0.4) { + withAnimation(.spring(response: 0.5, dampingFraction: 0.7)) { + _ = visibleMessageIds.insert(greeting.id) + } + } + } + } + } + .onDisappear { + syncToStore() + } + .dismissKeyboardOnTap() + .overlay(alignment: .topTrailing) { + // Only show Skip button during onboarding flow + if isOnboardingFlow { + Button("Skip") { + AnalyticsService.shared.trackOnboarding("Onboarding Chat Dismissed", properties: [ + "flow_type": coordinator.onboardingFlow.rawValue + ]) + + if let onDismiss { + onDismiss() + } else { + coordinator.dismissChatBot() + } + + if coordinator.onboardingFlow == .individual { + coordinator.showCanvas(.summaryJustMe) + } else { + coordinator.showCanvas(.summaryAddFamily) + } + } + .font(NunitoFont.semiBold.size(14)) + .foregroundStyle(.grayScale120) + .padding(.top, 16) + .padding(.trailing, 16) + } + } + } + + // MARK: - Initial Greeting + + private func generateInitialGreeting() -> [ChatMessage] { + // Check for general feedback context (from home screen quick action) + if effectiveContextKeyOverride == "general_feedback" { + return [ + ChatMessage( + id: "greeting_1", + isUser: false, + text: "Hey there!", + timestamp: Date() + ), + ChatMessage( + id: "greeting_2", + isUser: false, + text: "Would you like to give me some feedback to help improve your experience?", + timestamp: Date() + ) + ] + } + + let context = buildContext() + + switch context { + case _ as DTO.FeedbackContext: + // Feedback context (product, ingredient, or image feedback) + return [ + ChatMessage( + id: "greeting_1", + isUser: false, + text: "Hi! I see you have feedback about the analysis.", + timestamp: Date() + ), + ChatMessage( + id: "greeting_2", + isUser: false, + text: "What didn't seem right? I'm here to help improve the accuracy.", + timestamp: Date() + ) + ] + + case _ as DTO.ProductScanContext: + // Product scan context + return [ + ChatMessage( + id: "greeting_1", + isUser: false, + text: "Hi! I see you're looking at a product.", + timestamp: Date() + ), + ChatMessage( + id: "greeting_2", + isUser: false, + text: "I can help explain ingredients, check dietary compatibility, or answer questions about this product.", + timestamp: Date() + ) + ] + + case _ as DTO.FoodNotesContext: + // Food notes context + return [ + ChatMessage( + id: "greeting_1", + isUser: false, + text: "Hi! I'm here to help with your food preferences.", + timestamp: Date() + ), + ChatMessage( + id: "greeting_2", + isUser: false, + text: "Would you like to add or update dietary preferences, or learn how IngrediCheck analyzes products for you?", + timestamp: Date() + ) + ] + + default: + // Home context (default) + return [ + ChatMessage( + id: "greeting_1", + isUser: false, + text: "Hi! I'm IngrediBot, your food assistant.", + timestamp: Date() + ), + ChatMessage( + id: "greeting_2", + isUser: false, + text: "How can I help you today? Ask me about:\n- Understanding ingredients\n- Setting up dietary preferences\n- How to scan products", + timestamp: Date() + ) + ] + } + } + + // MARK: - Context Building + + private func buildContext() -> any Codable { + // Build context based on provided parameters or current screen + // Priority: general_feedback > feedbackId > scanId > food_notes > home + + // Check for general feedback context (from quick action) + if effectiveContextKeyOverride == "general_feedback" { + return ChatContextBuilder.buildFeedbackContext() + } + + // If feedbackId is provided, use feedback context + if let feedbackId = feedbackId { + return ChatContextBuilder.buildFeedbackContext(feedbackId: feedbackId) + } + + // If scanId is provided (without feedbackId), use product_scan context + if let scanId = scanId { + return ChatContextBuilder.buildProductScanContext(scanId: scanId) + } + + // Check if we're on food notes screen or in onboarding flow + // During onboarding, user is setting up food preferences, so use food_notes context + let isFoodNotes = coordinator.currentCanvasRoute == .summaryJustMe || + coordinator.currentCanvasRoute == .summaryAddFamily || + coordinator.currentCanvasRoute == .welcomeToYourFamily || + isOnboardingFlow + + if isFoodNotes { + return ChatContextBuilder.buildFoodNotesContext() + } + + // Default to home context + return ChatContextBuilder.buildHomeContext() + } + + // MARK: - Send Message + + private func sendMessage() { + let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, !isStreaming else { return } + + let userMessage = trimmed + isInputFocused = false // Dismiss keyboard first + message = "" // Then clear message + errorMessage = nil + + // Add user message immediately with animation + let userMsg = ChatMessage( + id: UUID().uuidString, + isUser: true, + text: userMessage, + timestamp: Date() + ) + messages.append(userMsg) + withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) { + _ = visibleMessageIds.insert(userMsg.id) + } + syncToStore() + + // Build context + let context = buildContext() + + // Start streaming + isStreaming = true + currentTurnId = nil + + Task { + do { + let contextJson = try ChatContextBuilder.encodeContext(context) + + try await webService.streamChatMessage( + message: userMessage, + context: context, + conversationId: conversationId, + onThinking: { convId, turnId in + Task { @MainActor in + self.conversationId = convId + self.currentTurnId = turnId + // Typing indicator is already shown via isStreaming + } + }, + onResponse: { convId, turnId, response in + Task { @MainActor in + self.conversationId = convId + self.currentTurnId = turnId + self.isStreaming = false + + // Add bot response with animation + let botMsg = ChatMessage( + id: turnId, + isUser: false, + text: response, + timestamp: Date() + ) + self.messages.append(botMsg) + withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) { + _ = self.visibleMessageIds.insert(botMsg.id) + } + self.syncToStore() + } + }, + onError: { error, convId, turnId in + Task { @MainActor in + self.isStreaming = false + self.conversationId = convId + self.currentTurnId = turnId + self.errorMessage = error.message + + // Add error message to chat with animation + let errorMsg = ChatMessage( + id: UUID().uuidString, + isUser: false, + text: "❌ Error: \(error.message)", + timestamp: Date() + ) + self.messages.append(errorMsg) + withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) { + _ = self.visibleMessageIds.insert(errorMsg.id) + } + self.syncToStore() + } + } + ) + } catch { + Task { @MainActor in + self.isStreaming = false + self.errorMessage = error.localizedDescription + + // Add error message to chat with animation + let errorMsg = ChatMessage( + id: UUID().uuidString, + isUser: false, + text: "❌ Error: \(error.localizedDescription)", + timestamp: Date() + ) + self.messages.append(errorMsg) + withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) { + _ = self.visibleMessageIds.insert(errorMsg.id) + } + self.syncToStore() + } + } + } + } + + // MARK: - Load Conversation History + + private func loadConversationHistoryIfNeeded() { + guard let convId = conversationId, !isLoadingHistory else { return } + + isLoadingHistory = true + + Task { + do { + let conversation = try await webService.getConversation(conversationId: convId) + + await MainActor.run { + // Convert ConversationTurn to ChatMessage + var loadedMessages: [ChatMessage] = [] + + // Create ISO8601 formatter with fractional seconds support + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + for turn in conversation.turns { + let timestamp = formatter.date(from: turn.created_at) ?? Date() + + // Add user message + if !turn.user_message.isEmpty { + loadedMessages.append(ChatMessage( + id: "\(turn.turn_id)_user", + isUser: true, + text: turn.user_message, + timestamp: timestamp + )) + } + + // Add assistant response if available + if let response = turn.assistant_response, !response.isEmpty { + loadedMessages.append(ChatMessage( + id: turn.turn_id, + isUser: false, + text: response, + timestamp: timestamp + )) + } + } + + self.messages = loadedMessages + // Make all loaded messages visible immediately + self.visibleMessageIds = Set(loadedMessages.map { $0.id }) + self.isLoadingHistory = false + self.syncToStore() + } + } catch { + await MainActor.run { + self.isLoadingHistory = false + // Don't show error for 404 - just means no conversation yet + if let networkError = error as? NetworkError, + case .notFound = networkError { + // 404 is expected for new conversations - don't show error + } else { + self.errorMessage = error.localizedDescription + } + } + } + } + } +} + +private struct ConversationBubble: View { + var text: String + var alignment: Alignment = .leading + var bubbleColor: Color = Color(hex: "75990E") + var textColor: Color = .white + var isFirstForSide: Bool = false + var leadingIconName: String? + + /// Parses the text as Markdown and returns an AttributedString + private var markdownText: AttributedString { + do { + return try AttributedString(markdown: text, options: AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace)) + } catch { + return AttributedString(text) + } + } + + var body: some View { + let isSender = alignment == .trailing + let baseRadius: CGFloat = 18 + let radii = CornerRadii( + topLeft: (!isSender && isFirstForSide) ? 0 : baseRadius, + topRight: baseRadius, + bottomLeft: isSender ? baseRadius : baseRadius, + bottomRight: (isSender && isFirstForSide) ? 0 : baseRadius + ) + + HStack(alignment: .top, spacing: 8) { + if alignment == .trailing { Spacer() } + + if alignment == .leading { + if let iconName = leadingIconName { + Image(iconName) + .resizable() + .scaledToFit() + .frame(width: 32, height: 32) + } else { + Color.clear + .frame(width: 32, height: 32) + } + } + + Text(markdownText) + .padding(12) + .font(ManropeFont.regular.size(14)) + .foregroundStyle(textColor) + .background( + RoundedCornerShape(radii: radii) + .fill(bubbleColor) + ) + .tint(textColor.opacity(0.8)) // For Markdown links + + if alignment == .leading { Spacer() } + } + } +} + +private struct TypingBubble: View { + enum Side { + case bot + case user + } + + var side: Side + @State private var animate = false + + var body: some View { + HStack(alignment: .top, spacing: 8) { + if side == .bot { + Image("ingrediBot") + .resizable() + .scaledToFit() + .frame(width: 32, height: 32) + } else { + Spacer() + } + + HStack(spacing: 8) { + ForEach(0..<3) { index in + Circle() + .fill(Color.gray.opacity(0.5)) + .frame(width: 8, height: 8) + .scaleEffect(animate ? 1 : 0.6) + .animation( + .easeInOut(duration: 0.6) + .repeatForever(autoreverses: true) + .delay(Double(index) * 0.15), + value: animate + ) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(side == .bot ? Color(hex: "F4F4F4") : Color(hex: "75990E").opacity(0.2)) + ) + + if side == .bot { + Spacer() + } + } + .frame(maxWidth: .infinity, alignment: side == .bot ? .leading : .trailing) + .onAppear { animate = true } + .onDisappear { animate = false } + } +} + +#Preview("IngrediBotChatView") { + NavigationStack { + IngrediBotChatView() + .environment(AppNavigationCoordinator()) + .environment(AppState()) + .environment(ChatStore()) + } +} + +#Preview("ConversationBubble") { + ConversationBubble(text: "Hello!!", isFirstForSide: true) +} + +private struct CornerRadii { + var topLeft: CGFloat + var topRight: CGFloat + var bottomLeft: CGFloat + var bottomRight: CGFloat +} + +private struct RoundedCornerShape: Shape { + var radii: CornerRadii + + func path(in rect: CGRect) -> Path { + var path = Path() + + let tl = max(0, min(min(rect.width, rect.height)/2, radii.topLeft)) + let tr = max(0, min(min(rect.width, rect.height)/2, radii.topRight)) + let bl = max(0, min(min(rect.width, rect.height)/2, radii.bottomLeft)) + let br = max(0, min(min(rect.width, rect.height)/2, radii.bottomRight)) + + path.move(to: CGPoint(x: rect.minX + tl, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.maxX - tr, y: rect.minY)) + path.addArc(center: CGPoint(x: rect.maxX - tr, y: rect.minY + tr), + radius: tr, + startAngle: .degrees(-90), + endAngle: .degrees(0), + clockwise: false) + + path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - br)) + path.addArc(center: CGPoint(x: rect.maxX - br, y: rect.maxY - br), + radius: br, + startAngle: .degrees(0), + endAngle: .degrees(90), + clockwise: false) + + path.addLine(to: CGPoint(x: rect.minX + bl, y: rect.maxY)) + path.addArc(center: CGPoint(x: rect.minX + bl, y: rect.maxY - bl), + radius: bl, + startAngle: .degrees(90), + endAngle: .degrees(180), + clockwise: false) + + path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + tl)) + path.addArc(center: CGPoint(x: rect.minX + tl, y: rect.minY + tl), + radius: tl, + startAngle: .degrees(180), + endAngle: .degrees(270), + clockwise: false) + + path.closeSubpath() + return path + } +} diff --git a/IngrediCheck/Views/ChatBot/IngrediBotView.swift b/IngrediCheck/Views/ChatBot/IngrediBotView.swift new file mode 100644 index 00000000..1d5ff7b0 --- /dev/null +++ b/IngrediCheck/Views/ChatBot/IngrediBotView.swift @@ -0,0 +1,136 @@ +// +// IngrediBotView.swift +// IngrediCheckPreview +// +// Created by Gunjan Haldar on 30/10/25. +// + +import SwiftUI + +struct IngrediBotView: View { + @Environment(AppNavigationCoordinator.self) private var coordinator + @State var other: Bool = true + var body: some View { + VStack(spacing: 0) { + + // Bot illustration + Image("ingrediBot") + .resizable() + .scaledToFit() + .frame(width: 187, height: 176) + .rotationEffect(Angle(degrees: 10)) + + // Greeting line + ( + Text("Hey! πŸ‘‹ I'm ") + .font(NunitoFont.semiBold.size(16)) + .foregroundStyle(.grayScale100) + + Text("IngrediBot,") + .font(NunitoFont.semiBold.size(16)) + .foregroundStyle(.primary700) + ) + .padding(.top, 4) + + // Title + Text("How about making food choices easier together?") + .font(NunitoFont.bold.size(20)) + .multilineTextAlignment(.center) + .foregroundStyle(.grayScale150) + .padding(.top, 12) + .padding(.bottom, 40) + + Text("Shall we get started?") + .font(NunitoFont.medium.size(20)) + .foregroundStyle(.grayScale110) + .padding(.bottom, 8) + + // Sub header + if other { + Group { + Text("I noticed you selected") + .font(NunitoFont.regular.size(14)) + .foregroundStyle(.grayScale110) + + + Text(" \"Other\" ") + .font(NunitoFont.bold.size(14)) + .foregroundStyle(.grayScale140) + + + Text("earlier, that's great!\nCould you tell me a bit more about it?") + .font(NunitoFont.regular.size(14)) + .foregroundStyle(.grayScale110) + } + .multilineTextAlignment(.center) + } else { + Text("\"Tell me a bit about what kind of food experience you'd love here.\"") + .font(NunitoFont.regular.size(14)) + .foregroundStyle(.grayScale110) + .multilineTextAlignment(.center) + } + + + // Action buttons + HStack(spacing: 12) { + SecondaryButton( + title: "Maybe later", + width: 159, + takeFullWidth: false, + action: { + let isOnboarding = coordinator.currentCanvasRoute != .home && coordinator.currentCanvasRoute != .summaryJustMe && coordinator.currentCanvasRoute != .summaryAddFamily + + if isOnboarding { + AnalyticsService.shared.trackOnboarding("Onboarding Chat Skipped", properties: [ + "flow_type": coordinator.onboardingFlow.rawValue + ]) + if coordinator.onboardingFlow == .individual { + coordinator.showCanvas(.summaryJustMe) + } else { + coordinator.showCanvas(.summaryAddFamily) + } + } else { + coordinator.navigateInBottomSheet(.homeDefault) + } + } + ) + + Button { + coordinator.navigateInBottomSheet(.chatConversation) + } label: { + GreenCapsule(title: "Yes, let's go", icon: nil, width: 152, height: 52) + } + .buttonStyle(.plain) + } + .padding(.top, 33) + + Text("No problem! You can come back anytime β€” I'll be here when you're ready.") + + .font(ManropeFont.regular.size(12)) + .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + .foregroundStyle(Color(hex: "B6B6B6")) + .padding(.top, 13) + .padding(.horizontal, -2) + .padding(.horizontal, 1) + .padding(.bottom, 32 ) + + + + } + .padding(.horizontal, 20) + + } +} + +#Preview("Default") { + IngrediBotView() + .environment(AppNavigationCoordinator()) +} + +#Preview("Other Selected") { + IngrediBotView(other: true) + .environment(AppNavigationCoordinator()) +} + +#Preview("Other Not Selected") { + IngrediBotView(other: false) + .environment(AppNavigationCoordinator()) +} diff --git a/IngrediCheck/Views/DietaryPreferencesAndRestrictions.swift b/IngrediCheck/Views/DietaryPreferencesAndRestrictions.swift new file mode 100644 index 00000000..2412789f --- /dev/null +++ b/IngrediCheck/Views/DietaryPreferencesAndRestrictions.swift @@ -0,0 +1,545 @@ +// +// DietaryPreferencesAndRestrictions.swift +// IngrediCheckPreview +// +// Created by Gunjan Haldar on 10/11/25. +// + +import SwiftUI + +struct DietaryPreferencesAndRestrictions: View { + let isFamilyFlow: Bool + @Environment(AppNavigationCoordinator.self) private var coordinator + + @State private var physicsController: PhysicsController? + @State private var hasStarted = true + + private let categories: [ChipCategory] = [ + .init(title: "Mediterranean", icon: "πŸ«’", gradientStart: UIColor(hex: "FFC978"), gradientEnd: UIColor(hex: "FF7A45"), isDummy: false), + .init(title: "Dairy Free", icon: "πŸ₯›", gradientStart: UIColor(hex: "A894FF"), gradientEnd: UIColor(hex: "6A6CFF"), isDummy: false), + .init(title: "Organic Only", icon: "πŸƒ", gradientStart: UIColor(hex: "FFB5D0"), gradientEnd: UIColor(hex: "FF7EA8"), isDummy: false), + .init(title: "Paleo", icon: "πŸ₯©", gradientStart: UIColor(hex: "B187FF"), gradientEnd: UIColor(hex: "6C6FFF"), isDummy: false), + .init(title: "Low Sugar", icon: "πŸ“", gradientStart: UIColor(hex: "FFB47E"), gradientEnd: UIColor(hex: "FF6F6F"), isDummy: false), + .init(title: "Vegetarian", icon: "πŸ₯¦", gradientStart: UIColor(hex: "8EE58B"), gradientEnd: UIColor(hex: "4BC76C"), isDummy: false), + .init(title: "Heart Health", icon: "πŸ«€", gradientStart: UIColor(hex: "FFE59D"), gradientEnd: UIColor(hex: "FFC857"), isDummy: false), + .init(title: "Molluscs", icon: "🐚", gradientStart: UIColor(hex: "FF9C7A"), gradientEnd: UIColor(hex: "FF5F63"), isDummy: false), + .init(title: "High Protein", icon: "πŸ—", gradientStart: UIColor(hex: "7ED4FF"), gradientEnd: UIColor(hex: "528FFF"), isDummy: false), + .init(title: "Celery", icon: "πŸ₯¬", gradientStart: UIColor(hex: "FFAF8C"), gradientEnd: UIColor(hex: "FF6B6B"), isDummy: false), + .init(title: "Low Fat", icon: "πŸ₯‘", gradientStart: UIColor(hex: "8FE7F5"), gradientEnd: UIColor(hex: "4ECDE0"), isDummy: false), + .init(title: "Gluten", icon: "🌾", gradientStart: UIColor(hex: "FFC488"), gradientEnd: UIColor(hex: "FF8F45"), isDummy: false), + .init(title: "", icon: "", gradientStart: .clear, gradientEnd: .clear, isDummy: true) + ] + + + + var body: some View { + VStack { + HStack { + VStack(alignment:.leading, spacing: 0) { + Text("Fine-Tune") + .font(ManropeFont.extraBold.size(36)) + .foregroundStyle(Color(hex: "D3D3D3")) + Text(" your Food") + .font(ManropeFont.extraBold.size(36)) + .foregroundStyle(Color(hex: "D3D3D3")) + Text("Choices!") + .font(ManropeFont.extraBold.size(36)) + .foregroundStyle(Color(hex: "D3D3D3")) + } + .multilineTextAlignment(.leading) + .padding(.top, 0) + + Spacer() + } + + // Physics container - give it a specific height to work with + PhysicsContainerView( + categories: categories, + hasStarted: $hasStarted, + physicsController: $physicsController + ) + .frame(maxWidth: .infinity) + .layoutPriority(1) // Give it priority to expand + + // falling top edge + LinearGradient(colors: [.black.opacity(0.4), .gray.opacity(0.5), .black.opacity(0.4)], startPoint: .leading, endPoint: .trailing) + .blur(radius: 4) + .frame(height: 2, alignment: .center) + .padding(.bottom, 24) + + Spacer(minLength: 220) + + } + .padding(.horizontal, 20) + .navigationBarBackButtonHidden(true) + .toolbar(.hidden, for: .navigationBar) + .onAppear { + coordinator.setCanvasRoute(.dietaryPreferencesAndRestrictions(isFamilyFlow: isFamilyFlow)) + } + } +} + +#Preview { + DietaryPreferencesAndRestrictions(isFamilyFlow: false) + .environment(AppNavigationCoordinator()) +} + +struct ChipCategory { + let title: String + let icon: String + let gradientStart: UIColor + let gradientEnd: UIColor + let isDummy: Bool +} + +struct DietaryPreferencesSheetContent: View { + let isFamilyFlow: Bool + let letsGoPressed: () -> Void + @Environment(FamilyStore.self) private var familyStore + + + var body: some View { + VStack(alignment: .center, spacing: 20) { + + VStack(alignment: .center, spacing: 8) { + Text("Personalize your Choices") + .font(NunitoFont.bold.size(22)) + .foregroundStyle(.grayScale150) + + Text("Let’s get started with you! We’ll create a profile just for you and guide you through personalized food tips.") + .multilineTextAlignment(.center) + .font(ManropeFont.medium.size(12)) + .foregroundStyle(.grayScale100) + } + +// Spacer() + + Button { + letsGoPressed() + } label: { + GreenCapsule(title: "Let's Go!", takeFullWidth: false) + } + .padding(.top, isFamilyFlow ? 8 : 32) + } + .padding(.vertical, isFamilyFlow ? 16 : 32) + .padding(.horizontal, 20) + .frame(height: 263) + } +} + +#Preview { + DietaryPreferencesSheetContent(isFamilyFlow: false) { + + } +} + + +// MARK: - Physics Container View +struct PhysicsContainerView: UIViewRepresentable { + let categories: [ChipCategory] + @Binding var hasStarted: Bool + @Binding var physicsController: PhysicsController? + + func makeUIView(context: Context) -> UIView { + let containerView = UIView() + containerView.backgroundColor = UIColor.clear + + return containerView + } + + func updateUIView(_ uiView: UIView, context: Context) { + if hasStarted && physicsController == nil { + // Create physics controller when simulation should start + let controller = PhysicsController(containerView: uiView, categories: categories) + physicsController = controller + + // Wait a bit more for layout to complete, then start simulation + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + controller.startSimulation() + } + } + + // Update bottom boundary when view size changes + if let controller = physicsController { + controller.updateBottomBoundary() + } + } +} + +// MARK: - Physics Controller +class PhysicsController: NSObject, UIDynamicAnimatorDelegate, UICollisionBehaviorDelegate { + private let containerView: UIView + private let categories: [ChipCategory] + private var animator: UIDynamicAnimator? + private var gravity: UIGravityBehavior? + private var collision: UICollisionBehavior? + private var itemBehavior: UIDynamicItemBehavior? + private var chips: [UIView] = [] + private var expectedChipCount: Int = 0 + private var createdChipCount: Int = 0 + private let hapticGenerator = UIImpactFeedbackGenerator(style: .light) + private var lastHapticTime: Date = Date.distantPast + private var hapticsEnabled: Bool = true + private var hapticFeedbackCount: Int = 0 + private let maxHapticFeedbackCount: Int = 3 + private var notificationObserver: NSObjectProtocol? + + static let stopHapticsNotification = Notification.Name("StopDietaryPreferencesHaptics") + + init(containerView: UIView, categories: [ChipCategory]) { + self.containerView = containerView + self.categories = categories + super.init() + hapticGenerator.prepare() + setupPhysics() + setupNotificationObserver() + } + + deinit { + if let observer = notificationObserver { + NotificationCenter.default.removeObserver(observer) + } + } + + private func setupNotificationObserver() { + notificationObserver = NotificationCenter.default.addObserver( + forName: Self.stopHapticsNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.stopHaptics() + } + } + + func stopHaptics() { + hapticsEnabled = false + collision?.collisionDelegate = nil + } + + private func setupPhysics() { + // Create dynamic animator + animator = UIDynamicAnimator(referenceView: containerView) + animator?.delegate = self + + // Create gravity + gravity = UIGravityBehavior() + gravity?.magnitude = 1.35 // Stronger gravity to avoid mid-air stalls + + // Create collision behavior (manual boundaries; no top so items can fall in) + collision = UICollisionBehavior() + collision?.translatesReferenceBoundsIntoBoundary = false + collision?.collisionDelegate = self + + // Create item behavior for bounciness and rotation + itemBehavior = UIDynamicItemBehavior() + itemBehavior?.elasticity = 0.35 // Slightly bouncier + itemBehavior?.friction = 0.18 // Lower friction so stacks settle + itemBehavior?.resistance = 0.02 // Very low air resistance so they keep moving + itemBehavior?.angularResistance = 0.02 + itemBehavior?.allowsRotation = true // Allow rotation on impact + + // Add behaviors to animator + if let gravity = gravity { animator?.addBehavior(gravity) } + if let collision = collision { animator?.addBehavior(collision) } + if let itemBehavior = itemBehavior { animator?.addBehavior(itemBehavior) } + + // Periodic nudge to prevent deadlocks in mid-air clusters + let tick = UIDynamicBehavior() + tick.action = { [weak self] in + guard let self = self, let itemBehavior = self.itemBehavior else { return } + let bottomY = self.containerView.bounds.height + var anyAboveFloor = false + var allResting = !self.chips.isEmpty + for view in self.chips { + let v = itemBehavior.linearVelocity(for: view) + let speed = hypot(v.x, v.y) + let aboveFloor = view.center.y < bottomY - 42 + if aboveFloor { anyAboveFloor = true } + if speed > 6 || aboveFloor { allResting = false } + // Nudge slow movers above the floor + if speed < 12 && aboveFloor { + let pushY: CGFloat = 140 + let pushX: CGFloat = CGFloat.random(in: -18...18) + itemBehavior.addLinearVelocity(CGPoint(x: pushX, y: pushY), for: view) + } + } + // Keep simulation alive until all created and resting + let allCreated = (self.createdChipCount >= self.expectedChipCount) + let mustKeepRunning = !(allCreated && allResting) + if mustKeepRunning, let animator = self.animator, animator.isRunning == false { + self.gravity?.magnitude = 1.5 + if let first = self.chips.first { + itemBehavior.addLinearVelocity(CGPoint(x: 0, y: 8), for: first) + } + } + } + animator?.addBehavior(tick) + } + + // MARK: - UIDynamicAnimatorDelegate + func dynamicAnimatorDidPause(_ animator: UIDynamicAnimator) { + // If animator paused but some items are still above the bottom, wake it with a tiny push + let bottomY = containerView.bounds.height + let stuck = chips.filter { $0.center.y < bottomY - 42 } + guard !stuck.isEmpty, let itemBehavior = itemBehavior else { return } + // Continuous push for a bit longer; enough to wake and keep moving + let push = UIPushBehavior(items: stuck, mode: .continuous) + push.pushDirection = CGVector(dx: 0, dy: 0.5) + animator.addBehavior(push) + // Also add a small linear velocity to the first to ensure motion + if let first = stuck.first { + itemBehavior.addLinearVelocity(CGPoint(x: 0, y: 40), for: first) + } + // Remove push after a short period + DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) { + animator.removeBehavior(push) + } + } + + // MARK: - UICollisionBehaviorDelegate + func collisionBehavior(_ behavior: UICollisionBehavior, beganContactFor item: UIDynamicItem, withBoundaryIdentifier identifier: NSCopying?, at p: CGPoint) { + // Trigger mild haptic feedback when chip hits the bottom surface + // Only if haptics are still enabled and we haven't exceeded the limit + guard hapticsEnabled, hapticFeedbackCount < maxHapticFeedbackCount else { return } + + // Throttle to prevent too many rapid haptics + if let boundaryIdentifier = identifier as? String, boundaryIdentifier == "bottomBoundary" { + let now = Date() + if now.timeIntervalSince(lastHapticTime) > 0.1 { // Throttle to max 10 per second + hapticGenerator.impactOccurred(intensity: 0.5) + lastHapticTime = now + hapticFeedbackCount += 1 + } + } + } + + func startSimulation() { + guard let gravity = gravity, let collision = collision else { return } + + // Update boundary first to ensure proper positioning + updateBottomBoundary() + + // Create chips with staggered delays + expectedChipCount = categories.count + createdChipCount = 0 + for (index, category) in categories.enumerated() { + var delay = Double.random(in: 0.05...0.2) + (Double(index) * 0.5) + + if index == expectedChipCount - 1 { + delay = 6.0 + } + + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + self.createChip(category: category, gravity: gravity, collision: collision) + } + } + } + + private func createChip(category: ChipCategory, gravity: UIGravityBehavior, collision: UICollisionBehavior) { + let chipView = createChipView(category: category) + containerView.addSubview(chipView) + chips.append(chipView) + createdChipCount += 1 + + // Random starting position ABOVE the screen + let screenWidth = containerView.bounds.width > 0 ? containerView.bounds.width : 375 + let randomX = CGFloat.random(in: 60...(screenWidth - 60)) + let startY: CGFloat = -100 // Start well above the screen + chipView.center = CGPoint(x: randomX, y: startY) + + // Force layout and compute best-fitting size from Auto Layout + chipView.setNeedsLayout() + chipView.layoutIfNeeded() + let fitting = chipView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) + let finalSize = CGSize(width: max(120, fitting.width), height: max(40, fitting.height)) + chipView.frame.size = finalSize + applyGradient(to: chipView, for: category) + + // Add random initial rotation + let randomRotation = CGFloat.random(in: -10...10) * .pi / 180 + chipView.transform = CGAffineTransform(rotationAngle: randomRotation) + + // Add to physics behaviors + gravity.addItem(chipView) + collision.addItem(chipView) + itemBehavior?.addItem(chipView) + + // Add random angular velocity for spinning effect + let randomAngularVelocity = CGFloat.random(in: -2...2) + itemBehavior?.addAngularVelocity(randomAngularVelocity, for: chipView) + + // Kick-start motion so a late chip definitely begins to fall + let initialVX = CGFloat.random(in: -40...40) + let initialVY: CGFloat = 260 + itemBehavior?.addLinearVelocity(CGPoint(x: initialVX, y: initialVY), for: chipView) + + // If animator is currently paused, apply a short continuous push to wake it + if let animator = animator, animator.isRunning == false { + let push = UIPushBehavior(items: [chipView], mode: .continuous) + push.pushDirection = CGVector(dx: 0, dy: 0.7) + animator.addBehavior(push) + // Keep push a bit longer so it doesn't stop immediately + DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { + animator.removeBehavior(push) + } + } + +// print("Created chip: \(category.title) at start position: \(chipView.center), container bounds: \(containerView.bounds)") + } + + private func createChipView(category: ChipCategory) -> UIView { + let chipView = UIView() + chipView.backgroundColor = .clear + chipView.layer.cornerRadius = 20 + chipView.layer.shadowColor = UIColor.black.cgColor + chipView.layer.shadowOffset = CGSize(width: 0, height: 4) + chipView.layer.shadowOpacity = 0.2 + chipView.layer.shadowRadius = 8 + + // Create label + let label = UILabel() + label.text = category.title + label.font = UIFont.systemFont(ofSize: 16, weight: .semibold) + label.textColor = .white + label.textAlignment = .center + label.numberOfLines = 1 + label.lineBreakMode = .byClipping // Prevent ellipsis + label.setContentHuggingPriority(.defaultLow, for: .horizontal) + label.setContentCompressionResistancePriority(.required, for: .horizontal) + label.setContentHuggingPriority(.defaultLow, for: .vertical) + + // Create icon (emoji) + let iconLabel = UILabel() + iconLabel.text = category.icon + iconLabel.font = UIFont.systemFont(ofSize: 18, weight: .regular) + iconLabel.textAlignment = .center + iconLabel.setContentHuggingPriority(.required, for: .horizontal) + iconLabel.setContentHuggingPriority(.required, for: .vertical) + + // Create stack view + var arrangedSubviews: [UIView] = [] + if !category.icon.isEmpty { + arrangedSubviews.append(iconLabel) + } + arrangedSubviews.append(label) + + let stackView = UIStackView(arrangedSubviews: arrangedSubviews) + stackView.axis = .horizontal + stackView.spacing = category.icon.isEmpty ? 0 : 6 + stackView.alignment = .center + stackView.distribution = .fill // Allow label to expand + stackView.setContentHuggingPriority(.defaultLow, for: .horizontal) + stackView.setContentCompressionResistancePriority(.required, for: .horizontal) + + chipView.addSubview(stackView) + stackView.translatesAutoresizingMaskIntoConstraints = false + + // Calculate text size to determine if we need more width + let textSize = category.title.size(withAttributes: [ + .font: UIFont.systemFont(ofSize: 16, weight: .semibold) + ]) + let iconWidth: CGFloat = category.icon.isEmpty ? 0 : 20 + let spacing: CGFloat = category.icon.isEmpty ? 0 : 8 + let padding: CGFloat = 32 // 16px on each side + let minWidth: CGFloat = 80 + let requiredWidth = iconWidth + spacing + textSize.width + padding + let finalWidth = max(minWidth, requiredWidth) + + // Dynamic sizing with calculated width + NSLayoutConstraint.activate([ + // Stack view constraints with 16px padding + stackView.leadingAnchor.constraint(equalTo: chipView.leadingAnchor, constant: 16), + stackView.trailingAnchor.constraint(equalTo: chipView.trailingAnchor, constant: -16), + stackView.topAnchor.constraint(equalTo: chipView.topAnchor, constant: 12), + stackView.bottomAnchor.constraint(equalTo: chipView.bottomAnchor, constant: -12), + + // No explicit width constraint; allow intrinsic width to determine size + ]) + + // Set intrinsic content size - allow chip to expand + chipView.setContentHuggingPriority(.defaultLow, for: .horizontal) + chipView.setContentHuggingPriority(.required, for: .vertical) + chipView.setContentCompressionResistancePriority(.required, for: .horizontal) + + if category.isDummy { + chipView.alpha = 0.0 + } + + return chipView + } + + func updateBottomBoundary() { + guard let collision = collision, containerView.bounds.height > 0 else { + print("Cannot update boundary - collision or container bounds not ready") + return + } + + // Remove existing custom boundaries if any + collision.removeBoundary(withIdentifier: "bottomBoundary" as NSCopying) + collision.removeBoundary(withIdentifier: "leftBoundary" as NSCopying) + collision.removeBoundary(withIdentifier: "rightBoundary" as NSCopying) + + // Add boundaries: left, right, and bottom only (no top boundary) + let width = containerView.bounds.width + let height = containerView.bounds.height + let bottomY = height + + print("Updating boundaries β€” bottomY: \(bottomY), width: \(width), height: \(height)") + + collision.addBoundary(withIdentifier: "leftBoundary" as NSCopying, + from: CGPoint(x: 0, y: -1000), + to: CGPoint(x: 0, y: bottomY)) + + collision.addBoundary(withIdentifier: "rightBoundary" as NSCopying, + from: CGPoint(x: width, y: -1000), + to: CGPoint(x: width, y: bottomY)) + + collision.addBoundary(withIdentifier: "bottomBoundary" as NSCopying, + from: CGPoint(x: 0, y: bottomY), + to: CGPoint(x: width, y: bottomY)) + } + + private func applyGradient(to view: UIView, for category: ChipCategory) { + let gradientLayer: CAGradientLayer + if let existing = view.layer.sublayers?.first(where: { $0.name == "chipGradient" }) as? CAGradientLayer { + gradientLayer = existing + } else { + gradientLayer = CAGradientLayer() + gradientLayer.name = "chipGradient" + gradientLayer.startPoint = CGPoint(x: 0, y: 0.5) + gradientLayer.endPoint = CGPoint(x: 1, y: 0.5) + view.layer.insertSublayer(gradientLayer, at: 0) + } + gradientLayer.colors = [category.gradientStart.cgColor, category.gradientEnd.cgColor] + gradientLayer.frame = view.bounds + gradientLayer.cornerRadius = view.layer.cornerRadius + } +} + +private extension UIColor { + convenience init(hex: String) { + var hexSanitized = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + if hexSanitized.count == 6 { + hexSanitized = "FF" + hexSanitized + } + + var int: UInt64 = 0 + Scanner(string: hexSanitized).scanHexInt64(&int) + + let a, r, g, b: UInt64 + switch hexSanitized.count { + case 8: + a = (int & 0xFF000000) >> 24 + r = (int & 0x00FF0000) >> 16 + g = (int & 0x0000FF00) >> 8 + b = int & 0x000000FF + default: + a = 255; r = 255; g = 255; b = 255 + } + + self.init(red: CGFloat(r) / 255, + green: CGFloat(g) / 255, + blue: CGFloat(b) / 255, + alpha: CGFloat(a) / 255) + } +} diff --git a/IngrediCheck/Views/EditableCanvasView.swift b/IngrediCheck/Views/EditableCanvasView.swift new file mode 100644 index 00000000..dab3d91c --- /dev/null +++ b/IngrediCheck/Views/EditableCanvasView.swift @@ -0,0 +1,817 @@ +// +// EditableCanvasView.swift +// IngrediCheckPreview +// +// Created by Gunjan Haldar on 15/11/25. +// + +import SwiftUI +import Observation + +struct EditableCanvasView: View { + @EnvironmentObject private var store: Onboarding + @Environment(\.dismiss) private var dismiss + @Environment(WebService.self) private var webService + @Environment(FamilyStore.self) private var familyStore + @Environment(AppNavigationCoordinator.self) private var navCoordinator + @Environment(AppState.self) private var appState + + // Optional: center a specific section when arriving here (e.g., Lifestyle/Nutrition) + let targetSectionName: String? + let onBack: (() -> Void)? + + @Environment(FoodNotesStore.self) private var foodNotesStore + @State private var didFinishInitialLoad: Bool = false + + let titleOverride: String? + let showBackButton: Bool + + @State private var tagBarScrollTarget: UUID? = nil + @State private var isProgrammaticChange: Bool = false + @State private var isLoadingMemberPreferences: Bool = false + @State private var isTabBarExpanded: Bool = true + @State private var scrollY: CGFloat = 0 + @State private var prevValue: CGFloat = 0 + @State private var maxScrollOffset: CGFloat = 0 + @State private var hasScrolledToTarget: Bool = false + @State private var headroomCollapsed: Bool = false + @State private var selectedMemberId: UUID? = nil // Track selected family member for filtering + + init(targetSectionName: String? = nil, titleOverride: String? = nil, showBackButton: Bool = true, onBack: (() -> Void)? = nil) { + self.targetSectionName = targetSectionName + self.titleOverride = titleOverride + self.showBackButton = showBackButton + self.onBack = onBack + } + + private var shouldCenterLifestyleNutrition: Bool { + guard let targetSectionName, targetSectionName.isEmpty == false else { return false } + let t = targetSectionName.lowercased().replacingOccurrences(of: " ", with: "") + return t.contains("lifestyle") || t.contains("nutrition") + } + + var body: some View { + ZStack(alignment: .bottom) { + mainContent + } + .modifier( + ConditionalBottomTabBar( + isEnabled: !navCoordinator.isEditSheetPresented, + gradientColors: [ + Color.white.opacity(0), + Color.white + ] + ) { + TabBar( + isExpanded: $isTabBarExpanded, + onRecentScansTap: { + // Navigate to Recent Scans view + appState.navigationPath.append(HistoryRouteItem.recentScansAll) + } + ) + .fixedSize(horizontal: false, vertical: true) + } + ) + .background(Color.white) + .navigationTitle(titleOverride ?? "Food Notes") + .navigationBarTitleDisplayMode(.inline) + .onDisappear { + onBack?() + } + .onAppear { + // Update completion status for all sections based on their data + store.updateSectionCompletionStatus() + } + .task { + Log.debug("EditableCanvasView", "Food notes load task triggered") + + // Fetch and load food notes data when view appears + await foodNotesStore.loadFoodNotesAll() + + // Prepare preferences for the current selection locally from the loaded data + foodNotesStore.preparePreferencesForMember(selectedMemberId: familyStore.selectedMemberId) + didFinishInitialLoad = true + } + .onChange(of: store.preferences) { _ in + // Update completion status whenever preferences change + store.updateSectionCompletionStatus() + + // If we were loading member/family preferences, this change came from a backend load. + // DO NOT save. + if isLoadingMemberPreferences { + print("[EditableCanvasView] Preferences updated during load, skipping save") + return + } + + // Capture the section that just changed so we don't lose it if the user navigates + // before the Task starts executing. + let changedSectionName = store.currentSection.name + let changedSections: Set = [changedSectionName] + + print("[EditableCanvasView] Preferences changed, saving section \(changedSectionName) immediately") + + // Optimistically update the canvas summary view from local preferences for this member. + foodNotesStore.applyLocalPreferencesOptimistic() + + foodNotesStore.updateFoodNotes() + + // Trigger scan history refresh when user navigates back to HomeView + appState.needsScanHistoryRefresh = true + } + .onChange(of: familyStore.selectedMemberId) { newValue in + // Don't switch members before initial load completes - it would clear preferences + guard didFinishInitialLoad else { + print("[EditableCanvasView] Member switch ignored - initial load not complete") + return + } + // When switching members, prepare preferences locally from associations. + print("[EditableCanvasView] Member switched to \(newValue?.uuidString ?? "Everyone"), preparing local preferences") + + // Mark as loading to prevent the onChange(of: preferences) from triggering a sync + // for the newly loaded member's existing state. + isLoadingMemberPreferences = true + foodNotesStore.preparePreferencesForMember(selectedMemberId: newValue) + isLoadingMemberPreferences = false + } + .onChange(of: navCoordinator.isEditSheetPresented) { oldValue, newValue in + // When edit sheet is dismissed, refresh canvas to show updated selections (only after initial load) + if oldValue == true && newValue == false && didFinishInitialLoad { + foodNotesStore.preparePreferencesForMember(selectedMemberId: selectedMemberId) + } + } + .onDisappear { + // Dismiss bottom sheet when view disappears (handles both back button and system swipe gesture) + if navCoordinator.isEditSheetPresented { + withAnimation(.easeInOut(duration: 0.2)) { + navCoordinator.isEditSheetPresented = false + } + } + } + } + + private func icon(for stepId: String) -> String { + if let step = store.step(for: stepId), + let icon = step.header.iconURL, + icon.isEmpty == false { + return icon + } + return "allergies" + } + + // Helper function to get member identifiers for an item + // Returns "Everyone" or member UUID strings for use in ChipMemberAvatarView + private func getMemberIdentifiers(for sectionName: String, itemName: String) -> [String] { + guard let memberIds = foodNotesStore.itemMemberAssociations[sectionName]?[itemName] else { + return [] + } + + // Return member IDs directly (already UUID strings or "Everyone") + // ChipMemberAvatarView will resolve these to FamilyMember objects + return memberIds + } + + private func chips(for stepId: String, sectionKey: String) -> [ChipsModel]? { + guard let step = store.step(for: stepId) else { return nil } + let sectionName = step.header.name + + // Use canvasPreferences so scroll cards always show the union view + // (Everyone + all members) and do not change when switching member. + guard let value = foodNotesStore.canvasPreferences.sections[sectionName], + case .list(let items) = value else { + return nil + } + + // Filter items by selected member if one is selected + let filteredItems: [String] + if let selectedMemberId = selectedMemberId { + // FoodNotesStore stores member keys lowercased. + let memberIdString = selectedMemberId.uuidString.lowercased() + filteredItems = items.filter { itemName in + // IMPORTANT: itemMemberAssociations is keyed by the *card/section name* shown in UI, + // not necessarily step.header.name. Use sectionKey to match what's used in cards. + if let memberIds = foodNotesStore.itemMemberAssociations[sectionKey]?[itemName] { + // Only show items explicitly associated with this member. + // Exclude any items that are also tagged for "Everyone". + return memberIds.contains(memberIdString) && !memberIds.contains("Everyone") + } + return false + } + } else { + // No member selected, show all items (union view) + filteredItems = items + } + + // Get icons from step options + let options = step.content.options ?? [] + return filteredItems.compactMap { itemName -> ChipsModel? in + if let option = options.first(where: { $0.name == itemName }) { + return ChipsModel(name: option.name, icon: option.icon) + } + return ChipsModel(name: itemName, icon: nil) + } + } + + private func sectionedChips(for stepId: String, sectionKey: String) -> [SectionedChipModel]? { + guard let step = store.step(for: stepId) else { return nil } + let sectionName = step.header.name + + // Use canvasPreferences for union view + guard let value = foodNotesStore.canvasPreferences.sections[sectionName], + case .nested(let nestedDict) = value else { + return nil + } + + // Type-2 steps use subSteps, type-3 steps use regions. Handle both. + var sections: [SectionedChipModel] = [] + + // Helper to filter items by selected member + let filterItems: ([String]) -> [String] = { items in + guard let selectedMemberId = selectedMemberId else { return items } + // FoodNotesStore stores member keys lowercased. + let memberIdString = selectedMemberId.uuidString.lowercased() + return items.filter { itemName in + // IMPORTANT: itemMemberAssociations is keyed by the *card/section name* shown in UI. + if let memberIds = foodNotesStore.itemMemberAssociations[sectionKey]?[itemName] { + return memberIds.contains(memberIdString) && !memberIds.contains("Everyone") + } + return false + } + } + + if let subSteps = step.content.subSteps { + // MARK: Type-2 (Avoid / Lifestyle / Nutrition-style) + for subStep in subSteps { + guard let selectedItems = nestedDict[subStep.title], + !selectedItems.isEmpty else { + continue + } + + // Filter items by selected member + let filteredItems = filterItems(selectedItems) + guard !filteredItems.isEmpty else { continue } + + // Map selected items to ChipsModel with icons + let selectedChips: [ChipsModel] = filteredItems.compactMap { itemName in + if let option = subStep.options?.first(where: { $0.name == itemName }) { + return ChipsModel(name: option.name, icon: option.icon) + } + return ChipsModel(name: itemName, icon: nil) + } + + if !selectedChips.isEmpty { + sections.append( + SectionedChipModel( + title: subStep.title, + subtitle: subStep.description, + chips: selectedChips + ) + ) + } + } + } else if let regions = step.content.regions { + // MARK: Type-3 (Region-style) + for region in regions { + guard let selectedItems = nestedDict[region.name], + !selectedItems.isEmpty else { + continue + } + + // Filter items by selected member + let filteredItems = filterItems(selectedItems) + guard !filteredItems.isEmpty else { continue } + + let selectedChips: [ChipsModel] = filteredItems.compactMap { itemName in + if let option = region.subRegions.first(where: { $0.name == itemName }) { + return ChipsModel(name: option.name, icon: option.icon) + } + return ChipsModel(name: itemName, icon: nil) + } + + if !selectedChips.isEmpty { + sections.append( + SectionedChipModel( + title: region.name, + subtitle: nil, + chips: selectedChips + ) + ) + } + } + } + + return sections.isEmpty ? nil : sections + } + + private func selectedCards() -> [EditableCanvasCardModel] { + var cards: [EditableCanvasCardModel] = [] + + for section in store.sections { + guard let stepId = section.screens.first?.stepId else { continue } + let rawChips = chips(for: stepId, sectionKey: section.name) + let rawGroupedChips = sectionedChips(for: stepId, sectionKey: section.name) + + let chips = (rawChips?.isEmpty == false) ? rawChips : nil + let groupedChips = (rawGroupedChips?.isEmpty == false) ? rawGroupedChips : nil + + cards.append( + EditableCanvasCardModel( + id: section.id, + title: section.name, + icon: icon(for: stepId), + stepId: stepId, + chips: chips, + sectionedChips: groupedChips + ) + ) + } + + return cards + } + + private func openEditSheetForCurrentSection() { + if let stepId = store.currentSection.screens.first?.stepId { + withAnimation(.easeInOut(duration: 0.2)) { + navCoordinator.currentEditingSectionIndex = store.currentSectionIndex + navCoordinator.editingStepId = stepId + navCoordinator.isEditSheetPresented = true + } + } + } + + private func openEditSheetForSection(at index: Int) { + guard store.sections.indices.contains(index), + let stepId = store.sections[index].screens.first?.stepId else { return } + + withAnimation(.easeInOut(duration: 0.2)) { + navCoordinator.currentEditingSectionIndex = index + navCoordinator.editingStepId = stepId + navCoordinator.isEditSheetPresented = true + } + } + + // MARK: - Food Notes API Integration + // All food notes logic is now handled by FoodNotesStore +} + +struct EditableCanvasCardModel: Identifiable { + let id: UUID + let title: String + let icon: String + let stepId: String + let chips: [ChipsModel]? + let sectionedChips: [SectionedChipModel]? +} + +struct EditableCanvasCard: View { + @Environment(FamilyStore.self) private var familyStore + + var chips: [ChipsModel]? = nil + var sectionedChips: [SectionedChipModel]? = nil + var title: String = "Allergies" + var iconName: String = "allergies" + var onEdit: (() -> Void)? = nil + var itemMemberAssociations: [String: [String: [String]]] = [:] + var showFamilyIcons: Bool = true + var activeMemberId: UUID? = nil + + private var isEmptyState: Bool { + let hasSectioned = (sectionedChips?.isEmpty == false) + let hasChips = (chips?.isEmpty == false) + return !hasSectioned && !hasChips + } + + // Helper function to get member identifiers for an item + // Returns "Everyone" or member UUID strings for use in ChipMemberAvatarView + private func getMemberIdentifiers(for sectionName: String, itemName: String) -> [String] { + guard let memberIds = itemMemberAssociations[sectionName]?[itemName] else { + return [] + } + // When a specific member is selected in the capsules row, only show THAT member’s avatar + // (avoid showing other members who share the same preference). + if let activeMemberId { + // Force display to only the selected member (even if multiple members share the item). + return [activeMemberId.uuidString] + } + + // Otherwise, show all associated members ("Everyone" or member UUID strings). + return memberIds + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + HStack(spacing: 8) { + Image(iconName) + .resizable() + .renderingMode(.template) + .foregroundStyle(.grayScale110) + .frame(width: 18, height: 18) + + Text(title.capitalized) + .font(NunitoFont.semiBold.size(14)) + .foregroundStyle(.grayScale110) + } + .fontWeight(.semibold) + + Spacer() + + // Edit button + if let onEdit = onEdit { + Button(action: onEdit) { + HStack(spacing: 4) { + Image("pen-line") + .resizable() + .renderingMode(.template) // πŸ‘ˆ important + .foregroundStyle(.grayScale110) + .frame(width: 14, height: 14) + + Text("Edit") + .font(NunitoFont.medium.size(14)) + .foregroundStyle(Color(.grayScale110)) + } + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background( + Capsule() + .foregroundStyle(.grayScale30) + ) + } + } + } + + VStack(alignment: .leading) { + if isEmptyState { + VStack(spacing:4) { + ZStack { + Circle() + .fill(Color.grayScale30.opacity(0.5)) + .frame(width: 40, height:40) + Image("edit-pen") + .frame(width: 24, height:24) + .foregroundStyle(.grayScale80) + } + .padding(.top, 8) + + Text("Nothing added yet") + .font(NunitoFont.semiBold.size(14)) + .foregroundStyle(.grayScale100) + + Text("You can add details anytime by tapping Edit.") + .font(NunitoFont.regular.size(10)) + .foregroundStyle(.grayScale100) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, ) + .frame(height : 100) + } else if let sectionedChips = sectionedChips { + ForEach(sectionedChips) { section in + VStack(alignment: .leading, spacing: 8) { + Text(section.title) + .font(ManropeFont.semiBold.size(12)) + .foregroundStyle(.grayScale150) + + FlowLayout(horizontalSpacing: 8, verticalSpacing: 8) { + ForEach(section.chips) { chip in + IngredientsChips( + title: chip.name, + bgColor: .secondary200, + image: chip.icon, + familyList: showFamilyIcons ? getMemberIdentifiers(for: title, itemName: chip.name) : [], + outlined: false + ) + } + } + } + } + } else if let chips = chips { + FlowLayout(horizontalSpacing: 8, verticalSpacing: 8) { + ForEach(chips, id: \.id) { chip in + IngredientsChips( + title: chip.name, + bgColor: .secondary200, + image: chip.icon, + familyList: showFamilyIcons ? getMemberIdentifiers(for: title, itemName: chip.name) : [], + outlined: false + ) + } + } + } + } + } + .padding(.horizontal, 12) + .padding(.vertical, isEmptyState ? 8 : 16) + .background( + RoundedRectangle(cornerRadius: 16) + .foregroundStyle(.white) + ) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(lineWidth: 0.25) + .foregroundStyle(.grayScale60) + ) + } +} + +// MARK: - Misc Notes Card + +struct MiscNotesCard: View { + let notes: [String] + var onEdit: (() -> Void)? = nil + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + HStack(spacing: 8) { + Image(systemName: "doc.text") + .resizable() + .foregroundStyle(.grayScale110) + .frame(width: 16, height: 18) + + Text("Notes") + .font(NunitoFont.semiBold.size(14)) + .foregroundStyle(.grayScale110) + } + .fontWeight(.semibold) + + Spacer() + + if let onEdit = onEdit { + Button(action: onEdit) { + HStack(spacing: 4) { + Image(systemName: "bubble.left") + .resizable() + .foregroundStyle(.grayScale110) + .frame(width: 14, height: 14) + + Text("Chat") + .font(NunitoFont.medium.size(14)) + .foregroundStyle(Color(.grayScale110)) + } + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background( + Capsule() + .foregroundStyle(.grayScale30) + ) + } + } + } + + VStack(alignment: .leading, spacing: 6) { + ForEach(Array(notes.enumerated()), id: \.offset) { _, note in + HStack(alignment: .top, spacing: 6) { + Text("β€’") + .font(NunitoFont.regular.size(14)) + .foregroundStyle(.grayScale100) + Text(note) + .font(NunitoFont.regular.size(14)) + .foregroundStyle(.grayScale100) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } + .padding(.horizontal, 12) + .padding(.vertical, 16) + .background( + RoundedRectangle(cornerRadius: 16) + .foregroundStyle(.white) + ) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(lineWidth: 0.25) + .foregroundStyle(.grayScale60) + ) + } +} + +// MARK: - Edit Section Bottom Sheet + + +// MARK: - Extracted subviews (compiler perf) + +private extension EditableCanvasView { + @ViewBuilder + var mainContent: some View { + VStack(spacing: 0) { + // Family member selector capsules (only if user has a family) + if let family = familyStore.family, !family.otherMembers.isEmpty { + familyCapsulesRow(members: [family.selfMember] + family.otherMembers) + .padding(.top, 22) + .padding(.bottom, 16) + .padding(.horizontal, 16) + } + + // Selected items scroll view + if foodNotesStore.isLoadingFoodNotes { + loadingView + } else { + // Always show all sections (even empty) so users can add later. + let cards = selectedCards() + cardsScrollView(cards: cards) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } + + var loadingView: some View { + VStack(spacing: 16) { + Spacer() + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + .scaleEffect(1.2) + Text("Loading your preferences...") + .font(ManropeFont.medium.size(16)) + .foregroundStyle(.grayScale100) + .padding(.top, 8) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + var emptyStateView: some View { + VStack(spacing: 16) { + Spacer() + Text("No selections yet") + .font(ManropeFont.regular.size(16)) + .foregroundStyle(.grayScale100) + Text("Complete onboarding to see your preferences here") + .font(ManropeFont.regular.size(14)) + .foregroundStyle(.grayScale130) + Spacer() + } + } + + func cardsScrollView(cards: [EditableCanvasCardModel]) -> some View { + ScrollViewReader { proxy in + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: 12) { + ForEach(Array(cards.enumerated()), id: \.element.id) { index, card in + EditableCanvasCard( + chips: card.chips, + sectionedChips: card.sectionedChips, + title: card.title, + iconName: card.icon, + onEdit: { openEdit(for: card) }, + itemMemberAssociations: foodNotesStore.itemMemberAssociations ?? [:], + showFamilyIcons: familyStore.family?.otherMembers.isEmpty == false, + activeMemberId: selectedMemberId + ) + .padding(.top, index == 0 ? 16 : 0) + .id(card.id) + } + } + .padding(.horizontal, 16) + .padding(.bottom, navCoordinator.isEditSheetPresented ? UIScreen.main.bounds.height * 0.5 : 80) + .background(scrollTrackingBackground) + } + .coordinateSpace(name: "editableCanvasScroll") + .onAppear { + scrollToTargetSectionIfNeeded(cards: cards, proxy: proxy) + } + .onChange(of: didFinishInitialLoad) { _ in + scrollToTargetSectionIfNeeded(cards: cards, proxy: proxy) + } + } + } + + // MARK: - Family Capsules Row + @ViewBuilder + private func familyCapsulesRow(members: [FamilyMember]) -> some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(members, id: \.id) { member in + let isSelected = selectedMemberId == member.id + + HStack(spacing: 8) { + MemberAvatar.custom(member: member, size: 24, imagePadding: 0) + + Text(member.name) + .font(ManropeFont.medium.size(14)) + .foregroundStyle(isSelected ? .white : .grayScale150) + .lineLimit(1) + } + .padding(.horizontal, 8) + .frame(height: 36, alignment: .leading) + .background( + Capsule() + .fill(isSelected ? Color(hex: "#91B640") : Color(hex: "#F8F8F8")) + ) + .onTapGesture { + // Toggle selection: tap again to deselect and show all + if selectedMemberId == member.id { + selectedMemberId = nil + } else { + selectedMemberId = member.id + } + } + } + } + } + } + + private func scrollToTargetSectionIfNeeded(cards: [EditableCanvasCardModel], proxy: ScrollViewProxy) { + guard hasScrolledToTarget == false else { return } + guard let targetSectionName, targetSectionName.isEmpty == false else { return } + guard didFinishInitialLoad else { return } + guard let targetCard = findTargetCard(cards: cards, targetSectionName: targetSectionName) else { return } + + // Ensure layout is complete before scrolling. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + withAnimation(.easeInOut(duration: 0.45)) { + let anchorPoint: UnitPoint = shouldCenterLifestyleNutrition ? .top : .center + proxy.scrollTo(targetCard.id, anchor: anchorPoint) + } + hasScrolledToTarget = true + } + } + + private func findTargetCard( + cards: [EditableCanvasCardModel], + targetSectionName: String + ) -> EditableCanvasCardModel? { + func norm(_ s: String) -> String { + s.lowercased() + .replacingOccurrences(of: " ", with: "") + .replacingOccurrences(of: "-", with: "") + .replacingOccurrences(of: "_", with: "") + } + + let target = norm(targetSectionName) + + // Primary attempt: exact normalized match + if let match = cards.first(where: { norm($0.title) == target }) { + return match + } + + // Secondary fallback: Nutrition + if let nutrition = cards.first(where: { norm($0.title).contains("nutrition") }) { + return nutrition + } + + return nil + } + + private func openEdit(for card: EditableCanvasCardModel) { + if let sectionIndex = store.sections.firstIndex(where: { section in + section.screens.first?.stepId == card.stepId + }) { + navCoordinator.currentEditingSectionIndex = sectionIndex + store.currentSectionIndex = sectionIndex + } + navCoordinator.editingStepId = card.stepId + navCoordinator.editingMemberId = selectedMemberId // Pass selected member to edit sheet + withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { + navCoordinator.isEditSheetPresented = true + } + } + + var scrollTrackingBackground: some View { + GeometryReader { geo in + Color.clear + .onAppear { + scrollY = geo.frame(in: .named("editableCanvasScroll")).minY + prevValue = scrollY + maxScrollOffset = scrollY < 0 ? scrollY : 0 + } + .onChange(of: geo.frame(in: .named("editableCanvasScroll")).minY) { newValue in + scrollY = newValue + + if scrollY < 0 { + maxScrollOffset = min(maxScrollOffset, newValue) + + let scrollDelta = newValue - prevValue + let minScrollDelta: CGFloat = 5 + + // Removed headroom collapsing logic to prevent scroll jumps + + if scrollDelta < -minScrollDelta { + isTabBarExpanded = false + } else if scrollDelta > minScrollDelta { + let bottomThreshold: CGFloat = 100 + if newValue > (maxScrollOffset + bottomThreshold) { + isTabBarExpanded = true + } + } + } else { + maxScrollOffset = 0 + } + + prevValue = newValue + } + } + } + +} + +#Preview { + let webService = WebService() + let onboarding = Onboarding(onboardingFlowtype: .individual) + let foodNotesStore = FoodNotesStore(webService: webService, onboardingStore: onboarding) + + EditableCanvasView() + .environmentObject(onboarding) + .environment(webService) + .environment(foodNotesStore) +} + diff --git a/IngrediCheck/Views/Family/ManageFamilyView.swift b/IngrediCheck/Views/Family/ManageFamilyView.swift new file mode 100644 index 00000000..c8391032 --- /dev/null +++ b/IngrediCheck/Views/Family/ManageFamilyView.swift @@ -0,0 +1,759 @@ +import SwiftUI + +// MARK: - Custom Swipeable Row + +struct SwipeableDeleteRow: View { + let content: Content + let isJoined: Bool + let onDelete: () -> Void + + @State private var offset: CGFloat = 0 + @State private var isSwiped: Bool = false + + private let deleteButtonWidth: CGFloat = 88 + + init(isJoined: Bool, onDelete: @escaping () -> Void, @ViewBuilder content: () -> Content) { + self.isJoined = isJoined + self.onDelete = onDelete + self.content = content() + } + + var body: some View { + ZStack(alignment: .trailing) { + // Delete button behind + HStack { + Spacer() + deleteButton + .padding(.trailing, 4) + } + + // Main content + content + .offset(x: offset) + .gesture( + DragGesture(minimumDistance: 20) + .onChanged { value in + let horizontal = abs(value.translation.width) + let vertical = abs(value.translation.height) + guard horizontal > vertical else { return } + let translation = value.translation.width + if translation < 0 { + // Swiping left + offset = max(translation, -deleteButtonWidth) + } else if isSwiped { + // Swiping right to close + offset = min(0, -deleteButtonWidth + translation) + } + } + .onEnded { value in + let horizontal = abs(value.translation.width) + let vertical = abs(value.translation.height) + guard horizontal > vertical else { + withAnimation(.easeOut(duration: 0.2)) { + offset = isSwiped ? -deleteButtonWidth : 0 + } + return + } + withAnimation(.easeOut(duration: 0.2)) { + if value.translation.width < -40 { + // Snap open + offset = -deleteButtonWidth + isSwiped = true + } else { + // Snap closed + offset = 0 + isSwiped = false + } + } + } + ) + } + .clipped() + } + + private var deleteButton: some View { + Button { + if !isJoined { + withAnimation(.easeOut(duration: 0.2)) { + offset = 0 + isSwiped = false + } + onDelete() + } + } label: { + HStack { + Spacer() + + VStack(alignment: .center, spacing: 4) { + Image("Delete-icon") + .font(.system(size: 20, weight: .regular)) + .foregroundStyle(isJoined ? Color(hex: "#BDBDBD") : Color(hex: "#F04438")) + Text("Remove") + .font(NunitoFont.medium.size(12)) + .foregroundStyle(isJoined ? Color(hex: "#BDBDBD") : Color(hex: "#F04438")) + } + .padding(.trailing) + } + .frame(height: 72) + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: 24) + .fill(Color(hex: "#F7F7F7")) + ) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(Color(hex: "#E5E5E5"), lineWidth: 1) + ) + } + .buttonStyle(.plain) + .disabled(isJoined) + } +} + +struct ManageFamilyView: View { + @Environment(\.dismiss) private var dismiss + @Environment(FamilyStore.self) private var familyStore + @Environment(AppNavigationCoordinator.self) private var coordinator + @Environment(WebService.self) private var webService + @State private var familyName: String = "" + @FocusState private var isEditingFamilyName: Bool + @State private var nameFieldWidth: CGFloat = 0 + @State private var shareItems: ShareItem? + @State private var isGeneratingInviteCode: Bool = false + + private let appStoreURL = "https://apps.apple.com/us/app/ingredicheck-grocery-scanner/id6477521615" + + struct ShareItem: Identifiable { + let id = UUID() + let items: [Any] + } + + private struct NameWidthPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = nextValue() + } + } + + private var members: [FamilyMember] { + if let family = familyStore.family { + return [family.selfMember] + family.otherMembers + } + var result: [FamilyMember] = [] + if let me = familyStore.pendingSelfMember { result.append(me) } + result.append(contentsOf: familyStore.pendingOtherMembers) + return result + } + + private var extraMemberCount: Int { + let maxShown = 6 + return max(members.count - maxShown, 0) + } + + var body: some View { + VStack(spacing: 0) { + List { + Section { + familyCard + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + } + Section { + ForEach(members) { member in + let isSelfRow: Bool = { + if let family = familyStore.family { + return member.id == family.selfMember.id + } + return member.id == familyStore.pendingSelfMember?.id + }() + + if isSelfRow { + // Self row - no swipe action + MemberRow(member: member, onInvite: handleInviteShare) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 12, leading: 20, bottom: 0, trailing: 20)) + .listRowBackground(Color.clear) + } else { + // Other members - custom swipe to delete + SwipeableDeleteRow( + isJoined: member.joined, + onDelete: { + Task { + if familyStore.family != nil { + await familyStore.deleteMember(id: member.id) + } else { + familyStore.removePendingOtherMember(id: member.id) + } + } + } + ) { + MemberRow(member: member, onInvite: handleInviteShare) + } + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 12, leading: 20, bottom: 0, trailing: 20)) + .listRowBackground(Color.clear) + } + } + } + } + .listStyle(.plain) + .refreshable { + await familyStore.loadCurrentFamily() + } + .scrollIndicators(.hidden) + .scrollContentBackground(.hidden) + .background(Color.pageBackground) + } + .background(Color.pageBackground) + .navigationTitle("Manage Family") + .navigationBarTitleDisplayMode(.inline) + .dismissKeyboardOnTap() + .onAppear { + // Initialize family name: use existing family name, or generate from self member's first name + if let family = familyStore.family { + familyName = family.name + } else if let selfMember = familyStore.pendingSelfMember { + let firstName = selfMember.name.components(separatedBy: " ").first ?? selfMember.name + familyName = "\(firstName)'s Family" + } else { + familyName = "" + } + } + .onChange(of: familyStore.family?.name) { _, newValue in + guard let newValue = newValue, !newValue.isEmpty, !isEditingFamilyName else { return } + if familyName != newValue { familyName = newValue } + } + .onChange(of: familyName) { oldValue, newValue in + // Filter to letters, spaces, apostrophes, and hyphens (for names like "O'Brien" or "Mary-Jane") + let filtered = newValue.filter { $0.isLetter || $0.isWhitespace || $0 == "'" || $0 == "-" } + var finalized = filtered + + // Limit to 50 characters (family names can be longer than individual names) + if finalized.count > 50 { + finalized = String(finalized.prefix(50)) + } + + if finalized != newValue { + familyName = finalized + } + } + .task { + if familyStore.family == nil { + await familyStore.loadCurrentFamily() + } + } + .sheet(item: $shareItems) { shareItem in + ShareSheet(activityItems: shareItem.items) + } + .onDisappear { + // Reset flag when leaving family management + coordinator.isCreatingFamilyFromSettings = false + } + } + + // MARK: - Invite Share Helper + + @MainActor + private func handleInviteShare(memberId: UUID) async { + guard !isGeneratingInviteCode else { return } + + isGeneratingInviteCode = true + defer { isGeneratingInviteCode = false } + + // Mark member as pending so the UI reflects it + familyStore.setInvitePendingForPendingOtherMember(id: memberId, pending: true) + + // Ensure family exists before creating invite codes + if familyStore.family == nil { + if coordinator.isCreatingFamilyFromSettings { + await familyStore.addPendingMembersToExistingFamily() + } else { + await familyStore.createFamilyFromPendingIfNeeded() + } + } + + guard let code = await familyStore.invite(memberId: memberId) else { + return + } + + let message = inviteShareMessage(inviteCode: code) + let items = [message] + shareItems = ShareItem(items: items) + } + + private func inviteShareMessage(inviteCode: String) -> String { + let formattedCode = formattedInviteCode(inviteCode) + return "You've been invited to join my IngrediCheck family.\nSet up your food profile and get personalized ingredient guidance tailored just for you.\n\nπŸ“² Download from the App Store \(appStoreURL) and enter this invite code:\n\(formattedCode)" + } + + private func formattedInviteCode(_ inviteCode: String) -> String { + let spaced = inviteCode.map { String($0) }.joined(separator: " ") + return "**\(spaced)**" + } + + private func commitFamilyName() { + let trimmed = familyName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + Task { @MainActor in + if let family = familyStore.family { + // Update family name using updateFamily (PATCH request) + guard family.name != trimmed else { return } + await familyStore.updateFamily(name: trimmed) + } else if familyStore.pendingSelfMember != nil { + // For pending families, just update the local state + // The family name will be set when the family is created + // For now, we can't update it until the family is created + print("[ManageFamilyView] Cannot update family name for pending family") + } + } + } + + @ViewBuilder + private func familyNameEditField() -> some View { + HStack(spacing: 12) { + TextField("", text: $familyName) + .font(NunitoFont.semiBold.size(22)) + .foregroundStyle(Color(hex: "#303030")) + .textInputAutocapitalization(.words) + .disableAutocorrection(true) + .focused($isEditingFamilyName) + .submitLabel(.done) + .onSubmit { commitFamilyName() } + Image("pen-line") + .resizable() + .frame(width: 12, height: 12) + .foregroundStyle(.grayScale100) + .onTapGesture { isEditingFamilyName = true } + } + .padding(.horizontal, 20) + .frame(minWidth: 144) + .frame(maxWidth: 335) + .frame(height: 38) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(isEditingFamilyName ? Color(hex: "#EEF5E3") : .white)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color(hex: "#E3E3E3"), lineWidth: 0.5) + ) + .contentShape(Rectangle()) + .fixedSize(horizontal: true, vertical: false) + .padding(.top, 10) + .onTapGesture { isEditingFamilyName = true } + .onChange(of: isEditingFamilyName) { _, editing in + if !editing { commitFamilyName() } + } + } + + private var familyCard: some View { + FamilyCardView( + familyName: $familyName, + members: members, + extraMemberCount: extraMemberCount, + onAddMember: { + coordinator.navigateInBottomSheet(.addMoreMembers) + }, + onCommitFamilyName: commitFamilyName + ) + } + + struct MemberRow: View { + let member: FamilyMember + let onInvite: (UUID) async -> Void + @Environment(FamilyStore.self) private var familyStore + @Environment(AppNavigationCoordinator.self) private var coordinator + @State private var showLeaveConfirm = false + + private var isSelf: Bool { + if let family = familyStore.family { + return member.id == family.selfMember.id + } + return member.id == familyStore.pendingSelfMember?.id + } + + private var isSingleMember: Bool { + if let family = familyStore.family { + return family.otherMembers.isEmpty + } + return familyStore.pendingOtherMembers.isEmpty + } + + var body: some View { + HStack(spacing: 12) { + // Info Section: Avatar + Name + Status (Tapping this triggers Edit) + HStack(spacing: 12) { + ZStack(alignment: .bottomTrailing) { + SmartMemberAvatar(member: member) + .frame(width: 48, height: 48) + + Circle() + .fill(.grayScale40) + .frame(width: 16, height: 16) + .overlay( + Image("pen-line") + .resizable() + .frame(width: 7.43, height: 7.43) + ) + .offset(x: -4, y: 4) + } + + VStack(alignment: .leading, spacing: 2) { + Text(member.name) + .font(NunitoFont.semiBold.size(18)) + .foregroundStyle(.grayScale150) + + statusView + } + + Spacer() + } + .contentShape(Rectangle()) + .onTapGesture { + print("[ManageFamilyView] Info area tapped for \(member.name), navigating to edit") + // All members use MeetYourProfileView for consistency + coordinator.navigateInBottomSheet(.meetYourProfile(memberId: member.id)) + } + + // Action Area: Invite or Leave (Independent tap target) + actionButton + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 24) + .fill(Color.white) + ) + .overlay( + RoundedRectangle(cornerRadius: 24) + .stroke(lineWidth: 0.75) + .foregroundStyle(Color(hex: "#EEEEEE")) + ) + } + + @ViewBuilder + private var statusView: some View { + if isSelf { + Text("(You)") + .font(ManropeFont.medium.size(12)) + .foregroundStyle(.grayScale110) + } else if member.joined { + HStack(spacing: 4) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 10)) + .foregroundStyle(Color(hex: "#4CAF50")) + Text("Joined") + .font(ManropeFont.semiBold.size(10)) + .foregroundStyle(Color(hex: "#4CAF50")) + } + .padding(.vertical, 4) + .padding(.horizontal, 8) + .background(Color(hex: "#EAF6ED"), in: Capsule()) + } else if member.invitePending == true { + HStack(spacing: 4) { + Image(systemName: "exclamationmark.circle.fill") + .font(.system(size: 10)) + .foregroundStyle(Color(hex: "#F4A100")) + Text("Pending") + .font(ManropeFont.semiBold.size(10)) + .foregroundStyle(Color(hex: "#F4A100")) + } + .padding(.vertical, 4) + .padding(.horizontal, 8) + .background(Color(hex: "#FFF7E6"), in: Capsule()) + } else { + Text("Not joined yet !") + .font(NunitoFont.regular.size(12)) + .foregroundStyle(.grayScale100) + } + } + + @ViewBuilder + private var actionButton: some View { + if isSelf { + Button { + showLeaveConfirm = true + } label: { + Text("Leave Family") + .font(NunitoFont.semiBold.size(12)) + .foregroundStyle(isSingleMember ? Color(hex: "#F04438").opacity(0.4) : Color(hex: "#F04438")) + .padding(.vertical, 8) + .padding(.horizontal, 16) + .background(Color.clear, in: Capsule()) + .overlay( + Capsule() + .stroke(isSingleMember ? Color(hex: "#F04438").opacity(0.4) : Color(hex: "#F04438"), lineWidth: 1) + ) + } + .buttonStyle(.plain) + .disabled(isSingleMember) + .confirmationDialog("Leave Family", isPresented: $showLeaveConfirm) { + Button("Leave Family", role: .destructive) { + Task { await familyStore.leave() } + } + } message: { + Text("Are you sure you want to leave?") + } + } else { + Button { + print("[ManageFamilyView] Invite button tapped for \(member.name)") + Task { @MainActor in + await onInvite(member.id) + } + } label: { + HStack(spacing: 6) { + Image("share") + .resizable() + .frame(width: 14, height: 14) + Text(member.joined ? "Re-invite" : "Invite") + .font(NunitoFont.semiBold.size(12)) + } + .foregroundStyle(Color(hex: "#91B640")) + .padding(.vertical, 8) + .padding(.horizontal, 16) + .background(Color.white) + .clipShape(Capsule()) + .overlay { + Capsule() + .stroke(Color(hex: "#EEEEEE").opacity(0.4), lineWidth: 1) + } + .shadow(color: Color(hex: "#CECECE63").opacity(0.39), radius: 4.8) + } + .buttonStyle(.plain) + } + } + } + + private struct SmartMemberAvatar: View { + let member: FamilyMember + + var body: some View { + // Use centralized MemberAvatar component with stroke overlay + ZStack { + MemberAvatar.medium(member: member) + + // Additional stroke overlay for this specific view + Circle() + .stroke(Color.grayScale40, lineWidth: 2) + .frame(width: 48, height: 48) + } + } + } +} + +// MARK: - Family Card Component + +private struct FamilyCardView: View { + @Binding var familyName: String + let members: [FamilyMember] + let extraMemberCount: Int + let onAddMember: () -> Void + let onCommitFamilyName: () -> Void + + @FocusState private var isEditingFamilyName: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 6) { + // Label to clarify this is the family name + Text("Family Name") + .font(ManropeFont.regular.size(11)) + .foregroundStyle(.grayScale110) + .padding(.top, 0) + + // Family name edit field + HStack(spacing: 12) { + TextField("", text: $familyName) + .font(NunitoFont.semiBold.size(22)) + .foregroundStyle(Color(hex: "#303030")) + .textInputAutocapitalization(.words) + .disableAutocorrection(true) + .focused($isEditingFamilyName) + .submitLabel(.done) + .onSubmit { + isEditingFamilyName = false + onCommitFamilyName() + } + Image("pen-line") + .resizable() + .frame(width: 12, height: 12) + .foregroundStyle(.grayScale100) + .onTapGesture { isEditingFamilyName = true } + } + .padding(.horizontal, 8) + .frame(minWidth: 144) + .frame(height: 38) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(isEditingFamilyName ? Color(hex: "#EEF5E3") : .white)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color(hex: "#E3E3E3"), lineWidth: 0.5) + ) + .contentShape(Rectangle()) + .fixedSize(horizontal: true, vertical: false) + .padding(.top, 4) + .onTapGesture { isEditingFamilyName = true } + .onChange(of: isEditingFamilyName) { _, editing in + if !editing { onCommitFamilyName() } + } + + Text("Everyone stays connected and updated here.") + .font(ManropeFont.regular.size(12)) + .multilineTextAlignment(.leading) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + .frame(width: 161, alignment: .leading) + .foregroundStyle(.grayScale110) + } + } + + HStack { + HStack(spacing: -12) { + ForEach(Array(members.prefix(6)), id: \.id) { member in + MemberAvatar.custom(member: member, size: 32, imagePadding: 0) + } + if extraMemberCount > 0 { + Circle() + .fill(Color(hex: "#F2F2F2")) + .frame(width: 32, height: 32) + .overlay( + Text("+\(extraMemberCount)") + .font(NunitoFont.semiBold.size(12)) + .foregroundStyle(.grayScale130) + ) + .overlay( + Circle() + .stroke(lineWidth: 1) + .foregroundStyle(Color.white) + ) + } + } + .fixedSize(horizontal: true, vertical: false) + + Spacer(minLength: 8) + + Button { + onAddMember() + } label: { + GreenCapsule(title: "Add Member",icon: "tabler_plus", width: 111, height: 36, takeFullWidth: false, labelFont: NunitoFont.semiBold.size(12)) + } + .buttonStyle(.plain) + .layoutPriority(1) + + } + } + .padding(16) + .background( + ZStack(alignment: .bottomTrailing) { + RoundedRectangle(cornerRadius: 28) + .fill(Color.white) + + Image(systemName: "tree") + .font(.system(size: 134)) + .foregroundStyle(.grayScale30) + .offset(x: 60) + } + .clipShape(RoundedRectangle(cornerRadius: 28)) + ) +// .overlay( +// RoundedRectangle(cornerRadius: 28) +// .stroke(lineWidth: 0.75) +// .foregroundStyle(Color(hex: "#EEEEEE")) +// ) + .padding(.horizontal, 20) + } +} + +// MARK: - Family Card Preview + +#Preview("Family Card") { + FamilyCardPreview() + .environment(WebService()) +} + +private struct FamilyCardPreview: View { + @State private var familyName: String = "Smith Family" + @FocusState private var isEditingFamilyName: Bool + + // Mock members data + private let mockMembers: [FamilyMember] = [ + FamilyMember(id: UUID(), name: "John", color: "#E0BBE4", joined: true, imageFileHash: nil), + FamilyMember(id: UUID(), name: "Sarah", color: "#BAE1FF", joined: true, imageFileHash: nil), + FamilyMember(id: UUID(), name: "Emma", color: "#BAFFC9", joined: true, imageFileHash: nil), + FamilyMember(id: UUID(), name: "Mike", color: "#FFB3BA", joined: false, imageFileHash: nil), + FamilyMember(id: UUID(), name: "Lisa", color: "#FFDFBA", joined: true, imageFileHash: nil), + FamilyMember(id: UUID(), name: "Tom", color: "#FFFFBA", joined: false, imageFileHash: nil), + FamilyMember(id: UUID(), name: "Anna", color: "#E0BBE4", joined: true, imageFileHash: nil) + ] + + private var extraMemberCount: Int { + let maxShown = 6 + return max(mockMembers.count - maxShown, 0) + } + + var body: some View { + FamilyCardView( + familyName: $familyName, + members: mockMembers, + extraMemberCount: extraMemberCount, + onAddMember: { + print("Add Member tapped in preview") + }, + onCommitFamilyName: { + print("Family name committed: \(familyName)") + } + ) + .background(Color(hex: "#F7F7F7")) + } +} + +#Preview("With Family") { + let familyStore = FamilyStore() + let coordinator = AppNavigationCoordinator() + let webService = WebService() + + // Set up mock family data + let mockSelfMember = FamilyMember( + id: UUID(), + name: "Alex", + color: "#E0BBE4", + joined: true, + imageFileHash: nil + ) + + let mockOtherMembers: [FamilyMember] = [ + FamilyMember(id: UUID(), name: "Sarah", color: "#BAE1FF", joined: true, imageFileHash: nil), + FamilyMember(id: UUID(), name: "Emma", color: "#BAFFC9", joined: true, imageFileHash: nil), + FamilyMember(id: UUID(), name: "Mike", color: "#FFB3BA", joined: false, imageFileHash: nil), + FamilyMember(id: UUID(), name: "Lisa", color: "#FFDFBA", joined: true, imageFileHash: nil), + FamilyMember(id: UUID(), name: "Tom", color: "#FFFFBA", joined: false, imageFileHash: nil, invitePending: true), + FamilyMember(id: UUID(), name: "Anna", color: "#B4E4FF", joined: true, imageFileHash: nil) + ] + + let mockFamily = Family( + name: "Smith Family", + selfMember: mockSelfMember, + otherMembers: mockOtherMembers, + version: Int64(Date().timeIntervalSince1970) + ) + + // Set mock family data for preview + familyStore.setMockFamilyForPreview(mockFamily) + + return NavigationStack { + ManageFamilyView() + .environment(familyStore) + .environment(coordinator) + .environment(webService) + } +} + +#Preview("Empty State") { + NavigationStack { + ManageFamilyView() + .environment(FamilyStore()) + .environment(AppNavigationCoordinator()) + .environment(WebService()) + } +} diff --git a/IngrediCheck/Views/HeyThereScreen.swift b/IngrediCheck/Views/HeyThereScreen.swift new file mode 100644 index 00000000..c53bc520 --- /dev/null +++ b/IngrediCheck/Views/HeyThereScreen.swift @@ -0,0 +1,109 @@ +// +// HeyThereScreen.swift +// IngrediCheckPreview +// +// Created by Gunjan Haldar on 07/11/25. +// + +import SwiftUI + +struct HeyThereScreen: View { + @Environment(AppNavigationCoordinator.self) private var coordinator + @Environment(AuthController.self) private var authController + + private var showOnboardingFamilyImage: Bool { + switch coordinator.currentBottomSheetRoute { + case .doYouHaveAnInviteCode, .enterInviteCode, .whosThisFor: + return true + default: + return false + } + } + + var body: some View { + Group { + if showOnboardingFamilyImage { + Group { + switch coordinator.currentBottomSheetRoute { + case .whosThisFor: + VStack { + Text("Welcome to IngrediFam!") + .font(ManropeFont.bold.size(16)) + .padding(.top ,32) + .padding(.bottom ,4) + Text("Join your family space and personalize food choices together.") + .font(ManropeFont.regular.size(13)) + .foregroundColor(Color(hex: "#BDBDBD")) + .lineLimit(2) + .frame(width : 247) + .multilineTextAlignment(.center ) + Image("onbordingfamilyimg2") + .resizable() + .scaledToFit() + .frame(height: 369) + .frame(maxWidth: .infinity) + .offset(y : -50) + Spacer() + } + case .doYouHaveAnInviteCode, .enterInviteCode: + VStack { + Text("Welcome to IngrediFam!") + .font(ManropeFont.bold.size(16)) + .padding(.top ,32) + .padding(.bottom ,4) + Text("Join your family space and personalize food choices together.") + .font(ManropeFont.regular.size(13)) + .foregroundColor(Color(hex: "#BDBDBD")) + .lineLimit(2) + .frame(width : 247) + .multilineTextAlignment(.center ) + + + Image("onbordingfamilyimg") + .resizable() + .scaledToFit() + .frame(height: 369) + .frame(maxWidth: .infinity) + .offset(y : -50) + Spacer() + } + default: + EmptyView() + } + } + .navigationBarBackButtonHidden(true) + .toolbar(.hidden, for: .navigationBar) + } else { + OnboardingPhoneCanvas(phoneImageName: "Iphone-image") + } + } + .onAppear { + if OnboardingPersistence.shared.localStage != .completed { + AnalyticsService.shared.trackOnboarding("Onboarding Started") + } + } + .task(id: coordinator.currentBottomSheetRoute) { + // Trigger anonymous sign-in only when explicitly asking "Who's this for?" + // This prevents "Get Started" or other pre-onboarding states from creating sessions. + if coordinator.currentBottomSheetRoute == .whosThisFor { + if authController.session == nil { + print("[OnboardingMeta] Auto-triggering guest login on .whosThisFor screen") + await authController.signIn() + + // Sync initial state to Supabase immediately after session is created + print("[OnboardingMeta] Syncing initial state after guest login") + await OnboardingPersistence.shared.sync(from: coordinator) + + // Pre-download tutorial video in background + Task { await TutorialVideoManager.shared.downloadIfNeeded() } + } + } + } + } +} + +#Preview { + HeyThereScreen() + .environment(AppNavigationCoordinator(initialRoute: .heyThere)) + .environment(AuthController()) +} diff --git a/IngrediCheck/Views/HomeView.swift b/IngrediCheck/Views/HomeView.swift new file mode 100644 index 00000000..e55f008e --- /dev/null +++ b/IngrediCheck/Views/HomeView.swift @@ -0,0 +1,924 @@ + +// +// HomeView.swift +// IngrediCheckPreview +// +// Created by Gunjan Haldar on 10/11/25. +// + +import SwiftUI +import AVFoundation + +struct HomeView: View { + @State private var isSettingsPresented = false + @State private var isTabBarExpanded: Bool = true + @State private var isRefreshingHistory: Bool = false + @State private var showEditableCanvas: Bool = false + @State private var editTargetSectionName: String? = nil + @SceneStorage("didPlayAverageScansLaunchAnimation") private var didPlayAverageScansLaunchAnimation: Bool = false + + private final class ScrollTrackingState { + var prevValue: CGFloat = 0 + var maxScrollOffset: CGFloat = 0 + var didInitialize: Bool = false + var scrollEndWorkItem: DispatchWorkItem? + } + + @State private var scrollTrackingState = ScrollTrackingState() + @State private var stats: DTO.StatsResponse? = nil + @State private var isLoadingStats: Bool = false + @State private var hasCheckedAutoScan: Bool = false + @State private var didFinishInitialLoad: Bool = false + // --------------------------- + // MERGED FROM YOUR BRANCH + // --------------------------- + private struct ProductDetailPayload: Identifiable { + let id = UUID() + let scanId: String // scan.id for ProductDetailView to track + let scan: DTO.Scan // Full scan object to pass as initialScan + let product: DTO.Product + let matchStatus: DTO.ProductRecommendation + let ingredientRecommendations: [DTO.IngredientRecommendation]? + let clientActivityId: String? // Optional for backwards compatibility (legacy API) + let favorited: Bool + } + + + @Environment(AppState.self) var appState + @Environment(WebService.self) var webService + @Environment(ScanHistoryStore.self) var scanHistoryStore + @Environment(UserPreferences.self) var userPreferences + @Environment(AuthController.self) private var authController + @Environment(FoodNotesStore.self) private var foodNotesStore + @EnvironmentObject private var onboarding: Onboarding + // --------------------------- + // MERGED FROM DEVELOP BRANCH + // --------------------------- + @Environment(FamilyStore.self) private var familyStore + @Environment(AppNavigationCoordinator.self) private var coordinator + @Environment(MemojiStore.self) private var memojiStore + private var familyMembers: [FamilyMember] { + guard let family = familyStore.family else { return [] } + return [family.selfMember] + family.otherMembers + } + private var primaryMemberName: String { + return familyStore.family?.selfMember.name ?? "BiteBuddy" + } + + // MARK: - Family avatars + + /// Small avatar used under "Your IngrediFam". Shows the member's memoji + /// if an imageFileHash is present, otherwise falls back to the first + /// letter of their name on top of their color. + struct FamilyMemberAvatarView: View { + let member: FamilyMember + + var body: some View { + // Use centralized MemberAvatar component + MemberAvatar.small(member: member) + } + } + + var body: some View { + // Make AppState observable/bindable so view updates when its properties change + @Bindable var appState = appState + + NavigationStack(path: $appState.navigationPath) { + ScrollView(.vertical, showsIndicators: false) { + // IMPORTANT: GeometryReader must be attached to the inner content + VStack(spacing: 12) { + if !didFinishInitialLoad { + // Shimmer skeleton placeholders + RedactedGreetingSection() + RedactedCardsRow() + RedactedStatsRow() + RedactedRecentScansSection() + } else { + // Greeting section + HStack { + VStack(alignment: .leading, spacing: 0) { + Text("Hello πŸ‘‹") + .font(NunitoFont.regular.size(14)) + .foregroundStyle(.grayScale150) + + Text(primaryMemberName) + .font(NunitoFont.semiBold.size(32)) + .foregroundStyle(.grayScale150) + .offset(x: -2) + + Text("Your food notes, personalized for you.") + .font(ManropeFont.regular.size(14)) + .foregroundStyle(.grayScale130) + } + + Spacer() + + ProfileCard(isProfileCompleted: true) + .onTapGesture { + isSettingsPresented = true + } + } + .padding(.bottom, 24) + .frame(maxWidth: .infinity) + + // Food Notes & Allergy Summary... + GeometryReader { geometry in + let cardWidth = (geometry.size.width - 12) / 2 // 12 is spacing + HStack(spacing: 12) { + VStack(alignment: .leading) { + VStack(alignment: .leading, spacing: 4) { + Text("Food Notes") + .font(ManropeFont.semiBold.size(18)) + .foregroundStyle(.grayScale150) + .frame(height: 15) + + Text("Here's what your family needs to avoid.") + .font(ManropeFont.medium.size(14)) + .foregroundStyle(.grayScale110) + .lineLimit(3) + } + + AskIngrediBotButton { + coordinator.showAIBotSheet() + } + } + .frame(width: cardWidth, alignment: .leading) + + AllergySummaryCard( + summary: foodNotesStore.foodNotesSummary, + dynamicSteps: onboarding.dynamicSteps, + onTap: { + editTargetSectionName = nil + showEditableCanvas = true + } + ) + .frame(width: cardWidth, height: 196) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(height: 196) + + // Family + Average scans - use GeometryReader to ensure equal width + GeometryReader { geometry in + let cardWidth = (geometry.size.width - 12) / 2 // 12 is spacing + HStack(spacing: 12) { + AverageScansCard( + playsLaunchAnimation: !didPlayAverageScansLaunchAnimation, + avgScans: stats?.avgScans ?? 0, + weeklyStats: stats?.weeklyStats + ) + .onAppear { + didPlayAverageScansLaunchAnimation = true + } + .frame(width: cardWidth) + + VStack(alignment: .leading) { + Text("Your IngrediFam") + .font(ManropeFont.semiBold.size(18)) + .foregroundStyle(.grayScale150) + .padding(.bottom, 4) + .lineLimit(2) + + Text("Your people, their choices.") + .font(ManropeFont.regular.size(14)) + .foregroundStyle(.grayScale110) + .lineLimit(2) + + Spacer() + + HStack { + ZStack(alignment: .bottomTrailing) { + let membersToShow = Array(familyMembers.prefix(3)) + + HStack(spacing: -8) { + ForEach(membersToShow, id: \.id) { member in + FamilyMemberAvatarView(member: member) + } + } + + if familyMembers.count > 3 { + Text("+\(familyMembers.count - 3)") + .font(NunitoFont.semiBold.size(12)) + .foregroundStyle(.grayScale100) + .background( + Circle() + .frame(width: 20, height: 20) + .foregroundStyle(.grayScale60) + ) + .offset(x: 10, y: -2) + } + } + + Spacer() + + Button { + coordinator.navigateInBottomSheet(.addMoreMembers) + } label: { + GreenCircle(iconName: "tabler_plus", iconSize: 24, circleSize: 36) + } + .buttonStyle(.plain) + } + } + .frame(height: 141) + .padding(16) + .frame(width: cardWidth) + .background( + RoundedRectangle(cornerRadius: 24) + .fill(Color.white) + .overlay( + RoundedRectangle(cornerRadius: 24) + .stroke(lineWidth: 0.75) + .foregroundStyle(Color(hex: "#EEEEEE")) + ) + ) + .contentShape(RoundedRectangle(cornerRadius: 24)) + .onTapGesture { + // Open Manage Family when tapped + appState.navigate(to: .manageFamily) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(height: 173) // Fixed height for the card row (141 content + 16*2 padding) + + HStack(spacing: 12) { + YourBarcodeScans(barcodeScansCount: stats?.barcodeScansCount ?? 0) + .frame(maxWidth: .infinity) + + UserFeedbackCard() + .frame(maxWidth: .infinity) + + } + .frame(maxWidth: .infinity) + + MatchingRateCard( + matchedCount: stats?.matchingStats.matched ?? 0, + uncertainCount: stats?.matchingStats.uncertain ?? 0, + unmatchedCount: stats?.matchingStats.unmatched ?? 0 + ) + + CreateYourAvatarCard() + + .onTapGesture { + // If family has more than one member, show SetUpAvatarFor first + // Otherwise, go directly to YourCurrentAvatar + if familyMembers.count > 1 { + coordinator.navigateInBottomSheet(.setUpAvatarFor) + } else { + coordinator.navigateInBottomSheet(.yourCurrentAvatar) + } + } + + + // Recent Scans Card + VStack(alignment: .leading, spacing: 16) { + // Header + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + Text("Recent Scans") + .font(ManropeFont.semiBold.size(18)) + .foregroundStyle(.grayScale150) + + Text("Here's what you checked last in past 2 days") + .font(ManropeFont.regular.size(12)) + .foregroundStyle(.grayScale100) + } + + Spacer() + + // Only show "View All" when there are scans + if let scans = appState.listsTabState.scans, !scans.isEmpty { + NavigationLink(value: HistoryRouteItem.recentScansAll) { + Text("View All") + .underline() + .font(ManropeFont.bold.size(14)) + .foregroundStyle(Color(hex: "#82B611")) + } + .buttonStyle(.plain) + } + } + + // Recent Scans list / empty state + if let scans = appState.listsTabState.scans, + !scans.isEmpty { + let items = Array(scans.prefix(5)) + + VStack(spacing: 0) { + ForEach(Array(items.enumerated()), id: \.element.id) { index, scan in + + NavigationLink(value: HistoryRouteItem.scan(scan)) { + RecentScanCard( + scan: scan, + style: .compact, + onFavoriteToggle: { scanId, newValue in + handleFavoriteToggle(scanId: scanId, favorited: newValue) + }, + onScanUpdated: { updatedScan in + handleScanUpdated(updatedScan) + } + ) + } + .buttonStyle(.plain) + + if index != items.count - 1 { + Divider().padding(.vertical, 14) + } + } + } + } else { + VStack(spacing: 12) { + Image("blackroboicon") + .resizable() + .scaledToFit() + .frame(width: 120, height: 120) + Text("Ooops, No scans yet!") + .font(NunitoFont.semiBold.size(16)) + .foregroundStyle(.grayScale100) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 24) + } + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 24) + .fill(Color.white) + .overlay( + RoundedRectangle(cornerRadius: 24) + .stroke(lineWidth: 0.75) + .foregroundStyle(Color(hex: "#EEEEEE")) + ) + ) + } // end else (real content) + } + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding(.horizontal, 20) + .padding(.bottom , 30) + .padding(.top ,16) + .navigationBarBackButtonHidden(true) + // ← Attach GeometryReader here so it measures inside the ScrollView's coordinate space + .background( + GeometryReader { geo in + Color.clear + .onAppear { + let value = geo.frame(in: .named("homeScroll")).minY + scrollTrackingState.prevValue = value + scrollTrackingState.maxScrollOffset = value < 0 ? value : 0 + scrollTrackingState.didInitialize = true + } + .onChange(of: geo.frame(in: .named("homeScroll")).minY) { newValue in + if !scrollTrackingState.didInitialize { + scrollTrackingState.prevValue = newValue + scrollTrackingState.maxScrollOffset = newValue < 0 ? newValue : 0 + scrollTrackingState.didInitialize = true + return + } + + // Track the maximum (most negative) scroll offset reached + if newValue < 0 { + scrollTrackingState.maxScrollOffset = min(scrollTrackingState.maxScrollOffset, newValue) + } else { + scrollTrackingState.maxScrollOffset = 0 + } + + let scrollDelta = newValue - scrollTrackingState.prevValue + let minScrollDelta: CGFloat = 5 // Minimum scroll change to trigger state change + + var nextExpanded = isTabBarExpanded + + // Only change expansion state when scrolled past the top (newValue < 0) + if newValue < 0 { + if scrollDelta < -minScrollDelta { + nextExpanded = false + } else if scrollDelta > minScrollDelta { + let bottomThreshold: CGFloat = 100 + if newValue > (scrollTrackingState.maxScrollOffset + bottomThreshold) { + nextExpanded = true + } + } + } + + if nextExpanded != isTabBarExpanded { + isTabBarExpanded = nextExpanded + } + + scrollTrackingState.prevValue = newValue + + // Cancel any pending scroll-end work item + scrollTrackingState.scrollEndWorkItem?.cancel() + + // Schedule re-expansion after scrolling stops (0.4s delay) + if !isTabBarExpanded { + let workItem = DispatchWorkItem { [weak scrollTrackingState] in + guard scrollTrackingState != nil else { return } + DispatchQueue.main.async { + withAnimation(.easeOut(duration: 0.25)) { + isTabBarExpanded = true + } + } + } + scrollTrackingState.scrollEndWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15, execute: workItem) + } + } + } + ) + } + .scrollBounceBehavior(.basedOnSize) + .coordinateSpace(name: "homeScroll") + .frame(maxWidth: .infinity) + .clipped() + .withBottomTabBar( + gradientColors: [ + Color.white.opacity(0), + Color(hex: "#FCFCFE") + ] + ) { + TabBar( + isExpanded: $isTabBarExpanded, + onRecentScansTap: { + appState.navigationPath.append(HistoryRouteItem.recentScansAll) + }, + onChatBotTap: { + coordinator.showAIBotSheet() + } + ) + } + .background(Color.pageBackground) + // .padding(.top , 16) + // .background(Color.red) + + + // ------------ COORDINATED INITIAL LOAD ------------ + .task { + guard !didFinishInitialLoad else { return } + let needsScans = appState.listsTabState.scans == nil + defer { + withAnimation(.easeInOut(duration: 0.3)) { + didFinishInitialLoad = true + } + } + await withTaskGroup(of: Void.self) { group in + group.addTask { @MainActor in + if needsScans { + await refreshRecentScans() + } + } + group.addTask { @MainActor in + await loadStats() + } + group.addTask { @MainActor in + await foodNotesStore.loadSummaryIfNeeded() + } + await group.waitForAll() + } + } + // Trigger a push navigation to Settings when requested by app state + .onChange(of: appState.navigateToSettings) { _, newValue in + if newValue { + isSettingsPresented = true + appState.navigateToSettings = false + } + } + // Refresh scan history when returning from ScanCameraView (push navigation) + .onChange(of: appState.isInScanCameraView) { wasInCamera, isInCamera in + // Refresh when leaving camera view (was in camera, now not) + if wasInCamera && !isInCamera { + Task { + await refreshRecentScans() + } + } + } + // Refresh scan history when food preferences are modified + .onChange(of: appState.needsScanHistoryRefresh) { _, needsRefresh in + if needsRefresh { + appState.needsScanHistoryRefresh = false + Task { + await refreshRecentScans() + } + } + } + + // ------------ SETTINGS SCREEN ------------ + // Use SettingsContentView (without NavigationStack) in navigationDestination + // to avoid nested NavigationStack issues that cause NavigationPath comparisonTypeMismatch errors + .navigationDestination(isPresented: $isSettingsPresented) { + SettingsContentView() + .environment(userPreferences) + .environment(coordinator) + .environment(memojiStore) + } + + // ------------ EDITABLE CANVAS ------------ + .navigationDestination(isPresented: $showEditableCanvas) { + UnifiedCanvasView( + mode: .editing, + targetSectionName: editTargetSectionName, + onDismiss: { + showEditableCanvas = false + } + ) + .environmentObject(onboarding) + } + + // ------------ HISTORY ROUTE HANDLING (For Recent Scans) ------------ + .navigationDestination(for: HistoryRouteItem.self) { item in + switch item { + case .scan(let scan): + let product = scan.toProduct() + let recommendations = scan.analysis_result?.toIngredientRecommendations() + ProductDetailView( + scanId: scan.id, + initialScan: scan, + product: product, + matchStatus: scan.toProductRecommendation(), + ingredientRecommendations: recommendations, + isPlaceholderMode: false, + presentationSource: .pushNavigation + ) + case .listItem(let item): + // Fallback for list items if reached from Home + FavoriteItemDetailView(item: item) // Assuming FavoriteItemDetailView is available + case .favoritesAll: + // Fallback, not strictly needed for Recent Scans + FavoritesPageView() + case .recentScansAll: + RecentScansPageView() + } + } + // ------------ APP ROUTE HANDLING (For ScanCamera, ProductDetail, etc.) ------------ + .navigationDestination(for: AppRoute.self) { route in + switch route { + case .scanCamera(let initialMode, let initialScanId): + ScanCameraView(initialMode: initialMode, initialScrollTarget: initialScanId, presentationSource: .pushNavigation) + .environment(userPreferences) + .environment(appState) + .environment(scanHistoryStore) + case .productDetail(let scanId, let initialScan): + ProductDetailView( + scanId: scanId, + initialScan: initialScan, + presentationSource: .pushNavigation + ) + case .favoritesAll: + FavoritesPageView() + .environment(appState) + case .recentScansAll: + RecentScansPageView() + .environment(appState) + .environment(scanHistoryStore) + case .favoriteDetail(let item): + ProductDetailView( + scanId: item.list_item_id, + initialScan: nil, + presentationSource: .pushNavigation + ) + case .settings: + SettingsContentView() + .environment(userPreferences) + .environment(coordinator) + .environment(memojiStore) + case .manageFamily: + ManageFamilyView() + .environment(coordinator) + case .editableCanvas(let targetSection): + UnifiedCanvasView(mode: .editing, targetSectionName: targetSection) + .environment(memojiStore) + .environment(coordinator) + } + } + .onAppear { + // Skip shimmer if data is already cached (e.g. returning to tab) + if !didFinishInitialLoad { + if appState.listsTabState.scans != nil || stats != nil { + didFinishInitialLoad = true + } + } + } + .onAppear { + // Check if we should auto-open scan camera on app start + // Only trigger once when HomeView first appears + if !hasCheckedAutoScan && userPreferences.startScanningOnAppStart { + hasCheckedAutoScan = true + // Small delay to ensure view is fully loaded + Task { @MainActor in + try? await Task.sleep(nanoseconds: 300_000_000) // 0.3 seconds + // Check camera permission before auto-opening + let status = AVCaptureDevice.authorizationStatus(for: .video) + if status == .authorized { + // Use push navigation instead of modal + appState.navigate(to: .scanCamera(initialMode: nil, initialScanId: nil)) + } else if status == .notDetermined { + AVCaptureDevice.requestAccess(for: .video) { granted in + DispatchQueue.main.async { + if granted { + // Use push navigation instead of modal + appState.navigate(to: .scanCamera(initialMode: nil, initialScanId: nil)) + } + } + } + } + // If denied/restricted, don't auto-open (user needs to enable in settings) + } + } + } + + } + .overlay(alignment: .bottomTrailing) { + if shouldShowAIBotFAB { + AIBotFAB( + onTap: { presentAIBotWithContext() }, + showPromptBubble: coordinator.showFeedbackPromptBubble, + onPromptTap: { coordinator.dismissFeedbackPrompt(openChat: true) }, + onPromptDismiss: { coordinator.dismissFeedbackPrompt(openChat: false) } + ) + .padding(.trailing, 20) +// .padding(.bottom, 100) + .transition(.scale.combined(with: .opacity)) + } + } + .animation(.easeInOut(duration: 0.25), value: shouldShowAIBotFAB) + .animation(.spring(response: 0.4, dampingFraction: 0.7), value: coordinator.showFeedbackPromptBubble) + .tint(Color(hex: "#303030")) // Back button and navigation tint color + // AI Bot sheet - attached here so it works correctly when Food Notes is shown via navigationDestination + .sheet(isPresented: Binding( + get: { coordinator.isAIBotSheetPresented }, + set: { coordinator.isAIBotSheetPresented = $0 } + ), onDismiss: { + coordinator.dismissAIBotSheet() + }) { + IngrediBotChatView( + scanId: coordinator.aibotContextScanId, + analysisId: coordinator.aibotContextAnalysisId, + ingredientName: coordinator.aibotContextIngredientName, + feedbackId: coordinator.aibotContextFeedbackId + ) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + .environment(coordinator) + .environment(appState) + } + } + + // MARK: - AIBot FAB + + private var shouldShowAIBotFAB: Bool { + // Don't show on root (HomeView has its own AIBot buttons) + guard !appState.navigationPath.isEmpty else { return false } + + // Hide when ScanCameraView is the visible/active view + // This flag is set by ScanCameraView on appear/disappear + if appState.isInScanCameraView { + return false + } + + // Show on all detail screens (ProductDetailView, etc.) + return true + } + + private func presentAIBotWithContext() { + // Dismiss any feedback prompt bubble first and open chat with pending context + // (feedback context includes analysisId, ingredientName, feedbackId as needed) + if coordinator.showFeedbackPromptBubble { + coordinator.dismissFeedbackPrompt(openChat: true) + return + } + + // Try to get context from AppRoute navigation + // Only pass scanId for product_scan context (not analysisId - that's for feedback) + if let currentRoute = appState.currentRoute { + switch currentRoute { + case .productDetail(let scanId, _): + coordinator.showAIBotSheetWithContext(scanId: scanId) + return + default: + break + } + } + + // Fallback: Check if ProductDetailView has set displayedScanId (for HistoryRouteItem navigation) + // Only pass scanId for product_scan context + if let displayedScanId = appState.displayedScanId { + coordinator.showAIBotSheetWithContext(scanId: displayedScanId) + return + } + + // No product context available - open chat with home context + coordinator.showAIBotSheet() + } + + private func loadStats() async { + guard !isLoadingStats else { return } + isLoadingStats = true + defer { isLoadingStats = false } + + do { + let fetchedStats = try await webService.fetchStats() + await MainActor.run { + stats = fetchedStats + } + } catch { + print("[HomeView] Failed to load stats: \(error.localizedDescription)") + } + } + + private func refreshRecentScans() async { + Log.debug("HomeView", "πŸ“‹ refreshRecentScans called") + guard !isRefreshingHistory else { + Log.debug("HomeView", "⏸️ refreshRecentScans skipped - already refreshing") + return + } + isRefreshingHistory = true + defer { isRefreshingHistory = false } + + Log.debug("HomeView", "πŸ“‹ refreshRecentScans calling loadHistory") + // Load via store (single source of truth) + await scanHistoryStore.loadHistory(limit: 20, offset: 0, forceRefresh: true) + Log.debug("HomeView", "βœ… refreshRecentScans loadHistory completed") + + // Sync store data to AppState for backwards compatibility with ListsTab + await MainActor.run { + appState.listsTabState.scans = scanHistoryStore.scans + } + + // Refresh stats + await loadStats() + } + + // MARK: - RecentScanCard Callbacks + + private func handleFavoriteToggle(scanId: String, favorited: Bool) { + // Update AppState for backwards compatibility + appState.setHistoryItemFavorited(clientActivityId: scanId, favorited: favorited) + + // Update scan in store and AppState.listsTabState.scans + if var scans = appState.listsTabState.scans, + let idx = scans.firstIndex(where: { $0.id == scanId }) { + let oldScan = scans[idx] + let newScan = DTO.Scan( + id: oldScan.id, + scan_type: oldScan.scan_type, + barcode: oldScan.barcode, + state: oldScan.state, + product_info: oldScan.product_info, + product_info_source: oldScan.product_info_source, + product_info_vote: oldScan.product_info_vote, + analysis_result: oldScan.analysis_result, + images: oldScan.images, + latest_guidance: oldScan.latest_guidance, + created_at: oldScan.created_at, + last_activity_at: oldScan.last_activity_at, + is_favorited: favorited, + analysis_id: oldScan.analysis_id + ) + scans[idx] = newScan + appState.listsTabState.scans = scans + scanHistoryStore.upsertScan(newScan) + } + } + + private func handleScanUpdated(_ updatedScan: DTO.Scan) { + // Update scan in store + scanHistoryStore.upsertScan(updatedScan) + + // Sync to AppState for backwards compatibility + if var scans = appState.listsTabState.scans, + let idx = scans.firstIndex(where: { $0.id == updatedScan.id }) { + scans[idx] = updatedScan + appState.listsTabState.scans = scans + } + } + + // MARK: - Redacted Shimmer Placeholders + + private struct RedactedGreetingSection: View { + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 8) { + RoundedRectangle(cornerRadius: 6) + .fill(Color.gray.opacity(0.15)) + .frame(width: 80, height: 14) + RoundedRectangle(cornerRadius: 8) + .fill(Color.gray.opacity(0.15)) + .frame(width: 160, height: 28) + RoundedRectangle(cornerRadius: 6) + .fill(Color.gray.opacity(0.15)) + .frame(width: 220, height: 14) + } + Spacer() + Circle() + .fill(Color.gray.opacity(0.15)) + .frame(width: 48, height: 48) + } + .padding(.bottom, 24) + .frame(maxWidth: .infinity) + .redacted(reason: .placeholder) + .shimmering() + } + } + + private struct RedactedCardsRow: View { + var body: some View { + GeometryReader { geometry in + let cardWidth = (geometry.size.width - 12) / 2 + HStack(spacing: 12) { + RoundedRectangle(cornerRadius: 24) + .fill(Color.gray.opacity(0.10)) + .frame(width: cardWidth, height: 196) + RoundedRectangle(cornerRadius: 24) + .fill(Color.gray.opacity(0.10)) + .frame(width: cardWidth, height: 196) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(height: 196) + .redacted(reason: .placeholder) + .shimmering() + } + } + + private struct RedactedStatsRow: View { + var body: some View { + GeometryReader { geometry in + let cardWidth = (geometry.size.width - 12) / 2 + HStack(spacing: 12) { + RoundedRectangle(cornerRadius: 24) + .fill(Color.gray.opacity(0.10)) + .frame(width: cardWidth, height: 173) + RoundedRectangle(cornerRadius: 24) + .fill(Color.gray.opacity(0.10)) + .frame(width: cardWidth, height: 173) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(height: 173) + .redacted(reason: .placeholder) + .shimmering() + } + } + + private struct RedactedRecentScansSection: View { + var body: some View { + VStack(alignment: .leading, spacing: 16) { + // Header placeholder + VStack(alignment: .leading, spacing: 4) { + RoundedRectangle(cornerRadius: 6) + .fill(Color.gray.opacity(0.15)) + .frame(width: 120, height: 18) + RoundedRectangle(cornerRadius: 6) + .fill(Color.gray.opacity(0.15)) + .frame(width: 240, height: 12) + } + + // Row placeholders + ForEach(0..<3, id: \.self) { index in + HStack(spacing: 12) { + RoundedRectangle(cornerRadius: 12) + .fill(Color.gray.opacity(0.10)) + .frame(width: 56, height: 56) + VStack(alignment: .leading, spacing: 6) { + RoundedRectangle(cornerRadius: 4) + .fill(Color.gray.opacity(0.15)) + .frame(width: 140, height: 14) + RoundedRectangle(cornerRadius: 4) + .fill(Color.gray.opacity(0.10)) + .frame(width: 100, height: 12) + } + Spacer() + } + if index < 2 { + Divider() + } + } + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 24) + .fill(Color.white) + .overlay( + RoundedRectangle(cornerRadius: 24) + .stroke(lineWidth: 0.75) + .foregroundStyle(Color(hex: "#EEEEEE")) + ) + ) + .redacted(reason: .placeholder) + .shimmering() + } + } +} + +#Preview { + let webService = WebService() + HomeView() + .environmentObject(Onboarding(onboardingFlowtype: .individual)) + .environment(AppState()) + .environment(webService) + .environment(ScanHistoryStore(webService: webService)) + .environment(UserPreferences()) + .environment(AuthController()) + .environment(FamilyStore()) + .environment(AppNavigationCoordinator(initialRoute: .home)) + .environment(MemojiStore()) + .environment(ChatStore()) +} diff --git a/IngrediCheck/Views/ImageCaptureView.swift b/IngrediCheck/Views/ImageCaptureView.swift index 9755b22f..cf6ccc6c 100644 --- a/IngrediCheck/Views/ImageCaptureView.swift +++ b/IngrediCheck/Views/ImageCaptureView.swift @@ -1,6 +1,7 @@ import SwiftUI import AVFoundation import Vision +import os extension View { @ViewBuilder @@ -25,6 +26,7 @@ struct ImageCaptureView: View { @State private var showFocusToast = false @Environment(WebService.self) var webService @Environment(UserPreferences.self) var userPreferences + @Environment(CheckTabState.self) var checkTabState @Environment(\.dismiss) var dismiss var body: some View { @@ -117,8 +119,12 @@ struct ImageCaptureView: View { } } .onDisappear { - capturedImages = [] + // Don't clear scanId on disappear - we want to preserve it for navigation + // Only clear if explicitly clearing all images via deleteCapturedImages() + // capturedImages = [] // Commented out - preserve images for navigation + // checkTabState.scanId = nil // Commented out - preserve scanId for navigation cameraManager.stopSession() + Log.debug("PHOTO_SCAN", "πŸ“Έ ImageCaptureView disappeared - scanId preserved: \(checkTabState.scanId ?? "nil")") } } @@ -152,102 +158,71 @@ struct ImageCaptureView: View { } func capturePhoto() { + Log.debug("PHOTO_SCAN", "πŸ”΅ capturePhoto() called") cameraManager.capturePhoto { image in - if let image = image { - let ocrTask = startOCRTask(image: image) - let uploadTask = startUploadTask(image: image) - let barcodeDetectionTask = startBarcodeDetectionTask(image: image) - - withAnimation { - capturedImages.append(ProductImage( - image: image, - ocrTask: ocrTask, - uploadTask: uploadTask, - barcodeDetectionTask: barcodeDetectionTask)) - } - } - } - } - - func startUploadTask(image: UIImage) -> Task { - Task { - try await webService.uploadImage(image: image) - } - } - - func startOCRTask(image: UIImage) -> Task { - Task { - guard let cgImage = image.cgImage else { - return "" - } + Log.debug("PHOTO_SCAN", "πŸ“Έ Camera callback received - hasImage: \(image != nil)") - var imageText = "" - let request = VNRecognizeTextRequest { request, error in - guard let observations = request.results as? [VNRecognizedTextObservation] else { - fatalError("Received invalid observations") - } + if let image = image { + // Capture current state before async work + let checkTabState = self.checkTabState + let currentImageCount = self.capturedImages.count + + Log.debug("PHOTO_SCAN", "πŸ“Έ Photo captured - current_image_count: \(currentImageCount), new_count: \(currentImageCount + 1)") - for observation in observations { - guard let bestCandidate = observation.topCandidates(1).first else { - print("No candidate") - continue + Task { @MainActor in + // Generate scan_id when first image is captured + if checkTabState.scanId == nil { + checkTabState.scanId = UUID().uuidString + Log.debug("PHOTO_SCAN", "πŸ†” Generated scan_id: \(checkTabState.scanId!)") + } else { + Log.debug("PHOTO_SCAN", "πŸ†” Using existing scan_id: \(checkTabState.scanId!)") + } + + guard let scanId = checkTabState.scanId else { + Log.debug("PHOTO_SCAN", "❌ ERROR: scanId is nil after generation!") + return + } + + withAnimation { + self.capturedImages.append(ProductImage(image: image)) } - imageText += bestCandidate.string - imageText += "\n" + // Submit image to scan API - use currentImageCount (0-indexed) before appending + Log.debug("PHOTO_SCAN", "πŸš€ Starting Task to submit image - scanId: \(scanId), imageIndex: \(currentImageCount)") + Task { + await self.submitImage(image: image, scanId: scanId, imageIndex: currentImageCount) + } } + } else { + Log.debug("PHOTO_SCAN", "❌ Camera callback returned nil image") } - request.recognitionLevel = .accurate - request.usesLanguageCorrection = true - request.automaticallyDetectsLanguage = true - - let handler = VNImageRequestHandler(cgImage: cgImage, options: [:]) - try? handler.perform([request]) - return imageText } } - func startBarcodeDetectionTask(image: UIImage) -> Task { - Task { - guard let cgImage = image.cgImage else { - return nil - } - - let request = VNDetectBarcodesRequest() - request.symbologies = [.ean8, .ean13] - request.coalesceCompositeSymbologies = true - - let handler = VNImageRequestHandler(cgImage: cgImage, options: [:]) - - do { - try handler.perform([request]) - guard let results = request.results as [VNBarcodeObservation]?, !results.isEmpty else { - return nil - } - - return results.first?.payloadStringValue - } catch { - print("Failed to perform barcode detection: \(error)") - return nil - } + private func submitImage(image: UIImage, scanId: String, imageIndex: Int) async { + Log.debug("PHOTO_SCAN", "πŸ”΅ submitImage() called - scanId: \(scanId), imageIndex: \(imageIndex)") + + guard let imageData = image.jpegData(compressionQuality: 0.8) else { + Log.debug("PHOTO_SCAN", "❌ Failed to convert image to JPEG data - image_index: \(imageIndex)") + return + } + + let imageSizeKB = imageData.count / 1024 + Log.debug("PHOTO_SCAN", "πŸ“€ Submitting image - scan_id: \(scanId), image_index: \(imageIndex), image_size: \(imageSizeKB)KB") + do { + let response = try await webService.submitScanImage(scanId: scanId, imageData: imageData) + Log.debug("PHOTO_SCAN", "βœ… Image submitted successfully - scan_id: \(scanId), image_index: \(imageIndex), queued: \(response.queued), queue_position: \(response.queue_position)") + } catch { + Log.debug("PHOTO_SCAN", "❌ Failed to submit image - scan_id: \(scanId), image_index: \(imageIndex), error: \(error.localizedDescription)") } } func deleteCapturedImages() { - let imagesToDelete = capturedImages - withAnimation { capturedImages = [] + checkTabState.scanId = nil // Reset scanId when clearing images } - - Task { - var filesToDelete: [String] = [] - for productImage in imagesToDelete { - filesToDelete.append(try await productImage.uploadTask.value) - _ = try await productImage.ocrTask.value - } - try await webService.deleteImages(imageFileNames: filesToDelete) - } + // No need to delete from Supabase storage for scan images } } @@ -380,7 +355,7 @@ class CameraPreviewUIView: UIView { // Show temporary visual feedback (confirmation animation) showFocusIndicator(at: tapPoint) } catch { - print("Could not lock device for configuration: \(error)") + Log.error("ImageCaptureView", "Could not lock device for configuration: \(error)") } } @@ -430,14 +405,14 @@ class CameraManager: NSObject, AVCapturePhotoCaptureDelegate { device.unlockForConfiguration() } catch { - print("Could not lock device for configuration: \(error)") + Log.error("ImageCaptureView", "Could not lock device for configuration: \(error)") } } func setupSession() { session.sessionPreset = .photo guard let backCamera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) else { - print("No back camera available.") + Log.debug("ImageCaptureView", "No back camera available.") return } @@ -457,7 +432,7 @@ class CameraManager: NSObject, AVCapturePhotoCaptureDelegate { session.startRunning() } } catch { - print("Error: \(error.localizedDescription)") + Log.error("ImageCaptureView", "Error: \(error.localizedDescription)") } } @@ -473,7 +448,7 @@ class CameraManager: NSObject, AVCapturePhotoCaptureDelegate { func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) { if let error { - print("photoOutput callback received error: \(error)") + Log.error("ImageCaptureView", "photoOutput callback received error: \(error)") } else { if let completion = self.completion { if let imageData = photo.fileDataRepresentation(), diff --git a/IngrediCheck/Views/LabelAnalysisView.swift b/IngrediCheck/Views/LabelAnalysisView.swift index 10c6282a..90001a82 100644 --- a/IngrediCheck/Views/LabelAnalysisView.swift +++ b/IngrediCheck/Views/LabelAnalysisView.swift @@ -5,25 +5,28 @@ import PostHog @MainActor @Observable class LabelAnalysisViewModel { - let productImages: [ProductImage] + let scanId: String let webService: WebService let dietaryPreferences: DietaryPreferences let userPreferences: UserPreferences private let impactFeedback = UIImpactFeedbackGenerator(style: .medium) + private var pollingTask: Task? - init(_ productImages: [ProductImage], _ webService: WebService, _ dietaryPreferences: DietaryPreferences, _ userPreferences: UserPreferences) { - self.productImages = productImages + init(_ scanId: String, _ webService: WebService, _ dietaryPreferences: DietaryPreferences, _ userPreferences: UserPreferences) { + self.scanId = scanId self.webService = webService self.dietaryPreferences = dietaryPreferences self.userPreferences = userPreferences impactFeedback.prepare() } + @MainActor var scan: DTO.Scan? = nil @MainActor var product: DTO.Product? = nil @MainActor var error: Error? = nil @MainActor var ingredientRecommendations: [DTO.IngredientRecommendation]? = nil @MainActor var feedbackData = FeedbackData() + @MainActor var latestGuidance: String? = nil let clientActivityId = UUID().uuidString func impactOccurred() { @@ -31,86 +34,112 @@ import PostHog } func analyze() async { - let userPreferenceText = dietaryPreferences.asString - var streamErrorHandled = false let requestId = UUID().uuidString let startTime = Date().timeIntervalSince1970 - let imageCount = productImages.count self.error = nil - PostHogSDK.shared.capture("Label Analysis Started", properties: [ + Log.debug("PHOTO_SCAN", "πŸ”΅ Starting photo scan analysis - scan_id: \(scanId), request_id: \(requestId)") + Log.debug("PHOTO_SCAN", "⏳ Polling: YES (polling GET /scan/{scan_id} every 2 seconds)") + + PostHogSDK.shared.capture("Photo Scan Analysis Started", properties: [ "request_id": requestId, "client_activity_id": clientActivityId, - "image_count": imageCount, - "has_preferences": !userPreferenceText.isEmpty && userPreferenceText.lowercased() != "none" + "scan_id": scanId ]) - do { - try await webService.streamUnifiedAnalysis( - input: .productImages(productImages), - clientActivityId: clientActivityId, - userPreferenceText: userPreferenceText, - onProduct: { product in - withAnimation { - self.product = product + // Poll for scan status + pollingTask = Task { + var isComplete = false + var pollCount = 0 + + while !isComplete && !Task.isCancelled { + pollCount += 1 + do { + Log.debug("PHOTO_SCAN", "πŸ”„ Poll #\(pollCount) - Getting scan status for scan_id: \(scanId)") + let currentScan = try await webService.getScan(scanId: scanId) + + await MainActor.run { + self.scan = currentScan + self.latestGuidance = currentScan.latest_guidance + + // Update product from scan + let productInfo = currentScan.product_info + let imageLocations: [DTO.ImageLocationInfo] = productInfo.images?.compactMap { scanImageInfo in + guard let urlString = scanImageInfo.url, + let url = URL(string: urlString) else { + return nil + } + return .url(url) + } ?? [] + + self.product = DTO.Product( + barcode: currentScan.barcode, + brand: productInfo.brand, + name: productInfo.name, + ingredients: productInfo.ingredients, + images: imageLocations, + claims: nil + ) + + // Update recommendations when analysis is complete + if currentScan.state == "done", + let analysisResult = currentScan.analysis_result { + let totalLatency = (Date().timeIntervalSince1970 - startTime) * 1000 + Log.debug("PHOTO_SCAN", "βœ… Analysis complete - scan_id: \(self.scanId), poll_count: \(pollCount), total_latency: \(Int(totalLatency))ms") + Log.debug("PHOTO_SCAN", "🎯 Stopping polls - state: done") + + withAnimation { + self.ingredientRecommendations = analysisResult.toIngredientRecommendations() + } + self.impactOccurred() + self.userPreferences.incrementScanCount() + + PostHogSDK.shared.capture("Photo Scan Analysis Completed", properties: [ + "request_id": requestId, + "client_activity_id": self.clientActivityId, + "scan_id": self.scanId, + "recommendations_count": self.ingredientRecommendations?.count ?? 0, + "total_latency_ms": totalLatency + ]) + + isComplete = true + } else { + Log.debug("PHOTO_SCAN", "⏳ Still processing - scan_id: \(self.scanId), state: \(currentScan.state), continuing to poll...") + } } - self.impactOccurred() - - PostHogSDK.shared.capture("Label Analysis Product Received", properties: [ - "request_id": requestId, - "client_activity_id": self.clientActivityId, - "image_count": imageCount, - "product_name": product.name ?? "Unknown", - "latency_ms": (Date().timeIntervalSince1970 - startTime) * 1000 - ]) - }, - onAnalysis: { recommendations in - withAnimation { - self.ingredientRecommendations = recommendations + + if !isComplete { + Log.debug("PHOTO_SCAN", "⏸️ Waiting 2 seconds before next poll...") + try await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds + } + } catch { + await MainActor.run { + if !Task.isCancelled { + let totalLatency = (Date().timeIntervalSince1970 - startTime) * 1000 + Log.debug("PHOTO_SCAN", "❌ Poll error - scan_id: \(self.scanId), poll_count: \(pollCount), error: \(error.localizedDescription)") + self.error = error + + PostHogSDK.shared.capture("Photo Scan Polling Failed", properties: [ + "request_id": requestId, + "client_activity_id": self.clientActivityId, + "scan_id": self.scanId, + "error": error.localizedDescription, + "total_latency_ms": totalLatency + ]) + } + isComplete = true } - self.impactOccurred() - - PostHogSDK.shared.capture("Label Analysis Completed", properties: [ - "request_id": requestId, - "client_activity_id": self.clientActivityId, - "image_count": imageCount, - "recommendations_count": recommendations.count, - "total_latency_ms": (Date().timeIntervalSince1970 - startTime) * 1000 - ]) - - self.userPreferences.incrementScanCount() - }, - onError: { streamError in - streamErrorHandled = true - self.error = streamError - - PostHogSDK.shared.capture("Label Analysis Failed", properties: [ - "request_id": requestId, - "client_activity_id": self.clientActivityId, - "image_count": imageCount, - "status_code": streamError.statusCode ?? -1, - "error": streamError.message, - "total_latency_ms": (Date().timeIntervalSince1970 - startTime) * 1000 - ]) } - ) - } catch { - if !streamErrorHandled { - self.error = error - - PostHogSDK.shared.capture("Label Analysis Failed", properties: [ - "request_id": requestId, - "client_activity_id": clientActivityId, - "image_count": imageCount, - "status_code": -1, - "error": error.localizedDescription, - "total_latency_ms": (Date().timeIntervalSince1970 - startTime) * 1000 - ]) } } - + + await pollingTask?.value impactOccurred() } + + func cancel() { + pollingTask?.cancel() + } func submitFeedback() { Task { @@ -121,7 +150,7 @@ import PostHog struct LabelAnalysisView: View { - let productImages: [ProductImage] + let scanId: String @Environment(WebService.self) var webService @Environment(UserPreferences.self) var userPreferences @@ -152,10 +181,17 @@ struct LabelAnalysisView: View { ProductImagesView(images: product.images) { Task { @MainActor in - checkTabState.capturedImages = productImages _ = checkTabState.routes.popLast() } } + + // Display latest guidance if available + if let guidance = viewModel.latestGuidance, !guidance.isEmpty { + Text(guidance) + .font(.subheadline) + .foregroundStyle(.secondary) + .padding(.horizontal) + } if let brand = product.brand { Text(brand) @@ -183,13 +219,6 @@ struct LabelAnalysisView: View { .toolbar { ToolbarItemGroup(placement: .topBarTrailing) { if viewModel.ingredientRecommendations != nil { - Button(action: { - checkTabState.capturedImages = productImages - _ = checkTabState.routes.popLast() - }, label: { - Image(systemName: "photo.badge.plus") - .font(.subheadline) - }) StarButton(clientActivityId: viewModel.clientActivityId, favorited: false) Button(action: { checkTabState.feedbackConfig = FeedbackConfig( @@ -217,12 +246,28 @@ struct LabelAnalysisView: View { } } } else { - ProgressView() - .task { - let newViewModel = LabelAnalysisViewModel(productImages, webService, dietaryPreferences, userPreferences) - Task { await newViewModel.analyze() } - DispatchQueue.main.async { self.viewModel = newViewModel } + VStack { + Spacer() + if let guidance = viewModel?.latestGuidance, !guidance.isEmpty { + Text(guidance) + .font(.subheadline) + .foregroundStyle(.secondary) + .padding() + } else { + Text("Analyzing Image...") } + Spacer() + ProgressView() + Spacer() + } + .task { + let newViewModel = LabelAnalysisViewModel(scanId, webService, dietaryPreferences, userPreferences) + DispatchQueue.main.async { self.viewModel = newViewModel } + Task { await newViewModel.analyze() } + } + .onDisappear { + viewModel?.cancel() + } } } } diff --git a/IngrediCheck/Views/LetsGetStartedView.swift b/IngrediCheck/Views/LetsGetStartedView.swift new file mode 100644 index 00000000..7eaf5c5b --- /dev/null +++ b/IngrediCheck/Views/LetsGetStartedView.swift @@ -0,0 +1,22 @@ +// +// LetsGetStartedView.swift +// IngrediCheckPreview +// +// Created by Gunjan Haldar on 10/11/25. +// + +import SwiftUI + +struct LetsGetStartedView: View { + + var body: some View { + VStack { + Text("Let's get started! Your IngrediFam will appear here as you set things up.") + .multilineTextAlignment(.center) + } + } +} + +#Preview { + LetsGetStartedView() +} diff --git a/IngrediCheck/Views/LetsMeetYourIngrediFamView.swift b/IngrediCheck/Views/LetsMeetYourIngrediFamView.swift new file mode 100644 index 00000000..fd74b98d --- /dev/null +++ b/IngrediCheck/Views/LetsMeetYourIngrediFamView.swift @@ -0,0 +1,416 @@ +// +// LetsMeetYourIngrediFamView.swift +// IngrediCheckPreview +// +// Created by Gunjan Haldar on 11/11/25. +// + +import SwiftUI +import UIKit + +struct LetsMeetYourIngrediFamView: View { + @Environment(AppNavigationCoordinator.self) private var coordinator + @Environment(FamilyStore.self) private var familyStore + @Environment(WebService.self) private var webService + + @State private var showLeaveConfirm = false + @State private var shareItems: ShareItem? + @State private var isGeneratingInviteCode: Bool = false + + private let appStoreURL = "https://apps.apple.com/us/app/ingredicheck-grocery-scanner/id6477521615" + + struct ShareItem: Identifiable { + let id = UUID() + let items: [Any] + } + + private var shouldShowWelcomeFamily: Bool { + // Show welcome screen when joining via invite code + if coordinator.isJoiningViaInviteCode { + return true + } + switch coordinator.currentBottomSheetRoute { + case .whosThisFor: + return true + default: + return false + } + } + + private func initial(from name: String) -> String { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard let first = trimmed.first else { return "" } + return String(first).uppercased() + } + + // Helper view to display member avatar with edit button overlay + struct MemberAvatarView: View { + let member: FamilyMember + let initial: (String) -> String + + var body: some View { + ZStack(alignment: .bottomTrailing) { + // Use centralized MemberAvatar component + MemberAvatar.medium(member: member) + + // Edit button overlay + Circle() + .fill(.grayScale40) + .frame(width: 16, height: 16) + .overlay( + Image("pen-line") + .frame(width: 7.43, height: 7.43) + ) + .offset(x: -4, y: 4) + } + } + } + + var body: some View { + Group { + if shouldShowWelcomeFamily { + VStack { + Text("Welcome to IngrediFam!") + .font(ManropeFont.bold.size(16)) + .padding(.top, 24) + .padding(.bottom ,4) + Text("Join your family space and personalize food choices together.") + .font(ManropeFont.regular.size(13)) + .foregroundColor(Color(hex: "#BDBDBD")) + .lineLimit(2) + .frame(width : 247) + .multilineTextAlignment(.center ) + + Image("onbordingfamilyimg2") + .resizable() + .scaledToFit() + .frame(height: 369) + .frame(maxWidth: .infinity) + .offset(y : -50) + + Spacer() + } + .navigationBarBackButtonHidden(true) + } else if let me = familyStore.family?.selfMember ?? familyStore.pendingSelfMember { + VStack(spacing: 0) { + Text("Your Family Overview") + .font(NunitoFont.bold.size(18)) + .foregroundStyle(.grayScale150) +// .padding(.top, 32) + .padding(.bottom, 12) + + + ScrollView { + VStack(spacing: 12) { + HStack(spacing: 12) { + MemberAvatarView(member: me, initial: initial(from:)) + + VStack(alignment: .leading, spacing: 2) { + Text(me.name) + .font(NunitoFont.semiBold.size(18)) + .foregroundStyle(.grayScale150) + Text("(You)") + .font(NunitoFont.regular.size(12)) + .foregroundStyle(.grayScale110) + } + + Spacer() + + if coordinator.isCreatingFamilyFromSettings { + Button { + showLeaveConfirm = true + } label: { + Text("Leave Family") + .font(NunitoFont.semiBold.size(12)) + .foregroundStyle(.grayScale110) + .padding(.vertical, 8) + .padding(.horizontal, 18) + .background(Color.clear, in: Capsule()) + } + .buttonStyle(.plain) + .confirmationDialog("Leave Family", isPresented: $showLeaveConfirm) { + Button("Leave Family", role: .destructive) { + print("[LetsMeetYourIngrediFamView] Leave Family confirmed") + Task { + print("[LetsMeetYourIngrediFamView] Calling familyStore.leave()") + await familyStore.leave() + let error = familyStore.errorMessage ?? "nil" + print("[LetsMeetYourIngrediFamView] familyStore.leave() finished. errorMessage=\(error)") + + if familyStore.errorMessage == nil { + print("[LetsMeetYourIngrediFamView] Leave success -> resetting local state and returning to start") + familyStore.resetLocalState() + coordinator.showCanvas(.heyThere) + } else { + print("[LetsMeetYourIngrediFamView] Leave failed -> staying on overview") + } + } + } + } message: { + Text("Are you sure you want to leave?") + } + } + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 24) + .fill(Color.white) + .overlay( + RoundedRectangle(cornerRadius: 24) + .stroke(lineWidth: 0.75) + .foregroundStyle(Color(hex: "#EEEEEE")) + ) + ) + .padding(.horizontal, 20) + .contentShape(Rectangle()) + .onTapGesture { + coordinator.navigateInBottomSheet(.meetYourProfile(memberId: me.id)) + } + + let others = (familyStore.family?.otherMembers ?? []) + familyStore.pendingOtherMembers + // Remove duplicates if any (though pending should be cleared if family exists) + // Actually, if family exists, pendingOtherMembers should be empty after create/add. + // But if we are in AddMoreMembers flow, we might have added members to family immediately. + + ForEach(others) { member in + HStack(spacing: 12) { + MemberAvatarView(member: member, initial: initial(from:)) + + VStack(alignment: .leading, spacing: 2) { + Text(member.name) + .font(NunitoFont.semiBold.size(18)) + .foregroundStyle(.grayScale150) + if member.invitePending == true || (!member.joined && member.id != me.id) { + // Show pending for anyone not joined (except self which is assumed joined) + // or if explicitly invitePending + HStack(spacing: 6) { + Image(systemName: "exclamationmark.circle.fill") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(Color(hex: "F4A100")) + Text("Pending") + .font(ManropeFont.semiBold.size(12)) + .foregroundStyle(Color(hex: "F4A100")) + } + .padding(.vertical, 4) + .padding(.horizontal, 10) + .background( + Capsule() + .fill(Color(hex: "FFF7E6")) + ) + } else { + Text("Not joined yet !") + .font(NunitoFont.regular.size(12)) + .foregroundStyle(.grayScale100) + } + } + + Spacer() + + Button { + Task { @MainActor in + await handleInviteShare(memberId: member.id) + } + } label: { + HStack(spacing: 10) { + Image( "share") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(Color(hex: "91B640")) + Text("Invite") + .font(NunitoFont.semiBold.size(12)) + .foregroundStyle(Color(hex: "91B640")) + } + .padding(.vertical, 8) + .padding(.horizontal, 18) + .background( + Capsule().fill(Color.white) + ) + .overlay( + Capsule() + .stroke(lineWidth: 1.5) + .foregroundStyle(Color(hex: "91B640")) + ) + } + .buttonStyle(.plain) + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 24) + .fill(Color.white) + .overlay( + RoundedRectangle(cornerRadius: 24) + .stroke(lineWidth: 0.75) + .foregroundStyle(Color(hex: "#EEEEEE")) + ) + ) + .padding(.horizontal, 20) + .contentShape(Rectangle()) + .onTapGesture { + coordinator.navigateInBottomSheet(.meetYourProfile(memberId: member.id)) + } + } + } + .padding(.top, 10) + .padding(.bottom, UIScreen.main.bounds.height * 0.3) + } + } + .navigationBarBackButtonHidden(true) + } else { + VStack { + Text("Getting Started!") + .font(ManropeFont.bold.size(16)) + .padding(.top, 24) + .padding(.bottom ,4) + Text("Add profiles so IngredientCheck can personalize results for each person.") + .font(ManropeFont.regular.size(13)) + .foregroundColor(Color(hex: "#BDBDBD")) + .lineLimit(2) + .frame(width : 247) + .multilineTextAlignment(.center ) + + Image("addfamilyimg") + .resizable() + .scaledToFit() + .frame(height: 369) + .frame(maxWidth: .infinity) + .offset(y : -50) + + Spacer() + } + .navigationBarBackButtonHidden(true) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.pageBackground) + .sheet(item: $shareItems) { shareItem in + ShareSheet(activityItems: shareItem.items) + } + } + + // MARK: - Invite Share Helper + + @MainActor + private func handleInviteShare(memberId: UUID) async { + guard !isGeneratingInviteCode else { return } + + isGeneratingInviteCode = true + defer { isGeneratingInviteCode = false } + + // Mark member as pending so the UI reflects it + familyStore.setInvitePendingForPendingOtherMember(id: memberId, pending: true) + + // Ensure family exists before creating invite codes + if familyStore.family == nil { + if coordinator.isCreatingFamilyFromSettings { + await familyStore.addPendingMembersToExistingFamily() + } else { + await familyStore.createFamilyFromPendingIfNeeded() + } + } + + guard let code = await familyStore.invite(memberId: memberId) else { + return + } + + let message = inviteShareMessage(inviteCode: code) + let items = [message] + shareItems = ShareItem(items: items) + } + + private func inviteShareMessage(inviteCode: String) -> String { + let formattedCode = formattedInviteCode(inviteCode) + return "You've been invited to join my IngrediCheck family.\nSet up your food profile and get personalized ingredient guidance tailored just for you.\n\nπŸ“² Download from the App Store \(appStoreURL) and enter this invite code:\n\(formattedCode)" + } + + private func formattedInviteCode(_ inviteCode: String) -> String { + let spaced = inviteCode.map { String($0) }.joined(separator: " ") + return "**\(spaced)**" + } +} + +// MARK: - SwiftUI Share Sheet + +struct ShareSheet: UIViewControllerRepresentable { + let activityItems: [Any] + let applicationActivities: [UIActivity]? = nil + + func makeUIViewController(context: Context) -> UIActivityViewController { + let controller = UIActivityViewController( + activityItems: activityItems, + applicationActivities: applicationActivities + ) + + // Configure for iPad + if let popover = controller.popoverPresentationController { + popover.sourceView = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap { $0.windows } + .first { $0.isKeyWindow } + popover.sourceRect = CGRect( + x: UIScreen.main.bounds.midX, + y: UIScreen.main.bounds.maxY, + width: 0, + height: 0 + ) + popover.permittedArrowDirections = [] + } + + return controller + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) { + // No updates needed + } +} + +/// Helper view for Onboarding avatars that supports both local assets and remote hashes. +fileprivate struct OnboardingSmartAvatarView: View { + let imageName: String + + @Environment(WebService.self) private var webService + @State private var remoteImage: UIImage? = nil + + var body: some View { + ZStack { + if let local = UIImage(named: imageName) { + Image(uiImage: local) + .resizable() + .scaledToFill() + .frame(width: 42, height: 42) + .clipShape(Circle()) + } else if let remote = remoteImage { + Image(uiImage: remote) + .resizable() + .scaledToFill() + .frame(width: 42, height: 42) + .clipShape(Circle()) + } else { + // Loading / Placeholder + Circle() + .fill(Color.grayScale20) + .frame(width: 42, height: 42) + .overlay(ProgressView().scaleEffect(0.5)) + .task { + await loadRemote() + } + } + } + } + + @MainActor + private func loadRemote() async { + do { + let uiImage = try await webService.fetchImage( + imageLocation: .imageFileHash(imageName), + imageSize: .small + ) + self.remoteImage = uiImage + } catch { + print("Failed to load onboarding avatar: \(error)") + } + } +} + +#Preview { + LetsMeetYourIngrediFamView() +} + diff --git a/IngrediCheck/Views/Lib/FeedbackView.swift b/IngrediCheck/Views/Lib/FeedbackView.swift index df73ccfb..ba4370cd 100644 --- a/IngrediCheck/Views/Lib/FeedbackView.swift +++ b/IngrediCheck/Views/Lib/FeedbackView.swift @@ -44,13 +44,115 @@ struct FeedbackView: View { var body: some View { NavigationStack(path: $routes) { switch feedbackCaptureOptions { - case .feedbackOnly, .feedbackAndImages: + case .feedbackOnly: + ScrollView { + VStack(spacing: 0) { + // Header + HStack { + Button { + dismiss() + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 18, weight: .semibold)) + .frame(width: 24, height: 14) + .foregroundStyle(.grayScale150) + } + Spacer() + Text("Share Your Feedback") + .font(NunitoFont.bold.size(22)) + .foregroundStyle(.grayScale150) + Spacer() + // spacer to balance header + Color.clear.frame(width: 24, height: 24) + } + .padding(.horizontal, 12) + .padding(.top, 15) + .padding(.bottom, 12) + + // Description + Text("Thanks for your feedback! Your ideas, issues, and praise help us improve. Please share your thoughts below.") + .frame(width :333) + .font(ManropeFont.regular.size(12)) + .foregroundStyle(.grayScale120) + .multilineTextAlignment(.center) + .padding(.horizontal, 20) + .padding(.bottom, 24) + + // Rating section + VStack( spacing: 16) { + Text("How is your experience with our app?") + .font(NunitoFont.bold.size(16)) + .foregroundStyle(.grayScale150) + + + HStack(spacing: 8) { + ratingOption(emoji: "😠", title: "Terrible", value: 1) + ratingOption(emoji: "☹️", title: "Bad", value: 2) + ratingOption(emoji: "πŸ™‚", title: "Average", value: 3) + ratingOption(emoji: "😊", title: "Good", value: 4) + ratingOption(emoji: "😍", title: "Excellent", value: 5) + } + } + .padding(.horizontal, 20) + .padding(.bottom, 24) + + // Comment section + VStack(alignment: .leading, spacing: 4) { + Text("Tell us what you think of this app") + .font(NunitoFont.bold.size(16)) + .foregroundStyle(.grayScale150) + Text("(Optional)") + .font(NunitoFont.regular.size(12)) + .foregroundStyle(.grayScale110) + .padding(.bottom ,12) + + ZStack(alignment: .topLeading) { + RoundedRectangle(cornerRadius: 10) + .fill(.white) + .frame(height: 47) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color(hex: "#E3E3E3"), lineWidth: 1) + ) + + TextEditor(text: $feedbackData.note) + .focused($isFocused) + .scrollContentBackground(.hidden) + .padding(12) + .frame(height: 47) + .foregroundStyle(.grayScale150) + if !isFocused && feedbackData.note.isEmpty { + Text("") + } + } + } + .padding(.horizontal, 20) + + // Submit button + Button { + onSubmit() + dismiss() + } label: { + GreenCapsule(title: "Submit") + .frame(width: 180) + } + .buttonStyle(.plain) + .padding(.top, 28) + }.background(Color.white) + .padding(.vertical, 16) + } + .background(.white) + .scrollIndicators(.hidden) + .toolbar(.hidden, for: .navigationBar) + .gesture(TapGesture().onEnded { isFocused = false }) + .dismissKeyboardOnTap() + .presentationDetents([.height(479)]) + .presentationBackground(.regularMaterial) + case .feedbackAndImages: ScrollView { VStack(spacing: 30) { - Text("What should I look into?") .padding(.horizontal) - VStack(alignment: .leading, spacing: 15) { ForEach(FeedbackReason.allCases, id: \.self) { reason in HStack { @@ -67,17 +169,11 @@ struct FeedbackView: View { } } } - TextEditor(text: $feedbackData.note) .focused($isFocused) .frame(height: 120) - .clipShape( - RoundedRectangle(cornerRadius: 8) - ) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(Color.secondary, lineWidth: 1) - ) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.secondary, lineWidth: 1)) .overlay( Group { if !isFocused && feedbackData.note.isEmpty { @@ -86,21 +182,16 @@ struct FeedbackView: View { } } ) - Spacer() } } .scrollIndicators(.hidden) .padding() - .navigationTitle("Help me Improve πŸ₯Ή") + .navigationTitle("Help me improve πŸ₯Ή") .navigationBarTitleDisplayMode(.inline) - .navigationBarItems( - leading: cancelButton, - trailing: nextOrSubmitButton - ) - .gesture(TapGesture().onEnded { - isFocused = false - }) + .navigationBarItems(leading: cancelButton, trailing: nextOrSubmitButton) + .gesture(TapGesture().onEnded { isFocused = false }) + .dismissKeyboardOnTap() .navigationDestination(for: String.self) { item in if item == "captureImages" { ImageCaptureView( @@ -129,6 +220,32 @@ struct FeedbackView: View { } } } + + @ViewBuilder + private func ratingOption(emoji: String, title: String, value: Int) -> some View { + Button { + feedbackData.rating = value + } label: { + VStack(spacing: 8) { + ZStack { + RoundedRectangle(cornerRadius: 12) + .fill(feedbackData.rating == value ? Color(hex: "#EEF5E3") : Color(hex: "#F9F9F8")) + .frame(width: 56, height: 56) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(feedbackData.rating == value ? Color(hex: "#75990E") : .grayScale50, lineWidth: 1) + ) + .shadow(color: Color.black.opacity(0.04), radius: 6, x: 0, y: 2) + Text(emoji) + .font(.system(size: 28)) + } + Text(title) + .font(NunitoFont.medium.size(12)) + .foregroundStyle(feedbackData.rating == value ? .grayScale150 : .grayScale110) + } + } + .buttonStyle(.plain) + } private var cancelButton: some View { Button("Cancel") { diff --git a/IngrediCheck/Views/Lib/SearchBar.swift b/IngrediCheck/Views/Lib/SearchBar.swift index e0323f31..a109fef4 100644 --- a/IngrediCheck/Views/Lib/SearchBar.swift +++ b/IngrediCheck/Views/Lib/SearchBar.swift @@ -43,5 +43,6 @@ struct SearchBar: View { .onAppear { isFocused = true } + .dismissKeyboardOnTap() } } diff --git a/IngrediCheck/Views/Lib/Splash.swift b/IngrediCheck/Views/Lib/Splash.swift index 25263fdc..9517b983 100644 --- a/IngrediCheck/Views/Lib/Splash.swift +++ b/IngrediCheck/Views/Lib/Splash.swift @@ -36,3 +36,4 @@ struct Splash: View { } } } + diff --git a/IngrediCheck/Views/Lib/View+Extensions.swift b/IngrediCheck/Views/Lib/View+Extensions.swift index 3cee2f62..ee5c7626 100644 --- a/IngrediCheck/Views/Lib/View+Extensions.swift +++ b/IngrediCheck/Views/Lib/View+Extensions.swift @@ -10,4 +10,17 @@ extension View { } } } + + func dismissKeyboardOnTap() -> some View { + self.simultaneousGesture( + TapGesture().onEnded { + UIApplication.shared.sendAction( + #selector(UIResponder.resignFirstResponder), + to: nil, + from: nil, + for: nil + ) + } + ) + } } diff --git a/IngrediCheck/Views/Onboarding/DynamicOnboardingViews.swift b/IngrediCheck/Views/Onboarding/DynamicOnboardingViews.swift new file mode 100644 index 00000000..caf28392 --- /dev/null +++ b/IngrediCheck/Views/Onboarding/DynamicOnboardingViews.swift @@ -0,0 +1,841 @@ +// +// DynamicOnboardingViews.swift +// IngrediCheckPreview +// +// Created to render dynamic onboarding JSON in three reusable shapes: +// - type-1: simple chip lists (Allergies-style) +// - type-2: stacked cards with chips (Avoid/LifeStyle/Nutrition-style) +// - type-3: grouped/expandable regions (Region-style) +// + +import SwiftUI + +// MARK: - Type 1: Simple options list (Allergies-style) + +struct DynamicOptionsQuestionView: View { + let step: DynamicStep + let flowType: OnboardingFlowType + @Binding var preferences: Preferences + @EnvironmentObject private var store: Onboarding + + private var headerVariant: DynamicHeaderVariant { + switch flowType { + case .individual: return step.header.individual + case .family: return step.header.family + case .singleMember: return step.header.singleMember ?? step.header.family + } + } + + var body: some View { + let options = step.content.options ?? [] + let selectedNames = currentSelections() + + VStack(spacing: 20) { + VStack(alignment: .leading, spacing: 4) { + if flowType == .singleMember, let name = store.memberName { + onboardingSheetTitle( + template: headerVariant.question, + memberName: name, + memberColor: Color(hex: "91B640") + ) + } else { + onboardingSheetTitle(title: headerVariant.question) + } + if let description = headerVariant.description { + onboardingSheetSubtitle(subtitle: description, onboardingFlowType: flowType) + } + } + .padding(.horizontal, 20) + + if flowType == .family || flowType == .singleMember { + VStack(alignment: .leading, spacing: 8) { + FamilyCarouselView() + if flowType == .family { + onboardingSheetFamilyMemberSelectNote() + } + } + .padding(.leading, 20) + } + + FlowLayout(horizontalSpacing: 8, verticalSpacing: 8) { + ForEach(options) { option in + IngredientsChips( + title: option.name, + image: option.icon, + onClick: { toggleSelection(for: option.name) }, + isSelected: selectedNames.contains(option.name) + ) + } + } + .padding(.horizontal, 20) + } + .id(step.id) + } + + private func currentSelections() -> Set { + Set(valuesForCurrentStep()) + } + + private func valuesForCurrentStep() -> [String] { + let sectionName = step.header.name + guard let value = preferences.sections[sectionName], + case .list(let items) = value else { + return [] + } + return items + } + + private func setValues(_ values: [String]) { + let sectionName = step.header.name + preferences.sections[sectionName] = .list(values) + } + + private func toggleSelection(for name: String) { + var values = valuesForCurrentStep() + let lowerName = name.lowercased() + let isExclusive = (lowerName == "none of these apply") + + if isExclusive { + // If Exclusive option is selected: + // 1. Clear everything else + // 2. Toggle itself (if already selected, remove it; if not, set it as the only item) + if values.contains(name) { + values.removeAll { $0 == name } + } else { + values = [name] + } + } else { + // Normal option selected (including "Other") + // 1. Remove any exclusive options ("None of these apply") + values.removeAll { $0.lowercased() == "none of these apply" } + + // 2. Toggle the selected option + if let index = values.firstIndex(of: name) { + values.remove(at: index) + } else { + values.append(name) + } + } + + setValues(values) + } +} + +// MARK: - Type 2: Stacked cards with chips (Avoid/LifeStyle/Nutrition-style) + +struct DynamicSubStepsQuestionView: View { + let step: DynamicStep + let flowType: OnboardingFlowType + @Binding var preferences: Preferences + @Environment(UserPreferences.self) var userPreferences + @EnvironmentObject private var store: Onboarding + + @State private var showTutorial: Bool = false + @State private var cardFrame: CGRect = .zero + @State private var isAnimatingHand: Bool = false + + private var headerVariant: DynamicHeaderVariant { + switch flowType { + case .individual: return step.header.individual + case .family: return step.header.family + case .singleMember: return step.header.singleMember ?? step.header.family + } + } + + var body: some View { + let subSteps = step.content.subSteps ?? [] + + let cards: [Card] = subSteps.map { subStep in + let chipModels = (subStep.options ?? []).map { ChipsModel(name: $0.name, icon: $0.icon) } + let color: Color + if let hex = subStep.colorHex { + color = Color(hex: hex) + } else { + color = .avatarYellow + } + return Card( + title: subStep.title, + subTitle: subStep.description, + color: color, + chips: chipModels + ) + } + + ZStack { + VStack(spacing: 20) { + VStack(alignment: .leading, spacing: 4) { + if flowType == .singleMember, let name = store.memberName { + onboardingSheetTitle( + template: headerVariant.question, + memberName: name, + memberColor: Color(hex: "91B640") + ) + } else { + onboardingSheetTitle(title: headerVariant.question) + } + if let description = headerVariant.description { + onboardingSheetSubtitle(subtitle: description, onboardingFlowType: flowType) + } + } + .padding(.horizontal, 20) + + if flowType == .family || flowType == .singleMember { + VStack(alignment: .leading, spacing: 8) { + FamilyCarouselView() + if flowType == .family { + onboardingSheetFamilyMemberSelectNote() + } + } + .padding(.leading, 20) + } + + StackedCards( + cards: cards, + isChipSelected: { card, chip in + selections(for: card.title).contains(chip.name) + }, + onChipTap: { card, chip in + toggleSelection(cardTitle: card.title, chipName: chip.name) + }, + onSwipe: { + if showTutorial { + withAnimation { + showTutorial = false + userPreferences.cardsSwipeTutorialShown = true + } + } + } + ) + .background( + GeometryReader { geo in + Color.clear + .onAppear { + cardFrame = geo.frame(in: .global) + } + .onChange(of: geo.frame(in: .global)) { + cardFrame = $0 + } + } + ) + .padding(.horizontal, 20) + } + .preference( + key: TutorialOverlayPreferenceKey.self, + value: TutorialData(show: showTutorial, cardFrame: cardFrame) + ) + } + .id(step.id) + .onAppear { + if step.id == "avoid" && !userPreferences.cardsSwipeTutorialShown { + // Determine if we should show tutorial + // Wait slightly for layout to settle + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + withAnimation { + showTutorial = true + } + } + } + } + .onReceive(NotificationCenter.default.publisher(for: .dismissSwipeTutorial)) { _ in + // Handle tap-to-dismiss from overlay + if showTutorial { + withAnimation { + showTutorial = false + userPreferences.cardsSwipeTutorialShown = true + } + } + } + } + + private func selections(for cardTitle: String) -> Set { + let sectionName = step.header.name + guard let value = preferences.sections[sectionName], + case .nested(let nestedDict) = value, + let items = nestedDict[cardTitle] else { + return [] + } + return Set(items) + } + + private func toggleSelection(cardTitle: String, chipName: String) { + var set = selections(for: cardTitle) + let lowerName = chipName.lowercased() + let isExclusive = (lowerName == "none of these apply") + + if isExclusive { + if set.contains(chipName) { + set.remove(chipName) + } else { + set = [chipName] + } + } else { + // Normal option selected (including "Other") + // Remove any exclusive options ("None of these apply") + let exclusives = set.filter { $0.lowercased() == "none of these apply" } + for exclusive in exclusives { + set.remove(exclusive) + } + + // Toggle the selected option + if set.contains(chipName) { + set.remove(chipName) + } else { + set.insert(chipName) + } + } + + syncPreferences(from: set, for: cardTitle) + } + + private func syncPreferences(from set: Set, for cardTitle: String) { + let sectionName = step.header.name + var nestedDict: [String: [String]] + + if let existingValue = preferences.sections[sectionName], + case .nested(let existingDict) = existingValue { + nestedDict = existingDict + } else { + nestedDict = [:] + } + + nestedDict[cardTitle] = Array(set) + preferences.sections[sectionName] = .nested(nestedDict) + } +} + +// MARK: - Tutorial Data Structures + +/// Notification posted when user taps the tutorial overlay to dismiss it +extension Notification.Name { + static let dismissSwipeTutorial = Notification.Name("dismissSwipeTutorial") +} + +struct TutorialData: Equatable { + var show: Bool + var cardFrame: CGRect +} + +struct TutorialOverlayPreferenceKey: PreferenceKey { + static var defaultValue: TutorialData = TutorialData(show: false, cardFrame: .zero) + + static func reduce(value: inout TutorialData, nextValue: () -> TutorialData) { + let next = nextValue() + if next.show { + value = next + } + } +} + +// MARK: - Type 3: Grouped/expandable regions (Region-style) + +struct DynamicRegionsQuestionView: View { + let step: DynamicStep + let flowType: OnboardingFlowType + @Binding var preferences: Preferences + @EnvironmentObject private var store: Onboarding + + @State private var sections: [SectionedChipModel] = [] + @State private var expandedSectionIds: Set = [] + + private var headerVariant: DynamicHeaderVariant { + switch flowType { + case .individual: return step.header.individual + case .family: return step.header.family + case .singleMember: return step.header.singleMember ?? step.header.family + } + } + + init(step: DynamicStep, flowType: OnboardingFlowType, preferences: Binding) { + self.step = step + self.flowType = flowType + self._preferences = preferences + + let initialSections: [SectionedChipModel] = (step.content.regions ?? []).map { region in + SectionedChipModel( + title: region.name, + subtitle: nil, + chips: region.subRegions.map { ChipsModel(name: $0.name, icon: $0.icon) } + ) + } + _sections = State(initialValue: initialSections) + _expandedSectionIds = State(initialValue: []) + } + + var body: some View { + VStack(spacing: 20) { + VStack(alignment: .leading, spacing: 4) { + if flowType == .singleMember, let name = store.memberName { + onboardingSheetTitle( + template: headerVariant.question, + memberName: name, + memberColor: Color(hex: "91B640") + ) + } else { + onboardingSheetTitle(title: headerVariant.question) + } + if let description = headerVariant.description { + onboardingSheetSubtitle(subtitle: description, onboardingFlowType: flowType) + } + } + .padding(.horizontal, 20) + + if flowType == .family || flowType == .singleMember { + VStack(alignment: .leading, spacing: 8) { + FamilyCarouselView() + if flowType == .family { + onboardingSheetFamilyMemberSelectNote() + } + } + .padding(.leading, 20) + } + + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + ForEach(sections) { section in + // If a region only has a single sub-region, skip the expandable + // header and surface the chip directly – a dropdown for a single + // option doesn’t add any value. + let selectedSet = selections(for: section.title) + if section.chips.count == 1, let chip = section.chips.first { + FlowLayout(horizontalSpacing: 8, verticalSpacing: 8) { + IngredientsChips( + title: chip.name, + image: chip.icon, + onClick: { + toggleSelection(sectionTitle: section.title, chipName: chip.name) + }, + isSelected: selectedSet.contains(chip.name) + ) + } + .padding(.vertical, 4) + } else { + DynamicRegionSectionRow( + section: section, + isSectionSelected: !selectedSet.isEmpty, + isExpanded: expandedSectionIds.contains(section.id), + onToggleExpanded: { + withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) { + if expandedSectionIds.contains(section.id) { + expandedSectionIds.remove(section.id) + } else { + expandedSectionIds.insert(section.id) + } + } + }, + isChipSelected: { chip in + selections(for: section.title).contains(chip.name) + }, + onChipTap: { chip in + toggleSelection(sectionTitle: section.title, chipName: chip.name) + } + ) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 20) + .padding(.bottom, 12) + } + .frame(height: UIScreen.main.bounds.height * 0.3) + } + .id(step.id) + } + + private func selections(for sectionTitle: String) -> Set { + let stepSectionName = step.header.name + guard let value = preferences.sections[stepSectionName], + case .nested(let nestedDict) = value, + let items = nestedDict[sectionTitle] else { + return [] + } + return Set(items) + } + + private func toggleSelection(sectionTitle: String, chipName: String) { + let lowerChipName = chipName.lowercased() + + // "None of these apply" is ALWAYS Global Exclusive. + // "Other" is now inclusive and behaves like a normal option. + + let isNone = (lowerChipName == "none of these apply") + + if isNone { + // GLOBAL EXCLUSIVE LOGIC + // 1. Clear ALL other sections + clearAllSections() + + // 2. Toggle this chip in this section + var set = selections(for: sectionTitle) + if set.contains(chipName) { + set.remove(chipName) + } else { + set = [chipName] + } + syncRegionPreferences(from: set, for: sectionTitle) + + } else { + // NORMAL OPTION LOGIC (including "Other") + + // 1. Check if we need to clear any Global Exclusives from OTHER sections + // (e.g. if "None of these apply" was selected, clear it) + clearGlobalExclusivesFromOtherSections(currentSectionTitle: sectionTitle) + + var set = selections(for: sectionTitle) + + // Normal Option (including "Other") + // 1. Remove Global Exclusives ("None of these apply") in THIS section + if let none = set.first(where: { $0.lowercased() == "none of these apply" }) { + set.remove(none) + } + + // 2. Toggle + if set.contains(chipName) { + set.remove(chipName) + } else { + set.insert(chipName) + } + + syncRegionPreferences(from: set, for: sectionTitle) + } + } + + private func clearAllSections() { + let stepSectionName = step.header.name + // Just empty the whole nested dictionary for this step + preferences.sections[stepSectionName] = .nested([:]) + } + + private func clearGlobalExclusivesFromOtherSections(currentSectionTitle: String) { + let stepSectionName = step.header.name + guard let value = preferences.sections[stepSectionName], + case .nested(var nestedDict) = value else { return } + + var changed = false + for (key, items) in nestedDict { + // Skip the current section (we handle it separately) + if key == currentSectionTitle { continue } + + // Check if this section has "None of these apply" + let hasNone = items.contains(where: { $0.lowercased() == "none of these apply" }) + + if hasNone { + // Remove the global exclusive item + let newItems = items.filter { $0.lowercased() != "none of these apply" } + if newItems.isEmpty { + nestedDict[key] = nil + } else { + nestedDict[key] = newItems + } + changed = true + } + } + + if changed { + preferences.sections[stepSectionName] = .nested(nestedDict) + } + } + + private func syncRegionPreferences(from set: Set, for sectionTitle: String) { + let stepSectionName = step.header.name + var nestedDict: [String: [String]] + + // Get existing nested dict or create new one + if let existingValue = preferences.sections[stepSectionName], + case .nested(let existingDict) = existingValue { + nestedDict = existingDict + } else { + nestedDict = [:] + } + + // Update the specific region's selections + nestedDict[sectionTitle] = Array(set) + + // Save back to preferences + preferences.sections[stepSectionName] = .nested(nestedDict) + } +} + +private struct DynamicRegionSectionRow: View { + let section: SectionedChipModel + let isSectionSelected: Bool + let isExpanded: Bool + let onToggleExpanded: () -> Void + let isChipSelected: (ChipsModel) -> Bool + let onChipTap: (ChipsModel) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Button { + onToggleExpanded() + } label: { + HStack(spacing: 40) { + Text(section.title) + .font(ManropeFont.medium.size(14)) + .foregroundStyle(isSectionSelected ? .primary100 : .grayScale150) + + Circle() + .fill(isSectionSelected ? .grayScale60 : .grayScale30) + .foregroundStyle(isSectionSelected ? .grayScale100 : .grayScale60) + .frame(width: 24, height: 24) + .overlay( + Image(systemName: "chevron.up") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.grayScale100) + .rotationEffect(isExpanded ? .degrees(0) : .degrees(180)) + ) + } + .padding(.vertical, 6) + .padding(.leading, 16) + .padding(.trailing, 4) + .background { + if isSectionSelected { + Capsule() + .fill( + LinearGradient( + colors: [Color(hex: "9DCF10"), Color(hex: "6B8E06")], + startPoint: .top, + endPoint: .bottom + ) + ) + } else { + Capsule() + .fill(.grayScale10) + } + } + .overlay( + Capsule() + .stroke(lineWidth: isSectionSelected ? 0 : 1) + .foregroundStyle(.grayScale60) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if isExpanded { + FlowLayout(horizontalSpacing: 8, verticalSpacing: 8) { + ForEach(section.chips) { chip in + IngredientsChips( + title: chip.name, + image: chip.icon, + onClick: { + onChipTap(chip) + }, + isSelected: isChipSelected(chip) + ) + } + } + .transition(.blurReplace) + } + } + .padding(.vertical, 4) + } +} + +// MARK: - Container that picks the right dynamic view for a step + +struct DynamicOnboardingStepView: View { + let step: DynamicStep + let flowType: OnboardingFlowType + @Binding var preferences: Preferences + + var body: some View { + switch step.type { + case .type1: + DynamicOptionsQuestionView(step: step, flowType: flowType, preferences: $preferences) + case .type2: + DynamicSubStepsQuestionView(step: step, flowType: flowType, preferences: $preferences) + case .type3: + DynamicRegionsQuestionView(step: step, flowType: flowType, preferences: $preferences) + case .unknown: + // Fallback simple view – safe default for unexpected types. + VStack(spacing: 12) { + Text(step.header.name) + .font(NunitoFont.bold.size(18)) + .foregroundStyle(.grayScale150) + Text("Unsupported step type in current build.") + .font(ManropeFont.regular.size(14)) + .foregroundStyle(.grayScale100) + } + .padding(20) + } + } +} + +//#Preview("Dynamic type-1 example") { +// let steps = DynamicStepsProvider.loadSteps() +// let step = steps.first { $0.type == .type1 } ?? steps.first! +// return DynamicOnboardingStepView(step: step, flowType: .individual, preferences: .constant(Preferences())) +//} + +// MARK: - Meet Your Profile Intro View + +struct MeetYourProfileIntroView: View { + @Environment(AppNavigationCoordinator.self) var coordinator + + var body: some View { + VStack { + Spacer() + + Button(action: { + coordinator.navigateInBottomSheet(.meetYourProfile(memberId: nil)) + }) { + GreenCapsule(title: "Continue", width: 159) + .frame(width: 159) + } + .padding(.bottom, 40) + } + } +} + +// MARK: - Preferences Added Success Sheet + +struct PreferencesAddedSuccessSheet: View { + var title: String = "Preferences added successfully!" + var onContinue: () -> Void + + var body: some View { + VStack(spacing: 0) { + + VStack(spacing: 12) { + Text(title) + .font(NunitoFont.bold.size(20)) + .foregroundStyle(.grayScale150) + .multilineTextAlignment(.center) + .padding(.top, 32) + + Text("Your food preferences are saved. You can review them anytime, or edit a specific preference section by tapping Edit.") + .font(ManropeFont.medium.size(12)) + .foregroundStyle(.grayScale130) + .multilineTextAlignment(.center) + + .padding(.horizontal, 24) + } + .padding(.bottom , 40) + + + + Button(action: onContinue) { + GreenCapsule(title: "Continue") + .frame(width : 152 , height : 52) + } + + .buttonStyle(.plain) + .padding(.bottom ,32) + + + } + .frame(maxWidth: .infinity) + .padding(.horizontal, 20) + .padding(.top, 24) + .padding(.bottom, 20) + } +} + +#Preview("PreferencesAddedSuccessSheet") { + PreferencesAddedSuccessSheet { + + } +} + + +struct EditSectionBottomSheet: View { + @EnvironmentObject private var store: Onboarding + @Environment(FamilyStore.self) private var familyStore + @Environment(FoodNotesStore.self) private var foodNotesStore + @Binding var isPresented: Bool + + let stepId: String + let currentSectionIndex: Int + let initialMemberId: UUID? // Track which member was selected when opening edit sheet + + @State private var dragOffset: CGFloat = 0 + @State private var isDragging: Bool = false + + // Determine flow type: use .family if user has a family, otherwise use store's flow type + private var effectiveFlowType: OnboardingFlowType { + if let family = familyStore.family, !family.otherMembers.isEmpty { + return .family + } + return .individual + } + + var body: some View { + ZStack(alignment: .bottomTrailing) { + VStack(spacing: 0) { + // Drag indicator + RoundedRectangle(cornerRadius: 4) + .fill(Color.grayScale60) + .frame(width: 60, height: 4) + .padding(.top, 12) + .padding(.bottom, 8) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + if value.translation.height > 0 { + isDragging = true + dragOffset = value.translation.height + } + } + .onEnded { value in + isDragging = false + if value.translation.height > 100 || value.predictedEndTranslation.height > 200 { + withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { + isPresented = false + } + } else { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + dragOffset = 0 + } + } + } + ) + + if let step = store.step(for: stepId) { + DynamicOnboardingStepView( + step: step, + flowType: effectiveFlowType, + preferences: $store.preferences + ) + .frame(maxWidth: .infinity, alignment: .top) + .padding(.top, 8) + .padding(.bottom, 100) + .transition(.opacity) + } + } + + Button(action: { + withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { + isPresented = false + } + }) { + GreenCapsule( + title: "Done", + takeFullWidth: false, + isLoading: false // Simplified for now to avoid dependency issues in preview/root + ) + } + .buttonStyle(.plain) + .padding(.trailing, 20) + .padding(.bottom, 24) + } + .padding(.bottom, UIApplication.shared.windows.first?.safeAreaInsets.bottom ?? 0) + .frame(maxWidth: .infinity) + .background(Color.white) + .cornerRadius(36, corners: [.topLeft, .topRight]) + .shadow(color: Color.black.opacity(0.1), radius: 20, x: 0, y: -5) + .ignoresSafeArea(edges: .bottom) + .offset(y: dragOffset) + .animation(isDragging ? nil : .spring(response: 0.3, dampingFraction: 0.8), value: dragOffset) + .animation(.spring(response: 0.3, dampingFraction: 0.8), value: stepId) + .onAppear { + // Only sync familyStore.selectedMemberId if a specific member was selected AND it's different + // This prevents triggering unnecessary onChange handlers that could reset preferences + if let memberId = initialMemberId, familyStore.selectedMemberId != memberId { + familyStore.selectedMemberId = memberId + } + } + .onDisappear { + familyStore.selectedMemberId = nil + } + } +} diff --git a/IngrediCheck/Views/Onboarding/MainCanvasView.swift b/IngrediCheck/Views/Onboarding/MainCanvasView.swift new file mode 100644 index 00000000..a8d6c5b4 --- /dev/null +++ b/IngrediCheck/Views/Onboarding/MainCanvasView.swift @@ -0,0 +1,495 @@ +// +// MainCanvasView.swift +// IngrediCheckPreview +// +// Created by Gunjan Haldar on 15/10/25. +// + +import SwiftUI + +struct MainCanvasView: View { + + @EnvironmentObject private var store: Onboarding + @Environment(AppNavigationCoordinator.self) private var coordinator + @Environment(WebService.self) private var webService + @Environment(FamilyStore.self) private var familyStore + @Environment(FoodNotesStore.self) private var foodNotesStore + + private let flow: OnboardingFlowType + @State private var cardScrollTarget: UUID? = nil + @State private var tagBarScrollTarget: UUID? = nil + @State private var isLoadingMemberPreferences: Bool = false // Track when loading member preferences + @State private var previousSectionIndex: Int = 0 // Track previous section index to detect forward navigation + + init(flow: OnboardingFlowType) { + self.flow = flow + } + + var body: some View { + let cards = canvasCards() + + VStack(spacing: 0) { + CustomIngrediCheckProgressBar(progress: CGFloat(store.progress * 100)) + .animation(.smooth, value: store.progress) + + CanvasTagBar( + store: store, + onTapCurrentSection: { + // Scroll to the current section's cards when tapping the active tag + scheduleScrollToCurrentSectionViews() + }, + scrollTarget: $tagBarScrollTarget, + currentBottomSheetRoute: coordinator.currentBottomSheetRoute + ) + .padding(.bottom, 16) + + // Always show the scroll view, and let it decide whether to render + // real cards or a placeholder when there is no data. + CanvasSummaryScrollView( + cards: cards ?? [], + scrollTarget: $cardScrollTarget, + showPlaceholder: cards?.isEmpty ?? true, + itemMemberAssociations: foodNotesStore.itemMemberAssociations ?? [:], + showFamilyIcons: flow == .family || flow == .singleMember + ) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .onAppear { + store.onboardingFlowtype = flow + + // Update completion status for all sections based on their data + store.updateSectionCompletionStatus() + + // Initialize previous section index + previousSectionIndex = store.currentSectionIndex + } + .task { + Log.debug("MainCanvasView", "Food notes load task triggered") + + // Fetch and load food notes data when view appears + // This loads the union view (Everyone + all members) for display + await foodNotesStore.loadFoodNotesAll() + + // Prepare preferences for the current selection locally from the loaded data + foodNotesStore.preparePreferencesForMember(selectedMemberId: familyStore.selectedMemberId) + } + .onChange(of: store.currentSectionIndex) { newIndex in + // Update previous section index + previousSectionIndex = newIndex + + scheduleScrollToCurrentSectionViews() + syncBottomSheetWithCurrentSection() + } + .onChange(of: store.preferences) { _ in + // Update completion status whenever preferences change + store.updateSectionCompletionStatus() + + // If we were loading member/family preferences, this change came from a backend load. + // DO NOT save. + if isLoadingMemberPreferences { + Log.debug("MainCanvasView", "Preferences updated during load, skipping save") + return + } + + // Don't save if preferences are empty (user hasn't made any selections yet) + guard !store.preferences.sections.isEmpty else { + Log.debug("MainCanvasView", "Skipping save - preferences are empty") + return + } + + // Capture the section that just changed so we don't lose it if the user navigates + // before the Task starts executing. + let changedSectionName = store.currentSection.name + + // Call API immediately when preferences change + Log.debug("MainCanvasView", "Preferences changed, saving section \(changedSectionName)") + Task { + // Double-check we're not loading and preferences aren't empty before saving + guard !isLoadingMemberPreferences, !store.preferences.sections.isEmpty else { + Log.debug("MainCanvasView", "Skipping save - loading state or empty preferences") + return + } + + let changedSections: Set = [changedSectionName] + + // Optimistically update the canvas summary view from local preferences for this member. + foodNotesStore.applyLocalPreferencesOptimistic() + + // Build content structure for the changed section(s) and call API in the background. + foodNotesStore.updateFoodNotes() + } + } + .onChange(of: familyStore.selectedMemberId) { newValue in + // When switching members, prepare preferences locally from associations. + Log.debug("MainCanvasView", "Member switched to \(newValue?.uuidString ?? "Everyone"), preparing local preferences") + + // Mark as loading to prevent the onChange(of: preferences) from triggering a sync + // for the newly loaded member's existing state. + isLoadingMemberPreferences = true + foodNotesStore.preparePreferencesForMember(selectedMemberId: newValue) + isLoadingMemberPreferences = false + } + .navigationBarBackButtonHidden(true) + } + + private func icon(for stepId: String) -> String { + // Get icon from dynamic JSON + if let step = store.step(for: stepId), + let icon = step.header.iconURL, + icon.isEmpty == false { + return icon + } + // Fallback to default icon if not found + return "allergies" + } + + private func scheduleScrollToCurrentSectionViews() { + let currentSectionId = store.currentSection.id + cardScrollTarget = currentSectionId + tagBarScrollTarget = currentSectionId + } + + /// Keep the bottom sheet question in sync with the currently selected section (tag). + private func syncBottomSheetWithCurrentSection() { + guard case .mainCanvas = coordinator.currentCanvasRoute else { return } + guard let stepId = store.currentSection.screens.first?.stepId else { return } + + let targetRoute = BottomSheetRoute.onboardingStep(stepId: stepId) + if targetRoute != coordinator.currentBottomSheetRoute { + coordinator.navigateInBottomSheet(targetRoute) + } + } + + private func chips(for stepId: String) -> [ChipsModel]? { + guard let step = store.step(for: stepId) else { return nil } + let sectionName = step.header.name + + // Use canvasPreferences so scroll cards always show the union view + // (Everyone + all members) and do not change when switching member. + guard let value = foodNotesStore.canvasPreferences.sections[sectionName], + case .list(let items) = value else { + return nil + } + + // Get icons from step options + let options = step.content.options ?? [] + return items.compactMap { itemName -> ChipsModel? in + if let option = options.first(where: { $0.name == itemName }) { + return ChipsModel(name: option.name, icon: option.icon) + } + return ChipsModel(name: itemName, icon: nil) + } + } + + private func sectionedChips(for stepId: String) -> [SectionedChipModel]? { + guard let step = store.step(for: stepId) else { return nil } + let sectionName = step.header.name + + // Use canvasPreferences for union view + guard let value = foodNotesStore.canvasPreferences.sections[sectionName], + case .nested(let nestedDict) = value else { + return nil + } + + // Type-2 steps use subSteps, type-3 steps use regions. Handle both. + var sections: [SectionedChipModel] = [] + + if let subSteps = step.content.subSteps { + // MARK: Type-2 (Avoid / Lifestyle / Nutrition-style) + for subStep in subSteps { + guard let selectedItems = nestedDict[subStep.title], + !selectedItems.isEmpty else { + continue + } + + // Map selected items to ChipsModel with icons + let selectedChips: [ChipsModel] = selectedItems.compactMap { itemName in + if let option = subStep.options?.first(where: { $0.name == itemName }) { + return ChipsModel(name: option.name, icon: option.icon) + } + return ChipsModel(name: itemName, icon: nil) + } + + if !selectedChips.isEmpty { + sections.append( + SectionedChipModel( + title: subStep.title, + subtitle: subStep.description, + chips: selectedChips + ) + ) + } + } + } else if let regions = step.content.regions { + // MARK: Type-3 (Region-style) + for region in regions { + guard let selectedItems = nestedDict[region.name], + !selectedItems.isEmpty else { + continue + } + + let selectedChips: [ChipsModel] = selectedItems.compactMap { itemName in + if let option = region.subRegions.first(where: { $0.name == itemName }) { + return ChipsModel(name: option.name, icon: option.icon) + } + return ChipsModel(name: itemName, icon: nil) + } + + if !selectedChips.isEmpty { + sections.append( + SectionedChipModel( + title: region.name, + subtitle: nil, + chips: selectedChips + ) + ) + } + } + } + + return sections.isEmpty ? nil : sections + } + + private func canvasCards() -> [CanvasCardModel]? { + var cards: [CanvasCardModel] = [] + + for section in store.sections { + guard let stepId = section.screens.first?.stepId else { continue } + let chips = chips(for: stepId) + let groupedChips = sectionedChips(for: stepId) + + if chips != nil || groupedChips != nil { + cards.append( + CanvasCardModel( + id: section.id, + title: section.name, + icon: icon(for: stepId), + stepId: stepId, + chips: chips, + sectionedChips: groupedChips + ) + ) + } + } + + return cards.isEmpty ? nil : cards + } + +} + +struct CanvasCardModel: Identifiable { + let id: UUID + let title: String + let icon: String + let stepId: String + let chips: [ChipsModel]? + let sectionedChips: [SectionedChipModel]? +} + +struct CanvasSummaryScrollView: View { + let cards: [CanvasCardModel] + @Binding var scrollTarget: UUID? + let showPlaceholder: Bool + let itemMemberAssociations: [String: [String: [String]]] + let showFamilyIcons: Bool + @State private var previousCardCount: Int = 0 + + var body: some View { + ScrollViewReader { proxy in + ScrollView(.vertical, showsIndicators: false) { + // Add dynamic bottom padding so content can scroll above the sheet + let extraBottomPadding: CGFloat = (!showPlaceholder && cards.count > 1) + ? UIScreen.main.bounds.height * 1.0 + : 20 + + VStack(spacing: 16) { + if showPlaceholder { + // Empty-state placeholder when there are no cards yet: + // show a stack of dummy cards similar to the real layout. + ForEach(0..<5, id: \.self) { index in + SkeletonCanvasCard() + .padding(.top, index == 0 ? 16 : 0) + } + } else { + ForEach(Array(cards.enumerated()), id: \.element.id) { index, card in + CanvasCard( + chips: card.chips, + sectionedChips: card.sectionedChips, + title: card.title, + iconName: card.icon, + itemMemberAssociations: itemMemberAssociations, + showFamilyIcons: showFamilyIcons + ) + .id(card.id) + // Add visual gap from the top edge so the card + // never touches the top when scrolled into view. + .padding(.top, index == 0 ? 16 : 0) + } + } + } + .padding(.horizontal, 16) + // Extra bottom padding so the last items can scroll fully above the bottom sheet, + // but only once there is more than one card to avoid an initial jump. + .padding(.bottom, extraBottomPadding) + } + .onAppear { + previousCardCount = cards.count + } + .onChange(of: cards.map(\.id)) { _ in + handleCardsChange(proxy: proxy) + } + .onChange(of: scrollTarget) { _ in + handleScrollTarget(proxy: proxy) + } + } + } + + private func handleCardsChange(proxy: ScrollViewProxy) { + // When a new card is added (e.g. user picks chips in a new section), + // automatically scroll so that the newest card is brought to the top. + if cards.count > previousCardCount, + let lastId = cards.last?.id { + scrollTo(id: lastId, proxy: proxy) + } + previousCardCount = cards.count + } + + private func handleScrollTarget(proxy: ScrollViewProxy) { + guard let target = scrollTarget, + cards.contains(where: { $0.id == target }) else { return } + + scrollTo(id: target, proxy: proxy) + scrollTarget = nil + } + + private func scrollTo(id: UUID, proxy: ScrollViewProxy) { + DispatchQueue.main.async { + withAnimation(.easeInOut(duration: 0.3)) { + proxy.scrollTo(id, anchor: .top) + } + } + } +} + +/// Lightweight skeleton version of the canvas card, used as a placeholder +/// when there is no data yet. Designed to roughly match the card layout +/// without showing any real content. +struct SkeletonCanvasCard: View { + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 16) + .fill(Color.grayScale10) + + VStack(alignment: .leading, spacing: 12) { + // Title bar + RoundedRectangle(cornerRadius: 4) + .fill(Color.grayScale30) + .frame(width: UIScreen.main.bounds.width * 0.46, height: UIScreen.main.bounds.height * 0.02) + + // Three rows of pill placeholders + VStack(alignment: .leading, spacing: 8) { + ForEach(0..<3, id: \.self) { _ in + HStack(spacing: 8) { + Capsule() + .fill(Color.grayScale30) + .frame(width: CGFloat(Int.random(in: 100...150)), height: UIScreen.main.bounds.height * 0.04) + + Capsule() + .fill(Color.grayScale30) + .frame(width: CGFloat(Int.random(in: 100...150)), height: UIScreen.main.bounds.height * 0.04) + } + } + } + } + .padding(.vertical, 12) + .padding(.horizontal, 12) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxWidth: .infinity) + .frame(height: UIScreen.main.bounds.height * 0.22) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(Color.grayScale60, lineWidth: 0.25) + ) + } +} + +func onboardingSheetTitle(title: String) -> some View { + Group { + (Text("Q. ") + .font(ManropeFont.bold.size(20)) + .foregroundStyle(.grayScale70) + + + Text(title) + .font(NunitoFont.bold.size(20)) + .foregroundStyle(.grayScale150)) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) // <-- important + } + +} + +func onboardingSheetTitle(template: String, memberName: String, memberColor: Color) -> some View { + let parts = template.components(separatedBy: "{name}") + return Group { + (Text("Q. ") + .font(ManropeFont.bold.size(20)) + .foregroundStyle(.grayScale70) + + + Text(parts.first ?? "") + .font(NunitoFont.bold.size(20)) + .foregroundStyle(.grayScale150) + + + Text(memberName) + .font(NunitoFont.bold.size(20)) + .foregroundStyle(memberColor) + + + Text(parts.count > 1 ? parts[1] : "") + .font(NunitoFont.bold.size(20)) + .foregroundStyle(.grayScale150)) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + } +} + +func onboardingSheetSubtitle(subtitle: String, onboardingFlowType: OnboardingFlowType) -> some View { + if onboardingFlowType == .individual { + Text(subtitle) + .font(ManropeFont.regular.size(14)) + .foregroundStyle(.grayScale100) + .fixedSize(horizontal: false, vertical: true) // <-- important + } else { + Text(subtitle) + .font(ManropeFont.regular.size(14)) + .foregroundStyle(.grayScale120) + .fixedSize(horizontal: false, vertical: true) // <-- important + } +} + +func onboardingSheetFamilyMemberSelectNote() -> some View { + HStack(alignment: .center, spacing: 0) { + + Image(.yellowBulb) + .resizable() + .frame(width: 22, height: 26) + + Text("Select members one by one to personalize their choices.") + .font(ManropeFont.regular.size(12)) + .foregroundStyle(.grayScale100) + } +} + +#Preview { + let webService = WebService() + let onboarding = Onboarding(onboardingFlowtype: .individual) + let foodNotesStore = FoodNotesStore(webService: webService, onboardingStore: onboarding) + + MainCanvasView(flow: .individual) + .environmentObject(onboarding) + .environment(webService) + .environment(foodNotesStore) + .environment(AppNavigationCoordinator(initialRoute: .blankScreen)) + .environment(FamilyStore()) +} diff --git a/IngrediCheck/Views/Onboarding/MeetYourProfileView.swift b/IngrediCheck/Views/Onboarding/MeetYourProfileView.swift new file mode 100644 index 00000000..22a6dcb9 --- /dev/null +++ b/IngrediCheck/Views/Onboarding/MeetYourProfileView.swift @@ -0,0 +1,293 @@ +// +// MeetYourProfileView.swift +// IngrediCheckPreview +// +// Created to display the user's profile with editable name and avatar. +// + +import SwiftUI + +// MARK: - Meet Your Profile View + +struct MeetYourProfileView: View { + var onContinue: () -> Void + let memberId: UUID? + @Environment(FamilyStore.self) var familyStore + @Environment(MemojiStore.self) var memojiStore + @Environment(AppNavigationCoordinator.self) var coordinator + @State private var primaryMemberName: String = "" + @FocusState private var isEditingPrimaryName: Bool + + init(memberId: UUID? = nil, onContinue: @escaping () -> Void) { + self.memberId = memberId + self.onContinue = onContinue + } + + private var isFromFamilyOverview: Bool { + coordinator.currentCanvasRoute == .letsMeetYourIngrediFam + } + + private var targetMember: FamilyMember? { + if let memberId = memberId { + // Find the specific member by ID + if let family = familyStore.family { + if family.selfMember.id == memberId { + return family.selfMember + } + return family.otherMembers.first { $0.id == memberId } + } else { + if familyStore.pendingSelfMember?.id == memberId { + return familyStore.pendingSelfMember + } + return familyStore.pendingOtherMembers.first { $0.id == memberId } + } + } else { + // Default to self member if no memberId provided + return familyStore.family?.selfMember ?? familyStore.pendingSelfMember + } + } + + private var isSelfMember: Bool { + guard let targetMember = targetMember else { return true } + if let family = familyStore.family { + return targetMember.id == family.selfMember.id + } + return targetMember.id == familyStore.pendingSelfMember?.id + } + + var body: some View { + VStack(spacing: 0) { + // Avatar Section + VStack(spacing: 8) { + ZStack(alignment: .bottomTrailing) { + Group { + if let image = memojiStore.image { + // 1. Show the image that was JUST generated + Image(uiImage: image) + .resizable() + .scaledToFit() + .frame(width: 80, height: 80) + .background(Color(hex: memojiStore.backgroundColorHex ?? "#E0BBE4")) + .clipShape(Circle()) + } else if let member = targetMember, + let hash = member.imageFileHash, !hash.isEmpty { + // 2. Show the avatar from the member's data (saved or pending) + MemberAvatar.custom(member: member, size: 80, imagePadding: 0) + } else { + // 3. Default placeholder with curly-lady + ZStack { + Circle() + .fill(Color(hex: (targetMember?.color ?? memojiStore.backgroundColorHex) ?? "#E0BBE4")) + .frame(width: 80, height: 80) + + Image("curly-lady") + .resizable() + .scaledToFit() + .frame(width: 80, height: 80) + .clipShape(Circle()) + } + } + } + + Button { + // Sync current name to store before generating + commitPrimaryName() + + // Navigation to avatar update sheet + memojiStore.displayName = primaryMemberName + + // Set the target member ID so handleAssignAvatar knows who to update + if let member = targetMember { + familyStore.avatarTargetMemberId = member.id + } else if isSelfMember { + // Create pending self member if none exists + familyStore.setPendingSelfMember(name: primaryMemberName) + familyStore.avatarTargetMemberId = familyStore.pendingSelfMember?.id + } else if let memberId = memberId { + // For other members, ensure they exist in pending + familyStore.avatarTargetMemberId = memberId + } + + // Navigate to UpdateAvatarSheet, which will allow choosing static avatars or generating a new one + memojiStore.previousRouteForGenerateAvatar = .meetYourProfile(memberId: memberId) + let targetId = targetMember?.id ?? familyStore.pendingSelfMember?.id ?? UUID() + coordinator.navigateInBottomSheet(.updateAvatar(memberId: targetId)) + } label: { + Circle() + .fill(Color.white) + .frame(width: 28, height: 28) + .shadow(color: .black.opacity(0.1), radius: 2, x: 0, y: 2) + .overlay( + Image("pen-line") + .resizable() + .frame(width: 14, height: 14) + .foregroundStyle(.grayScale100) + ) + } + .offset(x: 4, y: 4) + } + } + .padding(.top, 24) + + // Greeting Title + HStack(spacing: 8) { + Text("Hello,") + .font(NunitoFont.bold.size(22)) + .foregroundStyle(.grayScale150) + + HStack(spacing: 8) { + TextField("", text: $primaryMemberName) + .font(NunitoFont.bold.size(22)) + .foregroundStyle(Color(hex: "#303030")) + .disableAutocorrection(true) + .focused($isEditingPrimaryName) + .submitLabel(.done) + .onSubmit { commitPrimaryName() } + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: .infinity, alignment: .leading) + + Image("pen-line") + .resizable() + .frame(width: 12, height: 12) + .foregroundStyle(.grayScale100) + .onTapGesture { isEditingPrimaryName = true } + } + .padding(.leading, 8) + .padding(.trailing, 5) + .frame(height: 35) + .frame(maxWidth: 250) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(isEditingPrimaryName ? Color(hex: "#EEF5E3") : .white) + ) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color(hex: "#E3E3E3"), lineWidth: 0.5) + ) + .contentShape(Rectangle()) + .fixedSize(horizontal: true, vertical: false) + .onTapGesture { isEditingPrimaryName = true } + + Text("!") + .font(NunitoFont.bold.size(22)) + .foregroundStyle(.grayScale150) + } + .padding(.top, 24) + .padding(.bottom, 16) + + // Description + Text("We've created a profile name and avatar based on your preferences. You can edit the name or avatar anytime to make it truly yours.") + .font(ManropeFont.medium.size(12)) + .foregroundStyle(.grayScale120) + .multilineTextAlignment(.center) + .lineSpacing(4) + .padding(.horizontal, 32) + .padding(.bottom, 40) + + // Continue/Done Button + Button(action: { + commitPrimaryName() + onContinue() + }) { + GreenCapsule(title: isFromFamilyOverview ? "Done" : "Continue", width: 159) + .frame(width: 159) + } + .padding(.bottom, 24) + } + .frame(maxWidth: .infinity) + .dismissKeyboardOnTap() + .onAppear { + if let member = targetMember { + // If it's the self member and "Just Me" flow, backend defaults the member name to "Me" + // but the family name to "Bite Buddy". We should show "Bite Buddy" here. + if isSelfMember, let family = familyStore.family, + member.name == "Me" && !family.name.isEmpty { + primaryMemberName = family.name + } else { + primaryMemberName = member.name + } + } else { + primaryMemberName = "Bite Buddy" + } + } + .onChange(of: isEditingPrimaryName) { _, editing in + if !editing { + commitPrimaryName() + } + } + .onChange(of: primaryMemberName) { oldValue, newValue in + // Filter to letters and spaces only + let filtered = newValue.filter { $0.isLetter || $0.isWhitespace } + var finalized = filtered + + // Limit to 25 characters + if finalized.count > 25 { + finalized = String(finalized.prefix(25)) + } + + // Limit to max 3 words (max 2 spaces) + let components = finalized.components(separatedBy: .whitespaces) + if components.count > 3 { + finalized = components.prefix(3).joined(separator: " ") + } + + if finalized != newValue { + primaryMemberName = finalized + } + } + } + + private func commitPrimaryName() { + let trimmed = primaryMemberName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + + Task { @MainActor in + if let member = targetMember { + if isSelfMember { + // Update self member + if let family = familyStore.family { + var me = family.selfMember + guard me.name != trimmed else { return } + me.name = trimmed + await familyStore.editMember(me) + } else if let pending = familyStore.pendingSelfMember { + if pending.name != trimmed { + familyStore.updatePendingSelfMemberName(trimmed) + } + } else { + familyStore.setPendingSelfMember(name: trimmed) + } + } else { + // Update other member + if let family = familyStore.family, let existingMember = family.otherMembers.first(where: { $0.id == member.id }) { + var updatedMember = existingMember + guard updatedMember.name != trimmed else { return } + updatedMember.name = trimmed + await familyStore.editMember(updatedMember) + } else if let pendingMember = familyStore.pendingOtherMembers.first(where: { $0.id == member.id }) { + if pendingMember.name != trimmed { + familyStore.updatePendingOtherMemberName(id: member.id, name: trimmed) + } + } + } + } else if isSelfMember { + // Create pending self member if none exists + familyStore.setPendingSelfMember(name: trimmed) + } + } + } +} + +#Preview("Meet Your Profile View") { + let familyStore = FamilyStore() + let memojiStore = MemojiStore() + + // Set up mock memoji data for preview + memojiStore.backgroundColorHex = "#E0BBE4" + memojiStore.image = UIImage(systemName: "person.circle.fill") + + return MeetYourProfileView(onContinue: {}) + .environment(familyStore) + .environment(memojiStore) +} diff --git a/IngrediCheck/Views/Onboarding/SignInView.swift b/IngrediCheck/Views/Onboarding/SignInView.swift index 592133e9..7fa4441c 100644 --- a/IngrediCheck/Views/Onboarding/SignInView.swift +++ b/IngrediCheck/Views/Onboarding/SignInView.swift @@ -2,6 +2,7 @@ import SwiftUI import AuthenticationServices import GoogleSignIn import GoogleSignInSwift +import os struct SignInView: View { @@ -40,7 +41,7 @@ struct SignInView: View { onboardingState.useCasesShown = true case .failure(let error): // TODO: Show an alert to the user - print("Google Sign-In failed: \(error.localizedDescription)") + Log.error("SignInView", "Google Sign-In failed: \(error.localizedDescription)") } } }) { @@ -81,10 +82,7 @@ struct SignInView: View { } .padding(.horizontal) - Text("By continuing, you are agreeing to my **[Terms of Use](https://www.ingredicheck.app/terms-conditions)** and **[Privacy Policy](https://www.ingredicheck.app/privacy-policy)**.") - .multilineTextAlignment(.center) - .font(.footnote) - .tint(.paletteAccent) + LegalDisclaimerView(showShieldIcon: false) .padding(.horizontal) .padding(.horizontal) .padding(.horizontal) diff --git a/IngrediCheck/Views/Onboarding/UseCasesView.swift b/IngrediCheck/Views/Onboarding/UseCasesView.swift index 9e614bb2..737c4444 100644 --- a/IngrediCheck/Views/Onboarding/UseCasesView.swift +++ b/IngrediCheck/Views/Onboarding/UseCasesView.swift @@ -2,6 +2,7 @@ import SwiftUI import AuthenticationServices import GoogleSignIn import GoogleSignInSwift +import os struct UseCasesView: View { @@ -57,7 +58,7 @@ struct UseCasesView: View { onboardingState.useCasesShown = true case .failure(let error): // TODO: Show an alert to the user - print("Google Sign-In failed: \(error.localizedDescription)") + Log.error("UseCasesView", "Google Sign-In failed: \(error.localizedDescription)") } } }) { @@ -99,10 +100,7 @@ struct UseCasesView: View { } .padding(.horizontal) - Text("By continuing, you are agreeing to my **[Terms of Use](https://www.ingredicheck.app/terms-conditions)** and **[Privacy Policy](https://www.ingredicheck.app/privacy-policy)**.") - .multilineTextAlignment(.center) - .font(.footnote) - .tint(.paletteAccent) + LegalDisclaimerView(showShieldIcon: false) .padding(.horizontal) .padding(.horizontal) .padding(.horizontal) @@ -113,4 +111,6 @@ struct UseCasesView: View { #Preview { UseCasesView() + .environment(OnboardingState()) + .environment(AuthController()) } diff --git a/IngrediCheck/Views/ProductDetails/CollapsibleSection.swift b/IngrediCheck/Views/ProductDetails/CollapsibleSection.swift new file mode 100644 index 00000000..fa83ad24 --- /dev/null +++ b/IngrediCheck/Views/ProductDetails/CollapsibleSection.swift @@ -0,0 +1,48 @@ +import SwiftUI + +struct CollapsibleSection: View { + let title: String + @Binding var isExpanded: Bool + @ViewBuilder var content: () -> Content + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Button { + withAnimation(.easeInOut(duration: 0.3)) { + isExpanded.toggle() + } + } label: { + HStack { + Text(title) + .font(ManropeFont.semiBold.size(16)) + .foregroundStyle(.grayScale150) + + Spacer() + + Image(systemName: isExpanded ? "chevron.down" : "chevron.right") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(.grayScale100) + .contentTransition(.symbolEffect(.replace)) + } + } + + if isExpanded { + content() + .transition(.opacity.combined(with: .blurReplace)) + .padding(.top, 12) + } + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 24) + .fill(Color.white) + .overlay( + RoundedRectangle(cornerRadius: 24) + .stroke(lineWidth: 2) + .foregroundStyle(Color(hex: "#EEEEEE")) + ) + ) + .animation(.easeInOut(duration: 0.3), value: isExpanded) + } +} + diff --git a/IngrediCheck/Views/ProductDetails/DietaryTagView.swift b/IngrediCheck/Views/ProductDetails/DietaryTagView.swift new file mode 100644 index 00000000..c61ff911 --- /dev/null +++ b/IngrediCheck/Views/ProductDetails/DietaryTagView.swift @@ -0,0 +1,49 @@ +import SwiftUI + +// Simple struct for dietary claims (API now includes emojis in the claim text) +struct DietaryTag: Identifiable { + let id = UUID() + let claim: String // Full claim text with emoji from API (e.g., "🌾 No gluten") +} + +struct DietaryTagView: View { + let tag: DietaryTag + + var body: some View { + Text(tag.claim) + .font(ManropeFont.medium.size(14)) + .foregroundStyle(.grayScale150) + .padding(.vertical, 6) + .padding(.horizontal, 16) + .frame(height: 40) + .background( + Color(hex: "FfFfFf"), + in: Capsule() + ) + .overlay( + Capsule() + .stroke(Color(hex: "#E9E9E9"), lineWidth: 1) + ) + } +} + +// MARK: - Preview + +#if DEBUG +#Preview("Single Tag") { + DietaryTagView(tag: DietaryTag(claim: "🌾 No gluten")) + .padding() +} + +#Preview("Multiple Tags") { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + DietaryTagView(tag: DietaryTag(claim: "🌱 Vegan")) + DietaryTagView(tag: DietaryTag(claim: "🌾 No gluten")) + DietaryTagView(tag: DietaryTag(claim: "β˜• No caffeine")) + DietaryTagView(tag: DietaryTag(claim: "πŸ₯— Low fat")) + } + .padding() + } +} +#endif diff --git a/IngrediCheck/Views/ProductDetails/FullScreenImageViewer.swift b/IngrediCheck/Views/ProductDetails/FullScreenImageViewer.swift new file mode 100644 index 00000000..b0ad91e8 --- /dev/null +++ b/IngrediCheck/Views/ProductDetails/FullScreenImageViewer.swift @@ -0,0 +1,486 @@ +// +// FullScreenImageViewer.swift +// IngrediCheck +// +// Created on 05/01/26. +// + +import SwiftUI + +struct FullScreenImageViewer: View { + @Environment(\.dismiss) private var dismiss + + let images: [ProductDetailView.ProductImage] + @Binding var selectedIndex: Int + var onFeedback: ((String, String) -> Void)? + var loadingImageUrl: String? = nil // Which image URL is currently loading + + @State private var scale: CGFloat = 1.0 + @State private var lastScale: CGFloat = 1.0 + @State private var offset: CGSize = .zero + @State private var lastOffset: CGSize = .zero + @GestureState private var dragOffset: CGSize = .zero + + // Swipe to dismiss state + @State private var dismissOffset: CGFloat = 0 + @State private var isDismissing: Bool = false + + // Controls visibility + @State private var showControls: Bool = true + + // Constants + private let minScale: CGFloat = 1.0 + private let maxScale: CGFloat = 4.0 + private let dismissThreshold: CGFloat = 150 + + var body: some View { + ZStack { + // Background - fades as user drags down + Color.black + .opacity(backgroundOpacity) + .ignoresSafeArea() + + // Full screen image viewer (ignores safe area for immersive zoom) + TabView(selection: $selectedIndex) { + ForEach(images.indices, id: \.self) { index in + GeometryReader { geometry in + imageContent(for: images[index]) + .aspectRatio(contentMode: .fit) + .frame(width: geometry.size.width, height: geometry.size.height) + .scaleEffect(index == selectedIndex ? scale : 1.0) + .offset(index == selectedIndex ? offset : .zero) + .gesture( + index == selectedIndex ? magnificationGesture() : nil + ) + .simultaneousGesture( + index == selectedIndex ? dragGesture() : nil + ) + .onTapGesture { + // Toggle controls visibility on tap + withAnimation(.easeInOut(duration: 0.2)) { + showControls.toggle() + } + } + } + .tag(index) + } + } + .tabViewStyle(.page(indexDisplayMode: .never)) + .ignoresSafeArea() + .onChange(of: selectedIndex) { oldValue, newValue in + // Reset zoom when switching images + if oldValue != newValue { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + resetZoom() + } + } + } + .offset(y: dismissOffset) + .scaleEffect(dismissScale) + .gesture(swipeToDismissGesture) + + // Overlay controls with material background + VStack(spacing: 0) { + // Header with material background - hides when zoomed + header + .background( + MaterialBlurView() + .ignoresSafeArea(edges: .top) + ) + .opacity(controlsOpacity) + + Spacer() + + // Bottom controls container + VStack(spacing: 12) { + // Zoom controls - always visible (no blur background) + zoomControls + + // Thumbnail strip with blur - hides when zoomed + if images.count > 1 { + thumbnailStrip + .background( + MaterialBlurView() + .ignoresSafeArea(edges: .bottom) + ) + .opacity(controlsOpacity) + } + } + .padding(.bottom, images.count > 1 ? 0 : 32) + } + .animation(.easeInOut(duration: 0.2), value: showControls) + .animation(.easeInOut(duration: 0.2), value: scale) + } + .statusBarHidden(true) + .onChange(of: scale) { oldValue, newValue in + // Auto-hide controls when zoomed in significantly + if newValue > 1.5 && showControls { + withAnimation(.easeInOut(duration: 0.2)) { + showControls = false + } + } else if newValue <= 1.0 && !showControls { + withAnimation(.easeInOut(duration: 0.2)) { + showControls = true + } + } + } + } + + // MARK: - Controls Opacity + + private var controlsOpacity: Double { + guard showControls else { return 0 } + // Fade controls slightly when zoomed + if scale > 1.2 { + return 0.0 + } + return 1.0 + } + + // MARK: - Dismiss Animation Properties + + private var backgroundOpacity: Double { + let progress = min(abs(dismissOffset) / dismissThreshold, 1.0) + return 1.0 - (progress * 0.5) + } + + private var dismissScale: CGFloat { + let progress = min(abs(dismissOffset) / dismissThreshold, 1.0) + return 1.0 - (progress * 0.1) + } + + // MARK: - Header + + private var header: some View { + HStack { + Button { + dismiss() + } label: { + Image(systemName: "xmark") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.white) + .frame(width: 36, height: 36) + .background(.white.opacity(0.2)) + .clipShape(Circle()) + } + + Spacer() + + Text("\(selectedIndex + 1) / \(images.count)") + .font(ManropeFont.semiBold.size(14)) + .foregroundColor(.white) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + Capsule() + .fill(.black.opacity(0.3)) + ) + + Spacer() + + // Feedback buttons or placeholder + if let image = images[safe: selectedIndex], case .api(let locationInfo, let vote) = image, case .url(let url) = locationInfo { + let urlString = url.absoluteString + let isThisImageLoading = loadingImageUrl == urlString + + HStack(spacing: 8) { + FeedbackButton( + type: .up, + isSelected: vote?.value == "up", + isLoading: isThisImageLoading && vote?.value == "up", + isDisabled: isThisImageLoading || loadingImageUrl != nil, + style: .overlay + ) { + onFeedback?(urlString, "up") + } + + FeedbackButton( + type: .down, + isSelected: vote?.value == "down", + isLoading: isThisImageLoading && vote?.value == "down", + isDisabled: isThisImageLoading || loadingImageUrl != nil, + style: .overlay + ) { + onFeedback?(urlString, "down") + } + } + } else { + Color.clear + .frame(width: 36, height: 36) + } + } + .padding(.horizontal, 16) + .padding(.top, 8) + .padding(.bottom, 12) + } + + // MARK: - Thumbnail Strip + + private var thumbnailStrip: some View { + ScrollViewReader { proxy in + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + ForEach(images.indices, id: \.self) { index in + Button { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + selectedIndex = index + } + } label: { + imageContent(for: images[index]) + .frame(width: 52, height: 52) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke( + selectedIndex == index ? Color.white : Color.white.opacity(0.3), + lineWidth: selectedIndex == index ? 2 : 1 + ) + ) + .scaleEffect(selectedIndex == index ? 1.05 : 1.0) + .opacity(selectedIndex == index ? 1.0 : 0.7) + } + .id(index) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + .onChange(of: selectedIndex) { _, newValue in + withAnimation { + proxy.scrollTo(newValue, anchor: .center) + } + } + } + } + + // MARK: - Image Content Helper + + @ViewBuilder + private func imageContent(for image: ProductDetailView.ProductImage) -> some View { + switch image { + case .local(let uiImage): + Image(uiImage: uiImage) + .resizable() + case .api(let location, _): + HeaderImage(imageLocation: location) + } + } + + // MARK: - Gestures + + private func magnificationGesture() -> some Gesture { + MagnificationGesture() + .onChanged { value in + let delta = value / lastScale + lastScale = value + + // Calculate new scale + var newScale = scale * delta + + // Clamp scale between min and max + newScale = min(max(newScale, minScale), maxScale) + + scale = newScale + } + .onEnded { _ in + lastScale = 1.0 + + // If scale is close to min, snap back to 1.0 + if scale < minScale + 0.1 { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + scale = minScale + offset = .zero + } + } + } + } + + private func dragGesture() -> some Gesture { + DragGesture() + .onChanged { value in + // Only allow dragging when zoomed in + if scale > 1.0 { + offset = CGSize( + width: lastOffset.width + value.translation.width, + height: lastOffset.height + value.translation.height + ) + } + } + .onEnded { _ in + lastOffset = offset + + // If not zoomed, reset offset + if scale <= 1.0 { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + offset = .zero + lastOffset = .zero + } + } + } + } + + private var swipeToDismissGesture: some Gesture { + DragGesture() + .onChanged { value in + // Only allow swipe to dismiss when not zoomed + guard scale <= 1.0 else { return } + + // Allow both up and down swipe + let verticalMovement = value.translation.height + + // Apply resistance for upward swipes + if verticalMovement < 0 { + dismissOffset = verticalMovement * 0.3 + } else { + dismissOffset = verticalMovement + } + } + .onEnded { value in + guard scale <= 1.0 else { return } + + let velocity = value.predictedEndTranslation.height - value.translation.height + let shouldDismiss = abs(dismissOffset) > dismissThreshold || abs(velocity) > 500 + + if shouldDismiss && dismissOffset > 0 { + // Dismiss with animation + isDismissing = true + withAnimation(.easeOut(duration: 0.2)) { + dismissOffset = UIScreen.main.bounds.height + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + dismiss() + } + } else { + // Snap back + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + dismissOffset = 0 + } + } + } + } + + // MARK: - Zoom Controls + + private var zoomControls: some View { + HStack(spacing: 16) { + // Zoom out button + Button { + zoomOut() + } label: { + Image(systemName: "minus.magnifyingglass") + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(.white) + .frame(width: 40, height: 40) + .background(.white.opacity(0.2)) + .clipShape(Circle()) + } + .disabled(scale <= minScale) + .opacity(scale <= minScale ? 0.5 : 1.0) + + // Zoom percentage badge + Text("\(Int(scale * 100))%") + .font(ManropeFont.semiBold.size(13)) + .foregroundColor(.white) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background( + Capsule() + .fill(.black.opacity(0.4)) + ) + .frame(minWidth: 56) + + // Zoom in button + Button { + zoomIn() + } label: { + Image(systemName: "plus.magnifyingglass") + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(.white) + .frame(width: 40, height: 40) + .background(.white.opacity(0.2)) + .clipShape(Circle()) + } + .disabled(scale >= maxScale) + .opacity(scale >= maxScale ? 0.5 : 1.0) + } + .padding(.vertical, 8) + } + + // MARK: - Zoom Actions + + private func zoomIn() { + let increment: CGFloat = 0.5 + let newScale = min(scale + increment, maxScale) + + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + scale = newScale + lastScale = 1.0 + + // Reset offset when zooming in from 1.0 + if scale == increment + 1.0 { + offset = .zero + lastOffset = .zero + } + } + } + + private func zoomOut() { + let decrement: CGFloat = 0.5 + var newScale = max(scale - decrement, minScale) + + // If zooming out to 1.0, reset everything + if newScale <= minScale + 0.1 { + newScale = minScale + } + + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + scale = newScale + lastScale = 1.0 + + // Reset offset if zoomed out to 1.0 + if newScale <= minScale { + offset = .zero + lastOffset = .zero + } + } + } + + // MARK: - Helper Methods + + private func resetZoom() { + scale = 1.0 + lastScale = 1.0 + offset = .zero + lastOffset = .zero + showControls = true + } +} + +// MARK: - Material Blur View + +struct MaterialBlurView: View { + var body: some View { + Rectangle() + .fill(.ultraThinMaterial) + .environment(\.colorScheme, .dark) + } +} + +// MARK: - Preview + +#if DEBUG +#Preview { + @Previewable @State var selectedIndex = 0 + + let sampleImages: [ProductDetailView.ProductImage] = [ + .local(UIImage(named: "ram")!), + .local(UIImage(systemName: "photo.fill")!), + .local(UIImage(systemName: "photo.circle")!) + ] + + FullScreenImageViewer( + images: sampleImages, + selectedIndex: $selectedIndex + ) +} +#endif diff --git a/IngrediCheck/Views/ProductDetails/IngredientDetailsView.swift b/IngrediCheck/Views/ProductDetails/IngredientDetailsView.swift new file mode 100644 index 00000000..19ad67ae --- /dev/null +++ b/IngrediCheck/Views/ProductDetails/IngredientDetailsView.swift @@ -0,0 +1,190 @@ +import SwiftUI + +struct IngredientDetailsView: View { + let paragraphs: [IngredientParagraph] + @Binding var activeHighlight: IngredientHighlight? + let highlightColor: Color + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + ForEach(paragraphs) { paragraph in + VStack(alignment: .leading, spacing: 6) { + if let title = paragraph.title { + Text(title) + .font(NunitoFont.bold.size(15)) + .foregroundStyle(.grayScale150) + } + + HighlightableParagraph( + paragraph: paragraph, + activeHighlight: $activeHighlight, + highlightColor: highlightColor + ) + } + } + } + .padding(.vertical, 4) + } +} + +struct HighlightableParagraph: View { + let paragraph: IngredientParagraph + @Binding var activeHighlight: IngredientHighlight? + let highlightColor: Color + + private var segments: [IngredientSegment] { + segmentedText(paragraph.body, highlights: paragraph.highlights) + } + + var body: some View { + Text(buildAttributedString()) + .font(ManropeFont.regular.size(14)) + .foregroundStyle(.grayScale120) + .environment(\.openURL, OpenURLAction { url in + if url.scheme == "ingredihighlight", + let indexStr = url.host(), + let index = Int(indexStr), + index < paragraph.highlights.count { + let highlight = paragraph.highlights[index] + if activeHighlight?.id == highlight.id { + activeHighlight = nil + } else { + activeHighlight = highlight + } + } + return .handled + }) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func buildAttributedString() -> AttributedString { + var result = AttributedString() + for segment in segments { + switch segment { + case .text(let value): + result += AttributedString(value) + case .highlight(let value, let highlight): + var attr = AttributedString(value) + attr.font = ManropeFont.semiBold.size(14) + attr.foregroundColor = highlight.color + attr.underlineStyle = .init(pattern: .solid, color: .clear) + if let idx = paragraph.highlights.firstIndex(where: { $0.phrase.lowercased() == highlight.phrase.lowercased() }), + let url = URL(string: "ingredihighlight://\(idx)") { + attr.link = url + } + result += attr + case .lineBreak: + result += AttributedString("\n") + } + } + return result + } +} + +struct IngredientTooltipView: View { + let highlight: IngredientHighlight + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(highlight.phrase) + .font(NunitoFont.bold.size(13)) + .foregroundStyle(.grayScale150) + + Text(highlight.reason) + .font(ManropeFont.regular.size(12)) + .foregroundStyle(.grayScale120) + .lineSpacing(3) + } + .padding(14) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color.white) + .shadow(color: Color.black.opacity(0.08), radius: 18, x: 0, y: 10) + ) + .padding(.top, 8) + } +} + +// MARK: - Highlight Support + +enum IngredientSegment { + case text(String) + case highlight(String, IngredientHighlight) + case lineBreak +} + +struct IngredientParagraph: Identifiable { + let id = UUID() + let title: String? + let body: String + let highlights: [IngredientHighlight] +} + +struct IngredientHighlight: Identifiable, Equatable { + let id = UUID() + let phrase: String + let reason: String + let color: Color // Per-highlight color based on safety recommendation + + init(phrase: String, reason: String, color: Color = .red) { + self.phrase = phrase + self.reason = reason + self.color = color + } +} + +private func segmentedText(_ text: String, highlights: [IngredientHighlight]) -> [IngredientSegment] { + let lowercased = text.lowercased() + var matches: [(Range, IngredientHighlight)] = [] + + for highlight in highlights { + let searchPhrase = highlight.phrase.lowercased() + var searchStartIndex = lowercased.startIndex + + // Find ALL occurrences of this highlight phrase + while let range = lowercased.range(of: searchPhrase, range: searchStartIndex.. currentIndex { + appendNormal(text[currentIndex.. Void)? = nil // Item, Vote ("up", "down") + var productVote: DTO.Vote? = nil // Current product feedback vote + var onProductFeedback: ((String) -> Void)? = nil // Product feedback callback ("up", "down") + var isProductFeedbackLoading: Bool = false // Loading state for product feedback + var loadingIngredientName: String? = nil // Which ingredient is currently loading + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + header + summaryText + .padding(.horizontal, 20) + + if status != .matched { + if isExpanded { + alertItemsList + } else { + readMoreRow(text: "Read More") + .padding(.horizontal, 20) + .padding(.bottom, 20) + } + } else { + // Add bottom padding for matched state since there's no read more row + Color.clear.frame(height: 0).padding(.bottom, 20) + } + } + .background( + RoundedRectangle(cornerRadius: 32, style: .continuous) + .fill(status.alertCardBackground) + ) + .overlay( + RoundedRectangle(cornerRadius: 32, style: .continuous) + .stroke(Color.white.opacity(0.4), lineWidth: 1) + ) + .contentShape(Rectangle()) + .onTapGesture { + guard status != .matched else { return } + withAnimation(.easeInOut(duration: 0.3)) { + isExpanded.toggle() + } + } + } + + private var header: some View { + HStack { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.circle.fill") + .font(.system(size: 18)) + .foregroundStyle(.white) + + Text(status.alertTitle) + .font(NunitoFont.bold.size(12)) + .foregroundStyle(.white) + } + .padding(.vertical, 10) + .padding(.horizontal, 20) + .background(status.color, in: Capsule()) + + Spacer(minLength: 0) + + // Product feedback buttons (thumb up/down) on the right + if let onProductFeedback { + HStack(spacing: 12) { + FeedbackButton( + type: .up, + isSelected: productVote?.value == "up", + isLoading: isProductFeedbackLoading && productVote?.value == "up", + isDisabled: isProductFeedbackLoading, + style: .whiteBoxed + ) { + onProductFeedback("up") + } + + FeedbackButton( + type: .down, + isSelected: productVote?.value == "down", + isLoading: isProductFeedbackLoading && productVote?.value == "down", + isDisabled: isProductFeedbackLoading, + style: .whiteBoxed + ) { + onProductFeedback("down") + } + } + } + } + .padding(.horizontal, 20) + .padding(.top, 16) + } + + private var summaryText: some View { + buildHighlightedText() + .font(ManropeFont.regular.size(14)) + .lineSpacing(6) + .lineLimit(4) + } + + private func buildHighlightedText() -> Text { + guard let overallAnalysisText = overallAnalysis, !overallAnalysisText.isEmpty else { + // Fallback to generic message if no analysis + if status == .matched { + return Text("This product aligns with your dietary preferences.") + .foregroundStyle(.grayScale150) + } + return Text("This product contains ingredients that may not be suitable for your family's dietary preferences.") + .foregroundStyle(.grayScale150) + } + + // Get list of unmatched ingredient names + let unmatchedIngredients = ingredientRecommendations? + .filter { $0.safetyRecommendation == .definitelyUnsafe } + .map { $0.ingredientName } + ?? [] + + if unmatchedIngredients.isEmpty { + // No ingredients to highlight, return plain text + return Text(overallAnalysisText).foregroundStyle(.grayScale150) + } + + // Find all ranges of ingredients to highlight + var highlightRanges: [(range: Range, ingredient: String)] = [] + + for ingredient in unmatchedIngredients { + var searchStartIndex = overallAnalysisText.startIndex + + while searchStartIndex < overallAnalysisText.endIndex, + let range = overallAnalysisText.range(of: ingredient, options: .caseInsensitive, range: searchStartIndex..] = [] + for (range, _) in highlightRanges { + if let lastRange = mergedRanges.last, lastRange.overlaps(range) || lastRange.upperBound == range.lowerBound { + // Extend the last range + mergedRanges[mergedRanges.count - 1] = lastRange.lowerBound.. some View { + HStack { + Spacer() + HStack(spacing: 6) { + Text(text) + .font(NunitoFont.bold.size(15)) + .foregroundStyle(status.color) + + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(status.color) + } + } + } + + private var alertItemsList: some View { + VStack(spacing: 0) { + ForEach(Array(items.enumerated()), id: \.element.id) { index, item in + VStack(alignment: .leading, spacing: 12) { + // Ingredient name with status badge on the right + HStack(spacing: 8) { + Text(item.name) + .font(NunitoFont.bold.size(16)) + .foregroundStyle(Color.grayScale150) + Spacer() + statusChip(for: item.status) + } + .padding(.top, index == 0 ? 12 : 0) + + Text(item.detail) + .font(ManropeFont.regular.size(14)) + .foregroundStyle(.grayScale120) + .lineSpacing(4) + + HStack { + avatarStack(for: item) + Spacer() + HStack(spacing: 12) { + let isThisIngredientLoading = loadingIngredientName == item.rawIngredientName + + FeedbackButton( + type: .up, + isSelected: item.vote?.value == "up", + isLoading: isThisIngredientLoading && item.vote?.value == "up", + isDisabled: isThisIngredientLoading || loadingIngredientName != nil, + style: .boxed + ) { + onFeedback?(item, "up") + } + + FeedbackButton( + type: .down, + isSelected: item.vote?.value == "down", + isLoading: isThisIngredientLoading && item.vote?.value == "down", + isDisabled: isThisIngredientLoading || loadingIngredientName != nil, + style: .boxed + ) { + onFeedback?(item, "down") + } + } + } + } + .padding(.vertical, 12) + + if index != items.count - 1 { + Divider() + } + } + + readMoreRow(text: "Read Less") + } + .padding(.horizontal, 20) + .padding(.bottom, 20) + .background(Color.white, in: RoundedRectangle(cornerRadius: 24)) + .shadow(color: Color(hex: "#D8D8D8").opacity(0.25), radius: 9.8, y: 6) + .padding(.top, 4) + } + + private func statusChip(for status: IngredientAlertStatus) -> some View { + Text(status.title) + .font(NunitoFont.bold.size(12)) + .foregroundStyle(status.foregroundColor) + .padding(.vertical, 6) + .padding(.horizontal, 16) + .background(status.backgroundColor, in: Capsule()) + } + + private func avatarStack(for item: IngredientAlertItem) -> some View { + HStack(spacing: -8) { + if let memberIdentifiers = item.memberIdentifiers, !memberIdentifiers.isEmpty { + ForEach(Array(memberIdentifiers.prefix(5)), id: \.self) { memberIdentifier in + if memberIdentifier == "Family" { + // Special "Everyone" avatar + Circle() + .fill(Color(hex: "#D9D9D9")) + .frame(width: 32, height: 32) + .overlay { + Image("family") + .resizable() + .scaledToFill() + .frame(width: 30, height: 30) + .clipShape(Circle()) + } + .overlay { + Circle().stroke(Color.white, lineWidth: 1) + } + } else if let member = resolveMember(from: memberIdentifier) { + // Use centralized MemberAvatar component + MemberAvatar.custom(member: member, size: 32, imagePadding: 0) + } + } + } else { + // Fallback: show placeholder if no member identifiers + ForEach(["image-bg1", "image-bg2", "image-bg3"], id: \.self) { name in + Image(name) + .resizable() + .scaledToFill() + .frame(width: 32, height: 32) + .clipShape(Circle()) + .overlay( + Circle() + .stroke(Color.white, lineWidth: 1) + ) + } + } + } + .padding(.vertical, 4) + } + + /// Resolves a FamilyMember from a member identifier string + private func resolveMember(from identifier: String) -> FamilyMember? { + guard let uuid = UUID(uuidString: identifier), + let family = familyStore.family else { + return nil + } + + if uuid == family.selfMember.id { + return family.selfMember + } + return family.otherMembers.first { $0.id == uuid } + } +} + +struct IngredientAlertItem: Identifiable { + let id = UUID() + let name: String + let detail: String + let status: IngredientAlertStatus + let memberIdentifiers: [String]? // Array of member IDs or ["Family"] + let vote: DTO.Vote? + let rawIngredientName: String? // Actual ingredient name from API if available +} + +enum IngredientAlertStatus { + case unmatched + case uncertain + + var title: String { + switch self { + case .unmatched: return "Unmatched" + case .uncertain: return "Uncertain" + } + } + + var foregroundColor: Color { + switch self { + case .unmatched: return Color(hex: "#FF4E50") + case .uncertain: return Color(hex: "#E9A600") + } + } + + var backgroundColor: Color { + switch self { + case .unmatched: return Color(hex: "#FFE3E2") + case .uncertain: return Color(hex: "#FFF4DB") + } + } +} + +// MARK: - Previews + +#Preview("Unmatched - Collapsed") { + IngredientsAlertCard( + isExpanded: .constant(false), + items: [ + IngredientAlertItem( + name: "Sodium Nitrate", + detail: "This preservative is commonly found in processed meats and may not align with your preference to avoid artificial additives.", + status: .unmatched, + memberIdentifiers: ["Family"], + vote: nil, + rawIngredientName: "sodium nitrate" + ), + IngredientAlertItem( + name: "High Fructose Corn Syrup", + detail: "A sweetener that you've indicated you prefer to avoid.", + status: .unmatched, + memberIdentifiers: ["Family"], + vote: nil, + rawIngredientName: "high fructose corn syrup" + ) + ], + status: .unmatched, + overallAnalysis: "This product contains Sodium Nitrate and High Fructose Corn Syrup which may not be suitable for your dietary preferences.", + onProductFeedback: { _ in } + ) + .environment(FamilyStore()) + .padding() +} + +#Preview("Unmatched - Expanded") { + IngredientsAlertCard( + isExpanded: .constant(true), + items: [ + IngredientAlertItem( + name: "Sodium Nitrate", + detail: "This preservative is commonly found in processed meats and may not align with your preference to avoid artificial additives.", + status: .unmatched, + memberIdentifiers: ["Family"], + vote: nil, + rawIngredientName: "sodium nitrate" + ), + IngredientAlertItem( + name: "Artificial Colors", + detail: "Contains Red 40 and Yellow 5 which you prefer to avoid.", + status: .uncertain, + memberIdentifiers: ["Family"], + vote: nil, + rawIngredientName: "artificial colors" + ) + ], + status: .unmatched, + overallAnalysis: "This product contains Sodium Nitrate which may not be suitable for your dietary preferences.", + onFeedback: { _, _ in }, + onProductFeedback: { _ in } + ) + .environment(FamilyStore()) + .padding() +} + +#Preview("Matched") { + IngredientsAlertCard( + isExpanded: .constant(false), + items: [], + status: .matched, + overallAnalysis: "This product aligns with your dietary preferences.", + onProductFeedback: { _ in } + ) + .environment(FamilyStore()) + .padding() +} + +#Preview("Uncertain") { + IngredientsAlertCard( + isExpanded: .constant(false), + items: [ + IngredientAlertItem( + name: "Natural Flavors", + detail: "The source of these natural flavors is not specified and may contain ingredients you prefer to avoid.", + status: .uncertain, + memberIdentifiers: ["Family"], + vote: nil, + rawIngredientName: "natural flavors" + ) + ], + status: .uncertain, + overallAnalysis: "This product contains Natural Flavors with uncertain ingredients.", + onProductFeedback: { _ in } + ) + .environment(FamilyStore()) + .padding() +} diff --git a/IngrediCheck/Views/ProductDetails/ProductDetailView.swift b/IngrediCheck/Views/ProductDetails/ProductDetailView.swift new file mode 100644 index 00000000..5f530102 --- /dev/null +++ b/IngrediCheck/Views/ProductDetails/ProductDetailView.swift @@ -0,0 +1,1511 @@ +// +// ProductDetailView.swift +// IngrediCheckPreview +// +// Created on 18/11/25. +// + +import SwiftUI + +enum ProductDetailPresentationSource { + case homeView + case cameraView + case pushNavigation // Used when navigating via AppRoute (Single Root NavigationStack) +} + +struct ProductDetailView: View { + @Environment(\.dismiss) private var dismiss + @Environment(WebService.self) private var webService + @Environment(UserPreferences.self) private var userPreferences + @Environment(ScanHistoryStore.self) private var scanHistoryStore + @Environment(AppState.self) private var appState: AppState? // Optional - for push navigation + @Environment(AppNavigationCoordinator.self) private var coordinator: AppNavigationCoordinator? + + @State private var isFavorite = false + @AppStorage("ingredientsSectionExpanded") private var isIngredientsExpanded = true // Default: expanded, persists user choice + @State private var isIngredientsAlertExpanded = false + @State private var selectedImageIndex = 0 + @State private var activeIngredientHighlight: IngredientHighlight? + @State private var isImageViewerPresented = false + @State private var isReanalyzingLocally = false // Temporary state to show analyzing UI immediately + @State private var reanalysisRotation: Double = 0 // Rotation for sync icon animation + + // Real-time scan observation (new approach) + var scanId: String? = nil // If provided, view will fetch/poll for scan updates + var initialScan: DTO.Scan? = nil // Initial scan data (if from cache/SSE) + @State private var scan: DTO.Scan? = nil // Current scan data (updates via polling) + @State private var pollingTask: Task? = nil + + // Feedback loading states (show spinner on thumb buttons while API responds) + @State private var isProductFeedbackLoading = false + @State private var loadingIngredientName: String? = nil // Track which ingredient is loading + @State private var loadingImageUrl: String? = nil // Track which image is loading + + // Legacy static data (old approach - kept for backwards compatibility) + var product: DTO.Product? = nil + var matchStatus: DTO.ProductRecommendation? = nil + var ingredientRecommendations: [DTO.IngredientRecommendation]? = nil + var overallAnalysis: String? = nil + var localImages: [UIImage]? = nil // Local images captured in photo mode + var isPlaceholderMode: Bool = false + + // Presentation source tracking + var presentationSource: ProductDetailPresentationSource = .homeView + + // Bindings for camera control (when presented from CameraView) + var onRequestCameraWithScan: ((String) -> Void)? = nil + + private let fallbackProductStatus: ProductMatchStatus = .unknown + + // Compute product from scan if scanId mode, otherwise use legacy product + private var resolvedProduct: DTO.Product? { + if let scan = scan { + return scan.toProduct() + } + return product + } + + // Compute matchStatus from scan if scanId mode, otherwise use legacy matchStatus + private var resolvedMatchStatus: DTO.ProductRecommendation? { + if let scan = scan { + return scan.toProductRecommendation() + } + return matchStatus + } + + // Compute ingredientRecommendations from scan if scanId mode, otherwise use legacy + private var resolvedIngredientRecommendations: [DTO.IngredientRecommendation]? { + if let scan = scan { + return scan.analysis_result?.toIngredientRecommendations() + } + return ingredientRecommendations + } + + // Compute overallAnalysis from scan if scanId mode, otherwise use legacy + private var resolvedOverallAnalysis: String? { + if let scan = scan { + return scan.analysis_result?.overall_analysis + } + return overallAnalysis + } + + private var resolvedIsStale: Bool { + return scan?.analysis_result?.is_stale ?? false + } + + // Check if analysis is in progress + + + private var isAnalyzing: Bool { + isReanalyzingLocally || scan?.state == "analyzing" || scan?.state == "processing_images" || scan?.state == "fetching_product_info" + } + + // Combined images: local images (if available) take priority over API images + // This ensures photo mode shows the user's captured images + enum ProductImage: Identifiable { + case local(UIImage) + case api(DTO.ImageLocationInfo, vote: DTO.Vote?) + + var id: String { + switch self { + case .local(let image): + return "local_\(image.hashValue)" + case .api(let location, _): + switch location { + case .url(let url): + return "api_\(url.absoluteString)" + case .imageFileHash(let hash): + return "api_\(hash)" + case .scanImagePath(let path): + return "api_\(path)" + } + } + } + } + + private var allImages: [ProductImage] { + var images: [ProductImage] = [] + + // Add local images first (photo mode) + if let localImages = localImages, !localImages.isEmpty { + images.append(contentsOf: localImages.map { ProductImage.local($0) }) + } + + // Add API images + if let scan = scan, !scan.images.isEmpty { + // Prefer scan images as they contain vote info + if localImages == nil || localImages?.isEmpty == true { + for scanImage in scan.images { + switch scanImage { + case .inventory(let img): + if let url = URL(string: img.url) { + images.append(.api(.url(url), vote: img.vote)) + } + case .user(let img): + if img.status == "processed", let storagePath = img.storage_path { + images.append(.api(.scanImagePath(storagePath), vote: nil)) + } + } + } + } + } else if let product = resolvedProduct, !product.images.isEmpty { + // Fallback for legacy mode + if localImages == nil || localImages?.isEmpty == true { + images.append(contentsOf: product.images.map { ProductImage.api($0, vote: nil) }) + } + } + + return images + } + + // Dietary tags from product claims (API now includes emojis in claim text) + private var dietaryTags: [DietaryTag] { + guard let claims = resolvedProduct?.claims, !claims.isEmpty else { + return [] + } + + // Directly map claims to DietaryTag (emojis already included from API) + return claims.map { DietaryTag(claim: $0) } + } + + // Check if product has images/name but missing ingredients + private var hasMissingIngredients: Bool { + guard let product = resolvedProduct else { return false } + // Has product info (name or brand or images) but no ingredients + let hasProductInfo = product.name != nil || product.brand != nil || !product.images.isEmpty || !allImages.isEmpty + let hasNoIngredients = product.ingredients.isEmpty + return hasProductInfo && hasNoIngredients && !isPlaceholderMode && !isAnalyzing + } + + // Check if product exists but has no name, no brand, and no ingredients (not in our database) + private var hasEmptyProductDetails: Bool { + guard let product = resolvedProduct else { return false } + let hasNoName = product.name == nil || product.name?.isEmpty == true + let hasNoBrand = product.brand == nil || product.brand?.isEmpty == true + let hasNoIngredients = product.ingredients.isEmpty + return hasNoName && hasNoBrand && hasNoIngredients && !isPlaceholderMode && !isAnalyzing + } + + // Removed hardcoded descriptionText - now using resolvedDescriptionText computed property + + // Removed hardcoded ingredientAlertItems - now using resolvedIngredientAlertItems computed property + + // Removed hardcoded ingredientParagraphs - now using resolvedIngredientParagraphs computed property + + private var resolvedBrand: String { + if let brand = resolvedProduct?.brand, !brand.isEmpty { + return brand + } + return "" + } + + private var resolvedName: String { + if let name = resolvedProduct?.name, !name.isEmpty { + return name + } + return "Unknown Product" + } + + private var resolvedDetails: String { + // API doesn't provide a separate "details" field like "Instant Noodles Β· Pack of 70g" + // This field is not part of the Scan API response, so we don't display it + // Instead, product name and brand are shown separately above + return "" // Return empty string - don't show hardcoded fallback + } + + private var resolvedStatus: ProductMatchStatus { + // Show "analyzing" status if analysis is in progress + if isAnalyzing { + return .analyzing + } + + guard let matchStatus = resolvedMatchStatus else { + return fallbackProductStatus + } + switch matchStatus { + case .match: + return .matched + case .needsReview: + return .uncertain + case .notMatch: + return .unmatched + case .unknown: + return .unknown + } + } + + + private var resolvedIngredientAlertItems: [IngredientAlertItem] { + guard let ingredientRecommendations = resolvedIngredientRecommendations else { + return [] + } + + return ingredientRecommendations + .filter { $0.safetyRecommendation != .safe } // Only show flagged ingredients + .map { recommendation in + let status: IngredientAlertStatus = recommendation.safetyRecommendation == .definitelyUnsafe ? .unmatched : .uncertain + + // Find analysis matching this ingredient to get vote status + let analysis = scan?.analysis_result?.ingredient_analysis.first { $0.ingredient == recommendation.ingredientName } + + return IngredientAlertItem( + name: recommendation.ingredientName, + detail: recommendation.reasoning, + status: status, + memberIdentifiers: recommendation.memberIdentifiers, // Use memberIdentifiers array + vote: analysis?.vote, + rawIngredientName: analysis?.ingredient + ) + } + } + + private var resolvedIngredientParagraphs: [IngredientParagraph] { + guard let product = resolvedProduct else { + print("[ProductDetailView] ⚠️ No product data available for ingredients") + return [] + } + + guard !product.ingredients.isEmpty else { + print("[ProductDetailView] ⚠️ Product ingredients array is empty - product.name: \(product.name ?? "nil"), product.brand: \(product.brand ?? "nil")") + return [] + } + + // Use ingredientsListAsString to format ingredients properly (handles nested ingredients) + let ingredientsString = product.ingredientsListAsString + + if ingredientsString.isEmpty { + print("[ProductDetailView] ⚠️ ingredientsListAsString returned empty string despite non-empty ingredients array") + return [] + } + + print("[ProductDetailView] βœ… Ingredients available - count: \(product.ingredients.count), string length: \(ingredientsString.count)") + + // Create highlights from resolvedIngredientRecommendations (handles both scan and legacy modes) + var highlights: [IngredientHighlight] = [] + if let recommendations = resolvedIngredientRecommendations { + for recommendation in recommendations { + // Only create highlights for flagged ingredients + if recommendation.safetyRecommendation != .safe { + // Use appropriate color based on safety recommendation + let highlightColor: Color = recommendation.safetyRecommendation == .definitelyUnsafe + ? .fail100 // Red for unmatched + : .warning100 // Yellow for uncertain (maybeUnsafe) + + highlights.append(IngredientHighlight( + phrase: recommendation.ingredientName, + reason: recommendation.reasoning, + color: highlightColor + )) + } + } + } + + return [IngredientParagraph( + title: nil, + body: ingredientsString, + highlights: highlights + )] + } + + var body: some View { + VStack(spacing: 0) { + ScrollView(showsIndicators: false) { + VStack(spacing: 0) { + if hasEmptyProductDetails { + // Empty product state: no gallery, no product info β€” just centered empty state + emptyProductDetailsView + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.top, 60) + } else { + // Gallery section - never redacted, shows placeholder images + productGallery + .unredacted() + .padding(.top, 16) + + // Content sections - redacted in placeholder mode + Group { + productInformation + dietaryTagsRow + + // Show Missing Ingredients UI or regular content + if hasMissingIngredients { + missingIngredientsView + } else { + if !resolvedIngredientAlertItems.isEmpty || resolvedStatus == .matched { + IngredientsAlertCard( + isExpanded: $isIngredientsAlertExpanded, + items: resolvedIngredientAlertItems, + status: resolvedStatus, + overallAnalysis: resolvedOverallAnalysis, + ingredientRecommendations: resolvedIngredientRecommendations, + onFeedback: { item, voteType in + handleIngredientFeedback(item: item, voteType: voteType) + }, + productVote: scan?.product_info_vote, + onProductFeedback: { voteType in + handleProductFeedback(voteType: voteType) + }, + isProductFeedbackLoading: isProductFeedbackLoading, + loadingIngredientName: loadingIngredientName + ) + .padding(.horizontal, 20) + .padding(.bottom, 20) + } + + + CollapsibleSection( + title: "Ingredients", + isExpanded: $isIngredientsExpanded + ) { + if resolvedIngredientParagraphs.isEmpty { + Text("No ingredients available") + .font(ManropeFont.regular.size(14)) + .foregroundStyle(.grayScale100) + .lineSpacing(4) + } else { + IngredientDetailsView( + paragraphs: resolvedIngredientParagraphs, + activeHighlight: $activeIngredientHighlight, + highlightColor: resolvedStatus.color + ) + } + } + .padding(.horizontal, 20) + .padding(.bottom, 40) + } + } + .redacted(reason: isPlaceholderMode ? .placeholder : []) + } + } + } + } + .background(Color(hex: "#FAFAFA")) // Lighter background for Product Details + .overlay(alignment: .bottom) { + // Ingredient tooltip overlay - shown at ProductDetailView level for proper positioning + if let highlight = activeIngredientHighlight { + ZStack(alignment: .bottom) { + // Dismiss backdrop + Color.black.opacity(0.001) + .ignoresSafeArea() + .onTapGesture { + withAnimation(.easeInOut(duration: 0.2)) { + activeIngredientHighlight = nil + } + } + + // Tooltip card + VStack(alignment: .leading, spacing: 6) { + Text(highlight.phrase) + .font(NunitoFont.bold.size(14)) + .foregroundStyle(.grayScale150) + + Text(highlight.reason) + .font(ManropeFont.regular.size(13)) + .foregroundStyle(.grayScale120) + .lineSpacing(4) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + .background( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .fill(Color.white) + .shadow(color: Color.black.opacity(0.12), radius: 24, x: 0, y: -8) + ) + .padding(.horizontal, 20) + .padding(.bottom, 20) + } + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + .animation(.easeInOut(duration: 0.25), value: activeIngredientHighlight) + .navigationTitle(resolvedBrand.isEmpty ? "Product Detail" : resolvedBrand) + .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(.hidden, for: .navigationBar) + .toolbar { + // Back button (only show when presented from camera view) + if presentationSource == .cameraView { + ToolbarItem(placement: .topBarLeading) { + Button { + dismiss() + } label: { + HStack(spacing: 4) { + Image(systemName: "chevron.left") + .font(.system(size: 16, weight: .semibold)) + Text("Back") + .font(ManropeFont.medium.size(16)) + } + .foregroundStyle(.grayScale150) + .padding(.leading, 4) + } + } + } + + ToolbarItem(placement: .topBarTrailing) { + HStack(spacing: 12) { + // Re-analysis button (when stale or reanalyzing) + if resolvedIsStale || isReanalyzingLocally { + Button { + if !isReanalyzingLocally { + performReanalysis() + } + } label: { + Image(systemName: "arrow.trianglehead.2.clockwise.rotate.90.circle.fill") + .font(.system(size: 22)) + .foregroundStyle(isReanalyzingLocally ? Color.grayScale50 : Color(hex: "#FF8A00")) + .rotationEffect(.degrees(reanalysisRotation)) + } + .disabled(isReanalyzingLocally) + .onChange(of: isReanalyzingLocally) { _, isReanalyzing in + if isReanalyzing { + withAnimation(.linear(duration: 1.0).repeatForever(autoreverses: false)) { + reanalysisRotation = 360 + } + } else { + withAnimation(.easeOut(duration: 0.3)) { + reanalysisRotation = 0 + } + } + } + } + + // Favorite button + Button { + toggleFavorite() + } label: { + Image(systemName: isFavorite ? "heart.fill" : "heart") + .font(.system(size: 20)) + .foregroundStyle(isFavorite ? Color(hex: "#FF1100") : .grayScale150) + } + .disabled(scanId == nil && product == nil) + } + .padding(.trailing, 4) + } + } + .onChange(of: isIngredientsExpanded) { _, expanded in + if !expanded { + activeIngredientHighlight = nil + } + } + .fullScreenCover(isPresented: $isImageViewerPresented) { + FullScreenImageViewer( + images: allImages, + selectedIndex: $selectedImageIndex, + onFeedback: { url, vote in + handleImageFeedback(imageUrl: url, voteType: vote) + }, + loadingImageUrl: loadingImageUrl + ) + } + .task(id: scanId) { + // If scanId is provided, fetch and poll for scan updates + guard let scanId = scanId, !scanId.isEmpty else { return } + + // If initialScan is provided, use it directly + if let initialScan = initialScan { + print("[ProductDetailView] πŸ“¦ Using initialScan - scan_id: \(scanId), state: \(initialScan.state)") + await MainActor.run { + self.scan = initialScan + } + + // If scan is still processing/analyzing, start polling even with initialScan + // This ensures ProductDetailView stays in sync when opened during analysis + if initialScan.state != "done" { + print("[ProductDetailView] ⏳ initialScan not done, starting polling - scan_id: \(scanId), state: \(initialScan.state)") + await fetchAndPollScan(scanId: scanId) + return + } + + // If barcode scan is done, no polling needed - SSE handles updates + return + } + + // Photo scan: fetch and poll for updates + print("[ProductDetailView] πŸ”΅ Fetching scan for photo mode - scan_id: \(scanId)") + await fetchAndPollScan(scanId: scanId) + } + .task(id: initialScan) { + // Watch for changes in initialScan (e.g., SSE updates for barcode scans) + if let initialScan = initialScan, let scanId = scanId, initialScan.id == scanId { + await MainActor.run { + self.scan = initialScan + // Update favorite state from scan + self.isFavorite = initialScan.is_favorited ?? false + print("[ProductDetailView] πŸ”„ Updated scan from initialScan change - scan_id: \(scanId), state: \(initialScan.state)") + } + } + } + .task(id: scan?.is_favorited) { + // Update favorite state whenever scan favorite status changes + if let isFavorited = scan?.is_favorited { + await MainActor.run { + self.isFavorite = isFavorited + } + } + } + .onAppear { setDisplayedScanContext() } + .onDisappear { + // Cancel polling when view disappears + pollingTask?.cancel() + pollingTask = nil + + // Clear displayed scan context + appState?.displayedScanId = nil + appState?.displayedAnalysisId = nil + } + } + + // MARK: - Displayed Scan Context (for AIBot FAB) + + private func setDisplayedScanContext() { + appState?.displayedScanId = scanId + appState?.displayedAnalysisId = scan?.analysis_result?.id ?? scan?.analysis_id + } + + // MARK: - Favorite Toggle + + private func toggleFavorite() { + // Determine which ID to use for favoriting + // Priority: scanId > product data (shouldn't happen without scanId in new flow) + guard let favoriteId = scanId else { + print("[FAVORITE] ⚠️ Cannot favorite - no scanId available") + return + } + + // Optimistically update UI + let previousState = isFavorite + isFavorite = !previousState + + // Call API - toggleFavorite returns the actual new state + Task { + do { + let newFavoriteState = try await webService.toggleFavorite(scanId: favoriteId) + + // Update with server's response (in case of race conditions) + await MainActor.run { + self.isFavorite = newFavoriteState + } + + // Update scan object with new favorite status + if let currentScan = scan { + let updatedScan = DTO.Scan( + id: currentScan.id, + scan_type: currentScan.scan_type, + barcode: currentScan.barcode, + state: currentScan.state, + product_info: currentScan.product_info, + product_info_source: currentScan.product_info_source, + analysis_result: currentScan.analysis_result, + images: currentScan.images, + latest_guidance: currentScan.latest_guidance, + created_at: currentScan.created_at, + last_activity_at: currentScan.last_activity_at, + is_favorited: newFavoriteState, + analysis_id: currentScan.analysis_result?.id ?? currentScan.analysis_id + ) + + await MainActor.run { + self.scan = updatedScan + } + } + } catch { + print("[FAVORITE] ❌ Failed to toggle favorite - scanId: \(favoriteId), error: \(error.localizedDescription)") + + // Revert UI on error + await MainActor.run { + self.isFavorite = previousState + } + } + } + } + + private func performReanalysis() { + guard let scanId = scanId else { return } + + Task { + do { + print("[ProductDetailView] πŸ”„ Triggering re-analysis - scan_id: \(scanId)") + + // Show analyzing state immediately + await MainActor.run { + self.isReanalyzingLocally = true + } + + let updatedScan = try await webService.reanalyzeScan(scanId: scanId) + + await MainActor.run { + self.scan = updatedScan + self.isReanalyzingLocally = false // Reset local state as scan state takes over + + // If state became one that requires polling, restart polling + if updatedScan.state != "done" { + startPolling(scanId: scanId) + } + } + } catch { + print("[ProductDetailView] ❌ Re-analysis failed: \(error)") + await MainActor.run { + self.isReanalyzingLocally = false + } + } + } + } + + // MARK: - Fetch and Poll Logic + + private func fetchAndPollScan(scanId: String) async { + do { + // Initial fetch + print("[ProductDetailView] πŸ”΅ Fetching scan via API - scan_id: \(scanId)") + let fetchedScan = try await webService.getScan(scanId: scanId) + + await MainActor.run { + self.scan = fetchedScan + // Update favorite state from fetched scan + self.isFavorite = fetchedScan.is_favorited ?? false + print("[ProductDetailView] βœ… Scan fetched - scan_id: \(scanId), state: \(fetchedScan.state), is_favorited: \(fetchedScan.is_favorited ?? false)") + } + + // Start polling if scan is still processing or analyzing + if fetchedScan.scan_type == "photo" || fetchedScan.scan_type == "barcode_plus_photo" { + if fetchedScan.state != "done" { + print("[ProductDetailView] ⏳ Starting polling - scan_id: \(scanId), state: \(fetchedScan.state)") + startPolling(scanId: scanId) + } else { + // Scan complete, increment count + print("[ProductDetailView] βœ… Scan is done - scan_id: \(scanId)") + await MainActor.run { + userPreferences.incrementScanCount() + } + } + } + } catch { + print("[ProductDetailView] ❌ Failed to fetch scan - scan_id: \(scanId), error: \(error)") + } + } + + private func startPolling(scanId: String) { + // Cancel existing polling task + pollingTask?.cancel() + + pollingTask = Task { + var pollCount = 0 + let maxPolls = 30 + + while pollCount < maxPolls { + pollCount += 1 + + do { + try await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds + + print("[ProductDetailView] πŸ”„ Poll #\(pollCount) - scan_id: \(scanId)") + let fetchedScan = try await webService.getScan(scanId: scanId) + + await MainActor.run { + self.scan = fetchedScan + } + + // Stop polling if scan is done + if fetchedScan.state == "done" { + print("[ProductDetailView] βœ… Scan done - scan_id: \(scanId), stopping polls") + await MainActor.run { + userPreferences.incrementScanCount() + } + break + } + } catch is CancellationError { + print("[ProductDetailView] ⏹️ Polling cancelled - scan_id: \(scanId)") + break + } catch { + print("[ProductDetailView] ❌ Poll error - scan_id: \(scanId), error: \(error)") + } + } + + if pollCount >= maxPolls { + print("[ProductDetailView] ⏱️ Max polls reached - scan_id: \(scanId)") + } + } + } + + // MARK: - Feedback Handling + + private func handleProductFeedback(voteType: String) { + guard let currentScan = scan else { return } + guard !isProductFeedbackLoading else { return } // Prevent double-tap + + // Validate required fields for product_info feedback + guard !currentScan.id.isEmpty else { + Log.error("ProductDetailView", "Cannot submit product feedback: scan_id is missing") + return + } + + // Start loading + isProductFeedbackLoading = true + + // 1. Calculate optimistic new vote + let oldVote = currentScan.product_info_vote + + var optimisticVote: DTO.Vote? + + if let currentVote = currentScan.product_info_vote, currentVote.value == voteType { + // Toggle off + optimisticVote = nil + } else { + // Set new vote + optimisticVote = DTO.Vote(id: oldVote?.id ?? "optimistic-\(UUID().uuidString)", value: voteType) + } + + // 2. Apply optimistic state + var optimisticScan = currentScan + optimisticScan.product_info_vote = optimisticVote + self.scan = optimisticScan + // Sync to central store + scanHistoryStore.upsertScan(optimisticScan) + + Task { + defer { + Task { @MainActor in + isProductFeedbackLoading = false + } + } + + do { + let updatedScan: DTO.Scan + + // 3. Perform network request + if let currentVote = currentScan.product_info_vote, currentVote.value == voteType { + updatedScan = try await webService.updateFeedback(feedbackId: currentVote.id, vote: "none") + } + else if let currentVote = currentScan.product_info_vote { + updatedScan = try await webService.updateFeedback(feedbackId: currentVote.id, vote: voteType) + } + else { + let request = DTO.FeedbackRequest( + target: "product_info", + vote: voteType, + scan_id: currentScan.id, + analysis_id: nil, + image_url: nil, + ingredient_name: nil, + comment: nil + ) + updatedScan = try await webService.submitFeedback(request: request) + } + + // 4. Confirm state with server response (replaces optimistic ID with real one) + await MainActor.run { + self.scan = updatedScan + // Sync confirmed state to central store + scanHistoryStore.upsertScan(updatedScan) + + // Show feedback prompt bubble only for NEW down votes (not when resetting) + let wasToggleOff = currentScan.product_info_vote?.value == voteType + if voteType == "down" && !wasToggleOff, let feedbackId = updatedScan.product_info_vote?.id { + coordinator?.showFeedbackPrompt(feedbackId: feedbackId) + } + } + } catch { + Log.error("ProductDetailView", "Error submitting product feedback: \(error.localizedDescription)") + // 5. Revert on error + await MainActor.run { + self.scan = currentScan + // Revert in central store + scanHistoryStore.upsertScan(currentScan) + } + } + } + } + + private func handleIngredientFeedback(item: IngredientAlertItem, voteType: String) { + guard let currentScan = scan, let rawName = item.rawIngredientName else { return } + guard loadingIngredientName == nil else { return } // Prevent double-tap + + // Validate required fields for flagged_ingredient feedback + guard !rawName.isEmpty else { + Log.error("ProductDetailView", "Cannot submit ingredient feedback: ingredient_name is empty") + return + } + + // Check if we have an existing vote - if so, we can update without analysis_id + let hasExistingVote = item.vote != nil + + // For new feedback, analysis_id is required per API spec + // Get analysis_id from analysis_result.id (primary source) or fallback to top-level analysis_id + let analysisId = currentScan.analysis_result?.id ?? currentScan.analysis_id + + guard hasExistingVote || analysisId != nil || currentScan.analysis_result != nil else { + Log.error("ProductDetailView", "Cannot submit ingredient feedback: analysis_id is missing and no existing vote to update") + return + } + + // Start loading for this ingredient + loadingIngredientName = rawName + + // 1. Calculate optimistic new vote + let oldVote = item.vote + + var optimisticVote: DTO.Vote? + if let currentVote = item.vote, currentVote.value == voteType { + optimisticVote = nil // Toggle off + } else { + optimisticVote = DTO.Vote(id: oldVote?.id ?? "optimistic-\(UUID().uuidString)", value: voteType) + } + + // 2. Apply optimistic state locally by modifying the scan's analysis result + // We need to find the specific ingredient in the scan and update its vote + var optimisticScan = currentScan + if var analysisResult = optimisticScan.analysis_result { + var ingredientAnalysis = analysisResult.ingredient_analysis + + // Find index of ingredient matching rawName + if let index = ingredientAnalysis.firstIndex(where: { $0.ingredient == rawName }) { + var updatedIngredient = ingredientAnalysis[index] + updatedIngredient.vote = optimisticVote + ingredientAnalysis[index] = updatedIngredient + + // Assign back nested structs + analysisResult.ingredient_analysis = ingredientAnalysis + optimisticScan.analysis_result = analysisResult + + // Update state + self.scan = optimisticScan + // Sync to central store + scanHistoryStore.upsertScan(optimisticScan) + } + } + + Task { + defer { + Task { @MainActor in + loadingIngredientName = nil + } + } + do { + let updatedScan: DTO.Scan + + // 3. Perform network request + if let currentVote = item.vote, currentVote.value == voteType { + updatedScan = try await webService.updateFeedback(feedbackId: currentVote.id, vote: "none") + } else if let currentVote = item.vote { + updatedScan = try await webService.updateFeedback(feedbackId: currentVote.id, vote: voteType) + } else { + // For new feedback, analysis_id is required per API spec + // Get analysis_id from analysis_result.id (primary source) or fallback to top-level analysis_id + let finalAnalysisId = currentScan.analysis_result?.id ?? analysisId + guard let finalAnalysisId = finalAnalysisId, !finalAnalysisId.isEmpty else { + Log.error("ProductDetailView", "Cannot create new ingredient feedback: analysis_id is required but missing") + // Revert optimistic update + await MainActor.run { + self.scan = currentScan + scanHistoryStore.upsertScan(currentScan) + } + return + } + + let request = DTO.FeedbackRequest( + target: "flagged_ingredient", + vote: voteType, + scan_id: currentScan.id, + analysis_id: finalAnalysisId, + image_url: nil, + ingredient_name: rawName, + comment: nil + ) + updatedScan = try await webService.submitFeedback(request: request) + } + + // 4. Confirm state + await MainActor.run { + self.scan = updatedScan + // Sync confirmed state to central store + scanHistoryStore.upsertScan(updatedScan) + + // Show feedback prompt bubble only for NEW down votes (not when resetting) + let wasToggleOff = item.vote?.value == voteType + if voteType == "down" && !wasToggleOff { + if let feedbackId = updatedScan.analysis_result?.ingredient_analysis + .first(where: { $0.ingredient == rawName })?.vote?.id { + coordinator?.showFeedbackPrompt(feedbackId: feedbackId) + } + } + } + } catch { + Log.error("ProductDetailView", "Error submitting ingredient feedback: \(error.localizedDescription)") + // 5. Revert + await MainActor.run { + self.scan = currentScan + // Revert in central store + scanHistoryStore.upsertScan(currentScan) + } + } + } + } + + private func handleImageFeedback(imageUrl: String, voteType: String) { + guard let currentScan = scan else { return } + guard loadingImageUrl == nil else { return } // Prevent double-tap + + // Validate required fields for product_image feedback + guard !currentScan.id.isEmpty else { + Log.error("ProductDetailView", "Cannot submit image feedback: scan_id is missing") + return + } + + guard !imageUrl.isEmpty else { + Log.error("ProductDetailView", "Cannot submit image feedback: image_url is empty") + return + } + + // Start loading for this image + loadingImageUrl = imageUrl + + // 1. Find the image and current vote + guard let imageIndex = currentScan.images.firstIndex(where: { img in + switch img { + case .inventory(let i): return i.url == imageUrl + default: return false + } + }) else { + Log.error("ProductDetailView", "Cannot submit image feedback: image not found in scan") + return + } + + var targetImage = currentScan.images[imageIndex] + var oldVote: DTO.Vote? + if case .inventory(let invImg) = targetImage { + oldVote = invImg.vote + } + + // 2. Calculate optimistic vote + var optimisticVote: DTO.Vote? + if let currentVote = oldVote, currentVote.value == voteType { + optimisticVote = nil // Toggle off + } else { + optimisticVote = DTO.Vote(id: oldVote?.id ?? "optimistic-\(UUID().uuidString)", value: voteType) + } + + // 3. Apply optimistic state + var optimisticScan = currentScan + // Update the specific image + if case .inventory(var invImg) = targetImage { + invImg.vote = optimisticVote + targetImage = .inventory(invImg) + optimisticScan.images[imageIndex] = targetImage + + self.scan = optimisticScan + scanHistoryStore.upsertScan(optimisticScan) + } + + Task { + defer { + Task { @MainActor in + loadingImageUrl = nil + } + } + + do { + let updatedScan: DTO.Scan + + // 4. Network request + if let currentVote = oldVote, currentVote.value == voteType { + updatedScan = try await webService.updateFeedback(feedbackId: currentVote.id, vote: "none") + } else if let currentVote = oldVote { + updatedScan = try await webService.updateFeedback(feedbackId: currentVote.id, vote: voteType) + } else { + let request = DTO.FeedbackRequest( + target: "product_image", + vote: voteType, + scan_id: currentScan.id, + analysis_id: nil, + image_url: imageUrl, + ingredient_name: nil, + comment: nil + ) + updatedScan = try await webService.submitFeedback(request: request) + } + + // 5. Confirm state + await MainActor.run { + self.scan = updatedScan + scanHistoryStore.upsertScan(updatedScan) + + // Show feedback prompt bubble only for NEW down votes (not when resetting) + let wasToggleOff = oldVote?.value == voteType + if voteType == "down" && !wasToggleOff { + if let feedbackId = updatedScan.images.lazy.compactMap({ img -> String? in + switch img { + case .inventory(let i) where i.url == imageUrl: return i.vote?.id + default: return nil + } + }).first { + coordinator?.showFeedbackPrompt(feedbackId: feedbackId) + } + } + } + } catch { + Log.error("ProductDetailView", "Error submitting image feedback: \(error.localizedDescription)") + await MainActor.run { + self.scan = currentScan + scanHistoryStore.upsertScan(currentScan) + } + } + } + } + + // MARK: - Missing Ingredients View + + /// Shows when product has images/name but no ingredients + private var missingIngredientsView: some View { + VStack(spacing: 12) { + Spacer() + .frame(height: 20) + + // Bot Logo (black and white) + Image("ingrediBot") + .resizable() + .scaledToFit() + .frame(width: 117, height: 106) + .saturation(0) // Grayscale effect + + // Title + Text("Missing Ingredients") + .font(NunitoFont.bold.size(20)) + .foregroundStyle(Color(hex: "#303030")) + .multilineTextAlignment(.center) + + // Description + Text("Add photos to help us analyze it.") + .font(ManropeFont.medium.size(14)) + .foregroundStyle(Color(hex: "#949494")) + .multilineTextAlignment(.center) + .lineSpacing(4) + + // Upload photos button + Button { + handleCameraButtonTap() + } label: { + GreenCapsule(title: "Upload photos", takeFullWidth: false) + } + .buttonStyle(.plain) + .padding(.top, 40) + + Spacer() + .frame(height: 40) + } + .frame(maxWidth: .infinity) + .padding(.horizontal, 20) + } + + // MARK: - Empty Product Details View + + /// Shows when product exists but has no name, no brand, and no ingredients (not in our database) + private var emptyProductDetailsView: some View { + VStack(spacing: 12) { + Spacer() + .frame(height: 20) + + // Bot Logo (black and white) + Image("ingrediBot") + .resizable() + .scaledToFit() + .frame(width: 117, height: 106) + .saturation(0) // Grayscale effect + + // Title + Text("Product Not Found") + .font(NunitoFont.bold.size(20)) + .foregroundStyle(Color(hex: "#303030")) + .multilineTextAlignment(.center) + + // Description + Text("We couldn't find this product. Capture photos from different angles so we can analyze it for you.") + .font(ManropeFont.medium.size(14)) + .foregroundStyle(Color(hex: "#949494")) + .multilineTextAlignment(.center) + .lineSpacing(4) + + // Capture photos button + Button { + handleCameraButtonTap() + } label: { + GreenCapsule(title: "Capture photos", takeFullWidth: false) + } + .buttonStyle(.plain) + .padding(.top, 40) + + Spacer() + .frame(height: 40) + } + .frame(maxWidth: .infinity) + .padding(.horizontal, 20) + } + + // MARK: - Gallery Helper Components + + /// Resolves ProductImage enum to actual SwiftUI Image view + @ViewBuilder + private func imageContent(for productImage: ProductImage) -> some View { + switch productImage { + case .local(let image): + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + case .api(let location, _): + HeaderImage(imageLocation: location) + } + } + + /// Thumbnail view for a product image with selection styling + @ViewBuilder + private func thumbnailView(at index: Int) -> some View { + let isSelected = selectedImageIndex == index + + Button { + if !isPlaceholderMode { + selectedImageIndex = index + } + } label: { + imageContent(for: allImages[index]) + .frame(width: 50, height: 50) + .clipShape(RoundedRectangle(cornerRadius: 11)) + .opacity(isSelected ? 0.5 : 1.0) + .overlay( + RoundedRectangle(cornerRadius: 11) + .strokeBorder( + isSelected ? Color.primary600 : Color(hex: "#E3E3E3"), + lineWidth: 0.75 + ) + ) + } + .disabled(isPlaceholderMode) + } + + /// Placeholder thumbnail for empty image slots + private var placeholderThumbnail: some View { + ZStack { + RoundedRectangle(cornerRadius: 11) + .fill(Color(hex: "#F7F7F7")) + Image("addimageiconsmall") + .resizable() + .scaledToFit() + .frame(width: 30, height: 30) + } + .frame(width: 50, height: 50) + } + + /// Green add camera button + private var addCameraButton: some View { + Button { + handleCameraButtonTap() + } label: { + Image("addimageiconingreen") + .resizable() + .scaledToFit() + .frame(width: 30, height: 30) + .frame(width: 50, height: 50) + .background(Color(hex: "#F6FCED")) + .cornerRadius(8) + } + .disabled(isPlaceholderMode) + } + + private func handleCameraButtonTap() { + switch presentationSource { + case .homeView: + // From HomeView, navigate to camera (no existing camera in stack) + if let appState = appState { + appState.navigate(to: .scanCamera(initialMode: .photo, initialScanId: scanId)) + } + case .pushNavigation: + // From push navigation - check if camera is actually in the stack + if let appState = appState { + if appState.hasCameraInStack { + // Camera exists in stack, pop back to it + appState.scrollToScanId = scanId + appState.navigateBack() + } else { + // No camera in stack (came from HomeView/Recent Scans), push new camera + appState.navigate(to: .scanCamera(initialMode: .photo, initialScanId: scanId)) + } + } + case .cameraView: + // Dismiss ProductDetails and return to camera with this scanId + if let scanId = scanId { + onRequestCameraWithScan?(scanId) + } + dismiss() + } + } + + /// Main gallery image display (large preview) + private var mainGalleryImage: some View { + Button { + if !isPlaceholderMode { + isImageViewerPresented = true + } + } label: { + imageContent(for: allImages[selectedImageIndex]) + .frame( + width: UIScreen.main.bounds.width * 0.704, + height: UIScreen.main.bounds.height * 0.234 + ) + .cornerRadius(16) + .clipped() + .background(in: RoundedRectangle(cornerRadius: 24)) + .shadow(color: Color(hex: "#CECECE").opacity(0.25), radius: 12) + } + .buttonStyle(.plain) + } + + /// Empty state placeholder for main gallery + private var emptyStateGalleryImage: some View { + ZStack { + RoundedRectangle(cornerRadius: 24) + .fill(Color.white) + .shadow(color: Color(hex: "#CECECE").opacity(0.25), radius: 12) + Image("addimageiconlarge") + .resizable() + .scaledToFit() + .frame(width: 85, height: 79) + } + .frame( + width: UIScreen.main.bounds.width * 0.704, + height: UIScreen.main.bounds.height * 0.234 + ) + } + + /// Side panel with thumbnails + private var thumbnailSidePanel: some View { + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: 8) { + addCameraButton + + // Determine which indices to show + let indices = allImages.count <= 2 ? Array(0..<3) : Array(allImages.indices) + + ForEach(indices, id: \.self) { index in + if index < allImages.count { + thumbnailView(at: index) + } else { + placeholderThumbnail + } + } + } + } + .frame(width: 60, height: 196) + } + + /// Empty state side panel (all placeholders) + private var emptyStateSidePanel: some View { + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: 8) { + addCameraButton + + ForEach(0..<3, id: \.self) { _ in + placeholderThumbnail + } + } + } + .frame(width: 60, height: 196) + } + + // MARK: - Product Gallery + + private var productGallery: some View { + HStack(spacing: 12) { + if !allImages.isEmpty { + mainGalleryImage + thumbnailSidePanel + } else { + emptyStateGalleryImage + emptyStateSidePanel + } + } + .padding(.horizontal, 20) + .padding(.bottom, 20) + } + + private var productInformation: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .top, spacing: 24) { + VStack(alignment: .leading, spacing: 4) { + Text(resolvedName) + .font(NunitoFont.bold.size(20)) + .foregroundStyle(.grayScale150) + .lineLimit(2) + + // Only show resolvedDetails if it's not empty (API doesn't provide this field) + if !resolvedDetails.isEmpty { + Text(resolvedDetails) + .font(ManropeFont.medium.size(14)) + .foregroundStyle(.grayScale100) + } + } + + Spacer() + + HStack(spacing: 4) { + StatusDotView(status: resolvedStatus) + + Text(resolvedStatus.title) + .font(NunitoFont.bold.size(14)) + .foregroundStyle(resolvedStatus.color) + } + .padding(.vertical, 6) + .padding(.horizontal, 12) + .background(resolvedStatus.badgeBackground, in: Capsule()) + } + } + .padding(.horizontal, 20) + .padding(.bottom, 20) + } + + @ViewBuilder + private var dietaryTagsRow: some View { + if !dietaryTags.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(dietaryTags, id: \.claim) { tag in + DietaryTagView(tag: tag) + } + } + .padding(.horizontal, 20) + } + .padding(.bottom, 20) + } + } +} + +// MARK: - ScanCameraView Wrapper + +/// Wrapper to initialize ScanCameraView with specific mode and scanId +struct ScanCameraViewWrapper: View { + let initialScanId: String? + let initialMode: CameraMode + + var body: some View { + ScanCameraViewWithInitialState( + initialScanId: initialScanId, + initialMode: initialMode, + presentationSource: .productDetailView + ) + } +} + +#if DEBUG +// Sample product with ingredients for preview +private let sampleProductWithIngredients = DTO.Product( + barcode: "1234567890", + brand: "Sample Brand", + name: "Sample Product", + ingredients: [ + DTO.Ingredient(name: "Water", vegan: true, vegetarian: true, ingredients: []), + DTO.Ingredient(name: "Sugar", vegan: true, vegetarian: true, ingredients: []), + DTO.Ingredient(name: "Salt", vegan: true, vegetarian: true, ingredients: []) + ], + images: [], + claims: ["Vegan", "No gluten"] +) + +// Sample product WITHOUT ingredients for preview (triggers Missing Ingredients UI) +private let sampleProductMissingIngredients = DTO.Product( + barcode: "1234567890", + brand: "Sample Brand", + name: "Sample Product Without Ingredients", + ingredients: [], + images: [], + claims: [] +) + +#Preview("Normal Mode") { + ProductDetailView( + product: sampleProductWithIngredients, + isPlaceholderMode: false + ) + .environment(WebService()) + .environment(UserPreferences()) + .environment(AppNavigationCoordinator()) +} + +#Preview("Missing Ingredients") { + ProductDetailView( + product: sampleProductMissingIngredients, + isPlaceholderMode: false + ) + .environment(WebService()) + .environment(UserPreferences()) + .environment(AppNavigationCoordinator()) +} + +#Preview("Placeholder Mode") { + ProductDetailView(isPlaceholderMode: true) + .environment(WebService()) + .environment(UserPreferences()) + .environment(AppNavigationCoordinator()) +} +#endif + +// MARK: - Product Match Status + +enum ProductMatchStatus { + case matched + case uncertain + case unmatched + case unknown + case analyzing // Analysis in progress + + var title: String { + switch self { + case .matched: return "Matched" + case .uncertain: return "Uncertain" + case .unmatched: return "Unmatched" + case .unknown: return "Unknown" + case .analyzing: return "Analyzing" + } + } + + var color: Color { + switch self { + case .matched: return Color(hex: "#5A9C19") + case .uncertain: return Color(hex: "#E9A600") + case .unmatched: return Color(hex: "#FF4E50") + case .unknown: return Color.grayScale100 + case .analyzing: return Color(hex: "#007AFF") // Blue for analyzing + } + } + + var badgeBackground: Color { + switch self { + case .matched: return Color(hex: "#EAF6D9") + case .uncertain: return Color(hex: "#FFF5DA") + case .unmatched: return Color(hex: "#FFE3E2") + case .unknown: return Color.grayScale40 + case .analyzing: return Color(hex: "#E3F2FF") // Light blue for analyzing + } + } + + var alertTitle: String { + switch self { + case .matched: return "Great Match" + case .uncertain: return "Partially Compatible" + case .unmatched: return "Ingredients Alerts" + case .unknown: return "Status Unknown" + case .analyzing: return "Analyzing Product" + } + } + + var alertCardBackground: Color { + switch self { + case .matched: return Color(hex: "#F3FAE7") + case .uncertain: return Color(hex: "#FFF8E8") + case .unmatched: return Color(hex: "#FFEAEA") + case .unknown: return Color.grayScale40 + case .analyzing: return Color(hex: "#F0F8FF") // Light blue for analyzing + } + } + + var sectionBackground: Color { + badgeBackground + } +} + + diff --git a/IngrediCheck/Views/ProductDetails/StatusDotView.swift b/IngrediCheck/Views/ProductDetails/StatusDotView.swift new file mode 100644 index 00000000..cc95cab1 --- /dev/null +++ b/IngrediCheck/Views/ProductDetails/StatusDotView.swift @@ -0,0 +1,49 @@ + +import SwiftUI + +struct StatusDotView: View { + let status: ProductMatchStatus + @State private var isPulsing = false + + var body: some View { + ZStack { + // Pulse Ring (Only for analyzing state) + if status == .analyzing { + Circle() + .fill(status.color) + .frame(width: 10, height: 10) + .scaleEffect(isPulsing ? 2.5 : 1.0) + .opacity(isPulsing ? 0.0 : 0.3) // Starts with mild opacity and fades out + .onAppear { + // Reset state first to ensure animation restarts if view recycles + isPulsing = false + // Use a slight delay to ensure the view is ready, then animate + withAnimation(.easeOut(duration: 1.5).repeatForever(autoreverses: false)) { + isPulsing = true + } + } + } + + // Core Dot + Circle() + .fill(status.color) + .frame(width: 10, height: 10) + } + } +} + +#Preview { + VStack { + HStack { + StatusDotView(status: .analyzing) + + Text("Analyzing") + } + .padding() + .background(.blue) + .clipShape(Capsule()) + + Spacer() + + } +} diff --git a/IngrediCheck/Views/ProductDetails/View+RoundedCorner.swift b/IngrediCheck/Views/ProductDetails/View+RoundedCorner.swift new file mode 100644 index 00000000..091e084a --- /dev/null +++ b/IngrediCheck/Views/ProductDetails/View+RoundedCorner.swift @@ -0,0 +1,22 @@ +import SwiftUI + +extension View { + func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View { + clipShape(RoundedCorner(radius: radius, corners: corners)) + } +} + +struct RoundedCorner: Shape { + var radius: CGFloat = .infinity + var corners: UIRectCorner = .allCorners + + func path(in rect: CGRect) -> Path { + let path = UIBezierPath( + roundedRect: rect, + byRoundingCorners: corners, + cornerRadii: CGSize(width: radius, height: radius) + ) + return Path(path.cgPath) + } +} + diff --git a/IngrediCheck/Views/RecentScansFullView.swift b/IngrediCheck/Views/RecentScansFullView.swift new file mode 100644 index 00000000..ea03c3cb --- /dev/null +++ b/IngrediCheck/Views/RecentScansFullView.swift @@ -0,0 +1,58 @@ +// +// RecentScansFullView.swift +// IngrediCheckPreview +// +// Created by Gunjan Haldar on 15/11/25. +// + +import SwiftUI + +struct RecentScansFullView: View { + @State private var isProductDetailPresented = false + + // Sample data - feedback: nil = uncertain, true = matched, false = unmatched + private let scanItems: [Bool?] = [ + true, // matched + false, // unmatched + nil, // uncertain + false, // unmatched + true, // matched + false, // unmatched + nil // uncertain + ] + + var body: some View { + ScrollView(showsIndicators: false) { + VStack(spacing: 0) { + ForEach(Array(scanItems.enumerated()), id: \.offset) { index, feedback in + Button { + isProductDetailPresented = true + } label: { + RecentScansRow(feedback: feedback) + } + .buttonStyle(.plain) + + if index != scanItems.count - 1 { + Divider() + .padding(.vertical, 14) + } + } + } + .padding(.horizontal, 20) + .padding(.top, 16) + .padding(.bottom, 40) + } + .background(Color(hex: "FFFFFF")) + .navigationBarTitleDisplayMode(.inline) + .navigationTitle("Recent Scans") + .sheet(isPresented: $isProductDetailPresented) { + ProductDetailView() + .environment(AppNavigationCoordinator(initialRoute: .home)) + } + } +} + +#Preview { + RecentScansFullView() +} + diff --git a/IngrediCheck/Views/RootContainerView.swift b/IngrediCheck/Views/RootContainerView.swift new file mode 100644 index 00000000..981045c3 --- /dev/null +++ b/IngrediCheck/Views/RootContainerView.swift @@ -0,0 +1,329 @@ +// +// RootContainerView.swift +// IngrediCheckPreview +// +// Created on 13/11/25. +// + +import SwiftUI +import Observation + +struct RootContainerView: View { + @State private var coordinator: AppNavigationCoordinator + @StateObject private var onboarding: Onboarding + @State private var webService: WebService + @State private var memojiStore = MemojiStore() + @State private var chatStore = ChatStore() + @State private var foodNotesStore: FoodNotesStore + + init(restoredState: (canvas: CanvasRoute, sheet: BottomSheetRoute)? = nil) { + // Create shared instances eagerly so FoodNotesStore is available + // before any child view .task fires (fixes race condition where + // HomeView.task could run while foodNotesStore was still nil). + let ws = WebService() + _webService = State(initialValue: ws) + + // Determine onboarding flow type from restored state + let flowType: OnboardingFlowType + if let state = restoredState, case .mainCanvas(let flow) = state.canvas { + flowType = flow + } else { + flowType = .individual + } + let onb = Onboarding(onboardingFlowtype: flowType) + _onboarding = StateObject(wrappedValue: onb) + _foodNotesStore = State(initialValue: FoodNotesStore(webService: ws, onboardingStore: onb)) + + if let state = restoredState { + let coordinator = AppNavigationCoordinator(initialRoute: state.canvas) + // Force the sheet immediately without animation for launch + coordinator.navigateInBottomSheet(state.sheet) + _coordinator = State(initialValue: coordinator) + } else { + _coordinator = State(initialValue: AppNavigationCoordinator(initialRoute: .heyThere)) + } + } + + // --- HEAD BRANCH (keep these) + @State private var appState = AppState() + @State private var userPreferences = UserPreferences() + @State private var hasPendingFeedbackShortcut = false + + // --- DEVELOP BRANCH (keep these) + @Environment(FamilyStore.self) private var familyStore + @Environment(AuthController.self) private var authController + @Environment(\.dismiss) private var dismiss + + + var body: some View { + @Bindable var coordinator = coordinator + @Bindable var appState = appState + @Bindable var toastManager = ToastManager.shared + + ZStack(alignment: .bottom) { + // Show custom background when meetYourProfileIntro or meetYourProfile bottom sheet is active + // BUT only if we're NOT on the family overview screen (letsMeetYourIngrediFam) or home screen + // (where SettingsSheet might be shown) + let isOnFamilyOverview = coordinator.currentCanvasRoute == .letsMeetYourIngrediFam + let isOnHomeScreen = coordinator.currentCanvasRoute == .home + let isFromMeetYourProfile: Bool = { + if case .meetYourProfile = memojiStore.previousRouteForGenerateAvatar { + return true + } + return false + }() + let isMeetYourProfileRoute: Bool = { + if case .meetYourProfile = coordinator.currentBottomSheetRoute { + return true + } + return false + }() + let shouldShowCustomBackground = (coordinator.currentBottomSheetRoute == .meetYourProfileIntro || + isMeetYourProfileRoute || + ((coordinator.currentBottomSheetRoute == .generateAvatar || + coordinator.currentBottomSheetRoute == .yourCurrentAvatar || + coordinator.currentBottomSheetRoute == .bringingYourAvatar || + coordinator.currentBottomSheetRoute == .meetYourAvatar) && + (isFromMeetYourProfile || memojiStore.previousRouteForGenerateAvatar == .meetYourProfileIntro))) && !isOnFamilyOverview && !isOnHomeScreen + + if shouldShowCustomBackground { + VStack { + Text("Meet your profile") + .font(ManropeFont.bold.size(16)) + .padding(.top, 32) + .padding(.bottom, 4) + Text("This helps us tailor food checks and tips just for you.") + .font(ManropeFont.regular.size(13)) + .foregroundColor(Color(hex: "#BDBDBD")) + .lineLimit(2) + .frame(width: 247) + .multilineTextAlignment(.center) + + Image("addfamilyimg") + .resizable() + .scaledToFit() + .frame(height: 369) + .frame(maxWidth: .infinity) + .offset(y: -50) + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + canvasContent(for: coordinator.currentCanvasRoute) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // Dim background when certain sheets are presented (e.g., Invite) + Group { + switch coordinator.currentBottomSheetRoute { + case .wouldYouLikeToInvite(_, _): + Color.black.opacity(0.45) + .ignoresSafeArea() + .allowsHitTesting(false) + .transition(.opacity) + default: + EmptyView() + } + } + + PersistentBottomSheet() + + // Toast Overlay + if let toastData = toastManager.toast, toastManager.isPresented { + VStack { + ToastView(data: toastData) { + toastManager.dismiss() + } + Spacer() + } + .padding(.top, 60) // Adjust based on safe area or design + .transition(.move(edge: .top).combined(with: .opacity)) + .zIndex(200) // Ensure on top of everything including edit sheet + } + + // Secondary edit sheet overlay on top of everything (z-index 100) + editSheetOverlay + } + .environment(coordinator) + .environmentObject(onboarding) + .environment(webService) + .environment(appState) + .environment(userPreferences) + .environment(authController) + .environment(memojiStore) + .environment(chatStore) + .environment(foodNotesStore) + // Allow presenting SettingsSheet from anywhere in this container + .sheet(item: $appState.activeSheet) { sheet in + switch sheet { + case .settings: + SettingsSheet() + .environment(userPreferences) + .environment(memojiStore) + .environment(coordinator) + case .scan: + // Not used here; keep empty or route to a scan view if needed later + EmptyView() + } + } + // Global AI Bot sheet (post-login) - accessible from HomeView and other post-login screens + .sheet(isPresented: $coordinator.isAIBotSheetPresented, onDismiss: { + // Clear feedback context when sheet is dismissed (swipe down or skip) + // This resets to product_scan context for next FAB tap + coordinator.dismissAIBotSheet() + }) { + IngrediBotChatView( + scanId: coordinator.aibotContextScanId, + analysisId: coordinator.aibotContextAnalysisId, + ingredientName: coordinator.aibotContextIngredientName, + feedbackId: coordinator.aibotContextFeedbackId, + contextKeyOverride: coordinator.aibotContextKeyOverride + ) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + .environment(coordinator) + .environment(appState) + } + .onReceive(NotificationCenter.default.publisher(for: Notification.Name("AppDidReset"))) { _ in + coordinator = AppNavigationCoordinator(initialRoute: .heyThere) + onboarding.reset(flowType: .individual) + familyStore.resetLocalState() + dismiss() + } + .onReceive(NotificationCenter.default.publisher(for: Notification.Name("ShowFeedbackFromShortcut"))) { _ in + handleFeedbackShortcut() + } + .onAppear { + // Set up callback to sync onboarding state to Supabase whenever navigation changes + coordinator.onNavigationChange = { + print("[OnboardingMeta] onNavigationChange fired with canvasRoute=\(coordinator.currentCanvasRoute), bottomSheetRoute=\(coordinator.currentBottomSheetRoute)") + await OnboardingPersistence.shared.sync(from: coordinator) + } + } + .task { + // Load family state when the container becomes active. + await familyStore.loadCurrentFamily() + // Always attempt to restore onboarding position on launch from Supabase metadata. + // Guest login should happen at whosThisFor, so session should exist by then. + print("[OnboardingMeta] RootContainerView.task: attempting restoreOnboardingPosition on launch") + authController.restoreOnboardingPosition(into: coordinator) + + // Check for pending quick action from cold launch + if let shortcutItem = AppDelegate.pendingShortcutItem, + shortcutItem.type == "SendFeedback" { + AppDelegate.pendingShortcutItem = nil + handleFeedbackShortcut() + } + + // Sync Onboarding view model to match the restored coordinator state + if let stepId = coordinator.currentOnboardingStepId { + onboarding.restoreState(forStepId: stepId) + } else if case .fineTuneYourExperience = coordinator.currentBottomSheetRoute { + onboarding.restoreState(forStepId: "lifeStyle") + } else if case .workingOnSummary = coordinator.currentBottomSheetRoute { + onboarding.restoreToLastStep() + } + + // Ensure section completion status is accurate based on loaded preferences + onboarding.updateSectionCompletionStatus() + } + // Whenever authentication completes (including first-time login or + // upgrading a guest account), refresh the family from the backend so + // the home screen immediately reflects the latest household state + // without requiring an app restart. + // Only navigate to home if we're not already on home canvas to avoid + // disrupting navigation when Settings or other views are presented + .onChange(of: authController.signInState) { _, newValue in + if newValue == .signedIn { + Task { + await familyStore.loadCurrentFamily() + if !authController.signedInAsGuest && + OnboardingPersistence.shared.isLocallyCompleted && + coordinator.currentCanvasRoute != .home { + await MainActor.run { + coordinator.showCanvas(.home) + } + } + // Handle pending feedback shortcut after sign-in and family load + if hasPendingFeedbackShortcut { + await MainActor.run { + hasPendingFeedbackShortcut = false + coordinator.showAIBotSheetWithContext(contextKeyOverride: "general_feedback") + } + } + } + } + } + } + + private func handleFeedbackShortcut() { + guard authController.signInState == .signedIn else { + hasPendingFeedbackShortcut = true + return + } + coordinator.showAIBotSheetWithContext(contextKeyOverride: "general_feedback") + } + + @ViewBuilder + private func canvasContent(for route: CanvasRoute) -> some View { + switch route { + case .heyThere: + HeyThereScreen() + case .blankScreen: + BlankScreen() + case .letsGetStarted: + LetsGetStartedView() + case .letsMeetYourIngrediFam: + LetsMeetYourIngrediFamView() + case .dietaryPreferencesAndRestrictions(let isFamilyFlow): + DietaryPreferencesAndRestrictions(isFamilyFlow: isFamilyFlow) + case .welcomeToYourFamily: + NavigationStack { + UnifiedCanvasView( + mode: .editing, + titleOverride: "Welcome to \(familyStore.family?.name ?? "your")'s family", + showBackButton: false + ) + } + case .mainCanvas(let flow): + UnifiedCanvasView(mode: .onboarding(flow: flow)) + case .home: + HomeView() + case .summaryJustMe: + NavigationStack { + UnifiedCanvasView(mode: .editing, titleOverride: "Your Food Notes", showBackButton: false) + } + case .summaryAddFamily: + NavigationStack { + UnifiedCanvasView(mode: .editing, titleOverride: "Your IngrediFam Food Notes", showBackButton: false) + } + case .readyToScanFirstProduct: + ReadyToScanCanvas() + case .seeHowScanningWorks: + ScanningHelpCanvas() + case .whyWeNeedThesePermissions: + PermissionsCanvas() + } + } + + @ViewBuilder + private var editSheetOverlay: some View { + @Bindable var coordinator = coordinator + if coordinator.isEditSheetPresented, let stepId = coordinator.editingStepId { + EditSectionBottomSheet( + isPresented: $coordinator.isEditSheetPresented, + stepId: stepId, + currentSectionIndex: coordinator.currentEditingSectionIndex, + initialMemberId: coordinator.editingMemberId + ) + .transition(AnyTransition.asymmetric( + insertion: AnyTransition.move(edge: Edge.bottom).combined(with: AnyTransition.opacity), + removal: AnyTransition.move(edge: Edge.bottom).combined(with: AnyTransition.opacity) + )) + .zIndex(100) + .frame(maxWidth: CGFloat.infinity, maxHeight: CGFloat.infinity, alignment: Alignment.bottom) + .ignoresSafeArea() + } + } +} diff --git a/IngrediCheck/Views/Sheets/AccessDenied.swift b/IngrediCheck/Views/Sheets/AccessDenied.swift new file mode 100644 index 00000000..c5beec77 --- /dev/null +++ b/IngrediCheck/Views/Sheets/AccessDenied.swift @@ -0,0 +1,38 @@ +// +// AccessDenied.swift +// IngrediCheck +// +// Created by Gunjan Haldar on 31/12/25. +// +import SwiftUI + +struct AccessDenied: View { + var body: some View { + VStack(spacing: 40) { + VStack(spacing: 12) { + Text("Access Denied !") + .font(NunitoFont.bold.size(22)) + .foregroundStyle(.grayScale150) + .multilineTextAlignment(.center) + + Text("IngrediCheck needs camera access to scan products and give you personalized recommendations. Please enable it in settings to continue.") + .font(ManropeFont.medium.size(12)) + .foregroundStyle(.grayScale120) + .multilineTextAlignment(.center) + } + + GreenCapsule(title: "Open Settings") + .frame(width: 156) + } + .padding(.horizontal, 20) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay( + RoundedRectangle(cornerRadius: 4) + .fill(.neutral500) + .frame(width: 60, height: 4) + .padding(.top, 11) + , alignment: .top + ) + .navigationBarBackButtonHidden(true) + } +} diff --git a/IngrediCheck/Views/Sheets/AddMoreMembersMinimal.swift b/IngrediCheck/Views/Sheets/AddMoreMembersMinimal.swift new file mode 100644 index 00000000..574fc70f --- /dev/null +++ b/IngrediCheck/Views/Sheets/AddMoreMembersMinimal.swift @@ -0,0 +1,68 @@ +// +// AddMoreMembersMinimal.swift +// IngrediCheck +// +// Created by Gunjan Haldar on 31/12/25. +// +import SwiftUI + +struct AddMoreMembersMinimal: View { + let allSetPressed: () -> Void + let addMorePressed: () -> Void + + init(allSetPressed: @escaping () -> Void = {}, addMorePressed: @escaping () -> Void = {}) { + self.allSetPressed = allSetPressed + self.addMorePressed = addMorePressed + } + var body: some View { + VStack(spacing: 40) { + VStack(spacing: 12) { + Text("Add more members?") + .font(NunitoFont.bold.size(22)) + .foregroundStyle(.grayScale150) + .multilineTextAlignment(.center) + + Text("Start by adding their name and a fun avatarβ€”it’ll help us personalize food tips just for them.") + .font(ManropeFont.medium.size(12)) + .foregroundStyle(.grayScale120) + .multilineTextAlignment(.center) + } + .padding(.horizontal, 20) + + HStack(spacing: 16) { + SecondaryButton( + title: "All Set!", + takeFullWidth: true, + action: allSetPressed + ) + + Button { + addMorePressed() + } label: { + GreenCapsule(title: "Add Member") + } + + + } + .padding(.horizontal, 20) + } + .padding(.top, 24) + .padding(.bottom, 20) + .background(.white) +// .overlay( +// RoundedRectangle(cornerRadius: 4) +// .fill(.neutral500) +// .frame(width: 60, height: 4) +// .padding(.top, 11) +// , alignment: .top +// ) + } +} + +#Preview { + AddMoreMembersMinimal { + + } addMorePressed: { + + } +} diff --git a/IngrediCheck/Views/Sheets/AddPreferencesForMemberSheet.swift b/IngrediCheck/Views/Sheets/AddPreferencesForMemberSheet.swift new file mode 100644 index 00000000..71ac4349 --- /dev/null +++ b/IngrediCheck/Views/Sheets/AddPreferencesForMemberSheet.swift @@ -0,0 +1,55 @@ +// +// AddPreferencesForMemberSheet.swift +// IngrediCheck +// +// Created for showing "Add preferences for member?" prompt after adding a family member. +// + +import SwiftUI + +struct AddPreferencesForMemberSheet: View { + var name: String + var laterPressed: () -> Void + var yesPressed: () -> Void + + var body: some View { + VStack(spacing: 40) { + VStack(spacing: 12) { + Text("Want to add food notes for \(name)?") + .font(NunitoFont.bold.size(22)) + .foregroundStyle(.grayScale150) + .multilineTextAlignment(.center) + + Text("Don't worry, \(name) can add or edit their food notes once they join IngrediFam") + .font(ManropeFont.medium.size(14)) + .foregroundStyle(.grayScale120) + .multilineTextAlignment(.center) + } + + HStack(spacing: 16) { + SecondaryButton( + title: "Later", + takeFullWidth: true, + action: laterPressed + ) + + Button { + yesPressed() + } label: { + GreenCapsule(title: "Yes") + } + } + } + .padding(.top, 24) + .padding(.horizontal, 20) + .padding(.bottom, 20) + } +} + +#Preview { + AddPreferencesForMemberSheet( + name: "John", + laterPressed: {}, + yesPressed: {} + ) +} diff --git a/IngrediCheck/Views/Sheets/AllSetToJoinYourFamily.swift b/IngrediCheck/Views/Sheets/AllSetToJoinYourFamily.swift new file mode 100644 index 00000000..00b78166 --- /dev/null +++ b/IngrediCheck/Views/Sheets/AllSetToJoinYourFamily.swift @@ -0,0 +1,50 @@ +// +// AllSetToJoinYourFamily.swift +// IngrediCheck +// +// Created by Gunjan Haldar on 31/12/25. +// +import SwiftUI + +struct AllSetToJoinYourFamily: View { + let goToHomePressed: () -> Void + + init(goToHomePressed: @escaping () -> Void = {}) { + self.goToHomePressed = goToHomePressed + } + var body: some View { + VStack(spacing: 0) { + VStack(spacing: 12) { + Text("All set to join your family!") + .font(NunitoFont.bold.size(22)) + .foregroundStyle(.grayScale150) + .multilineTextAlignment(.center) + .padding(.bottom , 12) + + Text("Your family’s food preferences are already added. You can review them anytime, or edit a specific preference section by tapping Edit.") + .font(ManropeFont.medium.size(12)) + .foregroundStyle(.grayScale120) + .multilineTextAlignment(.center) + } + .padding(.bottom, 40) + + Button { + goToHomePressed() + } label: { + GreenCapsule(title: "Go to Home") + .frame(width: 156) + } + + } + .padding(.horizontal, 20) + .padding(.top, 24) + .padding(.bottom, 20) + .overlay( + RoundedRectangle(cornerRadius: 4) + .fill(.neutral500) + .frame(width: 60, height: 4) + .padding(.top, 11) + , alignment: .top + ) + } +} diff --git a/IngrediCheck/Views/Sheets/AlreadyHaveAnAccount.swift b/IngrediCheck/Views/Sheets/AlreadyHaveAnAccount.swift new file mode 100644 index 00000000..1aae0690 --- /dev/null +++ b/IngrediCheck/Views/Sheets/AlreadyHaveAnAccount.swift @@ -0,0 +1,71 @@ +// +// AlreadyHaveAnAccount.swift +// IngrediCheck +// +// Created by Gunjan Haldar on 31/12/25. +// +import SwiftUI + +struct AlreadyHaveAnAccount: View { + let yesPressed: () -> Void + let noPressed: () -> Void + + init(yesPressed: @escaping () -> Void = {}, noPressed: @escaping () -> Void = {}) { + self.yesPressed = yesPressed + self.noPressed = noPressed + } + + var body: some View { + VStack(spacing: 0) { + VStack(spacing: 0) { + Text("Are you an existing user?") + .font(NunitoFont.bold.size(22)) + .foregroundStyle(.grayScale150) + .multilineTextAlignment(.center) + .padding(.bottom ,12) + + Text("Have you used IngrediCheck earlier? If yes, continue. ") + .font(ManropeFont.medium.size(12)) + .foregroundStyle(.grayScale120) + .multilineTextAlignment(.center) + Text("If not, start new.") + .font(ManropeFont.medium.size(12)) + .foregroundStyle(.grayScale120) + .multilineTextAlignment(.center) + } + .padding(.bottom, 40) + + HStack(spacing: 16) { + SecondaryButton( + title: "Yes, continue", + width: 152, + takeFullWidth: true, + action: yesPressed + ) + + Button { + noPressed() + } label: { + GreenCapsule(title: "No, start new") + } + + } + .padding(.bottom, 32) + +// Text("You can switch anytime before continuing.") +// .font(ManropeFont.regular.size(12)) +// .foregroundStyle(.grayScale120) +// .multilineTextAlignment(.center) + } + .padding(.horizontal, 20) + .padding(.top, 24) + .padding(.bottom, 20) +// .overlay( +// RoundedRectangle(cornerRadius: 4) +// .fill(.neutral500) +// .frame(width: 60, height: 4) +// .padding(.top, 11) +// , alignment: .top +// ) + } +} diff --git a/IngrediCheck/Views/Sheets/CaptureYourProductSheet.swift b/IngrediCheck/Views/Sheets/CaptureYourProductSheet.swift new file mode 100644 index 00000000..93cc9d7f --- /dev/null +++ b/IngrediCheck/Views/Sheets/CaptureYourProductSheet.swift @@ -0,0 +1,203 @@ +// +// CaptureYourProductSheet.swift +// IngrediCheck +// +// Created on [Date]. +// + +import SwiftUI + +struct CaptureYourProductSheet: View { + let onDismiss: () -> Void + + @State private var currentSlide = 0 + @State private var timerTask: Task? + + private let slideCount = 5 + private let slideDuration: TimeInterval = 3.0 + + private struct SlideData { + let imageName: String + let isIntro: Bool + let subtitlePrefix: String + let subtitleKeyword: String + let subtitleSuffix: String + let description: String + } + + private let slides: [SlideData] = [ + SlideData( + imageName: "botwithgrid", + isIntro: true, + subtitlePrefix: "", subtitleKeyword: "", subtitleSuffix: "", + description: "We'll guide you through a few angles so our AI can identify the product and its ingredients accurately." + ), + SlideData( + imageName: "front-product", + isIntro: false, + subtitlePrefix: "First, take a photo of the ", + subtitleKeyword: "front", + subtitleSuffix: " of the product.", + description: "" + ), + SlideData( + imageName: "back-product", + isIntro: false, + subtitlePrefix: "Next, capture the ", + subtitleKeyword: "back side", + subtitleSuffix: ".", + description: "" + ), + SlideData( + imageName: "nutrition-product", + isIntro: false, + subtitlePrefix: "Then, take photo of ", + subtitleKeyword: "Nutrition facts", + subtitleSuffix: ".", + description: "" + ), + SlideData( + imageName: "ingredients-product", + isIntro: false, + subtitlePrefix: "Finally, take a clear photo of the ", + subtitleKeyword: "Ingredient list", + subtitleSuffix: ".", + description: "" + ), + ] + + var body: some View { + ZStack(alignment: .topTrailing) { + VStack(spacing: 12) { + + HStack { + Spacer() + Button(action: onDismiss) { + Image("xmark") + .resizable() + .frame(width: 24, height: 24) + .foregroundColor(Color(.lightGray)) + } + .buttonStyle(.plain) + } + .padding(.top, 20) + .padding(.trailing, 20) + + VStack(spacing: 12) { + // Title + Text("Guide to capture photos") + .font(NunitoFont.bold.size(24)) + .foregroundColor(.grayScale150) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + + // Subtitle (slides 1-4 only) + if !slides[currentSlide].isIntro { + subtitleView(for: slides[currentSlide]) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding(.horizontal, 24) + .padding(.bottom) + } + } + + // Image area + slideImageView() + + // Description (slide 0 only) + if slides[currentSlide].isIntro { + Text(slides[currentSlide].description) + .font(NunitoFont.medium.size(14)) + .foregroundColor(Color(.grayScale140)) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding(.horizontal, 45) + } + + // Footer + Text("Capture the front, back, barcode, and ingredient details of the product.") + .font(NunitoFont.regular.size(10)) + .foregroundColor(Color(.grayScale110)) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + .padding(.vertical, 24) + } + .frame(maxWidth: .infinity) + } + .frame(maxWidth: .infinity) + .frame(height: 431) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 36, style: .continuous)) + .shadow(color: Color(hex: "#D9D9D9").opacity(0.42), radius: 27.5) + .padding(.bottom, 0) + .ignoresSafeArea(edges: .bottom) + .onAppear { + timerTask = Task { + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: UInt64(slideDuration * 1_000_000_000)) + withAnimation(.easeInOut(duration: 0.5)) { + currentSlide = (currentSlide + 1) % slideCount + } + } + } + } + .onDisappear { + timerTask?.cancel() + } + } + + @ViewBuilder + private func slideImageView() -> some View { + let slide = slides[currentSlide] + + if slide.isIntro { + ZStack { + Image("backGrids") + .resizable() + .scaledToFit() + Image("botwithgrid") + .resizable() + .scaledToFit() + } + .id(currentSlide) + .transition(.opacity) + } else { + Image(slide.imageName) + .resizable() + .scaledToFit() + .id(currentSlide) + .transition(.asymmetric( + insertion: .move(edge: .trailing), + removal: .move(edge: .leading) + )) + } + } + + private func subtitleView(for slide: SlideData) -> Text { + Text(slide.subtitlePrefix) + .font(NunitoFont.medium.size(14)) + .foregroundColor(Color(.grayScale140)) + + + Text(slide.subtitleKeyword) + .font(NunitoFont.extraBold.size(14)) + .foregroundColor(Color(hex: "91B640")) + + + Text(slide.subtitleSuffix) + .font(NunitoFont.medium.size(14)) + .foregroundColor(Color(.grayScale140)) + } +} + +#Preview { + ZStack { + Color.black.opacity(0.35) + .ignoresSafeArea() + + VStack { + Spacer() + + CaptureYourProductSheet(onDismiss: {}) + } + } + .ignoresSafeArea(edges: .bottom) +} diff --git a/IngrediCheck/Views/Sheets/DoYouHaveAnInviteCode.swift b/IngrediCheck/Views/Sheets/DoYouHaveAnInviteCode.swift new file mode 100644 index 00000000..a8b2f657 --- /dev/null +++ b/IngrediCheck/Views/Sheets/DoYouHaveAnInviteCode.swift @@ -0,0 +1,79 @@ +// +// DoYouHaveAnInviteCode.swift +// IngrediCheck +// +// Created by Gunjan Haldar on 31/12/25. +// +import SwiftUI + +struct DoYouHaveAnInviteCode: View { + let yesPressed: (() -> Void)? + let noPressed: (() -> Void)? + @Environment(AppNavigationCoordinator.self) private var coordinator + + init(yesPressed: (() -> Void)? = nil, noPressed: (() -> Void)? = nil) { + self.yesPressed = yesPressed + self.noPressed = noPressed + } + var body: some View { + VStack(spacing: 0) { + VStack(spacing: 12) { + HStack { + Text("Do you have an invite code?") + .font(NunitoFont.bold.size(22)) + .foregroundStyle(.grayScale150) + .frame(maxWidth: .infinity, alignment: .center) + } + .frame(maxWidth: .infinity) + .overlay(alignment: .leading) { + Button { + coordinator.navigateInBottomSheet(.alreadyHaveAnAccount) + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(.black) + .frame(width: 24, height: 24) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + Text("Got a family invite to IngrediFam? Enter code.") + .font(ManropeFont.medium.size(12)) + .foregroundStyle(.grayScale120) + .multilineTextAlignment(.center) + } + .padding(.bottom, 24) + + HStack(spacing: 16) { + SecondaryButton( + title: "Enter invite code", + width: 152, + takeFullWidth: true, + action: { yesPressed?() } + ) + + Button { + noPressed?() + } label: { + GreenCapsule(title: "No, Continue") + } + + } + .padding(.bottom, 20) + + LegalDisclaimerView() + } + .padding(.horizontal, 20) + .padding(.top, 24) + .padding(.bottom, 20) + .navigationBarBackButtonHidden(true) +// .overlay( +// RoundedRectangle(cornerRadius: 4) +// .fill(.neutral500) +// .frame(width: 60, height: 4) +// .padding(.top, 11) +// , alignment: .top +// ) + } +} diff --git a/IngrediCheck/Views/Sheets/EnterYourInviteCode.swift b/IngrediCheck/Views/Sheets/EnterYourInviteCode.swift new file mode 100644 index 00000000..e42306c7 --- /dev/null +++ b/IngrediCheck/Views/Sheets/EnterYourInviteCode.swift @@ -0,0 +1,281 @@ +// +// EnterYourInviteCode.swift +// IngrediCheck +// +// Created by Gunjan Haldar on 31/12/25. +// +import SwiftUI + +struct EnterYourInviteCode : View { + @Environment(FamilyStore.self) private var familyStore + @Environment(AppNavigationCoordinator.self) private var coordinator + @Environment(AuthController.self) private var authController + @State private var isVerifying: Bool = false + @State var code: [String] = Array(repeating: "", count: 6) + @State private var isError: Bool = false + @State private var shouldFocus: Bool = false + @State private var shouldClear: Bool = false + let yesPressed: (() -> Void)? + let noPressed: (() -> Void)? + + // Computed property to check if all 6 characters are entered + private var isCodeComplete: Bool { + code.allSatisfy { !$0.isEmpty } + } + + // Computed property for button title + private var buttonTitle: String { + if isError && isCodeComplete { + return "Start Over" + } + return "Verify & Continue" + } + + init(yesPressed: (() -> Void)? = nil, noPressed: (() -> Void)? = nil) { + self.yesPressed = yesPressed + self.noPressed = noPressed + } + + var body: some View { + VStack(spacing: 0) { + VStack(spacing: 12) { + HStack { + Text("Enter your invite code") + .font(NunitoFont.bold.size(22)) + .foregroundStyle(.grayScale150) + .frame(maxWidth: .infinity, alignment: .center) + } + .frame(maxWidth: .infinity) + .overlay(alignment: .leading) { + Button { + coordinator.navigateInBottomSheet(.doYouHaveAnInviteCode) + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(.black) + .frame(width: 24, height: 24) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + Text("This connects you to your family or shared\nIngrediCheck space.") + .font(ManropeFont.medium.size(12)) + .foregroundStyle(.grayScale120) + .multilineTextAlignment(.center) + } + .padding(.bottom, 24) + + InviteTextField(code: $code, isError: $isError, shouldFocus: $shouldFocus, shouldClear: $shouldClear) + .padding(.bottom, 12) + + if isError { + Text("Hmm, that code didn't work. Check it and try again.") + .font(ManropeFont.regular.size(12)) + .foregroundStyle(.red) + .padding(.bottom, 44) + } else { + Text("You can add this later if you receive one.") + .font(ManropeFont.regular.size(12)) + .foregroundStyle(.grayScale100) + .padding(.bottom, 44) + } + + HStack(spacing: 16) { + + Spacer() + + Button { + // If error occurred and button shows "Start Over", reset the form + if isError && isCodeComplete { + // Clear all code + code = Array(repeating: "", count: 6) + isError = false + // Trigger clear and focus to first box and show keyboard + shouldClear = true + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + shouldClear = false + shouldFocus = true + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + shouldFocus = false + } + } + return + } + + let entered = code.joined().trimmingCharacters(in: .whitespacesAndNewlines) + Task { + // Require a full 6-character code + guard entered.count == 6 else { + print("[EnterYourInviteCode] Invalid code length: \(entered.count)") + await MainActor.run { isError = true } + return + } + + await MainActor.run { + isVerifying = true + isError = false + } + + // Ensure user is authenticated (sign in anonymously if needed) before joining + if await authController.signInState != .signedIn { + print("[EnterYourInviteCode] User not authenticated, signing in anonymously...") + await authController.signIn() + + // Wait a moment for session to be established + try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds + + // Verify we're now signed in + if await authController.signInState != .signedIn { + print("[EnterYourInviteCode] ❌ Failed to sign in anonymously") + await MainActor.run { + isVerifying = false + isError = true + } + return + } + print("[EnterYourInviteCode] βœ… Successfully signed in anonymously") + } + + print("[EnterYourInviteCode] Calling familyStore.join with code=\(entered)") + await familyStore.join(inviteCode: entered) + + await MainActor.run { + isVerifying = false + + if familyStore.family != nil, familyStore.errorMessage == nil { + print("[EnterYourInviteCode] Join success, proceeding to next step") + isError = false + yesPressed?() + } else { + print("[EnterYourInviteCode] Join failed, error=\(familyStore.errorMessage ?? "nil")") + isError = true + } + } + } + } label: { + GreenCapsule( + title: buttonTitle, + isLoading: isVerifying, + isDisabled: isVerifying || !isCodeComplete + ) + } + .disabled(isVerifying || !isCodeComplete) + + Spacer() + } + .padding(.bottom, 20) + LegalDisclaimerView() + } + .padding(.horizontal, 20) + .padding(.top, 24) + .padding(.bottom, 20) +// .overlay( +// RoundedRectangle(cornerRadius: 4) +// .fill(.neutral500) +// .frame(width: 60, height: 4) +// .padding(.top, 11) +// , alignment: .top +// ) + .navigationBarBackButtonHidden(true) + .dismissKeyboardOnTap() + } + + struct InviteTextField: View { + @Binding var code: [String] + @Binding var isError: Bool + @Binding var shouldFocus: Bool + @Binding var shouldClear: Bool + @State private var input: String = "" + @FocusState private var isFocused: Bool + + private let boxSize = CGSize(width: 44, height: 50) + private var nextIndex: Int { min(code.firstIndex(where: { $0.isEmpty }) ?? 5, 5) } + + var body: some View { + ZStack { + // Hidden TextField that captures all input and backspace behavior + TextField("", text: $input) + .textInputAutocapitalization(.characters) + .autocorrectionDisabled(true) + .keyboardType(.asciiCapable) + .focused($isFocused) + .onChange(of: input) { newValue in + // Allow only A-Z and 0-9, convert to uppercase, and limit to 6 chars + let filtered = newValue.uppercased().filter { $0.isLetter || $0.isNumber } + let trimmed = String(filtered.prefix(6)) + if trimmed != newValue { input = trimmed } + + let chars = Array(trimmed) + for i in 0..<6 { + if i < chars.count { + code[i] = String(chars[i]) + } else { + code[i] = "" + } + } + } + .frame(width: 1, height: 1) + .opacity(0.01) + .onChange(of: shouldFocus) { newValue in + if newValue { + isFocused = true + } + } + .onChange(of: shouldClear) { newValue in + if newValue { + input = "" + } + } + + // Visual OTP boxes + HStack(spacing: 14) { + HStack(spacing: 8) { + box(0) + box(1) + box(2) + } + + RoundedRectangle(cornerRadius: 10) + .foregroundStyle(isError && !code.last!.isEmpty ? Color(hex: "FFE2E0") : .grayScale40) + .frame(width: 12, height: 2.5) + + HStack(spacing: 8) { + box(3) + box(4) + box(5) + } + } + .contentShape(Rectangle()) + .onTapGesture { isFocused = true } + } + .onAppear { + // Pre-fill input if parent provided existing code + input = code.joined().uppercased() + } + .onChange(of: isError) { newValue in + // When error is cleared, ensure focus is maintained + if !newValue && code.allSatisfy({ $0.isEmpty }) { + isFocused = true + } + } + } + + @ViewBuilder + private func box(_ index: Int) -> some View { + ZStack { + let isFilled = !code[index].isEmpty + let isActive = isFilled || (isFocused && index == nextIndex) + RoundedRectangle(cornerRadius: 12) + .foregroundStyle(isError && !code.last!.isEmpty ? Color(hex: "FFE2E0") : isActive ? .secondary200 : .grayScale40) + .frame(width: boxSize.width, height: boxSize.height) + .shadow(color: (isFocused && index == nextIndex) ? Color(hex: "C7C7C7").opacity(0.25) : .clear, radius: 9, x: 0, y: 4) + + // Character for this box (if any) + Text(code[index]) + .font(NunitoFont.semiBold.size(22)) + .foregroundStyle(isError && !code.last!.isEmpty ? Color(hex: "FF1100") : .grayScale150) + } + } + } +} diff --git a/IngrediCheck/Views/Sheets/GenerateAvatar.swift b/IngrediCheck/Views/Sheets/GenerateAvatar.swift new file mode 100644 index 00000000..17561b43 --- /dev/null +++ b/IngrediCheck/Views/Sheets/GenerateAvatar.swift @@ -0,0 +1,807 @@ +// +// GenerateAvatar.swift +// IngrediCheck +// +// Created by Gunjan Haldar on 31/12/25. +// + +import SwiftUI + +struct GenerateAvatar: View { + @Environment(MemojiStore.self) private var memojiStore + @Environment(AppNavigationCoordinator.self) private var coordinator + @Environment(FamilyStore.self) private var familyStore + + @State var toolIcons: [String] = [ + "family-member", + "gesture", + "hair-style", + "skin-tone", + "accessories", + "color-theme" + ] + + @State var familyMember: [UserModel] = [ + UserModel(familyMemberName: "baby-boy", familyMemberImage: "baby-boy", backgroundColor: Color(hex: "FFD9B5")), // Baby Boy - Age (0-4) + UserModel(familyMemberName: "baby-girl", familyMemberImage: "baby-girl", backgroundColor: Color(hex: "F9C6D0")), // Baby Girl - Age (0-4) + UserModel(familyMemberName: "young-girl", familyMemberImage: "image-bg5", backgroundColor: Color(hex: "B8E6FF")), // Young Girl - Age (4-25) + UserModel(familyMemberName: "young-boy", familyMemberImage: "Young-Son", backgroundColor: Color(hex: "FFF6B3")), // Young Boy - Age (4-25) + UserModel(familyMemberName: "mother", familyMemberImage: "image 2", backgroundColor: Color(hex: "E6D5F5")), // Adult Woman - Age (25-50) + UserModel(familyMemberName: "father", familyMemberImage: "adult-man", backgroundColor: Color(hex: "D4E6F1")), // Adult Man - Age (25-50) + UserModel(familyMemberName: "grandfather", familyMemberImage: "image-bg3", backgroundColor: Color(hex: "BFF0D4")), + UserModel(familyMemberName: "grandmother", familyMemberImage: "image-bg2", backgroundColor: Color(hex: "A7D8F0")) + ] + + @State var selectedFamilyMember: UserModel = UserModel(familyMemberName: "baby-boy", familyMemberImage: "baby-boy", backgroundColor: Color(hex: "FFD9B5")) + @State var familyIdx: Int = 0 + + // Restore state from memojiStore when view appears + private func restoreState() { + // Restore selected family member + if !memojiStore.selectedFamilyMemberName.isEmpty, + let existingMember = familyMember.first(where: { $0.name == memojiStore.selectedFamilyMemberName }) { + selectedFamilyMember = existingMember + hasSelectedFamilyMember = true // User has previously selected this + if let idx = familyMember.firstIndex(where: { $0.name == memojiStore.selectedFamilyMemberName }) { + familyIdx = idx + } else { + familyIdx = 0 + selectedFamilyMember = familyMember[0] // Ensure valid selection + } + } else { + // No previous selection - nothing should appear selected + selectedFamilyMember = familyMember[0] + familyIdx = 0 + hasSelectedFamilyMember = false + } + + // Restore other selections + selectedGestureIcon = memojiStore.selectedGestureIcon + selectedHairStyleIcon = memojiStore.selectedHairStyleIcon + selectedSkinToneIcon = memojiStore.selectedSkinToneIcon + selectedAccessoriesIcon = memojiStore.selectedAccessoriesIcon + selectedColorThemeIcon = memojiStore.selectedColorThemeIcon + + // Restore selected tool and then restore idx based on selected icon + selectedTool = memojiStore.selectedTool + restoreIdxForTool(selectedTool) + } + + // Save state to memojiStore when selections change + private func saveState() { + memojiStore.selectedFamilyMemberName = selectedFamilyMember.name + memojiStore.selectedFamilyMemberImage = selectedFamilyMember.image + memojiStore.selectedTool = selectedTool + memojiStore.currentToolIndex = idx + memojiStore.selectedGestureIcon = selectedGestureIcon + memojiStore.selectedHairStyleIcon = selectedHairStyleIcon + memojiStore.selectedSkinToneIcon = selectedSkinToneIcon + memojiStore.selectedAccessoriesIcon = selectedAccessoriesIcon + memojiStore.selectedColorThemeIcon = selectedColorThemeIcon + } + + @Binding var isExpandedMinimal: Bool + @Namespace private var animation + + @State var tools: [GenerateAvatarTools] = [ + GenerateAvatarTools( + title: "Gesture", + icon: "gesture", + tools: [ + ChipsModel(name: "Wave", icon: "wave"), + ChipsModel(name: "Thumbs Up", icon: "thumbs-up"), + ChipsModel(name: "Heart Hands", icon: "heart-hands"), + ChipsModel(name: "Phone in Hand", icon: "phone-in-hand"), + ChipsModel(name: "Peace", icon: "peace"), + ChipsModel(name: "Pointing", icon: "pointing") + ] + ), + GenerateAvatarTools( + title: "Hair Style", + icon: "hair-style", + tools: [ + ChipsModel(name: "Short hair", icon: "short-hair"), + ChipsModel(name: "Long hair", icon: "long-hair"), + ChipsModel(name: "Ponytail", icon: "ponytail"), + ChipsModel(name: "Curly hair", icon: "curly-hair"), + ChipsModel(name: "Bun", icon: "bun"), + ChipsModel(name: "Bald", icon: "bald"), + ChipsModel(name: "Short Spiky", icon: "Short Spiky"), + ChipsModel(name: "Braided", icon: "Braided"), + ChipsModel(name: "Medium Curly", icon: "medium-curely") + ] + ), + GenerateAvatarTools( + title: "Skin Tone", + icon: "skin-tone", + tools: [ + ChipsModel(name: "Very Light", icon: "very-light"), + ChipsModel(name: "Light", icon: "light"), + ChipsModel(name: "Medium Light", icon: "medium-light"), + ChipsModel(name: "Medium", icon: "medium"), + ChipsModel(name: "Medium Dark", icon: "Medium-Dark"), + ChipsModel(name: "Very Dark", icon: "very-dark") + ] + ), + GenerateAvatarTools( + title: "Accessories", + icon: "accessories", + tools: [ + ChipsModel(name: "Glasses", icon: "glasses"), + ChipsModel(name: "Hat", icon: "hat"), + ChipsModel(name: "Earrings", icon: "earrings"), + ChipsModel(name: "Sunglasses", icon: "sunglasses"), + ChipsModel(name: "Cap", icon: "cap") + ] + ), + GenerateAvatarTools( + title: "Color Theme", + icon: "color-theme", + tools: [ + ChipsModel(name: "Pastel Blue", icon: "pastel-blue"), + ChipsModel(name: "Warm Pink", icon: "warm-pink"), + ChipsModel(name: "Soft Green", icon: "soft-green"), + ChipsModel(name: "Lavender", icon: "lavender"), + ChipsModel(name: "Cream", icon: "cream"), + ChipsModel(name: "Mint", icon: "mint"), + ChipsModel(name: "Transparent", icon: "transparent") + ] + ) + ] + + @State var isExpandedMaximal: Bool = false + + // State variables synced with memojiStore + @State var selectedTool: String = "family-member" + @State var idx: Int = 0 + @State var selectedGestureIcon: String? = nil + @State var selectedHairStyleIcon: String? = nil + @State var selectedSkinToneIcon: String? = nil + @State var selectedAccessoriesIcon: String? = nil + @State var selectedColorThemeIcon: String? = nil + @State var hasSelectedFamilyMember: Bool = false // Track if user has actively selected a family member + + var randomPressed: (MemojiSelection) -> Void = { _ in } + var generatePressed: (MemojiSelection) -> Void = { _ in } + + // Helper function to get selected icon for a tool category + func getSelectedIcon(for toolIcon: String) -> String? { + switch toolIcon { + case "family-member": + return selectedFamilyMember.image + case "gesture": + return selectedGestureIcon + case "hair-style": + return selectedHairStyleIcon + case "skin-tone": + return selectedSkinToneIcon + case "accessories": + return selectedAccessoriesIcon + case "color-theme": + return selectedColorThemeIcon + default: + return nil + } + } + + // Helper function to get primary icon name for a tool category (for selected state) + func getPrimaryIcon(for toolIcon: String) -> String? { + switch toolIcon { + case "family-member": + return "family-member-Primary" + case "gesture": + return "gesture-Primary" + case "hair-style": + return "hair-style-Primary" + case "skin-tone": + return "skin-tone-Primary" + case "accessories": + return "accessories-Primary" + case "color-theme": + return "color-theme-Primary" + default: + return nil + } + } + + // Helper function to restore idx based on selected icon for a tool category + private func restoreIdxForTool(_ toolIcon: String) { + // Skip family-member as it doesn't use idx + guard toolIcon != "family-member" else { + return + } + + // Get the tool index (0-based for tools array: gesture=0, hair-style=1, etc.) + guard let toolIconIndex = toolIcons.firstIndex(of: toolIcon), + toolIconIndex > 0 else { + idx = 0 + return + } + + let toolIdx = toolIconIndex - 1 // Convert to tools array index + guard toolIdx < tools.count else { + idx = 0 + return + } + + // Get the selected icon for this tool category + let selectedIcon = getSelectedIcon(for: toolIcon) + + // Find the index of the selected icon in the tools array + if let selectedIcon = selectedIcon, + let iconIndex = tools[toolIdx].tools.firstIndex(where: { $0.icon == selectedIcon }) { + idx = iconIndex + } else { + // If no selection, default to first item + idx = 0 + } + } + + // Helper function to set selected icon for a tool category + func setSelectedIcon(for toolIcon: String, icon: String?) { + switch toolIcon { + case "gesture": + selectedGestureIcon = icon + // Update idx to match the selected icon's position in gesture tools + if let icon = icon, let iconIndex = tools[0].tools.firstIndex(where: { $0.icon == icon }) { + idx = iconIndex + } + case "hair-style": + selectedHairStyleIcon = icon + // Update idx to match the selected icon's position in hair-style tools + if let icon = icon, let iconIndex = tools[1].tools.firstIndex(where: { $0.icon == icon }) { + idx = iconIndex + } + case "skin-tone": + selectedSkinToneIcon = icon + // Update idx to match the selected icon's position in skin-tone tools + if let icon = icon, let iconIndex = tools[2].tools.firstIndex(where: { $0.icon == icon }) { + idx = iconIndex + } + case "accessories": + selectedAccessoriesIcon = icon + // Update idx to match the selected icon's position in accessories tools + if let icon = icon, let iconIndex = tools[3].tools.firstIndex(where: { $0.icon == icon }) { + idx = iconIndex + } + case "color-theme": + selectedColorThemeIcon = icon + // Update idx to match the selected icon's position in color-theme tools + if let icon = icon, let iconIndex = tools[4].tools.firstIndex(where: { $0.icon == icon }) { + idx = iconIndex + } + default: + break + } + saveState() + } + + private func buildMemojiSelection() -> MemojiSelection? { + // Require explicit family member selection - don't fall back to default + guard hasSelectedFamilyMember else { + return nil + } + + // Note: We don't validate against familyMember array here because: + // - The array is manipulated for UI display (selected member is removed from list) + // - selectedFamilyMember is a valid UserModel object when hasSelectedFamilyMember is true + // - The array manipulation in familyMemberListView is just for showing "other" members + + let gesture = selectedGestureIcon ?? tools[0].tools.first?.icon ?? "wave" + let hair = selectedHairStyleIcon ?? tools[1].tools.first?.icon ?? "short-hair" + let skinTone = selectedSkinToneIcon ?? tools[2].tools.first?.icon ?? "light" + // Don't fallback to default for optional fields - if user doesn't select, send nil to API + let accessory = selectedAccessoriesIcon + let colorTheme = selectedColorThemeIcon + + // Format: "baby-boy age (0 to 4)" + let familyTypeWithAge = "\(selectedFamilyMember.name.lowercased()) \(getAgeRangeForAPI(for: selectedFamilyMember.name))" + + return MemojiSelection( + familyType: familyTypeWithAge, + gesture: gesture, + hair: hair, + skinTone: skinTone, + accessory: accessory, + colorThemeIcon: colorTheme + ) + } + + var body: some View { + GeometryReader { geometry in + ZStack { + if isExpandedMinimal { + VStack { + Spacer() + selectedMemberRow() + + } + .padding(.horizontal, 20) + .padding(.bottom, 20) + .matchedGeometryEffect(id: "circle", in: animation) + } else { + VStack(spacing: 22) { + VStack(alignment: .leading, spacing: 16) { + HStack(spacing: 8) { + Button { + // Navigate back to the previous route, or default to whatsYourName + let previousRoute = memojiStore.previousRouteForGenerateAvatar ?? .whatsYourName + coordinator.navigateInBottomSheet(previousRoute) + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(.black) + .frame(width: 24, height: 24) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + Text("Generate Avatar For : @" + (memojiStore.displayName ?? "")) + .font(ManropeFont.bold.size(14)) + .foregroundStyle(.grayScale150) + + Spacer() + } + .padding(.horizontal, 20) + + VStack(spacing: 0) { + ScrollViewReader { proxy in + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 35) { + ForEach(toolIcons, id: \.self) { ele in + GenerateAvatarToolPill( + icon: ele, + title: ele, + isSelected: $selectedTool, + selectedItemIcon: getSelectedIcon(for: ele), + primaryIcon: getPrimaryIcon(for: ele) + ) { + restoreIdxForTool(ele) + selectedTool = ele + } + .id(ele) + } + } + .padding(.horizontal,25) + } + .onChange(of: selectedTool) { _, newValue in + withAnimation { + proxy.scrollTo(newValue, anchor: .center) + } + } + } + + // Rectangle indicator above Divider, aligned with selected tool + ScrollViewReader { proxy in + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 35) { + ForEach(toolIcons, id: \.self) { ele in + Group { + if ele == selectedTool { + RoundedRectangle(cornerRadius: 1) + .fill(Color(hex: "#91B640")) + .frame(width: 28, height: 2) + .matchedGeometryEffect(id: "selectedIndicator", in: animation) + } else { + RoundedRectangle(cornerRadius: 1) + .fill(Color.clear) + .frame(width: 28, height: 2) + } + } + .id(ele) + } + } + .padding(.horizontal,25) + } + .frame(height: 2) + .onChange(of: selectedTool) { _, newValue in + withAnimation(.easeInOut(duration: 0.4)) { + proxy.scrollTo(newValue, anchor: .center) + } + } + } + .padding(.top, 9) + + Divider() + + } + + VStack { + switch selectedTool { + case "family-member": + minimalFamilySelector() + + case "gesture": + minimalToolsSelector(toolIdx: 0) + + case "hair-style": + minimalToolsSelector(toolIdx: 1) + + case "skin-tone": + minimalToolsSelector(toolIdx: 2) + + case "accessories": + minimalToolsSelector(toolIdx: 3) + + case "color-theme": + minimalToolsSelector(toolIdx: 4) + + default: + EmptyView() + } + } + // Removed padding to allow scrolling to phone edges for all selectors + // .padding(.horizontal, selectedTool == "family-member" ? 0 : 20) + } + + HStack(alignment: .top){ + VStack(alignment: .leading){ + // Check if any tool category is selected + let hasSelections = hasSelectedFamilyMember || + selectedGestureIcon != nil || + selectedHairStyleIcon != nil || + selectedSkinToneIcon != nil || + selectedAccessoriesIcon != nil || + selectedColorThemeIcon != nil + + Text("Selected") + .font(ManropeFont.medium.size(12)) + .foregroundStyle(hasSelections ? .grayScale130 : .grayScale70) + HStack(spacing: 8) { + // Selected icons row - show family member immediately when selected + // Family member (show if user has actively selected it) + if hasSelectedFamilyMember, let familyIcon = getSelectedIcon(for: "family-member") { + Image(familyIcon) + .resizable() + .scaledToFit() + .frame(width: 20, height: 20) + } + + // Gesture (only show if selected) + if let gestureIcon = getSelectedIcon(for: "gesture") { + Image(gestureIcon) + .resizable() + .scaledToFit() + .frame(width: 20, height: 20) + } + + // Hair style (only show if selected) + if let hairIcon = getSelectedIcon(for: "hair-style") { + Image(hairIcon) + .resizable() + .scaledToFit() + .frame(width: 20, height: 20) + } + + // Skin tone (only show if selected) + if let skinToneIcon = getSelectedIcon(for: "skin-tone") { + Image(skinToneIcon) + .resizable() + .scaledToFit() + .frame(width: 20, height: 20) + } + + // Accessories (only show if selected) + if let accessoriesIcon = getSelectedIcon(for: "accessories") { + Image(accessoriesIcon) + .resizable() + .scaledToFit() + .frame(width: 20, height: 20) + } + + // Color theme (only show if selected) + if let colorThemeIcon = getSelectedIcon(for: "color-theme") { + Image(colorThemeIcon) + .resizable() + .scaledToFit() + .frame(width: 20, height: 20) + } + + Spacer() + } + + } + .frame(width: 163) +// .padding(.horizontal, 20) + // Generate button + Button { + if let selection = buildMemojiSelection() { + generatePressed(selection) + } + } label: { + GreenCapsule(title: "Generate", icon: "stars-generate") + } + .disabled(!hasSelectedFamilyMember) + .opacity(hasSelectedFamilyMember ? 1.0 : 0.6) + }.padding(.horizontal, 20) + } + } +// } + } + .padding(.top, -20) // Reduce top spacing after drag handle overlay + .overlay(alignment: .bottom) { + if isExpandedMinimal { + familyMemberListView() + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) +// .overlay( +// RoundedRectangle(cornerRadius: 4) +// .fill(.neutral500) +// .frame(width: 60, height: 4) +// .padding(.top, 11) +// , alignment: .top +// ) + .animation(.easeInOut, value: isExpandedMinimal) + .onAppear { + restoreState() + // Ensure we always have a valid family member selected + if !familyMember.contains(where: { $0.name == selectedFamilyMember.name }) { + selectedFamilyMember = familyMember[0] + familyIdx = 0 + hasSelectedFamilyMember = false // Fallback, not a user selection + } + } + .onChange(of: selectedTool) { _, newValue in + restoreIdxForTool(newValue) + saveState() + } + .onChange(of: idx) { _, _ in + saveState() + } + .onChange(of: selectedFamilyMember.name) { _, _ in + saveState() + } + .onChange(of: selectedFamilyMember.image) { _, _ in + saveState() + } + } + } + + @ViewBuilder + func minimalToolsSelector(toolIdx: Int) -> some View { + // Map toolIdx to toolIcons: 0->gesture, 1->hair-style, 2->skin-tone, 3->accessories, 4->color-theme + let toolCategory = toolIcons[toolIdx + 1] // +1 because toolIcons[0] is "family-member" + let selectedIcon = getSelectedIcon(for: toolCategory) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 16) { + ForEach(tools[toolIdx].tools) { tool in + let isSelected = selectedIcon == tool.icon + + Button { + setSelectedIcon(for: toolCategory, icon: tool.icon) + } label: { + VStack(spacing: 8) { + // Icon with border when selected + ZStack { + // Background rectangle with color +// RoundedRectangle(cornerRadius: 12) +// .fill(Color(hex: "#F9F9F9")) +// .frame(width: 52, height: 52) + + // Icon image + if let icon = tool.icon { + Image(icon) + .resizable() + .scaledToFit() + .frame(width: 43.34, height: 43.34) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + + // Border - conditional based on selection + RoundedRectangle(cornerRadius: 12) + .stroke( + isSelected ? Color(hex: "#91B640") : .grayScale40, + lineWidth: isSelected ? 1 : 0.5 + ) + .frame(width: 70, height: 70) + } + + // Tool name label + Text(tool.name) + .font(ManropeFont.medium.size(12)) + .foregroundStyle(.grayScale110) + } +// .padding(.top, 22) + } + .buttonStyle(.plain) + } + } + .padding(.vertical, 8) + .padding(.horizontal, 20) + } + } + + // Helper function to get age range for family member (for UI display) + private func getAgeRange(for memberName: String) -> String { + switch memberName.lowercased() { + case "baby-boy", "baby-girl": + return "Age (0-4)" + case "young-girl", "young-boy": + return "Age (4-25)" + case "father", "mother": + return "Age (25-50)" + case "grandfather", "grandmother": + return "Age (50+)" + default: + return "Age (4-25)" + } + } + + // Helper function to get age range for API (format: "age (0 to 4)") + private func getAgeRangeForAPI(for memberName: String) -> String { + switch memberName.lowercased() { + case "baby-boy", "baby-girl": + return "age (0 to 4)" + case "young-girl", "young-boy": + return "age (4 to 25)" + case "father", "mother": + return "age (25 to 50)" + case "grandfather", "grandmother": + return "age (50+)" + default: + return "age (4 to 25)" + } + } + + @ViewBuilder + func minimalFamilySelector() -> some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 16) { + ForEach(familyMember) { member in + let isSelected = hasSelectedFamilyMember && selectedFamilyMember.name == member.name + + Button { + hasSelectedFamilyMember = true + selectedFamilyMember = member + if let idx = familyMember.firstIndex(where: { $0.name == member.name }) { + familyIdx = idx + } + saveState() + } label: { + VStack(spacing: 8) { + // Avatar with green border when selected + ZStack { + // Background circle with color + Circle() + .fill(Color(hex: "#F9F9F9")) + .frame(width: 52, height: 52) + + // Avatar image + Image(member.image) + .resizable() + .scaledToFill() + .frame(width: 43.34, height: 43.34) + .clipShape(Circle()) + + + RoundedRectangle(cornerRadius: 12) + .stroke( + isSelected ? Color(hex: "#91B640") : .grayScale40, + lineWidth: isSelected ? 1 : 0.5 + ) + .frame(width: 70, height: 70) + } + + // Age range label + Text(getAgeRange(for: member.name)) + .font(ManropeFont.medium.size(12)) + .foregroundStyle( .grayScale110) + } +// .padding(.top, 22) + } + .buttonStyle(.plain) + } + } + .padding(.vertical, 8) + .padding(.horizontal, 20) + } + } + + @ViewBuilder + func selectedMemberRow() -> some View { + HStack { + HStack(spacing: 8) { + ZStack { + Circle() + .frame(width: 36, height: 36) + .foregroundStyle(Color(hex: "F9F9F9")) + + Image(selectedFamilyMember.image) + .resizable() + .frame(width: 30, height: 30) + .shadow(color: Color(hex: "DEDDDD"), radius: 3.5, x: 0, y: 0) + } + + Text(selectedFamilyMember.name) + .font(ManropeFont.medium.size(14)) + .foregroundStyle(.grayScale150) + } + + Spacer() + + Image(.arrow) + .resizable() + .frame(width: 24, height: 24) + .rotationEffect(Angle(degrees: 180)) + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 24) + .foregroundStyle(.grayScale10) + .shadow(color: Color(hex: "ECECEC").opacity(0.4), radius: 5, x: 0, y: 0) + .overlay( + RoundedRectangle(cornerRadius: 24) + .stroke(lineWidth: 0.5) + .foregroundStyle(.grayScale40) + ) + + ) + .onTapGesture { + withAnimation(.easeInOut) { + isExpandedMinimal.toggle() + } + } + } + + @ViewBuilder + func familyMemberListView() -> some View { + VStack { + ScrollView { + VStack { + ForEach(familyMember) { member in + HStack(spacing: 8) { + ZStack { + Circle() + .frame(width: 36, height: 36) + .foregroundStyle(Color(hex: "F9F9F9")) + + Image(member.image) + .resizable() + .frame(width: 30, height: 30) + .shadow(color: Color(hex: "DEDDDD"), radius: 3.5, x: 0, y: 0) + } + + Text(member.name) + .font(ManropeFont.medium.size(14)) + .foregroundStyle(.grayScale150) + } + .frame(maxWidth: .infinity, alignment: .leading) + .onTapGesture { + let temp = selectedFamilyMember + // Update familyIdx before modifying the array + if let idx = familyMember.firstIndex(where: { $0.name == member.name }) { + familyIdx = idx + } + selectedFamilyMember = member + hasSelectedFamilyMember = true + familyMember.removeAll { $0.name == member.name } + familyMember.append(temp) + saveState() + + isExpandedMinimal = false + } + } + } + .padding(12) + } + } + .frame(maxWidth: .infinity) + .frame(height: UIScreen.main.bounds.height * 0.30) + .background( + RoundedRectangle(cornerRadius: 16) + .foregroundStyle(.grayScale10) + .shadow(color: Color(hex: "ECECEC"), radius: 5, x: 0, y: 0) + ) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(lineWidth: 0.5) + .foregroundStyle(.grayScale40) + ) + .padding(.bottom, 60) + .padding(.horizontal, 20) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .offset(y: -30) + } +} diff --git a/IngrediCheck/Views/Sheets/LetsScanSmarter.swift b/IngrediCheck/Views/Sheets/LetsScanSmarter.swift new file mode 100644 index 00000000..394f78d4 --- /dev/null +++ b/IngrediCheck/Views/Sheets/LetsScanSmarter.swift @@ -0,0 +1,46 @@ +// +// LetsScanSmarter.swift +// IngrediCheck +// +// Created by Gunjan Haldar on 31/12/25. +// +import SwiftUI + +struct LetsScanSmarter: View { + var body: some View { + VStack(spacing: 40) { + VStack(spacing: 12) { + Text("Let's scan smarter") + .font(NunitoFont.bold.size(22)) + .foregroundStyle(.grayScale150) + + Text("Your camera helps you quickly add products by scanning labels β€” it’s safe and private. We never record or share anything without your permission.") + .font(ManropeFont.medium.size(12)) + .foregroundStyle(.grayScale120) + .multilineTextAlignment(.center) + } + + HStack(spacing: 16) { + SecondaryButton( + title: "Later", + takeFullWidth: true, + action: {} + ) + + + + GreenCapsule(title: "Enable") + } + } + .padding(.horizontal, 20) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay( + RoundedRectangle(cornerRadius: 4) + .fill(.neutral500) + .frame(width: 60, height: 4) + .padding(.top, 11) + , alignment: .top + ) + .navigationBarBackButtonHidden(true) + } +} diff --git a/IngrediCheck/Views/Sheets/LoginToContinueSheet.swift b/IngrediCheck/Views/Sheets/LoginToContinueSheet.swift new file mode 100644 index 00000000..92725b45 --- /dev/null +++ b/IngrediCheck/Views/Sheets/LoginToContinueSheet.swift @@ -0,0 +1,290 @@ +import SwiftUI + +struct LoginToContinueSheet: View { + @Environment(AuthController.self) private var authController + + let onBack: () -> Void + let onSignedIn: () -> Void + let showAsAlert: Bool + + @State private var showUpgradeError = false + @State private var upgradeErrorMessage = "" + + init(onBack: @escaping () -> Void, onSignedIn: @escaping () -> Void, showAsAlert: Bool = false) { + self.onBack = onBack + self.onSignedIn = onSignedIn + self.showAsAlert = showAsAlert + } + + var body: some View { + Group { + if showAsAlert { + alertStyleView + } else { + sheetStyleView + } + } + .overlay(alignment: .center) { + if authController.isUpgradingAccount { + ZStack { + Color.black.opacity(0.4) + .ignoresSafeArea() + ProgressView() + .scaleEffect(2) + } + } + } + .onChange(of: authController.accountUpgradeError?.localizedDescription ?? "", initial: false) { _, message in + guard !message.isEmpty else { return } + upgradeErrorMessage = message + showUpgradeError = true + } + .alert("Sign-in Failed", isPresented: $showUpgradeError) { + Button("OK", role: .cancel) { + Task { @MainActor in + authController.accountUpgradeError = nil + } + } + } message: { + Text(upgradeErrorMessage) + } + } + + private var alertStyleView: some View { + ZStack { + // Dimmed background + Color.black.opacity(0.4) + .ignoresSafeArea() + .onTapGesture { + onBack() + } + + // Alert card + VStack(spacing: 0) { + // Header with close button + HStack { + Spacer() + Button(action: onBack) { + Image(systemName: "xmark") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(.grayScale130) + .frame(width: 32, height: 32) + .background(Color.grayScale20) + .clipShape(Circle()) + } + .buttonStyle(.plain) + } + .padding(.top, 16) + .padding(.trailing, 16) + + // Title + Text("Log in to continue") + .font(NunitoFont.bold.size(22)) + .foregroundStyle(.grayScale150) + .multilineTextAlignment(.center) + .padding(.top, 8) + + // Subtitle + Text("Sign in to save your preferences and scans, and keep\n them in sync across devices.") + .font(ManropeFont.medium.size(12)) + .foregroundStyle(.grayScale120) + .multilineTextAlignment(.center) + .padding(.top, 12) + .padding(.horizontal, 24) + + // Sign in buttons + HStack(spacing: 16) { + Button { + Task { + await authController.upgradeCurrentAccount(to: .google) + await MainActor.run { + if authController.session != nil && !authController.signedInAsGuest { + onSignedIn() + } + } + } + } label: { + HStack(spacing: 8) { + Image("google_logo") + .resizable() + .frame(width: 24, height: 24) + Text("Google") + .font(NunitoFont.semiBold.size(16)) + .foregroundStyle(.grayScale150) + } + .frame(maxWidth: .infinity) + .frame(height: 52) + .background(Color.white, in: .capsule) + .overlay( + Capsule() + .stroke(Color.grayScale40, lineWidth: 1) + ) + } + .buttonStyle(.plain) + .disabled(authController.isUpgradingAccount) + + Button { + Task { + await authController.upgradeCurrentAccount(to: .apple) + await MainActor.run { + if authController.session != nil && !authController.signedInAsGuest { + onSignedIn() + } + } + } + } label: { + HStack(spacing: 8) { + Image("apple_logo") + .resizable() + .frame(width: 24, height: 24) + Text("Apple") + .font(NunitoFont.semiBold.size(16)) + .foregroundStyle(.grayScale150) + } + .frame(maxWidth: .infinity) + .frame(height: 52) + .background(Color.white, in: .capsule) + .overlay( + Capsule() + .stroke(Color.grayScale40, lineWidth: 1) + ) + } + .buttonStyle(.plain) + .disabled(authController.isUpgradingAccount) + } + .padding(.top, 24) + .padding(.bottom, 32) + .padding(.horizontal, 24) + } + .frame(width: 327) + .background(Color.white) + .cornerRadius(40) + .shadow(color: Color.black.opacity(0.1), radius: 20, x: 0, y: 10) + } + } + + private var sheetStyleView: some View { + VStack(spacing: 0) { + ZStack { + // CENTER TEXT + Text("Log in to continue") + .font(NunitoFont.bold.size(22)) + .foregroundStyle(.grayScale150) + .multilineTextAlignment(.center) + .padding(.top, 4) + + // LEFT ICON + HStack { + Button(action: onBack) { + Image(systemName: "chevron.left") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(.grayScale150) + .frame(width: 24, height: 24) + .contentShape(Rectangle()) + Spacer() + } + .buttonStyle(.plain) + + + } + } + .padding(.top, 8) + + Text("Sign in to save your preferences and scans, and keep\n them in sync across devices.") + .font(ManropeFont.medium.size(12)) + .foregroundStyle(.grayScale120) + .multilineTextAlignment(.center) + .padding(.top, 12) + .padding(.horizontal, 24) + + HStack(spacing: 16) { + Button { + Task { + await authController.upgradeCurrentAccount(to: .google) + await MainActor.run { + if authController.session != nil && !authController.signedInAsGuest { + onSignedIn() + } + } + } + } label: { + HStack(spacing: 8) { + Image("google_logo") + .resizable() + .frame(width: 24, height: 24) + Text("Google") + .font(NunitoFont.semiBold.size(16)) + .foregroundStyle(.grayScale150) + } + .frame(maxWidth: .infinity) + .frame(height: 52) + .background(Color.white, in: .capsule) + .overlay( + Capsule() + .stroke(Color.grayScale40, lineWidth: 1) + ) + } + .buttonStyle(.plain) + .disabled(authController.isUpgradingAccount) + + Button { + Task { + await authController.upgradeCurrentAccount(to: .apple) + await MainActor.run { + if authController.session != nil && !authController.signedInAsGuest { + onSignedIn() + } + } + } + } label: { + HStack(spacing: 8) { + Image("apple_logo") + .resizable() + .frame(width: 24, height: 24) + Text("Apple") + .font(NunitoFont.semiBold.size(16)) + .foregroundStyle(.grayScale150) + } + .frame(maxWidth: .infinity) + .frame(height: 52) + .background(Color.white, in: .capsule) + .overlay( + Capsule() + .stroke(Color.grayScale40, lineWidth: 1) + ) + } + .buttonStyle(.plain) + .disabled(authController.isUpgradingAccount) + } + .padding(.top, 24) + .padding(.bottom, 20) + } + .frame(maxWidth: .infinity) + .padding(.horizontal, 20) + .padding(.top, 24) + .padding(.bottom, 20) + } +} + +#Preview("Alert Style") { + ZStack { + Color.gray.opacity(0.3) + .ignoresSafeArea() + + LoginToContinueSheet( + onBack: {}, + onSignedIn: {}, + showAsAlert: true + ) + .environment(AuthController()) + } +} + +#Preview("Sheet Style") { + LoginToContinueSheet( + onBack: {}, + onSignedIn: {}, + showAsAlert: false + ) + .environment(AuthController()) +} diff --git a/IngrediCheck/Views/Sheets/MeetYourAvatar.swift b/IngrediCheck/Views/Sheets/MeetYourAvatar.swift new file mode 100644 index 00000000..a9b346fe --- /dev/null +++ b/IngrediCheck/Views/Sheets/MeetYourAvatar.swift @@ -0,0 +1,142 @@ +// +// MeetYourAvatar.swift +// IngrediCheck +// +// Created by Gunjan Haldar on 31/12/25. +// +import SwiftUI +import DotLottie + +struct MeetYourAvatar: View { + let image: UIImage? + let backgroundColorHex: String? + let regeneratePressed: () -> Void + let assignedPressed: () async -> Void + @State private var showConfetti = false + @State private var isAssigning = false + @Environment(MemojiStore.self) private var memojiStore + + // Helper function to get the display name with possessive form + private var displayText: String { + if let typedName = memojiStore.displayName, !typedName.isEmpty { + // Use the typed name with possessive form + return "Meet \(typedName)'s avatar,\nlooking good!" + } else { + // Fallback to family member type if no typed name + let memberType = memojiStore.selectedFamilyMemberName + let possessiveName = getPossessiveName(for: memberType) + return "Meet your \(possessiveName) avatar,\nlooking good!" + } + } + + // Helper function to convert family member type to possessive form + private func getPossessiveName(for memberType: String) -> String { + switch memberType.lowercased() { + case "father": + return "dad's" + case "mother": + return "mom's" + case "grandfather": + return "grandfather's" + case "grandmother": + return "grandmother's" + case "baby-boy": + return "baby boy's" + case "baby-girl": + return "baby girl's" + case "young-boy": + return "young boy's" + case "young-girl": + return "young girl's" + default: + return "\(memberType)'s" + } + } + + init(image: UIImage? = nil, backgroundColorHex: String? = nil, regeneratePressed: @escaping () -> Void = {}, assignedPressed: @escaping () async -> Void = {}) { + self.image = image + self.backgroundColorHex = backgroundColorHex + self.regeneratePressed = regeneratePressed + self.assignedPressed = assignedPressed + } + + var body: some View { + let circleColor = Color(hex: backgroundColorHex ?? "F2F2F2") + + VStack(spacing: 20) { + // Avatar with background circle + ZStack { + // Background circle (behind the image) + Circle() + .fill(circleColor) + .frame(width: 137, height: 137) + + // Memoji image on top (transparent PNG should show circle through) + if let image = image { + Image(uiImage: image) + .resizable() + .renderingMode(.original) // Preserve transparency + .scaledToFit() // Preserve aspect ratio + .frame(width: 137, height: 137) + .clipShape(Circle()) + } + } + + VStack(spacing: 40) { + Text(displayText) + .font(NunitoFont.bold.size(18)) + .foregroundStyle(.grayScale150) + .multilineTextAlignment(.center) + + HStack(spacing: 12) { + SecondaryButton( + title: "Regenerate", + icon: "stars-generate", + iconWidth: 20, + iconHeight: 20, + action: regeneratePressed + ) + + Button { + Task { + isAssigning = true + await assignedPressed() + isAssigning = false + } + } label: { + GreenCapsule(title: "Assign", isLoading: isAssigning) + } + .disabled(isAssigning) + } + } + } + .padding(.horizontal, 20) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay( + RoundedRectangle(cornerRadius: 4) + .fill(.neutral500) + .frame(width: 60, height: 4) + .padding(.top, 11) + , alignment: .top + ) + .overlay { + if showConfetti { + DotLottieAnimation( + fileName: "Confetti", + config: AnimationConfig(autoplay: true, loop: true) + ) + .view() + .ignoresSafeArea() + } + } + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { + showConfetti = true + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 5.4) { + showConfetti = false + } + } + } +} diff --git a/IngrediCheck/Views/Sheets/PermissionsCanvas.swift b/IngrediCheck/Views/Sheets/PermissionsCanvas.swift new file mode 100644 index 00000000..380e02a6 --- /dev/null +++ b/IngrediCheck/Views/Sheets/PermissionsCanvas.swift @@ -0,0 +1,227 @@ +import SwiftUI +import AVFoundation +import UserNotifications +import UIKit + +struct PermissionsCanvas: View { + @Environment(AuthController.self) private var authController + @Environment(AppNavigationCoordinator.self) private var coordinator + @State private var cameraEnabled: Bool = false + @State private var notificationsEnabled: Bool = false + @State private var showCameraPermissionAlert: Bool = false + @State private var showNotificationsPermissionAlert: Bool = false + + private var isSignedIn: Bool { + authController.session != nil && !authController.signedInAsGuest + } + + var body: some View { + VStack(spacing: 0) { + VStack(spacing: 8) { + Text("Why we need these permissions") + .font(ManropeFont.bold.size(16)) + .foregroundStyle(.grayScale150) + .padding(.top,20) + + Text("They help us scan products accurately and\nimprove your experience.") + .font(ManropeFont.medium.size(12)) + .foregroundStyle(.grayScale120) + .multilineTextAlignment(.center) + + } + .padding(.horizontal, 24) + + VStack(spacing: 18) { + permissionRow( + icon: "camera-image", + title: "Camera", + subtitle: "Used only to scan barcodes and product photos.", + isOn: Binding( + get: { cameraEnabled }, + set: { newValue in + handleCameraToggleChanged(to: newValue) + } + ), + isLocked: cameraEnabled + ) + + permissionRow( + icon: "bell-on-image", + title: "Notifications", + subtitle: "Get alerts for scan results, tips and product updates.", + isOn: Binding( + get: { notificationsEnabled }, + set: { newValue in + handleNotificationsToggleChanged(to: newValue) + } + ), + isLocked: notificationsEnabled + ) + + permissionRow( + icon: "lock-image", + title: "Login Google or Apple account", + subtitle: "Save your scans and preferences securely across devices.", + isOn: Binding( + get: { isSignedIn }, + set: { newValue in + guard newValue, !isSignedIn else { return } + coordinator.navigateInBottomSheet(.loginToContinue) + } + ) + ) + } + + .padding(.top, 34) + .padding(.horizontal, 21) + + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.white) + .task { + refreshPermissionStates() + } + .alert("Camera Permission", isPresented: $showCameraPermissionAlert) { + Button("Open Settings") { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Please allow camera access in Settings to scan products.") + } + .alert("Notifications Permission", isPresented: $showNotificationsPermissionAlert) { + Button("Open Settings") { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Please allow notifications in Settings to receive updates.") + } + } + + private func refreshPermissionStates() { + let cameraStatus = AVCaptureDevice.authorizationStatus(for: .video) + cameraEnabled = (cameraStatus == .authorized) + + UNUserNotificationCenter.current().getNotificationSettings { settings in + DispatchQueue.main.async { + notificationsEnabled = (settings.authorizationStatus == .authorized) + } + } + } + + private func handleCameraToggleChanged(to newValue: Bool) { + if newValue == false { + if cameraEnabled { + cameraEnabled = true + } else { + cameraEnabled = false + } + return + } + + let status = AVCaptureDevice.authorizationStatus(for: .video) + switch status { + case .authorized: + cameraEnabled = true + case .notDetermined: + requestCameraAccess { granted in + cameraEnabled = granted + if !granted { + showCameraPermissionAlert = true + } + } + case .denied, .restricted: + cameraEnabled = false + showCameraPermissionAlert = true + @unknown default: + cameraEnabled = false + } + } + + private func handleNotificationsToggleChanged(to newValue: Bool) { + if newValue == false { + if notificationsEnabled { + notificationsEnabled = true + } else { + notificationsEnabled = false + } + return + } + + UNUserNotificationCenter.current().getNotificationSettings { settings in + switch settings.authorizationStatus { + case .authorized: + DispatchQueue.main.async { + notificationsEnabled = true + } + case .notDetermined: + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, _ in + DispatchQueue.main.async { + notificationsEnabled = granted + if !granted { + showNotificationsPermissionAlert = true + } + } + } + case .denied, .provisional, .ephemeral: + DispatchQueue.main.async { + notificationsEnabled = false + showNotificationsPermissionAlert = true + } + @unknown default: + DispatchQueue.main.async { + notificationsEnabled = false + } + } + } + } + + private func permissionRow( + icon: String, + title: String, + subtitle: String, + isOn: Binding, + isLocked: Bool = false + ) -> some View { + HStack(alignment: .top, spacing: 0) { + Image(icon) + .frame(width: 30 ,height: 30) + .padding(.trailing ,12) + + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(NunitoFont.bold.size(16)) + .foregroundStyle(.grayScale150) + .lineLimit(1) + + Text(subtitle) + .font(ManropeFont.regular.size(12)) + .foregroundStyle(.grayScale100) .fixedSize(horizontal: false, vertical: true) + } + + + + Spacer() + + Toggle("", isOn: isOn) + .labelsHidden() + .tint(Color(hex: "#91B640")) + .disabled(isOn.wrappedValue) + .allowsHitTesting(!isLocked) + } + .padding(.vertical, 8) + } +} + +#Preview { + PermissionsCanvas() + .environment(AuthController()) + .environment(AppNavigationCoordinator()) +} diff --git a/IngrediCheck/Views/Sheets/PreferenceAreReady.swift b/IngrediCheck/Views/Sheets/PreferenceAreReady.swift new file mode 100644 index 00000000..48cc3080 --- /dev/null +++ b/IngrediCheck/Views/Sheets/PreferenceAreReady.swift @@ -0,0 +1,37 @@ +// +// PreferenceAreReady.swift +// IngrediCheck +// +// Created by Gunjan Haldar on 31/12/25. +// +import SwiftUI + +struct PreferenceAreReady: View { + var body: some View { + VStack(spacing: 32) { + VStack(spacing: 12) { + Text("All set! Your IngrediFam’s\npreferences are ready.") + .font(NunitoFont.bold.size(22)) + .foregroundStyle(.grayScale150) + .multilineTextAlignment(.center) + + Text("Tap on any member to view their summary") + .font(ManropeFont.medium.size(12)) + .foregroundStyle(.grayScale120) + .multilineTextAlignment(.center) + } + + GreenCapsule(title: "Continue") + .padding(.horizontal, 20) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) +// .overlay( +// RoundedRectangle(cornerRadius: 4) +// .fill(.neutral500) +// .frame(width: 60, height: 4) +// .padding(.top, 11) +// , alignment: .top +// ) + .navigationBarBackButtonHidden(true) + } +} diff --git a/IngrediCheck/Views/Sheets/QuickAccessNeededSheet.swift b/IngrediCheck/Views/Sheets/QuickAccessNeededSheet.swift new file mode 100644 index 00000000..dae75390 --- /dev/null +++ b/IngrediCheck/Views/Sheets/QuickAccessNeededSheet.swift @@ -0,0 +1,52 @@ +import SwiftUI + +struct QuickAccessSheet: View { + let onBack: () -> Void + let onGoToHome: () -> Void + + var body: some View { + VStack(spacing: 0) { +// HStack { +// Spacer() +// Button(action: onBack) { +// Image(systemName: "chevron.right") +// .font(.system(size: 18, weight: .semibold)) +// .foregroundStyle(.grayScale150) +// .frame(width: 44, height: 44) +// .contentShape(Rectangle()) +// } +// .buttonStyle(.plain) +// } +// .padding(.top, 8) + + Text("Quick access needed") + .font(NunitoFont.bold.size(22)) + .foregroundStyle(.grayScale150) + .multilineTextAlignment(.center) + .padding(.top, 4) + + Text("So we can scan products and personalize results for you.") + .font(ManropeFont.regular.size(13)) + .foregroundStyle(Color(hex: "#BDBDBD")) + .multilineTextAlignment(.center) + .padding(.top, 12) + .padding(.horizontal, 24) + + Button(action: onGoToHome) { + GreenCapsule(title: "Go to Home", width: 159, takeFullWidth: false) + .frame(width: 159, height: 52) + } + .buttonStyle(.plain) + .padding(.top, 20) + .padding(.bottom, 32) + } + .frame(maxWidth: .infinity) + .padding(.horizontal, 20) + .padding(.top, 24) + .padding(.bottom, 20) + } +} + +#Preview { + QuickAccessSheet(onBack: {}, onGoToHome: {}) +} diff --git a/IngrediCheck/Views/Sheets/ReadyToScanFirstProductCanvasView.swift b/IngrediCheck/Views/Sheets/ReadyToScanFirstProductCanvasView.swift new file mode 100644 index 00000000..a18b7e86 --- /dev/null +++ b/IngrediCheck/Views/Sheets/ReadyToScanFirstProductCanvasView.swift @@ -0,0 +1,56 @@ + +import SwiftUI + +struct ReadyToScanCanvas: View { + var body: some View { + VStack (spacing : 0){ + + Text("Got a product handy?") + .font(ManropeFont.bold.size(16)) + .foregroundStyle(Color.grayScale150) + + .padding(.top ,34) + Text("Scan it to see what’s inside.") + .font(ManropeFont.regular.size(13)) + .foregroundStyle(Color.grayScale100) + + Image("Iphone-product-image") + .resizable() + .scaledToFit() + .frame(maxWidth : .infinity) + .frame(height: 494) + + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background( + LinearGradient( + colors: [ + Color(hex: "#FFFFFF"), + Color(hex: "#F7F7F7"), + ], + startPoint: .top, + endPoint: .bottom + ) + ) + .overlay( + LinearGradient( + colors: [ + Color.white.opacity(0.1), + Color.white, + ], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 150) + .frame(maxWidth: .infinity) + .offset(y: 75) + ) + } +} + +#Preview { + ReadyToScanCanvas() +} + diff --git a/IngrediCheck/Views/Sheets/ReadyToScanFirstProductSheet.swift b/IngrediCheck/Views/Sheets/ReadyToScanFirstProductSheet.swift new file mode 100644 index 00000000..ddba7719 --- /dev/null +++ b/IngrediCheck/Views/Sheets/ReadyToScanFirstProductSheet.swift @@ -0,0 +1,82 @@ +import SwiftUI + +struct ReadyToScanSheet: View { + let onBack: () -> Void + let onNotRightNow: () -> Void + let onHaveAProduct: () -> Void + + var body: some View { + VStack(spacing: 0) { + ZStack { + // CENTER TEXT + Text("Ready to scan your ") + .font(NunitoFont.bold.size(22)) + .multilineTextAlignment(.center) + .foregroundStyle(.grayScale150) + .padding(.top, 4) + + // LEFT ICON +// HStack { +// Button(action: onBack) { +// Image(systemName: "chevron.left") +// .font(.system(size: 18, weight: .semibold)) +// .foregroundStyle(.grayScale150) +// .frame(width: 24, height: 24) +// .contentShape(Rectangle()) +// Spacer() +// } +// .buttonStyle(.plain) +// +// +// } + } + .padding(.top, 8) + Text("first product? ") + .font(NunitoFont.bold.size(22)) + .foregroundStyle(.grayScale150) + + + Text("Do you have any food product around you right now?") + .font(ManropeFont.medium.size(12)) + .foregroundStyle(.grayScale120) + .padding(.top, 12) + .padding(.bottom, 24) + + HStack(spacing: 16) { +// Button(action: onNotRightNow) { +// Text("Not right now") +// .font(NunitoFont.semiBold.size(16)) +// .foregroundStyle(.grayScale110) +// .frame( width : 159 ,height: 52 ) +// .background( +// Capsule().fill(.grayScale40) +// ) +// } +// .buttonStyle(.plain) + + SecondaryButton(title: "Not right now", takeFullWidth: false) { + onNotRightNow() + } + + Button(action: onHaveAProduct) { + GreenCapsule(title: "Have a product") + .frame( width : 159 ,height: 52 ) + } + .buttonStyle(.plain) + } + .padding(.bottom, 32) + } + .frame(maxWidth: .infinity) + .padding(.horizontal, 20) + .padding(.top, 24) + .padding(.bottom, 20) + } +} + +#Preview { + ReadyToScanSheet( + onBack: {}, + onNotRightNow: {}, + onHaveAProduct: {} + ) +} diff --git a/IngrediCheck/Views/Sheets/SeeHowScanningWorksCanvasView.swift b/IngrediCheck/Views/Sheets/SeeHowScanningWorksCanvasView.swift new file mode 100644 index 00000000..73c343be --- /dev/null +++ b/IngrediCheck/Views/Sheets/SeeHowScanningWorksCanvasView.swift @@ -0,0 +1,164 @@ +import SwiftUI +import AVKit +import UIKit + +struct ScanningHelpCanvas: View { + + @State private var showFullScreen = false + @State private var isVideoReady = false + + private var videoURL: URL { + TutorialVideoManager.shared.videoFileURL + } + + private var playerItem: AVPlayerItem? { + guard isVideoReady else { return nil } + return AVPlayerItem(url: videoURL) + } + + var body: some View { + VStack { + + Image("logo-with-name") + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 170, height: 36) + .padding(.vertical, 24) + + Image("trans_mockup") + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 234) + .overlay { + if let playerItem { + LoopingVideoPlayer(playerItem: playerItem) + .clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous)) + .padding(.top, 8) + .padding(.bottom, 8) + .padding(.horizontal, 8) + } else { + ProgressView() + .tint(.white) + } + } + .clipped() + .overlay(alignment: .bottomTrailing) { + Button { + showFullScreen = true + } label: { + Image("full-screen-icon") + .resizable() + .frame(width: 26, height: 26) + .background(Color.grayScale40) + .clipShape(RoundedRectangle(cornerRadius: 7)) + } + .padding(.trailing , -32) + .padding(.bottom, 8) + } + .padding(.bottom, 200) + + } + .frame(maxWidth: .infinity) + .background(Color(.pageBackground)) + .task { + if TutorialVideoManager.shared.isVideoAvailable { + isVideoReady = true + } else { + await TutorialVideoManager.shared.downloadIfNeeded() + isVideoReady = TutorialVideoManager.shared.isVideoAvailable + } + } + .onDisappear { + TutorialVideoManager.shared.removeVideo() + } + .fullScreenCover(isPresented: $showFullScreen) { + if let playerItem { + FullScreenVideoPlayer(playerItem: playerItem) + } + } + } +} + +struct LoopingVideoPlayer: UIViewRepresentable { + + let playerItem: AVPlayerItem + + func makeUIView(context: Context) -> LoopingPlayerUIView { + LoopingPlayerUIView(playerItem: playerItem) + } + + func updateUIView(_ uiView: LoopingPlayerUIView, context: Context) {} +} + +class LoopingPlayerUIView: UIView { + + private let playerLayer = AVPlayerLayer() + private var player: AVQueuePlayer? + private var looper: AVPlayerLooper? + + init(playerItem: AVPlayerItem) { + super.init(frame: .zero) + + let player = AVQueuePlayer(items: [playerItem]) + player.isMuted = true + self.player = player + self.looper = AVPlayerLooper(player: player, templateItem: playerItem) + + playerLayer.player = player + playerLayer.videoGravity = .resizeAspectFill + layer.addSublayer(playerLayer) + + player.play() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + playerLayer.frame = bounds + } +} + +struct FullScreenVideoPlayer: View { + + @Environment(\.dismiss) private var dismiss + + let playerItem: AVPlayerItem + @State private var player: AVPlayer? + + var body: some View { + ZStack(alignment: .topLeading) { + Color.black.ignoresSafeArea() + + if let player { + VideoPlayer(player: player) + .ignoresSafeArea() + } + + Button { + dismiss() + } label: { + Image(systemName: "xmark.circle.fill") + .font(.title) + .foregroundStyle(.white.opacity(0.8)) + .padding() + } + } + .onAppear { + let avPlayer = AVPlayer(playerItem: playerItem) + self.player = avPlayer + avPlayer.play() + } + .onDisappear { + player?.pause() + player = nil + } + .persistentSystemOverlays(.hidden) + } +} + +#Preview { + ScanningHelpCanvas() +} diff --git a/IngrediCheck/Views/Sheets/SeeHowScanningWorksSheet.swift b/IngrediCheck/Views/Sheets/SeeHowScanningWorksSheet.swift new file mode 100644 index 00000000..94a42703 --- /dev/null +++ b/IngrediCheck/Views/Sheets/SeeHowScanningWorksSheet.swift @@ -0,0 +1,60 @@ +import SwiftUI + +struct ScanningHelpSheet: View { + let onBack: () -> Void + let onGotIt: () -> Void + + var body: some View { + VStack(spacing: 0) { +// ZStack { + // CENTER TEXT +// Text("See how scanning works") +// .font(NunitoFont.bold.size(22)) +// .foregroundStyle(.grayScale150) +// .multilineTextAlignment(.center) +// .padding(.top, 4) + +// // LEFT ICON +// HStack { +// Button(action: onBack) { +// Image(systemName: "chevron.left") +// .font(.system(size: 18, weight: .semibold)) +// .foregroundStyle(.grayScale150) +// .frame(width: 24, height: 24) +// .contentShape(Rectangle()) +// Spacer() +// } +// .buttonStyle(.plain) +// +// +// } +// } +// .padding(.top, 8) + + + + Text("Here’s a quick look at how you can scan products when you’re ready.") + .font(NunitoFont.bold.size(14)) + .foregroundStyle(.grayScale150) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + .padding(.bottom, 24) + .lineLimit(2) + + Button(action: onGotIt) { + GreenCapsule(title: "Got it", width: 159, takeFullWidth: false) + .frame(width: 156, height: 52) + } + .buttonStyle(.plain) + .padding(.bottom, 16) + } + .frame(maxWidth: .infinity) + .padding(.horizontal, 20) + .padding(.top, 16) + .padding(.bottom, 10) + } +} + +#Preview { + ScanningHelpSheet(onBack: {}, onGotIt: {}) +} diff --git a/IngrediCheck/Views/Sheets/SetUpAvatarFor.swift b/IngrediCheck/Views/Sheets/SetUpAvatarFor.swift new file mode 100644 index 00000000..0a1edff6 --- /dev/null +++ b/IngrediCheck/Views/Sheets/SetUpAvatarFor.swift @@ -0,0 +1,123 @@ +// +// SetUpAvatarFor.swift +// IngrediCheck +// +// Created by Gunjan Haldar on 31/12/25. +// +import SwiftUI + +struct SetUpAvatarFor: View { + @Environment(FamilyStore.self) private var familyStore + @Environment(WebService.self) private var webService + @Environment(MemojiStore.self) private var memojiStore + + private var members: [FamilyMember] { + guard let family = familyStore.family else { return [] } + return [family.selfMember] + family.otherMembers + } + + @State private var selectedMember: FamilyMember? = nil + let nextPressed: () -> Void + + init(nextPressed: @escaping () -> Void = {}) { + self.nextPressed = nextPressed + } + + var body: some View { + VStack(spacing: 24) { + + VStack(spacing: 10) { + Text("Whom do you want to set up\nan avatar for?") + .font(NunitoFont.bold.size(22)) + .foregroundStyle(.grayScale150) + .multilineTextAlignment(.center) + + Text("Choose a family member to start crafting their avatar") + .font(ManropeFont.medium.size(12)) + .foregroundStyle(.grayScale120) + .multilineTextAlignment(.center) + } + .padding(.horizontal, 20) + .padding(.top, 8) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 16) { + ForEach(members) { member in + VStack(spacing: 8) { + ZStack(alignment: .topTrailing) { + // Member avatar view that loads actual memoji if available + SetUpAvatarMemberView(member: member) + .grayscale(selectedMember?.id == member.id ? 0 : 1) + + if selectedMember?.id == member.id { + Circle() + .fill(Color(hex: "2C9C3D")) + .frame(width: 16, height: 16) + .overlay( + Circle() + .stroke(lineWidth: 1) + .foregroundStyle(.white) + ) + .overlay( + Image("white-rounded-checkmark") + ) + .offset(x: 0, y: -3) + } + } + + Text(member.name) + .font(ManropeFont.regular.size(10)) + .foregroundStyle(.grayScale150) + } + .onTapGesture { + selectedMember = member + } + } + } + .padding(.leading, 20) + .padding(.vertical, 6) + } + + Button { + guard let selected = selectedMember else { + print("[SetUpAvatarFor] Next tapped with no member selected, ignoring") + return + } + + // Update display name for the selected member + memojiStore.displayName = selected.name + + // Remember which member's avatar we are about to generate, + // so that MeetYourAvatar can upload the image for this member. + print("[SetUpAvatarFor] Next tapped, setting avatarTargetMemberId=\(selected.id), displayName=\(selected.name)") + familyStore.avatarTargetMemberId = selected.id + + nextPressed() + } label: { + GreenCapsule(title: "Next") + .frame(width: 180) + } + .padding(.bottom, 8) + } + + .padding(.bottom, 53) + .padding(.top, 40) + .overlay( + RoundedRectangle(cornerRadius: 4) + .fill(.neutral500) + .frame(width: 60, height: 4) + .padding(.top, 11) + , alignment: .top + ) + } +} + +/// Avatar view used in SetUpAvatarFor sheet to show actual member memoji avatars. +struct SetUpAvatarMemberView: View { + let member: FamilyMember + + var body: some View { + // Use centralized MemberAvatar component + MemberAvatar.custom(member: member, size: 46, imagePadding: 0) + } +} diff --git a/IngrediCheck/Views/Sheets/StayUpdated.swift b/IngrediCheck/Views/Sheets/StayUpdated.swift new file mode 100644 index 00000000..95df23f0 --- /dev/null +++ b/IngrediCheck/Views/Sheets/StayUpdated.swift @@ -0,0 +1,45 @@ +// +// StayUpdated.swift +// IngrediCheck +// +// Created by Gunjan Haldar on 31/12/25. +// +import SwiftUI + +struct StayUpdated: View { + var body: some View { + VStack(spacing: 40) { + VStack(spacing: 12) { + Text("Stay updated !") + .font(NunitoFont.bold.size(22)) + .foregroundStyle(.grayScale150) + .multilineTextAlignment(.center) + + Text("We’ll send you helpful meal tips, reminders, and important updatesβ€”only when you want them.") + .font(ManropeFont.medium.size(12)) + .foregroundStyle(.grayScale120) + .multilineTextAlignment(.center) + } + + HStack(spacing: 16) { + SecondaryButton( + title: "Remind me Later", + takeFullWidth: true, + action: {} + ) + + GreenCapsule(title: "Allow") + } + .padding(.horizontal, 20) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay( + RoundedRectangle(cornerRadius: 4) + .fill(.neutral500) + .frame(width: 60, height: 4) + .padding(.top, 11) + , alignment: .top + ) + .navigationBarBackButtonHidden(true) + } +} diff --git a/IngrediCheck/Views/Sheets/UpdateAvatarSheet.swift b/IngrediCheck/Views/Sheets/UpdateAvatarSheet.swift new file mode 100644 index 00000000..790f61b5 --- /dev/null +++ b/IngrediCheck/Views/Sheets/UpdateAvatarSheet.swift @@ -0,0 +1,364 @@ +// +// UpdateAvatarSheet.swift +// IngrediCheck +// +// Created on 23/01/26. +// + +import SwiftUI + +struct UpdateAvatarSheet: View { + let memberId: UUID + let onBack: () -> Void + + @Environment(FamilyStore.self) private var familyStore + @Environment(MemojiStore.self) private var memojiStore + @Environment(WebService.self) private var webService + @Environment(AppNavigationCoordinator.self) private var coordinator + + // Static memojis (same as AddMoreMembers) + @State private var familyMembersList: [UserModel] = [ + UserModel(familyMemberName: "Memoji 1", familyMemberImage: "memoji_1", backgroundColor: Color(hex: "FFB3BA")), + UserModel(familyMemberName: "Memoji 2", familyMemberImage: "memoji_2", backgroundColor: Color(hex: "FFDFBA")), + UserModel(familyMemberName: "Memoji 3", familyMemberImage: "memoji_3", backgroundColor: Color(hex: "FFFFBA")), + UserModel(familyMemberName: "Memoji 4", familyMemberImage: "memoji_4", backgroundColor: Color(hex: "BAFFC9")), + UserModel(familyMemberName: "Memoji 5", familyMemberImage: "memoji_5", backgroundColor: Color(hex: "BAE1FF")), + UserModel(familyMemberName: "Memoji 6", familyMemberImage: "memoji_6", backgroundColor: Color(hex: "E0BBE4")), + UserModel(familyMemberName: "Memoji 7", familyMemberImage: "memoji_7", backgroundColor: Color(hex: "FFCCCB")), + UserModel(familyMemberName: "Memoji 8", familyMemberImage: "memoji_8", backgroundColor: Color(hex: "B4E4FF")), + UserModel(familyMemberName: "Memoji 9", familyMemberImage: "memoji_9", backgroundColor: Color(hex: "C7CEEA")), + UserModel(familyMemberName: "Memoji 10", familyMemberImage: "memoji_10", backgroundColor: Color(hex: "F0E6FF")), + UserModel(familyMemberName: "Memoji 11", familyMemberImage: "memoji_11", backgroundColor: Color(hex: "FFE5B4")), + UserModel(familyMemberName: "Memoji 12", familyMemberImage: "memoji_12", backgroundColor: Color(hex: "E8F5E9")), + UserModel(familyMemberName: "Memoji 13", familyMemberImage: "memoji_13", backgroundColor: Color(hex: "FFF9C4")), + UserModel(familyMemberName: "Memoji 14", familyMemberImage: "memoji_14", backgroundColor: Color(hex: "F8BBD0")) + ] + + @State private var selectedStaticAvatar: UserModel? = nil + @State private var useCustomAvatar: Bool = false + @State private var isLoading: Bool = false + + private var currentMember: FamilyMember? { + guard let family = familyStore.family else { + // Check pending members + if let selfMember = familyStore.pendingSelfMember, selfMember.id == memberId { + return selfMember + } + return familyStore.pendingOtherMembers.first(where: { $0.id == memberId }) + } + + if memberId == family.selfMember.id { + return family.selfMember + } + return family.otherMembers.first(where: { $0.id == memberId }) + } + + private var hasSelection: Bool { + selectedStaticAvatar != nil || useCustomAvatar + } + + private var previewImage: UIImage? { + if useCustomAvatar, let customImage = memojiStore.image { + return customImage + } + return nil + } + + var body: some View { + VStack(spacing: 0) { + // Header with back button + headerView + .padding(.top, 24) + + // Large avatar preview + avatarPreview + .padding(.top, 16) + .padding(.bottom, 20) + + // Title & subtitle + Text("Update your avatar?") + .font(NunitoFont.bold.size(20)) + .foregroundStyle(Color(hex: "#303030")) + .multilineTextAlignment(.center) + .padding(.bottom, 8) + + Text("Select an avatar that reflects your personality.") + .font(ManropeFont.medium.size(14)) + .foregroundStyle(Color(hex: "#949494")) + .multilineTextAlignment(.center) + .padding(.bottom, 24) + + // Choose Avatar section + VStack(alignment: .leading, spacing: 12) { + Text("Choose Avatar") + .font(ManropeFont.bold.size(14)) + .foregroundStyle(Color(hex: "#303030")) + .padding(.leading, 20) + + avatarSelectionGrid + } + .padding(.bottom, 12) + + // Hint text + HStack(spacing: 6) { + Text("πŸ’‘") + .font(.system(size: 14)) + Text("Choose an optional avatar or tap + to generate a new one") + .font(ManropeFont.medium.size(12)) + .foregroundStyle(Color(hex: "#949494")) + } + .padding(.horizontal, 20) + .padding(.bottom, 24) + + // Save button + Button { + Task { + await saveAvatar() + } + } label: { + GreenCapsule(title: "Save", isLoading: isLoading, isDisabled: !hasSelection) + .frame(width: 159) + } + .disabled(!hasSelection || isLoading) + .padding(.bottom, 32) + } + .background(Color.white) + .onAppear { + // Check if returning from generateAvatar with a custom image + if memojiStore.image != nil && memojiStore.previousRouteForGenerateAvatar == .updateAvatar(memberId: memberId) { + useCustomAvatar = true + selectedStaticAvatar = nil + } + + // Set display name for avatar generation + if let member = currentMember { + memojiStore.displayName = member.name + familyStore.avatarTargetMemberId = member.id + } + } + } + + // MARK: - Header View + + private var headerView: some View { + HStack { + Button { + onBack() + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(Color(hex: "#303030")) + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + + Spacer() + } + .padding(.horizontal, 20) + } + + // MARK: - Avatar Preview + + private var avatarPreview: some View { + ZStack { + if useCustomAvatar, let customImage = memojiStore.image { + // Show custom generated avatar + Circle() + .fill(Color(hex: memojiStore.backgroundColorHex ?? "#E0BBE4")) + .frame(width: 120, height: 120) + .overlay( + Image(uiImage: customImage) + .resizable() + .scaledToFit() + .frame(width: 110, height: 110) + .clipShape(Circle()) + ) + .overlay( + Circle() + .stroke(Color.white, lineWidth: 2) + ) + } else if let selected = selectedStaticAvatar { + // Show selected static avatar + Circle() + .fill(selected.backgroundColor ?? .clear) + .frame(width: 120, height: 120) + .overlay( + Image(selected.image) + .resizable() + .scaledToFit() + .frame(width: 110, height: 110) + .clipShape(Circle()) + ) + .overlay( + Circle() + .stroke(Color.white, lineWidth: 2) + ) + } else if let member = currentMember { + // Show current member avatar + MemberAvatar.large(member: member) + } else { + // Fallback placeholder + Circle() + .fill(Color(hex: "#D9D9D9")) + .frame(width: 120, height: 120) + } + } + } + + // MARK: - Avatar Selection Grid + + private var avatarSelectionGrid: some View { + HStack(spacing: 16) { + // Plus button for custom avatar generation + Button { + memojiStore.previousRouteForGenerateAvatar = .updateAvatar(memberId: memberId) + coordinator.navigateInBottomSheet(.generateAvatar) + } label: { + ZStack { + Circle() + .stroke(lineWidth: 2) + .foregroundStyle(Color(hex: "#E0E0E0")) + .frame(width: 50, height: 50) + + Image(systemName: "plus") + .resizable() + .frame(width: 20, height: 20) + .foregroundStyle(Color(hex: "#E0E0E0")) + } + } + .buttonStyle(.plain) + .padding(.leading, 20) + + // Vertical divider + Rectangle() + .fill(Color(hex: "#E0E0E0")) + .frame(width: 1, height: 50) + + // Scrollable static avatars + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 16) { + ForEach(familyMembersList, id: \.id) { avatar in + ZStack(alignment: .topTrailing) { + Image(avatar.image) + .resizable() + .frame(width: 50, height: 50) + .clipShape(Circle()) + .overlay( + Circle() + .stroke( + selectedStaticAvatar?.id == avatar.id ? Color(hex: "#91B640") : Color.clear, + lineWidth: 2 + ) + ) + + // Green checkmark when selected + if selectedStaticAvatar?.id == avatar.id { + Circle() + .fill(Color(hex: "#2C9C3D")) + .frame(width: 16, height: 16) + .overlay( + Image(systemName: "checkmark") + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(.white) + ) + .offset(x: 2, y: -2) + } + } + .onTapGesture { + selectedStaticAvatar = avatar + useCustomAvatar = false + } + } + } + .padding(.trailing, 20) + } + } + } + + // MARK: - Save Avatar + + @MainActor + private func saveAvatar() async { + guard let member = currentMember else { return } + + isLoading = true + defer { isLoading = false } + + var imageFileHash: String? = nil + var colorHex: String? = nil + + if useCustomAvatar, let customImage = memojiStore.image { + // Upload custom avatar + do { + let hash = try await webService.uploadImage(image: customImage) + imageFileHash = hash + colorHex = memojiStore.backgroundColorHex + } catch { + Log.error("UpdateAvatarSheet", "Failed to upload custom avatar: \(error)") + ToastManager.shared.show(message: "Failed to upload avatar", type: .error) + return + } + } else if let selected = selectedStaticAvatar { + // Use static memoji path + imageFileHash = selected.image + colorHex = selected.backgroundColor?.toHex() + } else { + return + } + + // Update member's avatar + do { + try await familyStore.updateMemberAvatar( + memberId: member.id, + imageFileHash: imageFileHash, + color: colorHex + ) + + // Clear memojiStore state + memojiStore.image = nil + memojiStore.backgroundColorHex = nil + + // Navigate back + onBack() + } catch { + Log.error("UpdateAvatarSheet", "Failed to update avatar: \(error)") + ToastManager.shared.show(message: "Failed to save avatar", type: .error) + } + } +} + +// MARK: - Preview + +#Preview("Update Avatar Sheet") { + UpdateAvatarSheetPreview() +} + +private struct UpdateAvatarSheetPreview: View { + @State private var familyStore = FamilyStore() + @State private var memojiStore = MemojiStore() + @State private var webService = WebService() + @State private var coordinator = AppNavigationCoordinator(initialRoute: .home) + @State private var memberId: UUID? + + var body: some View { + Group { + if let id = memberId { + UpdateAvatarSheet(memberId: id) { + print("Back tapped") + } + } else { + ProgressView() + } + } + .environment(familyStore) + .environment(memojiStore) + .environment(webService) + .environment(coordinator) + .onAppear { + // Create a mock member for preview using public method + familyStore.setPendingSelfMember(name: "John") + // Get the created member's ID + memberId = familyStore.pendingSelfMember?.id + } + } +} diff --git a/IngrediCheck/Views/Sheets/WelcomeBack.swift b/IngrediCheck/Views/Sheets/WelcomeBack.swift new file mode 100644 index 00000000..2aa3d5cf --- /dev/null +++ b/IngrediCheck/Views/Sheets/WelcomeBack.swift @@ -0,0 +1,186 @@ +// +// WelcomeBack.swift +// IngrediCheck +// +// Created by Gunjan Haldar on 31/12/25. +// +import SwiftUI + +struct WelcomeBack: View { + @Environment(AuthController.self) var authController + @Environment(AppNavigationCoordinator.self) private var coordinator + @State private var isSigningIn = false + + var body: some View { + VStack(spacing: 0) { + VStack(spacing: 12) { + HStack { + Text("Welcome back!") + .font(NunitoFont.bold.size(22)) + .foregroundStyle(.grayScale150) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .overlay(alignment: .leading) { + Button { + // Go back one bottom sheet route + coordinator.navigateInBottomSheet(.alreadyHaveAnAccount) + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(.black) + .frame(width: 24, height: 24) // comfortable tap target + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + Text("Log in to your existing IngrediCheck account.") + .font(ManropeFont.medium.size(12)) .foregroundStyle(.grayScale120) + .multilineTextAlignment(.center) + } + .padding(.bottom, 40) + + HStack(spacing: 16) { + Button { + isSigningIn = true + authController.signInWithGoogle { result in + switch result { + case .success: + Task { + let metadata = await OnboardingPersistence.shared.fetchRemoteMetadata() + Log.debug("WelcomeBack", "Google sign-in metadata: stage=\(metadata?.stage?.rawValue ?? "nil"), flowType=\(metadata?.flowType?.rawValue ?? "nil"), stepId=\(metadata?.currentStepId ?? "nil"), bottomSheet=\(metadata?.bottomSheetRoute?.rawValue ?? "nil")") + await MainActor.run { + if let stage = metadata?.stage, stage == .completed { + AnalyticsService.shared.trackOnboarding("Onboarding Existing User Completed", properties: ["sign_in_method": "google"]) + OnboardingPersistence.shared.markCompleted() + coordinator.showCanvas(.home) + } else if let metadata = metadata, let stage = metadata.stage, stage != .none { + let (canvas, sheet) = AppNavigationCoordinator.restoreState(from: metadata) + Log.debug("WelcomeBack", "Restoring to canvas=\(canvas), sheet=\(sheet)") + coordinator.showCanvas(canvas) + coordinator.navigateInBottomSheet(sheet) + } else { + Log.debug("WelcomeBack", "No metadata or no progress β€” navigating to whosThisFor") + coordinator.showCanvas(.letsGetStarted) + coordinator.navigateInBottomSheet(.whosThisFor) + } + isSigningIn = false + } + } + case .failure(let error): + Log.error("WelcomeBack", "Google Sign-In failed: \(error.localizedDescription)") + isSigningIn = false + } + } + } label: { + HStack(spacing: 8) { + Image("google_logo") + .resizable() + .frame(width: 24, height: 24) + Text("Google") + .font(NunitoFont.semiBold.size(16)) + .foregroundStyle(.grayScale150) + } + .frame(maxWidth: .infinity) + .frame(height: 52) + .background(Color.white, in: .capsule) + .overlay( + Capsule() + .stroke(Color.grayScale40, lineWidth: 1) + ) + } + .disabled(isSigningIn) + + Button { + isSigningIn = true + authController.signInWithApple { result in + switch result { + case .success: + Task { + let metadata = await OnboardingPersistence.shared.fetchRemoteMetadata() + Log.debug("WelcomeBack", "Apple sign-in metadata: stage=\(metadata?.stage?.rawValue ?? "nil"), flowType=\(metadata?.flowType?.rawValue ?? "nil"), stepId=\(metadata?.currentStepId ?? "nil"), bottomSheet=\(metadata?.bottomSheetRoute?.rawValue ?? "nil")") + await MainActor.run { + if let stage = metadata?.stage, stage == .completed { + AnalyticsService.shared.trackOnboarding("Onboarding Existing User Completed", properties: ["sign_in_method": "apple"]) + OnboardingPersistence.shared.markCompleted() + coordinator.showCanvas(.home) + } else if let metadata = metadata, let stage = metadata.stage, stage != .none { + let (canvas, sheet) = AppNavigationCoordinator.restoreState(from: metadata) + Log.debug("WelcomeBack", "Restoring to canvas=\(canvas), sheet=\(sheet)") + coordinator.showCanvas(canvas) + coordinator.navigateInBottomSheet(sheet) + } else { + Log.debug("WelcomeBack", "No metadata or no progress β€” navigating to whosThisFor") + coordinator.showCanvas(.letsGetStarted) + coordinator.navigateInBottomSheet(.whosThisFor) + } + isSigningIn = false + } + } + case .failure(let error): + Log.error("WelcomeBack", "Apple Sign-In failed: \(error.localizedDescription)") + isSigningIn = false + } + } + } label: { + HStack(spacing: 8) { + Image("apple_logo") + .resizable() + .frame(width: 24, height: 24) + Text("Apple") + .font(NunitoFont.semiBold.size(16)) + .foregroundStyle(.grayScale150) + } + .frame(maxWidth: .infinity) + .frame(height: 52) + .background(Color.white, in: .capsule) + .overlay( + Capsule() + .stroke(Color.grayScale40, lineWidth: 1) + ) + } + .disabled(isSigningIn) + } + .padding(.bottom, 20) + +// HStack(spacing: 4) { +// Text("New here?") +// .font(ManropeFont.regular.size(12)) +// .foregroundStyle(.grayScale120) +// +// Button { +// +// } label: { +// Text("Get started instead") +// .font(ManropeFont.semiBold.size(12)) +// .foregroundStyle(rotatedGradient(colors: [Color(hex: "9DCF10"), Color(hex: "6B8E06")], angle: 88)) +// } +// } + + LegalDisclaimerView() + + } + .padding(.horizontal, 20) + .padding(.top, 24) + .padding(.bottom, 20) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay { + if isSigningIn { + ZStack { + Color.black.opacity(0.4) + ProgressView() + .scaleEffect(2) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } +// .overlay( +// RoundedRectangle(cornerRadius: 4) +// .fill(.neutral500) +// .frame(width: 60, height: 4) +// .padding(.top, 11) +// , alignment: .top +// ) + .navigationBarBackButtonHidden(true) + } +} diff --git a/IngrediCheck/Views/Sheets/WhosThisFor.swift b/IngrediCheck/Views/Sheets/WhosThisFor.swift new file mode 100644 index 00000000..d3e2d2d8 --- /dev/null +++ b/IngrediCheck/Views/Sheets/WhosThisFor.swift @@ -0,0 +1,129 @@ +// +// WhosThisFor.swift +// IngrediCheck +// +// Created by Gunjan Haldar on 31/12/25. +// +import SwiftUI + +struct WhosThisFor: View { + let justmePressed: (() async -> Void)? + let addFamilyPressed: (() async -> Void)? + @Environment(AppNavigationCoordinator.self) private var coordinator + @State private var isJustMeLoading = false + @State private var isAddFamilyLoading = false + + init(justmePressed: (() async -> Void)? = nil, addFamilyPressed: (() async -> Void)? = nil) { + self.justmePressed = justmePressed + self.addFamilyPressed = addFamilyPressed + } + var body: some View { + VStack(spacing: 0) { + VStack(spacing: 12) { + HStack { + Text("Hey there! Who’s this for?") + .font(NunitoFont.bold.size(22)) + .foregroundStyle(.grayScale150) + .frame(maxWidth: .infinity, alignment: .center) + } + .frame(maxWidth: .infinity) + .overlay(alignment: .leading) { + Button { + coordinator.navigateInBottomSheet(.doYouHaveAnInviteCode) + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(.black) + .frame(width: 24, height: 24) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + Text("Is it just you, or your whole IngrediFam β€” family, friends, anyone you care about?") + .font(ManropeFont.medium.size(12)) + .foregroundStyle(.grayScale120) + .multilineTextAlignment(.center) + } + .padding(.bottom, 40) + + HStack(spacing: 16) { + SecondaryButton( + title: "Just Me", + icon: "justMe", + takeFullWidth: true, + isLoading: isJustMeLoading, + isDisabled: isJustMeLoading || isAddFamilyLoading, + action: { + guard !isJustMeLoading else { return } + Task { + isJustMeLoading = true + await justmePressed?() + // Reset loading state after operation completes + // Add a small delay to ensure navigation completes + try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds + await MainActor.run { + isJustMeLoading = false + } + } + } + ) + + SecondaryButton( + title: "Add Family", + icon: "addfamily", + takeFullWidth: true, + isLoading: isAddFamilyLoading, + isDisabled: isJustMeLoading || isAddFamilyLoading, + action: { + guard !isAddFamilyLoading else { return } + Task { + isAddFamilyLoading = true + await addFamilyPressed?() + // Reset loading state after operation completes + // Add a small delay to ensure navigation completes + try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds + await MainActor.run { + isAddFamilyLoading = false + } + } + } + ) + + } + .padding(.bottom, 20) + + Text("You can always add or edit members later.") + .font(ManropeFont.regular.size(12)) + .foregroundStyle(.grayScale90) + .multilineTextAlignment(.center) + } + .padding(.horizontal, 20) + .padding(.top, 24) + .padding(.bottom, 20) +// .overlay( +// RoundedRectangle(cornerRadius: 4) +// .fill(.neutral500) +// .frame(width: 60, height: 4) +// .padding(.top, 11) +// , alignment: .top +// ) + } +} + +#Preview("Default") { + WhosThisFor( + justmePressed: { + print("Just Me pressed") + }, + addFamilyPressed: { + print("Add Family pressed") + } + ) + .environment(AppNavigationCoordinator()) +} + +#Preview("Without Actions") { + WhosThisFor() + .environment(AppNavigationCoordinator()) +} diff --git a/IngrediCheck/Views/Sheets/WouldYouLikeToInvite.swift b/IngrediCheck/Views/Sheets/WouldYouLikeToInvite.swift new file mode 100644 index 00000000..d46084b9 --- /dev/null +++ b/IngrediCheck/Views/Sheets/WouldYouLikeToInvite.swift @@ -0,0 +1,68 @@ +// +// WouldYouLikeToInvite.swift +// IngrediCheck +// +// Created by Gunjan Haldar on 31/12/25. +// +import SwiftUI + +struct WouldYouLikeToInvite: View { + var name: String + var isLoading: Bool = false + var invitePressed: () -> Void = { } + var continuePressed: () -> Void = { } + var body: some View { + VStack(spacing: 40) { + VStack(spacing: 12) { + Text("Would you like to invite \(name) to join IngrediFam?") + .font(NunitoFont.bold.size(20)) + .foregroundStyle(.grayScale150) + .multilineTextAlignment(.center) + + Text("No worries if you skip this step. You can share the code with \(name) later too.") + .font(ManropeFont.medium.size(12)) + .foregroundStyle(.grayScale120) + .multilineTextAlignment(.center) + } + .padding(.horizontal, 20) + + HStack(spacing: 16) { + SecondaryButton( + title: "Maybe later", + takeFullWidth: true, + isDisabled: isLoading, + action: continuePressed + ) + + Button { + invitePressed() + } label: { + ZStack { + GreenCapsule(title: "Invite" , icon: "share" ,iconWidth: 12 , iconHeight: 12 ,) + .opacity(isLoading ? 0.6 : 1) + + if isLoading { + ProgressView() + .tint(.white) + } + } + } + .disabled(isLoading) + + + + + } + .padding(.horizontal, 20) + } + .padding(.top, 24) + .padding(.bottom, 20) + .overlay( + RoundedRectangle(cornerRadius: 4) + .fill(.neutral500) + .frame(width: 60, height: 4) + .padding(.top, 11) + , alignment: .top + ) + } +} diff --git a/IngrediCheck/Views/Sheets/YourCurrentAvatar.swift b/IngrediCheck/Views/Sheets/YourCurrentAvatar.swift new file mode 100644 index 00000000..81f368d9 --- /dev/null +++ b/IngrediCheck/Views/Sheets/YourCurrentAvatar.swift @@ -0,0 +1,88 @@ +// +// YourCurrentAvatar.swift +// IngrediCheck +// +// Created by Gunjan Haldar on 31/12/25. +// +import SwiftUI + +struct YourCurrentAvatar: View { + @Environment(FamilyStore.self) private var familyStore + @Environment(WebService.self) private var webService + @Environment(MemojiStore.self) private var memojiStore + + let createNewPressed: () -> Void + + init(createNewPressed: @escaping () -> Void = {}) { + self.createNewPressed = createNewPressed + } + + private var currentMember: FamilyMember? { + guard let family = familyStore.family else { return nil } + + // If a member was selected in SetUpAvatarFor, show that member's avatar + if let targetMemberId = familyStore.avatarTargetMemberId { + if targetMemberId == family.selfMember.id { + return family.selfMember + } else if let member = family.otherMembers.first(where: { $0.id == targetMemberId }) { + return member + } + } + + // Otherwise, default to selfMember + return family.selfMember + } + + var body: some View { + VStack(spacing: 0) { + // Show actual member avatar + if let member = currentMember { + YourCurrentAvatarView(member: member) + .padding(.bottom, 26) + } else { + Circle() + .fill(Color(hex: "#D9D9D9")) + .frame(width: 120, height: 120) + .padding(.bottom, 26) + } + + Text("Here's your current avatar. Would you like to make a new one?") + .font(NunitoFont.bold.size(20)) + .multilineTextAlignment(.center) + .padding(.bottom, 23) + + Button { + // Update display name to current member's name before creating new avatar + if let member = currentMember { + memojiStore.displayName = member.name + } + createNewPressed() + } label: { + GreenCapsule(title: "Create New") + .frame(width: 159) + } + } + .padding(.horizontal, 20) + .padding(.bottom, 64) + .padding(.top, 40) +// .frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay( + RoundedRectangle(cornerRadius: 4) + .fill(.neutral500) + .frame(width: 60, height: 4) + .padding(.top, 11) + , alignment: .top + ) + } +} + + +/// Large avatar view (120x120) used in YourCurrentAvatar sheet to show the member's current memoji. +struct YourCurrentAvatarView: View { + let member: FamilyMember + + var body: some View { + // Use centralized MemberAvatar component + MemberAvatar.large(member: member) + } +} diff --git a/IngrediCheck/Views/Splash Screen/SplashScreen.swift b/IngrediCheck/Views/Splash Screen/SplashScreen.swift new file mode 100644 index 00000000..4557500b --- /dev/null +++ b/IngrediCheck/Views/Splash Screen/SplashScreen.swift @@ -0,0 +1,160 @@ +// +// SplashScreen.swift +// IngrediCheckPreview +// +// Created by Gunjan Haldar on 07/11/25. +// + +import SwiftUI + +struct SplashScreen: View { + @State private var isFirstLaunch: Bool = true + @State private var isCheckingLaunchState: Bool = true + @State private var shouldNavigateToHome: Bool = false + @State private var shouldNavigateToOnboarding: Bool = false + @State private var shouldNavigateFromWelcome: Bool = false + @State private var restoredState: (canvas: CanvasRoute, sheet: BottomSheetRoute)? + @Environment(AuthController.self) private var authController + @Environment(FamilyStore.self) private var familyStore + + var body: some View { + Group { + if isCheckingLaunchState { + Image("SplashScreen") + .resizable() + .scaledToFill() + .ignoresSafeArea() + } else if shouldNavigateToHome { + // In preview flow, if there's already a Supabase session + // (including anonymous/guest), skip the marketing carousel + // and go straight into the main container. + Splash { + Image("SplashScreen") + .resizable() + .scaledToFill() + .ignoresSafeArea() + } content: { + RootContainerView(restoredState: (canvas: .home, sheet: .homeDefault)) + .environment(authController) + .environment(familyStore) + } + } else if shouldNavigateFromWelcome { + // User tapped "Get Started" - navigate directly without showing splash again + RootContainerView(restoredState: restoredState) + .environment(authController) + .environment(familyStore) + } else if shouldNavigateToOnboarding { + // If there's a session but onboarding isn't complete, + // go to RootContainerView which will restore from metadata + Splash { + Image("SplashScreen") + .resizable() + .scaledToFill() + .ignoresSafeArea() + } content: { + RootContainerView(restoredState: restoredState) + .environment(authController) + .environment(familyStore) + } + } else { + WelcomeView(onGetStarted: { + restoredState = nil + shouldNavigateFromWelcome = true + }) + } + } + .task { + let firstLaunchKey = "hasLaunchedOncePreviewFlow" + let hasLaunchedBefore = UserDefaults.standard.bool(forKey: firstLaunchKey) + + if !hasLaunchedBefore { + // Mark that we've now launched at least once. For this + // initial launch we force onboarding by treating it as + // first launch even if a stale session exists in keychain. + UserDefaults.standard.set(true, forKey: firstLaunchKey) + isFirstLaunch = true + + + // If we somehow already have a Supabase session on first + // launch (e.g., carried over via keychain from a previous + // install), clear it so the user is not auto-logged in + // before they choose Google/Apple or "Sign-in later". + // We call signOut unconditionally to handle race conditions where + // authController.session might still be nil but GoTrue is restoring. + print("[SplashScreen] First launch detected. Ensuring clean slate.") + await authController.signOut() + isCheckingLaunchState = false + return + } else { + isFirstLaunch = false + } + + // PRIORITY: Check local completion status FIRST (fast path) + // This matches the logic in AppNavigationCoordinator.init + if OnboardingPersistence.shared.isLocallyCompleted { + print("[SplashScreen] βœ… Onboarding completed locally, navigating to home immediately") + shouldNavigateToHome = true + isCheckingLaunchState = false + return + } + + // Wait a bit for session to be fully restored and metadata to be available + // The session might be restored from keychain but userMetadata might need a moment + // Check if session exists (or wait briefly if we suspect it should) + // Actually authController.session might be nil initially but update shortly. + // We'll give it a tiny grace period if it's nil? + // Checks on kill/launch typically have session ready from keychain immediately if using GoTrue synchronously or fast async. + + if authController.session != nil { + print("[SplashScreen] Session exists, checking metadata...") + // Session exists + } else { + // Maybe wait 0.1s just in case session is being restored async? + try? await Task.sleep(nanoseconds: 100_000_000) + } + + if authController.session != nil { + // Check metadata + let metadata = await OnboardingPersistence.shared.fetchRemoteMetadata() + print("[SplashScreen] Metadata check result: \(metadata != nil ? "found" : "not found")") + + if let metadata = metadata { + if metadata.stage == .completed { + shouldNavigateToHome = true + print("[SplashScreen] βœ… Onboarding complete (remote), navigating to home") + } else { + // Restore state specifically + restoredState = AppNavigationCoordinator.restoreState(from: metadata) + shouldNavigateToOnboarding = true + print("[SplashScreen] ⚠️ Onboarding not complete, restoring to \(restoredState?.canvas ?? .heyThere)") + } + } else { + // Session exists but no metadata? Navigate to onboarding start I guess. + shouldNavigateToOnboarding = true + print("[SplashScreen] ⚠️ No metadata found, navigating to Onboarding Default") + } + } else { + // No session - this is a new user or logged out user + // They should see WelcomeView to start onboarding + print("[SplashScreen] No session found, showing WelcomeView") + } + + isCheckingLaunchState = false + // Do NOT auto-sign-in here; login should only happen when + // the user explicitly chooses a provider or taps "Sign-in later". + } + // If a session restores slightly after first frame, reactively + // navigate to Home for returning users (non-first launch). +// .onChange(of: authController.signInState) { _, newValue in +// if !isFirstLaunch && newValue == .signedIn { +// shouldNavigateToHome = true +// } +// } + } +} + +#Preview { + SplashScreen() + .environment(AuthController()) + .environment(FamilyStore()) +} diff --git a/IngrediCheck/Views/Splash Screen/WelcomeView.swift b/IngrediCheck/Views/Splash Screen/WelcomeView.swift new file mode 100644 index 00000000..271879d3 --- /dev/null +++ b/IngrediCheck/Views/Splash Screen/WelcomeView.swift @@ -0,0 +1,118 @@ +// +// FirstLaunchWelcomeView.swift +// IngrediCheckPreview +// +// Marketing carousel shown on first app launch +// + +import SwiftUI +import RiveRuntime + +struct WelcomeView: View { + @State private var isFillingComplete: Bool = false + let onGetStarted: () -> Void + + var body: some View { + GeometryReader { geometry in + VStack(spacing: 0) { + + // FillingPipeLine at top + FillingPipeLine(onComplete: { + isFillingComplete = true + }) + .padding(.horizontal, 20) + .padding(.bottom, 32) + .zIndex(1) + + // Central illustration - Rive animation + RiveViewModel(fileName: "ingridecheck") + .view() + .scaleEffect(1.3) +// .aspectRatio(contentMode: .fill) +// .padding(.bottom, 46) + + Spacer() + + // Get Started button + if isFillingComplete { + Button { + onGetStarted() + } label: { + GreenCapsule(title: "Get Started") + } + .padding(.horizontal, 20) + .padding(.bottom, 32) + .transition(.scale.combined(with: .opacity)) + .zIndex(2) + } else { + Button { + // Disabled - do nothing + } label: { + HStack(spacing: 8) { + Text("Get Started") + .font(NunitoFont.semiBold.size(16)) + .foregroundStyle(.grayScale80) + } + .frame(maxWidth: .infinity) + .frame(height: 52) + .background( + Capsule() + .fill(.grayScale30) + ) + } + .disabled(true) + .padding(.horizontal, 20) + .padding(.bottom, 32) + .transition(.scale.combined(with: .opacity)) + .zIndex(2) + } + + LegalDisclaimerView() + + } + .padding(.horizontal, 20) + } + .animation(.spring(response: 0.5, dampingFraction: 0.7), value: isFillingComplete) + } +} + +struct FillingPipeLine: View { + @State private var progress: CGFloat = 0 + @State private var shimmerOffset: CGFloat = -1 + let onComplete: () -> Void + + var body: some View { + GeometryReader { geo in + ZStack(alignment: .leading) { + // 1️⃣ Empty pipe (track) + RoundedRectangle(cornerRadius: 2) + .stroke(Color(hex:"#EEEEEE"), lineWidth: 1) + + // 2️⃣ Filling layer + RoundedRectangle(cornerRadius: 2) + .fill(Color(hex:"#D3D3D3")) + .frame(width: geo.size.width) + .scaleEffect(x: progress, y: 1, anchor: .leading) + } + } + .frame(height: 4) + .onAppear { + withAnimation( + .linear(duration: 18) + ) { + progress = 1 + } + // Trigger completion after animation duration + Task { + try? await Task.sleep(nanoseconds: UInt64(15 * 1_000_000_000)) + await MainActor.run { + onComplete() + } + } + } + } +} + +#Preview { + WelcomeView(onGetStarted: {}) +} diff --git a/IngrediCheck/Views/Tabs/CheckTab.swift b/IngrediCheck/Views/Tabs/CheckTab.swift index bf2effb1..469eb0da 100644 --- a/IngrediCheck/Views/Tabs/CheckTab.swift +++ b/IngrediCheck/Views/Tabs/CheckTab.swift @@ -1,10 +1,8 @@ import SwiftUI +import os struct ProductImage: Hashable { let image: UIImage - let ocrTask: Task - let uploadTask: Task - let barcodeDetectionTask: Task } struct CapturedBarcode: Hashable { @@ -29,7 +27,7 @@ struct CapturedBarcode: Hashable { enum CapturedItem: Hashable { case barcode(CapturedBarcode) - case productImages([ProductImage]) + case productImages(String) // scanId for photo scans } struct CheckTab: View { @@ -42,7 +40,7 @@ struct CheckTab: View { Spacer() } .sheet(item: $checkTabState.feedbackConfig) { feedbackConfig in - let _ = print("Activating feedback sheet") + let _ = Log.debug("CheckTab", "Activating feedback sheet") FeedbackView( feedbackData: feedbackConfig.feedbackData, feedbackCaptureOptions: feedbackConfig.feedbackCaptureOptions, @@ -52,8 +50,8 @@ struct CheckTab: View { .environment(checkTabState) .navigationDestination(for: CapturedItem.self) { item in switch item { - case .productImages(let productImages): - LabelAnalysisView(productImages: productImages) + case .productImages(let scanId): + LabelAnalysisView(scanId: scanId) .environment(checkTabState) case .barcode(let capturedBarcode): BarcodeAnalysisView(barcode: capturedBarcode.barcode, viewModel: capturedBarcode.viewModel) @@ -61,6 +59,7 @@ struct CheckTab: View { } } } + .tint(Color(hex: "#303030")) } } diff --git a/IngrediCheck/Views/Tabs/HomeTab.swift b/IngrediCheck/Views/Tabs/HomeTab.swift index 4e7957b0..aeaeac41 100644 --- a/IngrediCheck/Views/Tabs/HomeTab.swift +++ b/IngrediCheck/Views/Tabs/HomeTab.swift @@ -24,29 +24,32 @@ enum ValidationResult { @Environment(AppState.self) var appState @Environment(WebService.self) var webService @Environment(DietaryPreferences.self) var dp + @Environment(UserPreferences.self) var userPreferences + @Environment(MemojiStore.self) var memojiStore + @Environment(AppNavigationCoordinator.self) var coordinator var body: some View { - NavigationStack { - VStack { - textInputField - if dp.preferences.isEmpty && !isFocused { - EmptyPreferencesView() - } else { - preferenceListView - } - } - .onAppear { - dp.refreshPreferences() + // Note: NavigationStack is provided by LoggedInRootView (Single Root NavigationStack) + VStack { + textInputField + if dp.preferences.isEmpty && !isFocused { + EmptyPreferencesView() + } else { + preferenceListView } - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - settingsButton - } + } + .onAppear { + dp.refreshPreferences() + } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + settingsButton } - .animation(.linear, value: isFocused) - .navigationBarTitleDisplayMode(.inline) - .navigationTitle("Your Dietary Preferences") } + .animation(.linear, value: isFocused) + .navigationBarTitleDisplayMode(.inline) + .navigationTitle("Your Dietary Preferences") + .dismissKeyboardOnTap() } private var validationStatus: some View { @@ -70,11 +73,11 @@ enum ValidationResult { } private var settingsButton: some View { - Button(action: { - appState.activeSheet = .settings - }, label: { + Button { + appState.navigate(to: .settings) + } label: { Image(systemName: "gearshape") - }) + } } private var textInputField: some View { diff --git a/IngrediCheck/Views/Tabs/ListsTab.swift b/IngrediCheck/Views/Tabs/ListsTab.swift index b3388e42..fa1dc96b 100644 --- a/IngrediCheck/Views/Tabs/ListsTab.swift +++ b/IngrediCheck/Views/Tabs/ListsTab.swift @@ -1,35 +1,24 @@ import SwiftUI import Combine import SimpleToast +import os @MainActor struct ListsTab: View { - + @State private var isSearching: Bool = false @Environment(AppState.self) var appState @Environment(WebService.self) var webService + @Environment(ScanHistoryStore.self) var scanHistoryStore var body: some View { - @Bindable var appState = appState - NavigationStack(path: $appState.listsTabState.routes) { - Group { - if isSearching { - ScanHistorySearchingView(webService: webService, isSearching: $isSearching) - } else { - defaultView - } - } - .navigationDestination(for: HistoryRouteItem.self) { item in - switch item { - case .historyItem(let item): - HistoryItemDetailView(item: item) - case .listItem(let item): - FavoriteItemDetailView(item: item) - case .favoritesAll: - FavoritesPageView() - case .recentScansAll: - RecentScansPageView() - } + // Note: NavigationStack is provided by LoggedInRootView (Single Root NavigationStack) + // HistoryRouteItem navigation is registered at LoggedInRootView level + Group { + if isSearching { + ScanHistorySearchingView(webService: webService, scanHistoryStore: scanHistoryStore, isSearching: $isSearching) + } else { + defaultView } } .animation(.default, value: isSearching) @@ -48,8 +37,8 @@ import SimpleToast .navigationBarTitle("Lists") .toolbar { Group { - if let historyItems = appState.listsTabState.historyItems, - historyItems.count > 4 { + if let scans = appState.listsTabState.scans, + scans.count > 4 { ToolbarItem(placement: .topBarTrailing) { Button(action: { isSearching = true @@ -97,6 +86,7 @@ import SimpleToast } .navigationBarTitleDisplayMode(.inline) .navigationBarTitle("Favorites") + .background(Color.pageBackground) } } @@ -176,56 +166,198 @@ import SimpleToast @MainActor struct RecentScansPageView: View { @State private var isSearching: Bool = false + @State private var selectedFilter: RecentScansFilter = .all @Environment(AppState.self) var appState @Environment(WebService.self) var webService + @Environment(ScanHistoryStore.self) var scanHistoryStore + @Environment(\.dismiss) var dismiss var body: some View { Group { if isSearching { - ScanHistorySearchingView(webService: webService, isSearching: $isSearching) + ScanHistorySearchingView(webService: webService, scanHistoryStore: scanHistoryStore, isSearching: $isSearching) .navigationBarBackButtonHidden() } else { defaultView } } + .background(Color.pageBackground) } - + + private var filteredScans: [DTO.Scan] { + guard let scans = appState.listsTabState.scans else { return [] } + switch selectedFilter { + case .all: + return scans + case .favorites: + return scans.filter { $0.is_favorited == true } + } + } + var defaultView: some View { Group { - if let historyItems = appState.listsTabState.historyItems { - RecentScansListView(historyItems: historyItems) - .refreshable { - if let history = try? await webService.fetchHistory() { - appState.listsTabState.historyItems = history + if let scans = appState.listsTabState.scans, !scans.isEmpty { + if filteredScans.isEmpty { + // Scans exist but filter returns empty (e.g., no favorites) + EmptyStateView( + imageName: "history-emptystate", + title: "No Favorites Yet!", + description: [ + "Products you favorite will appear here.", + "Tap the heart icon on any scan to save it." + ], + buttonTitle: nil, + buttonAction: nil + ) + .frame(maxWidth: .infinity) + } else { + ScrollView { + LazyVStack(spacing: 12) { + ForEach(Array(filteredScans.enumerated()), id: \.element.id) { index, scan in + NavigationLink(value: HistoryRouteItem.scan(scan)) { + RecentScanCard( + scan: scan, + onFavoriteToggle: { scanId, isFavorited in + handleFavoriteToggle(scanId: scanId, isFavorited: isFavorited) + }, + onScanUpdated: { updatedScan in + handleScanUpdated(updatedScan: updatedScan) + } + ) + } + .buttonStyle(.plain) + .onAppear { + // Load more when reaching the end (3 rows remaining) + if index >= filteredScans.count - 3 { + Task { + await scanHistoryStore.loadMore() + appState.listsTabState.scans = scanHistoryStore.scans + } + } + } + } + + // Loading indicator at the bottom + if scanHistoryStore.isLoading && scanHistoryStore.hasMore { + ProgressView() + .frame(maxWidth: .infinity) + .padding(.vertical, 20) + } } + .padding(.horizontal, 20) + .padding(.top, 12) + .padding(.bottom, 20) + } + .scrollIndicators(.hidden) + .refreshable { + Log.debug("RecentScansPageView", "Pull-to-refresh triggered") + await scanHistoryStore.loadHistory(limit: 20, offset: 0, forceRefresh: true) + appState.listsTabState.scans = scanHistoryStore.scans + } + } + } else if scanHistoryStore.isLoading { + VStack { + Spacer() + ProgressView("Loading scans...") + Spacer() + } + .frame(maxWidth: .infinity) + } else { + EmptyStateView( + imageName: "history-emptystate", + title: "No Scans !", + description: [ + "Your recent scans will appear here once", + "you start scanning products." + ], + buttonTitle: "Start Scanning", + buttonAction: { + appState.navigate(to: .scanCamera(initialMode: nil, initialScanId: nil)) } - .padding(.top) + ) + .frame(maxWidth: .infinity) } } - .padding(.horizontal) - .navigationBarTitleDisplayMode(.inline) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .navigationTitle("Recent Scans") + .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { - Button(action: { - isSearching = true - }, label: { - Image(systemName: "magnifyingglass") - }) + FilterSegmentedControl(selection: $selectedFilter) + } + } + .task { + if appState.listsTabState.scans == nil || appState.listsTabState.scans?.isEmpty == true { + if scanHistoryStore.isLoading { + while scanHistoryStore.isLoading { + try? await Task.sleep(nanoseconds: 100_000_000) + } + } else if scanHistoryStore.scans.isEmpty { + await scanHistoryStore.loadHistory(limit: 20, offset: 0) + } + await MainActor.run { + appState.listsTabState.scans = scanHistoryStore.scans + } + } + } + } + + // MARK: - Actions + + private func handleFavoriteToggle(scanId: String, isFavorited: Bool) { + // Update in store + let scan = appState.listsTabState.scans?.first { $0.id == scanId } + if let scan = scan { + let updatedScan = DTO.Scan( + id: scan.id, + scan_type: scan.scan_type, + barcode: scan.barcode, + state: scan.state, + product_info: scan.product_info, + product_info_source: scan.product_info_source, + product_info_vote: scan.product_info_vote, + analysis_result: scan.analysis_result, + images: scan.images, + latest_guidance: scan.latest_guidance, + created_at: scan.created_at, + last_activity_at: scan.last_activity_at, + is_favorited: isFavorited, + analysis_id: scan.analysis_id + ) + scanHistoryStore.upsertScan(updatedScan) + + // Sync to AppState + if var scans = appState.listsTabState.scans, + let idx = scans.firstIndex(where: { $0.id == scanId }) { + scans[idx] = updatedScan + appState.listsTabState.scans = scans } } } + + private func handleScanUpdated(updatedScan: DTO.Scan) { + // Update scan in store + scanHistoryStore.upsertScan(updatedScan) + + // Sync to AppState + if var scans = appState.listsTabState.scans, + let idx = scans.firstIndex(where: { $0.id == updatedScan.id }) { + scans[idx] = updatedScan + appState.listsTabState.scans = scans + } + } } @MainActor struct RecentScansView: View { @Environment(AppState.self) var appState @Environment(WebService.self) var webService + @Environment(ScanHistoryStore.self) var scanHistoryStore var showViewAll: Bool { - if let historyItems = appState.listsTabState.historyItems { - return historyItems.count > 4 + if let scans = appState.listsTabState.scans { + return scans.count > 4 } return false } @@ -243,17 +375,20 @@ import SimpleToast } } .padding(.bottom) - - if let historyItems = appState.listsTabState.historyItems { - RecentScansListView(historyItems: historyItems) + + if let scans = appState.listsTabState.scans { + RecentScansListView(scans: scans) .frame(maxWidth: .infinity) .refreshable { - if let history = try? await webService.fetchHistory() { - appState.listsTabState.historyItems = history - } + NSLog("[RecentScansView] πŸ”„ Pull-to-refresh triggered") + // Load via store (single source of truth) + await scanHistoryStore.loadHistory(limit: 20, offset: 0, forceRefresh: true) + NSLog("[RecentScansView] βœ… loadHistory completed") + // Sync to AppState for backwards compatibility + appState.listsTabState.scans = scanHistoryStore.scans } .overlay { - if historyItems.isEmpty { + if scans.isEmpty { VStack { Spacer() Image("EmptyRecentScans") @@ -282,31 +417,74 @@ import SimpleToast } @MainActor struct RecentScansListView: View { - - var historyItems: [DTO.HistoryItem] + + var scans: [DTO.Scan] + @Environment(ScanHistoryStore.self) var scanHistoryStore + @Environment(AppState.self) var appState var body: some View { - ScrollView { - VStack(spacing: 10) { - ForEach(Array(historyItems.enumerated()), id: \.element.client_activity_id) { index, item in - NavigationLink(value: HistoryRouteItem.historyItem(item)) { - HistoryItemCardView(item: item) + Group { + if scans.isEmpty { + EmptyStateView( + imageName: "history-emptystate", + title: "No Scans !", + description: [ + "Your recent scans will appear here once", + "you start scanning products." + ], + buttonTitle: "Start Scanning", + buttonAction: { + appState.navigate(to: .scanCamera(initialMode: nil, initialScanId: nil)) } - .foregroundStyle(.primary) + ) + } else { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(Array(scans.enumerated()), id: \.element.id) { index, scan in + NavigationLink(value: HistoryRouteItem.scan(scan)) { + ScanRow(scan: scan) + } + .foregroundStyle(.primary) + .onAppear { + // Load more when reaching the end (3 rows remaining) + if index >= scans.count - 3 { + Task { + await scanHistoryStore.loadMore() + // Sync to AppState for backwards compatibility + appState.listsTabState.scans = scanHistoryStore.scans + } + } + } - if index != historyItems.count - 1 { - Divider() + if index != scans.count - 1 { + Divider() + .padding(.vertical, 14) + } + } + + // Loading indicator at the bottom + if scanHistoryStore.isLoading && scanHistoryStore.hasMore { + ProgressView() + .frame(maxWidth: .infinity) + .padding(.vertical, 20) + } } } + .scrollIndicators(.hidden) } } - .scrollIndicators(.hidden) } } +//#Preview { +// RecentScansListView() +// .environment(AppState()) +//} + @Observable @MainActor class ScanHistorySearchingViewModel { let webService: WebService + let scanHistoryStore: ScanHistoryStore var searchText: String = "" { didSet { @@ -314,13 +492,14 @@ import SimpleToast } } - var searchResults: [DTO.HistoryItem] = [] + var searchResults: [DTO.Scan] = [] private var searchTextSubject = PassthroughSubject() private var cancellables = Set() - init(webService: WebService) { + init(webService: WebService, scanHistoryStore: ScanHistoryStore) { self.webService = webService + self.scanHistoryStore = scanHistoryStore searchTextSubject .debounce(for: .milliseconds(500), scheduler: RunLoop.main) .sink { [weak self] text in @@ -338,10 +517,18 @@ import SimpleToast } Task { - print("Searching for \(searchText)") - let newSearchResults = try await webService.fetchHistory(searchText: searchText) + Log.debug("ListsTab", "Searching for \(searchText)") + // Load from store and filter client-side + await scanHistoryStore.loadHistory(limit: 100, offset: 0, forceRefresh: true) + + let filtered = scanHistoryStore.scans.filter { scan in + let name = scan.product_info.name?.lowercased() ?? "" + let brand = scan.product_info.brand?.lowercased() ?? "" + let query = searchText.lowercased() + return name.contains(query) || brand.contains(query) + } await MainActor.run { - self.searchResults = newSearchResults + self.searchResults = filtered } } } @@ -358,9 +545,9 @@ import SimpleToast @State private var vm: ScanHistorySearchingViewModel @Environment(AppState.self) var appState - - init(webService: WebService, isSearching: Binding) { - _vm = State(initialValue: ScanHistorySearchingViewModel(webService: webService)) + + init(webService: WebService, scanHistoryStore: ScanHistoryStore, isSearching: Binding) { + _vm = State(initialValue: ScanHistorySearchingViewModel(webService: webService, scanHistoryStore: scanHistoryStore)) _isSearching = isSearching } @@ -370,9 +557,9 @@ import SimpleToast .padding(.bottom) ScrollView { VStack(spacing: 10) { - ForEach(Array(vm.searchResults.enumerated()), id: \.element.client_activity_id) { index, item in - NavigationLink(value: HistoryRouteItem.historyItem(item)) { - HistoryItemCardView(item: item) + ForEach(Array(vm.searchResults.enumerated()), id: \.element.id) { index, scan in + NavigationLink(value: HistoryRouteItem.scan(scan)) { + ScanRow(scan: scan) } .foregroundStyle(.primary) @@ -510,7 +697,7 @@ struct HistoryItemDetailView: View { ) } if item.ingredients.isEmpty { - Text("Help! Our Product Database is missing an Ingredient List for this Product. Submit Product Images and Earn IngrediPoiints\u{00A9}!") + Text("Help! Our Product Database is missing an Ingredient List for this Product. Submit Product Images and Earn IngrediPoints\u{00A9}!") .font(.subheadline) .padding() .multilineTextAlignment(.center) @@ -529,7 +716,8 @@ struct HistoryItemDetailView: View { brand: item.brand, name: item.name, ingredients: item.ingredients, - images: item.images + images: item.images, + claims: nil ) AnalysisResultView(product: product, ingredientRecommendations: item.ingredient_recommendations) @@ -584,6 +772,129 @@ struct HistoryItemDetailView: View { } } +struct ScanDetailView: View { + let scan: DTO.Scan + + @State private var feedbackData = FeedbackData() + @State private var showToast: Bool = false + + @Environment(WebService.self) var webService + @Environment(AppState.self) var appState + @Environment(UserPreferences.self) var userPreferences + + private func submitFeedback() { + Task { + // Note: clientActivityId is not available in Scan, so feedback submission would need scan.id + // For now, leaving as placeholder + Log.debug("ListsTab", "Feedback submission not yet implemented for Scan") + } + } + + var body: some View { + @Bindable var userPreferencesBindable = userPreferences + let product = scan.toProduct() + let imageLocations: [DTO.ImageLocationInfo] = scan.product_info.images?.compactMap { scanImageInfo in + guard let urlString = scanImageInfo.url, + let url = URL(string: urlString) else { + return nil + } + return .url(url) + } ?? [] + + ScrollView { + VStack(spacing: 15) { + if let name = scan.product_info.name { + Text(name) + .font(.headline) + .lineLimit(1) + .truncationMode(.tail) + .padding(.horizontal) + } + if let brand = scan.product_info.brand { + Text(brand) + .lineLimit(1) + .truncationMode(.tail) + .padding(.horizontal) + } + ProductImagesView(images: imageLocations) { + appState.feedbackConfig = FeedbackConfig( + feedbackData: $feedbackData, + feedbackCaptureOptions: .imagesOnly, + onSubmit: { + showToast.toggle() + submitFeedback() + } + ) + } + if scan.product_info.ingredients.isEmpty { + Text("Help! Our Product Database is missing an Ingredient List for this Product. Submit Product Images and Earn IngrediPoints\u{00A9}!") + .font(.subheadline) + .padding() + .multilineTextAlignment(.center) + Button(action: { + userPreferencesBindable.captureType = .ingredients + appState.activeSheet = .scan + }, label: { + Image(systemName: "photo.badge.plus") + .font(.largeTitle) + }) + Text("Product will be analyzed instantly!") + .font(.subheadline) + } else { + let recommendations = scan.analysis_result?.toIngredientRecommendations() + AnalysisResultView(product: product, ingredientRecommendations: recommendations) + + HStack { + Text("Ingredients").font(.headline) + Spacer() + } + .padding(.horizontal) + + IngredientsText(ingredients: scan.product_info.ingredients, ingredientRecommendations: recommendations) + .padding(.horizontal) + } + } + } + .scrollIndicators(.hidden) + .simpleToast(isPresented: $showToast, options: SimpleToastOptions(hideAfter: 3)) { + FeedbackSuccessToastView() + } + .toolbar { + ToolbarItemGroup(placement: .topBarTrailing) { + if !imageLocations.isEmpty && !scan.product_info.ingredients.isEmpty { + Button(action: { + appState.feedbackConfig = FeedbackConfig( + feedbackData: $feedbackData, + feedbackCaptureOptions: .imagesOnly, + onSubmit: { + showToast.toggle() + submitFeedback() + } + ) + }, label: { + Image(systemName: "photo.badge.plus") + .font(.subheadline) + }) + } + // Note: StarButton needs clientActivityId which is not in Scan - would need to be handled differently + Button(action: { + appState.feedbackConfig = FeedbackConfig( + feedbackData: $feedbackData, + feedbackCaptureOptions: .feedbackAndImages, + onSubmit: { + showToast.toggle() + submitFeedback() + } + ) + }, label: { + Image(systemName: "flag") + .font(.subheadline) + }) + } + } + } +} + struct FavoriteItemCardView: View { let item: DTO.ListItem @@ -710,7 +1021,8 @@ struct FavoriteItemDetailView: View { brand: item.brand, name: item.name, ingredients: item.ingredients, - images: item.images + images: item.images, + claims: nil ) HStack { diff --git a/IngrediCheck/Views/Tabs/LoggedInRootView.swift b/IngrediCheck/Views/Tabs/LoggedInRootView.swift index 0c5f6991..7a377b99 100644 --- a/IngrediCheck/Views/Tabs/LoggedInRootView.swift +++ b/IngrediCheck/Views/Tabs/LoggedInRootView.swift @@ -1,12 +1,13 @@ import SwiftUI +import os enum TabScreen { case home case lists } -enum Sheets: Identifiable { +enum Sheets: Identifiable, Equatable { case settings case scan @@ -24,11 +25,12 @@ enum Sheets: Identifiable { @Observable class CheckTabState { var routes: [CapturedItem] = [] var capturedImages: [ProductImage] = [] + var scanId: String? // For photo scans - generated when first image is captured var feedbackConfig: FeedbackConfig? } enum HistoryRouteItem: Hashable { - case historyItem(DTO.HistoryItem) + case scan(DTO.Scan) case listItem(DTO.ListItem) case favoritesAll case recentScansAll @@ -36,7 +38,7 @@ enum HistoryRouteItem: Hashable { struct ListsTabState { var routes: [HistoryRouteItem] = [] - var historyItems: [DTO.HistoryItem]? = nil + var scans: [DTO.Scan]? = nil var listItems: [DTO.ListItem]? = nil } @@ -45,50 +47,192 @@ struct ListsTabState { @MainActor var activeTab: TabScreen = .home @MainActor var listsTabState = ListsTabState() @MainActor var feedbackConfig: FeedbackConfig? + @MainActor var navigateToSettings: Bool = false + + // MARK: - Single Root NavigationStack + + /// The unified navigation path for the entire app. + /// All navigation flows through this path via `navigate(to:)`. + @MainActor var navigationPath = NavigationPath() + + /// Tracks the current navigation route for FAB visibility and context. + /// Updated when navigating via `navigate(to:)`. + @MainActor var currentRoute: AppRoute? = nil + + /// ScanId to scroll to when returning to ScanCameraView (e.g., from ProductDetail "Add Image") + @MainActor var scrollToScanId: String? + + /// Tracks whether ScanCameraView is currently in the navigation stack. + /// Used by ProductDetailView to decide whether to pop back or push new camera. + @MainActor var hasCameraInStack: Bool = false + + /// Tracks whether ScanCameraView is currently the visible/active view. + /// Set by ScanCameraView on appear/disappear. Used by AIBot FAB visibility logic. + @MainActor var isInScanCameraView: Bool = false + + /// The currently displayed scan ID (set by ProductDetailView on appear). + /// Used by AIBot FAB to provide context regardless of navigation method. + @MainActor var displayedScanId: String? = nil + + /// The currently displayed analysis ID (set by ProductDetailView on appear). + /// Used by AIBot FAB to provide context for analysis feedback. + @MainActor var displayedAnalysisId: String? = nil + + /// Flag to trigger scan history refresh from other views (e.g., after food preferences change). + /// Set to true by views that modify data affecting scan results, observed by HomeView. + @MainActor var needsScanHistoryRefresh: Bool = false + + /// Navigate to a route by pushing it onto the navigation stack. + @MainActor func navigate(to route: AppRoute) { + currentRoute = route + navigationPath.append(route) + } + + /// Pop the top route from the navigation stack. + @MainActor func navigateBack() { + if !navigationPath.isEmpty { + navigationPath.removeLast() + } + if navigationPath.isEmpty { + currentRoute = nil + } + } + + /// Pop all routes, returning to the root view. + @MainActor func navigateToRoot() { + navigationPath = NavigationPath() + currentRoute = nil + } + + @MainActor func setHistoryItemFavorited(clientActivityId: String, favorited: Bool) { + // Legacy function for backwards compatibility with old HistoryItem API + // The new API uses scans with id instead of clientActivityId + // Try to find matching scan by id (clientActivityId might be a scan id) + guard var scans = listsTabState.scans else { return } + guard let idx = scans.firstIndex(where: { $0.id == clientActivityId }) else { + // If not found, this might be a legacy HistoryItem - no-op for now + // The new API handles favorites via toggleFavorite(scanId:) in WebService + return + } + + // Update the scan's is_favorited field + var updatedScan = scans[idx] + // Since is_favorited is let, we need to create a new Scan with updated value + let newScan = DTO.Scan( + id: updatedScan.id, + scan_type: updatedScan.scan_type, + barcode: updatedScan.barcode, + state: updatedScan.state, + product_info: updatedScan.product_info, + product_info_source: updatedScan.product_info_source, + product_info_vote: updatedScan.product_info_vote, + analysis_result: updatedScan.analysis_result, + images: updatedScan.images, + latest_guidance: updatedScan.latest_guidance, + created_at: updatedScan.created_at, + last_activity_at: updatedScan.last_activity_at, + is_favorited: favorited, + analysis_id: updatedScan.analysis_id + ) + scans[idx] = newScan + listsTabState.scans = scans + } } @MainActor struct LoggedInRootView: View { @Environment(WebService.self) var webService + @Environment(ScanHistoryStore.self) var scanHistoryStore @Environment(AppState.self) var appState @Environment(UserPreferences.self) var userPreferences @Environment(DietaryPreferences.self) var dietaryPreferences + @Environment(AppNavigationCoordinator.self) var coordinator + @Environment(MemojiStore.self) var memojiStore + @State private var lastPresentedSheet: Sheets? = nil + + // Provide Onboarding state object for PersistentBottomSheet and other consumers + @StateObject private var onboarding = Onboarding(onboardingFlowtype: .individual) var body: some View { @Bindable var appState = appState - VStack { - switch (appState.activeTab) { - case .home: - HomeTab() - case .lists: - ListsTab() + @Bindable var coordinator = coordinator + + NavigationStack(path: $appState.navigationPath) { + ZStack(alignment: .bottom) { + VStack { + switch (appState.activeTab) { + case .home: + HomeTab() + case .lists: + ListsTab() + } + } + .tabViewStyle(PageTabViewStyle()) + .toolbar { + ToolbarItemGroup(placement: .bottomBar) { + tabButtons + } + } + + // Dim background when certain sheets are presented (e.g., Invite) + Group { + switch coordinator.currentBottomSheetRoute { + case .wouldYouLikeToInvite(_, _): + Color.black.opacity(0.45) + .ignoresSafeArea() + .allowsHitTesting(false) + .transition(.opacity) + default: + EmptyView() + } + } + + PersistentBottomSheet() + } + .navigationDestination(for: AppRoute.self) { route in + destinationView(for: route) + } + .navigationDestination(for: HistoryRouteItem.self) { item in + historyDestinationView(for: item) } } - .tabViewStyle(PageTabViewStyle()) - .toolbar { - ToolbarItemGroup(placement: .bottomBar) { - tabButtons + .overlay(alignment: .bottomTrailing) { + if shouldShowAIBotFAB { + AIBotFAB( + onTap: { presentAIBotWithContext() }, + showPromptBubble: coordinator.showFeedbackPromptBubble, + onPromptTap: { coordinator.dismissFeedbackPrompt(openChat: true) }, + onPromptDismiss: { coordinator.dismissFeedbackPrompt(openChat: false) } + ) + .padding(.trailing, 20) +// .padding(.bottom, 100) + .transition(.scale.combined(with: .opacity)) } } + .animation(.easeInOut(duration: 0.25), value: shouldShowAIBotFAB) + .animation(.spring(response: 0.4, dampingFraction: 0.7), value: coordinator.showFeedbackPromptBubble) + .tint(Color(hex: "#303030")) + .environment(coordinator) + .environmentObject(onboarding) .onAppear { - if userPreferences.startScanningOnAppStart - && - !dietaryPreferences.preferences.isEmpty { - appState.activeSheet = .scan - } + // Note: Auto-scan on app start is now handled in HomeView + // to open ScanCameraView directly instead of CheckTab + refreshHistory() } .sheet(item: $appState.activeSheet) { sheet in switch sheet { - case .settings: - SettingsSheet() - .environment(userPreferences) case .scan: CheckTab() .environment(userPreferences) + case .settings: + SettingsSheet() + .environment(userPreferences) + .environment(memojiStore) + .environment(coordinator) } } .sheet(item: $appState.feedbackConfig) { feedbackConfig in - let _ = print("Activating feedback sheet") + let _ = Log.debug("LoggedInRootView", "Activating feedback sheet") FeedbackView( feedbackData: feedbackConfig.feedbackData, feedbackCaptureOptions: feedbackConfig.feedbackCaptureOptions, @@ -96,6 +240,18 @@ struct ListsTabState { ) .environment(userPreferences) } + .onChange(of: appState.activeSheet) { newSheet in + // When the scan sheet is closed, refresh history so Home/Lists + // recent scans reflect the latest product immediately. + if lastPresentedSheet == .scan && newSheet == nil { + refreshHistory() + } + lastPresentedSheet = newSheet + } + .onChange(of: appState.activeTab) { _, _ in + // Reset navigation to root when switching tabs + appState.navigateToRoot() + } } @ViewBuilder @@ -117,7 +273,8 @@ struct ListsTabState { Spacer() Spacer() Button(action: { - appState.activeSheet = .scan + // Navigate to ScanCameraView via push navigation (Single Root NavigationStack) + appState.navigate(to: .scanCamera(initialMode: nil, initialScanId: nil)) }) { ZStack { Circle() @@ -156,13 +313,71 @@ struct ListsTabState { Spacer() } + // MARK: - AIBot FAB + + private var shouldShowAIBotFAB: Bool { + // Don't show on root (HomeView has its own AIBot buttons) + guard !appState.navigationPath.isEmpty else { return false } + + // Hide on camera (check if current route is scanCamera) + if let currentRoute = appState.currentRoute { + switch currentRoute { + case .scanCamera: + return false // Hide during scanning + default: + break + } + } + + // Show on all detail screens (AppRoute or HistoryRouteItem navigation) + return true + } + + private func presentAIBotWithContext() { + // Dismiss any feedback prompt bubble first and open chat with pending context + // (feedback context includes analysisId, ingredientName, feedbackId as needed) + if coordinator.showFeedbackPromptBubble { + coordinator.dismissFeedbackPrompt(openChat: true) + return + } + + // Try to get context from AppRoute navigation + // Only pass scanId for product_scan context (not analysisId - that's for feedback) + if let currentRoute = appState.currentRoute { + switch currentRoute { + case .productDetail(let scanId, _): + coordinator.showAIBotSheetWithContext(scanId: scanId) + return + default: + break + } + } + + // Fallback: Check if ProductDetailView has set displayedScanId (for HistoryRouteItem navigation) + // Only pass scanId for product_scan context + if let displayedScanId = appState.displayedScanId { + coordinator.showAIBotSheetWithContext(scanId: displayedScanId) + return + } + + // No product context available - open chat with home context + coordinator.showAIBotSheet() + } + private func refreshHistory() { + Log.debug("LoggedInRootView", "πŸ“‹ refreshHistory called") Task { - if let history = try? await webService.fetchHistory() { - await MainActor.run { - appState.listsTabState.historyItems = history - } + Log.debug("LoggedInRootView", "πŸ“‹ refreshHistory Task started, calling loadHistory") + // Load scan history via store (single source of truth) + await scanHistoryStore.loadHistory(limit: 20, offset: 0, forceRefresh: true) + Log.debug("LoggedInRootView", "πŸ“‹ refreshHistory loadHistory completed") + + // Sync to AppState for backwards compatibility + await MainActor.run { + appState.listsTabState.scans = scanHistoryStore.scans } + + // Load favorites if let listItems = try? await webService.getFavorites() { await MainActor.run { appState.listsTabState.listItems = listItems @@ -170,4 +385,91 @@ struct ListsTabState { } } } + + // MARK: - Navigation Destination Builder + + /// Builds the destination view for each AppRoute. + /// All navigated views receive proper environment objects. + @ViewBuilder + private func destinationView(for route: AppRoute) -> some View { + switch route { + case .productDetail(let scanId, let initialScan): + ProductDetailView( + scanId: scanId, + initialScan: initialScan, + presentationSource: .pushNavigation + ) + + case .scanCamera(let initialMode, let initialScanId): + ScanCameraView(initialMode: initialMode, initialScrollTarget: initialScanId, presentationSource: .pushNavigation) + .environment(userPreferences) + .environment(appState) + + case .favoritesAll: + FavoritesPageView() + .environment(appState) + + case .recentScansAll: + RecentScansPageView() + .environment(appState) + .environment(scanHistoryStore) + + case .favoriteDetail(let item): + // Show product detail for a favorite list item + ProductDetailView( + scanId: item.list_item_id, + initialScan: nil, + presentationSource: .pushNavigation + ) + + case .settings: + SettingsContentView() + .environment(userPreferences) + .environment(memojiStore) + .environment(coordinator) + + case .manageFamily: + ManageFamilyView() + .environment(coordinator) + + case .editableCanvas(let targetSection): + UnifiedCanvasView(mode: .editing, targetSectionName: targetSection) + .environment(memojiStore) + .environment(coordinator) + } + } + + // MARK: - History Navigation Destination Builder + + /// Builds the destination view for HistoryRouteItem navigation. + /// This supports legacy navigation from ListsTab and related views. + @ViewBuilder + private func historyDestinationView(for item: HistoryRouteItem) -> some View { + switch item { + case .scan(let scan): + let product = scan.toProduct() + let recommendations = scan.analysis_result?.toIngredientRecommendations() + ProductDetailView( + scanId: scan.id, + initialScan: scan, + product: product, + matchStatus: scan.toProductRecommendation(), + ingredientRecommendations: recommendations, + isPlaceholderMode: false, + presentationSource: .pushNavigation + ) + + case .listItem(let item): + FavoriteItemDetailView(item: item) + + case .favoritesAll: + FavoritesPageView() + .environment(appState) + + case .recentScansAll: + RecentScansPageView() + .environment(appState) + .environment(scanHistoryStore) + } + } } diff --git a/IngrediCheck/Views/Tabs/SettingsSheet.swift b/IngrediCheck/Views/Tabs/SettingsSheet.swift index 76742fec..849ca4ae 100644 --- a/IngrediCheck/Views/Tabs/SettingsSheet.swift +++ b/IngrediCheck/Views/Tabs/SettingsSheet.swift @@ -1,358 +1,987 @@ import SwiftUI import SimpleToast -struct SettingsSheet: View { +// Content view without NavigationStack - can be used in navigationDestination +struct SettingsContentView: View { @Environment(UserPreferences.self) var userPreferences @Environment(\.dismiss) var dismiss @Environment(AppState.self) var appState @Environment(AuthController.self) var authController + @Environment(FamilyStore.self) var familyStore + @Environment(DietaryPreferences.self) var dietaryPreferences + @Environment(\.openURL) var openURL + @Environment(AppNavigationCoordinator.self) var coordinator + @Environment(MemojiStore.self) var memojiStore + @Environment(FoodNotesStore.self) var foodNotesStore + @EnvironmentObject var onboarding: Onboarding @State private var showInternalModeToast = false @State private var internalModeToastMessage = "Internal Mode Unlocked" @Environment(WebService.self) var webService + @State private var settingsFeedbackData = FeedbackData() + @State private var showFeedbackToast = false + @State private var primaryMemberName: String = "" + @FocusState private var isEditingPrimaryName: Bool + @State private var isFeedbackPresented = false + @State private var showSignOutConfirm = false + @State private var showDeleteConfirm = false + @State private var deleteConfirmText: String = "" + @State private var showEditableCanvas: Bool = false + @State private var editTargetSectionName: String? = nil + + // Binding helper to avoid local @Bindable in body + private var startScanningOnAppStartBinding: Binding { + Binding( + get: { userPreferences.startScanningOnAppStart }, + set: { userPreferences.startScanningOnAppStart = $0 } + ) + } - var body: some View { - @Bindable var userPreferences = userPreferences - NavigationStack { - Form { - Section("Settings") { - Toggle("Start Scanning on App Start", isOn: $userPreferences.startScanningOnAppStart) + private struct FeedbackSubmittedToastView: View { + var body: some View { + HStack(spacing: 8) { + ZStack { + Circle() + .fill(.white) + .frame(width: 18, height: 18) + Image(systemName: "checkmark") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(.paletteAccent) } - Section("Account") { - if authController.signedInWithApple || authController.signedInWithGoogle { - // Provider badge - if let providerDisplay = authController.currentSignInProviderDisplay { - HStack(spacing: 10) { - if authController.signedInWithApple { - Image(systemName: "applelogo") - .foregroundStyle(.primary) - } else { - Image("google_logo") - .resizable() - .scaledToFit() - .frame(width: 18, height: 18) - } - VStack(alignment: .leading, spacing: 2) { - Text(providerDisplay.text) - .font(.footnote) - .fontWeight(.semibold) - if let email = authController.displayableEmail, !email.isEmpty { - Text(email) - .font(.caption2) - .foregroundStyle(.secondary) - } + Text("Feedback Submitted") + .font(NunitoFont.semiBold.size(12)) + .foregroundStyle(.grayScale10) + } + .padding(.horizontal, 14) + .padding(.vertical, 6) + .background(Capsule().fill(.paletteAccent)) + } + } + + var body: some View { + VStack(spacing: 0) { + // Sticky Header Section + VStack(spacing: 24) { + // Profile Image and Name Header + VStack(spacing: 8) { + ProfileCard(isProfileCompleted: true) + .frame(width: 72, height: 72) + .overlay(alignment: .bottomTrailing) { + Circle() + .fill(.white) + .frame(width: 24, height: 24) + .overlay(Image("pen-line").resizable().frame(width: 14, height: 14)) + .offset(x: -6, y: -6) + } + .contentShape(Rectangle()) + .onTapGesture { + if let me = familyStore.family?.selfMember { + familyStore.avatarTargetMemberId = me.id + memojiStore.displayName = me.name } + memojiStore.previousRouteForGenerateAvatar = .yourCurrentAvatar + coordinator.navigateInBottomSheet(.yourCurrentAvatar) + } + nameEditField() + } + } + .padding(.horizontal, 20) + .padding(.bottom, 24) // Spacing from Header to the start of scrolling content + + // Scrolling Content Section + ScrollView { + VStack(spacing: 24) { + // Account + VStack(spacing: 8) { + Text("ACCOUNT") + .font(ManropeFont.semiBold.size(14)) + .foregroundStyle(Color(hex: "#9EA19B")) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 4) + + if authController.session != nil && !authController.signedInAsGuest { + accountSignedInCard() + } else { + accountGuestCard() + } + } + + // Settings + VStack(spacing: 8) { + Text("SETTINGS") + .font(ManropeFont.semiBold.size(14)) + .foregroundStyle(Color(hex: "#9EA19B")) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 4) + sectionCard { + HStack { + Text("Start Scanning on App Start") + .font(NunitoFont.medium.size(16)) + .foregroundStyle(.grayScale150) Spacer() - - // Provider-aware Sign out - SignoutButton() + Toggle("", isOn: startScanningOnAppStartBinding) + .labelsHidden() } - .padding(.vertical, 2) + .padding( 16) } + + sectionCard { + VStack(spacing: 0) { + // Conditional title based on family existence AND other members + // Show "Manage Family" if family exists AND has other members + // Show "Create Family" if no family OR family is just "Just Me" (no other members) + let hasExistingFamily = familyStore.family != nil && !familyStore.family!.otherMembers.isEmpty - // Danger Zone - VStack(spacing: 8) { - Text("Danger Zone") - .font(.caption) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - DeleteAccountView(labelText: "Delete Data & Account") + // Use standard NavigationLink for both cases - ManageFamilyView handles both + NavigationLink { + ManageFamilyView() + .environment(coordinator) + .onAppear { + // Set flag when creating family from settings (no existing family members) + if !hasExistingFamily { + coordinator.isCreatingFamilyFromSettings = true + } + } + } label: { + rowContent( + image: Image("create-family-icon"), + title: hasExistingFamily ? "Manage Family" : "Create Family", + iconColor: Color(hex: "#75990E") + ) + } + .buttonStyle(.plain) + Divider() + .padding(.horizontal, 16) + settingsRow(icon: "Pen-Line-2", title: "Food Notes", iconColor: Color(hex: "#75990E")) { + editTargetSectionName = nil + showEditableCanvas = true + } + } } - .padding(.top, 6) - } else if authController.signedInAsGuest { - AccountUpgradeView() - DeleteAccountView(labelText: "Reset App State") - } else { - Text("Sign in to manage your account.") - .font(.footnote) - .foregroundStyle(.secondary) } - } - Section("About") { - NavigationLink(value: URL(string: "https://www.ingredicheck.app/about")!) { - Label { - Text("About me") - } icon: { - Image(systemName: "person.circle") + + // About + VStack(spacing: 8) { + Text("ABOUT") + .font(ManropeFont.semiBold.size(14)) + .foregroundStyle(Color(hex: "#9EA19B")) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 4) + + sectionCard { + NavigationLink { + WebView(url: URL(string: "https://www.ingredicheck.app/about")!) + } label: { + rowContent(image: Image("About-Me"), title: "About me") + } + .buttonStyle(.plain) } } - NavigationLink { - TipJarView() - } label: { - Label { - Text("Tip Jar") - } icon: { - Image(systemName: "heart") + // Support us + VStack(spacing: 8) { + Text("SUPPORT US") + .font(ManropeFont.semiBold.size(14)) + .foregroundStyle(Color(hex: "#9EA19B")) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 4) + + sectionCard { + Button { + isFeedbackPresented = true + } label: { rowContent(image: Image("Feedback-icon"), title: "Feedback") } + .buttonStyle(.plain) + Divider() + .padding(.horizontal, 16) + ShareLink(item: URL(string: "https://apps.apple.com/us/app/ingredicheck-grocery-scanner/id6477521615")!, subject: Text("IngrediCheck"), message: Text("Check out IngrediCheck, it helps you and your family check food ingredients easily!")) { + rowContent(image: Image("share") , title: "Share us", iconColor: Color(hex: "#75990E")) + } + .buttonStyle(.plain) + Divider() + .padding(.horizontal, 16) + NavigationLink { TipJarView() } label: { rowContent(image: Image("Tip-Jar-icon"), title: "Tip Jar") } + .buttonStyle(.plain) } } - - NavigationLink(value: URL(string: "https://www.ingredicheck.app/about")!) { - Label { - Text("Help") - } icon: { - Image(systemName: "questionmark.circle") + // Others + VStack(spacing: 8) { + Text("OTHERS") + .font(ManropeFont.semiBold.size(14)) + .foregroundStyle(Color(hex: "#9EA19B")) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 4) + + sectionCard { + NavigationLink { + WebView(url: URL(string: "https://www.ingredicheck.app/about")!) + } label: { rowContent(image: Image("Help-icon"), title: "Help") } + .buttonStyle(.plain) + Divider() + .padding(.horizontal, 16) + NavigationLink { + WebView(url: URL(string: "https://www.ingredicheck.app/terms-conditions")!) + } label: { rowContent(image: Image("Terms-of-use"), title: "Terms of use") } + .buttonStyle(.plain) + Divider() + .padding(.horizontal, 16) + NavigationLink { + WebView(url: URL(string: "https://www.ingredicheck.app/privacy-policy")!) + } label: { rowContent(image: Image("Privacy-polices"), title: "Privacy policy") } + .buttonStyle(.plain) + Divider() + .padding(.horizontal, 16) + if authController.isInternalUser { + rowContent(image: Image("Internal-Mode"), title: "Internal Mode Enabled", showChevron: false) + Divider() + .padding(.horizontal, 16) + } + Button { + Task { + do { + _ = try await webService.markDeviceInternal(deviceId: authController.deviceId) + await MainActor.run { + authController.setInternalUser(true) + internalModeToastMessage = "Internal Mode Unlocked" + showInternalModeToast = false + showInternalModeToast = true + } + } catch { + print("Failed to mark device internal: \(error)") + } + } + } label: { rowContent(image: Image("LogoGreenv2"), title: "IngrediCheck for iOS \(appVersion).(\(buildNumber))", showChevron: false) } + .buttonStyle(.plain) } } - NavigationLink(value: URL(string: "https://www.ingredicheck.app/terms-conditions")!) { - Label { - Text("Terms of Use") - } icon: { - Image(systemName: "book.pages") + + // Danger + VStack(spacing: 12) { + sectionCard { + if authController.session != nil && !authController.signedInAsGuest { + DeleteAccountView(labelText: "Delete Data & Account", showDeleteConfirm: $showDeleteConfirm) + .padding(16) + + } else { + ResetAppStateView(labelText: "Reset App State") + .padding(16) + + } } + .padding(.top, 20) } - NavigationLink(value: URL(string: "https://www.ingredicheck.app/privacy-policy")!) { - Label { - Text("Privacy Policy") - } icon: { - Image(systemName: "lock") - } } - if authController.isInternalUser { - Label { - Text("Internal Mode Enabled") - .foregroundStyle(.paletteAccent) - } icon: { - Image(systemName: "hammer") - .foregroundStyle(.paletteAccent) + .padding(.horizontal, 20) + .padding(.bottom, 24) + } + + } + // Use navigationDestination for standard iOS push navigation with swipe-back gesture + .navigationDestination(isPresented: $showEditableCanvas) { + UnifiedCanvasView( + mode: .editing, + targetSectionName: editTargetSectionName, + onDismiss: { + showEditableCanvas = false + } + ) + .environmentObject(onboarding) + .environment(coordinator) + .environment(webService) + .environment(familyStore) + .environment(foodNotesStore) + } + .background(Color.pageBackground) + .navigationTitle("Profile") + .navigationBarTitleDisplayMode(.inline) + .dismissKeyboardOnTap() + .sheet(isPresented: $isFeedbackPresented) { + FeedbackView( + feedbackData: $settingsFeedbackData, + feedbackCaptureOptions: .feedbackOnly, + onSubmit: { + showFeedbackToast = true + settingsFeedbackData = FeedbackData() + } + ) + .environment(userPreferences) + } + .onAppear { + // 1) Prefill immediately from whatever is already in memory to avoid flicker/lag + primaryMemberName = familyStore.family?.selfMember.name + ?? familyStore.pendingSelfMember?.name + ?? "Bite Buddy" + + // 2) Load family fresh in the background and update if it changes + Task { + await familyStore.loadCurrentFamily() + await MainActor.run { + if let family = familyStore.family { + // If it's the "Just Me" flow, backend defaults the member name to "Me" + // but the family name to "Bite Buddy". We should show "Bite Buddy" here. + if family.selfMember.name == "Me" && !family.name.isEmpty { + primaryMemberName = family.name + } else { + primaryMemberName = family.selfMember.name + } + } else if let pending = familyStore.pendingSelfMember { + primaryMemberName = pending.name + } else if primaryMemberName.isEmpty { + primaryMemberName = "Bite Buddy" } } - Label { - Text("IngrediCheck for iOS \(appVersion).(\(buildNumber))") - } icon: { - Image(systemName: "app") + } + + // 3) Check internal mode concurrently; do not block UI/name + Task { + do { + let isInternal = try await webService.isDeviceInternal(deviceId: authController.deviceId) + await MainActor.run { authController.setInternalUser(isInternal) } + } catch { + print("Failed to check device internal status: \(error)") } - .onTapGesture(count: 7) { - Task { - do { - _ = try await webService.markDeviceInternal(deviceId: authController.deviceId) - await MainActor.run { - authController.setInternalUser(true) - internalModeToastMessage = "Internal Mode Unlocked" - showInternalModeToast = false - showInternalModeToast = true + } + } + // Keep name in sync when FamilyStore finishes loading or changes, + // but do not override while the user is editing. + .onChange(of: (familyStore.family?.selfMember.name ?? familyStore.pendingSelfMember?.name ?? "")) { _, newValue in + guard !newValue.isEmpty, !isEditingPrimaryName else { return } + if primaryMemberName != newValue { primaryMemberName = newValue } + } + // If the user turns on "Start Scanning on App Start" from Settings, open the scan screen immediately + .onChange(of: userPreferences.startScanningOnAppStart) { _, newValue in + if newValue && !dietaryPreferences.preferences.isEmpty { + appState.activeSheet = .scan + } + } + .onChange(of: primaryMemberName) { oldValue, newValue in + // Filter to letters and spaces only + let filtered = newValue.filter { $0.isLetter || $0.isWhitespace } + var finalized = filtered + + // Limit to 25 characters + if finalized.count > 25 { + finalized = String(finalized.prefix(25)) + } + + // Limit to max 3 words (max 2 spaces) + let components = finalized.components(separatedBy: .whitespaces) + if components.count > 3 { + finalized = components.prefix(3).joined(separator: " ") + } + + if finalized != newValue { + primaryMemberName = finalized + } + } + .simpleToast( + isPresented: $showInternalModeToast, + options: SimpleToastOptions(alignment: .top, hideAfter: 2) + ) { + InternalModeToastView(message: internalModeToastMessage) + } + .simpleToast( + isPresented: $showFeedbackToast, + options: SimpleToastOptions(alignment: .top, hideAfter: 3) + ) { + FeedbackSubmittedToastView() + } + .overlay { + if showSignOutConfirm || showDeleteConfirm { + ZStack { + Color.black.opacity(0.4) + .ignoresSafeArea() + .contentShape(Rectangle()) + .onTapGesture { /* block background taps */ } + + if showSignOutConfirm { + VStack(spacing: 6) { + Text("Are you sure you want to Sign out?") + .multilineTextAlignment(.center) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + .font(ManropeFont.medium.size(17)) + .foregroundStyle(.grayScale150) + + Divider() + + Button { + // Confirm sign out + Task { + await authController.resetForAppReset() + await MainActor.run { + appState.activeSheet = nil + appState.activeTab = .home + appState.feedbackConfig = nil + appState.listsTabState = ListsTabState() + familyStore.selectedMemberId = nil + showSignOutConfirm = false + } + } + } label: { + Text("Sign out") + .font(ManropeFont.medium.size(16)) + .foregroundStyle(Color(hex: "#FF1100")) + .frame(maxWidth: .infinity) + } + + Divider() + + Button { + showSignOutConfirm = false + } label: { + Text("Not now") + .font(ManropeFont.medium.size(16)) + .foregroundStyle(.grayScale150) + .frame(maxWidth: .infinity) } - } catch { - print("Failed to mark device internal: \(error)") } + .padding(12) + .frame(width: 270, height: 167) + .background(Color.grayScale10) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + } + + if showDeleteConfirm { + VStack(spacing: 12) { + Text("Type \"DELETE\" to confirm") + .multilineTextAlignment(.center) + .font(ManropeFont.medium.size(17)) + .foregroundStyle(.grayScale150) + + Text("This action can not be undone") + .font(ManropeFont.medium.size(14)) + .foregroundStyle(.grayScale110) + .multilineTextAlignment(.center) + + VStack(spacing: 0) { + TextField("", text: $deleteConfirmText) + .textInputAutocapitalization(.characters) + .disableAutocorrection(true) + .font(ManropeFont.medium.size(16)) + Rectangle() + .fill(Color(hex: "#E3E3E3")) + .frame(height: 1) + } + + Divider() + + HStack(spacing: 0) { + Button { + deleteConfirmText = "" + showDeleteConfirm = false + } label: { + Text("Cancel") + .font(ManropeFont.medium.size(16)) + .foregroundStyle(.grayScale150) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + } + + Rectangle() + .fill(Color(hex: "#E3E3E3")) + .frame(width: 1, height: 44) + + Button { + guard deleteConfirmText.uppercased() == "DELETE" else { return } + Task { + await authController.deleteAccount() + await MainActor.run { + appState.activeSheet = nil + appState.activeTab = .home + appState.feedbackConfig = nil + appState.listsTabState = ListsTabState() + deleteConfirmText = "" + showDeleteConfirm = false + } + } + } label: { + Text("Confirm") + .font(ManropeFont.medium.size(16)) + .foregroundStyle(Color(hex: "#FF1100")) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + } + .disabled(deleteConfirmText.uppercased() != "DELETE") + .opacity(deleteConfirmText.uppercased() == "DELETE" ? 1 : 0.5) + } + }.padding(.top, 20) + .padding(20) + .frame(width: 270, height: 170) + .background(Color.grayScale10) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) } } } } - .navigationDestination(for: URL.self, destination: { url in - WebView(url: url) - }) - .navigationBarTitleDisplayMode(.inline) - .navigationTitle("SETTINGS") - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button { - dismiss() - } label: { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.gray) + } + + // MARK: - Header name edit + @ViewBuilder + private func nameEditField() -> some View { + HStack(spacing: 12) { + TextField("", text: $primaryMemberName) + .font(NunitoFont.semiBold.size(22)) + .foregroundStyle(Color(hex: "#303030")) + .textInputAutocapitalization(.words) + .disableAutocorrection(true) + .focused($isEditingPrimaryName) + .submitLabel(.done) + .onSubmit { commitPrimaryName() } + Image("pen-line") + .resizable() + .frame(width: 12, height: 12) + .foregroundStyle(.grayScale100) + .onTapGesture { isEditingPrimaryName = true } + } + .padding(.horizontal, 20) + .frame(minWidth: 144) + .frame(maxWidth: 335) + .frame(height: 38) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(isEditingPrimaryName ? Color(hex: "#EEF5E3") : .white)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color(hex: "#E3E3E3"), lineWidth: 0.5) + ) + .contentShape(Rectangle()) + .fixedSize(horizontal: true, vertical: false) + .padding(.top,10) + .onTapGesture { isEditingPrimaryName = true } + .onChange(of: isEditingPrimaryName) { _, editing in + if !editing { commitPrimaryName() } + } + } + + private func commitPrimaryName() { + let trimmed = primaryMemberName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + Task { @MainActor in + if let family = familyStore.family { + var me = family.selfMember + guard me.name != trimmed else { return } + me.name = trimmed + await familyStore.editMember(me) + } else if let pending = familyStore.pendingSelfMember { + if pending.name != trimmed { + familyStore.updatePendingSelfMemberName(trimmed) } - + } else { + familyStore.setPendingSelfMember(name: trimmed) } } } - .onAppear { - Task { - do { - let isInternal = try await webService.isDeviceInternal(deviceId: authController.deviceId) - await MainActor.run { - authController.setInternalUser(isInternal) - } - } catch { - print("Failed to check device internal status: \(error)") + + // MARK: - Section Card wrapper + @ViewBuilder + func sectionCard(@ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 0) { + content() + } + // .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(Color.white) + // .shadow(color: Color(hex: "ECECEC"), radius: 9, x: 0, y: 0) + ) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(lineWidth: 0.75) + .foregroundStyle(Color(hex: "#EEEEEE")) + ) + } + + // MARK: - Rows + @ViewBuilder + func settingsRow(icon: String, title: String, iconColor: Color = .grayScale150, action: @escaping () -> Void) -> some View { + Button(action: action) { + HStack(spacing: 12) { + Image(icon) + .resizable() + .frame(width: 20, height: 20) + + Text(title) + .font(NunitoFont.medium.size(16)) + .foregroundStyle(.grayScale150) + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .semibold)) + .frame(width: 24, height: 24) + .foregroundStyle(.grayScale150) } + .padding( 16) + .contentShape(Rectangle()) } + .buttonStyle(.plain) } - .simpleToast( - isPresented: $showInternalModeToast, - options: SimpleToastOptions(alignment: .top, hideAfter: 2) - ) { - InternalModeToastView(message: internalModeToastMessage) + + @ViewBuilder + func rowContent(systemIcon: String, title: String) -> some View { + HStack(spacing: 8) { + Image(systemName: systemIcon) + .frame(width: 20, height: 20) + Text(title) + .font(NunitoFont.medium.size(16)) + .foregroundStyle(.grayScale150) + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .semibold)) + .frame(width: 24, height: 24) + .foregroundStyle(.grayScale150) + } + .padding( 12) + .contentShape(Rectangle()) + } - } - - struct AccountUpgradeView: View { - @Environment(AuthController.self) var authController - @State private var showUpgradeError = false - @State private var upgradeErrorMessage = "" - - var body: some View { - Group { - Text("Sign-in to avoid losing data.") - .font(.footnote) - .foregroundStyle(.secondary) - - if authController.isUpgradingAccount { - HStack { - Spacer() - ProgressView("Signing in...") - Spacer() - } + + @ViewBuilder + func rowContent(image: Image, title: String, iconColor: Color? = nil, showChevron: Bool = true) -> some View { + HStack(spacing: 8) { + image + .resizable() + .renderingMode(iconColor == nil ? .original : .template) + .frame(width: 20, height: 20) + .foregroundStyle(iconColor ?? .primary) + + Text(title) + .font(NunitoFont.medium.size(16)) + .foregroundStyle(.grayScale150) + Spacer() + if showChevron { + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .semibold)) + .frame(width: 24, height: 24) + .foregroundStyle(.grayScale150) } - - Button { - Task { - await authController.upgradeCurrentAccount(to: .apple) + } + .padding( 16) + .contentShape(Rectangle()) + } + + // MARK: - Account Cards + @ViewBuilder + func accountGuestCard() -> some View { + sectionCard { + VStack(spacing: 12) { + Text("Sign-in to avoid losing data") + .font(ManropeFont.medium.size(12)) + .foregroundStyle(.grayScale120) + .frame(maxWidth: .infinity, alignment: .leading) + HStack(spacing: 12) { + Button { + Task { await authController.upgradeCurrentAccount(to: .google) } + } label: { + HStack(spacing: 8) { + Image("google_logo") + .resizable() + .frame(width: 18, height: 18) + Text("Google") + .font(NunitoFont.semiBold.size(14)) + } + .padding(.vertical, 10) + .frame(maxWidth: .infinity) + .background(Color(hex: "#F7F7F7"), in: .capsule) + // .overlay(Capsule().stroke(lineWidth: 1.5).foregroundStyle(Color(hex: "91B640"))) + } + .buttonStyle(.plain) + + Button { + Task { await authController.upgradeCurrentAccount(to: .apple) } + } label: { + HStack(spacing: 8) { + Image("apple_logo") + .resizable() + .frame(width: 18, height: 18) + Text("Apple") + .font(NunitoFont.semiBold.size(14)) + } + .padding(.vertical, 10) + .frame(maxWidth: .infinity) + .background(Color(hex: "#F7F7F7") , in: .capsule) + // .overlay(Capsule().stroke(lineWidth: 1.5).foregroundStyle(Color(hex: "91B640"))) + } + .buttonStyle(.plain) } - } label: { - Label { - Text("Sign-in with Apple") - } icon: { + }.padding(16) + } + } + + @ViewBuilder + func accountSignedInCard() -> some View { + sectionCard { + HStack(spacing: 12) { + if authController.signedInWithApple { Image(systemName: "applelogo") + .frame(width: 22.96, height: 23.27) + } else if authController.signedInWithGoogle { + Image("google_logo").resizable() + .frame(width: 22.96, height: 23.27) + } else { + Image(systemName: "person.circle") + .frame(width: 22.96, height: 23.27) + } + VStack(alignment: .leading, spacing: 3) { + Text(authController.currentSignInProviderDisplay?.text ?? "Signed in") + .font(ManropeFont.bold.size(12)) + .foregroundStyle(.grayScale150) + if let email = authController.displayableEmail { + Text(email) + .font(ManropeFont.medium.size(12)) + .foregroundStyle(.grayScale110) + .lineLimit(1) + + } } + Spacer() + SignoutButton(showConfirm: $showSignOutConfirm) } - .disabled(authController.isUpgradingAccount) - - Button { - Task { - await authController.upgradeCurrentAccount(to: .google) + .padding(16) + .background(Color(hex: "#F7F7F7")) + .cornerRadius(24) + .padding(16) + .cornerRadius(24) + } + } + + struct AccountUpgradeView: View { + @Environment(AuthController.self) var authController + @State private var showUpgradeError = false + @State private var upgradeErrorMessage = "" + + var body: some View { + Group { + Text("Sign-in to avoid losing data.") + .font(.footnote) + .foregroundStyle(.secondary) + + if authController.isUpgradingAccount { + HStack { + Spacer() + ProgressView("Signing in...") + Spacer() + } } - } label: { - Label { - Text("Sign-in with Google") - } icon: { - Image("google_logo") + + Button { + Task { + await authController.upgradeCurrentAccount(to: .apple) + } + } label: { + Label { + Text("Sign-in with Apple") + } icon: { + Image(systemName: "applelogo") + } } + .disabled(authController.isUpgradingAccount) + + Button { + Task { + await authController.upgradeCurrentAccount(to: .google) + } + } label: { + Label { + Text("Sign-in with Google") + } icon: { + Image("google_logo") + } + } + .disabled(authController.isUpgradingAccount) } - .disabled(authController.isUpgradingAccount) - } - .onChange(of: authController.accountUpgradeError?.localizedDescription ?? "", initial: false) { _, message in - guard !message.isEmpty else { - return + .onChange(of: authController.accountUpgradeError?.localizedDescription ?? "", initial: false) { _, message in + guard !message.isEmpty else { + return + } + upgradeErrorMessage = message + showUpgradeError = true } - upgradeErrorMessage = message - showUpgradeError = true - } - .alert("Upgrade Failed", isPresented: $showUpgradeError) { - Button("OK", role: .cancel) { - Task { @MainActor in - authController.accountUpgradeError = nil + .alert("Upgrade Failed", isPresented: $showUpgradeError) { + Button("OK", role: .cancel) { + Task { @MainActor in + authController.accountUpgradeError = nil + } } + } message: { + Text(upgradeErrorMessage) } - } message: { - Text(upgradeErrorMessage) } } - } - - - var appVersion: String { - guard let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String else { - return "0.0" + + + var appVersion: String { + guard let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String else { + return "0.0" + } + return version + } + + var buildNumber: String { + guard let buildNumber = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String else { + return "00" + } + return buildNumber } - return version - } - var buildNumber: String { - guard let buildNumber = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String else { - return "00" + struct DeleteAccountView: View { + + let labelText: String + @Binding var showDeleteConfirm: Bool + + @Environment(\.dismiss) var dismiss + @Environment(AuthController.self) var authController + @Environment(OnboardingState.self) var onboardingState + @Environment(UserPreferences.self) var userPreferences + @Environment(DietaryPreferences.self) var dietaryPreferences + @Environment(AppState.self) var appState + + var body: some View { + Button { + showDeleteConfirm = true + } label: { + Label { + Text(labelText) + .font(NunitoFont.medium.size(16)) + + } icon: { + Image("Delete-icon") + .resizable() + .frame(width: 20, height: 20) + } + .foregroundStyle(Color(hex: "#F04438")) + } } - return buildNumber } -} - -struct DeleteAccountView: View { - let labelText: String - - @Environment(AuthController.self) var authController - @Environment(OnboardingState.self) var onboardingState - @Environment(UserPreferences.self) var userPreferences - @Environment(DietaryPreferences.self) var dietaryPreferences - @Environment(AppState.self) var appState - - @State private var confirmationShown = false - - var body: some View { - Button { - confirmationShown = true - } label: { - Label { - Text(labelText) - } icon: { - Image(systemName: "exclamationmark.triangle") + struct ResetAppStateView: View { + + let labelText: String + + @Environment(\.dismiss) var dismiss + @Environment(AuthController.self) var authController + @Environment(OnboardingState.self) var onboardingState + @Environment(UserPreferences.self) var userPreferences + @Environment(DietaryPreferences.self) var dietaryPreferences + @Environment(AppState.self) var appState + + @State private var confirmationShown = false + + var body: some View { + Button { + confirmationShown = true + } label: { + Label { + Text(labelText) + .font(NunitoFont.medium.size(16)) + } icon: { + Image("Reset-icon") + .resizable() + .frame(width: 20, height: 20) + } + .foregroundStyle(Color(hex: "#F04438")) } - .foregroundStyle(.red) - } - .confirmationDialog( - "Your Data cannot be recovered", - isPresented: $confirmationShown, - titleVisibility: .visible - ) { - Button("I Understand") { - Task { - await authController.deleteAccount() - await MainActor.run { - appState.activeSheet = nil - appState.activeTab = .home - appState.feedbackConfig = nil - appState.listsTabState = ListsTabState() - onboardingState.clearAll() - userPreferences.clearAll() - dietaryPreferences.clearAll() + .confirmationDialog( + "This will sign you out and reset the app", + isPresented: $confirmationShown, + titleVisibility: .visible + ) { + Button("Reset") { + Task { + await authController.resetForAppReset() + await MainActor.run { + appState.activeSheet = nil + appState.activeTab = .home + appState.feedbackConfig = nil + appState.listsTabState = ListsTabState() + onboardingState.clearAll() + userPreferences.clearAll() + dietaryPreferences.clearAll() + UserDefaults.standard.removeObject(forKey: "hasLaunchedOncePreviewFlow") + dismiss() + NotificationCenter.default.post( + name: Notification.Name("AppDidReset"), + object: nil + ) + } } } } } } -} - -private struct InternalModeToastView: View { - let message: String - var body: some View { - HStack(spacing: 8) { - Image(systemName: "sparkles") - .foregroundStyle(.paletteAccent) - Text(message) - .font(.subheadline) - .foregroundStyle(.primary) + private struct InternalModeToastView: View { + let message: String + + var body: some View { + HStack(spacing: 8) { + Image(systemName: "sparkles") + .foregroundStyle(.paletteAccent) + Text(message) + .font(.subheadline) + .foregroundStyle(.primary) + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + .background(Color(.systemBackground).opacity(0.9)) + .cornerRadius(12) + .shadow(radius: 6, y: 2) + } + } + + struct SignoutButton: View { + @Binding var showConfirm: Bool + @Environment(AuthController.self) var authController + @Environment(\.dismiss) var dismiss + @Environment(OnboardingState.self) var onboardingState + @Environment(UserPreferences.self) var userPreferences + @Environment(DietaryPreferences.self) var dietaryPreferences + @Environment(AppState.self) var appState + + var body: some View { + Button { + // Present confirmation modal; actual sign-out handled from overlay + showConfirm = true + } label: { + ZStack { + Text("Sign out") + .font(NunitoFont.semiBold.size(12)) + .foregroundStyle(.grayScale10) + } + .frame(width: 77, height: 33) + .background( + Capsule() + .fill( + LinearGradient( + colors: [Color(hex: "9DCF10"), Color(hex: "6B8E06")], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .shadow(.inner(color: Color(hex: "EDEDED").opacity(0.25), radius: 7.5, x: 2, y: 9)) + .shadow(.inner(color: Color(hex: "72930A"), radius: 5.7, x: 0, y: 4)) + .shadow(.drop(color: Color(hex: "C5C5C5").opacity(0.57), radius: 11, x: 0, y: 4)) + ) + ) + .overlay( + Capsule() + .stroke(lineWidth: 1) + .foregroundStyle(.grayScale10) + ) + } + } - .padding(.horizontal, 20) - .padding(.vertical, 12) - .background(Color(.systemBackground).opacity(0.9)) - .cornerRadius(12) - .shadow(radius: 6, y: 2) } } -struct SignoutButton: View { - - @Environment(AuthController.self) var authController - @Environment(OnboardingState.self) var onboardingState - @Environment(UserPreferences.self) var userPreferences - @Environment(DietaryPreferences.self) var dietaryPreferences - @Environment(AppState.self) var appState - +// SettingsSheet wraps SettingsContentView in NavigationStack for sheet presentation +struct SettingsSheet: View { var body: some View { - Button { - Task { - await authController.signOut() - await MainActor.run { - appState.activeSheet = nil - appState.activeTab = .home - appState.feedbackConfig = nil - appState.listsTabState = ListsTabState() - onboardingState.clearAll() - userPreferences.clearAll() - dietaryPreferences.clearAll() - } - } - } label: { - HStack(spacing: 8) { - Text("Sign out") - } + NavigationStack { + SettingsContentView() } - .buttonStyle(.borderedProminent) - .tint(.accentColor) - .buttonBorderShape(.capsule) + .navigationBarTitleDisplayMode(.inline) + .tint(Color(hex: "#303030")) } } diff --git a/IngrediCheck/Views/TipJar/TipJarViewModel.swift b/IngrediCheck/Views/TipJar/TipJarViewModel.swift index 50fb9ca2..6ef59587 100644 --- a/IngrediCheck/Views/TipJar/TipJarViewModel.swift +++ b/IngrediCheck/Views/TipJar/TipJarViewModel.swift @@ -7,6 +7,7 @@ import Foundation import StoreKit +import os @MainActor class TipJarViewModel: ObservableObject { @@ -21,11 +22,11 @@ class TipJarViewModel: ObservableObject { do { let transaction = try result.payloadValue if transaction.revocationDate == nil { - print("πŸ” Transaction update received: \(transaction.productID)") + Log.debug("TipJarViewModel", "πŸ” Transaction update received: \(transaction.productID)") await transaction.finish() } } catch { - print("⚠️ Failed to handle transaction update: \(error)") + Log.error("TipJarViewModel", "⚠️ Failed to handle transaction update: \(error)") } } } @@ -37,7 +38,7 @@ class TipJarViewModel: ObservableObject { let products = try await Product.products(for: Constants.tipJarIdentifiers).sorted(by: { $0.price < $1.price }) productsArr = products } catch { - print("Error while fetching products from connect file: \(error)") + Log.error("TipJarViewModel", "Error while fetching products from connect file: \(error)") } } @@ -51,25 +52,25 @@ class TipJarViewModel: ObservableObject { switch verification { case .verified(let transaction): - print("Purchase Successful: \(transaction.productID)") + Log.debug("TipJarViewModel", "Purchase Successful: \(transaction.productID)") await transaction.finish() case .unverified(_, let error): - print("Unverified Purchase: \(error.localizedDescription)") + Log.error("TipJarViewModel", "Unverified Purchase: \(error.localizedDescription)") } case .userCancelled: - print("Purchase cancelled by the user.") + Log.debug("TipJarViewModel", "Purchase cancelled by the user.") case .pending: - print("Purchase is pending.") + Log.debug("TipJarViewModel", "Purchase is pending.") @unknown default: - print("Unknown purchase result.") + Log.debug("TipJarViewModel", "Unknown purchase result.") } } catch { - print("Failed to purchase the product: \(error.localizedDescription)") + Log.error("TipJarViewModel", "Failed to purchase the product: \(error.localizedDescription)") } } } diff --git a/IngrediCheck/Views/WelcomeToYourFamilyView.swift b/IngrediCheck/Views/WelcomeToYourFamilyView.swift new file mode 100644 index 00000000..82d80f78 --- /dev/null +++ b/IngrediCheck/Views/WelcomeToYourFamilyView.swift @@ -0,0 +1,33 @@ +// +// WelcomeToYourFamilyView.swift +// IngrediCheckPreview +// +// Created by Gunjan Haldar on 10/11/25. +// + +import SwiftUI + +struct WelcomeToYourFamilyView: View { + + var body: some View { + VStack { + Group { + Text("Welcome to your family,") + Text("Patel Family! πŸ‘‹") + } + .font(NunitoFont.bold.size(22)) + .foregroundStyle(.grayScale150) + + Text("You're now part of this shared space β€” where everyone's preferences and safety come together.") + .font(ManropeFont.medium.size(12)) + .foregroundStyle(.grayScale120) + .multilineTextAlignment(.center) + + Spacer() + } + } +} + +#Preview { + WelcomeToYourFamilyView() +} diff --git a/IngrediCheck/WebService.swift b/IngrediCheck/WebService.swift index 5d688e9e..fabbdd28 100644 --- a/IngrediCheck/WebService.swift +++ b/IngrediCheck/WebService.swift @@ -6,6 +6,8 @@ import Supabase import PostHog import Network import CoreTelephony +import os +import StoreKit enum NetworkError: Error { case invalidResponse(Int) @@ -15,6 +17,26 @@ enum NetworkError: Error { case notFound(String) } +extension NetworkError: LocalizedError { + var errorDescription: String? { + switch self { + case .invalidResponse(let code): + if code == 201 { + return "The request was successful, but we couldn't verify the result. Please check your connection and try again." + } + return "Server error occurred. Please try again." + case .badUrl: + return "Invalid request. Please try again." + case .decodingError: + return "We received an unexpected response. Please try again." + case .authError: + return "Please sign in to continue." + case .notFound(let message): + return message.isEmpty ? "Resource not found. Please try again." : message + } + } +} + extension Sequence { func asyncMap( _ transform: (Element) async throws -> T @@ -24,7 +46,6 @@ extension Sequence { for element in self { try await values.append(transform(element)) } - return values } } @@ -47,8 +68,24 @@ struct UnifiedAnalysisStreamError: Error, LocalizedError { var errorDescription: String? { message } } +struct ScanStreamError: Error, LocalizedError { + let message: String + let statusCode: Int? + var errorDescription: String? { message } +} + +struct ChatStreamError: Error, LocalizedError { + let message: String + let statusCode: Int? + var errorDescription: String? { message } +} + @Observable final class WebService { - + + /// Tracks image upload start times by content_hash for latency measurement + var imageSubmitTimes: [String: TimeInterval] = [:] + private var reportedProcessedHashes: Set = [] + private let smallImageStore: FileStore private let mediumImageStore: FileStore private let largeImageStore: FileStore @@ -77,30 +114,62 @@ struct UnifiedAnalysisStreamError: Error, LocalizedError { } func fetchImage(imageLocation: DTO.ImageLocationInfo, imageSize: ImageSize) async throws -> UIImage { - - var fileLocation: FileLocation { - switch imageLocation { - case .url(let url): - return FileLocation.url(url) - case .imageFileHash(let imageFileHash): - return FileLocation.supabase(SupabaseFile(bucket: "productimages", name: imageFileHash)) - } - } - var imageData: Data { - get async throws { - switch imageSize { - case .small: - return try await smallImageStore.fetchFile(fileLocation: fileLocation) - case .medium: - return try await mediumImageStore.fetchFile(fileLocation: fileLocation) - case .large: - return try await largeImageStore.fetchFile(fileLocation: fileLocation) + let fileLocation: FileLocation + switch imageLocation { + case .url(let url): + fileLocation = FileLocation.url(url) + + case .imageFileHash(let imageFileHash): + // Heuristic: memoji images live in the `memoji-images` bucket and include + // a year/month path segment (e.g. "2025/01/.png"). Product images + // are flat hashes without slashes. + if imageFileHash.contains("/") { + // For memoji images in public bucket, use public URL directly + // Format: https://.supabase.co/storage/v1/object/public/memoji-images/ + // URL-encode each path segment to handle special characters properly + let pathComponents = imageFileHash.split(separator: "/") + let encodedComponents = pathComponents.map { component in + component.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? String(component) + } + let encodedPath = encodedComponents.joined(separator: "/") + let publicUrlString = "\(Config.supabaseURL.absoluteString)/storage/v1/object/public/memoji-images/\(encodedPath)" + + guard let publicUrl = URL(string: publicUrlString) else { + Log.error("WebService", "fetchImage: ❌ Failed to construct URL for memoji path: \(imageFileHash)") + throw NetworkError.badUrl } + fileLocation = FileLocation.url(publicUrl) + } else { + // For product images, use Supabase download API + fileLocation = FileLocation.supabase(SupabaseFile(bucket: "productimages", name: imageFileHash)) } + + case .scanImagePath(let storagePath): + // User-uploaded scan images are stored in the "scan-images" bucket + fileLocation = FileLocation.supabase(SupabaseFile(bucket: "scan-images", name: storagePath)) + } + + let data: Data + switch imageSize { + case .small: + data = try await smallImageStore.fetchFile(fileLocation: fileLocation) + case .medium: + data = try await mediumImageStore.fetchFile(fileLocation: fileLocation) + case .large: + data = try await largeImageStore.fetchFile(fileLocation: fileLocation) + } + + // CRITICAL: UIImage(data:) must be called on main thread - UIImage operations are not thread-safe + let image = await MainActor.run { + UIImage(data: data) + } + + guard let validImage = image else { + throw NetworkError.decodingError } - return UIImage(data: try await imageData)! + return validImage } func streamUnifiedAnalysis( @@ -140,23 +209,10 @@ struct UnifiedAnalysisStreamError: Error, LocalizedError { requestBuilder = requestBuilder.setFormData(name: "barcode", value: barcode) analyticsProperties["input_type"] = "barcode" analyticsProperties["barcode"] = barcode - case .productImages(let productImages): - let productImagesDTO = try await productImages.asyncMap { productImage in - DTO.ImageInfo( - imageFileHash: try await productImage.uploadTask.value, - imageOCRText: try await productImage.ocrTask.value, - barcode: try await productImage.barcodeDetectionTask.value - ) - } - - let productImagesData = try JSONEncoder().encode(productImagesDTO) - guard let productImagesString = String(data: productImagesData, encoding: .utf8) else { - throw NetworkError.decodingError - } - - requestBuilder = requestBuilder.setFormData(name: "productImages", value: productImagesString) - analyticsProperties["input_type"] = "product_images" - analyticsProperties["image_count"] = productImages.count + case .productImages: + // DEPRECATED: productImages case no longer supported + // Use the new Scan API (submitScanImage + getScan) for photo scans instead + throw NetworkError.invalidResponse(400) // Bad request - method deprecated } var request = requestBuilder.build() @@ -262,6 +318,113 @@ struct UnifiedAnalysisStreamError: Error, LocalizedError { guard let resolvedEventType = eventType else { return } switch resolvedEventType { + case "scan": + guard !payloadString.isEmpty else { return } + + // Define payload structure locally to match the partial/progressive SSE response + struct ScanStreamPayload: Decodable { + let id: String + let state: String + let barcode: String? + let product_info: DTO.ScanProductInfo? + let analysis_result: DTO.ScanAnalysisResult? + let error: String? + + func toProduct() -> DTO.Product? { + guard let info = product_info else { return nil } + + // Map images from ScanProductInfo to ImageLocationInfo + let mappedImages = info.images?.compactMap { imgInfo -> DTO.ImageLocationInfo? in + guard let urlString = imgInfo.url, let url = URL(string: urlString) else { return nil } + return .url(url) + } ?? [] + + return DTO.Product( + barcode: barcode, + brand: info.brand, + name: info.name ?? "Scanning...", + ingredients: info.ingredients, + images: mappedImages, + claims: info.claims + ) + } + } + + do { + let scanPayload: ScanStreamPayload = try decodePayload(payloadString, as: ScanStreamPayload.self) + + switch scanPayload.state { + case "fetching_product_info": + // Initial state, maybe just log or update generic feedback + // The UI might use "Scanning..." based on analyzing state + break + + case "analyzing": + // We have product info but no analysis yet + if let product = scanPayload.toProduct() { + productReceivedTime = Date().timeIntervalSince1970 + + var productProps = analyticsProperties + productProps["product_name"] = product.name ?? "Unknown" + productProps["state"] = "analyzing" + PostHogSDK.shared.capture("Unified Analysis Stream Product", properties: productProps) + + await MainActor.run { + onProduct(product) + } + } + + case "done": + // Final state with analysis + if let analysis = scanPayload.analysis_result { + let recommendations = analysis.toIngredientRecommendations() + + analysisReceivedTime = Date().timeIntervalSince1970 + + var analysisProps = analyticsProperties + analysisProps["recommendations_count"] = recommendations.count + analysisProps["state"] = "done" + PostHogSDK.shared.capture("Unified Analysis Stream Done", properties: analysisProps) + + // Send product update again (to ensure match status is updated with analysis) + if let product = scanPayload.toProduct() { + await MainActor.run { + onProduct(product) + } + } + + await MainActor.run { + onAnalysis(recommendations) + } + + shouldTerminate = true + } + + case "error": + hasReportedError = true + let errorMessage = scanPayload.error ?? "Unknown scan error" + + var errorProps = analyticsProperties + errorProps["error_message"] = errorMessage + PostHogSDK.shared.capture("Unified Analysis Stream Error Event", properties: errorProps) + + await MainActor.run { + onError(UnifiedAnalysisStreamError(message: errorMessage, statusCode: nil)) + } + shouldTerminate = true + + default: + break + } + + } catch { + var errorProps = analyticsProperties + errorProps["decode_stage"] = "scan" + errorProps["raw_payload"] = payloadString + PostHogSDK.shared.capture("Unified Analysis Stream Decode Error", properties: errorProps) + Log.error("SSE", "Failed to decode scan payload: \(error)") + } + case "product": guard !payloadString.isEmpty else { return } do { @@ -353,23 +516,34 @@ struct UnifiedAnalysisStreamError: Error, LocalizedError { } do { + // Use byte accumulation with proper UTF-8 decoding to handle multi-byte characters + var byteBuffer = Data() + let doubleNewlineData = "\n\n".data(using: .utf8)! + let carriageReturnNewlineData = "\r\n\r\n".data(using: .utf8)! + for try await byte in asyncBytes { if shouldTerminate { break } - let scalar = UnicodeScalar(byte) - buffer.append(Character(scalar)) + byteBuffer.append(byte) + // Check for event separators in accumulated bytes while true { - if let range = buffer.range(of: doubleNewline) { - let eventString = String(buffer[..? + + if let range = byteBuffer.range(of: doubleNewlineData) { + separatorRange = range + } else if let range = byteBuffer.range(of: carriageReturnNewlineData) { + separatorRange = range + } + + if let range = separatorRange { + let eventData = byteBuffer[.. Void, // (productInfo, scanId, product_info_source, images) + onAnalysis: @escaping (DTO.ScanAnalysisResult) -> Void, + onError: @escaping (ScanStreamError, String?) -> Void // (error, scanId) ) async throws { - + + let requestId = UUID().uuidString + let startTime = Date().timeIntervalSince1970 + var scanId: String? + var hasReportedError = false + + Log.debug("BARCODE_SCAN", "πŸ”΅ Starting barcode scan - barcode: \(barcode), request_id: \(requestId)") + + // Wake up fly.io backend before scan + pingFlyIO() + guard let token = try? await supabaseClient.auth.session.accessToken else { + Log.error("BARCODE_SCAN", "❌ Auth error - no access token") throw NetworkError.authError } - - var feedbackDataDto = DTO.FeedbackData() - feedbackDataDto.rating = feedbackData.rating - feedbackDataDto.reasons = - feedbackData.reasons.isEmpty - ? nil - : Array(feedbackData.reasons).map { reason in reason.rawValue } - feedbackDataDto.note = - feedbackData.note.isEmpty - ? nil - : feedbackData.note - feedbackDataDto.images = - feedbackData.images.isEmpty - ? nil - : try await feedbackData.images.asyncMap { productImage in - DTO.ImageInfo( - imageFileHash: try await productImage.uploadTask.value, - imageOCRText: try await productImage.ocrTask.value, - barcode: try await productImage.barcodeDetectionTask.value - ) + + let requestBody = try JSONEncoder().encode(["barcode": barcode]) + let endpoint = Config.flyIOBaseURL + "/" + SafeEatsEndpoint.scan_barcode.rawValue + + var request = SupabaseRequestBuilder(endpoint: .scan_barcode) + .setAuthorization(with: token) + .setMethod(to: "POST") + .setJsonBody(to: requestBody) + .build() + + request.setValue("text/event-stream", forHTTPHeaderField: "Accept") + request.timeoutInterval = 60 + + Log.debug("BARCODE_SCAN", "πŸ“‘ API Call: POST \(endpoint)") + Log.debug("BARCODE_SCAN", "πŸ“‘ Request body: barcode=\(barcode)") + + PostHogSDK.shared.capture("Barcode Scan Started", properties: [ + "request_id": requestId, + "barcode": barcode + ]) + + let (asyncBytes, response) = try await URLSession.shared.bytes(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1 + Log.error("BARCODE_SCAN", "❌ HTTP Error - Status: \(statusCode)") + PostHogSDK.shared.capture("Barcode Scan Failed - HTTP", properties: [ + "request_id": requestId, + "status_code": statusCode + ]) + throw NetworkError.invalidResponse(statusCode) + } + + Log.debug("BARCODE_SCAN", "βœ… Connected to SSE stream - Status: 200, starting to receive events...") + Log.debug("BARCODE_SCAN", "⏳ Polling: NO (using Server-Sent Events stream)") + + var buffer = "" + let doubleNewline = "\n\n" + + func processEvent(_ rawEvent: String) async { + let trimmed = rawEvent.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + + var eventType: String? + var dataLines: [String] = [] + + trimmed.split(whereSeparator: \.isNewline).forEach { line in + if line.hasPrefix("event:") { + eventType = String(line.dropFirst(6)).trimmingCharacters(in: .whitespaces) + } else if line.hasPrefix("data:") { + dataLines.append(String(line.dropFirst(5)).trimmingCharacters(in: .whitespaces)) + } + } + + let payloadString = dataLines.joined(separator: "\n") + guard let resolvedEventType = eventType, + let payloadData = payloadString.data(using: .utf8) else { return } + + switch resolvedEventType { + case "scan": + // Log raw payload before decoding + Log.debug("BARCODE_SCAN", "πŸ“„ Raw SSE Event (scan):") + print(payloadString) + + do { + let scan = try JSONDecoder().decode(DTO.Scan.self, from: payloadData) + scanId = scan.id + + let latency = (Date().timeIntervalSince1970 - startTime) * 1000 + Log.debug("BARCODE_SCAN", "πŸ“¦ Event: scan - scan_id: \(scan.id), state: \(scan.state), latency: \(Int(latency))ms") + + switch scan.state { + case "fetching_product_info": + // Initial state - product info is being fetched + // product_info may be empty at this stage + Log.debug("BARCODE_SCAN", "⏳ State: fetching_product_info - waiting for product info...") + PostHogSDK.shared.capture("Barcode Scan State", properties: [ + "request_id": requestId, + "scan_id": scan.id, + "state": scan.state, + "latency_ms": latency + ]) + // No callback needed at this stage + + case "processing_images": + // Images are being processed + Log.debug("BARCODE_SCAN", "πŸ–ΌοΈ State: processing_images - processing uploaded images...") + PostHogSDK.shared.capture("Barcode Scan State", properties: [ + "request_id": requestId, + "scan_id": scan.id, + "state": scan.state, + "latency_ms": latency + ]) + // No callback needed at this stage + + case "analyzing": + // Product info is available, analysis is in progress + Log.debug("BARCODE_SCAN", "πŸ” State: analyzing - product info available, analyzing ingredients...") + + // Convert ScanProductInfo to the format expected by onProductInfo callback + let productInfo = scan.product_info + let productInfoSource = scan.product_info_source ?? "unknown" + + PostHogSDK.shared.capture("Barcode Scan Product Info", properties: [ + "request_id": requestId, + "scan_id": scan.id, + "source": productInfoSource, + "latency_ms": latency + ]) + + await MainActor.run { + onProductInfo(productInfo, scan.id, productInfoSource, scan.images) + } + + case "done": + // Scan complete with analysis result + Log.debug("BARCODE_SCAN", "βœ… State: done - scan complete") + + if let analysisResult = scan.analysis_result { + // Log raw ingredient_analysis data including members_affected + Log.debug("BARCODE_SCAN", "πŸ“Š Raw analysis_result - ingredient_analysis count: \(analysisResult.ingredient_analysis.count)") + for (index, analysis) in analysisResult.ingredient_analysis.enumerated() { + Log.debug("BARCODE_SCAN", "πŸ“Š ingredient_analysis[\(index)]: ingredient=\(analysis.ingredient), match=\(analysis.match), members_affected=\(analysis.members_affected)") + } + + Log.debug("BARCODE_SCAN", "🎯 Scan complete - no polling needed (SSE stream)") + + let totalLatency = (Date().timeIntervalSince1970 - startTime) * 1000 + PostHogSDK.shared.capture("Barcode Scan Analysis", properties: [ + "request_id": requestId, + "scan_id": scan.id, + "total_latency_ms": totalLatency + ]) + + await MainActor.run { + onAnalysis(analysisResult) + } + } else { + Log.warning("BARCODE_SCAN", "⚠️ Scan done but analysis_result is nil") + } + + case "error": + // Scan failed + hasReportedError = true + let errorMessage = scan.error ?? "Unknown scan error" + + Log.error("BARCODE_SCAN", "❌ State: error - scan_id: \(scan.id), error: \(errorMessage)") + + PostHogSDK.shared.capture("Barcode Scan Error", properties: [ + "request_id": requestId, + "scan_id": scan.id, + "error": errorMessage + ]) + + await MainActor.run { + onError(ScanStreamError(message: errorMessage, statusCode: nil), scan.id) + } + + default: + Log.warning("BARCODE_SCAN", "⚠️ Unknown state: \(scan.state)") + break + } + } catch { + Log.error("BARCODE_SCAN", "❌ Failed to decode scan payload: \(error)") + // Log the raw payload for debugging + if let payloadString = String(data: payloadData, encoding: .utf8) { + Log.debug("BARCODE_SCAN", "πŸ“„ Raw scan payload: \(payloadString.prefix(1000))") + } + + // Try to extract scan_id and error from raw JSON if decoding fails + if let jsonObject = try? JSONSerialization.jsonObject(with: payloadData) as? [String: Any] { + if let id = jsonObject["id"] as? String { + scanId = id + } + if let errorMsg = jsonObject["error"] as? String { + hasReportedError = true + await MainActor.run { + onError(ScanStreamError(message: errorMsg, statusCode: nil), scanId) + } + } + } + } + + default: + Log.warning("BARCODE_SCAN", "⚠️ Unknown event type: \(resolvedEventType)") + break } + } + + do { + // Use byte accumulation with proper UTF-8 decoding to handle multi-byte characters + var byteBuffer = Data() + let doubleNewlineData = "\n\n".data(using: .utf8)! - let feedbackDataJson = try JSONEncoder().encode(feedbackDataDto) - let feedbackDataJsonString = String(data: feedbackDataJson, encoding: .utf8)! + for try await byte in asyncBytes { + byteBuffer.append(byte) - let request = SupabaseRequestBuilder(endpoint: .feedback) + // Check for event separator in accumulated bytes + while let range = byteBuffer.range(of: doubleNewlineData) { + let eventData = byteBuffer[.. DTO.SubmitImageResponse { + + let submitStartTime = Date().timeIntervalSince1970 + let imageSizeKB = imageData.count / 1024 + Log.debug("PHOTO_SCAN", "πŸ“Έ Submitting image - scan_id: \(scanId), image_size: \(imageSizeKB)KB") + + // Wake up fly.io backend before scan + pingFlyIO() + + guard let token = try? await supabaseClient.auth.session.accessToken else { + Log.error("PHOTO_SCAN", "❌ Auth error - no access token") + throw NetworkError.authError + } + + let endpoint = Config.flyIOBaseURL + "/" + String(format: SafeEatsEndpoint.scan_image.rawValue, scanId) + let request = SupabaseRequestBuilder(endpoint: .scan_image, itemId: scanId) .setAuthorization(with: token) .setMethod(to: "POST") - .setFormData(name: "clientActivityId", value: clientActivityId) - .setFormData(name: "feedback", value: feedbackDataJsonString) + .setFormData(name: "image", value: imageData, contentType: "image/jpeg") .build() - - let (_, response) = try await URLSession.shared.data(for: request) - + + Log.debug("PHOTO_SCAN", "πŸ“‘ API Call: POST \(endpoint)") + + let (data, response) = try await URLSession.shared.data(for: request) let httpResponse = response as! HTTPURLResponse - - guard httpResponse.statusCode == 201 else { + + switch httpResponse.statusCode { + case 200: + let submitResponse = try JSONDecoder().decode(DTO.SubmitImageResponse.self, from: data) + imageSubmitTimes[submitResponse.content_hash] = submitStartTime + Log.debug("PHOTO_SCAN", "βœ… Image submitted successfully - scan_id: \(scanId), queued: \(submitResponse.queued), queue_position: \(submitResponse.queue_position)") + return submitResponse + case 401: + Log.error("PHOTO_SCAN", "❌ Status 401 - Unauthorized") + throw NetworkError.authError + case 403: + Log.error("PHOTO_SCAN", "❌ Status 403 - Scan belongs to another user") + throw NetworkError.notFound("Scan belongs to another user") + case 413: + Log.error("PHOTO_SCAN", "❌ Status 413 - Image too large (>10MB)") + throw NetworkError.invalidResponse(413) // Image too large (>10MB) + case 400: + Log.error("PHOTO_SCAN", "❌ Status 400 - Max images reached (20)") + throw NetworkError.invalidResponse(400) // Max images reached (20) + case 502, 503, 504: + // Server-side errors - gateway/proxy/upstream issues + let responseBody = String(data: data, encoding: .utf8) ?? "No response body" + Log.error("PHOTO_SCAN", "❌ Status \(httpResponse.statusCode) - Server error (likely server-side issue)") + Log.debug("PHOTO_SCAN", "πŸ“„ Response body: \(responseBody.prefix(500))") + throw NetworkError.invalidResponse(httpResponse.statusCode) + default: + let responseBody = String(data: data, encoding: .utf8) ?? "No response body" + Log.error("PHOTO_SCAN", "❌ Status \(httpResponse.statusCode) - Unexpected error") + Log.debug("PHOTO_SCAN", "πŸ“„ Response body: \(responseBody.prefix(500))") throw NetworkError.invalidResponse(httpResponse.statusCode) } } - - func fetchHistory(searchText: String? = nil) async throws -> [DTO.HistoryItem] { - - let requestId = UUID().uuidString - let startTime = Date().timeIntervalSince1970 - + + func reanalyzeScan(scanId: String) async throws -> DTO.Scan { + Log.debug("REANALYZE", "πŸ”„ Reanalyzing scan - scan_id: \(scanId)") + + // Wake up fly.io backend before reanalyze + pingFlyIO() + guard let token = try? await supabaseClient.auth.session.accessToken else { + Log.error("REANALYZE", "❌ Auth error - no access token") throw NetworkError.authError } - - var requestBuilder = SupabaseRequestBuilder(endpoint: .history) + + // Use endpoint construction logic directly or update SupabaseRequestBuilder to handle it + // Since we added .scan_reanalyze to SupabaseRequestBuilder, we can use it. + // Note: The formatted URL string logic in SupabaseRequestBuilder might need double check + // if it handles single parameter correctly for scan/%@/reanalyze + // endpoint.rawValue is scan/%@/reanalyze. formatting with scanId should work. + + let endpoint = Config.flyIOBaseURL + "/" + String(format: SafeEatsEndpoint.scan_reanalyze.rawValue, scanId) + let request = SupabaseRequestBuilder(endpoint: .scan_reanalyze, itemId: scanId) .setAuthorization(with: token) - .setMethod(to: "GET") - - if let searchText { - requestBuilder = - requestBuilder - .setQueryItems(queryItems: [ - URLQueryItem(name: "searchText", value: searchText) - ]) - } - - let request = requestBuilder.build() - + .setMethod(to: "POST") + .build() + + Log.debug("REANALYZE", "πŸ“‘ API Call: POST \(endpoint)") + let (data, response) = try await URLSession.shared.data(for: request) - let httpResponse = response as! HTTPURLResponse - + guard httpResponse.statusCode == 200 else { - PostHogSDK.shared.capture("History Fetch Failed", properties: [ - "request_id": requestId, - "has_search_text": searchText != nil, - "search_length": searchText?.count ?? 0, - "status_code": httpResponse.statusCode, - "latency_ms": (Date().timeIntervalSince1970 - startTime) * 1000 - ]) - - throw NetworkError.invalidResponse(httpResponse.statusCode) + let responseBody = String(data: data, encoding: .utf8) ?? "No response body" + Log.error("REANALYZE", "❌ HTTP Error - Status: \(httpResponse.statusCode)") + Log.debug("REANALYZE", "πŸ“„ Response body: \(responseBody)") + + if httpResponse.statusCode == 400 { + // Cannot reanalyze (e.g. no ingredients) + throw NetworkError.invalidResponse(400) + } else if httpResponse.statusCode == 404 { + throw NetworkError.notFound("Scan not found") + } else { + throw NetworkError.invalidResponse(httpResponse.statusCode) + } } - + do { - let history = try JSONDecoder().decode([DTO.HistoryItem].self, from: data) - - PostHogSDK.shared.capture("History Fetch Successful", properties: [ - "request_id": requestId, - "has_search_text": searchText != nil, - "search_length": searchText?.count ?? 0, - "history_count": history.count, - "latency_ms": (Date().timeIntervalSince1970 - startTime) * 1000 - ]) - - return history + let scan = try JSONDecoder().decode(DTO.Scan.self, from: data) + Log.debug("REANALYZE", "βœ… Reanalysis complete - scan_id: \(scan.id)") + return scan } catch { - let responseText = String(data: data, encoding: .utf8) ?? "" - print("Failed to decode History object: \(error)") - print(responseText) - - PostHogSDK.shared.capture("History Fetch Decode Error", properties: [ - "request_id": requestId, - "has_search_text": searchText != nil, - "search_length": searchText?.count ?? 0, - "error": error.localizedDescription, - "latency_ms": (Date().timeIntervalSince1970 - startTime) * 1000 - ]) - + Log.error("REANALYZE", "❌ Failed to decode reanalysis response: \(error)") throw NetworkError.decodingError } } + + // MARK: - Chat API + + func streamChatMessage( + message: String, + context: any Codable, + conversationId: String? = nil, + onThinking: @escaping (String, String) -> Void, // (conversationId, turnId) + onResponse: @escaping (String, String, String) -> Void, // (conversationId, turnId, response) + onError: @escaping (ChatStreamError, String?, String?) -> Void // (error, conversationId, turnId) + ) async throws { + + let requestId = UUID().uuidString + let startTime = Date().timeIntervalSince1970 + var currentConversationId: String? + var currentTurnId: String? + var hasReportedError = false + + Log.debug("CHAT", "πŸ”΅ Starting chat message - message: \(message.prefix(50))..., request_id: \(requestId)") + + // Wake up fly.io backend before chat + pingFlyIO() + + guard let token = try? await supabaseClient.auth.session.accessToken else { + Log.error("CHAT", "❌ Auth error - no access token") + throw NetworkError.authError + } + + // Encode context to JSON string + let contextJson: String + do { + let encoder = JSONEncoder() + let contextData = try encoder.encode(context) + contextJson = String(data: contextData, encoding: .utf8) ?? "{}" + } catch { + Log.error("CHAT", "❌ Failed to encode context: \(error)") + throw NetworkError.badUrl + } + + let endpoint = Config.flyIOBaseURL + "/" + SafeEatsEndpoint.chat_send.rawValue + + var requestBuilder = SupabaseRequestBuilder(endpoint: .chat_send) + .setAuthorization(with: token) + .setMethod(to: "POST") + .setFormData(name: "message", value: message) + .setFormData(name: "context", value: contextJson) + + if let convId = conversationId { + requestBuilder = requestBuilder.setFormData(name: "conversation_id", value: convId) + } + + var request = requestBuilder.build() + + request.setValue("text/event-stream", forHTTPHeaderField: "Accept") + request.timeoutInterval = 60 + + // Log request details + Log.debug("CHAT", "πŸ“‘ API Call: POST \(endpoint)") + Log.debug("CHAT", "πŸ“‘ Request Headers: Authorization=Bearer ***, Accept=text/event-stream") + var requestBodyLog = "πŸ“‘ Request Body:\n" + requestBodyLog += " message: \(message)\n" + requestBodyLog += " context: \(contextJson)\n" + if let convId = conversationId { + requestBodyLog += " conversation_id: \(convId)\n" + } + Log.debug("CHAT", requestBodyLog) + + PostHogSDK.shared.capture("Chat Message Started", properties: [ + "request_id": requestId, + "has_conversation_id": conversationId != nil + ]) + + let (asyncBytes, response) = try await URLSession.shared.bytes(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + Log.error("CHAT", "❌ Invalid response type") + throw NetworkError.invalidResponse(-1) + } + + switch httpResponse.statusCode { + case 200: + Log.debug("CHAT", "βœ… Connected to SSE stream - Status: 200, starting to receive events...") + case 401: + Log.error("CHAT", "❌ Status 401 - Unauthorized") + throw NetworkError.authError + case 404: + Log.error("CHAT", "❌ Status 404 - Conversation not found") + throw NetworkError.notFound("Conversation not found") + case 422: + // For 422, we need to read the error response + // But we've already started the stream, so we'll handle it in the error event + Log.error("CHAT", "❌ Status 422 - Validation error") + throw NetworkError.invalidResponse(422) + default: + Log.error("CHAT", "❌ HTTP Error - Status: \(httpResponse.statusCode)") + PostHogSDK.shared.capture("Chat Message Failed - HTTP", properties: [ + "request_id": requestId, + "status_code": httpResponse.statusCode + ]) + throw NetworkError.invalidResponse(httpResponse.statusCode) + } + + var buffer = "" + let doubleNewline = "\n\n" + + func processEvent(_ rawEvent: String) async { + let trimmed = rawEvent.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + + var eventType: String? + var dataLines: [String] = [] + + trimmed.split(whereSeparator: \.isNewline).forEach { line in + if line.hasPrefix("event:") { + eventType = String(line.dropFirst(6)).trimmingCharacters(in: .whitespaces) + } else if line.hasPrefix("data:") { + dataLines.append(String(line.dropFirst(5)).trimmingCharacters(in: .whitespaces)) + } + } + + let payloadString = dataLines.joined(separator: "\n") + guard let resolvedEventType = eventType, + let payloadData = payloadString.data(using: .utf8) else { return } + + // Log raw SSE event + Log.debug("CHAT", "πŸ“₯ SSE Event Received - event: \(resolvedEventType)") + Log.debug("CHAT", "πŸ“₯ SSE Event Data: \(payloadString)") + + switch resolvedEventType { + case "turn": + do { + // Try to decode as TurnThinkingEvent or TurnDoneEvent based on state + if let jsonObject = try? JSONSerialization.jsonObject(with: payloadData) as? [String: Any], + let state = jsonObject["state"] as? String { + + if state == "thinking" { + let thinkingEvent = try JSONDecoder().decode(DTO.TurnThinkingEvent.self, from: payloadData) + currentConversationId = thinkingEvent.conversation_id + currentTurnId = thinkingEvent.turn_id + + let latency = (Date().timeIntervalSince1970 - startTime) * 1000 + Log.debug("CHAT", "πŸ“¦ Event: turn (thinking) - conversation_id: \(thinkingEvent.conversation_id), turn_id: \(thinkingEvent.turn_id), latency: \(Int(latency))ms") + + PostHogSDK.shared.capture("Chat Turn Thinking", properties: [ + "request_id": requestId, + "conversation_id": thinkingEvent.conversation_id, + "turn_id": thinkingEvent.turn_id + ]) + + await MainActor.run { + onThinking(thinkingEvent.conversation_id, thinkingEvent.turn_id) + } + } else if state == "done" { + let doneEvent = try JSONDecoder().decode(DTO.TurnDoneEvent.self, from: payloadData) + currentConversationId = doneEvent.conversation_id + currentTurnId = doneEvent.turn_id + + let latency = (Date().timeIntervalSince1970 - startTime) * 1000 + Log.debug("CHAT", "πŸ“¦ Event: turn (done) - conversation_id: \(doneEvent.conversation_id), turn_id: \(doneEvent.turn_id), response_length: \(doneEvent.response.count), latency: \(Int(latency))ms") + + PostHogSDK.shared.capture("Chat Turn Done", properties: [ + "request_id": requestId, + "conversation_id": doneEvent.conversation_id, + "turn_id": doneEvent.turn_id, + "response_length": doneEvent.response.count + ]) + + await MainActor.run { + onResponse(doneEvent.conversation_id, doneEvent.turn_id, doneEvent.response) + } + } + } + } catch { + Log.error("CHAT", "❌ Failed to decode turn event: \(error)") + if let payloadString = String(data: payloadData, encoding: .utf8) { + Log.debug("CHAT", "πŸ“„ Raw turn payload: \(payloadString.prefix(500))") + } + } + + case "error": + do { + let errorEvent = try JSONDecoder().decode(DTO.ChatErrorEvent.self, from: payloadData) + hasReportedError = true + currentConversationId = errorEvent.conversation_id + currentTurnId = errorEvent.turn_id + + Log.error("CHAT", "❌ Event: error - conversation_id: \(errorEvent.conversation_id ?? "nil"), turn_id: \(errorEvent.turn_id ?? "nil"), error: \(errorEvent.error)") + + PostHogSDK.shared.capture("Chat Error", properties: [ + "request_id": requestId, + "conversation_id": errorEvent.conversation_id ?? "", + "turn_id": errorEvent.turn_id ?? "", + "error": errorEvent.error + ]) + + await MainActor.run { + onError(ChatStreamError(message: errorEvent.error, statusCode: nil), errorEvent.conversation_id, errorEvent.turn_id) + } + } catch { + Log.error("CHAT", "❌ Failed to decode error event: \(error)") + if let payloadString = String(data: payloadData, encoding: .utf8) { + Log.debug("CHAT", "πŸ“„ Raw error payload: \(payloadString.prefix(500))") + } + hasReportedError = true + await MainActor.run { + onError(ChatStreamError(message: "Failed to parse error event", statusCode: nil), nil, nil) + } + } + + default: + Log.warning("CHAT", "⚠️ Unknown event type: \(resolvedEventType)") + break + } + } + + do { + // Use byte accumulation with proper UTF-8 decoding to handle multi-byte characters + var byteBuffer = Data() + let doubleNewlineData = "\n\n".data(using: .utf8)! - func uploadImage(image: UIImage) async throws -> String { - - let imageData = image.jpegData(compressionQuality: 1.0)! - let imageFileName = SHA256.hash(data: imageData).compactMap { String(format: "%02x", $0) }.joined() - - try await supabaseClient.storage.from("productimages").upload( - path: imageFileName, - file: imageData, - options: FileOptions(contentType: "image/jpeg") - ) + for try await byte in asyncBytes { + byteBuffer.append(byte) - return imageFileName - } + // Check for event separator in accumulated bytes + while let range = byteBuffer.range(of: doubleNewlineData) { + let eventData = byteBuffer[.. DTO.ConversationResponse { + Log.debug("CHAT", "πŸ“₯ Fetching conversation - conversation_id: \(conversationId)") guard let token = try? await supabaseClient.auth.session.accessToken else { + Log.error("CHAT", "❌ Auth error - no access token") throw NetworkError.authError } - - let listId = UUID(uuidString: "00000000-0000-0000-0000-000000000000")!.uuidString - let request = SupabaseRequestBuilder(endpoint: .list_items, itemId: listId) + + let endpoint = Config.flyIOBaseURL + "/" + String(format: SafeEatsEndpoint.chat_get.rawValue, conversationId) + let request = SupabaseRequestBuilder(endpoint: .chat_get, itemId: conversationId) .setAuthorization(with: token) - .setMethod(to: "POST") - .setFormData(name: "clientActivityId", value: clientActivityId) + .setMethod(to: "GET") .build() - - let (_, response) = try await URLSession.shared.data(for: request) - + + // Log request details + Log.debug("CHAT", "πŸ“‘ API Call: GET \(endpoint)") + Log.debug("CHAT", "πŸ“‘ Request Headers: Authorization=Bearer ***") + Log.debug("CHAT", "πŸ“‘ Request Body: (GET request - no body)") + + let (data, response) = try await URLSession.shared.data(for: request) let httpResponse = response as! HTTPURLResponse - - guard httpResponse.statusCode == 201 else { - print("Bad response from server: \(httpResponse.statusCode)") + + // Log response details + Log.debug("CHAT", "πŸ“₯ Response Status: \(httpResponse.statusCode)") + if let responseBody = String(data: data, encoding: .utf8) { + Log.debug("CHAT", "πŸ“₯ Response Body: \(responseBody)") + } else { + Log.debug("CHAT", "πŸ“₯ Response Body: (unable to decode as string, size: \(data.count) bytes)") + } + + switch httpResponse.statusCode { + case 200: + let conversation = try JSONDecoder().decode(DTO.ConversationResponse.self, from: data) + Log.debug("CHAT", "βœ… Conversation loaded - conversation_id: \(conversation.conversation_id), turns: \(conversation.turns.count)") + return conversation + case 401: + Log.error("CHAT", "❌ Status 401 - Unauthorized") + throw NetworkError.authError + case 404: + Log.error("CHAT", "❌ Status 404 - Conversation not found") + throw NetworkError.notFound("Conversation not found") + default: + Log.error("CHAT", "❌ HTTP Error - Status: \(httpResponse.statusCode)") throw NetworkError.invalidResponse(httpResponse.statusCode) } } - func removeFromFavorites(clientActivityId: String) async throws { - + func getScan(scanId: String) async throws -> DTO.Scan { + guard let token = try? await supabaseClient.auth.session.accessToken else { + Log.error("PHOTO_SCAN", "❌ Auth error - no access token for polling") throw NetworkError.authError } - - let listId = UUID(uuidString: "00000000-0000-0000-0000-000000000000")!.uuidString - let request = SupabaseRequestBuilder(endpoint: .list_items_item, itemId: listId, subItemId: clientActivityId) + + let endpoint = Config.supabaseFunctionsURLBase + String(format: SafeEatsEndpoint.scan_get.rawValue, scanId) + let request = SupabaseRequestBuilder(endpoint: .scan_get, itemId: scanId) .setAuthorization(with: token) - .setMethod(to: "DELETE") + .setMethod(to: "GET") .build() - - let (_, response) = try await URLSession.shared.data(for: request) - + + Log.debug("PHOTO_SCAN", "πŸ”„ Polling: GET \(endpoint)") + + let (data, response) = try await URLSession.shared.data(for: request) let httpResponse = response as! HTTPURLResponse + + switch httpResponse.statusCode { + case 200: + // Log raw response for debugging + if let rawResponse = String(data: data, encoding: .utf8) { + Log.debug("PHOTO_SCAN", "πŸ“„ Raw response: \(rawResponse)") + } + + do { + let scan = try JSONDecoder().decode(DTO.Scan.self, from: data) + Log.debug("PHOTO_SCAN", "βœ… Poll response - scan_id: \(scanId), state: \(scan.state)") + + // Check for newly processed images and emit latency events + for scanImage in scan.images { + if case .user(let userImage) = scanImage, + userImage.status == "processed", + !reportedProcessedHashes.contains(userImage.content_hash), + let submitTime = imageSubmitTimes[userImage.content_hash] { + reportedProcessedHashes.insert(userImage.content_hash) + let latency = (Date().timeIntervalSince1970 - submitTime) * 1000 + PostHogSDK.shared.capture("Photo Scan Image Processed", properties: [ + "scan_id": scanId, + "content_hash": userImage.content_hash, + "latency_ms": latency + ]) + } + } - guard httpResponse.statusCode == 200 else { - print("Bad response from server: \(httpResponse.statusCode)") + // Log raw ingredient_analysis data including members_affected if available + if let analysisResult = scan.analysis_result { + Log.debug("PHOTO_SCAN", "πŸ“Š Raw analysis_result - ingredient_analysis count: \(analysisResult.ingredient_analysis.count)") + for (index, analysis) in analysisResult.ingredient_analysis.enumerated() { + Log.debug("PHOTO_SCAN", "πŸ“Š ingredient_analysis[\(index)]: ingredient=\(analysis.ingredient), match=\(analysis.match), members_affected=\(analysis.members_affected)") + } + } + + return scan + } catch let error { + // Log detailed decoding error + Log.error("PHOTO_SCAN", "❌ Failed to decode Scan: \(error)") + if let rawResponse = String(data: data, encoding: .utf8) { + Log.error("PHOTO_SCAN", "πŸ“„ Raw response that failed to decode: \(rawResponse)") + } + if let decodingError = error as? DecodingError { + switch decodingError { + case .keyNotFound(let key, let context): + Log.error("PHOTO_SCAN", "❌ Missing key: \(key.stringValue) at path: \(context.codingPath.map { $0.stringValue }.joined(separator: ")."))") + case .typeMismatch(let type, let context): + Log.error("PHOTO_SCAN", "❌ Type mismatch: expected \(type) at path: \(context.codingPath.map { $0.stringValue }.joined(separator: ")."))") + case .valueNotFound(let type, let context): + Log.error("PHOTO_SCAN", "❌ Value not found: \(type) at path: \(context.codingPath.map { $0.stringValue }.joined(separator: ")."))") + case .dataCorrupted(let context): + Log.error("PHOTO_SCAN", "❌ Data corrupted at path: \(context.codingPath.map { $0.stringValue }.joined(separator: ").")), \(context.debugDescription)") + @unknown default: + Log.error("PHOTO_SCAN", "❌ Unknown decoding error: \(decodingError)") + } + } + throw error + } + case 401: + Log.error("PHOTO_SCAN", "❌ Poll Status 401 - Unauthorized") + throw NetworkError.authError + case 403: + Log.error("PHOTO_SCAN", "❌ Poll Status 403 - Scan belongs to another user") + throw NetworkError.notFound("Scan belongs to another user") + case 404: + Log.error("PHOTO_SCAN", "❌ Poll Status 404 - Scan not found") + throw NetworkError.notFound("Scan not found") + default: + Log.error("PHOTO_SCAN", "❌ Poll Status \(httpResponse.statusCode) - Unexpected error") throw NetworkError.invalidResponse(httpResponse.statusCode) } } - func getFavorites(searchText: String? = nil) async throws -> [DTO.ListItem] { + func fetchScanHistory( + limit: Int = 20, + offset: Int = 0 + ) async throws -> DTO.ScanHistoryResponse { let requestId = UUID().uuidString let startTime = Date().timeIntervalSince1970 + Log.debug("SCAN_HISTORY", "πŸ”΅ fetchScanHistory called - limit: \(limit), offset: \(offset), requestId: \(requestId)") + guard let token = try? await supabaseClient.auth.session.accessToken else { + Log.error("SCAN_HISTORY", "❌ Auth error - no access token") throw NetworkError.authError } - let listId = UUID(uuidString: "00000000-0000-0000-0000-000000000000")!.uuidString - - var requestBuilder = SupabaseRequestBuilder(endpoint: .list_items, itemId: listId) + let request = SupabaseRequestBuilder(endpoint: .scan_history) .setAuthorization(with: token) .setMethod(to: "GET") + .setQueryItems(queryItems: [ + URLQueryItem(name: "limit", value: String(limit)), + URLQueryItem(name: "offset", value: String(offset)) + ]) + .build() - if let searchText { - requestBuilder = - requestBuilder - .setQueryItems(queryItems: [ - URLQueryItem(name: "searchText", value: searchText) - ]) - } - - let request = requestBuilder.build() - + Log.debug("SCAN_HISTORY", "πŸ“‘ Sending request to scan_history API...") let (data, response) = try await URLSession.shared.data(for: request) - let httpResponse = response as! HTTPURLResponse - + + // Log raw response + if let rawResponse = String(data: data, encoding: .utf8) { + Log.debug("SCAN_HISTORY", "πŸ“„ Raw API Response:") + print(rawResponse) + } else { + Log.warning("SCAN_HISTORY", "⚠️ Could not convert response data to string") + } + guard httpResponse.statusCode == 200 else { - print("Bad response from server: \(httpResponse.statusCode)") - - PostHogSDK.shared.capture("Favorites Fetch Failed", properties: [ + PostHogSDK.shared.capture("Scan History Fetch Failed", properties: [ "request_id": requestId, - "has_search_text": searchText != nil, - "search_length": searchText?.count ?? 0, "status_code": httpResponse.statusCode, "latency_ms": (Date().timeIntervalSince1970 - startTime) * 1000 ]) - throw NetworkError.invalidResponse(httpResponse.statusCode) } - + do { - let listItems = try JSONDecoder().decode([DTO.ListItem].self, from: data) + let historyResponse = try JSONDecoder().decode(DTO.ScanHistoryResponse.self, from: data) + let latencyMs = (Date().timeIntervalSince1970 - startTime) * 1000 - PostHogSDK.shared.capture("Favorites Fetch Successful", properties: [ + Log.debug("SCAN_HISTORY", "βœ… fetchScanHistory success - \(historyResponse.scans.count) scans, total: \(historyResponse.total), latency: \(String(format: "%.0f", latencyMs))ms") + + PostHogSDK.shared.capture("Scan History Fetch Successful", properties: [ "request_id": requestId, - "has_search_text": searchText != nil, - "search_length": searchText?.count ?? 0, - "favorites_count": listItems.count, - "latency_ms": (Date().timeIntervalSince1970 - startTime) * 1000 + "scan_count": historyResponse.scans.count, + "total": historyResponse.total, + "has_more": historyResponse.has_more, + "latency_ms": latencyMs ]) - return listItems + return historyResponse } catch { - print("Failed to decode ListItem array: \(error)") - let responseText = String(data: data, encoding: .utf8) ?? "" - print(responseText) - - PostHogSDK.shared.capture("Favorites Fetch Decode Error", properties: [ + let latencyMs = (Date().timeIntervalSince1970 - startTime) * 1000 + Log.error("SCAN_HISTORY", "❌ Failed to decode response - error: \(error.localizedDescription), latency: \(String(format: "%.0f", latencyMs))ms") + + PostHogSDK.shared.capture("Scan History Decode Error", properties: [ "request_id": requestId, - "has_search_text": searchText != nil, - "search_length": searchText?.count ?? 0, "error": error.localizedDescription, "latency_ms": (Date().timeIntervalSince1970 - startTime) * 1000 ]) - + throw NetworkError.decodingError } } - - func addOrEditDietaryPreference( - clientActivityId: String, - preferenceText: String, - id: Int? - ) async throws -> DTO.PreferenceValidationResult { - - let requestId: String = UUID().uuidString + + func fetchStats() async throws -> DTO.StatsResponse { + let requestId = UUID().uuidString let startTime = Date().timeIntervalSince1970 - func buildRequest(_ token: String) -> URLRequest { - if let id { - PostHogSDK.shared.capture("User Inputed Preference", properties: [ - "request_id": requestId, - "endpoint": SafeEatsEndpoint.preference_lists_default_items.rawValue, - "client_activity_id": clientActivityId, - "item_id": String(id), - "preference_text": preferenceText, - "method": "PUT", - "start_time": String(startTime) - ]) - - return SupabaseRequestBuilder(endpoint: .preference_lists_default_items, itemId: String(id)) - .setAuthorization(with: token) - .setMethod(to: "PUT") - .setFormData(name: "clientActivityId", value: clientActivityId) - .setFormData(name: "preference", value: preferenceText) - .build() - - } else { - PostHogSDK.shared.capture("User Inputed Preference", properties: [ - "request_id": requestId, - "endpoint": SafeEatsEndpoint.preference_lists_default.rawValue, - "client_activity_id": clientActivityId, - "preference_text": preferenceText, - "method": "POST", - "start_time": String(startTime) - ]) - - return SupabaseRequestBuilder(endpoint: .preference_lists_default) - .setAuthorization(with: token) - .setMethod(to: "POST") - .setFormData(name: "clientActivityId", value: clientActivityId) - .setFormData(name: "preference", value: preferenceText) - .build() - } - } - guard let token = try? await supabaseClient.auth.session.accessToken else { throw NetworkError.authError } - - let request = buildRequest(token) - let (data, response) = try await URLSession.shared.data(for: request) + // Calculate UTC offset in minutes (e.g., -480 for PST, +330 for IST) + let utcOffsetMinutes = TimeZone.current.secondsFromGMT() / 60 + + let request = SupabaseRequestBuilder(endpoint: .stats_v2) + .setAuthorization(with: token) + .setMethod(to: "GET") + .setQueryItems(queryItems: [ + URLQueryItem(name: "utcOffset", value: String(utcOffsetMinutes)) + ]) + .build() + + let (data, response) = try await URLSession.shared.data(for: request) let httpResponse = response as! HTTPURLResponse - - guard ([200, 201, 204, 422].contains(httpResponse.statusCode)) else { - - PostHogSDK.shared.capture("User Input Validation: Bad response from the server", properties: [ + + guard httpResponse.statusCode == 200 else { + PostHogSDK.shared.capture("Stats Fetch Failed", properties: [ "request_id": requestId, - "client_activity_id": clientActivityId, - "preference_text": preferenceText, "status_code": httpResponse.statusCode, - "latency_ms": Date().timeIntervalSince1970 * 1000 - startTime * 1000 + "latency_ms": (Date().timeIntervalSince1970 - startTime) * 1000 ]) - - print("Bad response from server: \(httpResponse.statusCode)") throw NetworkError.invalidResponse(httpResponse.statusCode) } do { - PostHogSDK.shared.capture("User Input Validation Successful", properties: [ + let stats = try JSONDecoder().decode(DTO.StatsResponse.self, from: data) + + PostHogSDK.shared.capture("Stats Fetch Successful", properties: [ "request_id": requestId, - "client_activity_id": clientActivityId, - "preference_text": preferenceText, - "latency_ms": Date().timeIntervalSince1970 * 1000 - startTime * 1000 + "avg_scans": stats.avgScans, + "barcode_scans_count": stats.barcodeScansCount, + "matching_rate_matched": stats.matchingStats.matched, + "matching_rate_unmatched": stats.matchingStats.unmatched, + "matching_rate_uncertain": stats.matchingStats.uncertain, + "latency_ms": (Date().timeIntervalSince1970 - startTime) * 1000 ]) - return try JSONDecoder().decode(DTO.PreferenceValidationResult.self, from: data) + return stats } catch { - PostHogSDK.shared.capture("User Input Validation Error", properties: [ + print("Failed to decode StatsResponse: \(error)") + + PostHogSDK.shared.capture("Stats Decode Error", properties: [ "request_id": requestId, - "client_activity_id": clientActivityId, - "preference_text": preferenceText, - "latency_ms": Date().timeIntervalSince1970 * 1000 - startTime * 1000, - "error": error.localizedDescription + "error": error.localizedDescription, + "latency_ms": (Date().timeIntervalSince1970 - startTime) * 1000 ]) - print("Failed to decode PreferenceValidationResult object: \(error)") - let responseText = String(data: data, encoding: .utf8) ?? "" - print(responseText) throw NetworkError.decodingError } } - - func getDietaryPreferences() async throws -> [DTO.DietaryPreference] { - + + func submitFeedback( + clientActivityId: String, + feedbackData: FeedbackData + ) async throws { + guard let token = try? await supabaseClient.auth.session.accessToken else { throw NetworkError.authError } - let request = SupabaseRequestBuilder(endpoint: .preference_lists_default) + var feedbackDataDto = DTO.FeedbackData() + feedbackDataDto.rating = feedbackData.rating + feedbackDataDto.reasons = + feedbackData.reasons.isEmpty + ? nil + : Array(feedbackData.reasons).map { reason in reason.rawValue } + feedbackDataDto.note = + feedbackData.note.isEmpty + ? nil + : feedbackData.note + feedbackDataDto.images = + feedbackData.images.isEmpty + ? nil + : try await feedbackData.images.asyncMap { productImage in + // Upload image and get hash + let imageFileHash = try await uploadImage(image: productImage.image) + // For feedback, we don't need OCR text or barcode + return DTO.ImageInfo( + imageFileHash: imageFileHash, + imageOCRText: "", // Not needed for feedback + barcode: nil // Not needed for feedback + ) + } + + let feedbackDataJson = try JSONEncoder().encode(feedbackDataDto) + let feedbackDataJsonString = String(data: feedbackDataJson, encoding: .utf8)! + + let request = SupabaseRequestBuilder(endpoint: .feedback) .setAuthorization(with: token) - .setMethod(to: "GET") + .setMethod(to: "POST") + .setFormData(name: "clientActivityId", value: clientActivityId) + .setFormData(name: "feedback", value: feedbackDataJsonString) .build() + let (_, response) = try await URLSession.shared.data(for: request) + + let httpResponse = response as! HTTPURLResponse + + guard httpResponse.statusCode == 201 else { + throw NetworkError.invalidResponse(httpResponse.statusCode) + } + } + + func fetchHistory(searchText: String? = nil) async throws -> [DTO.HistoryItem] { + + let requestId = UUID().uuidString + let startTime = Date().timeIntervalSince1970 + + guard let token = try? await supabaseClient.auth.session.accessToken else { + throw NetworkError.authError + } + + var requestBuilder = SupabaseRequestBuilder(endpoint: .history) + .setAuthorization(with: token) + .setMethod(to: "GET") + + if let searchText { + requestBuilder = + requestBuilder + .setQueryItems(queryItems: [ + URLQueryItem(name: "searchText", value: searchText) + ]) + } + + let request = requestBuilder.build() + let (data, response) = try await URLSession.shared.data(for: request) + let httpResponse = response as! HTTPURLResponse guard httpResponse.statusCode == 200 else { - print("Bad response from server: \(httpResponse.statusCode)") + PostHogSDK.shared.capture("History Fetch Failed", properties: [ + "request_id": requestId, + "has_search_text": searchText != nil, + "search_length": searchText?.count ?? 0, + "status_code": httpResponse.statusCode, + "latency_ms": (Date().timeIntervalSince1970 - startTime) * 1000 + ]) + throw NetworkError.invalidResponse(httpResponse.statusCode) } do { - return try JSONDecoder().decode([DTO.DietaryPreference].self, from: data) + let history = try JSONDecoder().decode([DTO.HistoryItem].self, from: data) + + PostHogSDK.shared.capture("History Fetch Successful", properties: [ + "request_id": requestId, + "has_search_text": searchText != nil, + "search_length": searchText?.count ?? 0, + "history_count": history.count, + "latency_ms": (Date().timeIntervalSince1970 - startTime) * 1000 + ]) + + return history } catch { - print("Failed to decode [DTO.DietaryPreference] object: \(error)") let responseText = String(data: data, encoding: .utf8) ?? "" + print("Failed to decode History object: \(error)") print(responseText) + + PostHogSDK.shared.capture("History Fetch Decode Error", properties: [ + "request_id": requestId, + "has_search_text": searchText != nil, + "search_length": searchText?.count ?? 0, + "error": error.localizedDescription, + "latency_ms": (Date().timeIntervalSince1970 - startTime) * 1000 + ]) + throw NetworkError.decodingError } } - func deleteDietaryPreference( - clientActivityId: String, - id: Int - ) async throws -> Void { - + func toggleScanFavorite(scanId: String) async throws -> Bool { guard let token = try? await supabaseClient.auth.session.accessToken else { throw NetworkError.authError } - let request = - SupabaseRequestBuilder(endpoint: .preference_lists_default_items, itemId: String(id)) - .setAuthorization(with: token) - .setMethod(to: "DELETE") - .setFormData(name: "clientActivityId", value: clientActivityId) - .build() - let (_, response) = try await URLSession.shared.data(for: request) + Log.debug("WebService", "toggleScanFavorite: start scanId=\(scanId)") + + let request = SupabaseRequestBuilder(endpoint: .scan_favorite, itemId: scanId) + .setAuthorization(with: token) + .setMethod(to: "PATCH") + .build() + + let (data, response) = try await URLSession.shared.data(for: request) let httpResponse = response as! HTTPURLResponse - guard httpResponse.statusCode == 204 else { - print("Bad response from server: \(httpResponse.statusCode)") + Log.debug("WebService", "toggleScanFavorite: status=\(httpResponse.statusCode), bytes=\(data.count)") + + guard httpResponse.statusCode == 200 else { + let responseText = String(data: data, encoding: .utf8) ?? "" + Log.debug("WebService", "toggleScanFavorite: non-200 response body=\(responseText)") throw NetworkError.invalidResponse(httpResponse.statusCode) } + + // Some backends return the updated scan record; others return a small payload. + if let decoded = try? JSONDecoder().decode(DTO.HistoryItem.self, from: data) { + Log.debug("WebService", "toggleScanFavorite: decoded HistoryItem favorited=\(decoded.favorited)") + return decoded.favorited + } + + struct ToggleFavoriteResponse: Decodable { + let favorited: Bool? + let favorite: Bool? + } + + if let decoded = try? JSONDecoder().decode(ToggleFavoriteResponse.self, from: data) { + Log.debug("WebService", "toggleScanFavorite: decoded ToggleFavoriteResponse favorited=\(String(describing: decoded.favorited)) favorite=\(String(describing: decoded.favorite))") + if let favorited = decoded.favorited { return favorited } + if let favorite = decoded.favorite { return favorite } + } + + if let json = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] { + Log.debug("WebService", "toggleScanFavorite: decoded JSON keys=\(Array(json.keys))") + if let favorited = json["favorited"] as? Bool { return favorited } + if let favorite = json["favorite"] as? Bool { return favorite } + } + + let responseText = String(data: data, encoding: .utf8) ?? "" + Log.error("WebService", "toggleScanFavorite: failed to decode response body=\(responseText)") + throw NetworkError.decodingError + } + + func setHistoryFavorite(clientActivityId: String, favorited: Bool) async throws -> Bool { + Log.debug("WebService", "setHistoryFavorite: start clientActivityId=\(clientActivityId), favorited=\(favorited)") + do { + if favorited { + try await addToFavorites(clientActivityId: clientActivityId) + Log.debug("WebService", "setHistoryFavorite: addToFavorites βœ… clientActivityId=\(clientActivityId)") + } else { + try await removeFromFavorites(clientActivityId: clientActivityId) + Log.debug("WebService", "setHistoryFavorite: removeFromFavorites βœ… clientActivityId=\(clientActivityId)") + } + return favorited + } catch { + Log.error("WebService", "setHistoryFavorite: ❌ clientActivityId=\(clientActivityId), error=\(error.localizedDescription)") + throw error + } } + + func uploadImage(image: UIImage) async throws -> String { + Log.debug("WebService", "uploadImage: Before pngData() - Thread.isMainThread=\(Thread.isMainThread)") + // CRITICAL: pngData() must be called on main thread - UIImage operations are not thread-safe + let imageData = await MainActor.run { + let isMainThread = Thread.isMainThread + Log.debug("WebService", "uploadImage: Inside MainActor.run - Thread.isMainThread=\(isMainThread)") + let data = image.pngData()! + Log.debug("WebService", "uploadImage: pngData() completed - data size=\(data.count) bytes") + return data + } + Log.debug("WebService", "uploadImage: After MainActor.run - Thread.isMainThread=\(Thread.isMainThread)") + let imageFileName = SHA256.hash(data: imageData).compactMap { String(format: "%02x", $0) }.joined() + + Log.debug("WebService", "uploadImage: Uploading image to storage with key=\(imageFileName)") + + do { + try await supabaseClient.storage.from("productimages").upload( + path: imageFileName, + file: imageData, + options: FileOptions(contentType: "image/png") + ) + Log.debug("WebService", "uploadImage: βœ… Upload succeeded for key=\(imageFileName)") + } catch { + let message = String(describing: error) + // Supabase storage returns "The resource already exists" when the same + // object key is uploaded again. In our case the key is a SHA256 hash + // of the image bytes, so if the content is identical we can safely + // treat this as a success and reuse the existing object. + if message.contains("resource already exists") { + Log.debug("WebService", "uploadImage: ℹ️ Resource already exists for key=\(imageFileName), reusing existing file") + } else { + Log.error("WebService", "uploadImage: ❌ Upload failed for key=\(imageFileName): \(error.localizedDescription)") + throw error + } + } - func uploadGrandFatheredPreferences(_ preferences: [String]) async throws -> Void { + return imageFileName + } + + func deleteImages(imageFileNames: [String]) async throws { + _ = try await supabaseClient.storage.from("productimages").remove(paths: imageFileNames) + } + + /// Toggles favorite status for a scan using the v2 API + /// POST /v2/scan/{scan_id}/favorite + /// Returns the new `is_favorited` state + func toggleFavorite(scanId: String) async throws -> Bool { guard let token = try? await supabaseClient.auth.session.accessToken else { + Log.error("FAVORITE", "❌ Auth error - no access token") throw NetworkError.authError } - - let request = - SupabaseRequestBuilder(endpoint: .preference_lists_grandfathered) - .setAuthorization(with: token) - .setMethod(to: "POST") - .setJsonBody( - to: try! JSONSerialization.data(withJSONObject: preferences, options: []) - ) - .build() - let (_, response) = try await URLSession.shared.data(for: request) + + let endpoint = Config.supabaseFunctionsURLBase + String(format: SafeEatsEndpoint.scan_favorite.rawValue, scanId) + let request = SupabaseRequestBuilder(endpoint: .scan_favorite, itemId: scanId) + .setAuthorization(with: token) + .setMethod(to: "PATCH") + .build() + + Log.debug("FAVORITE", "πŸ“‘ API Call: POST \(endpoint)") + + let (data, response) = try await URLSession.shared.data(for: request) let httpResponse = response as! HTTPURLResponse - - guard httpResponse.statusCode == 201 else { - print("Bad response from server: \(httpResponse.statusCode)") + + switch httpResponse.statusCode { + case 200: + // Parse response to get is_favorited value + struct FavoriteResponse: Codable { + let is_favorited: Bool + } + + do { + let favoriteResponse = try JSONDecoder().decode(FavoriteResponse.self, from: data) + Log.debug("FAVORITE", "βœ… Toggle successful - scanId: \(scanId), is_favorited: \(favoriteResponse.is_favorited)") + return favoriteResponse.is_favorited + } catch { + Log.error("FAVORITE", "❌ Failed to decode response: \(error)") + if let rawResponse = String(data: data, encoding: .utf8) { + Log.debug("FAVORITE", "πŸ“„ Raw response: \(rawResponse)") + } + throw NetworkError.decodingError + } + case 401: + Log.error("FAVORITE", "❌ Status 401 - Unauthorized") + throw NetworkError.authError + case 404: + Log.error("FAVORITE", "❌ Status 404 - Scan not found") + throw NetworkError.notFound("Scan not found") + default: + let responseBody = String(data: data, encoding: .utf8) ?? "No response body" + Log.error("FAVORITE", "❌ Status \(httpResponse.statusCode) - Unexpected error") + Log.debug("FAVORITE", "πŸ“„ Response body: \(responseBody.prefix(500))") throw NetworkError.invalidResponse(httpResponse.statusCode) } } - func deleteUserAccount() async throws -> Void { + // MARK: - Legacy Favorite Methods (deprecated, use toggleFavorite instead) + + @available(*, deprecated, message: "Use toggleFavorite(scanId:) instead") + func addToFavorites(clientActivityId: String) async throws { + // For backward compatibility, call toggleFavorite + // Note: This may toggle OFF if already favorited + _ = try await toggleFavorite(scanId: clientActivityId) + } + + @available(*, deprecated, message: "Use toggleFavorite(scanId:) instead") + func removeFromFavorites(clientActivityId: String) async throws { + // For backward compatibility, call toggleFavorite + // Note: This may toggle ON if not favorited + _ = try await toggleFavorite(scanId: clientActivityId) + } + + func getFavorites(searchText: String? = nil) async throws -> [DTO.ListItem] { + + let requestId = UUID().uuidString + let startTime = Date().timeIntervalSince1970 + guard let token = try? await supabaseClient.auth.session.accessToken else { throw NetworkError.authError } - let request = - SupabaseRequestBuilder(endpoint: .deleteme) - .setAuthorization(with: token) + let listId = UUID(uuidString: "00000000-0000-0000-0000-000000000000")!.uuidString + + var requestBuilder = SupabaseRequestBuilder(endpoint: .list_items, itemId: listId) + .setAuthorization(with: token) + .setMethod(to: "GET") + + if let searchText { + requestBuilder = + requestBuilder + .setQueryItems(queryItems: [ + URLQueryItem(name: "searchText", value: searchText) + ]) + } + + let request = requestBuilder.build() + + let (data, response) = try await URLSession.shared.data(for: request) + + let httpResponse = response as! HTTPURLResponse + + guard httpResponse.statusCode == 200 else { + print("Bad response from server: \(httpResponse.statusCode)") + + PostHogSDK.shared.capture("Favorites Fetch Failed", properties: [ + "request_id": requestId, + "has_search_text": searchText != nil, + "search_length": searchText?.count ?? 0, + "status_code": httpResponse.statusCode, + "latency_ms": (Date().timeIntervalSince1970 - startTime) * 1000 + ]) + + throw NetworkError.invalidResponse(httpResponse.statusCode) + } + + do { + let listItems = try JSONDecoder().decode([DTO.ListItem].self, from: data) + + PostHogSDK.shared.capture("Favorites Fetch Successful", properties: [ + "request_id": requestId, + "has_search_text": searchText != nil, + "search_length": searchText?.count ?? 0, + "favorites_count": listItems.count, + "latency_ms": (Date().timeIntervalSince1970 - startTime) * 1000 + ]) + + return listItems + } catch { + print("Failed to decode ListItem array: \(error)") + let responseText = String(data: data, encoding: .utf8) ?? "" + print(responseText) + + PostHogSDK.shared.capture("Favorites Fetch Decode Error", properties: [ + "request_id": requestId, + "has_search_text": searchText != nil, + "search_length": searchText?.count ?? 0, + "error": error.localizedDescription, + "latency_ms": (Date().timeIntervalSince1970 - startTime) * 1000 + ]) + + throw NetworkError.decodingError + } + } + + func addOrEditDietaryPreference( + clientActivityId: String, + preferenceText: String, + id: Int? + ) async throws -> DTO.PreferenceValidationResult { + + let requestId: String = UUID().uuidString + let startTime = Date().timeIntervalSince1970 + + func buildRequest(_ token: String) -> URLRequest { + if let id { + PostHogSDK.shared.capture("User Inputed Preference", properties: [ + "request_id": requestId, + "endpoint": SafeEatsEndpoint.preference_lists_default_items.rawValue, + "client_activity_id": clientActivityId, + "item_id": String(id), + "preference_text": preferenceText, + "method": "PUT", + "start_time": String(startTime) + ]) + + return SupabaseRequestBuilder(endpoint: .preference_lists_default_items, itemId: String(id)) + .setAuthorization(with: token) + .setMethod(to: "PUT") + .setFormData(name: "clientActivityId", value: clientActivityId) + .setFormData(name: "preference", value: preferenceText) + .build() + + } else { + PostHogSDK.shared.capture("User Inputed Preference", properties: [ + "request_id": requestId, + "endpoint": SafeEatsEndpoint.preference_lists_default.rawValue, + "client_activity_id": clientActivityId, + "preference_text": preferenceText, + "method": "POST", + "start_time": String(startTime) + ]) + + return SupabaseRequestBuilder(endpoint: .preference_lists_default) + .setAuthorization(with: token) + .setMethod(to: "POST") + .setFormData(name: "clientActivityId", value: clientActivityId) + .setFormData(name: "preference", value: preferenceText) + .build() + } + } + + guard let token = try? await supabaseClient.auth.session.accessToken else { + throw NetworkError.authError + } + + let request = buildRequest(token) + let (data, response) = try await URLSession.shared.data(for: request) + + let httpResponse = response as! HTTPURLResponse + + guard ([200, 201, 204, 422].contains(httpResponse.statusCode)) else { + + PostHogSDK.shared.capture("User Input Validation: Bad response from the server", properties: [ + "request_id": requestId, + "client_activity_id": clientActivityId, + "preference_text": preferenceText, + "status_code": httpResponse.statusCode, + "latency_ms": Date().timeIntervalSince1970 * 1000 - startTime * 1000 + ]) + + print("Bad response from server: \(httpResponse.statusCode)") + throw NetworkError.invalidResponse(httpResponse.statusCode) + } + + do { + PostHogSDK.shared.capture("User Input Validation Successful", properties: [ + "request_id": requestId, + "client_activity_id": clientActivityId, + "preference_text": preferenceText, + "latency_ms": Date().timeIntervalSince1970 * 1000 - startTime * 1000 + ]) + + return try JSONDecoder().decode(DTO.PreferenceValidationResult.self, from: data) + } catch { + PostHogSDK.shared.capture("User Input Validation Error", properties: [ + "request_id": requestId, + "client_activity_id": clientActivityId, + "preference_text": preferenceText, + "latency_ms": Date().timeIntervalSince1970 * 1000 - startTime * 1000, + "error": error.localizedDescription + ]) + + print("Failed to decode PreferenceValidationResult object: \(error)") + let responseText = String(data: data, encoding: .utf8) ?? "" + print(responseText) + throw NetworkError.decodingError + } + } + + func getDietaryPreferences() async throws -> [DTO.DietaryPreference] { + + guard let token = try? await supabaseClient.auth.session.accessToken else { + throw NetworkError.authError + } + + let request = SupabaseRequestBuilder(endpoint: .preference_lists_default) + .setAuthorization(with: token) + .setMethod(to: "GET") + .build() + + let (data, response) = try await URLSession.shared.data(for: request) + let httpResponse = response as! HTTPURLResponse + + guard httpResponse.statusCode == 200 else { + print("Bad response from server: \(httpResponse.statusCode)") + throw NetworkError.invalidResponse(httpResponse.statusCode) + } + + do { + return try JSONDecoder().decode([DTO.DietaryPreference].self, from: data) + } catch { + print("Failed to decode [DTO.DietaryPreference] object: \(error)") + let responseText = String(data: data, encoding: .utf8) ?? "" + print(responseText) + throw NetworkError.decodingError + } + } + + func deleteDietaryPreference( + clientActivityId: String, + id: Int + ) async throws -> Void { + + guard let token = try? await supabaseClient.auth.session.accessToken else { + throw NetworkError.authError + } + + let request = + SupabaseRequestBuilder(endpoint: .preference_lists_default_items, itemId: String(id)) + .setAuthorization(with: token) + .setMethod(to: "DELETE") + .setFormData(name: "clientActivityId", value: clientActivityId) + .build() + let (_, response) = try await URLSession.shared.data(for: request) + let httpResponse = response as! HTTPURLResponse + + guard httpResponse.statusCode == 204 else { + print("Bad response from server: \(httpResponse.statusCode)") + throw NetworkError.invalidResponse(httpResponse.statusCode) + } + } + + func uploadGrandFatheredPreferences(_ preferences: [String]) async throws -> Void { + + guard let token = try? await supabaseClient.auth.session.accessToken else { + throw NetworkError.authError + } + + let request = + SupabaseRequestBuilder(endpoint: .preference_lists_grandfathered) + .setAuthorization(with: token) + .setMethod(to: "POST") + .setJsonBody( + to: try! JSONSerialization.data(withJSONObject: preferences, options: []) + ) + .build() + let (_, response) = try await URLSession.shared.data(for: request) + let httpResponse = response as! HTTPURLResponse + + guard httpResponse.statusCode == 201 else { + print("Bad response from server: \(httpResponse.statusCode)") + throw NetworkError.invalidResponse(httpResponse.statusCode) + } + } + + func deleteUserAccount() async throws -> Void { + guard let token = try? await supabaseClient.auth.session.accessToken else { + throw NetworkError.authError + } + + let request = + SupabaseRequestBuilder(endpoint: .deleteme) + .setAuthorization(with: token) .setMethod(to: "POST") .build() - let (_, response) = try await URLSession.shared.data(for: request) - let httpResponse = response as! HTTPURLResponse + let (_, response) = try await URLSession.shared.data(for: request) + let httpResponse = response as! HTTPURLResponse + + guard httpResponse.statusCode == 204 else { + print("Bad response from server: \(httpResponse.statusCode)") + throw NetworkError.invalidResponse(httpResponse.statusCode) + } + } + + func registerDevice( + deviceId: String, + platform: String? = nil, + osVersion: String? = nil, + appVersion: String? = nil, + markInternal: Bool? = nil, + timezone: String? = nil, + localeRegion: String? = nil, + preferredLanguage: String? = nil, + storeCountry: String? = nil + ) async throws -> Bool { + guard let token = try? await supabaseClient.auth.session.accessToken else { + throw NetworkError.authError + } + + var requestBody: [String: Any] = ["deviceId": deviceId] + if let platform = platform { + requestBody["platform"] = platform + } + if let osVersion = osVersion { + requestBody["osVersion"] = osVersion + } + if let appVersion = appVersion { + requestBody["appVersion"] = appVersion + } + if let markInternal = markInternal { + requestBody["markInternal"] = markInternal + } + if let timezone = timezone { + requestBody["timezone"] = timezone + } + if let localeRegion = localeRegion { + requestBody["localeRegion"] = localeRegion + } + if let preferredLanguage = preferredLanguage { + requestBody["preferredLanguage"] = preferredLanguage + } + if let storeCountry = storeCountry { + requestBody["storeCountry"] = storeCountry + } + + let requestBodyData = try JSONSerialization.data(withJSONObject: requestBody, options: []) + + let request = SupabaseRequestBuilder(endpoint: .devices_register) + .setAuthorization(with: token) + .setMethod(to: "POST") + .setJsonBody(to: requestBodyData) + .build() + + let (data, response) = try await URLSession.shared.data(for: request) + let httpResponse = response as! HTTPURLResponse + + guard httpResponse.statusCode == 200 else { + print("Failed to register device: \(httpResponse.statusCode)") + throw NetworkError.invalidResponse(httpResponse.statusCode) + } + + struct RegisterDeviceResponse: Codable { + let is_internal: Bool + } + + do { + let response = try JSONDecoder().decode(RegisterDeviceResponse.self, from: data) + return response.is_internal + } catch { + print("Failed to decode register device response: \(error)") + throw NetworkError.decodingError + } + } + + func registerDeviceAfterLogin(deviceId: String, completion: @escaping (Bool?) -> Void) { + Task.detached { + do { + let platform = UIDevice.current.systemName.lowercased() + let osVersion = UIDevice.current.systemVersion + let appVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String + + // Approximate location fields + let timezone = TimeZone.current.identifier + let localeRegion = Locale.current.region?.identifier + let preferredLanguage = Locale.preferredLanguages.first + let storeCountry = await Storefront.current?.countryCode + + #if targetEnvironment(simulator) || DEBUG + let markInternal = true + #else + let markInternal: Bool? = nil + #endif + + Log.debug("DEVICE", "Registering device - timezone: \(timezone), region: \(localeRegion ?? "nil"), language: \(preferredLanguage ?? "nil"), storeCountry: \(storeCountry ?? "nil")") + + let isInternal = try await self.registerDevice( + deviceId: deviceId, + platform: platform, + osVersion: osVersion, + appVersion: appVersion, + markInternal: markInternal, + timezone: timezone, + localeRegion: localeRegion, + preferredLanguage: preferredLanguage, + storeCountry: storeCountry + ) + + completion(isInternal) + } catch { + // Silently handle errors - fire-and-forget + print("Failed to register device after login: \(error)") + completion(nil) + } + } + } + + func markDeviceInternal(deviceId: String) async throws -> (device_id: String, affected_users: Int) { + guard let token = try? await supabaseClient.auth.session.accessToken else { + throw NetworkError.authError + } + + let requestBody = ["deviceId": deviceId] + let requestBodyData = try JSONSerialization.data(withJSONObject: requestBody, options: []) + + let request = SupabaseRequestBuilder(endpoint: .devices_mark_internal) + .setAuthorization(with: token) + .setMethod(to: "POST") + .setJsonBody(to: requestBodyData) + .build() + + let (data, response) = try await URLSession.shared.data(for: request) + let httpResponse = response as! HTTPURLResponse + + guard httpResponse.statusCode == 200 else { + print("Failed to mark device internal: \(httpResponse.statusCode)") + throw NetworkError.invalidResponse(httpResponse.statusCode) + } + + struct MarkInternalResponse: Codable { + let device_id: String + let affected_users: Int + } + + do { + let response = try JSONDecoder().decode(MarkInternalResponse.self, from: data) + return (response.device_id, response.affected_users) + } catch { + print("Failed to decode mark device internal response: \(error)") + throw NetworkError.decodingError + } + } + + func isDeviceInternal(deviceId: String) async throws -> Bool { + guard let token = try? await supabaseClient.auth.session.accessToken else { + throw NetworkError.authError + } + + let request = SupabaseRequestBuilder(endpoint: .devices_is_internal, itemId: deviceId) + .setAuthorization(with: token) + .setMethod(to: "GET") + .build() + + let (data, response) = try await URLSession.shared.data(for: request) + let httpResponse = response as! HTTPURLResponse + + guard httpResponse.statusCode == 200 else { + print("Failed to check device internal status: \(httpResponse.statusCode)") + throw NetworkError.invalidResponse(httpResponse.statusCode) + } + + struct IsInternalResponse: Codable { + let is_internal: Bool + } + + do { + let response = try JSONDecoder().decode(IsInternalResponse.self, from: data) + return response.is_internal + } catch { + print("Failed to decode is device internal response: \(error)") + throw NetworkError.decodingError + } + } + + // MARK: - Network Information Helpers + + private func getNetworkType() -> String { + let monitor = NWPathMonitor() + let semaphore = DispatchSemaphore(value: 0) + var networkType: String = "none" + + monitor.pathUpdateHandler = { path in + if path.status == .satisfied { + if path.usesInterfaceType(.wifi) { + networkType = "wifi" + } else if path.usesInterfaceType(.cellular) { + networkType = "cellular" + } else { + networkType = "other" + } + } else { + networkType = "none" + } + semaphore.signal() + } + + let queue = DispatchQueue(label: "NetworkMonitor") + monitor.start(queue: queue) + _ = semaphore.wait(timeout: .now() + 0.5) + monitor.cancel() + + return networkType + } + + private func getCellularGeneration() -> String { + let networkInfo = CTTelephonyNetworkInfo() + + guard let serviceCurrentRadioAccessTechnology = networkInfo.serviceCurrentRadioAccessTechnology, + !serviceCurrentRadioAccessTechnology.isEmpty else { + return "none" + } + + // Get the first available radio access technology + let radioAccessTechnology = serviceCurrentRadioAccessTechnology.values.first ?? "" + + switch radioAccessTechnology { + case CTRadioAccessTechnologyGPRS, + CTRadioAccessTechnologyEdge, + CTRadioAccessTechnologyCDMA1x: + return "3g" + case CTRadioAccessTechnologyWCDMA, + CTRadioAccessTechnologyHSDPA, + CTRadioAccessTechnologyHSUPA, + CTRadioAccessTechnologyCDMAEVDORev0, + CTRadioAccessTechnologyCDMAEVDORevA, + CTRadioAccessTechnologyCDMAEVDORevB, + CTRadioAccessTechnologyeHRPD: + return "3g" + case CTRadioAccessTechnologyLTE: + return "4g" + case CTRadioAccessTechnologyNRNSA, + CTRadioAccessTechnologyNR: + return "5g" + default: + return "unknown" + } + } + + private func getCarrier() -> String? { + let networkInfo = CTTelephonyNetworkInfo() + + guard let serviceSubscriberCellularProviders = networkInfo.serviceSubscriberCellularProviders, + !serviceSubscriberCellularProviders.isEmpty else { + return nil + } + + // Get the first available carrier + if let carrier = serviceSubscriberCellularProviders.values.first { + return carrier.carrierName + } + + return nil + } + + // MARK: - Ping API + + func pingFlyIO() { + Task.detached { + guard let url = URL(string: "\(Config.flyIOBaseURL)/ping") else { + return + } + + let appVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown" + let deviceModel = await UIDevice.current.model + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = 30 // Allow time for Fly.io cold start + + do { + let startTime = Date().timeIntervalSince1970 + let (_, response) = try await URLSession.shared.data(for: request) + let endTime = Date().timeIntervalSince1970 + let clientLatencyMs = (endTime - startTime) * 1000 + + let httpResponse = response as? HTTPURLResponse + if httpResponse?.statusCode == 204 { + let properties: [String: Any] = [ + "client_latency_ms": clientLatencyMs, + "app_version": appVersion, + "device_model": deviceModel + ] + PostHogSDK.shared.capture("ai_ping", properties: properties) + } + } catch { + // Non-critical, silently ignore + } + } + } + + func ping() { + Task.detached { [self] in + let appVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown" + let deviceModel = UIDevice.current.model + let networkType = self.getNetworkType() + let cellularGeneration = networkType == "cellular" ? self.getCellularGeneration() : "none" + let carrier = networkType == "cellular" ? self.getCarrier() : nil + + let request = SupabaseRequestBuilder(endpoint: .ingredicheck_ping) + .setMethod(to: "GET") + .build() + + do { + let startTime = Date().timeIntervalSince1970 + let (_, response) = try await URLSession.shared.data(for: request) + let endTime = Date().timeIntervalSince1970 + let clientLatencyMs = (endTime - startTime) * 1000 + + let httpResponse = response as? HTTPURLResponse + if httpResponse?.statusCode == 204 { + var properties: [String: Any] = [ + "client_latency_ms": clientLatencyMs, + "app_version": appVersion, + "device_model": deviceModel, + "network_type": networkType, + "cellular_generation": cellularGeneration + ] + + if let carrier = carrier, !carrier.isEmpty { + properties["carrier"] = carrier + } + + PostHogSDK.shared.capture("edge_ping", properties: properties) + } + } catch { + // Non-critical, silently ignore + } + } + } + + // MARK: - Food Notes API + + // Pretty-print helper for logging JSON responses in the console. + private func prettyPrintedJSON(from data: Data) -> String { + guard !data.isEmpty else { return "" } + + if let jsonObject = try? JSONSerialization.jsonObject(with: data), + JSONSerialization.isValidJSONObject(jsonObject), + let prettyData = try? JSONSerialization.data(withJSONObject: jsonObject, options: [.prettyPrinted]), + let prettyString = String(data: prettyData, encoding: .utf8) { + return prettyString + } + + // Fallback to raw UTF-8 string if not valid JSON + return String(data: data, encoding: .utf8) ?? "" + } + + struct FoodNotesResponse { + let content: [String: Any] + let version: Int + let updatedAt: String + } + + struct FoodNotesAllResponse { + let familyNote: FoodNotesResponse? + let memberNotes: [String: FoodNotesResponse] // Key is member ID + } + + struct VersionMismatchError: Error { + let currentNote: FoodNotesResponse + let expectedVersion: Int + } + + func fetchFoodNotes() async throws -> FoodNotesResponse? { + guard let token = try? await supabaseClient.auth.session.accessToken else { + throw NetworkError.authError + } + + Log.debug("WebService", "fetchFoodNotes: Starting GET request to /family/food-notes") + + let request = SupabaseRequestBuilder(endpoint: .family_food_notes) + .setAuthorization(with: token) + .setMethod(to: "GET") + .build() + + let (data, response) = try await URLSession.shared.data(for: request) + + Log.debug("WebService", "fetchFoodNotes: Raw response body (pretty-printed if JSON):\n\(prettyPrintedJSON(from: data))") + let httpResponse = response as! HTTPURLResponse + + guard httpResponse.statusCode == 200 else { + // 404 means no food notes exist yet, which is fine + if httpResponse.statusCode == 404 { + Log.debug("WebService", "fetchFoodNotes: No food notes found (404), returning nil") + return nil + } + let errorMessage = String(data: data, encoding: .utf8) ?? "Unknown error" + Log.error("WebService", "fetchFoodNotes: ❌ Failed with status \(httpResponse.statusCode): \(errorMessage)") + throw NetworkError.invalidResponse(httpResponse.statusCode) + } + + // Backend returns null if no food notes exist (status 200 with null body) + let responseString = String(data: data, encoding: .utf8) ?? "" + if responseString.trimmingCharacters(in: .whitespacesAndNewlines) == "null" || data.isEmpty { + Log.debug("WebService", "fetchFoodNotes: No food notes found (null response), returning nil") + return nil + } + + // Parse response - include content, version and updatedAt + guard let jsonObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let version = jsonObject["version"] as? Int, + let updatedAt = jsonObject["updatedAt"] as? String, + let content = jsonObject["content"] as? [String: Any] else { + Log.error("WebService", "fetchFoodNotes: ❌ Failed to parse response") + Log.debug("WebService", "fetchFoodNotes: Response body: \(responseString)") + throw NetworkError.decodingError + } + + Log.debug("WebService", "fetchFoodNotes: βœ… Success! Version: \(version), Content keys: \(content.keys.joined(separator: "), "))") + + return FoodNotesResponse(content: content, version: version, updatedAt: updatedAt) + } + + func fetchFoodNotesAll() async throws -> FoodNotesAllResponse? { + guard let token = try? await supabaseClient.auth.session.accessToken else { + throw NetworkError.authError + } + + Log.debug("WebService", "fetchFoodNotesAll: Starting GET request to /family/food-notes/all") + + let request = SupabaseRequestBuilder(endpoint: .family_food_notes_all) + .setAuthorization(with: token) + .setMethod(to: "GET") + .build() + + let (data, response) = try await URLSession.shared.data(for: request) + + Log.debug("WebService", "fetchFoodNotesAll: Raw response body (pretty-printed if JSON):\n\(prettyPrintedJSON(from: data))") + let httpResponse = response as! HTTPURLResponse + + guard httpResponse.statusCode == 200 else { + // 404 means no food notes exist yet, which is fine + if httpResponse.statusCode == 404 { + Log.debug("WebService", "fetchFoodNotesAll: No food notes found (404), returning nil") + return nil + } + let errorMessage = String(data: data, encoding: .utf8) ?? "Unknown error" + Log.error("WebService", "fetchFoodNotesAll: ❌ Failed with status \(httpResponse.statusCode): \(errorMessage)") + throw NetworkError.invalidResponse(httpResponse.statusCode) + } + + // Backend returns null if no food notes exist (status 200 with null body) + let responseString = String(data: data, encoding: .utf8) ?? "" + if responseString.trimmingCharacters(in: .whitespacesAndNewlines) == "null" || data.isEmpty { + Log.debug("WebService", "fetchFoodNotesAll: No food notes found (null response), returning nil") + return nil + } + + // Parse response - includes familyNote and memberNotes + guard let jsonObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + Log.error("WebService", "fetchFoodNotesAll: ❌ Failed to parse response") + Log.debug("WebService", "fetchFoodNotesAll: Response body: \(responseString)") + throw NetworkError.decodingError + } + + // Parse familyNote (can be null) + var familyNote: FoodNotesResponse? = nil + if let familyNoteDict = jsonObject["familyNote"] as? [String: Any], + let version = familyNoteDict["version"] as? Int, + let updatedAt = familyNoteDict["updatedAt"] as? String, + let content = familyNoteDict["content"] as? [String: Any] { + familyNote = FoodNotesResponse(content: content, version: version, updatedAt: updatedAt) + } + + // Parse memberNotes (dictionary of member ID -> FoodNotesResponse) + var memberNotes: [String: FoodNotesResponse] = [:] + if let memberNotesDict = jsonObject["memberNotes"] as? [String: [String: Any]] { + for (memberId, noteDict) in memberNotesDict { + if let version = noteDict["version"] as? Int, + let updatedAt = noteDict["updatedAt"] as? String, + let content = noteDict["content"] as? [String: Any] { + memberNotes[memberId] = FoodNotesResponse(content: content, version: version, updatedAt: updatedAt) + } + } + } + + Log.debug("WebService", "fetchFoodNotesAll: βœ… Success! Family note: \(familyNote != nil ? ")present" : "null"), Member notes: \(memberNotes.count)") + + return FoodNotesAllResponse(familyNote: familyNote, memberNotes: memberNotes) + } + + /// Fetches a summary of the user's food notes from the AI server. + /// Returns nil if no food notes exist (404) or if the response is empty. + func fetchFoodNotesSummary() async throws -> DTO.FoodNotesSummaryResponse? { + guard let token = try? await supabaseClient.auth.session.accessToken else { + throw NetworkError.authError + } + + Log.debug("WebService", "fetchFoodNotesSummary: Starting GET request to /family/food-notes/summary") + + let request = SupabaseRequestBuilder(endpoint: .family_food_notes_summary) + .setAuthorization(with: token) + .setMethod(to: "GET") + .build() + + let (data, response) = try await URLSession.shared.data(for: request) + + let httpResponse = response as! HTTPURLResponse + + guard httpResponse.statusCode == 200 else { + // 404 means no food notes exist yet + if httpResponse.statusCode == 404 { + Log.debug("WebService", "fetchFoodNotesSummary: No food notes found (404)") + return nil + } + let errorMessage = String(data: data, encoding: .utf8) ?? "Unknown error" + Log.error("WebService", "fetchFoodNotesSummary: ❌ Failed with status \(httpResponse.statusCode): \(errorMessage)") + throw NetworkError.invalidResponse(httpResponse.statusCode) + } + + // Check for null/empty response + let responseString = String(data: data, encoding: .utf8) ?? "" + if responseString.trimmingCharacters(in: .whitespacesAndNewlines) == "null" || data.isEmpty { + Log.debug("WebService", "fetchFoodNotesSummary: Empty response") + return nil + } - guard httpResponse.statusCode == 204 else { - print("Bad response from server: \(httpResponse.statusCode)") - throw NetworkError.invalidResponse(httpResponse.statusCode) + do { + let decoded = try JSONDecoder().decode(DTO.FoodNotesSummaryResponse.self, from: data) + Log.debug("WebService", "fetchFoodNotesSummary: βœ… Success - summary: \(decoded.summary.prefix(50))...") + return decoded + } catch { + Log.error("WebService", "fetchFoodNotesSummary: ❌ Decoding failed: \(error)") + throw NetworkError.decodingError } } - - func registerDevice(deviceId: String, platform: String? = nil, osVersion: String? = nil, appVersion: String? = nil, markInternal: Bool? = nil) async throws -> Bool { + + func updateFoodNotes(content: [String: Any], version: Int) async throws -> FoodNotesResponse { guard let token = try? await supabaseClient.auth.session.accessToken else { throw NetworkError.authError } - var requestBody: [String: Any] = ["deviceId": deviceId] - if let platform = platform { - requestBody["platform"] = platform - } - if let osVersion = osVersion { - requestBody["osVersion"] = osVersion - } - if let appVersion = appVersion { - requestBody["appVersion"] = appVersion - } - if let markInternal = markInternal { - requestBody["markInternal"] = markInternal - } + // Convert content to JSON + let requestBody: [String: Any] = [ + "content": content, + "version": version + ] let requestBodyData = try JSONSerialization.data(withJSONObject: requestBody, options: []) - let request = SupabaseRequestBuilder(endpoint: .devices_register) + let request = SupabaseRequestBuilder(endpoint: .family_food_notes) .setAuthorization(with: token) - .setMethod(to: "POST") + .setMethod(to: "PUT") .setJsonBody(to: requestBodyData) .build() let (data, response) = try await URLSession.shared.data(for: request) + + Log.debug("WebService", "updateFoodNotes: Raw response body (pretty-printed if JSON):\n\(prettyPrintedJSON(from: data))") let httpResponse = response as! HTTPURLResponse guard httpResponse.statusCode == 200 else { - print("Failed to register device: \(httpResponse.statusCode)") + let errorMessage = String(data: data, encoding: .utf8) ?? "Unknown error" + Log.error("WebService", "updateFoodNotes failed with status \(httpResponse.statusCode): \(errorMessage)") + + // Handle version mismatch (409 Conflict) - backend now returns currentNote in response. + // For family notes, currentNote may be null when there is no existing note yet. + if httpResponse.statusCode == 409 { + if let jsonObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + if let currentNoteDict = jsonObject["currentNote"] as? [String: Any], + let currentVersion = currentNoteDict["version"] as? Int, + let currentUpdatedAt = currentNoteDict["updatedAt"] as? String, + let currentContent = currentNoteDict["content"] as? [String: Any] { + let currentNote = FoodNotesResponse( + content: currentContent, + version: currentVersion, + updatedAt: currentUpdatedAt + ) + Log.debug("WebService", "updateFoodNotes: Version mismatch with existing note - current version: \(currentVersion), expected: \(version)") + throw VersionMismatchError(currentNote: currentNote, expectedVersion: version) + } else { + // currentNote is null or missing: treat this as "no existing note", + // so retry once with version=0 to create the family note. + Log.debug("WebService", "updateFoodNotes: version_mismatch with currentNote=null. Retrying once with version=0.") + return try await updateFoodNotes(content: content, version: 0) + } + } + } + throw NetworkError.invalidResponse(httpResponse.statusCode) } - struct RegisterDeviceResponse: Codable { - let is_internal: Bool - } - - do { - let response = try JSONDecoder().decode(RegisterDeviceResponse.self, from: data) - return response.is_internal - } catch { - print("Failed to decode register device response: \(error)") + // Parse response - include content, version and updatedAt + guard let jsonObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let version = jsonObject["version"] as? Int, + let updatedAt = jsonObject["updatedAt"] as? String, + let content = jsonObject["content"] as? [String: Any] else { + Log.error("WebService", "updateFoodNotes: Failed to parse response") throw NetworkError.decodingError } + + return FoodNotesResponse(content: content, version: version, updatedAt: updatedAt) } - func registerDeviceAfterLogin(deviceId: String, completion: @escaping (Bool?) -> Void) { - Task.detached { - do { - let platform = UIDevice.current.systemName.lowercased() - let osVersion = UIDevice.current.systemVersion - let appVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String - - #if targetEnvironment(simulator) || DEBUG - let markInternal = true - #else - let markInternal: Bool? = nil - #endif - - let isInternal = try await self.registerDevice( - deviceId: deviceId, - platform: platform, - osVersion: osVersion, - appVersion: appVersion, - markInternal: markInternal - ) - - completion(isInternal) - } catch { - // Silently handle errors - fire-and-forget - print("Failed to register device after login: \(error)") - completion(nil) - } - } - } + // MARK: - Member-specific Food Notes API - func markDeviceInternal(deviceId: String) async throws -> (device_id: String, affected_users: Int) { + /// Fetch food notes for a specific family member by ID. + func fetchMemberFoodNotes(memberId: String) async throws -> FoodNotesResponse? { guard let token = try? await supabaseClient.auth.session.accessToken else { throw NetworkError.authError } - let requestBody = ["deviceId": deviceId] - let requestBodyData = try JSONSerialization.data(withJSONObject: requestBody, options: []) + Log.debug("WebService", "fetchMemberFoodNotes: Starting GET request to /family/members/\(memberId)/food-notes") - let request = SupabaseRequestBuilder(endpoint: .devices_mark_internal) + let request = SupabaseRequestBuilder(endpoint: .family_member_food_notes, itemId: memberId) .setAuthorization(with: token) - .setMethod(to: "POST") - .setJsonBody(to: requestBodyData) + .setMethod(to: "GET") .build() let (data, response) = try await URLSession.shared.data(for: request) + + Log.debug("WebService", "fetchMemberFoodNotes: Raw response body (pretty-printed if JSON):\n\(prettyPrintedJSON(from: data))") let httpResponse = response as! HTTPURLResponse guard httpResponse.statusCode == 200 else { - print("Failed to mark device internal: \(httpResponse.statusCode)") + // 404 means no food notes exist yet for this member, which is fine + if httpResponse.statusCode == 404 { + Log.debug("WebService", "fetchMemberFoodNotes: No member food notes found (404), returning nil") + return nil + } + let errorMessage = String(data: data, encoding: .utf8) ?? "Unknown error" + Log.error("WebService", "fetchMemberFoodNotes: ❌ Failed with status \(httpResponse.statusCode): \(errorMessage)") throw NetworkError.invalidResponse(httpResponse.statusCode) } - struct MarkInternalResponse: Codable { - let device_id: String - let affected_users: Int + // Backend returns null if no food notes exist (status 200 with null body) + let responseString = String(data: data, encoding: .utf8) ?? "" + if responseString.trimmingCharacters(in: .whitespacesAndNewlines) == "null" || data.isEmpty { + Log.debug("WebService", "fetchMemberFoodNotes: No food notes found (null response), returning nil") + return nil } - do { - let response = try JSONDecoder().decode(MarkInternalResponse.self, from: data) - return (response.device_id, response.affected_users) - } catch { - print("Failed to decode mark device internal response: \(error)") + // Parse response - include content, version and updatedAt + guard let jsonObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let version = jsonObject["version"] as? Int, + let updatedAt = jsonObject["updatedAt"] as? String, + let content = jsonObject["content"] as? [String: Any] else { + Log.error("WebService", "fetchMemberFoodNotes: ❌ Failed to parse response") + Log.debug("WebService", "fetchMemberFoodNotes: Response body: \(responseString)") throw NetworkError.decodingError } + + Log.debug("WebService", "fetchMemberFoodNotes: βœ… Success! Version: \(version), Content keys: \(content.keys.joined(separator: "), "))") + + return FoodNotesResponse(content: content, version: version, updatedAt: updatedAt) } - func isDeviceInternal(deviceId: String) async throws -> Bool { + /// Update food notes for a specific family member by ID. + func updateMemberFoodNotes(memberId: String, content: [String: Any], version: Int) async throws -> FoodNotesResponse { guard let token = try? await supabaseClient.auth.session.accessToken else { throw NetworkError.authError } - let request = SupabaseRequestBuilder(endpoint: .devices_is_internal, itemId: deviceId) + // Convert content to JSON + let requestBody: [String: Any] = [ + "content": content, + "version": version + ] + + let requestBodyData = try JSONSerialization.data(withJSONObject: requestBody, options: []) + + let request = SupabaseRequestBuilder(endpoint: .family_member_food_notes, itemId: memberId) .setAuthorization(with: token) - .setMethod(to: "GET") + .setMethod(to: "PUT") + .setJsonBody(to: requestBodyData) .build() let (data, response) = try await URLSession.shared.data(for: request) + + Log.debug("WebService", "updateMemberFoodNotes: Raw response body (pretty-printed if JSON):\n\(prettyPrintedJSON(from: data))") let httpResponse = response as! HTTPURLResponse guard httpResponse.statusCode == 200 else { - print("Failed to check device internal status: \(httpResponse.statusCode)") + let errorMessage = String(data: data, encoding: .utf8) ?? "Unknown error" + Log.error("WebService", "updateMemberFoodNotes failed with status \(httpResponse.statusCode): \(errorMessage)") + + // Handle version mismatch (409 Conflict). + // For member notes, the backend may return { "error": "version_mismatch", "currentNote": null } + // when there is no existing note yet. In that case we should retry once with version=0. + if httpResponse.statusCode == 409 { + if let jsonObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + if let currentNoteDict = jsonObject["currentNote"] as? [String: Any], + let currentVersion = currentNoteDict["version"] as? Int, + let currentUpdatedAt = currentNoteDict["updatedAt"] as? String, + let currentContent = currentNoteDict["content"] as? [String: Any] { + let currentNote = FoodNotesResponse( + content: currentContent, + version: currentVersion, + updatedAt: currentUpdatedAt + ) + Log.debug("WebService", "updateMemberFoodNotes: Version mismatch with existing note - current version: \(currentVersion), expected: \(version)") + throw VersionMismatchError(currentNote: currentNote, expectedVersion: version) + } else { + // currentNote is null or missing: treat this as "no existing note", + // so retry once with version=0 to create the member note. + Log.debug("WebService", "updateMemberFoodNotes: version_mismatch with currentNote=null. Retrying once with version=0.") + return try await updateMemberFoodNotes(memberId: memberId, content: content, version: 0) + } + } + } + throw NetworkError.invalidResponse(httpResponse.statusCode) } - struct IsInternalResponse: Codable { - let is_internal: Bool - } - - do { - let response = try JSONDecoder().decode(IsInternalResponse.self, from: data) - return response.is_internal - } catch { - print("Failed to decode is device internal response: \(error)") + // Parse response - include content, version and updatedAt + guard let jsonObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let version = jsonObject["version"] as? Int, + let updatedAt = jsonObject["updatedAt"] as? String, + let content = jsonObject["content"] as? [String: Any] else { + Log.error("WebService", "updateMemberFoodNotes: Failed to parse response") + Log.debug("WebService", "updateMemberFoodNotes: Response body: \(String(data: data, encoding: .utf8) ?? "nil")") throw NetworkError.decodingError } + + return FoodNotesResponse(content: content, version: version, updatedAt: updatedAt) } - - // MARK: - Network Information Helpers - - private func getNetworkType() -> String { - let monitor = NWPathMonitor() - let semaphore = DispatchSemaphore(value: 0) - var networkType: String = "none" + // MARK: - Feedback API + + func submitFeedback(request: DTO.FeedbackRequest) async throws -> DTO.Scan { + guard let token = try? await supabaseClient.auth.session.accessToken else { + throw NetworkError.authError + } - monitor.pathUpdateHandler = { path in - if path.status == .satisfied { - if path.usesInterfaceType(.wifi) { - networkType = "wifi" - } else if path.usesInterfaceType(.cellular) { - networkType = "cellular" - } else { - networkType = "other" - } - } else { - networkType = "none" - } - semaphore.signal() + let requestBody = try JSONEncoder().encode(request) + if let jsonString = String(data: requestBody, encoding: .utf8) { + Log.debug("WebService", "submitFeedback Request Body: \(jsonString)") } - let queue = DispatchQueue(label: "NetworkMonitor") - monitor.start(queue: queue) - _ = semaphore.wait(timeout: .now() + 0.5) - monitor.cancel() + let urlRequest = SupabaseRequestBuilder(endpoint: .scan_feedback) + .setAuthorization(with: token) + .setMethod(to: "POST") + .setJsonBody(to: requestBody) + .build() - return networkType - } - - private func getCellularGeneration() -> String { - let networkInfo = CTTelephonyNetworkInfo() + Log.debug("WebService", "submitFeedback URL: \(urlRequest.url?.absoluteString ?? "nil")") - guard let serviceCurrentRadioAccessTechnology = networkInfo.serviceCurrentRadioAccessTechnology, - !serviceCurrentRadioAccessTechnology.isEmpty else { - return "none" - } + let (data, response) = try await URLSession.shared.data(for: urlRequest) - // Get the first available radio access technology - let radioAccessTechnology = serviceCurrentRadioAccessTechnology.values.first ?? "" + guard let httpResponse = response as? HTTPURLResponse else { + throw NetworkError.invalidResponse(0) + } - switch radioAccessTechnology { - case CTRadioAccessTechnologyGPRS, - CTRadioAccessTechnologyEdge, - CTRadioAccessTechnologyCDMA1x: - return "3g" - case CTRadioAccessTechnologyWCDMA, - CTRadioAccessTechnologyHSDPA, - CTRadioAccessTechnologyHSUPA, - CTRadioAccessTechnologyCDMAEVDORev0, - CTRadioAccessTechnologyCDMAEVDORevA, - CTRadioAccessTechnologyCDMAEVDORevB, - CTRadioAccessTechnologyeHRPD: - return "3g" - case CTRadioAccessTechnologyLTE: - return "4g" - case CTRadioAccessTechnologyNRNSA, - CTRadioAccessTechnologyNR: - return "5g" - default: - return "unknown" + if httpResponse.statusCode == 200 { + if let responseString = String(data: data, encoding: .utf8) { + Log.debug("WebService", "submitFeedback Response Body: \(responseString)") + } + do { + let scan = try JSONDecoder().decode(DTO.Scan.self, from: data) + return scan + } catch { + print("Decoding error: \(error)") + throw NetworkError.decodingError + } + } else { + print("Feedback API Error Status: \(httpResponse.statusCode)") + throw NetworkError.invalidResponse(httpResponse.statusCode) } } - private func getCarrier() -> String? { - let networkInfo = CTTelephonyNetworkInfo() - - guard let serviceSubscriberCellularProviders = networkInfo.serviceSubscriberCellularProviders, - !serviceSubscriberCellularProviders.isEmpty else { - return nil + func updateFeedback(feedbackId: String, vote: String) async throws -> DTO.Scan { + guard let token = try? await supabaseClient.auth.session.accessToken else { + throw NetworkError.authError } - // Get the first available carrier - if let carrier = serviceSubscriberCellularProviders.values.first { - return carrier.carrierName + let updateRequest = DTO.FeedbackUpdateRequest(vote: vote) + let requestBody = try JSONEncoder().encode(updateRequest) + if let jsonString = String(data: requestBody, encoding: .utf8) { + Log.debug("WebService", "updateFeedback Request Body: \(jsonString)") } - return nil - } - - // MARK: - Ping API - - func ping() { - Task.detached { [self] in - let appVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown" - let deviceModel = UIDevice.current.model - let networkType = self.getNetworkType() - let cellularGeneration = networkType == "cellular" ? self.getCellularGeneration() : "none" - let carrier = networkType == "cellular" ? self.getCarrier() : nil - - guard let token = try? await supabaseClient.auth.session.accessToken else { - return - } - - let request = SupabaseRequestBuilder(endpoint: .ingredicheck_ping) - .setAuthorization(with: token) - .setMethod(to: "GET") - .build() - - do { - let startTime = Date().timeIntervalSince1970 - let (_, response) = try await URLSession.shared.data(for: request) - let endTime = Date().timeIntervalSince1970 - let clientLatencyMs = (endTime - startTime) * 1000 - - let httpResponse = response as? HTTPURLResponse - if httpResponse?.statusCode == 204 { - var properties: [String: Any] = [ - "client_latency_ms": clientLatencyMs, - "app_version": appVersion, - "device_model": deviceModel, - "network_type": networkType, - "cellular_generation": cellularGeneration - ] - - if let carrier = carrier, !carrier.isEmpty { - properties["carrier"] = carrier - } - - print("edge_ping properties: \(properties)") - PostHogSDK.shared.capture("edge_ping", properties: properties) - } - } catch { - print("Ping API call failed: \(error.localizedDescription)") - } + let urlRequest = SupabaseRequestBuilder(endpoint: .scan_feedback_update, itemId: feedbackId) + .setAuthorization(with: token) + .setMethod(to: "PATCH") + .setJsonBody(to: requestBody) + .build() + + Log.debug("WebService", "updateFeedback URL: \(urlRequest.url?.absoluteString ?? "nil")") + + let (data, response) = try await URLSession.shared.data(for: urlRequest) + + guard let httpResponse = response as? HTTPURLResponse else { + throw NetworkError.invalidResponse(0) + } + + if httpResponse.statusCode == 200 { + if let responseString = String(data: data, encoding: .utf8) { + Log.debug("WebService", "updateFeedback Response Body: \(responseString)") + } + do { + let scan = try JSONDecoder().decode(DTO.Scan.self, from: data) + return scan + } catch { + print("Decoding error: \(error)") + throw NetworkError.decodingError + } + } else { + throw NetworkError.invalidResponse(httpResponse.statusCode) } } } diff --git a/IngrediCheck/backend/AuthAPI.swift b/IngrediCheck/backend/AuthAPI.swift new file mode 100644 index 00000000..9ddd0b33 --- /dev/null +++ b/IngrediCheck/backend/AuthAPI.swift @@ -0,0 +1,46 @@ +import Foundation + +struct AuthAPI { + private static let apiKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" + + static func signupEmptyBody(baseURL: String) async throws -> (statusCode: Int, body: String) { + let urlString = baseURL.hasSuffix("/") ? baseURL + "auth/v1/signup" : baseURL + "/auth/v1/signup" + let url = urlString.hasPrefix("http") ? URL(string: urlString)! : URL(string: "http://\(urlString)")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue(apiKey, forHTTPHeaderField: "apikey") + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + // Explicitly send an empty body + request.httpBody = Data() + + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) + } + let bodyString = String(data: data, encoding: .utf8) ?? "" + return (http.statusCode, bodyString) + } + + static func signupAnonymous(baseURL: String) async throws -> (statusCode: Int, body: String) { + let urlString = baseURL.hasSuffix("/") ? baseURL + "auth/v1/signup" : baseURL + "/auth/v1/signup" + let url = urlString.hasPrefix("http") ? URL(string: urlString)! : URL(string: "http://\(urlString)")! + var components = URLComponents(url: url, resolvingAgainstBaseURL: false)! + components.queryItems = [URLQueryItem(name: "provider", value: "anonymous")] + var request = URLRequest(url: components.url!) + request.httpMethod = "POST" + request.setValue(apiKey, forHTTPHeaderField: "apikey") + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + // Send empty JSON object as body + request.httpBody = Data("{}".utf8) + + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) + } + let bodyString = String(data: data, encoding: .utf8) ?? "" + return (http.statusCode, bodyString) + } +} + + diff --git a/IngrediCheck/backend/FamilyAPI.swift b/IngrediCheck/backend/FamilyAPI.swift new file mode 100644 index 00000000..84831e9d --- /dev/null +++ b/IngrediCheck/backend/FamilyAPI.swift @@ -0,0 +1,313 @@ +import Foundation + +struct FamilyAPI { + static func makeRequest( + baseURL: String, + path: String, + method: String, + apiKey: String, + jwt: String, + body: [String: Any]? = nil + ) async throws -> (statusCode: Int, body: String) { + let urlString = baseURL.hasSuffix("/") ? baseURL + path : baseURL + "/" + path + let url = urlString.hasPrefix("http") ? URL(string: urlString)! : URL(string: "http://\(urlString)")! + + Log.debug("FamilyAPI", "πŸ”΅ makeRequest - Method: \(method), Path: \(path)") + Log.debug("FamilyAPI", "πŸ“‘ URL: \(url.absoluteString)") + + var request = URLRequest(url: url) + request.httpMethod = method + request.setValue(apiKey, forHTTPHeaderField: "apikey") + request.setValue("Bearer \(jwt)", forHTTPHeaderField: "Authorization") + + // Configure timeouts to prevent connection loss + request.timeoutInterval = 30.0 // 30 seconds timeout + + if let body = body { + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + do { + let bodyData = try JSONSerialization.data(withJSONObject: body) + request.httpBody = bodyData + Log.debug("FamilyAPI", "πŸ“¦ Request body size: \(bodyData.count) bytes") + } catch { + Log.debug("FamilyAPI", "❌ Failed to serialize request body: \(error)") + throw error + } + } + + Log.debug("FamilyAPI", "πŸ”‘ Headers - apikey: \(apiKey.prefix(10))..., Authorization: Bearer \(jwt.prefix(20))...") + + // Retry logic for connection loss errors + var lastError: Error? + let maxRetries = 3 + + for attempt in 1...maxRetries { + do { + Log.debug("FamilyAPI", "⏳ Sending request (attempt \(attempt)/\(maxRetries))...") + let (data, response) = try await URLSession.shared.data(for: request) + lastError = nil // Clear error on success + + guard let http = response as? HTTPURLResponse else { + Log.debug("FamilyAPI", "❌ Invalid response type: \(type(of: response))") + throw URLError(.badServerResponse) + } + + let bodyString = String(data: data, encoding: .utf8) ?? "" + Log.debug("FamilyAPI", "βœ… Response received - Status: \(http.statusCode), Body length: \(bodyString.count) chars") + + if http.statusCode >= 400 { + Log.debug("FamilyAPI", "❌ Error response - Status: \(http.statusCode)") + Log.debug("FamilyAPI", "πŸ“„ Error body: \(bodyString.prefix(500))") + } else { + Log.debug("FamilyAPI", "βœ… Success response - Status: \(http.statusCode)") + if !bodyString.isEmpty { + Log.debug("FamilyAPI", "πŸ“„ Response body (first 500 chars): \(bodyString.prefix(500))") + } + } + + return (http.statusCode, bodyString) + } catch { + lastError = error + Log.debug("FamilyAPI", "❌ Network error on attempt \(attempt): \(error.localizedDescription)") + + if let urlError = error as? URLError { + Log.debug("FamilyAPI", "❌ URLError code: \(urlError.code.rawValue), description: \(urlError.localizedDescription)") + + // Retry on connection loss errors (-1005, -1001 timeout, -1009 no internet) + let retryableErrors: [URLError.Code] = [.networkConnectionLost, .timedOut, .notConnectedToInternet] + + if retryableErrors.contains(urlError.code) && attempt < maxRetries { + let delay = UInt64(1_000_000_000 * UInt64(attempt)) // 1s, 2s, 3s + Log.debug("FamilyAPI", "πŸ”„ Retrying after \(attempt) second(s)...") + try? await Task.sleep(nanoseconds: delay) + continue // Retry the request + } + } + + // If not retryable or max retries reached, throw the error + if attempt == maxRetries { + Log.debug("FamilyAPI", "❌ Max retries reached, throwing error") + throw error + } + } + } + + // This should never be reached, but just in case + if let error = lastError { + throw error + } + + throw URLError(.unknown) + } + + // POST /ingredicheck/family + static func createFamily( + baseURL: String, + apiKey: String, + jwt: String, + name: String, + selfMember: [String: Any], + otherMembers: [[String: Any]]? = nil + ) async throws -> (statusCode: Int, body: String) { + Log.debug("FamilyAPI", "πŸ”΅ createFamily called") + Log.debug("FamilyAPI", "πŸ“ Parameters - name: \(name), selfMember: \(selfMember), otherMembers count: \(otherMembers?.count ?? 0)") + + var body: [String: Any] = [ + "name": name, + "selfMember": selfMember + ] + if let otherMembers = otherMembers { + body["otherMembers"] = otherMembers + } + + // Log request body for debugging + if let jsonData = try? JSONSerialization.data(withJSONObject: body, options: .prettyPrinted), + let jsonString = String(data: jsonData, encoding: .utf8) { + Log.debug("FamilyAPI", "πŸ“¦ createFamily request body: \(jsonString)") + } else { + Log.debug("FamilyAPI", "⚠️ Failed to serialize request body to JSON string") + } + + return try await makeRequest( + baseURL: baseURL, + path: "family", + method: "POST", + apiKey: apiKey, + jwt: jwt, + body: body + ) + } + + // GET /ingredicheck/family + static func getFamily( + baseURL: String, + apiKey: String, + jwt: String + ) async throws -> (statusCode: Int, body: String) { + return try await makeRequest( + baseURL: baseURL, + path: "family", + method: "GET", + apiKey: apiKey, + jwt: jwt, + body: nil + ) + } + + // POST /ingredicheck/family/invite + static func createInvite( + baseURL: String, + apiKey: String, + jwt: String, + memberID: String + ) async throws -> (statusCode: Int, body: String) { + let body: [String: Any] = ["memberID": memberID] + return try await makeRequest( + baseURL: baseURL, + path: "family/invite", + method: "POST", + apiKey: apiKey, + jwt: jwt, + body: body + ) + } + + // POST /ingredicheck/family/join + static func joinFamily( + baseURL: String, + apiKey: String, + jwt: String, + inviteCode: String + ) async throws -> (statusCode: Int, body: String) { + let body: [String: Any] = ["inviteCode": inviteCode] + return try await makeRequest( + baseURL: baseURL, + path: "family/join", + method: "POST", + apiKey: apiKey, + jwt: jwt, + body: body + ) + } + + // POST /ingredicheck/family/leave + static func leaveFamily( + baseURL: String, + apiKey: String, + jwt: String + ) async throws -> (statusCode: Int, body: String) { + return try await makeRequest( + baseURL: baseURL, + path: "family/leave", + method: "POST", + apiKey: apiKey, + jwt: jwt, + body: nil + ) + } + + // POST /ingredicheck/family/members + static func addMember( + baseURL: String, + apiKey: String, + jwt: String, + member: [String: Any] + ) async throws -> (statusCode: Int, body: String) { + return try await makeRequest( + baseURL: baseURL, + path: "family/members", + method: "POST", + apiKey: apiKey, + jwt: jwt, + body: member + ) + } + + // PATCH /ingredicheck/family/members/:id + static func editMember( + baseURL: String, + apiKey: String, + jwt: String, + memberID: String, + member: [String: Any] + ) async throws -> (statusCode: Int, body: String) { + return try await makeRequest( + baseURL: baseURL, + path: "family/members/\(memberID)", + method: "PATCH", + apiKey: apiKey, + jwt: jwt, + body: member + ) + } + + // DELETE /ingredicheck/family/members/:id + static func deleteMember( + baseURL: String, + apiKey: String, + jwt: String, + memberID: String + ) async throws -> (statusCode: Int, body: String) { + return try await makeRequest( + baseURL: baseURL, + path: "family/members/\(memberID)", + method: "DELETE", + apiKey: apiKey, + jwt: jwt, + body: nil + ) + } + + // POST /ingredicheck/family/personal + static func createPersonalFamily( + baseURL: String, + apiKey: String, + jwt: String, + name: String, + memberID: String + ) async throws -> (statusCode: Int, body: String) { + let body: [String: Any] = [ + "name": name, + "memberID": memberID + ] + return try await makeRequest( + baseURL: baseURL, + path: "family/personal", + method: "POST", + apiKey: apiKey, + jwt: jwt, + body: body + ) + } + + // PATCH /ingredicheck/family + static func updateFamily( + baseURL: String, + apiKey: String, + jwt: String, + name: String + ) async throws -> (statusCode: Int, body: String) { + Log.debug("FamilyAPI", "πŸ”΅ updateFamily called") + Log.debug("FamilyAPI", "πŸ“ Parameters - name: \(name)") + + let body: [String: Any] = ["name": name] + + // Log request body for debugging + if let jsonData = try? JSONSerialization.data(withJSONObject: body, options: .prettyPrinted), + let jsonString = String(data: jsonData, encoding: .utf8) { + Log.debug("FamilyAPI", "πŸ“¦ updateFamily request body: \(jsonString)") + } else { + Log.debug("FamilyAPI", "⚠️ Failed to serialize request body to JSON string") + } + + return try await makeRequest( + baseURL: baseURL, + path: "family", + method: "PATCH", + apiKey: apiKey, + jwt: jwt, + body: body + ) + } +} + diff --git a/IngrediCheck/backend/SignupTestView.swift b/IngrediCheck/backend/SignupTestView.swift new file mode 100644 index 00000000..fbd5e093 --- /dev/null +++ b/IngrediCheck/backend/SignupTestView.swift @@ -0,0 +1,491 @@ +import SwiftUI + +struct SignupTestView: View { + @State private var output: String = "" + @State private var isLoading: Bool = false + @State private var baseURL: String = "192.168.1.9:54321/functions/v1" + @State private var apiKey: String = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" + @State private var jwt: String = "" + + @State private var uuid: String = UUID().uuidString + @State private var name: String = "" + @State private var nickname: String = "" + @State private var info: String = "" + @State private var color: String = "" + @State private var memberID: String = "" + @State private var inviteCode: String = "" + + @State private var currentLoadingButton: String? = nil + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + // API Key and JWT fields + VStack(alignment: .leading, spacing: 8) { + Text("Base URL") + .font(.headline) + TextField("Base URL (e.g., 127.0.0.1:54321/functions/v1)", text: $baseURL) + .textFieldStyle(.roundedBorder) + .autocapitalization(.none) + .autocorrectionDisabled() + + Text("⚠️ For physical device: Use your Mac's IP (e.g., 192.168.1.100:54321/functions/v1)") + .font(.caption) + .foregroundColor(.orange) + + Text("API Key") + .font(.headline) + TextField("API Key", text: $apiKey) + .textFieldStyle(.roundedBorder) + + Text("JWT Token") + .font(.headline) + TextField("JWT Token", text: $jwt) + .textFieldStyle(.roundedBorder) + + Button("Get JWT from Anonymous Signup") { + Task { + isLoading = true + defer { isLoading = false } + do { + let authBaseURL = baseURL.replacingOccurrences(of: "/functions/v1", with: "") + let result = try await AuthAPI.signupAnonymous(baseURL: authBaseURL) + if + let data = result.body.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let token = json["access_token"] as? String { + jwt = token + output = formatResponse(statusCode: result.statusCode, body: result.body) + } else { + output = formatResponse(statusCode: result.statusCode, body: result.body) + } + } catch { + output = formatError(error) + } + } + } + .buttonStyle(.borderedProminent) + .disabled(isLoading) + } + + Divider() + + // Input fields + VStack(alignment: .leading, spacing: 8) { + Text("Input Fields") + .font(.headline) + + HStack { + TextField("UUID", text: $uuid) + .textFieldStyle(.roundedBorder) + + Button(action: { + uuid = UUID().uuidString + }) { + Image(systemName: "arrow.clockwise") + .foregroundColor(.blue) + } + .buttonStyle(.plain) + } + + TextField("Name", text: $name) + .textFieldStyle(.roundedBorder) + + TextField("Nickname (comma-separated)", text: $nickname) + .textFieldStyle(.roundedBorder) + + TextField("Info", text: $info) + .textFieldStyle(.roundedBorder) + + TextField("Color", text: $color) + .textFieldStyle(.roundedBorder) + + TextField("Member ID", text: $memberID) + .textFieldStyle(.roundedBorder) + + TextField("Invite Code", text: $inviteCode) + .textFieldStyle(.roundedBorder) + } + + Divider() + + // API Buttons + VStack(alignment: .leading, spacing: 8) { + Text("Family API Endpoints") + .font(.headline) + + Button("Create Family") { + callCreateFamily() + } + .buttonStyle(.bordered) + .disabled(isLoading || currentLoadingButton != nil) + + Button("Get Family") { + callGetFamily() + } + .buttonStyle(.bordered) + .disabled(isLoading || currentLoadingButton != nil) + + Button("Create Invite") { + callCreateInvite() + } + .buttonStyle(.bordered) + .disabled(isLoading || currentLoadingButton != nil) + + Button("Join Family") { + callJoinFamily() + } + .buttonStyle(.bordered) + .disabled(isLoading || currentLoadingButton != nil) + + Button("Leave Family") { + callLeaveFamily() + } + .buttonStyle(.bordered) + .disabled(isLoading || currentLoadingButton != nil) + + Button("Add Member") { + callAddMember() + } + .buttonStyle(.bordered) + .disabled(isLoading || currentLoadingButton != nil) + + Button("Edit Member") { + callEditMember() + } + .buttonStyle(.bordered) + .disabled(isLoading || currentLoadingButton != nil) + + Button("Delete Member") { + callDeleteMember() + } + .buttonStyle(.bordered) + .disabled(isLoading || currentLoadingButton != nil) + } + + Divider() + + // Output + VStack(alignment: .leading, spacing: 8) { + Text("Output") + .font(.headline) + + ScrollView { + Text(output.isEmpty ? "No output yet." : output) + .font(.system(.body, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + } + .frame(height: 500) + .background(Color(UIColor.secondarySystemBackground)) + .cornerRadius(8) + } + } + .padding() + } + } + + private func formatError(_ error: Error) -> String { + let errorMsg = error.localizedDescription + if errorMsg.contains("1004") || errorMsg.contains("Connection refused") || errorMsg.contains("61") || errorMsg.contains("Could not connect") { + return """ + Error: Connection Refused + + This usually means: + 1. Your Supabase server is not running + 2. You're on a physical device using 127.0.0.1 (use your Mac's IP instead) + 3. Device and Mac are not on the same WiFi network + + To find your Mac's IP: + - Open Terminal and run: ifconfig | grep "inet " | grep -v 127.0.0.1 + - Or: System Settings β†’ Network β†’ Wi-Fi β†’ Details + + Then update Base URL to: YOUR_IP:54321/functions/v1 + Example: 192.168.1.100:54321/functions/v1 + + Original error: \(errorMsg) + """ + } else { + return "Error: \(errorMsg)" + } + } + + private func formatResponse(statusCode: Int, body: String) -> String { + var formattedOutput = "Status: \(statusCode)\n\n" + + // Try to pretty print JSON + if !body.isEmpty, + let data = body.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data, options: []), + let prettyData = try? JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys]), + let prettyString = String(data: prettyData, encoding: .utf8) { + formattedOutput += "Body:\n\(prettyString)" + } else { + // If not valid JSON or empty, just show the raw body + if body.isEmpty { + formattedOutput += "Body: (empty)" + } else { + formattedOutput += "Body:\n\(body)" + } + } + + return formattedOutput + } + + private func callCreateFamily() { + guard !jwt.isEmpty, !name.isEmpty, !color.isEmpty, !uuid.isEmpty else { + output = "Error: JWT, name, color, and UUID are required" + return + } + + currentLoadingButton = "createFamily" + Task { + isLoading = true + defer { + isLoading = false + currentLoadingButton = nil + } + + do { + let nicknames = nickname.isEmpty ? [] : nickname.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) } + var selfMember: [String: Any] = [ + "id": uuid, + "name": name, + "nicknames": nicknames, + "color": color + ] + if !info.isEmpty { + selfMember["info"] = info + } + + let result = try await FamilyAPI.createFamily( + baseURL: baseURL, + apiKey: apiKey, + jwt: jwt, + name: name, + selfMember: selfMember + ) + output = formatResponse(statusCode: result.statusCode, body: result.body) + } catch { + output = formatError(error) + } + } + } + + private func callGetFamily() { + guard !jwt.isEmpty else { + output = "Error: JWT is required" + return + } + + currentLoadingButton = "getFamily" + Task { + isLoading = true + defer { + isLoading = false + currentLoadingButton = nil + } + + do { + let result = try await FamilyAPI.getFamily(baseURL: baseURL, apiKey: apiKey, jwt: jwt) + output = formatResponse(statusCode: result.statusCode, body: result.body) + } catch { + output = formatError(error) + } + } + } + + private func callCreateInvite() { + guard !jwt.isEmpty, !memberID.isEmpty else { + output = "Error: JWT and memberID are required" + return + } + + currentLoadingButton = "createInvite" + Task { + isLoading = true + defer { + isLoading = false + currentLoadingButton = nil + } + + do { + let result = try await FamilyAPI.createInvite( + baseURL: baseURL, + apiKey: apiKey, + jwt: jwt, + memberID: memberID + ) + output = formatResponse(statusCode: result.statusCode, body: result.body) + } catch { + output = formatError(error) + } + } + } + + private func callJoinFamily() { + guard !jwt.isEmpty, !inviteCode.isEmpty else { + output = "Error: JWT and inviteCode are required" + return + } + + currentLoadingButton = "joinFamily" + Task { + isLoading = true + defer { + isLoading = false + currentLoadingButton = nil + } + + do { + let result = try await FamilyAPI.joinFamily( + baseURL: baseURL, + apiKey: apiKey, + jwt: jwt, + inviteCode: inviteCode + ) + output = formatResponse(statusCode: result.statusCode, body: result.body) + } catch { + output = formatError(error) + } + } + } + + private func callLeaveFamily() { + guard !jwt.isEmpty else { + output = "Error: JWT is required" + return + } + + currentLoadingButton = "leaveFamily" + Task { + isLoading = true + defer { + isLoading = false + currentLoadingButton = nil + } + + do { + let result = try await FamilyAPI.leaveFamily(baseURL: baseURL, apiKey: apiKey, jwt: jwt) + output = formatResponse(statusCode: result.statusCode, body: result.body) + } catch { + output = formatError(error) + } + } + } + + private func callAddMember() { + guard !jwt.isEmpty, !name.isEmpty, !color.isEmpty, !uuid.isEmpty else { + output = "Error: JWT, name, color, and UUID are required" + return + } + + currentLoadingButton = "addMember" + Task { + isLoading = true + defer { + isLoading = false + currentLoadingButton = nil + } + + do { + let nicknames = nickname.isEmpty ? [] : nickname.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) } + var member: [String: Any] = [ + "id": uuid, + "name": name, + "nicknames": nicknames, + "color": color + ] + if !info.isEmpty { + member["info"] = info + } + + let result = try await FamilyAPI.addMember( + baseURL: baseURL, + apiKey: apiKey, + jwt: jwt, + member: member + ) + output = formatResponse(statusCode: result.statusCode, body: result.body) + } catch { + output = formatError(error) + } + } + } + + private func callEditMember() { + guard !jwt.isEmpty, !name.isEmpty, !color.isEmpty, !memberID.isEmpty else { + output = "Error: JWT, name, color, and memberID are required" + return + } + + currentLoadingButton = "editMember" + Task { + isLoading = true + defer { + isLoading = false + currentLoadingButton = nil + } + + do { + let nicknames = nickname.isEmpty ? [] : nickname.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) } + var member: [String: Any] = [ + "id": uuid, + "name": name, + "nicknames": nicknames, + "color": color + ] + if !info.isEmpty { + member["info"] = info + } + + let result = try await FamilyAPI.editMember( + baseURL: baseURL, + apiKey: apiKey, + jwt: jwt, + memberID: memberID, + member: member + ) + output = formatResponse(statusCode: result.statusCode, body: result.body) + } catch { + output = "Error: \(error.localizedDescription)" + } + } + } + + private func callDeleteMember() { + guard !jwt.isEmpty, !memberID.isEmpty else { + output = "Error: JWT and memberID are required" + return + } + + currentLoadingButton = "deleteMember" + Task { + isLoading = true + defer { + isLoading = false + currentLoadingButton = nil + } + + do { + let result = try await FamilyAPI.deleteMember( + baseURL: baseURL, + apiKey: apiKey, + jwt: jwt, + memberID: memberID + ) + output = formatResponse(statusCode: result.statusCode, body: result.body) + } catch { + output = formatError(error) + } + } + } +} + +struct SignupTestView_Previews: PreviewProvider { + static var previews: some View { + SignupTestView() + } +} + + diff --git a/TESTING_NOTES.md b/TESTING_NOTES.md new file mode 100644 index 00000000..bf62117b --- /dev/null +++ b/TESTING_NOTES.md @@ -0,0 +1,208 @@ +# Testing Notes - App Refinements Sprint Branch + +**Branch:** `fix/app-refinements-sprint` +**Base:** `develop` + +## πŸ› Critical Bug Fixes + +### 1. Settings Navigation Issues & NavigationPath Crash +- **Issue:** Settings navigation was broken and caused app crashes +- **Fix:** + - Refactored SettingsSheet to avoid nested NavigationStack + - Changed Settings presentation from sheet to navigationDestination + - Fixed NavigationPath comparisonTypeMismatch crash + - Restored all missing lifecycle code including selfMember prefill logic +- **Test:** + - Navigate to Settings from Home + - Verify all Settings screens work correctly + - Check that navigation back to Home works without crashes + - Verify selfMember information is pre-filled correctly + +### 2. Navigation Regression - Redirect to "Get Started" Screen +- **Issue:** Users were incorrectly redirected to "Get Started" after completing onboarding +- **Fix:** + - Prevented canvas reset to .heyThere when onboarding is already completed + - Fixed navigation logic to respect completed onboarding state +- **Test:** + - Complete onboarding flow + - Navigate to Profile/Recent Scans + - Navigate back - should NOT redirect to "Get Started" + - Verify onboarding completion state persists correctly + +## 🎨 UI/UX Improvements + +### 3. Button Consistency - SecondaryButton Component +- **Change:** Replaced all gray buttons and GreenOutlinedCapsule with unified SecondaryButton +- **Impact:** Consistent button styling across the app +- **Test:** + - Check all secondary buttons throughout the app + - Verify button actions work correctly + - Check button text is properly displayed (no truncation) + - Verify buttons adapt to content length + +### 4. Recent Scans Empty State +- **Change:** + - Improved empty state layout (ZStack β†’ VStack) + - Updated text from "No Scans !" to "No Scans Found!" + - Updated history-emptystate asset images +- **Test:** + - View Recent Scans when no scans exist + - Verify empty state displays correctly + - Check "Start Scanning" button works + - Verify layout looks good on different screen sizes + +### 5. Onboarding Flow Improvements +- **New Steps Added:** + - "Ready to Scan First Product" screen + - "See How Scanning Works" screen + - "Quick Access Needed" screen + - "Login to Continue" screen +- **Test:** + - Complete full onboarding flow + - Verify all new screens appear in correct order + - Check navigation between onboarding steps + - Verify permissions prompts work correctly + +### 6. Onboarding Sheets & Permissions Toggles +- **Change:** Refined onboarding sheets and permission toggle behavior +- **Test:** + - Test permission toggles in onboarding + - Verify sheets dismiss correctly + - Check permission states persist + +### 7. Splash & Welcome Screens +- **Change:** Updated with new assets +- **Test:** + - Launch app and verify splash screen + - Check welcome screen displays correctly + - Verify animations work smoothly + +### 8. Fallback Card in Onboarding +- **Change:** Added fallback card to StackedCards component +- **New Assets:** Questionmark-bot and circle-cards +- **Test:** + - Verify fallback card appears when appropriate + - Check card progress tracking works + - Verify new assets display correctly + +## πŸ”§ Technical Improvements + +### 9. Onboarding Persistence Refactor +- **Change:** + - Extracted onboarding persistence into dedicated utility + - Improved conflict resolution between local and remote state + - Better separation of concerns +- **Test:** + - Complete onboarding on one device + - Verify state syncs correctly + - Test onboarding completion persists after app restart + - Check conflict resolution when local/remote states differ + +### 10. Toast Notification System +- **New Feature:** Added ToastManager and ToastView components +- **Types:** Info, success, error, and warning toasts +- **Test:** + - Trigger various toast notifications throughout the app + - Verify toasts display correctly + - Check toast auto-dismiss works + - Verify toast positioning and styling + +### 11. Network Retry Logic & Timeout Improvements +- **Change:** + - Added retry logic with exponential backoff + - Configured appropriate timeouts for API calls + - Improved error handling for network issues +- **Test:** + - Test with poor network conditions + - Verify retry logic works for failed requests + - Check timeout behavior for slow connections + - Verify error messages are user-friendly + +### 12. Stats API Integration +- **Change:** Fully integrated stats API into Home Bento cards +- **Test:** + - Verify stats display correctly on Home screen + - Check AverageScansCard shows correct data + - Verify stats update when data changes + - Test with empty stats data + +### 13. Family Management Improvements +- **Changes:** + - Simplified family name field editing + - Improved scroll tracking in HomeView + - Enhanced FamilyStore with immediate action methods + - Better error handling for family operations +- **Test:** + - Edit family name in ManageFamilyView + - Verify scroll tracking works in HomeView + - Test family creation and member addition + - Verify error messages display correctly + +### 14. Invite Code Flow Improvements +- **Changes:** + - Improved EnterYourInviteCode: uppercase input, Start Over button + - Better invite code generation and sharing + - Enhanced loading states +- **Test:** + - Enter invite code (should auto-uppercase) + - Test "Start Over" button + - Verify invite code sharing works + - Check loading states during invite operations + +### 15. Memoji Assets Update +- **Change:** Added 14 new memoji assets and updated naming convention +- **Test:** + - Verify all memoji display correctly + - Check MemberAvatar component uses new assets + - Verify memoji selection works in avatar creation + +## πŸ“± Component Updates + +### 16. ListsTab & SettingsSheet Headers +- **Change:** Refactored headers for improved navigation and consistency +- **Test:** + - Check ListsTab header navigation + - Verify SettingsSheet header behavior + - Test back button functionality + +### 17. EditableCanvasView +- **Change:** Added support for custom back actions +- **Test:** + - Verify custom back actions work + - Check navigation flow in canvas views + +## πŸ§ͺ Testing Checklist + +### Critical Paths +- [ ] Complete onboarding flow end-to-end +- [ ] Navigate to Settings and back without crashes +- [ ] View Recent Scans (both empty and populated states) +- [ ] Test family creation and member addition +- [ ] Verify invite code flow works +- [ ] Check stats display on Home screen + +### UI Consistency +- [ ] Verify all SecondaryButton instances work correctly +- [ ] Check empty states display properly +- [ ] Verify toast notifications appear correctly +- [ ] Test all onboarding screens and navigation + +### Network & Persistence +- [ ] Test with poor network conditions +- [ ] Verify onboarding state persists after app restart +- [ ] Check retry logic for failed requests +- [ ] Test stats API integration + +### Edge Cases +- [ ] Test with no scans (empty state) +- [ ] Test with no family members +- [ ] Test permission denial scenarios +- [ ] Test with slow network connections + +## πŸ“ Notes for Testers + +- This branch includes significant refactoring of onboarding and navigation logic +- Pay special attention to navigation flows, especially Settings and onboarding +- Test on different network conditions to verify retry logic +- Verify all button interactions work correctly after SecondaryButton refactor +- Check that onboarding completion state persists correctly across app sessions diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..8173a1fe --- /dev/null +++ b/docs/README.md @@ -0,0 +1,11 @@ +# Documentation Guidelines + +This folder contains app-level documentation such as API integration guides, feature specifications, and architectural overviews. + +**Guidelines:** +- **Do NOT** add temporary planning documents (e.g., implementation plans, task checklists) here. +- Use this space for persistent documentation that provides value for understanding the codebase and its features. +- Examples of appropriate documents: + - API Integration Guides + - Feature Architecture Specs + - Design Decisions diff --git a/docs/ScanAPIIntegration.md b/docs/ScanAPIIntegration.md new file mode 100644 index 00000000..951cdb7c --- /dev/null +++ b/docs/ScanAPIIntegration.md @@ -0,0 +1,823 @@ +# Scan API Integration Guide + +This guide explains how to integrate the new Scan API endpoints into the IngrediCheck iOS app. + +## Overview + +The new Scan API provides a unified system for barcode and photo-based product scans. It differs from the current `streamUnifiedAnalysis` approach: + +| Feature | Current (`analyze-stream`) | New Scan API | +|---------|---------------------------|--------------| +| Barcode lookup | Client sends barcode | Server looks up OpenFoodFacts | +| Photo scans | Images sent with request | Images uploaded incrementally | +| Scan persistence | No server-side history | Full scan history with images | +| Analysis | Runs inline | Runs after product info found | + +## New Endpoints + +Add these to `SafeEatsEndpoint`: + +```swift +enum SafeEatsEndpoint: String { + // ... existing endpoints ... + + // New Scan API endpoints + case scan_barcode = "scan/barcode" + case scan_image = "scan/%@/image" // scan_id + case scan_get = "scan/%@" // scan_id + case scan_history = "scan/history" +} +``` + +--- + +## 1. Barcode Scan (SSE) + +**Endpoint:** `POST /scan/barcode` + +This is similar to the existing `streamUnifiedAnalysis` but simpler - it handles OpenFoodFacts lookup server-side. + +### Add to WebService.swift + +```swift +// MARK: - Scan API + +struct ScanStreamError: Error, LocalizedError { + let message: String + let statusCode: Int? + var errorDescription: String? { message } +} + +func streamBarcodeScan( + barcode: String, + onProductInfo: @escaping (DTO.ScanProductInfo, String) -> Void, // (productInfo, scanId) + onAnalysis: @escaping (DTO.ScanAnalysisResult) -> Void, + onError: @escaping (ScanStreamError, String?) -> Void // (error, scanId) +) async throws { + + let requestId = UUID().uuidString + let startTime = Date().timeIntervalSince1970 + var scanId: String? + var hasReportedError = false + + guard let token = try? await supabaseClient.auth.session.accessToken else { + throw NetworkError.authError + } + + let requestBody = try JSONEncoder().encode(["barcode": barcode]) + + var request = SupabaseRequestBuilder(endpoint: .scan_barcode) + .setAuthorization(with: token) + .setMethod(to: "POST") + .setJsonBody(to: requestBody) + .build() + + request.setValue("text/event-stream", forHTTPHeaderField: "Accept") + request.timeoutInterval = 60 + + PostHogSDK.shared.capture("Barcode Scan Started", properties: [ + "request_id": requestId, + "barcode": barcode + ]) + + let (asyncBytes, response) = try await URLSession.shared.bytes(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1 + PostHogSDK.shared.capture("Barcode Scan Failed - HTTP", properties: [ + "request_id": requestId, + "status_code": statusCode + ]) + throw NetworkError.invalidResponse(statusCode) + } + + var buffer = "" + let doubleNewline = "\n\n" + + func processEvent(_ rawEvent: String) async { + let trimmed = rawEvent.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + + var eventType: String? + var dataLines: [String] = [] + + trimmed.split(whereSeparator: \.isNewline).forEach { line in + if line.hasPrefix("event:") { + eventType = String(line.dropFirst(6)).trimmingCharacters(in: .whitespaces) + } else if line.hasPrefix("data:") { + dataLines.append(String(line.dropFirst(5)).trimmingCharacters(in: .whitespaces)) + } + } + + let payloadString = dataLines.joined(separator: "\n") + guard let resolvedEventType = eventType, + let payloadData = payloadString.data(using: .utf8) else { return } + + switch resolvedEventType { + case "product_info": + do { + let event = try JSONDecoder().decode(DTO.ScanProductInfoEvent.self, from: payloadData) + scanId = event.scan_id + + let latency = (Date().timeIntervalSince1970 - startTime) * 1000 + PostHogSDK.shared.capture("Barcode Scan Product Info", properties: [ + "request_id": requestId, + "scan_id": event.scan_id, + "source": event.product_info_source, + "latency_ms": latency + ]) + + await MainActor.run { + onProductInfo(event.product_info, event.scan_id) + } + } catch { + print("Failed to decode product_info: \(error)") + } + + case "analysis": + do { + let event = try JSONDecoder().decode(DTO.ScanAnalysisEvent.self, from: payloadData) + + PostHogSDK.shared.capture("Barcode Scan Analysis", properties: [ + "request_id": requestId, + "scan_id": scanId ?? "unknown" + ]) + + await MainActor.run { + onAnalysis(event.analysis_result) + } + } catch { + print("Failed to decode analysis: \(error)") + } + + case "error": + hasReportedError = true + var errorMessage = "Product not found" + + if let jsonObject = try? JSONSerialization.jsonObject(with: payloadData) as? [String: Any] { + if let id = jsonObject["scan_id"] as? String { + scanId = id + } + if let msg = jsonObject["error"] as? String { + errorMessage = msg + } + } + + PostHogSDK.shared.capture("Barcode Scan Error", properties: [ + "request_id": requestId, + "scan_id": scanId ?? "unknown", + "error": errorMessage + ]) + + await MainActor.run { + onError(ScanStreamError(message: errorMessage, statusCode: nil), scanId) + } + + case "done": + break + + default: + break + } + } + + do { + for try await byte in asyncBytes { + let scalar = UnicodeScalar(byte) + buffer.append(Character(scalar)) + + while let range = buffer.range(of: doubleNewline) { + let eventString = String(buffer[.. DTO.SubmitImageResponse { + + guard let token = try? await supabaseClient.auth.session.accessToken else { + throw NetworkError.authError + } + + let request = SupabaseRequestBuilder(endpoint: .scan_image, itemId: scanId) + .setAuthorization(with: token) + .setMethod(to: "POST") + .setFormData(name: "image", value: imageData, contentType: "image/jpeg") + .build() + + let (data, response) = try await URLSession.shared.data(for: request) + let httpResponse = response as! HTTPURLResponse + + switch httpResponse.statusCode { + case 200: + return try JSONDecoder().decode(DTO.SubmitImageResponse.self, from: data) + case 401: + throw NetworkError.authError + case 403: + throw NetworkError.notFound("Scan belongs to another user") + case 413: + throw NetworkError.invalidResponse(413) // Image too large (>10MB) + case 400: + throw NetworkError.invalidResponse(400) // Max images reached (20) + default: + throw NetworkError.invalidResponse(httpResponse.statusCode) + } +} +``` + +--- + +## 3. Get Scan + +**Endpoint:** `GET /scan/{scan_id}` + +Use this to poll for updates after submitting images. + +### Add to WebService.swift + +```swift +func getScan(scanId: String) async throws -> DTO.Scan { + + guard let token = try? await supabaseClient.auth.session.accessToken else { + throw NetworkError.authError + } + + let request = SupabaseRequestBuilder(endpoint: .scan_get, itemId: scanId) + .setAuthorization(with: token) + .setMethod(to: "GET") + .build() + + let (data, response) = try await URLSession.shared.data(for: request) + let httpResponse = response as! HTTPURLResponse + + switch httpResponse.statusCode { + case 200: + return try JSONDecoder().decode(DTO.Scan.self, from: data) + case 401: + throw NetworkError.authError + case 403: + throw NetworkError.notFound("Scan belongs to another user") + case 404: + throw NetworkError.notFound("Scan not found") + default: + throw NetworkError.invalidResponse(httpResponse.statusCode) + } +} +``` + +--- + +## 4. Scan History + +**Endpoint:** `GET /scan/history?limit=20&offset=0` + +Returns paginated scan history with full scan objects (including `product_info`, `analysis_result`, and `images`). + +### Query Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `limit` | int | 20 | Number of scans to return (1-100) | +| `offset` | int | 0 | Number of scans to skip | + +### Add to WebService.swift + +```swift +func fetchScanHistory( + limit: Int = 20, + offset: Int = 0 +) async throws -> DTO.ScanHistoryResponse { + + let requestId = UUID().uuidString + let startTime = Date().timeIntervalSince1970 + + guard let token = try? await supabaseClient.auth.session.accessToken else { + throw NetworkError.authError + } + + let request = SupabaseRequestBuilder(endpoint: .scan_history) + .setAuthorization(with: token) + .setMethod(to: "GET") + .setQueryItems(queryItems: [ + URLQueryItem(name: "limit", value: String(limit)), + URLQueryItem(name: "offset", value: String(offset)) + ]) + .build() + + let (data, response) = try await URLSession.shared.data(for: request) + let httpResponse = response as! HTTPURLResponse + + guard httpResponse.statusCode == 200 else { + PostHogSDK.shared.capture("Scan History Fetch Failed", properties: [ + "request_id": requestId, + "status_code": httpResponse.statusCode, + "latency_ms": (Date().timeIntervalSince1970 - startTime) * 1000 + ]) + throw NetworkError.invalidResponse(httpResponse.statusCode) + } + + do { + let historyResponse = try JSONDecoder().decode(DTO.ScanHistoryResponse.self, from: data) + + PostHogSDK.shared.capture("Scan History Fetch Successful", properties: [ + "request_id": requestId, + "scan_count": historyResponse.scans.count, + "total": historyResponse.total, + "has_more": historyResponse.has_more, + "latency_ms": (Date().timeIntervalSince1970 - startTime) * 1000 + ]) + + return historyResponse + } catch { + print("Failed to decode ScanHistoryResponse: \(error)") + + PostHogSDK.shared.capture("Scan History Decode Error", properties: [ + "request_id": requestId, + "error": error.localizedDescription, + "latency_ms": (Date().timeIntervalSince1970 - startTime) * 1000 + ]) + + throw NetworkError.decodingError + } +} +``` + +--- + +## 5. DTO Models + +Add these to `DTO.swift`: + +```swift +// MARK: - Scan API Models + +struct ScanProductInfo: Codable, Hashable { + let name: String? + let brand: String? + let ingredients: [Ingredient] + let images: [ScanImageInfo]? +} + +struct ScanImageInfo: Codable, Hashable { + let url: String? +} + +struct ScanAnalysisResult: Codable, Hashable { + let overall_analysis: String + let overall_match: String // "matched", "uncertain", "unmatched" + let ingredient_analysis: [ScanIngredientAnalysis] +} + +struct ScanIngredientAnalysis: Codable, Hashable { + let ingredient: String + let match: String // "unmatched", "uncertain" + let reasoning: String + let members_affected: [String] +} + +// SSE Event payloads +struct ScanProductInfoEvent: Codable { + let scan_id: String + let product_info: ScanProductInfo + let product_info_source: String + let images: [ScanImage] +} + +struct ScanAnalysisEvent: Codable { + let analysis_status: String + let analysis_result: ScanAnalysisResult +} + +// Image types in scan response +enum ScanImage: Codable, Hashable { + case inventory(InventoryScanImage) + case user(UserScanImage) + + struct InventoryScanImage: Codable, Hashable { + let type: String // "inventory" + let url: String + } + + struct UserScanImage: Codable, Hashable { + let type: String // "user" + let content_hash: String + let storage_path: String? + let status: String // "pending", "processing", "processed", "failed" + let extraction_error: String? + } + + private enum CodingKeys: String, CodingKey { + case type + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + + switch type { + case "inventory": + self = .inventory(try InventoryScanImage(from: decoder)) + case "user": + self = .user(try UserScanImage(from: decoder)) + default: + throw DecodingError.dataCorruptedError( + forKey: .type, + in: container, + debugDescription: "Unknown image type: \(type)" + ) + } + } + + func encode(to encoder: Encoder) throws { + switch self { + case .inventory(let img): + try img.encode(to: encoder) + case .user(let img): + try img.encode(to: encoder) + } + } +} + +// Full Scan object +struct Scan: Codable, Hashable { + let id: String + let scan_type: String // "barcode" or "photo" + let barcode: String? + let status: String // "idle" or "processing" + let product_info: ScanProductInfo + let product_info_source: String? // "openfoodfacts", "extraction", "enriched" + let analysis_status: String? // "analyzing", "complete", "stale" + let analysis_result: ScanAnalysisResult? + let images: [ScanImage] + let latest_guidance: String? + let created_at: String + let last_activity_at: String +} + +// Submit image response +struct SubmitImageResponse: Codable { + let queued: Bool + let queue_position: Int + let content_hash: String +} + +// Scan history response +struct ScanHistoryResponse: Codable { + let scans: [Scan] + let total: Int + let has_more: Bool +} +``` + +--- + +## 6. View Examples + +Following the MV pattern, Views directly consume `WebService` via `@Environment` and use `@State` for local view state. + +### Barcode Scan View + +```swift +struct BarcodeScanView: View { + let barcode: String + + @Environment(WebService.self) var webService + + @State private var scanId: String? + @State private var productInfo: DTO.ScanProductInfo? + @State private var analysisResult: DTO.ScanAnalysisResult? + @State private var errorMessage: String? + @State private var isLoading = false + @State private var productNotFound = false + + var body: some View { + Group { + if isLoading { + ProgressView("Scanning...") + } else if let productInfo { + ProductInfoView(productInfo: productInfo, analysisResult: analysisResult) + } else if productNotFound { + ProductNotFoundView(scanId: scanId, onAddPhoto: addPhoto) + } else if let errorMessage { + ErrorView(message: errorMessage) + } + } + .task { + await startScan() + } + } + + private func startScan() async { + isLoading = true + errorMessage = nil + productNotFound = false + + do { + try await webService.streamBarcodeScan( + barcode: barcode, + onProductInfo: { productInfo, id in + self.productInfo = productInfo + self.scanId = id + }, + onAnalysis: { analysis in + self.analysisResult = analysis + self.isLoading = false + }, + onError: { error, id in + self.scanId = id + if error.message.contains("not found") { + self.productNotFound = true + } else { + self.errorMessage = error.message + } + self.isLoading = false + } + ) + } catch { + errorMessage = error.localizedDescription + isLoading = false + } + } + + private func addPhoto(_ image: UIImage) async { + guard let scanId, + let imageData = image.jpegData(compressionQuality: 0.8) else { return } + + isLoading = true + + do { + _ = try await webService.submitScanImage(scanId: scanId, imageData: imageData) + await pollForUpdates() + } catch { + errorMessage = error.localizedDescription + isLoading = false + } + } + + private func pollForUpdates() async { + guard let scanId else { return } + + while isLoading { + do { + let scan = try await webService.getScan(scanId: scanId) + productInfo = scan.product_info + analysisResult = scan.analysis_result + + if scan.status == "idle" { + isLoading = false + productNotFound = false + break + } + + try await Task.sleep(nanoseconds: 2_000_000_000) + } catch { + isLoading = false + break + } + } + } +} +``` + +### Photo Scan View + +```swift +struct PhotoScanView: View { + @Environment(WebService.self) var webService + + // Client generates UUID for new scan + @State private var scanId = UUID().uuidString + @State private var scan: DTO.Scan? + @State private var isProcessing = false + @State private var errorMessage: String? + @State private var guidanceMessage: String? + + var body: some View { + VStack { + if let scan { + ScanResultView(scan: scan) + } + + if let guidanceMessage { + Text(guidanceMessage) + .font(.callout) + .foregroundStyle(.secondary) + } + + if isProcessing { + ProgressView("Processing...") + } + + CaptureButton(onCapture: addPhoto) + .disabled(isProcessing) + + if let errorMessage { + Text(errorMessage) + .foregroundStyle(.red) + } + } + } + + private func addPhoto(_ image: UIImage) async { + guard let imageData = image.jpegData(compressionQuality: 0.8) else { return } + + isProcessing = true + errorMessage = nil + + do { + let response = try await webService.submitScanImage( + scanId: scanId, + imageData: imageData + ) + print("Image \(response.content_hash) queued at position \(response.queue_position)") + + await pollForUpdates() + } catch let error as NetworkError { + switch error { + case .invalidResponse(413): + errorMessage = "Image too large. Please use a smaller image." + case .invalidResponse(400): + errorMessage = "Maximum images reached for this scan." + default: + errorMessage = "Failed to upload image." + } + isProcessing = false + } catch { + errorMessage = error.localizedDescription + isProcessing = false + } + } + + private func pollForUpdates() async { + while isProcessing { + do { + let scan = try await webService.getScan(scanId: scanId) + self.scan = scan + self.guidanceMessage = scan.latest_guidance + + if scan.status == "idle" && scan.analysis_status == "complete" { + isProcessing = false + break + } + + try await Task.sleep(nanoseconds: 2_000_000_000) + } catch { + isProcessing = false + break + } + } + } + + private func reset() { + scanId = UUID().uuidString + scan = nil + isProcessing = false + errorMessage = nil + guidanceMessage = nil + } +} +``` + +### Scan History View + +```swift +struct ScanHistoryView: View { + @Environment(WebService.self) var webService + + @State private var scans: [DTO.Scan] = [] + @State private var total = 0 + @State private var hasMore = false + @State private var isLoading = false + @State private var currentOffset = 0 + + private let pageSize = 20 + + var body: some View { + List { + ForEach(scans, id: \.id) { scan in + ScanRowView(scan: scan) + .onAppear { + if scan.id == scans.last?.id && hasMore { + Task { await loadMore() } + } + } + } + + if isLoading { + HStack { + Spacer() + ProgressView() + Spacer() + } + } + } + .task { + await loadInitial() + } + .refreshable { + await loadInitial() + } + } + + private func loadInitial() async { + currentOffset = 0 + scans = [] + await loadMore() + } + + private func loadMore() async { + guard !isLoading else { return } + + isLoading = true + + do { + let response = try await webService.fetchScanHistory( + limit: pageSize, + offset: currentOffset + ) + + scans.append(contentsOf: response.scans) + total = response.total + hasMore = response.has_more + currentOffset += response.scans.count + } catch { + print("Failed to load scan history: \(error)") + } + + isLoading = false + } +} +``` + +--- + +## Key Differences from Current Implementation + +1. **Scan ID Management** + - Barcode scan: Server generates `scan_id`, returned in `product_info` event + - Photo scan: Client generates `scan_id` as `UUID().uuidString` before first image upload + +2. **SSE Events** + - Current: `product`, `analysis`, `error` + - New: `product_info`, `analysis`, `error`, `done` + +3. **Image Storage** + - Current: Images uploaded to `productimages` bucket, hash returned + - New: Images uploaded to `scan-images` bucket via `/scan/{id}/image` endpoint + +4. **Analysis Trigger** + - Current: Analysis runs during stream + - New: Analysis runs after ingredients are found (either from OpenFoodFacts or extraction) + +5. **Polling** + - Current: SSE stream provides all data + - New: Use `GET /scan/{id}` to poll for photo scan updates + +--- + +## Testing Tips + +1. **Known Barcode:** `3017620422003` (Nutella) - should return product info +2. **Unknown Barcode:** `0000000000000` - should return error event with scan_id +3. **Image Size:** Max 10MB per image +4. **Image Count:** Max 20 images per scan diff --git a/docs/guest-upgrade-design.md b/docs/guest-upgrade-design.md deleted file mode 100644 index a79d3ca0..00000000 --- a/docs/guest-upgrade-design.md +++ /dev/null @@ -1,150 +0,0 @@ -# Anonymous Guest Upgrade Design - -## Context -- Anonymous sign-in (`signInAnonymously`) is the default entry path and stores Supabase credentials in `AuthController` along with a keychain copy of the email/password for legacy guests. -- Users currently have no path to attach a permanent identity without wiping the anonymous account and starting from scratch. -- `AuthController` already supports Apple and Google login for brand-new sessions via `signInWithIdToken`, and the settings sheet exposes only sign-out and delete actions. - -## Goals -- Let an anonymous guest promote their existing Supabase user to an Apple or Google identity without losing saved data (preferences, dietary settings, lists, etc.). -- Keep a single Supabase user record; avoid creating a parallel account and migrating data. -- Surface the upgrade action from the Settings sheet with clear status feedback and failure recovery. -- Preserve backwards compatibility for existing full sign-ins (Apple/Google) and legacy guest credentials. - -## Non-Goals -- Supporting linking multiple providers per user (e.g., both Apple and Google at once). -- Providing upgrade options outside of the authenticated settings experience. -- Altering server-side Supabase policies beyond what is necessary to permit identity linking. - -## Current State Summary -- `AuthController` manages Supabase sessions, exposes `signedInWithApple/Google` and `signedInAsGuest`, and handles anonymous sign-in plus Apple/Google sign-in flows using `signInWithIdToken`. -- Settings UI (`Views/Tabs/SettingsSheet.swift`) shows account actions based on the provider, but anonymous users only see "Reset App State". -- Keychain stores anonymous credentials via `anonEmail` and `anonPassword` to support a legacy login pathway. -- Supabase session change listener (`authStateChanges`) updates `AuthController.session` and `signInState`. - -## Proposed Solution - -### Supabase Identity Linking -- Use Supabase Auth **linking** to attach a new OAuth provider to the currently authenticated anonymous session instead of signing in fresh. The current `supabase-swift` dependency (v2.34.0) exposes this as `supabaseClient.auth.link`. -- Flow: - 1. Collect provider credentials (Apple identity token or Google ID token) while the user is still signed in anonymously. - 2. Call `auth.link(credentials:)` with the token; Supabase upgrades the user in place and returns an updated `Session`. - 3. Persist the session in `AuthController` and clear any legacy anonymous keychain entries. -- Ensure Supabase dashboard has "Allow linking" enabled for Apple and Google providers and that redirect URIs include the native overrides already used for sign-in. - -### Failure Handling & Edge Cases -- `identity_already_exists`: surface the Supabase error, keep the anonymous session active, and instruct the user to sign in with the existing provider or contact support. -- Token acquisition cancelled or missing nonce: bubble the error up to the UI and provide a retry button; do not mutate session state. -- Network or Supabase outage: report the failure, keep the anonymous session untouched, and allow retry; leverage existing loading state to prevent duplicate submissions. -- Expired anonymous session prior to linking: attempt `supabaseClient.auth.refreshSession()` before calling `link`; if refresh fails, inform the user and redirect to re-authenticate anonymously. -- Anonymous credentials cleanup: defer keychain deletion until `auth.link` returns successfully and the new session is stored. -- Concurrent upgrade attempts: track an `isUpgradingAccount` state in `AuthController`/UI to disable other upgrade buttons until the operation completes or fails. -### AuthController Updates -- Introduce an `enum AccountUpgradeProvider { case apple, google }` and a single entry point `upgradeCurrentAccount(to:) async`. -- Refactor existing Apple/Google helpers into shared routines that can operate in **sign-in** or **link** mode: - - Extract token acquisition (`requestAppleIDToken()` / `requestGoogleIDToken(...)`) from the current sign-in methods. - - Move Supabase calls into `finalizeAuth(with credentials: OpenIDConnectCredentials, mode: AuthMode)` where `AuthMode` determines whether to call `signInWithIdToken` (fresh login) or `link`. -- On successful upgrade: - - Update `session`. - - Clear `anonEmail` / `anonPassword` in the keychain. - - Flip `signInState` to `.signedIn` (should already occur through `authStateChanges` but keep explicit safety). - - Emit a Combine/AsyncStream event if other components need to refresh (e.g., `AppState`). -- Errors from Supabase (e.g., provider already linked elsewhere, invalid token) bubble up so the UI can present actionable messaging. - -### Settings UI Changes -- In `SettingsSheet`: - - Replace the anonymous branch with a new `AccountUpgradeSection` component when `authController.signedInAsGuest` is true. - - Present two buttonsβ€”"Upgrade with Apple" and "Upgrade with Google"β€”that trigger the corresponding upgrade flow. - - Show loading states while the upgrade is running; disable opposing actions to prevent double submits. - - On success, display a transient confirmation (e.g., via `appState.feedbackConfig` toast). - - On failure, surface the error message from `AuthController` in an alert, with guidance to retry or contact support. -- Keep the existing "Reset App State" action accessible but separate so users can still wipe data. - -### Keychain & Local State Handling -- After upgrade, remove stored anonymous credentials to prevent reusing them. -- No schema changes needed for in-app stores (`OnboardingState`, `DietaryPreferences`, etc.) because the Supabase user ID remains consistent. -- Consider adding a boolean flag in `UserPreferences` (e.g., `hasUpgradedAccount`) if the UI needs to hide upgrade prompts after success. - -### Analytics & Logging -- Emit structured logs in `AuthController` for upgrade attempts, failures, and completion to aid debugging. -- Hook into existing analytics (if any) with events like `account_upgrade_started`, `account_upgrade_completed`, and `account_upgrade_failed` including provider and failure codes (avoid sending tokens). - -## Step-by-Step Implementation Plan - -Follow these steps on a new branch named `feature/account-upgrade`. - -### 1. Preparation -1.1 Create the branch: `git checkout -b feature/account-upgrade` (starting from `main`). -1.2 Run `xcodebuild build -project IngrediCheck.xcodeproj -scheme IngrediCheck -destination 'platform=iOS Simulator,name=iPhone 15'` to ensure a clean baseline. -1.3 Skim `AuthController.swift` and `SettingsSheet.swift` to confirm no outstanding conflicts with pending work. - -### 2. AuthController Refactor -2.1 Add a new `enum AccountUpgradeProvider { case apple, google }` near existing auth enums. -2.2 Extract the Apple credential acquisition into a helper (`requestAppleIDToken() async throws -> OpenIDConnectCredentials`) that: -- Configures and launches the `ASAuthorizationController`. -- Returns the `OpenIDConnectCredentials` with provider `.apple`. -2.3 Extract the Google credential acquisition into `requestGoogleIDToken(from rootViewController: UIViewController) async throws -> OpenIDConnectCredentials`, reusing the existing nonce logic. -2.4 Introduce an internal `enum AuthFlowMode { case signIn, link }`. -2.5 Implement `finalizeAuth(with credentials: OpenIDConnectCredentials, mode: AuthFlowMode) async throws` that calls `auth.signInWithIdToken` for `.signIn` and `auth.link(credentials:)` for `.link`. -2.6 Add `@Published var isUpgradingAccount = false` (or `@MainActor var`) within `AuthController` to track progress. -2.7 Implement `public func upgradeCurrentAccount(to provider: AccountUpgradeProvider) async`: -- Guard that `session?.user.isAnonymous == true` (or `signedInAsGuest`). -- Flip `isUpgradingAccount = true` on the main actor. -- Acquire credentials using the helpers above (running on the main actor for UI). -- Call `finalizeAuth(..., mode: .link)` from a `Task`. -- On success: assign the returned session, clear keychain anon credentials, set `isUpgradingAccount = false`, and optionally trigger a toast via `AppState`. -- On failure: set `isUpgradingAccount = false`, store the error in a property for the UI to display, and do not clear keychain entries. -2.8 Update existing Apple/Google sign-in entry points to reuse the shared helpers: -- `handleSignInWithAppleCompletion` should call `finalizeAuth(..., mode: .signIn)`. -- `signInWithGoogle` should use the new Google helper and `finalizeAuth`. -2.9 Ensure `authChangeWatcher` still updates `session`/`signInState` and verify no duplicate assignments occur after `link`. - -### 3. Settings UI Enhancements -3.1 Create `AccountUpgradeSection` in `SettingsSheet.swift` (near other components) that takes `AuthController` as an environment dependency. -3.2 When `authController.signedInAsGuest` is true, display the section with: -- A descriptive text explaining the benefit of upgrading. -- Two buttons: `Upgrade with Apple` and `Upgrade with Google`. -3.3 Wrap button actions in `Task { await authController.upgradeCurrentAccount(to: ...) }`. -3.4 Bind button disabled states to `authController.isUpgradingAccount`, show a `ProgressView` when true, and present any surfaced errors using `Alert`. -3.5 Keep `DeleteAccountView` available but visually separated (e.g., different section) so users can still reset their state. -3.6 Use `appState.feedbackConfig` (or `SimpleToast`) to show success confirmation after upgrading. - -### 4. Keychain and Session Handling -4.1 Inside `upgradeCurrentAccount`, call `keychain.delete(anonUserNameKey)` and `keychain.delete(anonPasswordKey)` only after Supabase responds with a successful `Session`. -4.2 Ensure failures leave keychain entries untouched for future sign-ins. -4.3 Verify deleting the account (`deleteAccount()`) still clears the keychainβ€”even after upgradeβ€”by testing the flow manually. - -### 5. Logging and Analytics -5.1 Add guard-rail `print` statements or structured logs inside `upgradeCurrentAccount` denoting start, success, and error cases. -5.2 If analytics hooks exist, fire events `account_upgrade_started`, `account_upgrade_succeeded`, and `account_upgrade_failed` with provider metadata (omit tokens). -5.3 Ensure analytics calls run on the main actor or a safe background queue per existing conventions. - -### 6. Testing & Verification -6.1 Write unit tests in `IngrediCheckTests`: -- Mock `SupabaseClient` to assert `auth.link` is called when upgrading. -- Verify `keychain.delete` runs only after success. -- Check `isUpgradingAccount` toggles during the flow. -6.2 Run `xcodebuild test -project IngrediCheck.xcodeproj -scheme IngrediCheck -destination 'platform=iOS Simulator,name=iPhone 15'`. -6.3 Manual validation: -- Launch as anonymous, upgrade with Apple, confirm preferences persist, and `signedInWithApple` becomes true. -- Repeat for Google. -- Trigger failure cases (cancel sign-in, disable network) and confirm the UI recovers gracefully. -6.4 Document final test results in the PR description. - -## Testing Strategy -- **Unit tests** - - Mock `SupabaseClient` behavior to ensure `upgradeCurrentAccount` calls `link` when `session.user.isAnonymous` is true. - - Test error propagation and keychain cleanup logic using a test double around `KeychainSwift`. - - Validate that `signedInAsGuest` transitions to false and `signedInWithApple/Google` flip appropriately. -- **Integration/manual** - - Simulator run upgrading from anonymous β†’ Apple and anonymous β†’ Google; verify data persistence and session continuity. - - Attempt upgrade when network offline to confirm retry surfaces. - - Attempt upgrade with provider already linked to another account; ensure Supabase error appears. -- **Regression** - - Confirm fresh Apple/Google sign-in still works from cold start (not in upgrade mode). - - Verify account deletion flows still sign out and clear local state after an upgraded account is deleted. - -## Open Questions -- Do we need to display the user's current linked provider(s) after upgrade (e.g., show email or Apple ID under Account)? -- Should we offer a way to unlink and revert to anonymous for troubleshooting? -- Are there analytics or marketing requirements (e.g., prompt after upgrade to collect email preferences)? diff --git a/publish/README.md b/publish/README.md deleted file mode 100644 index b6cfe255..00000000 --- a/publish/README.md +++ /dev/null @@ -1,180 +0,0 @@ -# App Store Distribution Script Setup Guide - -This guide will help you set up the `publish_appstore.sh` script to build and upload the IngrediCheck app to App Store Connect. - -## Prerequisites - -1. **Xcode** (latest version recommended) - - Verify installation: `xcodebuild -version` - - Ensure Xcode command line tools are installed - -2. **Transporter App** - - Install from the Mac App Store: [Transporter](https://apps.apple.com/us/app/transporter/id1450874784) - - Or ensure it's available via Xcode (included with Xcode 15+) - -3. **Apple Developer Account Access** - - You need access to the Apple Developer account for team `58MYNHGN72` (FUNGEE LLC) - - You need **Account Holder** or **Admin** access to App Store Connect - -## Step 1: Create App Store Connect API Key - -1. Go to [App Store Connect](https://appstoreconnect.apple.com/) -2. Navigate to **Users and Access** β†’ **Integrations** β†’ **App Store Connect API** -3. Click the **"+"** button to create a new key -4. Choose **Individual Keys** (or Team Keys if your team prefers) -5. Provide a name (e.g., "CI/CD Distribution Key") -6. Select **App Manager** or **Admin** access level -7. Click **Generate** -8. **IMPORTANT**: Download the `.p8` private key file immediately (you can only download it once!) -9. Note the following values: - - **Key ID** (e.g., `OTZKTEFV3F6Z`) - - **Issuer ID** (found at the top of the Keys page, looks like a UUID) - -## Step 2: Set Up Distribution Certificate and Provisioning Profile - -### Create Apple Distribution Certificate - -1. Open **Xcode** β†’ **Settings** (⌘,) β†’ **Accounts** -2. Select your Apple ID β†’ click **Manage Certificates...** -3. Click the **"+"** button at the bottom left -4. Choose **Apple Distribution** -5. Xcode will create and install the certificate automatically - -### Create App Store Provisioning Profile - -1. Go to [Apple Developer Portal](https://developer.apple.com/account/resources/profiles/list) -2. Click **"+"** to create a new profile -3. Select **App Store** (under Distribution) -4. Select your App ID: `llc.fungee.ingredicheck` -5. Select the **Apple Distribution** certificate you just created -6. Name it (e.g., "IngrediCheck App Store") -7. Click **Generate** -8. Download the `.mobileprovision` file -9. Double-click the file to install it in Xcode - -### Configure Xcode Signing - -1. Open `IngrediCheck.xcodeproj` in Xcode -2. Select the **IngrediCheck** project in the navigator -3. Select the **IngrediCheck** target -4. Go to **Signing & Capabilities** tab -5. Switch to **Release** configuration (top dropdown) -6. Ensure **Automatically manage signing** is checked - - Xcode should automatically select your Apple Distribution certificate and App Store provisioning profile -7. Verify it shows: - - **Signing Certificate**: Apple Distribution: FUNGEE LLC (58MYNHGN72) - - **Provisioning Profile**: IngrediCheck App Store - -## Step 3: Configure Environment Variables - -1. Copy the `.p8` private key file to the `publish/` directory: - ```bash - cp ~/Downloads/AuthKey_XXXXXXXX.p8 publish/ - ``` - Or if you downloaded it as `ApiKey_XXXXXXXX.p8`: - ```bash - cp ~/Downloads/ApiKey_XXXXXXXX.p8 publish/ - ``` - -2. Create a `.env` file in the `publish/` directory: - ```bash - cd publish - touch .env - ``` - -3. Edit `.env` and add your credentials: - ```bash - APP_STORE_CONNECT_API_KEY=YOUR_KEY_ID_HERE - APP_STORE_CONNECT_API_ISSUER=YOUR_ISSUER_ID_HERE - APP_STORE_CONNECT_API_PRIVATE_KEY_PATH=./ApiKey_YOUR_KEY_ID.p8 - APP_STORE_CONNECT_API_KEY_TYPE=individual - ``` - - Replace: - - `YOUR_KEY_ID_HERE` with your actual Key ID (e.g., `OTZKTEFV3F6Z`) - - `YOUR_ISSUER_ID_HERE` with your Issuer ID (e.g., `9b6ab061-e88d-411f-8828-677c9b84011c`) - - `ApiKey_YOUR_KEY_ID.p8` with the actual filename of your `.p8` file - - `individual` with `team` if you created a Team Key instead - - **Example:** - ```bash - APP_STORE_CONNECT_API_KEY=OTZKTEFV3F6Z - APP_STORE_CONNECT_API_ISSUER=9b6ab061-e88d-411f-8828-677c9b84011c - APP_STORE_CONNECT_API_PRIVATE_KEY_PATH=./ApiKey_OTZKTEFV3F6Z.p8 - APP_STORE_CONNECT_API_KEY_TYPE=individual - ``` - -4. **Security Note**: The `.env` file and `.p8` files are already in `.gitignore`, so they won't be committed to the repository. Keep them secure! - -## Step 4: Verify Setup - -1. Make sure the script is executable: - ```bash - chmod +x publish/publish_appstore.sh - ``` - -2. Test the script (without uploading): - ```bash - SKIP_UPLOAD=1 ./publish/publish_appstore.sh - ``` - - This will: - - Archive the app - - Create an IPA - - Skip the upload step - - If this succeeds, your setup is correct! - -## Step 5: Run the Full Distribution - -Once everything is set up, run: - -```bash -./publish/publish_appstore.sh -``` - -The script will: -1. βœ… Auto-increment the build number (just like Xcode does) -2. βœ… Archive the app with distribution signing -3. βœ… Create an IPA file -4. βœ… Upload to App Store Connect via iTMSTransporter - -## Troubleshooting - -### "Transporter CLI not found" -- Install the Transporter app from the Mac App Store -- Or ensure Xcode is properly installed and selected: `sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer` - -### "Unable to detect DEVELOPMENT_TEAM" -- Ensure your Xcode project has the Development Team set in Signing & Capabilities -- Or set `APPLE_TEAM_ID=58MYNHGN72` in your `.env` file - -### "Authentication credentials are missing or invalid" -- Verify your API Key ID and Issuer ID are correct in `.env` -- Ensure the `.p8` file path is correct and the file exists -- Check that the `.p8` file is named correctly: `ApiKey_.p8` or `AuthKey_.p8` - -### "The bundle version must be higher than the previously uploaded version" -- The script should auto-increment the build number, but if you see this error: - - Check App Store Connect to see what the latest build number is - - Manually increment it in Xcode if needed - -### Build doesn't appear in App Store Connect -- Wait a few minutes for Apple to process the upload -- Check App Store Connect β†’ Your App β†’ TestFlight -- Look for any processing errors in App Store Connect - -## Additional Notes - -- The script automatically increments build numbers before each upload -- Build artifacts are stored in `build/` directory (already in `.gitignore`) -- The script uses `agvtool` to manage version numbers, which requires the project to be configured for it -- If you encounter issues with `agvtool`, the script will fall back to timestamp-based build numbers - -## Need Help? - -If you encounter issues not covered here, check: -1. The script output for specific error messages -2. App Store Connect for build processing status -3. Xcode's signing and capabilities settings - diff --git a/publish/publish_appstore.sh b/publish/publish_appstore.sh deleted file mode 100755 index 6f8865e6..00000000 --- a/publish/publish_appstore.sh +++ /dev/null @@ -1,178 +0,0 @@ -#!/bin/zsh -# -# App Store Distribution Script -# -# This script builds, archives, and uploads the IngrediCheck app to App Store Connect. -# It automatically increments the build number before each upload. -# -# Setup Instructions: -# 1. See publish/README.md for complete setup guide -# 2. Create publish/.env with your App Store Connect API credentials -# 3. Ensure you have Apple Distribution certificate and App Store provisioning profile -# -# Usage: -# ./publish/publish_appstore.sh # Full build and upload -# SKIP_UPLOAD=1 ./publish/publish_appstore.sh # Build only, skip upload -# - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -PUBLISH_DIR="$SCRIPT_DIR" -PROJECT="${PROJECT:-IngrediCheck.xcodeproj}" -SCHEME="${SCHEME:-IngrediCheck}" -CONFIGURATION="${CONFIGURATION:-Release}" -ARCHIVE_PATH="${ARCHIVE_PATH:-$PROJECT_ROOT/build/IngrediCheck.xcarchive}" -EXPORT_PATH="${EXPORT_PATH:-$PROJECT_ROOT/build/AppStoreExport}" -IPA_NAME="${IPA_NAME:-IngrediCheck}" -IPA_PATH="$EXPORT_PATH/$IPA_NAME.ipa" -EXPORT_PLIST_PATH="$EXPORT_PATH/exportOptions.plist" -PROJECT_PATH="$PROJECT_ROOT/$PROJECT" - -if [[ -f "$PUBLISH_DIR/.env" ]]; then - # shellcheck disable=SC1090 - set -a # auto-export all variables - source "$PUBLISH_DIR/.env" - set +a -fi - -cd "$PROJECT_ROOT" - -if ! command -v xcodebuild >/dev/null 2>&1; then - echo "xcodebuild not found. Install Xcode command line tools first." >&2 - exit 1 -fi - -# Locate iTMSTransporter for upload (used later if not skipping upload) -if [[ "${SKIP_UPLOAD:-0}" != "1" ]]; then - if [[ -z "${TRANSPORTER_CLI:-}" ]]; then - if [[ -x /Applications/Transporter.app/Contents/itms/bin/iTMSTransporter ]]; then - TRANSPORTER_CLI="/Applications/Transporter.app/Contents/itms/bin/iTMSTransporter" - elif command -v iTMSTransporter >/dev/null 2>&1; then - TRANSPORTER_CLI="$(command -v iTMSTransporter)" - else - TRANSPORTER_CLI="$(xcrun --find iTMSTransporter 2>/dev/null || true)" - fi - fi - - if [[ -z "${TRANSPORTER_CLI:-}" ]]; then - echo "iTMSTransporter CLI not found. Install the Transporter app from the Mac App Store." >&2 - exit 1 - fi -fi - -if [[ -z "${APPLE_TEAM_ID:-}" ]]; then - echo "Detecting DEVELOPMENT_TEAM from Xcode project..." - APPLE_TEAM_ID="$(xcodebuild -project "$PROJECT_PATH" -scheme "$SCHEME" -showBuildSettings 2>/dev/null | awk '/DEVELOPMENT_TEAM/ {print $3; exit}')" -fi - -if [[ -z "${APPLE_TEAM_ID:-}" ]]; then - echo "Unable to detect DEVELOPMENT_TEAM. Set APPLE_TEAM_ID and retry." >&2 - exit 1 -fi - -echo "Using team ID: $APPLE_TEAM_ID" - -if [[ "${SKIP_UPLOAD:-0}" != "1" ]]; then - : "${APP_STORE_CONNECT_API_KEY:?Set APP_STORE_CONNECT_API_KEY to your App Store Connect API key ID}" - : "${APP_STORE_CONNECT_API_ISSUER:?Set APP_STORE_CONNECT_API_ISSUER to your App Store Connect issuer ID}" - : "${APP_STORE_CONNECT_API_PRIVATE_KEY_PATH:?Set APP_STORE_CONNECT_API_PRIVATE_KEY_PATH to your .p8 key file path}" - - # Resolve relative paths against PUBLISH_DIR (where .env lives) - if [[ "$APP_STORE_CONNECT_API_PRIVATE_KEY_PATH" != /* ]]; then - APP_STORE_CONNECT_API_PRIVATE_KEY_PATH="$PUBLISH_DIR/$APP_STORE_CONNECT_API_PRIVATE_KEY_PATH" - fi - - if [[ ! -f "$APP_STORE_CONNECT_API_PRIVATE_KEY_PATH" ]]; then - echo "Private key file not found at $APP_STORE_CONNECT_API_PRIVATE_KEY_PATH" >&2 - exit 1 - fi - - PRIVATE_KEYS_DIR="$PROJECT_ROOT/private_keys" - mkdir -p "$PRIVATE_KEYS_DIR" - - DEFAULT_KEY_KIND="${APP_STORE_CONNECT_API_KEY_TYPE:-individual}" - if [[ "$DEFAULT_KEY_KIND" != "team" ]]; then - DEFAULT_KEY_KIND="individual" - fi - - EXPECTED_KEY_NAME="ApiKey_${APP_STORE_CONNECT_API_KEY}.p8" - if [[ "$DEFAULT_KEY_KIND" == "team" ]]; then - EXPECTED_KEY_NAME="AuthKey_${APP_STORE_CONNECT_API_KEY}.p8" - fi - - TARGET_KEY_PATH="$PRIVATE_KEYS_DIR/$EXPECTED_KEY_NAME" - cp -f "$APP_STORE_CONNECT_API_PRIVATE_KEY_PATH" "$TARGET_KEY_PATH" -fi - -echo "Cleaning previous build artifacts..." -rm -rf "$ARCHIVE_PATH" "$EXPORT_PATH" -mkdir -p "$EXPORT_PATH" - -# Auto-increment build number (like Xcode does on manual upload) -echo "Incrementing build number..." -cd "$PROJECT_ROOT/IngrediCheck.xcodeproj/.." -CURRENT_BUILD=$(agvtool what-version -terse 2>/dev/null || echo "0") -# Require build number to be a simple integer -if [[ ! "$CURRENT_BUILD" =~ ^[0-9]+$ ]]; then - echo "Error: Current build number '$CURRENT_BUILD' is not a simple integer." >&2 - echo "Build number must be a simple number (e.g., 1, 2, 3) to auto-increment." >&2 - exit 1 -fi -NEW_BUILD=$((CURRENT_BUILD + 1)) -agvtool new-version -all "$NEW_BUILD" >/dev/null 2>&1 -echo "Build number set to: $NEW_BUILD" -cd "$PROJECT_ROOT" - -echo "Archiving $SCHEME from $PROJECT_PATH..." -xcodebuild archive \ - -project "$PROJECT_PATH" \ - -scheme "$SCHEME" \ - -configuration "$CONFIGURATION" \ - -destination 'generic/platform=iOS' \ - -archivePath "$ARCHIVE_PATH" \ - SKIP_INSTALL=NO - -# Create IPA manually from the archive (workaround for Xcode 26 exportArchive issues) -echo "Creating IPA from archive..." -APP_PATH="$ARCHIVE_PATH/Products/Applications/IngrediCheck.app" -if [[ ! -d "$APP_PATH" ]]; then - echo "App bundle not found at $APP_PATH" >&2 - exit 1 -fi - -# Create Payload directory and copy app -PAYLOAD_DIR="$EXPORT_PATH/Payload" -mkdir -p "$PAYLOAD_DIR" -cp -R "$APP_PATH" "$PAYLOAD_DIR/" - -# Create the IPA (it's just a zip with .ipa extension) -cd "$EXPORT_PATH" -zip -r -q "$IPA_NAME.ipa" Payload -rm -rf Payload -cd "$PROJECT_ROOT" - -if [[ ! -f "$IPA_PATH" ]]; then - echo "Failed to create IPA at $IPA_PATH" >&2 - exit 1 -fi - -echo "IPA created at $IPA_PATH" - -if [[ "${SKIP_UPLOAD:-0}" == "1" ]]; then - echo "SKIP_UPLOAD=1 set; skipping Transporter upload." - exit 0 -fi - -echo "Uploading IPA via iTMSTransporter..." - -"${TRANSPORTER_CLI}" -m upload \ - -apiKey "$APP_STORE_CONNECT_API_KEY" \ - -apiIssuer "$APP_STORE_CONNECT_API_ISSUER" \ - -apiKeyType "$DEFAULT_KEY_KIND" \ - -assetFile "$IPA_PATH" \ - -v informational - -echo "Upload complete. Check App Store Connect for build status." -