diff --git a/.archive/2026-02-27/apns-setup.md b/.archive/2026-02-27/apns-setup.md
new file mode 100644
index 0000000..20ff8be
--- /dev/null
+++ b/.archive/2026-02-27/apns-setup.md
@@ -0,0 +1,72 @@
+---
+date: 2026-02-27
+title: APNs Key Setup and CI/CD Integration
+category: infrastructure
+tags: [apns, ios, push-notifications, secrets-manager, ecs, github-actions]
+related: [2026-02-27/aws-infra-setup.md]
+---
+
+# APNs Key Setup and CI/CD Integration
+
+## Apple Developer Portal
+
+- **Key Name**: KickWatch APNs
+- **Key ID**: `GUFRSCY8ZV`
+- **Team ID**: `7Q28CBP3S5` (same as SnapAction)
+- **Bundle ID**: `com.rescience.kickwatch`
+- **Environment**: Sandbox & Production (covers both dev and prod with one key)
+- **Key Restriction**: Team Scoped (All Topics)
+- **File**: `AuthKey_GUFRSCY8ZV.p8` — downloaded to `/Users/yilin/Downloads/`
+
+## Secrets Manager (us-east-2)
+
+All 4 APNs secrets set for both dev and prod prefixes:
+
+| Secret | Value |
+|--------|-------|
+| `kickwatch-dev/apns-key-id` | `GUFRSCY8ZV` |
+| `kickwatch-dev/apns-team-id` | `7Q28CBP3S5` |
+| `kickwatch-dev/apns-bundle-id` | `com.rescience.kickwatch` |
+| `kickwatch-dev/apns-key` | Full `.p8` PEM content |
+| `kickwatch/apns-key-id` | `GUFRSCY8ZV` |
+| `kickwatch/apns-team-id` | `7Q28CBP3S5` |
+| `kickwatch/apns-bundle-id` | `com.rescience.kickwatch` |
+| `kickwatch/apns-key` | Full `.p8` PEM content |
+
+## Commands Used
+
+```bash
+KEY_ID="GUFRSCY8ZV"
+REGION=us-east-2
+
+# Key ID
+aws secretsmanager put-secret-value \
+ --secret-id kickwatch-dev/apns-key-id --region $REGION --secret-string "$KEY_ID"
+
+# .p8 content
+aws secretsmanager put-secret-value \
+ --secret-id kickwatch-dev/apns-key --region $REGION \
+ --secret-string "$(cat ~/Downloads/AuthKey_GUFRSCY8ZV.p8)"
+```
+
+## Backend Change: File Path → Env Var
+
+`internal/service/apns.go` updated to read key from `APNS_KEY` env var first, falling back to `APNS_KEY_PATH` file. Avoids need to mount `.p8` file into ECS container.
+
+`internal/config/config.go` added `APNSKey string` field reading `APNS_KEY`.
+
+## CI Workflow Change
+
+`deploy-backend.yml` — removed `APNS_KEY_PATH` env var, added `APNS_KEY` secret injected from Secrets Manager ARN.
+
+## iOS Changes
+
+- `project.yml`: `DEVELOPMENT_TEAM: 7Q28CBP3S5`, `PRODUCT_BUNDLE_IDENTIFIER: com.rescience.kickwatch`
+- `KickWatch.entitlements`: `aps-environment = development`
+
+## Gotchas
+
+- APNs key environment set to **Sandbox & Production** — one key works for both; do NOT create separate keys
+- Bundle ID must match exactly what's registered in Apple Developer Portal
+- `APNS_KEY` env var content is the raw PEM string including `-----BEGIN PRIVATE KEY-----` header/footer
+- ECS task execution role needs `secretsmanager:GetSecretValue` for `kickwatch*` ARNs (already added)
diff --git a/.archive/2026-02-27/app-icon-creation.md b/.archive/2026-02-27/app-icon-creation.md
new file mode 100644
index 0000000..31b225e
--- /dev/null
+++ b/.archive/2026-02-27/app-icon-creation.md
@@ -0,0 +1,62 @@
+---
+date: 2026-02-27
+title: KickWatch App Icon Creation
+category: design
+tags: [app-icon, logo, svg, nanobanana, rsvg-convert, xcode, appiconset]
+related: [2026-02-27/mvp-implementation.md]
+---
+
+# App Icon Creation
+
+## Concept
+
+Kickstarter K shape (rounded bubbly white K on green #05CE78) + newspaper/daily digest metaphor = daily monitor app identity. Notion-style: flat, no gradients, clean.
+
+## Process
+
+1. Generated 7 variations via nanobanana skill (Gemini image gen)
+2. Selected `logo-07.png` — but user preferred a manually provided `o.png`
+3. Pipeline on `o.png`:
+ - `crop_logo.py` → `final-cropped.png` (719×719 from 776×776)
+ - `remove_bg.py` → `final-nobg.png` (170KB, transparent bg via remove.bg API)
+ - `vectorize.py` → `final.svg` (10KB via Recraft API)
+
+## SVG Centering Fix
+
+The vectorized SVG had content off-center (bbox x:106–1983, y:285–1887 in 2000×2000 viewBox). Solution — wrap in centered group with white background:
+
+```svg
+
+
+
+
+```
+
+Content center: (1044.5, 1086). Scale 0.82 gives ~10% padding on all sides.
+
+## PNG Generation
+
+Used `rsvg-convert` (available via homebrew at `/opt/homebrew/bin/rsvg-convert`):
+
+```bash
+for SIZE in 20 29 40 58 60 76 80 87 120 152 167 180 1024; do
+ rsvg-convert -w $SIZE -h $SIZE final-centered.svg -o AppIcon-${SIZE}x${SIZE}.png
+done
+cp AppIcon-1024x1024.png AppIcon.png
+```
+
+**cairosvg NOT usable** on this machine — cairo native library missing. Use `rsvg-convert` instead.
+
+## Contents.json
+
+Mirrors SnapAction's `AppIcon.appiconset/Contents.json` exactly (18 image entries for iPhone + iPad + ios-marketing).
+
+## Files
+
+All source assets in:
+`.skill-archive/logo-creator/2026-02-27-kickwatch-logo/`
+- `final-centered.svg` ← source of truth for icon
+- `final-nobg.png`, `final-cropped.png`, `final.svg`
+
+Final PNGs committed to:
+`ios/KickWatch/Assets.xcassets/AppIcon.appiconset/`
diff --git a/.archive/2026-02-27/aws-infra-setup.md b/.archive/2026-02-27/aws-infra-setup.md
new file mode 100644
index 0000000..719b753
--- /dev/null
+++ b/.archive/2026-02-27/aws-infra-setup.md
@@ -0,0 +1,112 @@
+---
+date: 2026-02-27
+title: KickWatch AWS Infrastructure Setup
+category: infrastructure
+tags: [aws, ecs, ecr, rds, iam, oidc, secrets-manager, github-actions]
+related: [2026-02-27/mvp-implementation.md]
+---
+
+# KickWatch AWS Infrastructure Setup
+
+## Account & Region
+- Account ID: `739654145647`
+- Region: `us-east-2`
+- IAM user: `snapaction-admin` (shared with SnapAction)
+
+## Resources Created
+
+### ECR Repositories
+- `kickwatch-api` — prod
+- `kickwatch-api-dev` — dev
+
+### IAM Roles
+- `kickwatch-deploy-role` — GitHub Actions OIDC deploy role
+ - Trust: `repo:ReScienceLab/KickWatch:*`
+ - Policy: `kickwatch-deploy-policy` (ECR push, ECS deploy, iam:PassRole, secrets read)
+- `kickwatch-task-role` — ECS container role (no extra permissions in v1)
+- `ecsTaskExecutionRole` — existing shared role, added `kickwatch-secrets-access` inline policy
+
+### OIDC Provider
+- Reused existing: `arn:aws:iam::739654145647:oidc-provider/token.actions.githubusercontent.com`
+
+### ECS Clusters
+- `kickwatch-cluster` (prod, containerInsights=enabled)
+- `kickwatch-cluster-dev` (dev, containerInsights=enabled)
+
+### ECS Services
+- `kickwatch-cluster-dev/kickwatch-api-dev-service` (desired=0, task def :2)
+- `kickwatch-cluster/kickwatch-api-service` (desired=0, task def :1)
+
+### ECS Task Definitions
+- `kickwatch-api-dev:2` — dev, GIN_MODE=debug, APNS_ENV=sandbox
+- `kickwatch-api:1` — prod, GIN_MODE=release, APNS_ENV=production
+- Networking: awsvpc, subnets: `subnet-03c3f58cea867dac7`, `subnet-0eaf3dc3284bf18d9`, `subnet-0d6addfa05326637e`
+- SG: `sg-09a8956d7d1e3274e` (default VPC SG)
+- assignPublicIp: ENABLED
+
+### RDS
+- `kickwatch-db-dev` — postgres 16.8, db.t3.micro, 20GB
+ - Endpoint: `kickwatch-db-dev.c164w44w2oh3.us-east-2.rds.amazonaws.com`
+ - DB name: `kickwatch_dev`
+ - User: `kickwatch`
+ - Password: stored in `/tmp/kw_dbpw_dev.txt` locally → set in Secrets Manager
+ - Publicly accessible: YES (needed for ECS task; protected by SG)
+ - SG: `sg-0f27ad8fd043ce974` (snapaction-rds-sg, allows 5432 from default SG)
+- Prod RDS: **not yet created** — create when ready to deploy prod
+
+### CloudWatch Log Groups
+- `/ecs/kickwatch-api` (30 day retention)
+- `/ecs/kickwatch-api-dev` (14 day retention)
+
+### Secrets Manager
+All in `us-east-2`:
+| Secret | Value |
+|--------|-------|
+| `kickwatch-dev/database-url` | Real URL pointing to kickwatch-db-dev |
+| `kickwatch-dev/apns-key-id` | `FILL_IN_APNS_KEY_ID` ← needs real value |
+| `kickwatch-dev/apns-team-id` | `FILL_IN_APNS_TEAM_ID` ← needs real value |
+| `kickwatch-dev/apns-bundle-id` | `com.kickwatch.app` |
+| `kickwatch/database-url` | `PLACEHOLDER` ← fill when prod RDS created |
+| `kickwatch/apns-key-id` | `FILL_IN_APNS_KEY_ID` ← needs real value |
+| `kickwatch/apns-team-id` | `FILL_IN_APNS_TEAM_ID` ← needs real value |
+| `kickwatch/apns-bundle-id` | `com.kickwatch.app` |
+
+### GitHub Secrets (ReScienceLab/KickWatch)
+- `AWS_DEPLOY_ROLE_ARN` = `arn:aws:iam::739654145647:role/kickwatch-deploy-role`
+- `AWS_ACCOUNT_ID` = `739654145647`
+
+## GitHub Actions Workflow
+- `test-backend.yml` — triggers on `backend/**` changes, go vet + test + build
+- `deploy-backend.yml` — OIDC auth, `develop`→dev deploy, `main`→prod deploy, Dockerfile.ci
+- PR #2 open: `feature/ci-oidc → develop`
+
+## What Needs Manual Action
+1. Fill APNs secrets: `APNS_KEY_ID`, `APNS_TEAM_ID` — from Apple Developer Portal
+2. Upload `.p8` APNs key file — needs ECS secrets mount or embed in Secrets Manager
+3. Create prod RDS (`kickwatch-db`) when ready to go to production
+4. Set `DEVELOPMENT_TEAM` in `ios/project.yml` for Xcode builds
+5. Set ECS service desired_count to 1 once first image is pushed to ECR
+
+## Gotchas
+- VPN (Quantumult X) causes RDS DNS to resolve to 198.18.x.x — same issue as SnapAction
+ - Can't connect to RDS via psql from local machine when VPN active
+ - Fix: disable VPN, OR use ECS Exec from a running container
+- `kickwatch-db-dev` shares SG `sg-0f27ad8fd043ce974` with SnapAction RDS
+ - SG already had port 5432 intra-SG rule (default SG → default SG)
+- snapaction-db-dev was temporarily set to `publicly-accessible` during setup → reverted
+- ECS services start at `desired_count=0` — CI deploy sets count to 1 on first deploy
+
+## Key Commands
+```bash
+# Update a secret
+aws secretsmanager put-secret-value \
+ --secret-id kickwatch-dev/apns-key-id \
+ --region us-east-2 --secret-string "YOUR_KEY_ID"
+
+# Tail ECS logs
+aws logs tail /ecs/kickwatch-api-dev --region us-east-2 --follow
+
+# Force new deployment
+aws ecs update-service --cluster kickwatch-cluster-dev \
+ --service kickwatch-api-dev-service --force-new-deployment --region us-east-2
+```
diff --git a/.archive/2026-02-27/mvp-implementation.md b/.archive/2026-02-27/mvp-implementation.md
new file mode 100644
index 0000000..a593e1b
--- /dev/null
+++ b/.archive/2026-02-27/mvp-implementation.md
@@ -0,0 +1,135 @@
+---
+date: 2026-02-27
+title: KickWatch MVP Implementation
+category: feature
+tags: [go, gin, gorm, swiftui, swiftdata, kickstarter, graphql, apns, cron, xcodegen]
+---
+
+# KickWatch MVP Implementation
+
+## What Was Built
+
+Full MVP from scratch across 22+ atomic commits on `develop` branch.
+
+### Backend (Go/Gin) — `backend/`
+
+**Module**: `github.com/kickwatch/backend`
+
+**Key packages added**:
+```
+github.com/gin-gonic/gin
+github.com/joho/godotenv
+github.com/google/uuid
+github.com/golang-jwt/jwt/v5
+github.com/robfig/cron/v3
+gorm.io/gorm
+gorm.io/driver/postgres
+```
+
+**Structure**:
+- `internal/config/` — env var loader
+- `internal/model/` — GORM models: Campaign, Category, Device, Alert, AlertMatch
+- `internal/db/` — AutoMigrate on startup
+- `internal/middleware/` — CORS, Logger
+- `internal/service/kickstarter_rest.go` — REST /discover/advanced.json (no auth, nightly crawl)
+- `internal/service/kickstarter_graph.go` — GraphQL /graph with session bootstrap (CSRF + _ksr_session cookie), 12h refresh, 403 retry
+- `internal/service/apns.go` — APNs HTTP/2 with JWT signing (golang-jwt ES256)
+- `internal/service/cron.go` — nightly 02:00 UTC crawl, 15 categories × 10 pages, upsert + alert matching + APNs push
+- `internal/handler/` — campaigns, search, categories, devices, alerts CRUD
+
+**API routes**:
+```
+GET /api/health
+GET /api/campaigns?sort=trending|newest|ending&category_id=&cursor=&limit=
+GET /api/campaigns/search?q=&category_id=&cursor=
+GET /api/campaigns/:pid
+GET /api/categories
+POST /api/devices/register
+POST /api/alerts
+GET /api/alerts?device_id=
+PATCH /api/alerts/:id
+DELETE /api/alerts/:id
+GET /api/alerts/:id/matches
+```
+
+**Gotcha**: `restProject` struct had duplicate json tag `"urls"` on two fields — caused `go vet` failure. Fixed by removing the unused `URL string` field.
+
+### iOS (SwiftUI/SwiftData) — `ios/`
+
+**project.yml** key settings:
+```yaml
+bundleIdPrefix: com.kickwatch
+deploymentTarget: iOS: "17.0"
+PRODUCT_BUNDLE_IDENTIFIER: com.kickwatch.app
+DEVELOPMENT_TEAM: "" # fill in before building
+```
+
+**SwiftData models**: Campaign, WatchlistAlert, RecentSearch
+
+**Services**:
+- `APIClient` — actor, base URL switches DEBUG/Release, supports GET/POST/PATCH/DELETE
+- `KeychainHelper` — identical pattern to SnapAction
+- `NotificationService` — @MainActor ObservableObject, registers APNs token via APIClient, stores device_id in Keychain
+- `ImageCache` — actor-based URL→Image cache with RemoteImage SwiftUI view
+
+**ViewModels**: `@Observable` (iOS 17 pattern, not ObservableObject)
+- `DiscoverViewModel` — sort + category filter + cursor pagination
+- `AlertsViewModel` — full CRUD
+
+**Views**: DiscoverView, CampaignRowView, CampaignDetailView (funding ring), WatchlistView, AlertsView, AlertMatchesView, SearchView, SettingsView, CategoryChip
+
+**App entry**: `KickWatchApp` with `@UIApplicationDelegateAdaptor(AppDelegate.self)` for APNs token registration
+
+### CI/CD
+- `.github/workflows/test-backend.yml` — triggered on `backend/**` changes, runs `go build`, `go test`, `go vet`
+- `.github/workflows/deploy-backend.yml` — triggered on `main` push, builds Docker image, pushes to ECR, deploys to ECS
+
+## Git Workflow
+
+- Worktree created at `.worktrees/develop` for `develop` branch
+- `.worktrees/` added to `.gitignore` before creation
+- All work on `develop`, never touched `main` directly
+- Published repo: https://github.com/ReScienceLab/KickWatch
+
+## .gitignore Fix
+
+Original pattern `*.env` blocked `.env.example`. Changed to:
+```
+.env
+.env.local
+.env.production
+!.env.example
+```
+
+## App Icon
+
+- Generated via Gemini (nanobanana skill): Kickstarter K + newspaper/daily digest metaphor, Notion-style flat design, green (#05CE78)
+- Best result: `logo-07.png` → user provided `o.png` as final source
+- Processed: crop → remove_bg (remove.bg API) → vectorize (Recraft API) → SVG
+- Centered in white background SVG: `final-centered.svg`
+ - Transform: `translate(1000,1000) scale(0.82) translate(-1044.5,-1086)` (content bbox: x 106–1983, y 285–1887)
+- All 14 PNG sizes generated with `rsvg-convert` (homebrew):
+ ```bash
+ rsvg-convert -w $SIZE -h $SIZE input.svg -o AppIcon-${SIZE}x${SIZE}.png
+ ```
+ Sizes: 20, 29, 40, 58, 60, 76, 80, 87, 120, 152, 167, 180, 1024
+
+## Commands Reference
+
+```bash
+# Backend
+cd backend && go run ./cmd/api
+cd backend && go test ./...
+cd backend && go build ./... && go vet ./...
+
+# iOS
+cd ios && xcodegen generate
+xcodebuild -project ios/KickWatch.xcodeproj -scheme KickWatch build
+
+# Worktree
+git worktree add .worktrees/develop -b develop
+cd .worktrees/develop
+
+# Icon generation
+rsvg-convert -w 1024 -h 1024 final-centered.svg -o AppIcon-1024x1024.png
+```
diff --git a/.archive/MEMORY.md b/.archive/MEMORY.md
index fb9b64c..9471b6c 100644
--- a/.archive/MEMORY.md
+++ b/.archive/MEMORY.md
@@ -4,9 +4,18 @@ Archived learnings, debugging solutions, and infrastructure notes.
Search: `grep -ri "keyword" .archive/`
## Infrastructure & AWS
+- `2026-02-27/aws-infra-setup.md` — Full AWS setup: ECR (kickwatch-api/-dev), IAM OIDC deploy role, ECS clusters+services (desired=0), kickwatch-db-dev RDS (postgres 16.8, t3.micro, us-east-2), 8 Secrets Manager entries, GitHub secrets. **Pending**: create prod RDS, set ECS desired_count=1 after first ECR push. **Gotcha**: VPN breaks local→RDS psql (same as SnapAction).
+- `2026-02-27/apns-setup.md` — APNs key `GUFRSCY8ZV`, team `7Q28CBP3S5`, bundle `com.rescience.kickwatch`, Sandbox+Production env. All 8 secrets set. Backend reads APNS_KEY from env var (not file). CI injects via Secrets Manager.
## Release & Deploy
+- `2026-02-27/mvp-implementation.md` — Full MVP build: Go backend + iOS app, git workflow, CI/CD, repo published to ReScienceLab/KickWatch
## Debugging & Fixes
+- `2026-02-27/mvp-implementation.md` — .gitignore blocked `.env.example` (fix: replace `*.env` with explicit patterns + `!.env.example`); `go vet` failed on duplicate json tag in restProject struct
## Features
+- `2026-02-27/mvp-implementation.md` — Backend: Kickstarter REST+GraphQL clients, APNs, nightly cron, full REST API. iOS: SwiftData models, APIClient actor, all 4 tabs, cursor pagination
+- `2026-02-27/mvp-implementation.md` — CI/CD: GitHub Actions test + ECS deploy workflows
+
+## Design
+- `2026-02-27/app-icon-creation.md` — App icon: K + newspaper concept, Notion-style. SVG centering transform, rsvg-convert for all 14 PNG sizes (cairosvg broken on this machine — use rsvg-convert)
diff --git a/.github/workflows/deploy-backend.yml b/.github/workflows/deploy-backend.yml
new file mode 100644
index 0000000..cc0174e
--- /dev/null
+++ b/.github/workflows/deploy-backend.yml
@@ -0,0 +1,194 @@
+name: Deploy Backend to AWS ECS
+
+on:
+ push:
+ branches:
+ - main
+ - develop
+ paths:
+ - "backend/**"
+ - ".github/workflows/deploy-backend.yml"
+ workflow_dispatch:
+
+env:
+ AWS_REGION: us-east-2
+
+jobs:
+ build-and-deploy:
+ name: Build and Deploy
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ id-token: write
+
+ env:
+ IS_PROD: ${{ github.ref == 'refs/heads/main' }}
+
+ steps:
+ - name: Set environment variables
+ run: |
+ if [ "${{ env.IS_PROD }}" = "true" ]; then
+ echo "ECR_REPOSITORY=kickwatch-api" >> $GITHUB_ENV
+ echo "ECS_CLUSTER=kickwatch-cluster" >> $GITHUB_ENV
+ echo "ECS_SERVICE=kickwatch-api-service" >> $GITHUB_ENV
+ echo "CONTAINER_NAME=kickwatch-api" >> $GITHUB_ENV
+ echo "DEPLOY_ENV=production" >> $GITHUB_ENV
+ echo "SECRET_PREFIX=kickwatch" >> $GITHUB_ENV
+ echo "LOG_GROUP=/ecs/kickwatch-api" >> $GITHUB_ENV
+ echo "GIN_MODE=release" >> $GITHUB_ENV
+ else
+ echo "ECR_REPOSITORY=kickwatch-api-dev" >> $GITHUB_ENV
+ echo "ECS_CLUSTER=kickwatch-cluster-dev" >> $GITHUB_ENV
+ echo "ECS_SERVICE=kickwatch-api-dev-service" >> $GITHUB_ENV
+ echo "CONTAINER_NAME=kickwatch-api-dev" >> $GITHUB_ENV
+ echo "DEPLOY_ENV=development" >> $GITHUB_ENV
+ echo "SECRET_PREFIX=kickwatch-dev" >> $GITHUB_ENV
+ echo "LOG_GROUP=/ecs/kickwatch-api-dev" >> $GITHUB_ENV
+ echo "GIN_MODE=debug" >> $GITHUB_ENV
+ fi
+
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-go@v5
+ with:
+ go-version-file: backend/go.mod
+ cache-dependency-path: backend/go.sum
+
+ - name: Run tests and vet
+ working-directory: backend
+ run: |
+ go vet ./... &
+ VET_PID=$!
+ go test ./... &
+ TEST_PID=$!
+ wait $VET_PID || exit 1
+ wait $TEST_PID || exit 1
+
+ - name: Build Go binary
+ working-directory: backend
+ run: CGO_ENABLED=0 GOOS=linux go build -o api ./cmd/api
+
+ - name: Configure AWS credentials (OIDC)
+ uses: aws-actions/configure-aws-credentials@v4
+ with:
+ role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
+ aws-region: ${{ env.AWS_REGION }}
+
+ - name: Login to Amazon ECR
+ id: login-ecr
+ uses: aws-actions/amazon-ecr-login@v2
+
+ - name: Ensure ECR repository exists
+ run: |
+ aws ecr describe-repositories --repository-names $ECR_REPOSITORY --region $AWS_REGION 2>/dev/null || \
+ aws ecr create-repository --repository-name $ECR_REPOSITORY --region $AWS_REGION \
+ --image-scanning-configuration scanOnPush=true
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Build and push Docker image
+ id: build-image
+ uses: docker/build-push-action@v6
+ with:
+ context: backend
+ file: backend/Dockerfile.ci
+ push: true
+ tags: |
+ ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }}
+ ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:latest
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+ provenance: false
+
+ - name: Resolve Secrets Manager ARNs
+ id: secrets
+ env:
+ AWS_ACCOUNT_ID: ${{ secrets.AWS_ACCOUNT_ID }}
+ run: |
+ get_arn() { aws secretsmanager describe-secret --secret-id "$1" --region $AWS_REGION --query "ARN" --output text; }
+ echo "db_arn=$(get_arn ${SECRET_PREFIX}/database-url)" >> $GITHUB_OUTPUT
+ echo "apns_key_id_arn=$(get_arn ${SECRET_PREFIX}/apns-key-id)" >> $GITHUB_OUTPUT
+ echo "apns_team_id_arn=$(get_arn ${SECRET_PREFIX}/apns-team-id)" >> $GITHUB_OUTPUT
+ echo "apns_bundle_id_arn=$(get_arn ${SECRET_PREFIX}/apns-bundle-id)" >> $GITHUB_OUTPUT
+ echo "apns_key_arn=$(get_arn ${SECRET_PREFIX}/apns-key)" >> $GITHUB_OUTPUT
+
+ - name: Generate ECS task definition
+ env:
+ AWS_ACCOUNT_ID: ${{ secrets.AWS_ACCOUNT_ID }}
+ run: |
+ cat > /tmp/task-definition.json <> $GITHUB_STEP_SUMMARY
+ echo "- **Environment**: ${{ env.DEPLOY_ENV }}" >> $GITHUB_STEP_SUMMARY
+ echo "- **Cluster**: ${{ env.ECS_CLUSTER }}" >> $GITHUB_STEP_SUMMARY
+ echo "- **Service**: ${{ env.ECS_SERVICE }}" >> $GITHUB_STEP_SUMMARY
+ echo "- **Image**: ${{ github.sha }}" >> $GITHUB_STEP_SUMMARY
diff --git a/.github/workflows/test-backend.yml b/.github/workflows/test-backend.yml
new file mode 100644
index 0000000..10ad409
--- /dev/null
+++ b/.github/workflows/test-backend.yml
@@ -0,0 +1,27 @@
+name: Test Backend
+
+on:
+ push:
+ paths:
+ - "backend/**"
+ - ".github/workflows/test-backend.yml"
+ pull_request:
+ paths:
+ - "backend/**"
+ - ".github/workflows/test-backend.yml"
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: backend
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ cache-dependency-path: go.sum
+ - run: go vet ./...
+ - run: go test ./...
+ - run: CGO_ENABLED=0 GOOS=linux go build -o /dev/null ./cmd/api
diff --git a/.gitignore b/.gitignore
index 36bf299..2c125ae 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,8 +23,9 @@ vendor/
# Environment
.env
-.env.*
-*.env
+.env.local
+.env.production
+!.env.example
# Secrets
Configs/Secrets.swift
diff --git a/backend/.env.example b/backend/.env.example
new file mode 100644
index 0000000..e741130
--- /dev/null
+++ b/backend/.env.example
@@ -0,0 +1,8 @@
+DATABASE_URL=postgres://user:password@localhost:5432/kickwatch?sslmode=disable
+PORT=8080
+
+APNS_KEY_ID=YOUR_KEY_ID
+APNS_TEAM_ID=YOUR_TEAM_ID
+APNS_BUNDLE_ID=com.yourname.kickwatch
+APNS_KEY_PATH=/secrets/apns.p8
+APNS_ENV=sandbox
diff --git a/backend/Dockerfile b/backend/Dockerfile
new file mode 100644
index 0000000..72d7fce
--- /dev/null
+++ b/backend/Dockerfile
@@ -0,0 +1,18 @@
+FROM golang:1.24-alpine AS builder
+
+WORKDIR /app
+COPY go.mod go.sum ./
+RUN go mod download
+
+COPY . .
+RUN CGO_ENABLED=0 GOOS=linux go build -o /api ./cmd/api
+
+FROM alpine:3.20
+RUN apk --no-cache add ca-certificates && \
+ adduser -D -u 1001 appuser
+WORKDIR /app
+COPY --from=builder /api .
+
+USER appuser
+EXPOSE 8080
+CMD ["./api"]
diff --git a/backend/Dockerfile.ci b/backend/Dockerfile.ci
new file mode 100644
index 0000000..1add1a5
--- /dev/null
+++ b/backend/Dockerfile.ci
@@ -0,0 +1,8 @@
+FROM alpine:3.20
+RUN apk --no-cache add ca-certificates && \
+ adduser -D -u 1001 appuser
+WORKDIR /app
+COPY api .
+USER appuser
+EXPOSE 8080
+CMD ["./api"]
diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go
new file mode 100644
index 0000000..42dc886
--- /dev/null
+++ b/backend/cmd/api/main.go
@@ -0,0 +1,75 @@
+package main
+
+import (
+ "log"
+
+ "github.com/gin-gonic/gin"
+ "github.com/joho/godotenv"
+ "github.com/kickwatch/backend/internal/config"
+ "github.com/kickwatch/backend/internal/db"
+ "github.com/kickwatch/backend/internal/handler"
+ "github.com/kickwatch/backend/internal/middleware"
+ "github.com/kickwatch/backend/internal/service"
+)
+
+func main() {
+ _ = godotenv.Load()
+
+ cfg := config.Load()
+
+ if cfg.DatabaseURL != "" {
+ if err := db.Init(cfg); err != nil {
+ log.Fatalf("DB init: %v", err)
+ }
+ } else {
+ log.Println("DATABASE_URL not set, running without database")
+ }
+
+ graphClient := service.NewKickstarterGraphClient()
+ restClient := service.NewKickstarterRESTClient()
+
+ var cronSvc *service.CronService
+ if db.IsEnabled() {
+ var apnsClient *service.APNsClient
+ if cfg.APNSKeyPath != "" {
+ var err error
+ apnsClient, err = service.NewAPNsClient(cfg)
+ if err != nil {
+ log.Printf("APNs init failed (push disabled): %v", err)
+ }
+ }
+ cronSvc = service.NewCronService(db.DB, restClient, apnsClient)
+ cronSvc.Start()
+ defer cronSvc.Stop()
+ }
+
+ r := gin.Default()
+ r.Use(middleware.CORS())
+ r.Use(middleware.Logger())
+
+ api := r.Group("/api")
+ {
+ api.GET("/health", handler.Health)
+
+ api.GET("/campaigns", handler.ListCampaigns(graphClient))
+ api.GET("/campaigns/search", handler.SearchCampaigns(graphClient))
+ api.GET("/campaigns/:pid", handler.GetCampaign)
+ api.GET("/categories", handler.ListCategories(graphClient))
+
+ api.POST("/devices/register", handler.RegisterDevice)
+
+ alerts := api.Group("/alerts")
+ {
+ alerts.POST("", handler.CreateAlert)
+ alerts.GET("", handler.ListAlerts)
+ alerts.PATCH("/:id", handler.UpdateAlert)
+ alerts.DELETE("/:id", handler.DeleteAlert)
+ alerts.GET("/:id/matches", handler.GetAlertMatches)
+ }
+ }
+
+ log.Printf("KickWatch API starting on :%s", cfg.Port)
+ if err := r.Run(":" + cfg.Port); err != nil {
+ log.Fatal(err)
+ }
+}
diff --git a/backend/go.mod b/backend/go.mod
new file mode 100644
index 0000000..513f5da
--- /dev/null
+++ b/backend/go.mod
@@ -0,0 +1,50 @@
+module github.com/kickwatch/backend
+
+go 1.25.5
+
+require (
+ github.com/bytedance/sonic v1.14.0 // indirect
+ github.com/bytedance/sonic/loader v0.3.0 // indirect
+ github.com/cloudwego/base64x v0.1.6 // indirect
+ github.com/gabriel-vasile/mimetype v1.4.8 // indirect
+ github.com/gin-contrib/sse v1.1.0 // indirect
+ github.com/gin-gonic/gin v1.11.0 // indirect
+ github.com/go-playground/locales v0.14.1 // indirect
+ github.com/go-playground/universal-translator v0.18.1 // indirect
+ github.com/go-playground/validator/v10 v10.27.0 // indirect
+ github.com/goccy/go-json v0.10.2 // indirect
+ github.com/goccy/go-yaml v1.18.0 // indirect
+ github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/jackc/pgpassfile v1.0.0 // indirect
+ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
+ github.com/jackc/pgx/v5 v5.6.0 // indirect
+ github.com/jackc/puddle/v2 v2.2.2 // indirect
+ github.com/jinzhu/inflection v1.0.0 // indirect
+ github.com/jinzhu/now v1.1.5 // indirect
+ github.com/joho/godotenv v1.5.1 // indirect
+ github.com/json-iterator/go v1.1.12 // indirect
+ github.com/klauspost/cpuid/v2 v2.3.0 // indirect
+ github.com/leodido/go-urn v1.4.0 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
+ github.com/modern-go/reflect2 v1.0.2 // indirect
+ github.com/pelletier/go-toml/v2 v2.2.4 // indirect
+ github.com/quic-go/qpack v0.5.1 // indirect
+ github.com/quic-go/quic-go v0.54.0 // indirect
+ github.com/robfig/cron/v3 v3.0.1 // indirect
+ github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
+ github.com/ugorji/go/codec v1.3.0 // indirect
+ go.uber.org/mock v0.5.0 // indirect
+ golang.org/x/arch v0.20.0 // indirect
+ golang.org/x/crypto v0.40.0 // indirect
+ golang.org/x/mod v0.25.0 // indirect
+ golang.org/x/net v0.42.0 // indirect
+ golang.org/x/sync v0.16.0 // indirect
+ golang.org/x/sys v0.35.0 // indirect
+ golang.org/x/text v0.27.0 // indirect
+ golang.org/x/tools v0.34.0 // indirect
+ google.golang.org/protobuf v1.36.9 // indirect
+ gorm.io/driver/postgres v1.6.0 // indirect
+ gorm.io/gorm v1.31.1 // indirect
+)
diff --git a/backend/go.sum b/backend/go.sum
new file mode 100644
index 0000000..717a086
--- /dev/null
+++ b/backend/go.sum
@@ -0,0 +1,104 @@
+github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
+github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
+github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
+github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
+github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
+github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
+github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
+github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
+github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
+github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
+github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
+github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
+github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
+github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
+github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
+github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
+github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
+github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
+github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
+github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
+github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
+github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
+github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
+github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
+github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
+github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
+github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
+github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
+github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
+github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
+github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
+github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
+github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
+github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
+github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
+github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
+github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
+github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
+github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
+github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
+github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
+github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
+github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
+github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
+github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
+github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
+github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
+go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
+go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
+golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
+golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
+golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
+golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
+golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
+golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
+golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
+golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
+golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
+golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
+golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
+golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
+golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
+golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
+google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
+google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
+gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
+gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
+gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go
new file mode 100644
index 0000000..0fcb121
--- /dev/null
+++ b/backend/internal/config/config.go
@@ -0,0 +1,35 @@
+package config
+
+import "os"
+
+type Config struct {
+ DatabaseURL string
+ Port string
+ APNSKeyID string
+ APNSTeamID string
+ APNSBundleID string
+ APNSKeyPath string
+ APNSKey string
+ APNSEnv string
+}
+
+func Load() *Config {
+ port := os.Getenv("PORT")
+ if port == "" {
+ port = "8080"
+ }
+ apnsEnv := os.Getenv("APNS_ENV")
+ if apnsEnv == "" {
+ apnsEnv = "sandbox"
+ }
+ return &Config{
+ DatabaseURL: os.Getenv("DATABASE_URL"),
+ Port: port,
+ APNSKeyID: os.Getenv("APNS_KEY_ID"),
+ APNSTeamID: os.Getenv("APNS_TEAM_ID"),
+ APNSBundleID: os.Getenv("APNS_BUNDLE_ID"),
+ APNSKeyPath: os.Getenv("APNS_KEY_PATH"),
+ APNSKey: os.Getenv("APNS_KEY"),
+ APNSEnv: apnsEnv,
+ }
+}
diff --git a/backend/internal/db/db.go b/backend/internal/db/db.go
new file mode 100644
index 0000000..8877ab9
--- /dev/null
+++ b/backend/internal/db/db.go
@@ -0,0 +1,54 @@
+package db
+
+import (
+ "fmt"
+ "log"
+ "time"
+
+ "github.com/kickwatch/backend/internal/config"
+ "github.com/kickwatch/backend/internal/model"
+ "gorm.io/driver/postgres"
+ "gorm.io/gorm"
+ "gorm.io/gorm/logger"
+)
+
+var DB *gorm.DB
+
+func Init(cfg *config.Config) error {
+ if cfg.DatabaseURL == "" {
+ return fmt.Errorf("DATABASE_URL is required")
+ }
+
+ var err error
+ DB, err = gorm.Open(postgres.Open(cfg.DatabaseURL), &gorm.Config{
+ Logger: logger.Default.LogMode(logger.Warn),
+ })
+ if err != nil {
+ return fmt.Errorf("connect db: %w", err)
+ }
+
+ sqlDB, err := DB.DB()
+ if err != nil {
+ return fmt.Errorf("get sql.DB: %w", err)
+ }
+ sqlDB.SetMaxIdleConns(5)
+ sqlDB.SetMaxOpenConns(20)
+ sqlDB.SetConnMaxLifetime(time.Hour)
+
+ if err := DB.AutoMigrate(
+ &model.Campaign{},
+ &model.Category{},
+ &model.Device{},
+ &model.Alert{},
+ &model.AlertMatch{},
+ ); err != nil {
+ return fmt.Errorf("migrate: %w", err)
+ }
+
+ log.Println("Database connected and migrated")
+ return nil
+}
+
+func IsEnabled() bool {
+ return DB != nil
+}
diff --git a/backend/internal/handler/alerts.go b/backend/internal/handler/alerts.go
new file mode 100644
index 0000000..374734c
--- /dev/null
+++ b/backend/internal/handler/alerts.go
@@ -0,0 +1,157 @@
+package handler
+
+import (
+ "net/http"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/google/uuid"
+ "github.com/kickwatch/backend/internal/db"
+ "github.com/kickwatch/backend/internal/model"
+)
+
+type createAlertRequest struct {
+ DeviceID string `json:"device_id" binding:"required"`
+ Keyword string `json:"keyword" binding:"required"`
+ CategoryID string `json:"category_id"`
+ MinPercent float64 `json:"min_percent"`
+}
+
+func CreateAlert(c *gin.Context) {
+ var req createAlertRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ deviceID, err := uuid.Parse(req.DeviceID)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid device_id"})
+ return
+ }
+
+ alert := model.Alert{
+ DeviceID: deviceID,
+ Keyword: req.Keyword,
+ CategoryID: req.CategoryID,
+ MinPercent: req.MinPercent,
+ IsEnabled: true,
+ }
+ if err := db.DB.Create(&alert).Error; err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusCreated, alert)
+}
+
+func ListAlerts(c *gin.Context) {
+ deviceIDStr := c.Query("device_id")
+ if deviceIDStr == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "device_id required"})
+ return
+ }
+ deviceID, err := uuid.Parse(deviceIDStr)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid device_id"})
+ return
+ }
+
+ var alerts []model.Alert
+ if err := db.DB.Where("device_id = ?", deviceID).Find(&alerts).Error; err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, alerts)
+}
+
+type updateAlertRequest struct {
+ IsEnabled *bool `json:"is_enabled"`
+ Keyword *string `json:"keyword"`
+ CategoryID *string `json:"category_id"`
+ MinPercent *float64 `json:"min_percent"`
+}
+
+func UpdateAlert(c *gin.Context) {
+ id, err := uuid.Parse(c.Param("id"))
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid alert id"})
+ return
+ }
+
+ var alert model.Alert
+ if err := db.DB.First(&alert, "id = ?", id).Error; err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "alert not found"})
+ return
+ }
+
+ var req updateAlertRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ updates := map[string]interface{}{}
+ if req.IsEnabled != nil {
+ updates["is_enabled"] = *req.IsEnabled
+ }
+ if req.Keyword != nil {
+ updates["keyword"] = *req.Keyword
+ }
+ if req.CategoryID != nil {
+ updates["category_id"] = *req.CategoryID
+ }
+ if req.MinPercent != nil {
+ updates["min_percent"] = *req.MinPercent
+ }
+
+ if err := db.DB.Model(&alert).Updates(updates).Error; err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, alert)
+}
+
+func DeleteAlert(c *gin.Context) {
+ id, err := uuid.Parse(c.Param("id"))
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid alert id"})
+ return
+ }
+ if err := db.DB.Delete(&model.Alert{}, "id = ?", id).Error; err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.Status(http.StatusNoContent)
+}
+
+func GetAlertMatches(c *gin.Context) {
+ id, err := uuid.Parse(c.Param("id"))
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid alert id"})
+ return
+ }
+
+ since := time.Now().Add(-24 * time.Hour)
+ if sinceStr := c.Query("since"); sinceStr != "" {
+ if t, err := time.Parse(time.RFC3339, sinceStr); err == nil {
+ since = t
+ }
+ }
+
+ var matches []model.AlertMatch
+ if err := db.DB.Where("alert_id = ? AND matched_at > ?", id, since).Find(&matches).Error; err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ pids := make([]string, 0, len(matches))
+ for _, m := range matches {
+ pids = append(pids, m.CampaignPID)
+ }
+
+ var campaigns []model.Campaign
+ if len(pids) > 0 {
+ db.DB.Where("pid IN ?", pids).Find(&campaigns)
+ }
+ c.JSON(http.StatusOK, campaigns)
+}
diff --git a/backend/internal/handler/campaigns.go b/backend/internal/handler/campaigns.go
new file mode 100644
index 0000000..8be2ef7
--- /dev/null
+++ b/backend/internal/handler/campaigns.go
@@ -0,0 +1,112 @@
+package handler
+
+import (
+ "net/http"
+ "strconv"
+
+ "github.com/gin-gonic/gin"
+ "github.com/kickwatch/backend/internal/db"
+ "github.com/kickwatch/backend/internal/model"
+ "github.com/kickwatch/backend/internal/service"
+)
+
+var sortMap = map[string]string{
+ "trending": "MAGIC",
+ "newest": "NEWEST",
+ "ending": "END_DATE",
+}
+
+func ListCampaigns(graphClient *service.KickstarterGraphClient) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ sort := c.DefaultQuery("sort", "trending")
+ gqlSort, ok := sortMap[sort]
+ if !ok {
+ gqlSort = "MAGIC"
+ }
+ categoryID := c.Query("category_id")
+ cursor := c.Query("cursor")
+ limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
+ if limit > 50 {
+ limit = 50
+ }
+
+ result, err := graphClient.Search("", categoryID, gqlSort, cursor, limit)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ nextCursor := ""
+ if result.HasNextPage {
+ nextCursor = result.NextCursor
+ }
+ c.JSON(http.StatusOK, gin.H{
+ "campaigns": result.Campaigns,
+ "next_cursor": nextCursor,
+ "total": result.TotalCount,
+ })
+ }
+}
+
+func SearchCampaigns(graphClient *service.KickstarterGraphClient) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ q := c.Query("q")
+ if q == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "q is required"})
+ return
+ }
+ categoryID := c.Query("category_id")
+ cursor := c.Query("cursor")
+
+ result, err := graphClient.Search(q, categoryID, "MAGIC", cursor, 20)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ nextCursor := ""
+ if result.HasNextPage {
+ nextCursor = result.NextCursor
+ }
+ c.JSON(http.StatusOK, gin.H{
+ "campaigns": result.Campaigns,
+ "next_cursor": nextCursor,
+ })
+ }
+}
+
+func GetCampaign(c *gin.Context) {
+ pid := c.Param("pid")
+ if !db.IsEnabled() {
+ c.JSON(http.StatusServiceUnavailable, gin.H{"error": "database not available"})
+ return
+ }
+ var campaign model.Campaign
+ if err := db.DB.First(&campaign, "pid = ?", pid).Error; err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "campaign not found"})
+ return
+ }
+ c.JSON(http.StatusOK, campaign)
+}
+
+func ListCategories(graphClient *service.KickstarterGraphClient) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if db.IsEnabled() {
+ var cats []model.Category
+ if err := db.DB.Find(&cats).Error; err == nil && len(cats) > 0 {
+ c.JSON(http.StatusOK, cats)
+ return
+ }
+ }
+
+ cats, err := graphClient.FetchCategories()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ if db.IsEnabled() && len(cats) > 0 {
+ db.DB.Save(&cats)
+ }
+ c.JSON(http.StatusOK, cats)
+ }
+}
diff --git a/backend/internal/handler/devices.go b/backend/internal/handler/devices.go
new file mode 100644
index 0000000..9ac53a9
--- /dev/null
+++ b/backend/internal/handler/devices.go
@@ -0,0 +1,31 @@
+package handler
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/kickwatch/backend/internal/db"
+ "github.com/kickwatch/backend/internal/model"
+)
+
+type registerDeviceRequest struct {
+ DeviceToken string `json:"device_token" binding:"required"`
+}
+
+func RegisterDevice(c *gin.Context) {
+ var req registerDeviceRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ var device model.Device
+ result := db.DB.Where("device_token = ?", req.DeviceToken).FirstOrCreate(&device, model.Device{
+ DeviceToken: req.DeviceToken,
+ })
+ if result.Error != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": result.Error.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"device_id": device.ID})
+}
diff --git a/backend/internal/handler/health.go b/backend/internal/handler/health.go
new file mode 100644
index 0000000..7922bb2
--- /dev/null
+++ b/backend/internal/handler/health.go
@@ -0,0 +1,11 @@
+package handler
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+)
+
+func Health(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"status": "ok", "service": "kickwatch-api"})
+}
diff --git a/backend/internal/middleware/cors.go b/backend/internal/middleware/cors.go
new file mode 100644
index 0000000..af2b3d8
--- /dev/null
+++ b/backend/internal/middleware/cors.go
@@ -0,0 +1,16 @@
+package middleware
+
+import "github.com/gin-gonic/gin"
+
+func CORS() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
+ c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
+ c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
+ if c.Request.Method == "OPTIONS" {
+ c.AbortWithStatus(204)
+ return
+ }
+ c.Next()
+ }
+}
diff --git a/backend/internal/middleware/logger.go b/backend/internal/middleware/logger.go
new file mode 100644
index 0000000..8afa2d9
--- /dev/null
+++ b/backend/internal/middleware/logger.go
@@ -0,0 +1,16 @@
+package middleware
+
+import (
+ "log"
+ "time"
+
+ "github.com/gin-gonic/gin"
+)
+
+func Logger() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ start := time.Now()
+ c.Next()
+ log.Printf("%s %s %d %s", c.Request.Method, c.Request.URL.Path, c.Writer.Status(), time.Since(start))
+ }
+}
diff --git a/backend/internal/model/model.go b/backend/internal/model/model.go
new file mode 100644
index 0000000..a44e4d1
--- /dev/null
+++ b/backend/internal/model/model.go
@@ -0,0 +1,79 @@
+package model
+
+import (
+ "time"
+
+ "github.com/google/uuid"
+ "gorm.io/gorm"
+)
+
+type Campaign struct {
+ PID string `gorm:"primaryKey" json:"pid"`
+ Name string `gorm:"not null" json:"name"`
+ Blurb string `json:"blurb"`
+ PhotoURL string `json:"photo_url"`
+ GoalAmount float64 `json:"goal_amount"`
+ GoalCurrency string `json:"goal_currency"`
+ PledgedAmount float64 `json:"pledged_amount"`
+ Deadline time.Time `json:"deadline"`
+ State string `json:"state"`
+ CategoryID string `json:"category_id"`
+ CategoryName string `json:"category_name"`
+ ProjectURL string `json:"project_url"`
+ CreatorName string `json:"creator_name"`
+ PercentFunded float64 `json:"percent_funded"`
+ Slug string `json:"slug"`
+ FirstSeenAt time.Time `gorm:"not null;default:now()" json:"first_seen_at"`
+ LastUpdatedAt time.Time `gorm:"not null;default:now()" json:"last_updated_at"`
+}
+
+type Category struct {
+ ID string `gorm:"primaryKey" json:"id"`
+ Name string `gorm:"not null" json:"name"`
+ ParentID string `json:"parent_id,omitempty"`
+}
+
+type Device struct {
+ ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
+ DeviceToken string `gorm:"uniqueIndex;not null" json:"device_token"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+func (d *Device) BeforeCreate(tx *gorm.DB) error {
+ if d.ID == uuid.Nil {
+ d.ID = uuid.New()
+ }
+ return nil
+}
+
+type Alert struct {
+ ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
+ DeviceID uuid.UUID `gorm:"type:uuid;index;not null" json:"device_id"`
+ Keyword string `gorm:"not null" json:"keyword"`
+ CategoryID string `json:"category_id,omitempty"`
+ MinPercent float64 `gorm:"default:0" json:"min_percent"`
+ IsEnabled bool `gorm:"default:true" json:"is_enabled"`
+ CreatedAt time.Time `json:"created_at"`
+ LastMatchedAt *time.Time `json:"last_matched_at,omitempty"`
+}
+
+func (a *Alert) BeforeCreate(tx *gorm.DB) error {
+ if a.ID == uuid.Nil {
+ a.ID = uuid.New()
+ }
+ return nil
+}
+
+type AlertMatch struct {
+ ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
+ AlertID uuid.UUID `gorm:"type:uuid;index;not null" json:"alert_id"`
+ CampaignPID string `json:"campaign_pid"`
+ MatchedAt time.Time `gorm:"default:now()" json:"matched_at"`
+}
+
+func (am *AlertMatch) BeforeCreate(tx *gorm.DB) error {
+ if am.ID == uuid.Nil {
+ am.ID = uuid.New()
+ }
+ return nil
+}
diff --git a/backend/internal/service/apns.go b/backend/internal/service/apns.go
new file mode 100644
index 0000000..c4117ce
--- /dev/null
+++ b/backend/internal/service/apns.go
@@ -0,0 +1,172 @@
+package service
+
+import (
+ "crypto/ecdsa"
+ "crypto/x509"
+ "encoding/json"
+ "encoding/pem"
+ "fmt"
+ "log"
+ "net/http"
+ "os"
+ "sync"
+ "time"
+
+ "github.com/golang-jwt/jwt/v5"
+ "github.com/kickwatch/backend/internal/config"
+)
+
+type APNsClient struct {
+ cfg *config.Config
+ httpClient *http.Client
+ mu sync.Mutex
+ token string
+ tokenExpAt time.Time
+ privKey *ecdsa.PrivateKey
+}
+
+func NewAPNsClient(cfg *config.Config) (*APNsClient, error) {
+ var keyData []byte
+ var err error
+ if cfg.APNSKey != "" {
+ keyData = []byte(cfg.APNSKey)
+ } else if cfg.APNSKeyPath != "" {
+ keyData, err = os.ReadFile(cfg.APNSKeyPath)
+ if err != nil {
+ return nil, fmt.Errorf("read apns key: %w", err)
+ }
+ } else {
+ return nil, fmt.Errorf("APNS_KEY or APNS_KEY_PATH must be set")
+ }
+ block, _ := pem.Decode(keyData)
+ if block == nil {
+ return nil, fmt.Errorf("invalid pem block in apns key")
+ }
+ key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
+ if err != nil {
+ return nil, fmt.Errorf("parse apns key: %w", err)
+ }
+ ecKey, ok := key.(*ecdsa.PrivateKey)
+ if !ok {
+ return nil, fmt.Errorf("apns key is not ECDSA")
+ }
+
+ transport := &http.Transport{}
+ _ = http.ProxyFromEnvironment
+
+ return &APNsClient{
+ cfg: cfg,
+ httpClient: &http.Client{Transport: transport, Timeout: 10 * time.Second},
+ privKey: ecKey,
+ }, nil
+}
+
+func (a *APNsClient) bearerToken() (string, error) {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+
+ if a.token != "" && time.Now().Before(a.tokenExpAt) {
+ return a.token, nil
+ }
+
+ now := time.Now()
+ claims := jwt.RegisteredClaims{
+ Issuer: a.cfg.APNSTeamID,
+ IssuedAt: jwt.NewNumericDate(now),
+ }
+ t := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
+ t.Header["kid"] = a.cfg.APNSKeyID
+
+ signed, err := t.SignedString(a.privKey)
+ if err != nil {
+ return "", fmt.Errorf("sign apns jwt: %w", err)
+ }
+ a.token = signed
+ a.tokenExpAt = now.Add(45 * time.Minute)
+ return signed, nil
+}
+
+type APNsPayload struct {
+ APS struct {
+ Alert struct {
+ Title string `json:"title"`
+ Body string `json:"body"`
+ } `json:"alert"`
+ Badge int `json:"badge,omitempty"`
+ Sound string `json:"sound,omitempty"`
+ } `json:"aps"`
+ AlertID string `json:"alert_id,omitempty"`
+ MatchCount int `json:"match_count,omitempty"`
+}
+
+func (a *APNsClient) Send(deviceToken string, payload APNsPayload) error {
+ host := "https://api.push.apple.com"
+ if a.cfg.APNSEnv == "sandbox" {
+ host = "https://api.sandbox.push.apple.com"
+ }
+
+ body, err := json.Marshal(payload)
+ if err != nil {
+ return err
+ }
+
+ url := fmt.Sprintf("%s/3/device/%s", host, deviceToken)
+ req, err := http.NewRequest("POST", url, nil)
+ if err != nil {
+ return err
+ }
+
+ token, err := a.bearerToken()
+ if err != nil {
+ return err
+ }
+
+ req.Header.Set("Authorization", "bearer "+token)
+ req.Header.Set("apns-topic", a.cfg.APNSBundleID)
+ req.Header.Set("apns-push-type", "alert")
+ req.Header.Set("Content-Type", "application/json")
+ req.Body = http.NoBody
+ req.ContentLength = int64(len(body))
+
+ // Re-set body after NoBody assignment
+ req2, _ := http.NewRequest("POST", url, jsonBody(body))
+ req2.Header = req.Header.Clone()
+
+ resp, err := a.httpClient.Do(req2)
+ if err != nil {
+ return fmt.Errorf("apns send: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode == http.StatusGone {
+ return fmt.Errorf("apns: device token invalid (410)")
+ }
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("apns status %d", resp.StatusCode)
+ }
+ log.Printf("APNs sent to %s...", deviceToken[:min(8, len(deviceToken))])
+ return nil
+}
+
+type byteReader struct {
+ data []byte
+ offset int
+}
+
+func jsonBody(data []byte) *byteReader { return &byteReader{data: data} }
+
+func (r *byteReader) Read(p []byte) (int, error) {
+ if r.offset >= len(r.data) {
+ return 0, fmt.Errorf("EOF")
+ }
+ n := copy(p, r.data[r.offset:])
+ r.offset += n
+ return n, nil
+}
+
+func min(a, b int) int {
+ if a < b {
+ return a
+ }
+ return b
+}
diff --git a/backend/internal/service/cron.go b/backend/internal/service/cron.go
new file mode 100644
index 0000000..10581c0
--- /dev/null
+++ b/backend/internal/service/cron.go
@@ -0,0 +1,152 @@
+package service
+
+import (
+ "fmt"
+ "log"
+ "time"
+
+ "github.com/kickwatch/backend/internal/model"
+ "github.com/robfig/cron/v3"
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+)
+
+var rootCategories = []string{
+ "1", "3", "4", "5", "6", "7", "9", "10", "11", "12", "13", "14", "15", "16", "17",
+}
+
+type CronService struct {
+ db *gorm.DB
+ restClient *KickstarterRESTClient
+ apnsClient *APNsClient
+ scheduler *cron.Cron
+}
+
+func NewCronService(db *gorm.DB, restClient *KickstarterRESTClient, apns *APNsClient) *CronService {
+ return &CronService{
+ db: db,
+ restClient: restClient,
+ apnsClient: apns,
+ scheduler: cron.New(cron.WithLocation(time.UTC)),
+ }
+}
+
+func (s *CronService) Start() {
+ s.scheduler.AddFunc("0 2 * * *", func() {
+ log.Println("Cron: starting nightly crawl")
+ if err := s.runCrawl(); err != nil {
+ log.Printf("Cron: crawl error: %v", err)
+ }
+ })
+ s.scheduler.Start()
+ log.Println("Cron scheduler started (02:00 UTC daily)")
+}
+
+func (s *CronService) Stop() {
+ s.scheduler.Stop()
+}
+
+func (s *CronService) runCrawl() error {
+ upserted := 0
+ for _, catID := range rootCategories {
+ for page := 1; page <= 10; page++ {
+ campaigns, err := s.restClient.DiscoverCampaigns(catID, "newest", page)
+ if err != nil {
+ log.Printf("Cron: REST error cat=%s page=%d: %v", catID, page, err)
+ break
+ }
+ if len(campaigns) == 0 {
+ break
+ }
+ now := time.Now()
+ for i := range campaigns {
+ campaigns[i].LastUpdatedAt = now
+ }
+ result := s.db.Clauses(clause.OnConflict{
+ Columns: []clause.Column{{Name: "pid"}},
+ DoUpdates: clause.AssignmentColumns([]string{
+ "name", "blurb", "photo_url", "goal_amount", "goal_currency",
+ "pledged_amount", "deadline", "state", "category_id", "category_name",
+ "project_url", "creator_name", "percent_funded", "slug", "last_updated_at",
+ }),
+ }).Create(&campaigns)
+ if result.Error != nil {
+ log.Printf("Cron: upsert error: %v", result.Error)
+ } else {
+ upserted += len(campaigns)
+ }
+ time.Sleep(500 * time.Millisecond)
+ }
+ }
+ log.Printf("Cron: crawl done, upserted %d campaigns", upserted)
+
+ return s.matchAlerts()
+}
+
+func (s *CronService) matchAlerts() error {
+ cutoff := time.Now().Add(-25 * time.Hour)
+
+ var alerts []model.Alert
+ if err := s.db.Where("is_enabled = true").Find(&alerts).Error; err != nil {
+ return fmt.Errorf("fetch alerts: %w", err)
+ }
+
+ for _, alert := range alerts {
+ var campaigns []model.Campaign
+ query := s.db.Where(
+ "first_seen_at > ? AND name ILIKE ? AND percent_funded >= ?",
+ cutoff, "%"+alert.Keyword+"%", alert.MinPercent,
+ )
+ if alert.CategoryID != "" {
+ query = query.Where("category_id = ?", alert.CategoryID)
+ }
+ if err := query.Find(&campaigns).Error; err != nil {
+ log.Printf("Cron: match query error for alert %s: %v", alert.ID, err)
+ continue
+ }
+ if len(campaigns) == 0 {
+ continue
+ }
+
+ matches := make([]model.AlertMatch, 0, len(campaigns))
+ for _, c := range campaigns {
+ matches = append(matches, model.AlertMatch{
+ AlertID: alert.ID,
+ CampaignPID: c.PID,
+ MatchedAt: time.Now(),
+ })
+ }
+ s.db.Create(&matches)
+
+ now := time.Now()
+ s.db.Model(&alert).Update("last_matched_at", &now)
+
+ s.sendAlertPush(alert, len(campaigns))
+ }
+ return nil
+}
+
+func (s *CronService) sendAlertPush(alert model.Alert, matchCount int) {
+ if s.apnsClient == nil {
+ return
+ }
+ var device model.Device
+ if err := s.db.First(&device, "id = ?", alert.DeviceID).Error; err != nil {
+ return
+ }
+
+ payload := APNsPayload{}
+ payload.APS.Alert.Title = fmt.Sprintf("%d new \"%s\" campaigns", matchCount, alert.Keyword)
+ payload.APS.Alert.Body = "Tap to see today's matches in KickWatch"
+ payload.APS.Badge = 1
+ payload.APS.Sound = "default"
+ payload.AlertID = alert.ID.String()
+ payload.MatchCount = matchCount
+
+ if err := s.apnsClient.Send(device.DeviceToken, payload); err != nil {
+ log.Printf("Cron: APNs error for device %s: %v", device.ID, err)
+ if err.Error() == "apns: device token invalid (410)" {
+ s.db.Delete(&device)
+ }
+ }
+}
diff --git a/backend/internal/service/kickstarter_graph.go b/backend/internal/service/kickstarter_graph.go
new file mode 100644
index 0000000..befbac5
--- /dev/null
+++ b/backend/internal/service/kickstarter_graph.go
@@ -0,0 +1,293 @@
+package service
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "regexp"
+ "strconv"
+ "sync"
+ "time"
+
+ "github.com/kickwatch/backend/internal/model"
+)
+
+const (
+ ksBaseURL = "https://www.kickstarter.com"
+ ksGraphURL = "https://www.kickstarter.com/graph"
+ sessionTTL = 12 * time.Hour
+)
+
+var csrfPattern = regexp.MustCompile(`]+name="csrf-token"[^>]+content="([^"]+)"`)
+
+type graphSession struct {
+ cookie string
+ csrfToken string
+ fetchedAt time.Time
+}
+
+type KickstarterGraphClient struct {
+ mu sync.Mutex
+ session *graphSession
+ httpClient *http.Client
+}
+
+func NewKickstarterGraphClient() *KickstarterGraphClient {
+ return &KickstarterGraphClient{
+ httpClient: &http.Client{Timeout: 30 * time.Second},
+ }
+}
+
+func (c *KickstarterGraphClient) ensureSession() error {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ if c.session != nil && time.Since(c.session.fetchedAt) < sessionTTL {
+ return nil
+ }
+
+ req, _ := http.NewRequest("GET", ksBaseURL, nil)
+ req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36")
+ req.Header.Set("Accept", "text/html")
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("bootstrap session: %w", err)
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return fmt.Errorf("read bootstrap body: %w", err)
+ }
+
+ matches := csrfPattern.FindSubmatch(body)
+ if len(matches) < 2 {
+ return fmt.Errorf("csrf token not found in page")
+ }
+ csrfToken := string(matches[1])
+
+ var sessionCookie string
+ for _, cookie := range resp.Cookies() {
+ if cookie.Name == "_ksr_session" {
+ sessionCookie = cookie.Value
+ break
+ }
+ }
+ if sessionCookie == "" {
+ return fmt.Errorf("_ksr_session cookie not found")
+ }
+
+ c.session = &graphSession{
+ cookie: sessionCookie,
+ csrfToken: csrfToken,
+ fetchedAt: time.Now(),
+ }
+ log.Println("Kickstarter GraphQL session refreshed")
+ return nil
+}
+
+func (c *KickstarterGraphClient) doGraphQL(query string, variables map[string]interface{}, result interface{}) error {
+ if err := c.ensureSession(); err != nil {
+ return err
+ }
+
+ payload := map[string]interface{}{
+ "query": query,
+ "variables": variables,
+ }
+ body, _ := json.Marshal(payload)
+
+ req, _ := http.NewRequest("POST", ksGraphURL, bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36")
+ req.Header.Set("x-csrf-token", c.session.csrfToken)
+ req.Header.Set("Cookie", "_ksr_session="+c.session.cookie)
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("graphql request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode == http.StatusForbidden {
+ c.mu.Lock()
+ c.session = nil
+ c.mu.Unlock()
+ return fmt.Errorf("graphql 403: session expired")
+ }
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("graphql status %d", resp.StatusCode)
+ }
+
+ return json.NewDecoder(resp.Body).Decode(result)
+}
+
+const searchQuery = `
+query Search($term: String, $sort: ProjectSort, $categoryId: String, $state: PublicProjectState, $first: Int, $cursor: String) {
+ projects(term: $term, sort: $sort, categoryId: $categoryId, state: $state, after: $cursor, first: $first) {
+ nodes {
+ pid
+ name
+ state
+ deadlineAt
+ percentFunded
+ url
+ image { url(width: 1024) }
+ goal { amount currency }
+ pledged { amount currency }
+ creator { name }
+ category { id name }
+ }
+ totalCount
+ pageInfo { endCursor hasNextPage }
+ }
+}`
+
+type graphSearchResp struct {
+ Data struct {
+ Projects struct {
+ Nodes []struct {
+ PID string `json:"pid"`
+ Name string `json:"name"`
+ State string `json:"state"`
+ DeadlineAt *string `json:"deadlineAt"`
+ PercentFunded float64 `json:"percentFunded"`
+ URL string `json:"url"`
+ Image *struct {
+ URL string `json:"url"`
+ } `json:"image"`
+ Goal *struct {
+ Amount string
+ Currency string
+ } `json:"goal"`
+ Pledged *struct {
+ Amount string
+ Currency string
+ } `json:"pledged"`
+ Creator *struct{ Name string } `json:"creator"`
+ Category *struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ } `json:"category"`
+ } `json:"nodes"`
+ TotalCount int `json:"totalCount"`
+ PageInfo struct {
+ EndCursor string `json:"endCursor"`
+ HasNextPage bool `json:"hasNextPage"`
+ } `json:"pageInfo"`
+ } `json:"projects"`
+ } `json:"data"`
+}
+
+type SearchResult struct {
+ Campaigns []model.Campaign
+ TotalCount int
+ NextCursor string
+ HasNextPage bool
+}
+
+func (c *KickstarterGraphClient) Search(term, categoryID, sort, cursor string, first int) (*SearchResult, error) {
+ vars := map[string]interface{}{
+ "term": term,
+ "sort": sort,
+ "first": first,
+ "state": "LIVE",
+ }
+ if categoryID != "" {
+ vars["categoryId"] = categoryID
+ }
+ if cursor != "" {
+ vars["cursor"] = cursor
+ }
+
+ var resp graphSearchResp
+ if err := c.doGraphQL(searchQuery, vars, &resp); err != nil {
+ return nil, err
+ }
+
+ campaigns := make([]model.Campaign, 0, len(resp.Data.Projects.Nodes))
+ for _, n := range resp.Data.Projects.Nodes {
+ cam := model.Campaign{
+ PID: n.PID,
+ Name: n.Name,
+ State: n.State,
+ ProjectURL: n.URL,
+ }
+ if n.Image != nil {
+ cam.PhotoURL = n.Image.URL
+ }
+ if n.Goal != nil {
+ cam.GoalAmount, _ = strconv.ParseFloat(n.Goal.Amount, 64)
+ cam.GoalCurrency = n.Goal.Currency
+ }
+ if n.Pledged != nil {
+ cam.PledgedAmount, _ = strconv.ParseFloat(n.Pledged.Amount, 64)
+ }
+ if n.Creator != nil {
+ cam.CreatorName = n.Creator.Name
+ }
+ if n.Category != nil {
+ cam.CategoryID = n.Category.ID
+ cam.CategoryName = n.Category.Name
+ }
+ if n.DeadlineAt != nil {
+ cam.Deadline, _ = time.Parse(time.RFC3339, *n.DeadlineAt)
+ }
+ cam.PercentFunded = n.PercentFunded
+ campaigns = append(campaigns, cam)
+ }
+
+ return &SearchResult{
+ Campaigns: campaigns,
+ TotalCount: resp.Data.Projects.TotalCount,
+ NextCursor: resp.Data.Projects.PageInfo.EndCursor,
+ HasNextPage: resp.Data.Projects.PageInfo.HasNextPage,
+ }, nil
+}
+
+const categoriesQuery = `
+query FetchRootCategories {
+ rootCategories {
+ id
+ name
+ subcategories {
+ nodes { id name parentId }
+ }
+ }
+}`
+
+type graphCategoriesResp struct {
+ Data struct {
+ RootCategories []struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Subcategories struct {
+ Nodes []struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ ParentID string `json:"parentId"`
+ } `json:"nodes"`
+ } `json:"subcategories"`
+ } `json:"rootCategories"`
+ } `json:"data"`
+}
+
+func (c *KickstarterGraphClient) FetchCategories() ([]model.Category, error) {
+ var resp graphCategoriesResp
+ if err := c.doGraphQL(categoriesQuery, nil, &resp); err != nil {
+ return nil, err
+ }
+
+ var cats []model.Category
+ for _, rc := range resp.Data.RootCategories {
+ cats = append(cats, model.Category{ID: rc.ID, Name: rc.Name})
+ for _, sub := range rc.Subcategories.Nodes {
+ cats = append(cats, model.Category{ID: sub.ID, Name: sub.Name, ParentID: sub.ParentID})
+ }
+ }
+ return cats, nil
+}
diff --git a/backend/internal/service/kickstarter_rest.go b/backend/internal/service/kickstarter_rest.go
new file mode 100644
index 0000000..d4795bc
--- /dev/null
+++ b/backend/internal/service/kickstarter_rest.go
@@ -0,0 +1,116 @@
+package service
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strconv"
+ "time"
+
+ "github.com/kickwatch/backend/internal/model"
+)
+
+const restBaseURL = "https://www.kickstarter.com/discover/advanced.json"
+
+type restProject struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ Blurb string `json:"blurb"`
+ State string `json:"state"`
+ PercentFunded int `json:"percent_funded"`
+ Goal string `json:"goal"`
+ Pledged string `json:"pledged"`
+ Currency string `json:"currency"`
+ Deadline int64 `json:"deadline"`
+ Slug string `json:"slug"`
+ Photo struct {
+ Full string `json:"full"`
+ } `json:"photo"`
+ Creator struct {
+ Name string `json:"name"`
+ } `json:"creator"`
+ Category struct {
+ ID int `json:"id"`
+ Name string `json:"name"`
+ ParentID *int `json:"parent_id"`
+ } `json:"category"`
+ URLs struct {
+ Web struct {
+ Project string `json:"project"`
+ } `json:"web"`
+ } `json:"urls"`
+}
+
+type restResponse struct {
+ Projects []restProject `json:"projects"`
+}
+
+type KickstarterRESTClient struct {
+ httpClient *http.Client
+}
+
+func NewKickstarterRESTClient() *KickstarterRESTClient {
+ return &KickstarterRESTClient{
+ httpClient: &http.Client{Timeout: 30 * time.Second},
+ }
+}
+
+func (c *KickstarterRESTClient) DiscoverCampaigns(categoryID string, sort string, page int) ([]model.Campaign, error) {
+ params := url.Values{}
+ params.Set("sort", sort)
+ params.Set("page", strconv.Itoa(page))
+ params.Set("per_page", "20")
+ if categoryID != "" {
+ params.Set("category_id", categoryID)
+ }
+
+ reqURL := restBaseURL + "?" + params.Encode()
+ req, err := http.NewRequest("GET", reqURL, nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36")
+ req.Header.Set("Accept", "application/json")
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("rest discover: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("rest discover: status %d", resp.StatusCode)
+ }
+
+ var result restResponse
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return nil, fmt.Errorf("rest decode: %w", err)
+ }
+
+ campaigns := make([]model.Campaign, 0, len(result.Projects))
+ for _, p := range result.Projects {
+ goal, _ := strconv.ParseFloat(p.Goal, 64)
+ pledged, _ := strconv.ParseFloat(p.Pledged, 64)
+ deadline := time.Unix(p.Deadline, 0)
+
+ campaigns = append(campaigns, model.Campaign{
+ PID: strconv.FormatInt(p.ID, 10),
+ Name: p.Name,
+ Blurb: p.Blurb,
+ PhotoURL: p.Photo.Full,
+ GoalAmount: goal,
+ GoalCurrency: p.Currency,
+ PledgedAmount: pledged,
+ Deadline: deadline,
+ State: p.State,
+ CategoryID: strconv.Itoa(p.Category.ID),
+ CategoryName: p.Category.Name,
+ ProjectURL: p.URLs.Web.Project,
+ CreatorName: p.Creator.Name,
+ PercentFunded: float64(p.PercentFunded),
+ Slug: p.Slug,
+ })
+ }
+ return campaigns, nil
+}
diff --git a/ios/KickWatch/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/KickWatch/Assets.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 0000000..f0e4ccc
--- /dev/null
+++ b/ios/KickWatch/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "colorSpace" : "sRGB",
+ "components" : { "alpha" : "1.000", "blue" : "0.200", "green" : "0.478", "red" : "0.000" }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : { "author" : "xcode", "version" : 1 }
+}
diff --git a/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-1024x1024.png b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-1024x1024.png
new file mode 100644
index 0000000..6251057
Binary files /dev/null and b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-1024x1024.png differ
diff --git a/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-120x120.png b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-120x120.png
new file mode 100644
index 0000000..41cc4f9
Binary files /dev/null and b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-120x120.png differ
diff --git a/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-152x152.png b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-152x152.png
new file mode 100644
index 0000000..d714bee
Binary files /dev/null and b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-152x152.png differ
diff --git a/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-167x167.png b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-167x167.png
new file mode 100644
index 0000000..225fd42
Binary files /dev/null and b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-167x167.png differ
diff --git a/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-180x180.png b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-180x180.png
new file mode 100644
index 0000000..d0a98f4
Binary files /dev/null and b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-180x180.png differ
diff --git a/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20.png b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20.png
new file mode 100644
index 0000000..696f16c
Binary files /dev/null and b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20.png differ
diff --git a/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29.png b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29.png
new file mode 100644
index 0000000..a1aac0e
Binary files /dev/null and b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29.png differ
diff --git a/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40.png b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40.png
new file mode 100644
index 0000000..5867768
Binary files /dev/null and b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40.png differ
diff --git a/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-58x58.png b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-58x58.png
new file mode 100644
index 0000000..6a14a5e
Binary files /dev/null and b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-58x58.png differ
diff --git a/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60.png b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60.png
new file mode 100644
index 0000000..4011590
Binary files /dev/null and b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60.png differ
diff --git a/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76.png b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76.png
new file mode 100644
index 0000000..a8917bb
Binary files /dev/null and b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76.png differ
diff --git a/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-80x80.png b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-80x80.png
new file mode 100644
index 0000000..361570f
Binary files /dev/null and b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-80x80.png differ
diff --git a/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-87x87.png b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-87x87.png
new file mode 100644
index 0000000..38ca7d1
Binary files /dev/null and b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-87x87.png differ
diff --git a/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon.png
new file mode 100644
index 0000000..6251057
Binary files /dev/null and b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ
diff --git a/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon.svg b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon.svg
new file mode 100644
index 0000000..7d5027f
--- /dev/null
+++ b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/AppIcon.svg
@@ -0,0 +1,17 @@
+
diff --git a/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..10c27ca
--- /dev/null
+++ b/ios/KickWatch/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,116 @@
+{
+ "images" : [
+ {
+ "filename" : "AppIcon-40x40.png",
+ "idiom" : "iphone",
+ "scale" : "2x",
+ "size" : "20x20"
+ },
+ {
+ "filename" : "AppIcon-60x60.png",
+ "idiom" : "iphone",
+ "scale" : "3x",
+ "size" : "20x20"
+ },
+ {
+ "filename" : "AppIcon-58x58.png",
+ "idiom" : "iphone",
+ "scale" : "2x",
+ "size" : "29x29"
+ },
+ {
+ "filename" : "AppIcon-87x87.png",
+ "idiom" : "iphone",
+ "scale" : "3x",
+ "size" : "29x29"
+ },
+ {
+ "filename" : "AppIcon-80x80.png",
+ "idiom" : "iphone",
+ "scale" : "2x",
+ "size" : "40x40"
+ },
+ {
+ "filename" : "AppIcon-120x120.png",
+ "idiom" : "iphone",
+ "scale" : "3x",
+ "size" : "40x40"
+ },
+ {
+ "filename" : "AppIcon-120x120.png",
+ "idiom" : "iphone",
+ "scale" : "2x",
+ "size" : "60x60"
+ },
+ {
+ "filename" : "AppIcon-180x180.png",
+ "idiom" : "iphone",
+ "scale" : "3x",
+ "size" : "60x60"
+ },
+ {
+ "filename" : "AppIcon-20x20.png",
+ "idiom" : "ipad",
+ "scale" : "1x",
+ "size" : "20x20"
+ },
+ {
+ "filename" : "AppIcon-40x40.png",
+ "idiom" : "ipad",
+ "scale" : "2x",
+ "size" : "20x20"
+ },
+ {
+ "filename" : "AppIcon-29x29.png",
+ "idiom" : "ipad",
+ "scale" : "1x",
+ "size" : "29x29"
+ },
+ {
+ "filename" : "AppIcon-58x58.png",
+ "idiom" : "ipad",
+ "scale" : "2x",
+ "size" : "29x29"
+ },
+ {
+ "filename" : "AppIcon-40x40.png",
+ "idiom" : "ipad",
+ "scale" : "1x",
+ "size" : "40x40"
+ },
+ {
+ "filename" : "AppIcon-80x80.png",
+ "idiom" : "ipad",
+ "scale" : "2x",
+ "size" : "40x40"
+ },
+ {
+ "filename" : "AppIcon-76x76.png",
+ "idiom" : "ipad",
+ "scale" : "1x",
+ "size" : "76x76"
+ },
+ {
+ "filename" : "AppIcon-152x152.png",
+ "idiom" : "ipad",
+ "scale" : "2x",
+ "size" : "76x76"
+ },
+ {
+ "filename" : "AppIcon-167x167.png",
+ "idiom" : "ipad",
+ "scale" : "2x",
+ "size" : "83.5x83.5"
+ },
+ {
+ "filename" : "AppIcon-1024x1024.png",
+ "idiom" : "ios-marketing",
+ "scale" : "1x",
+ "size" : "1024x1024"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ios/KickWatch/Assets.xcassets/Contents.json b/ios/KickWatch/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/ios/KickWatch/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ios/KickWatch/KickWatch.entitlements b/ios/KickWatch/KickWatch.entitlements
new file mode 100644
index 0000000..2cf0210
--- /dev/null
+++ b/ios/KickWatch/KickWatch.entitlements
@@ -0,0 +1,8 @@
+
+
+
+
+ aps-environment
+ development
+
+
diff --git a/ios/KickWatch/Sources/App/AppDelegate.swift b/ios/KickWatch/Sources/App/AppDelegate.swift
new file mode 100644
index 0000000..1a590dc
--- /dev/null
+++ b/ios/KickWatch/Sources/App/AppDelegate.swift
@@ -0,0 +1,20 @@
+import UIKit
+
+final class AppDelegate: NSObject, UIApplicationDelegate {
+ func application(
+ _ application: UIApplication,
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
+ ) -> Bool {
+ return true
+ }
+
+ func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
+ Task {
+ await NotificationService.shared.registerDeviceToken(deviceToken)
+ }
+ }
+
+ func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
+ print("AppDelegate: failed to register for remote notifications: \(error)")
+ }
+}
diff --git a/ios/KickWatch/Sources/App/ContentView.swift b/ios/KickWatch/Sources/App/ContentView.swift
new file mode 100644
index 0000000..1e2dedf
--- /dev/null
+++ b/ios/KickWatch/Sources/App/ContentView.swift
@@ -0,0 +1,19 @@
+import SwiftUI
+
+struct ContentView: View {
+ var body: some View {
+ TabView {
+ DiscoverView()
+ .tabItem { Label("Discover", systemImage: "safari") }
+
+ WatchlistView()
+ .tabItem { Label("Watchlist", systemImage: "heart.fill") }
+
+ AlertsView()
+ .tabItem { Label("Alerts", systemImage: "bell.fill") }
+
+ SettingsView()
+ .tabItem { Label("Settings", systemImage: "gearshape") }
+ }
+ }
+}
diff --git a/ios/KickWatch/Sources/App/KickWatchApp.swift b/ios/KickWatch/Sources/App/KickWatchApp.swift
new file mode 100644
index 0000000..6729b5c
--- /dev/null
+++ b/ios/KickWatch/Sources/App/KickWatchApp.swift
@@ -0,0 +1,38 @@
+import SwiftUI
+import SwiftData
+
+@main
+struct KickWatchApp: App {
+ let container: ModelContainer
+ @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
+
+ private static let schemaVersion = 1
+
+ init() {
+ let defaults = UserDefaults.standard
+ if defaults.integer(forKey: "schemaVersion") != Self.schemaVersion {
+ Self.deleteStore()
+ defaults.set(Self.schemaVersion, forKey: "schemaVersion")
+ }
+ do {
+ container = try ModelContainer(for: Campaign.self, WatchlistAlert.self, RecentSearch.self)
+ } catch {
+ Self.deleteStore()
+ container = try! ModelContainer(for: Campaign.self, WatchlistAlert.self, RecentSearch.self)
+ }
+ }
+
+ private static func deleteStore() {
+ let url = URL.applicationSupportDirectory.appending(path: "default.store")
+ for ext in ["", "-wal", "-shm"] {
+ try? FileManager.default.removeItem(at: url.appendingPathExtension(ext))
+ }
+ }
+
+ var body: some Scene {
+ WindowGroup {
+ ContentView()
+ }
+ .modelContainer(container)
+ }
+}
diff --git a/ios/KickWatch/Sources/Models/Campaign.swift b/ios/KickWatch/Sources/Models/Campaign.swift
new file mode 100644
index 0000000..bb08d48
--- /dev/null
+++ b/ios/KickWatch/Sources/Models/Campaign.swift
@@ -0,0 +1,71 @@
+import Foundation
+import SwiftData
+
+@Model
+final class Campaign {
+ @Attribute(.unique) var pid: String
+ var name: String
+ var blurb: String
+ var photoURL: String
+ var goalAmount: Double
+ var goalCurrency: String
+ var pledgedAmount: Double
+ var deadline: Date
+ var state: String
+ var categoryName: String
+ var categoryID: String
+ var projectURL: String
+ var creatorName: String
+ var percentFunded: Double
+ var isWatched: Bool
+ var lastFetchedAt: Date
+
+ init(
+ pid: String,
+ name: String,
+ blurb: String = "",
+ photoURL: String = "",
+ goalAmount: Double = 0,
+ goalCurrency: String = "USD",
+ pledgedAmount: Double = 0,
+ deadline: Date = .distantFuture,
+ state: String = "live",
+ categoryName: String = "",
+ categoryID: String = "",
+ projectURL: String = "",
+ creatorName: String = "",
+ percentFunded: Double = 0,
+ isWatched: Bool = false,
+ lastFetchedAt: Date = .now
+ ) {
+ self.pid = pid
+ self.name = name
+ self.blurb = blurb
+ self.photoURL = photoURL
+ self.goalAmount = goalAmount
+ self.goalCurrency = goalCurrency
+ self.pledgedAmount = pledgedAmount
+ self.deadline = deadline
+ self.state = state
+ self.categoryName = categoryName
+ self.categoryID = categoryID
+ self.projectURL = projectURL
+ self.creatorName = creatorName
+ self.percentFunded = percentFunded
+ self.isWatched = isWatched
+ self.lastFetchedAt = lastFetchedAt
+ }
+
+ var daysLeft: Int {
+ max(0, Calendar.current.dateComponents([.day], from: .now, to: deadline).day ?? 0)
+ }
+
+ var stateLabel: String {
+ switch state {
+ case "successful": return "Funded"
+ case "failed": return "Failed"
+ case "canceled": return "Canceled"
+ default: return "Live"
+ }
+ }
+}
diff --git a/ios/KickWatch/Sources/Models/RecentSearch.swift b/ios/KickWatch/Sources/Models/RecentSearch.swift
new file mode 100644
index 0000000..7f35d05
--- /dev/null
+++ b/ios/KickWatch/Sources/Models/RecentSearch.swift
@@ -0,0 +1,13 @@
+import Foundation
+import SwiftData
+
+@Model
+final class RecentSearch {
+ var query: String
+ var searchedAt: Date
+
+ init(query: String, searchedAt: Date = .now) {
+ self.query = query
+ self.searchedAt = searchedAt
+ }
+}
diff --git a/ios/KickWatch/Sources/Models/WatchlistAlert.swift b/ios/KickWatch/Sources/Models/WatchlistAlert.swift
new file mode 100644
index 0000000..4d3d0c0
--- /dev/null
+++ b/ios/KickWatch/Sources/Models/WatchlistAlert.swift
@@ -0,0 +1,31 @@
+import Foundation
+import SwiftData
+
+@Model
+final class WatchlistAlert {
+ @Attribute(.unique) var id: String
+ var keyword: String
+ var categoryID: String?
+ var minPercentFunded: Double
+ var isEnabled: Bool
+ var createdAt: Date
+ var lastMatchedAt: Date?
+
+ init(
+ id: String = UUID().uuidString,
+ keyword: String,
+ categoryID: String? = nil,
+ minPercentFunded: Double = 0,
+ isEnabled: Bool = true,
+ createdAt: Date = .now,
+ lastMatchedAt: Date? = nil
+ ) {
+ self.id = id
+ self.keyword = keyword
+ self.categoryID = categoryID
+ self.minPercentFunded = minPercentFunded
+ self.isEnabled = isEnabled
+ self.createdAt = createdAt
+ self.lastMatchedAt = lastMatchedAt
+ }
+}
diff --git a/ios/KickWatch/Sources/Services/APIClient.swift b/ios/KickWatch/Sources/Services/APIClient.swift
new file mode 100644
index 0000000..2e6db76
--- /dev/null
+++ b/ios/KickWatch/Sources/Services/APIClient.swift
@@ -0,0 +1,184 @@
+import Foundation
+
+struct CampaignDTO: Codable {
+ let pid: String
+ let name: String
+ let blurb: String?
+ let photo_url: String?
+ let goal_amount: Double?
+ let goal_currency: String?
+ let pledged_amount: Double?
+ let deadline: String?
+ let state: String?
+ let category_name: String?
+ let category_id: String?
+ let project_url: String?
+ let creator_name: String?
+ let percent_funded: Double?
+ let slug: String?
+}
+
+struct CategoryDTO: Codable {
+ let id: String
+ let name: String
+ let parent_id: String?
+}
+
+struct CampaignListResponse: Codable {
+ let campaigns: [CampaignDTO]
+ let next_cursor: String?
+ let total: Int?
+}
+
+struct SearchResponse: Codable {
+ let campaigns: [CampaignDTO]
+ let next_cursor: String?
+}
+
+struct RegisterDeviceRequest: Codable {
+ let device_token: String
+}
+
+struct RegisterDeviceResponse: Codable {
+ let device_id: String
+}
+
+struct CreateAlertRequest: Codable {
+ let device_id: String
+ let keyword: String
+ let category_id: String?
+ let min_percent: Double?
+}
+
+struct AlertDTO: Codable {
+ let id: String
+ let device_id: String
+ let keyword: String
+ let category_id: String?
+ let min_percent: Double
+ let is_enabled: Bool
+ let created_at: String
+ let last_matched_at: String?
+}
+
+struct UpdateAlertRequest: Codable {
+ let is_enabled: Bool?
+ let keyword: String?
+ let category_id: String?
+ let min_percent: Double?
+}
+
+enum APIError: LocalizedError {
+ case invalidURL
+ case invalidResponse
+ case serverError(statusCode: Int)
+
+ var errorDescription: String? {
+ switch self {
+ case .invalidURL: return "Invalid URL"
+ case .invalidResponse: return "Invalid server response"
+ case .serverError(let code): return "Server error: \(code)"
+ }
+ }
+}
+
+actor APIClient {
+ static let shared = APIClient()
+
+ private let baseURL: String
+ private let session: URLSession
+
+ init(baseURL: String? = nil) {
+ #if DEBUG
+ self.baseURL = baseURL ?? "http://localhost:8080"
+ #else
+ self.baseURL = baseURL ?? "https://api.kickwatch.app"
+ #endif
+ let config = URLSessionConfiguration.default
+ config.timeoutIntervalForRequest = 30
+ self.session = URLSession(configuration: config)
+ }
+
+ func fetchCampaigns(sort: String = "trending", categoryID: String? = nil, cursor: String? = nil) async throws -> CampaignListResponse {
+ var components = URLComponents(string: baseURL + "/api/campaigns")!
+ var items: [URLQueryItem] = [URLQueryItem(name: "sort", value: sort)]
+ if let cat = categoryID { items.append(URLQueryItem(name: "category_id", value: cat)) }
+ if let cur = cursor { items.append(URLQueryItem(name: "cursor", value: cur)) }
+ components.queryItems = items
+ return try await get(url: components.url!)
+ }
+
+ func searchCampaigns(query: String, categoryID: String? = nil, cursor: String? = nil) async throws -> SearchResponse {
+ var components = URLComponents(string: baseURL + "/api/campaigns/search")!
+ var items: [URLQueryItem] = [URLQueryItem(name: "q", value: query)]
+ if let cat = categoryID { items.append(URLQueryItem(name: "category_id", value: cat)) }
+ if let cur = cursor { items.append(URLQueryItem(name: "cursor", value: cur)) }
+ components.queryItems = items
+ return try await get(url: components.url!)
+ }
+
+ func fetchCategories() async throws -> [CategoryDTO] {
+ return try await get(url: URL(string: baseURL + "/api/categories")!)
+ }
+
+ func registerDevice(token: String) async throws -> RegisterDeviceResponse {
+ return try await post(url: URL(string: baseURL + "/api/devices/register")!, body: RegisterDeviceRequest(device_token: token))
+ }
+
+ func fetchAlerts(deviceID: String) async throws -> [AlertDTO] {
+ let url = URL(string: baseURL + "/api/alerts?device_id=\(deviceID)")!
+ return try await get(url: url)
+ }
+
+ func createAlert(_ req: CreateAlertRequest) async throws -> AlertDTO {
+ return try await post(url: URL(string: baseURL + "/api/alerts")!, body: req)
+ }
+
+ func updateAlert(id: String, req: UpdateAlertRequest) async throws -> AlertDTO {
+ return try await patch(url: URL(string: baseURL + "/api/alerts/\(id)")!, body: req)
+ }
+
+ func deleteAlert(id: String) async throws {
+ let url = URL(string: baseURL + "/api/alerts/\(id)")!
+ var request = URLRequest(url: url)
+ request.httpMethod = "DELETE"
+ let (_, response) = try await session.data(for: request)
+ guard let http = response as? HTTPURLResponse, 200..<300 ~= http.statusCode else {
+ throw APIError.invalidResponse
+ }
+ }
+
+ func fetchAlertMatches(alertID: String) async throws -> [CampaignDTO] {
+ let url = URL(string: baseURL + "/api/alerts/\(alertID)/matches")!
+ return try await get(url: url)
+ }
+
+ private func get(url: URL) async throws -> R {
+ let (data, response) = try await session.data(from: url)
+ guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
+ guard 200..<300 ~= http.statusCode else { throw APIError.serverError(statusCode: http.statusCode) }
+ return try JSONDecoder().decode(R.self, from: data)
+ }
+
+ private func post(url: URL, body: T) async throws -> R {
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.httpBody = try JSONEncoder().encode(body)
+ let (data, response) = try await session.data(for: request)
+ guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
+ guard 200..<300 ~= http.statusCode else { throw APIError.serverError(statusCode: http.statusCode) }
+ return try JSONDecoder().decode(R.self, from: data)
+ }
+
+ private func patch(url: URL, body: T) async throws -> R {
+ var request = URLRequest(url: url)
+ request.httpMethod = "PATCH"
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.httpBody = try JSONEncoder().encode(body)
+ let (data, response) = try await session.data(for: request)
+ guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
+ guard 200..<300 ~= http.statusCode else { throw APIError.serverError(statusCode: http.statusCode) }
+ return try JSONDecoder().decode(R.self, from: data)
+ }
+}
diff --git a/ios/KickWatch/Sources/Services/ImageCache.swift b/ios/KickWatch/Sources/Services/ImageCache.swift
new file mode 100644
index 0000000..7bbe6cb
--- /dev/null
+++ b/ios/KickWatch/Sources/Services/ImageCache.swift
@@ -0,0 +1,34 @@
+import SwiftUI
+
+actor ImageCache {
+ static let shared = ImageCache()
+ private var cache: [URL: Image] = [:]
+
+ func image(for url: URL) async -> Image? {
+ if let cached = cache[url] { return cached }
+ guard let (data, _) = try? await URLSession.shared.data(from: url),
+ let uiImage = UIImage(data: data) else { return nil }
+ let image = Image(uiImage: uiImage)
+ cache[url] = image
+ return image
+ }
+}
+
+struct RemoteImage: View {
+ let urlString: String
+ @State private var image: Image?
+
+ var body: some View {
+ Group {
+ if let image {
+ image.resizable().scaledToFill()
+ } else {
+ Rectangle().fill(Color(.systemGray5))
+ .task {
+ guard let url = URL(string: urlString) else { return }
+ image = await ImageCache.shared.image(for: url)
+ }
+ }
+ }
+ }
+}
diff --git a/ios/KickWatch/Sources/Services/KeychainHelper.swift b/ios/KickWatch/Sources/Services/KeychainHelper.swift
new file mode 100644
index 0000000..4ae57de
--- /dev/null
+++ b/ios/KickWatch/Sources/Services/KeychainHelper.swift
@@ -0,0 +1,36 @@
+import Foundation
+import Security
+
+enum KeychainHelper {
+ static func save(_ value: String, for key: String) {
+ let data = Data(value.utf8)
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrAccount as String: key,
+ kSecValueData as String: data
+ ]
+ SecItemDelete(query as CFDictionary)
+ SecItemAdd(query as CFDictionary, nil)
+ }
+
+ static func load(for key: String) -> String? {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrAccount as String: key,
+ kSecReturnData as String: true,
+ kSecMatchLimit as String: kSecMatchLimitOne
+ ]
+ var result: AnyObject?
+ let status = SecItemCopyMatching(query as CFDictionary, &result)
+ guard status == errSecSuccess, let data = result as? Data else { return nil }
+ return String(decoding: data, as: UTF8.self)
+ }
+
+ static func delete(for key: String) {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrAccount as String: key
+ ]
+ SecItemDelete(query as CFDictionary)
+ }
+}
diff --git a/ios/KickWatch/Sources/Services/NotificationService.swift b/ios/KickWatch/Sources/Services/NotificationService.swift
new file mode 100644
index 0000000..a531b32
--- /dev/null
+++ b/ios/KickWatch/Sources/Services/NotificationService.swift
@@ -0,0 +1,35 @@
+import Foundation
+import UserNotifications
+
+@MainActor
+final class NotificationService: ObservableObject {
+ static let shared = NotificationService()
+ private let deviceIDKey = "kickwatch.deviceID"
+
+ @Published var isAuthorized = false
+
+ func requestPermission() async {
+ let center = UNUserNotificationCenter.current()
+ let granted = (try? await center.requestAuthorization(options: [.alert, .badge, .sound])) ?? false
+ isAuthorized = granted
+ }
+
+ func checkAuthorizationStatus() async {
+ let settings = await UNUserNotificationCenter.current().notificationSettings()
+ isAuthorized = settings.authorizationStatus == .authorized
+ }
+
+ func registerDeviceToken(_ tokenData: Data) async {
+ let token = tokenData.map { String(format: "%02x", $0) }.joined()
+ do {
+ let response = try await APIClient.shared.registerDevice(token: token)
+ KeychainHelper.save(response.device_id, for: deviceIDKey)
+ } catch {
+ print("NotificationService: failed to register device token: \(error)")
+ }
+ }
+
+ var deviceID: String? {
+ KeychainHelper.load(for: deviceIDKey)
+ }
+}
diff --git a/ios/KickWatch/Sources/ViewModels/AlertsViewModel.swift b/ios/KickWatch/Sources/ViewModels/AlertsViewModel.swift
new file mode 100644
index 0000000..05d58ec
--- /dev/null
+++ b/ios/KickWatch/Sources/ViewModels/AlertsViewModel.swift
@@ -0,0 +1,55 @@
+import Foundation
+
+@Observable
+final class AlertsViewModel {
+ var alerts: [AlertDTO] = []
+ var isLoading = false
+ var error: String?
+
+ func load(deviceID: String) async {
+ isLoading = true
+ error = nil
+ do {
+ alerts = try await APIClient.shared.fetchAlerts(deviceID: deviceID)
+ } catch {
+ self.error = error.localizedDescription
+ }
+ isLoading = false
+ }
+
+ func createAlert(deviceID: String, keyword: String, categoryID: String?, minPercent: Double) async {
+ let req = CreateAlertRequest(
+ device_id: deviceID,
+ keyword: keyword,
+ category_id: categoryID,
+ min_percent: minPercent > 0 ? minPercent : nil
+ )
+ do {
+ let alert = try await APIClient.shared.createAlert(req)
+ alerts.insert(alert, at: 0)
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+
+ func toggleAlert(_ alert: AlertDTO) async {
+ let req = UpdateAlertRequest(is_enabled: !alert.is_enabled, keyword: nil, category_id: nil, min_percent: nil)
+ do {
+ let updated = try await APIClient.shared.updateAlert(id: alert.id, req: req)
+ if let idx = alerts.firstIndex(where: { $0.id == alert.id }) {
+ alerts[idx] = updated
+ }
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+
+ func deleteAlert(_ alert: AlertDTO) async {
+ do {
+ try await APIClient.shared.deleteAlert(id: alert.id)
+ alerts.removeAll { $0.id == alert.id }
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+}
diff --git a/ios/KickWatch/Sources/ViewModels/DiscoverViewModel.swift b/ios/KickWatch/Sources/ViewModels/DiscoverViewModel.swift
new file mode 100644
index 0000000..1f97de9
--- /dev/null
+++ b/ios/KickWatch/Sources/ViewModels/DiscoverViewModel.swift
@@ -0,0 +1,69 @@
+import Foundation
+import SwiftData
+
+@Observable
+final class DiscoverViewModel {
+ var campaigns: [CampaignDTO] = []
+ var categories: [CategoryDTO] = []
+ var isLoading = false
+ var isLoadingMore = false
+ var error: String?
+ var nextCursor: String?
+ var hasMore = false
+
+ var selectedSort = "trending"
+ var selectedCategoryID: String?
+
+ func load() async {
+ isLoading = true
+ error = nil
+ do {
+ let resp = try await APIClient.shared.fetchCampaigns(
+ sort: selectedSort, categoryID: selectedCategoryID, cursor: nil
+ )
+ campaigns = resp.campaigns
+ nextCursor = resp.next_cursor
+ hasMore = resp.next_cursor != nil
+ } catch {
+ self.error = error.localizedDescription
+ }
+ isLoading = false
+ }
+
+ func loadMore() async {
+ guard hasMore, let cursor = nextCursor, !isLoadingMore else { return }
+ isLoadingMore = true
+ do {
+ let resp = try await APIClient.shared.fetchCampaigns(
+ sort: selectedSort, categoryID: selectedCategoryID, cursor: cursor
+ )
+ campaigns.append(contentsOf: resp.campaigns)
+ nextCursor = resp.next_cursor
+ hasMore = resp.next_cursor != nil
+ } catch {
+ self.error = error.localizedDescription
+ }
+ isLoadingMore = false
+ }
+
+ func loadCategories() async {
+ guard categories.isEmpty else { return }
+ do {
+ categories = try await APIClient.shared.fetchCategories()
+ } catch {
+ print("DiscoverViewModel: failed to load categories: \(error)")
+ }
+ }
+
+ func selectSort(_ sort: String) async {
+ selectedSort = sort
+ nextCursor = nil
+ await load()
+ }
+
+ func selectCategory(_ id: String?) async {
+ selectedCategoryID = id
+ nextCursor = nil
+ await load()
+ }
+}
diff --git a/ios/KickWatch/Sources/Views/AlertsView.swift b/ios/KickWatch/Sources/Views/AlertsView.swift
new file mode 100644
index 0000000..4c95e6e
--- /dev/null
+++ b/ios/KickWatch/Sources/Views/AlertsView.swift
@@ -0,0 +1,151 @@
+import SwiftUI
+
+struct AlertsView: View {
+ @State private var vm = AlertsViewModel()
+ @State private var showNewAlert = false
+
+ var body: some View {
+ NavigationStack {
+ Group {
+ if vm.isLoading && vm.alerts.isEmpty {
+ ProgressView()
+ } else if vm.alerts.isEmpty {
+ emptyState
+ } else {
+ alertList
+ }
+ }
+ .navigationTitle("Alerts")
+ .toolbar {
+ ToolbarItem(placement: .topBarTrailing) {
+ Button { showNewAlert = true } label: { Image(systemName: "plus") }
+ }
+ }
+ .sheet(isPresented: $showNewAlert) { NewAlertSheet(vm: vm) }
+ .task {
+ if let deviceID = NotificationService.shared.deviceID {
+ await vm.load(deviceID: deviceID)
+ }
+ }
+ }
+ }
+
+ private var emptyState: some View {
+ VStack(spacing: 16) {
+ Image(systemName: "bell.slash").font(.system(size: 48)).foregroundStyle(.secondary)
+ Text("No alerts yet").font(.headline)
+ Text("Create a keyword alert to get notified when matching campaigns launch.")
+ .font(.subheadline).foregroundStyle(.secondary).multilineTextAlignment(.center)
+ Button("Create Alert") { showNewAlert = true }
+ .buttonStyle(.borderedProminent)
+ }
+ .padding()
+ }
+
+ private var alertList: some View {
+ List {
+ ForEach(vm.alerts, id: \.id) { alert in
+ NavigationLink(destination: AlertMatchesView(alert: alert)) {
+ AlertRowView(alert: alert, vm: vm)
+ }
+ }
+ .onDelete { offsets in
+ let toDelete = offsets.map { vm.alerts[$0] }
+ for alert in toDelete { Task { await vm.deleteAlert(alert) } }
+ }
+ }
+ }
+}
+
+struct AlertRowView: View {
+ let alert: AlertDTO
+ let vm: AlertsViewModel
+
+ var body: some View {
+ HStack {
+ VStack(alignment: .leading, spacing: 4) {
+ Text("\"\(alert.keyword)\"").font(.subheadline).fontWeight(.semibold)
+ Group {
+ if let cat = alert.category_id { Text("Category: \(cat)") }
+ if alert.min_percent > 0 { Text("Min \(Int(alert.min_percent))% funded") }
+ }
+ .font(.caption).foregroundStyle(.secondary)
+ }
+ Spacer()
+ Toggle("", isOn: Binding(
+ get: { alert.is_enabled },
+ set: { _ in Task { await vm.toggleAlert(alert) } }
+ ))
+ .labelsHidden()
+ }
+ .padding(.vertical, 4)
+ }
+}
+
+struct NewAlertSheet: View {
+ let vm: AlertsViewModel
+ @State private var keyword = ""
+ @State private var minPercent = 0.0
+ @Environment(\.dismiss) private var dismiss
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section("Keyword") {
+ TextField("e.g. mechanical keyboard", text: $keyword)
+ }
+ Section("Min % Funded") {
+ Slider(value: $minPercent, in: 0...100, step: 10) {
+ Text("\(Int(minPercent))%")
+ }
+ Text("\(Int(minPercent))% minimum").foregroundStyle(.secondary)
+ }
+ }
+ .navigationTitle("New Alert")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } }
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Save") {
+ guard !keyword.isEmpty, let deviceID = NotificationService.shared.deviceID else { return }
+ Task {
+ await vm.createAlert(deviceID: deviceID, keyword: keyword, categoryID: nil, minPercent: minPercent)
+ dismiss()
+ }
+ }
+ .disabled(keyword.isEmpty)
+ }
+ }
+ }
+ }
+}
+
+struct AlertMatchesView: View {
+ let alert: AlertDTO
+ @State private var campaigns: [CampaignDTO] = []
+ @State private var isLoading = false
+
+ var body: some View {
+ Group {
+ if isLoading {
+ ProgressView()
+ } else if campaigns.isEmpty {
+ Text("No matches yet").foregroundStyle(.secondary)
+ } else {
+ List(campaigns, id: \.pid) { campaign in
+ NavigationLink(destination: CampaignDetailView(campaign: campaign)) {
+ CampaignRowView(campaign: campaign)
+ }
+ .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 16))
+ }
+ .listStyle(.plain)
+ }
+ }
+ .navigationTitle("\"\(alert.keyword)\" matches")
+ .task {
+ isLoading = true
+ campaigns = (try? await APIClient.shared.fetchAlertMatches(alertID: alert.id)) ?? []
+ isLoading = false
+ }
+ }
+}
diff --git a/ios/KickWatch/Sources/Views/CampaignDetailView.swift b/ios/KickWatch/Sources/Views/CampaignDetailView.swift
new file mode 100644
index 0000000..b244edc
--- /dev/null
+++ b/ios/KickWatch/Sources/Views/CampaignDetailView.swift
@@ -0,0 +1,159 @@
+import SwiftUI
+import SwiftData
+
+struct CampaignDetailView: View {
+ let campaign: CampaignDTO
+ @Query private var watchlist: [Campaign]
+ @Environment(\.modelContext) private var modelContext
+
+ private var isWatched: Bool {
+ watchlist.contains { $0.pid == campaign.pid && $0.isWatched }
+ }
+
+ private var deadline: Date? {
+ campaign.deadline.flatMap { ISO8601DateFormatter().date(from: $0) }
+ }
+
+ private var daysLeft: Int {
+ guard let d = deadline else { return 0 }
+ return max(0, Calendar.current.dateComponents([.day], from: .now, to: d).day ?? 0)
+ }
+
+ var body: some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 0) {
+ heroImage
+ content
+ }
+ }
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar { toolbarItems }
+ }
+
+ private var heroImage: some View {
+ RemoteImage(urlString: campaign.photo_url ?? "")
+ .frame(maxWidth: .infinity)
+ .frame(height: 240)
+ .clipped()
+ }
+
+ private var content: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(campaign.name)
+ .font(.title2).fontWeight(.bold)
+ if let creator = campaign.creator_name {
+ Text("by \(creator)").font(.subheadline).foregroundStyle(.secondary)
+ }
+ if let cat = campaign.category_name {
+ Text(cat).font(.caption).padding(.horizontal, 8).padding(.vertical, 3)
+ .background(Color(.systemGray5)).clipShape(Capsule())
+ }
+ }
+
+ fundingStats
+
+ if let url = campaign.project_url, let link = URL(string: url) {
+ Link(destination: link) {
+ Label("Back this project", systemImage: "arrow.up.right.square.fill")
+ .frame(maxWidth: .infinity)
+ .padding()
+ .background(Color.accentColor)
+ .foregroundStyle(.white)
+ .clipShape(RoundedRectangle(cornerRadius: 12))
+ }
+ }
+ }
+ .padding()
+ }
+
+ private var fundingStats: some View {
+ VStack(spacing: 12) {
+ fundingRing
+ HStack {
+ statBox(label: "Goal", value: formattedAmount(campaign.goal_amount, currency: campaign.goal_currency))
+ Divider()
+ statBox(label: "Pledged", value: formattedAmount(campaign.pledged_amount, currency: campaign.goal_currency))
+ Divider()
+ statBox(label: "Days Left", value: "\(daysLeft)")
+ }
+ .frame(height: 60)
+ .padding()
+ .background(Color(.systemGray6))
+ .clipShape(RoundedRectangle(cornerRadius: 12))
+ }
+ }
+
+ private var fundingRing: some View {
+ let pct = min((campaign.percent_funded ?? 0) / 100, 1.0)
+ return ZStack {
+ Circle().stroke(Color(.systemGray5), lineWidth: 12)
+ Circle().trim(from: 0, to: pct).stroke(Color.accentColor, style: StrokeStyle(lineWidth: 12, lineCap: .round))
+ .rotationEffect(.degrees(-90))
+ VStack(spacing: 0) {
+ Text("\(Int((campaign.percent_funded ?? 0)))%").font(.title2).fontWeight(.bold)
+ Text("funded").font(.caption).foregroundStyle(.secondary)
+ }
+ }
+ .frame(width: 120, height: 120)
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 8)
+ }
+
+ private func statBox(label: String, value: String) -> some View {
+ VStack(spacing: 2) {
+ Text(value).font(.subheadline).fontWeight(.semibold)
+ Text(label).font(.caption2).foregroundStyle(.secondary)
+ }
+ .frame(maxWidth: .infinity)
+ }
+
+ private func formattedAmount(_ amount: Double?, currency: String?) -> String {
+ guard let amount else { return "—" }
+ let sym = currency == "USD" ? "$" : (currency ?? "")
+ if amount >= 1_000_000 { return "\(sym)\(String(format: "%.1fM", amount / 1_000_000))" }
+ if amount >= 1_000 { return "\(sym)\(String(format: "%.0fK", amount / 1_000))" }
+ return "\(sym)\(Int(amount))"
+ }
+
+ @ToolbarContentBuilder
+ private var toolbarItems: some ToolbarContent {
+ ToolbarItem(placement: .topBarTrailing) {
+ HStack {
+ if let url = campaign.project_url, let link = URL(string: url) {
+ ShareLink(item: link)
+ }
+ Button { toggleWatch() } label: {
+ Image(systemName: isWatched ? "heart.fill" : "heart")
+ .foregroundStyle(isWatched ? .red : .primary)
+ }
+ }
+ }
+ }
+
+ private func toggleWatch() {
+ if let existing = watchlist.first(where: { $0.pid == campaign.pid }) {
+ existing.isWatched.toggle()
+ } else {
+ let c = Campaign(
+ pid: campaign.pid,
+ name: campaign.name,
+ blurb: campaign.blurb ?? "",
+ photoURL: campaign.photo_url ?? "",
+ goalAmount: campaign.goal_amount ?? 0,
+ goalCurrency: campaign.goal_currency ?? "USD",
+ pledgedAmount: campaign.pledged_amount ?? 0,
+ deadline: deadline ?? .distantFuture,
+ state: campaign.state ?? "live",
+ categoryName: campaign.category_name ?? "",
+ categoryID: campaign.category_id ?? "",
+ projectURL: campaign.project_url ?? "",
+ creatorName: campaign.creator_name ?? "",
+ percentFunded: campaign.percent_funded ?? 0,
+ isWatched: true
+ )
+ modelContext.insert(c)
+ }
+ try? modelContext.save()
+ }
+}
diff --git a/ios/KickWatch/Sources/Views/CampaignRowView.swift b/ios/KickWatch/Sources/Views/CampaignRowView.swift
new file mode 100644
index 0000000..bb05f39
--- /dev/null
+++ b/ios/KickWatch/Sources/Views/CampaignRowView.swift
@@ -0,0 +1,97 @@
+import SwiftUI
+
+struct CampaignRowView: View {
+ let campaign: CampaignDTO
+ @Query private var watchlist: [Campaign]
+
+ private var isWatched: Bool {
+ watchlist.contains { $0.pid == campaign.pid && $0.isWatched }
+ }
+
+ var body: some View {
+ HStack(spacing: 12) {
+ thumbnail
+ info
+ Spacer()
+ watchButton
+ }
+ .padding(.vertical, 10)
+ .padding(.leading, 16)
+ }
+
+ private var thumbnail: some View {
+ RemoteImage(urlString: campaign.photo_url ?? "")
+ .frame(width: 72, height: 72)
+ .clipShape(RoundedRectangle(cornerRadius: 8))
+ }
+
+ private var info: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(campaign.name)
+ .font(.subheadline).fontWeight(.semibold)
+ .lineLimit(2)
+ if let creator = campaign.creator_name {
+ Text("by \(creator)")
+ .font(.caption).foregroundStyle(.secondary)
+ }
+ fundingBar
+ HStack(spacing: 8) {
+ Text("\(Int(campaign.percent_funded ?? 0))% funded")
+ .font(.caption2).foregroundStyle(.secondary)
+ if let deadline = campaign.deadline, let date = ISO8601DateFormatter().date(from: deadline) {
+ let days = max(0, Calendar.current.dateComponents([.day], from: .now, to: date).day ?? 0)
+ Text("\(days)d left")
+ .font(.caption2).foregroundStyle(.secondary)
+ }
+ }
+ }
+ }
+
+ private var fundingBar: some View {
+ GeometryReader { geo in
+ ZStack(alignment: .leading) {
+ RoundedRectangle(cornerRadius: 2).fill(Color(.systemGray5)).frame(height: 4)
+ RoundedRectangle(cornerRadius: 2).fill(Color.accentColor)
+ .frame(width: min(geo.size.width * CGFloat((campaign.percent_funded ?? 0) / 100), geo.size.width), height: 4)
+ }
+ }
+ .frame(height: 4)
+ }
+
+ @Environment(\.modelContext) private var modelContext
+
+ private var watchButton: some View {
+ Button {
+ toggleWatch()
+ } label: {
+ Image(systemName: isWatched ? "heart.fill" : "heart")
+ .foregroundStyle(isWatched ? .red : .secondary)
+ }
+ .buttonStyle(.plain)
+ }
+
+ private func toggleWatch() {
+ if let existing = watchlist.first(where: { $0.pid == campaign.pid }) {
+ existing.isWatched.toggle()
+ } else {
+ let c = Campaign(
+ pid: campaign.pid,
+ name: campaign.name,
+ blurb: campaign.blurb ?? "",
+ photoURL: campaign.photo_url ?? "",
+ goalAmount: campaign.goal_amount ?? 0,
+ goalCurrency: campaign.goal_currency ?? "USD",
+ pledgedAmount: campaign.pledged_amount ?? 0,
+ state: campaign.state ?? "live",
+ categoryName: campaign.category_name ?? "",
+ categoryID: campaign.category_id ?? "",
+ projectURL: campaign.project_url ?? "",
+ creatorName: campaign.creator_name ?? "",
+ percentFunded: campaign.percent_funded ?? 0,
+ isWatched: true
+ )
+ modelContext.insert(c)
+ }
+ try? modelContext.save()
+ }
+}
diff --git a/ios/KickWatch/Sources/Views/CategoryChip.swift b/ios/KickWatch/Sources/Views/CategoryChip.swift
new file mode 100644
index 0000000..2a84041
--- /dev/null
+++ b/ios/KickWatch/Sources/Views/CategoryChip.swift
@@ -0,0 +1,20 @@
+import SwiftUI
+
+struct CategoryChip: View {
+ let title: String
+ let isSelected: Bool
+ let action: () -> Void
+
+ var body: some View {
+ Button(action: action) {
+ Text(title)
+ .font(.subheadline)
+ .padding(.horizontal, 12)
+ .padding(.vertical, 6)
+ .background(isSelected ? Color.accentColor : Color(.systemGray5))
+ .foregroundStyle(isSelected ? .white : .primary)
+ .clipShape(Capsule())
+ }
+ .buttonStyle(.plain)
+ }
+}
diff --git a/ios/KickWatch/Sources/Views/DiscoverView.swift b/ios/KickWatch/Sources/Views/DiscoverView.swift
new file mode 100644
index 0000000..05681e9
--- /dev/null
+++ b/ios/KickWatch/Sources/Views/DiscoverView.swift
@@ -0,0 +1,86 @@
+import SwiftUI
+import SwiftData
+
+struct DiscoverView: View {
+ @State private var vm = DiscoverViewModel()
+ @State private var searchText = ""
+ @State private var showSearch = false
+
+ private let sortOptions = [("trending", "Trending"), ("newest", "New"), ("ending", "Ending Soon")]
+
+ var body: some View {
+ NavigationStack {
+ VStack(spacing: 0) {
+ sortPicker
+ categoryScroll
+ campaignList
+ }
+ .navigationTitle("Discover")
+ .toolbar {
+ ToolbarItem(placement: .topBarTrailing) {
+ Button { showSearch = true } label: { Image(systemName: "magnifyingglass") }
+ }
+ }
+ .sheet(isPresented: $showSearch) { SearchView() }
+ .task { await vm.loadCategories(); await vm.load() }
+ .refreshable { await vm.load() }
+ }
+ }
+
+ private var sortPicker: some View {
+ Picker("Sort", selection: Binding(
+ get: { vm.selectedSort },
+ set: { Task { await vm.selectSort($0) } }
+ )) {
+ ForEach(sortOptions, id: \.0) { Text($1).tag($0) }
+ }
+ .pickerStyle(.segmented)
+ .padding(.horizontal)
+ .padding(.vertical, 8)
+ }
+
+ private var categoryScroll: some View {
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 8) {
+ CategoryChip(title: "All", isSelected: vm.selectedCategoryID == nil) {
+ Task { await vm.selectCategory(nil) }
+ }
+ ForEach(vm.categories.filter { $0.parent_id == nil }, id: \.id) { cat in
+ CategoryChip(title: cat.name, isSelected: vm.selectedCategoryID == cat.id) {
+ Task { await vm.selectCategory(cat.id) }
+ }
+ }
+ }
+ .padding(.horizontal)
+ }
+ .padding(.bottom, 4)
+ }
+
+ private var campaignList: some View {
+ Group {
+ if vm.isLoading && vm.campaigns.isEmpty {
+ ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
+ } else if let err = vm.error {
+ Text(err).foregroundStyle(.secondary).padding()
+ } else {
+ List {
+ ForEach(vm.campaigns, id: \.pid) { campaign in
+ NavigationLink(destination: CampaignDetailView(campaign: campaign)) {
+ CampaignRowView(campaign: campaign)
+ }
+ .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 16))
+ .onAppear {
+ if campaign.pid == vm.campaigns.last?.pid {
+ Task { await vm.loadMore() }
+ }
+ }
+ }
+ if vm.isLoadingMore {
+ ProgressView().frame(maxWidth: .infinity)
+ }
+ }
+ .listStyle(.plain)
+ }
+ }
+ }
+}
diff --git a/ios/KickWatch/Sources/Views/SearchView.swift b/ios/KickWatch/Sources/Views/SearchView.swift
new file mode 100644
index 0000000..01a329f
--- /dev/null
+++ b/ios/KickWatch/Sources/Views/SearchView.swift
@@ -0,0 +1,68 @@
+import SwiftUI
+import SwiftData
+
+struct SearchView: View {
+ @State private var query = ""
+ @State private var results: [CampaignDTO] = []
+ @State private var isLoading = false
+ @State private var nextCursor: String?
+ @State private var hasMore = false
+ @Environment(\.modelContext) private var modelContext
+ @Environment(\.dismiss) private var dismiss
+
+ var body: some View {
+ NavigationStack {
+ List {
+ if isLoading && results.isEmpty {
+ ProgressView().frame(maxWidth: .infinity)
+ } else {
+ ForEach(results, id: \.pid) { campaign in
+ NavigationLink(destination: CampaignDetailView(campaign: campaign)) {
+ CampaignRowView(campaign: campaign)
+ }
+ .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 16))
+ }
+ if hasMore {
+ ProgressView().frame(maxWidth: .infinity)
+ .task { await loadMore() }
+ }
+ }
+ }
+ .listStyle(.plain)
+ .navigationTitle("Search")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } }
+ .searchable(text: $query, placement: .navigationBarDrawer(displayMode: .always))
+ .onSubmit(of: .search) { Task { await search() } }
+ .onChange(of: query) { _, new in if new.isEmpty { results = [] } }
+ }
+ }
+
+ private func search() async {
+ guard !query.isEmpty else { return }
+ isLoading = true
+ do {
+ let resp = try await APIClient.shared.searchCampaigns(query: query)
+ results = resp.campaigns
+ nextCursor = resp.next_cursor
+ hasMore = resp.next_cursor != nil
+ } catch {
+ print("SearchView: \(error)")
+ }
+ isLoading = false
+ }
+
+ private func loadMore() async {
+ guard let cursor = nextCursor, !isLoading else { return }
+ isLoading = true
+ do {
+ let resp = try await APIClient.shared.searchCampaigns(query: query, cursor: cursor)
+ results.append(contentsOf: resp.campaigns)
+ nextCursor = resp.next_cursor
+ hasMore = resp.next_cursor != nil
+ } catch {
+ print("SearchView loadMore: \(error)")
+ }
+ isLoading = false
+ }
+}
diff --git a/ios/KickWatch/Sources/Views/SettingsView.swift b/ios/KickWatch/Sources/Views/SettingsView.swift
new file mode 100644
index 0000000..eca2986
--- /dev/null
+++ b/ios/KickWatch/Sources/Views/SettingsView.swift
@@ -0,0 +1,31 @@
+import SwiftUI
+
+struct SettingsView: View {
+ @StateObject private var notificationService = NotificationService.shared
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section("Notifications") {
+ HStack {
+ Label("Push Notifications", systemImage: "bell")
+ Spacer()
+ if notificationService.isAuthorized {
+ Text("Enabled").foregroundStyle(.secondary)
+ } else {
+ Button("Enable") {
+ Task { await notificationService.requestPermission() }
+ }
+ }
+ }
+ }
+ Section("About") {
+ LabeledContent("Version", value: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "—")
+ LabeledContent("Build", value: Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "—")
+ }
+ }
+ .navigationTitle("Settings")
+ .task { await notificationService.checkAuthorizationStatus() }
+ }
+ }
+}
diff --git a/ios/KickWatch/Sources/Views/WatchlistView.swift b/ios/KickWatch/Sources/Views/WatchlistView.swift
new file mode 100644
index 0000000..5abd002
--- /dev/null
+++ b/ios/KickWatch/Sources/Views/WatchlistView.swift
@@ -0,0 +1,99 @@
+import SwiftUI
+import SwiftData
+
+struct WatchlistView: View {
+ @Query(filter: #Predicate { $0.isWatched }, sort: \Campaign.deadline)
+ private var campaigns: [Campaign]
+
+ @Environment(\.modelContext) private var modelContext
+
+ var body: some View {
+ NavigationStack {
+ Group {
+ if campaigns.isEmpty {
+ emptyState
+ } else {
+ List {
+ ForEach(campaigns) { campaign in
+ NavigationLink(destination: CampaignDetailView(campaign: toCampaignDTO(campaign))) {
+ WatchlistRowView(campaign: campaign)
+ }
+ }
+ .onDelete(perform: remove)
+ }
+ .listStyle(.plain)
+ }
+ }
+ .navigationTitle("Watchlist")
+ }
+ }
+
+ private var emptyState: some View {
+ VStack(spacing: 16) {
+ Image(systemName: "heart.slash").font(.system(size: 48)).foregroundStyle(.secondary)
+ Text("No saved campaigns").font(.headline)
+ Text("Tap the heart icon on any campaign to add it here.")
+ .font(.subheadline).foregroundStyle(.secondary).multilineTextAlignment(.center)
+ }
+ .padding()
+ }
+
+ private func remove(at offsets: IndexSet) {
+ for idx in offsets {
+ campaigns[idx].isWatched = false
+ }
+ try? modelContext.save()
+ }
+
+ private func toCampaignDTO(_ c: Campaign) -> CampaignDTO {
+ CampaignDTO(
+ pid: c.pid, name: c.name, blurb: c.blurb, photo_url: c.photoURL,
+ goal_amount: c.goalAmount, goal_currency: c.goalCurrency,
+ pledged_amount: c.pledgedAmount,
+ deadline: ISO8601DateFormatter().string(from: c.deadline),
+ state: c.state, category_name: c.categoryName, category_id: c.categoryID,
+ project_url: c.projectURL, creator_name: c.creatorName,
+ percent_funded: c.percentFunded, slug: nil
+ )
+ }
+}
+
+struct WatchlistRowView: View {
+ let campaign: Campaign
+
+ var body: some View {
+ HStack(spacing: 12) {
+ RemoteImage(urlString: campaign.photoURL)
+ .frame(width: 60, height: 60)
+ .clipShape(RoundedRectangle(cornerRadius: 8))
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text(campaign.name).font(.subheadline).fontWeight(.semibold).lineLimit(2)
+ Text(campaign.creatorName).font(.caption).foregroundStyle(.secondary)
+ HStack {
+ stateBadge
+ Text("\(Int(campaign.percentFunded))% • \(campaign.daysLeft)d left")
+ .font(.caption2).foregroundStyle(.secondary)
+ }
+ }
+ }
+ .padding(.vertical, 4)
+ }
+
+ private var stateBadge: some View {
+ Text(campaign.stateLabel)
+ .font(.caption2).fontWeight(.medium)
+ .padding(.horizontal, 6).padding(.vertical, 2)
+ .background(badgeColor.opacity(0.15))
+ .foregroundStyle(badgeColor)
+ .clipShape(Capsule())
+ }
+
+ private var badgeColor: Color {
+ switch campaign.state {
+ case "successful": return .green
+ case "failed", "canceled": return .red
+ default: return .accentColor
+ }
+ }
+}
diff --git a/ios/Package.swift b/ios/Package.swift
new file mode 100644
index 0000000..48c97e6
--- /dev/null
+++ b/ios/Package.swift
@@ -0,0 +1,16 @@
+// swift-tools-version: 5.9
+import PackageDescription
+
+let package = Package(
+ name: "KickWatch",
+ platforms: [.iOS(.v17)],
+ products: [
+ .library(name: "KickWatch", targets: ["KickWatch"]),
+ ],
+ targets: [
+ .target(
+ name: "KickWatch",
+ path: "KickWatch/Sources"
+ ),
+ ]
+)
diff --git a/ios/project.yml b/ios/project.yml
new file mode 100644
index 0000000..937fc58
--- /dev/null
+++ b/ios/project.yml
@@ -0,0 +1,36 @@
+name: KickWatch
+options:
+ bundleIdPrefix: com.kickwatch
+ deploymentTarget:
+ iOS: "17.0"
+ xcodeVersion: "16.0"
+settings:
+ base:
+ SWIFT_VERSION: "5.9"
+targets:
+ KickWatch:
+ type: application
+ platform: iOS
+ sources:
+ - path: KickWatch/Sources
+ - path: KickWatch/Assets.xcassets
+ info:
+ path: KickWatch/Info.plist
+ properties:
+ CFBundleShortVersionString: "1.0.0"
+ CFBundleVersion: "1"
+ UILaunchScreen: {}
+ CFBundleDisplayName: KickWatch
+ CFBundleIconName: AppIcon
+ NSUserNotificationsUsageDescription: "KickWatch sends daily digests when new campaigns match your keyword alerts."
+ UISupportedInterfaceOrientations:
+ - UIInterfaceOrientationPortrait
+ entitlements:
+ path: KickWatch/KickWatch.entitlements
+ settings:
+ base:
+ PRODUCT_BUNDLE_IDENTIFIER: com.rescience.kickwatch
+ INFOPLIST_FILE: KickWatch/Info.plist
+ CODE_SIGN_STYLE: Automatic
+ DEVELOPMENT_TEAM: 7Q28CBP3S5
+ ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon