diff --git a/CLAUDE.md b/CLAUDE.md index e37544d..ab9a8e0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,7 +79,7 @@ Fully implemented end-to-end. - **Geolocator**: App uses `desiredAccuracy: LocationAccuracy.high` (geolocator ^10). In geolocator 13+ prefer `locationSettings: LocationSettings(accuracy: LocationAccuracy.high)`. - **Flutter Compass**: Code uses `FlutterCompass.events?.listen(...)` and `mounted` check for null safety. Package is lightly maintained; alternative: `sensors_plus` (magnetometer) with custom heading calculation. - **Android**: Root `android/build.gradle` uses **jcenter()** (deprecated/removed). **compileSdkVersion 33**, **targetSdkVersion 30** — Play Store may require higher target. Kotlin plugin version needs updating (Flutter now prompts: update `org.jetbrains.kotlin.android` in `android/settings.gradle`). Debug builds fail with Java heap space — use `--release` or increase Gradle JVM heap. -- **iOS**: `ios/Podfile` exists (iOS 16.0 minimum). `firebase_options.dart` has iOS config (FlutterFire CLI has been run). Built and shipped to TestFlight from the `build-ios` CI job (see CI below) — there is no local Mac. `Info.plist` has `NSLocation*`, `NSCameraUsageDescription`, and `NSPhotoLibraryUsageDescription` strings (the last two added for report photo attachments). +- **iOS**: **iPhone-only** (`TARGETED_DEVICE_FAMILY = 1`, chosen for the first App Store release so no iPad screenshots/review are needed). App Store listing copy is in `fastlane/metadata/ios/` (deliver layout, guarded by `test/app_store_metadata_test.dart`: field limits, keyword format, no other-platform names), the 6.9" screenshot set in `screenshots/marketing/appstore/` (`screenshots/build_appstore.sh`), the support page at `web/support.html`, and the field-by-field checklist (App Privacy, age rating, review notes) in `docs/app-store-submission.md`. `ios/Podfile` exists (iOS 16.0 minimum). `firebase_options.dart` has iOS config (FlutterFire CLI has been run). Built and shipped to TestFlight from the `build-ios` CI job (see CI below) — there is no local Mac. `Info.plist` has `NSLocation*`, `NSCameraUsageDescription`, and `NSPhotoLibraryUsageDescription` strings (the last two added for report photo attachments). - **firebase_dynamic_links**: Removed (Firebase deprecated Dynamic Links Aug 2025; was unused in lib). - **Intro / Postman James assets**: (Fixed) `flare_flutter` / `james.flr` removed. James is `PostmanJames` in `lib/postman_james.dart` using `assets/postman_james.png`. No custom font needed — `google_fonts` provides Plus Jakarta Sans and Playfair Display. - **Claim screen**: (Fixed) Full `initial/searching/results/empty/quiz/quizFailed/claimed` state machine. `startScoring` Cloud Function implemented with per-user claim tracking, streak updates, and leaderboard aggregation. @@ -88,7 +88,7 @@ Fully implemented end-to-end. ## Tests -- `test/widget_test.dart` uses `firebase_auth_mocks` + `fake_cloud_firestore` and `setupFirebaseCoreMocks()` — tests run without real Firebase. **709 Dart tests passing.** +- `test/widget_test.dart` uses `firebase_auth_mocks` + `fake_cloud_firestore` and `setupFirebaseCoreMocks()` — tests run without real Firebase. **720 Dart tests passing.** - `functions/src/test/test.index.ts` uses `firebase-functions-test`. **606 TypeScript tests passing** (pure unit tests + auth/validation integration tests that gracefully skip when no emulator is running). Includes tests for `updateFcmTokens`, `diffFriends`, `shouldNotifyFirstClaim`, `shouldNotifyOvertake`, `buildOsmChange`, `parsePhotos`, `nextQuotaState`, `pointsForMonarch`, `maxDailyFromClaims`, `repointClaimsForPostbox` (mock Firestore), `submitReport`/`reviewReport` auth & validation, and the `plan_route` CLI helpers. - `test/cross_language_sync_test.dart` is the drift guard for facts duplicated across languages/files. Beyond the constants listed under "Added features", it now also parses source to assert: every `startScoring` call site sends an `attemptId` (Dart sheet, Wear, **and the Kotlin car**); every claim surface consults `MaintenanceGuard` and every entry point initialises Remote Config; and every collection the Cloud Functions touch has a `match` block in `firestore.rules`. Each is verified to FAIL when the thing it guards is removed. `countySlug` is checked against all 218 features of the heatmap geojson (TS side, `test.index.ts`). diff --git a/assets/legal/privacy_policy.md b/assets/legal/privacy_policy.md index 3690517..a4346c1 100644 --- a/assets/legal/privacy_policy.md +++ b/assets/legal/privacy_policy.md @@ -1,6 +1,6 @@ # The Postbox Game – Privacy Policy -_Last updated: September 2026_ +_Last updated: October 2026_ This Privacy Policy explains how The Postbox Game ("the App", "we", "us") collects, uses, and protects your personal data when you use our mobile application. We are committed to complying with the UK General Data Protection Regulation (UK GDPR) and the Data Protection Act 2018. @@ -10,7 +10,7 @@ The Postbox Game is an independent mobile game. For data protection enquiries, c ## 2. Data We Collect -- **Account information:** When you register or sign in with Google, we receive your email address and display name. When you register with email/password, we store your email address and chosen display name. +- **Account information:** When you register or sign in with Google, we receive your email address and display name. When you sign in with Apple, we receive your email address (or an Apple private-relay address, if you choose to hide yours) and, the first time only, your name. When you register with email/password, we store your email address and chosen display name. - **Location data:** With your permission, we collect your precise GPS location to identify nearby postboxes and validate claims. The location you claimed from is stored with each claim for anti-cheat verification and is automatically removed after 90 days. - **Gameplay data:** Postbox claims you make (postbox ID, timestamp, points awarded), your running scores, streaks, and leaderboard entries (display name and points only). - **Device token:** A random per-install identifier sent with claims to detect multi-account abuse. It is not derived from your hardware and identifies only the app installation; it is removed from claims after 90 days. @@ -42,6 +42,7 @@ We process your personal data on the following legal bases under UK GDPR: We do not sell your personal data. We share data only with the following service providers, who process it on our behalf: - **Google Firebase** (Authentication, Firestore database, Cloud Functions, Crashlytics, Performance Monitoring, Analytics) – processed within Google's infrastructure. See Google's Privacy Policy at policies.google.com/privacy. +- **Apple** (Sign in with Apple) – only if you choose to sign in with Apple; Apple confirms your identity and shares the details above. Deleting your account also revokes the App's access to your Apple ID where your device supports it. See Apple's Privacy Policy at apple.com/legal/privacy. - **OpenStreetMap / Nominatim:** If you search for a destination in Route Mode, only the text you type is sent to the OpenStreetMap Nominatim search service – never your GPS position. Your display name and score are visible to other users on leaderboards and to friends you add in the App. Corrections we submit back to OpenStreetMap from accepted postbox reports contain only postbox data, never anything about you. diff --git a/docs/app-store-submission.md b/docs/app-store-submission.md new file mode 100644 index 0000000..f75bbdd --- /dev/null +++ b/docs/app-store-submission.md @@ -0,0 +1,120 @@ +# App Store submission checklist (iOS) + +Everything App Store Connect asks for before **Add for Review**, in the order it +appears. Copy lives in `fastlane/metadata/ios/` (fastlane `deliver` layout) so it is +versioned and validated by `test/app_store_metadata_test.dart`; this page covers +the parts that are questionnaires or settings rather than text. + +## 1. Build + +- [ ] Merge, then run **Actions → CI → Run workflow** (`build-ios`). This build is + the first to be iPhone-only (`TARGETED_DEVICE_FAMILY = 1`), so earlier + TestFlight builds must not be submitted. +- [ ] Sign in with Apple prerequisites (from the Apple sign-in PR) are done: + the capability is enabled on the App ID and the "Postbox Game App Store" + profile was regenerated afterwards. The Apple provider is enabled in Firebase Auth. +- [ ] APNs key is uploaded to Firebase Cloud Messaging. +- [ ] On the version page, under **Build**, select the new build. Export compliance + is answered automatically (`ITSAppUsesNonExemptEncryption = NO`). + +## 2. Version page (English (U.K.)) + +| Field | Source | Limit | +|---|---|---| +| Promotional Text | `fastlane/metadata/ios/en-GB/promotional_text.txt` | 170 | +| Description | `…/description.txt` | 4000 | +| Keywords | `…/keywords.txt` | 100 | +| Support URL | `…/support_url.txt` → `web/support.html` | | +| Marketing URL | optional, leave blank | | +| Version | `1.5.3` (from `pubspec.yaml`) | | +| Copyright | `2026 ` | | +| What's New | not shown for a first release | | + +**Previews and Screenshots → iPhone 6.9" Display.** Upload the 8 files from +`screenshots/marketing/appstore/iphone_6.9/light/` in filename order. The dark set +is an alternative. Smaller iPhone sizes are generated from these. No iPad set is +needed now that the app is iPhone-only. + +**App Previews (video)** are optional; skip them for the first release. If added +later: 15–30 s, portrait **886×1920** for the 6.9" slot, H.264 `.mov`/`.mp4`, max +30 fps, and only footage captured from the app itself. + +### Screenshots: where they come from + +`screenshots/build_appstore.sh [light|dark]` builds the set. For each shot, a +genuine iPhone capture in `screenshots/raw/ios//.png` wins, if one +exists. Otherwise the app screen is lifted from the existing Play final, with the +Android status bar and gesture bar painted over. The current set comes from the +Play finals, so its UI is pixel-for-pixel the same app, upscaled ~1.5×. Replacing +them with real iPhone captures from TestFlight sharpens them. Redact leaderboard +names first; `raw/` is gitignored. + +## 3. App Information + +| Field | Value | +|---|---| +| Name | `…/name.txt`: **The Postbox Game** (must be unique on the store) | +| Subtitle | `…/subtitle.txt` | +| Primary category | Games, subcategories **Adventure** and **Trivia** | +| Secondary category | Travel | +| Content Rights | **Yes**, it contains third-party content, and you have the rights: postbox data and map tiles are OpenStreetMap (ODbL), attributed in-app on every map | +| Age Rating | see §5 | + +## 4. App Privacy + +**Privacy Policy URL:** `…/privacy_url.txt`. **Tracking:** No. There are no ads, no +IDFA and no data brokers, so no App Tracking Transparency prompt is needed. + +Data types to declare. All are collected; none are used for tracking. + +| Data type | Linked to user | Purposes | +|---|---|---| +| Contact Info → Email Address | Yes | App Functionality | +| Contact Info → Name | Yes | App Functionality (Apple/Google sign-in name, display name) | +| Location → Precise Location | Yes | App Functionality (finding postboxes, verifying claims) | +| User Content → Photos or Videos | Yes | App Functionality (optional report photos) | +| User Content → Other User Content | Yes | App Functionality (report notes) | +| Identifiers → User ID | Yes | App Functionality, Analytics | +| Identifiers → Device ID | Yes | App Functionality (per-install anti-abuse token) | +| Usage Data → Product Interaction | Yes | Analytics (Firebase Analytics with user ID; opt-out in Settings) | +| Diagnostics → Crash Data | No | App Functionality | +| Diagnostics → Performance Data | No | App Functionality | + +## 5. Age Rating questionnaire + +Answer **None / No** to everything (violence, sexual content, profanity, horror, +drugs, gambling, simulated gambling, contests, medical, unrestricted web access), +except: + +- **User-generated content / messaging:** No. Players can't message each other. + The only player-visible text is profanity-filtered display names, and report + photos and notes are visible only to the reporter and moderators. +- **Location:** the questionnaire asks about *sharing* location with other users. + The answer is No, because the fuzzy compass never reveals exact positions, and + other players never see yours. +- **Made for Kids:** No (the privacy policy says the app is not directed at under-13s). + +Expected result: **4+**. + +## 6. App Review Information + +- [ ] **Sign-in required: Yes.** Create a dedicated demo account (email/password) + in the app, give it a few claims, friends and leaderboard history, and enter + its email and password here. Never commit the password. +- [ ] **Contact:** your first name, last name, phone and email. +- [ ] **Notes:** paste `fastlane/metadata/ios/review_information/notes.txt`. + First record a screen recording of a full claim at a postbox on the iPhone, + upload it (unlisted YouTube, or a shared Drive link) and replace + `[ADD VIDEO LINK]`. Reviewers are not in the UK, and without the video the + core loop can't be seen. + +## 7. Pricing and Availability + +- Price: **Free**. +- Availability: **United Kingdom** only for launch. Gameplay only works there, and + players elsewhere would leave "it doesn't work" reviews. App Review is unaffected. + +## 8. Submit + +- [ ] **Add for Review → Submit.** First reviews usually take 1–3 days. +- [ ] Choose manual or automatic release. Manual lets you check the live listing first. diff --git a/fastlane/metadata/ios/en-GB/description.txt b/fastlane/metadata/ios/en-GB/description.txt new file mode 100644 index 0000000..84f7230 --- /dev/null +++ b/fastlane/metadata/ios/en-GB/description.txt @@ -0,0 +1,32 @@ +Turn every walk into a treasure hunt. Britain's red postboxes carry the royal cypher of the monarch who reigned when they were installed, and some are well over a century old. The Postbox Game turns spotting them into a daily game. + +FIND, CLAIM, SCORE +• Walk up to any postbox in the UK, scan, and name its royal cypher to claim it. +• Each postbox can be claimed once a day. Common Elizabeth II boxes earn 2 points; rare Victorian, Edward VII and Edward VIII boxes are worth far more. +• Keep a daily streak going and watch your collection grow. + +HINTS, NOT DIRECTIONS +A fuzzy compass shows roughly which way unclaimed postboxes lie, never their exact spot. You still have to go and look, which is half the fun. + +WALK SOMEWHERE WITH ROUTE MODE +Pick a destination and the game tells you how many postboxes, and how many points, you could pick up on the way. Add a few minutes of detour and it finds a route with more. When you pass one, you can claim it without leaving the route. + +COMPETE WITH FRIENDS +• Daily, weekly, monthly and all-time leaderboards. +• Add friends and filter the leaderboard to just them. +• See every box you've claimed on your personal history map. + +MEET POSTMAN JAMES +Your cheerful guide shows you the ropes and keeps you company with dry, very British commentary as you play. + +PLAYS WELL OUTDOORS +• No signal? Claims are saved on your phone and submitted when you're back online. +• Light and dark themes. + +HELP KEEP THE MAP RIGHT +Postbox data comes from OpenStreetMap. Report a missing postbox or a wrong cypher, with photos if you like, and accepted fixes update everyone's scores. + +FAIR AND PRIVATE +Claims use your location only to check you're really at the postbox. Leaderboards show display names and points, nothing more. Analytics and crash reporting can be turned off in Settings, and you can delete your account from the app at any time. + +The Postbox Game currently covers postboxes in the United Kingdom. \ No newline at end of file diff --git a/fastlane/metadata/ios/en-GB/keywords.txt b/fastlane/metadata/ios/en-GB/keywords.txt new file mode 100644 index 0000000..baa1ed2 --- /dev/null +++ b/fastlane/metadata/ios/en-GB/keywords.txt @@ -0,0 +1 @@ +pillar box,letterbox,cypher,walking,explore,uk,history,heritage,collect,streak,outdoor,quiz,monarch \ No newline at end of file diff --git a/fastlane/metadata/ios/en-GB/name.txt b/fastlane/metadata/ios/en-GB/name.txt new file mode 100644 index 0000000..ea07278 --- /dev/null +++ b/fastlane/metadata/ios/en-GB/name.txt @@ -0,0 +1 @@ +The Postbox Game \ No newline at end of file diff --git a/fastlane/metadata/ios/en-GB/privacy_url.txt b/fastlane/metadata/ios/en-GB/privacy_url.txt new file mode 100644 index 0000000..0ae8648 --- /dev/null +++ b/fastlane/metadata/ios/en-GB/privacy_url.txt @@ -0,0 +1 @@ +https://the-postbox-game.web.app/privacy-policy \ No newline at end of file diff --git a/fastlane/metadata/ios/en-GB/promotional_text.txt b/fastlane/metadata/ios/en-GB/promotional_text.txt new file mode 100644 index 0000000..f600190 --- /dev/null +++ b/fastlane/metadata/ios/en-GB/promotional_text.txt @@ -0,0 +1 @@ +Every red postbox wears a royal cypher. Spot them on your walks, claim one a day for points, and chase the rare Victorian and Edward VII boxes up the leaderboards. \ No newline at end of file diff --git a/fastlane/metadata/ios/en-GB/subtitle.txt b/fastlane/metadata/ios/en-GB/subtitle.txt new file mode 100644 index 0000000..16d1a68 --- /dev/null +++ b/fastlane/metadata/ios/en-GB/subtitle.txt @@ -0,0 +1 @@ +Hunt Britain's royal postboxes \ No newline at end of file diff --git a/fastlane/metadata/ios/en-GB/support_url.txt b/fastlane/metadata/ios/en-GB/support_url.txt new file mode 100644 index 0000000..e708ade --- /dev/null +++ b/fastlane/metadata/ios/en-GB/support_url.txt @@ -0,0 +1 @@ +https://the-postbox-game.web.app/support \ No newline at end of file diff --git a/fastlane/metadata/ios/review_information/notes.txt b/fastlane/metadata/ios/review_information/notes.txt new file mode 100644 index 0000000..13d4800 --- /dev/null +++ b/fastlane/metadata/ios/review_information/notes.txt @@ -0,0 +1,20 @@ +The Postbox Game is a location-based game played at real postboxes in the United Kingdom. A player stands within about 30 m of a postbox, scans, and names the royal cypher shown on the box to claim it for points. + +SIGNING IN +Use the demo account in the Sign-In Information fields (email and password on the login screen). Sign in with Apple and Google are also offered. + +TESTING OUTSIDE THE UK +Claiming requires being physically at a UK postbox, so it cannot be completed from outside the UK. Everything else works anywhere: +- Nearby tab: tap "Find nearby postboxes". Outside the UK it finds none and offers "Report a missing postbox". +- Leaderboard, Friends and History tabs show the demo account's existing claims, friends and rankings. +- Nearby tab > "Walk to a destination" (Route Mode) opens the destination picker. Route previews start from your current location and need a destination within 30 km, so they only show postboxes when used in the UK. +- A screen recording of a complete claim at a London postbox is here: [ADD VIDEO LINK] + +LOCATION +Location is used only while the app is in use, to find nearby postboxes and confirm the player is at a postbox when claiming. The app shows only rough directions to postboxes, never exact locations. + +ACCOUNT DELETION +Settings (top-right menu) > Delete account. Sign in with Apple accounts also have their Apple tokens revoked. + +CONTENT +Display names are profanity-filtered. Photos attached to postbox reports are visible only to the reporter and our moderators. \ No newline at end of file diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index bbbeba1..959ba7e 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -302,7 +302,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 16.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = 1; VALIDATE_PRODUCT = YES; }; name = Profile; @@ -383,7 +383,7 @@ MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = 1; }; name = Debug; }; @@ -431,7 +431,7 @@ MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = 1; VALIDATE_PRODUCT = YES; }; name = Release; @@ -549,7 +549,7 @@ MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = 1; }; name = "Debug-phone"; }; @@ -597,7 +597,7 @@ MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = 1; VALIDATE_PRODUCT = YES; }; name = "Release-phone"; @@ -645,7 +645,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 16.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = 1; VALIDATE_PRODUCT = YES; }; name = "Profile-phone"; diff --git a/screenshots/README.md b/screenshots/README.md index 37adb29..16b0575 100644 --- a/screenshots/README.md +++ b/screenshots/README.md @@ -23,6 +23,23 @@ The upload-ready copies also live in the fastlane layout: `fastlane/metadata/android/en-GB/images/{phoneScreenshots,wearScreenshots}/` (structure only — nothing is auto-uploaded). +## App Store (iOS) set + +`marketing/appstore/iphone_6.9/{light,dark}/` holds the App Store Connect iPhone set: +8 shots at **1320x2868** (the 6.9" size; App Store Connect scales smaller iPhones from +it), RGB with no alpha, in the same narrative order and with the same captions as the +Play set. Build with `./build_appstore.sh [light|dark]` (needs `fonts/`, see below); +`frame.sh ios` does the compositing. + +For each shot the script prefers a genuine iPhone capture at +`raw/ios//.png` (e.g. a TestFlight screenshot, PII redacted). Without +one, it lifts the app screen out of the Play final and paints over the Android status +bar and gesture handle, because App Review rejects listings that show another platform +(guideline 2.3.10). The app is iPhone-only, so there is no iPad set. +`test/app_store_metadata_test.dart` checks the dimensions and colour type. +Listing copy and the submission checklist live in `fastlane/metadata/ios/` and +`docs/app-store-submission.md`. + ## The marquee set (order = narrative) 1. Stand close. Tap. Claim. (claim CTA + Postman James + streak) diff --git a/screenshots/build_appstore.sh b/screenshots/build_appstore.sh new file mode 100755 index 0000000..c779184 --- /dev/null +++ b/screenshots/build_appstore.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Build the App Store Connect iPhone screenshot set (6.9", 1320x2868). +# +# Source per shot, in order of preference: +# 1. raw/ios//.png - a genuine iPhone capture (TestFlight build; +# redact PII first, e.g. leaderboard names). Used as-is. +# 2. marketing/phone//.png - the existing Play final: the app +# screen is lifted out of its frame and the Android status bar and gesture +# bar are painted over, so no other platform's chrome reaches the listing +# (App Review guideline 2.3.10). +# +# build_appstore.sh [light|dark] (default: light) +set -euo pipefail +cd "$(dirname "$0")" +THEME="${1:-light}" +OUT_DIR="marketing/appstore/iphone_6.9/$THEME" +mkdir -p "$OUT_DIR" work + +# "name|caption" - same narrative order as the Play set (build_finals.sh). +SHOTS=( + "01_claim_initial|Stand close. Tap. Claim." + "02_nearby_results|Postboxes worth points, nearby" + "03_fuzzy_compass|Hints, not directions" + "04_claim_quiz|Name the royal cypher" + "05_claimed|Rarer boxes, bigger scores" + "06_leaderboard|Climb the leaderboards" + "07_route_live|Where now, postie?" + "08_history_map|Every pin, a place you've been" +) + +# Device screen inside a 1080x1920 Play final (see frame.sh "phone"). +DX=207; DY=424; DW=666; DH=1480 +STATUS_H=73 # Android status bar band at the top of the device screen +GESTURE_Y=1452 # Android gesture-handle band near the bottom +GESTURE_H=18 + +px() { convert "$1" -format "%[pixel:p{$2,$3}]" info:; } + +for row in "${SHOTS[@]}"; do + IFS='|' read -r name cap <<<"$row" + ios_raw="raw/ios/$THEME/$name.png" + if [ -f "$ios_raw" ]; then + src="$ios_raw" + else + final="marketing/phone/$THEME/$name.png" + src="work/ios_${THEME}_$name.png" + convert "$final" -crop "${DW}x${DH}+${DX}+${DY}" +repage "$src" + # Status bar: fill with the app bar colour from a text-free spot. + top=$(px "$src" 333 6) + # Gesture handle: fill with the nav bar colour just below it. + bot=$(px "$src" 333 $((GESTURE_Y + GESTURE_H + 4))) + convert "$src" \ + -fill "$top" -draw "rectangle 0,0 $((DW-1)),$((STATUS_H-1))" \ + -fill "$bot" -draw "rectangle 0,$GESTURE_Y $((DW-1)),$((GESTURE_Y+GESTURE_H))" \ + "$src" + fi + ./frame.sh ios "$src" "$OUT_DIR/$name.png" "$cap" +done + +echo "=== App Store check (1320x2868, RGB, no alpha) ===" +fail=0 +for f in "$OUT_DIR"/*.png; do + info=$(identify -format '%wx%h %[channels]' "$f") + case "$info" in "1320x2868 srgb"*) ok=OK;; *) ok=FAIL; fail=1;; esac + echo " $f $info $ok" +done +echo "fail=$fail" +exit $fail diff --git a/screenshots/frame.sh b/screenshots/frame.sh index b7a49b4..e15928a 100755 --- a/screenshots/frame.sh +++ b/screenshots/frame.sh @@ -4,6 +4,7 @@ # Usage: # frame.sh phone "" -> 1080x1920 (9:16, compliant) # frame.sh wear "" -> 1080x1080 (1:1, round-masked dial) +# frame.sh ios "" -> 1320x2868 (App Store 6.9" iPhone) # # Brand: postal red #C8102E, royal navy #0A1931, gold #FFB400. set -euo pipefail @@ -48,6 +49,30 @@ if [ "$KIND" = "phone" ]; then fi convert "$TMP/out.png" -background "$NAVY" -flatten -depth 8 "$OUT" +elif [ "$KIND" = "ios" ]; then + # App Store Connect's required iPhone size (6.9" display, portrait). Smaller + # iPhone sizes are scaled down from this set automatically. No alpha allowed. + CW=1320; CH=2868 + DEV_W=1000 # device screenshot target width + read -r W H < <(identify -format '%w %h\n' "$RAW") + DEV_H=$(( DEV_W * H / W )) + convert "$RAW" -filter Lanczos -resize "${DEV_W}x${DEV_H}!" "$TMP/dev.png" + convert -size "${DEV_W}x${DEV_H}" xc:black -fill white -draw "roundrectangle 0,0,$((DEV_W-1)),$((DEV_H-1)),64,64" "$TMP/mask.png" + convert "$TMP/dev.png" "$TMP/mask.png" -alpha off -compose CopyOpacity -composite "$TMP/devr.png" + convert "$TMP/devr.png" \( +clone -background black -shadow 60x28+0+22 \) \ + +swap -background none -layers merge +repage "$TMP/devsh.png" + convert -size "${CH}x${CW}" gradient:"$RED"-"$NAVY" -rotate 90 "$TMP/bg.png" + convert "$TMP/bg.png" -resize "${CW}x${CH}!" "$TMP/bg.png" + convert "$TMP/bg.png" "$TMP/devsh.png" -gravity north -geometry +0+560 -composite "$TMP/out.png" + if [ -n "$CAPTION" ]; then + convert -background none -fill white -font "$FONT_SANS" -weight 700 \ + -pointsize 92 -size 1180x -gravity center caption:"$CAPTION" "$TMP/cap.png" + convert -size 240x8 xc:"$GOLD" "$TMP/rule.png" + convert "$TMP/out.png" "$TMP/cap.png" -gravity north -geometry +0+170 -composite "$TMP/out.png" + convert "$TMP/out.png" "$TMP/rule.png" -gravity north -geometry +0+430 -composite "$TMP/out.png" + fi + convert "$TMP/out.png" -background "$NAVY" -flatten -alpha off -depth 8 -type TrueColor "PNG24:$OUT" + elif [ "$KIND" = "wear" ]; then CW=1080; CH=1080 DIAL=720 diff --git a/screenshots/marketing/appstore/_contact_iphone_dark.png b/screenshots/marketing/appstore/_contact_iphone_dark.png new file mode 100644 index 0000000..4434c79 Binary files /dev/null and b/screenshots/marketing/appstore/_contact_iphone_dark.png differ diff --git a/screenshots/marketing/appstore/_contact_iphone_light.png b/screenshots/marketing/appstore/_contact_iphone_light.png new file mode 100644 index 0000000..a2029b1 Binary files /dev/null and b/screenshots/marketing/appstore/_contact_iphone_light.png differ diff --git a/screenshots/marketing/appstore/iphone_6.9/dark/01_claim_initial.png b/screenshots/marketing/appstore/iphone_6.9/dark/01_claim_initial.png new file mode 100644 index 0000000..a852f1b Binary files /dev/null and b/screenshots/marketing/appstore/iphone_6.9/dark/01_claim_initial.png differ diff --git a/screenshots/marketing/appstore/iphone_6.9/dark/02_nearby_results.png b/screenshots/marketing/appstore/iphone_6.9/dark/02_nearby_results.png new file mode 100644 index 0000000..ee1f25e Binary files /dev/null and b/screenshots/marketing/appstore/iphone_6.9/dark/02_nearby_results.png differ diff --git a/screenshots/marketing/appstore/iphone_6.9/dark/03_fuzzy_compass.png b/screenshots/marketing/appstore/iphone_6.9/dark/03_fuzzy_compass.png new file mode 100644 index 0000000..fa4211f Binary files /dev/null and b/screenshots/marketing/appstore/iphone_6.9/dark/03_fuzzy_compass.png differ diff --git a/screenshots/marketing/appstore/iphone_6.9/dark/04_claim_quiz.png b/screenshots/marketing/appstore/iphone_6.9/dark/04_claim_quiz.png new file mode 100644 index 0000000..d471918 Binary files /dev/null and b/screenshots/marketing/appstore/iphone_6.9/dark/04_claim_quiz.png differ diff --git a/screenshots/marketing/appstore/iphone_6.9/dark/05_claimed.png b/screenshots/marketing/appstore/iphone_6.9/dark/05_claimed.png new file mode 100644 index 0000000..3b765fc Binary files /dev/null and b/screenshots/marketing/appstore/iphone_6.9/dark/05_claimed.png differ diff --git a/screenshots/marketing/appstore/iphone_6.9/dark/06_leaderboard.png b/screenshots/marketing/appstore/iphone_6.9/dark/06_leaderboard.png new file mode 100644 index 0000000..dba0079 Binary files /dev/null and b/screenshots/marketing/appstore/iphone_6.9/dark/06_leaderboard.png differ diff --git a/screenshots/marketing/appstore/iphone_6.9/dark/07_route_live.png b/screenshots/marketing/appstore/iphone_6.9/dark/07_route_live.png new file mode 100644 index 0000000..fe3bc62 Binary files /dev/null and b/screenshots/marketing/appstore/iphone_6.9/dark/07_route_live.png differ diff --git a/screenshots/marketing/appstore/iphone_6.9/dark/08_history_map.png b/screenshots/marketing/appstore/iphone_6.9/dark/08_history_map.png new file mode 100644 index 0000000..3119179 Binary files /dev/null and b/screenshots/marketing/appstore/iphone_6.9/dark/08_history_map.png differ diff --git a/screenshots/marketing/appstore/iphone_6.9/light/01_claim_initial.png b/screenshots/marketing/appstore/iphone_6.9/light/01_claim_initial.png new file mode 100644 index 0000000..760cb40 Binary files /dev/null and b/screenshots/marketing/appstore/iphone_6.9/light/01_claim_initial.png differ diff --git a/screenshots/marketing/appstore/iphone_6.9/light/02_nearby_results.png b/screenshots/marketing/appstore/iphone_6.9/light/02_nearby_results.png new file mode 100644 index 0000000..9943ba2 Binary files /dev/null and b/screenshots/marketing/appstore/iphone_6.9/light/02_nearby_results.png differ diff --git a/screenshots/marketing/appstore/iphone_6.9/light/03_fuzzy_compass.png b/screenshots/marketing/appstore/iphone_6.9/light/03_fuzzy_compass.png new file mode 100644 index 0000000..28e80b1 Binary files /dev/null and b/screenshots/marketing/appstore/iphone_6.9/light/03_fuzzy_compass.png differ diff --git a/screenshots/marketing/appstore/iphone_6.9/light/04_claim_quiz.png b/screenshots/marketing/appstore/iphone_6.9/light/04_claim_quiz.png new file mode 100644 index 0000000..34563a1 Binary files /dev/null and b/screenshots/marketing/appstore/iphone_6.9/light/04_claim_quiz.png differ diff --git a/screenshots/marketing/appstore/iphone_6.9/light/05_claimed.png b/screenshots/marketing/appstore/iphone_6.9/light/05_claimed.png new file mode 100644 index 0000000..abffe4f Binary files /dev/null and b/screenshots/marketing/appstore/iphone_6.9/light/05_claimed.png differ diff --git a/screenshots/marketing/appstore/iphone_6.9/light/06_leaderboard.png b/screenshots/marketing/appstore/iphone_6.9/light/06_leaderboard.png new file mode 100644 index 0000000..7ec337a Binary files /dev/null and b/screenshots/marketing/appstore/iphone_6.9/light/06_leaderboard.png differ diff --git a/screenshots/marketing/appstore/iphone_6.9/light/07_route_live.png b/screenshots/marketing/appstore/iphone_6.9/light/07_route_live.png new file mode 100644 index 0000000..7533a0f Binary files /dev/null and b/screenshots/marketing/appstore/iphone_6.9/light/07_route_live.png differ diff --git a/screenshots/marketing/appstore/iphone_6.9/light/08_history_map.png b/screenshots/marketing/appstore/iphone_6.9/light/08_history_map.png new file mode 100644 index 0000000..100b06c Binary files /dev/null and b/screenshots/marketing/appstore/iphone_6.9/light/08_history_map.png differ diff --git a/test/app_store_metadata_test.dart b/test/app_store_metadata_test.dart new file mode 100644 index 0000000..ca8bb60 --- /dev/null +++ b/test/app_store_metadata_test.dart @@ -0,0 +1,95 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; + +/// Guards the App Store listing in fastlane/metadata/ios (deliver layout) and +/// the screenshot set in screenshots/marketing/appstore against App Store +/// Connect's limits, so a copy edit can't silently break submission. +void main() { + const dir = 'fastlane/metadata/ios/en-GB'; + String read(String name) => File('$dir/$name.txt').readAsStringSync(); + + // App Store Connect field limits, in characters. + const limits = { + 'name': 30, + 'subtitle': 30, + 'promotional_text': 170, + 'keywords': 100, + 'description': 4000, + }; + + for (final entry in limits.entries) { + test('${entry.key} is present and within ${entry.value} characters', () { + final text = read(entry.key); + expect(text.trim(), isNotEmpty); + expect(text.length, lessThanOrEqualTo(entry.value)); + expect(text, equals(text.trim()), + reason: 'stray whitespace counts toward the limit'); + }); + } + + test('keywords are comma-separated with no spaces or repeats', () { + final keywords = read('keywords').split(','); + expect(keywords.every((k) => k.isNotEmpty && k == k.trim()), isTrue, + reason: 'spaces after commas waste the 100-character budget'); + expect(keywords.toSet().length, keywords.length); + }); + + test('keywords do not repeat words already indexed from name or subtitle', + () { + final indexed = '${read('name')} ${read('subtitle')}' + .toLowerCase() + .split(RegExp(r"[^a-z]+")) + .where((w) => w.length > 2) + .toSet(); + for (final keyword in read('keywords').split(',')) { + for (final word in keyword.split(' ')) { + expect(indexed, isNot(contains(word)), reason: '"$word" in keywords'); + } + } + }); + + test('listing text names no other platform (guideline 2.3.10)', () { + final text = [ + for (final name in [...limits.keys]) read(name), + File('fastlane/metadata/ios/review_information/notes.txt') + .readAsStringSync(), + ].join('\n').toLowerCase(); + for (final banned in ['android', 'google play', 'play store', 'wear os']) { + expect(text, isNot(contains(banned)), reason: banned); + } + }); + + test('support and privacy URLs are https and served by the web build', () { + for (final (name, page) in [ + ('support_url', 'web/support.html'), + ('privacy_url', 'web/privacy-policy.html'), + ]) { + final url = read(name); + expect(url, startsWith('https://'), reason: name); + // Hosting uses cleanUrls, so /support serves web/support.html. + expect(File(page).existsSync(), isTrue, reason: page); + } + }); + + group('iPhone 6.9" screenshots', () { + for (final theme in ['light', 'dark']) { + test('$theme set: 1-10 PNGs at 1320x2868 without alpha', () { + final shots = Directory('screenshots/marketing/appstore/iphone_6.9/$theme') + .listSync() + .whereType() + .where((f) => f.path.endsWith('.png')) + .toList(); + expect(shots.length, inInclusiveRange(1, 10)); + for (final shot in shots) { + final header = ByteData.sublistView(shot.readAsBytesSync(), 0, 26); + expect(header.getUint32(16), 1320, reason: shot.path); + expect(header.getUint32(20), 2868, reason: shot.path); + // IHDR colour type 2 = RGB; 6 (RGBA) is rejected by App Store Connect. + expect(header.getUint8(25), 2, reason: '${shot.path} must be RGB'); + } + }); + } + }); +} diff --git a/web/privacy-policy.html b/web/privacy-policy.html index 7808650..2bc6bde 100644 --- a/web/privacy-policy.html +++ b/web/privacy-policy.html @@ -69,7 +69,7 @@

The Postbox Game – Privacy Policy

Your privacy matters to us.

- Last updated: September 2026 + Last updated: October 2026

This Privacy Policy explains how The Postbox Game ("the App", "we", "us") collects, uses, and protects your personal data when you use our mobile application. We are committed to complying with the UK General Data Protection Regulation (UK GDPR) and the Data Protection Act 2018.

@@ -78,7 +78,7 @@

1. Who We Are

2. Data We Collect

    -
  • Account information: When you register or sign in with Google, we receive your email address and display name. When you register with email/password, we store your email address and chosen display name.
  • +
  • Account information: When you register or sign in with Google, we receive your email address and display name. When you sign in with Apple, we receive your email address (or an Apple private-relay address, if you choose to hide yours) and, the first time only, your name. When you register with email/password, we store your email address and chosen display name.
  • Location data: With your permission, we collect your precise GPS location to identify nearby postboxes and validate claims. The location you claimed from is stored with each claim for anti-cheat verification and is automatically removed after 90 days.
  • Gameplay data: Postbox claims you make (postbox ID, timestamp, points awarded), your running scores, streaks, and leaderboard entries (display name and points only).
  • Device token: A random per-install identifier sent with claims to detect multi-account abuse. It is not derived from your hardware and identifies only the app installation; it is removed from claims after 90 days.
  • @@ -111,6 +111,7 @@

    5. Data Sharing

    We do not sell your personal data. We share data only with the following service providers, who process it on our behalf:

    • Google Firebase (Authentication, Firestore database, Cloud Functions, Crashlytics, Performance Monitoring, Analytics) – processed within Google's infrastructure. See Google's Privacy Policy.
    • +
    • Apple (Sign in with Apple) – only if you choose to sign in with Apple; Apple confirms your identity and shares the details above. Deleting your account also revokes the App's access to your Apple ID where your device supports it. See Apple's Privacy Policy.
    • OpenStreetMap / Nominatim: If you search for a destination in Route Mode, only the text you type is sent to the OpenStreetMap Nominatim search service – never your GPS position.

    Your display name and score are visible to other users on leaderboards and to friends you add in the App. Corrections we submit back to OpenStreetMap from accepted postbox reports contain only postbox data, never anything about you.

    diff --git a/web/support.html b/web/support.html new file mode 100644 index 0000000..735157e --- /dev/null +++ b/web/support.html @@ -0,0 +1,99 @@ + + + + + + Support – The Postbox Game + + + +
    +

    The Postbox Game – Support

    +

    Help with playing, your account and postbox data.

    +
    +
    +

    Contact us

    +

    Email richard@agilepixel.io. Please include your device model and, if it's about your account, the UID shown on the Friends screen. We aim to reply within a few working days.

    + +

    How to play

    +
      +
    • Walk to a real postbox in the UK and stand within about 30 m of it.
    • +
    • On the Claim screen, tap Scan for postboxes nearby, then name the royal cypher on the box to claim it.
    • +
    • Each postbox can be claimed once per day. Rarer cyphers (Victorian, Edward VII, Edward VIII) score more points.
    • +
    • The Nearby screen's compass shows the rough direction of postboxes around you, never their exact location.
    • +
    + +

    Common questions

    +

    Why can't I claim a postbox? You need to be close to it, with location permission turned on and a good GPS fix. Check Settings → Privacy → Location Services → The Postbox Game is set to "While Using the App" with Precise Location on.

    +

    Can I play outside the UK? Not yet. Postbox data covers the United Kingdom.

    +

    A postbox is missing or has the wrong cypher. Report it from the app: missing postboxes from the Claim or Nearby screen when nothing is found, and wrong cyphers from the postbox in your History. Accepted reports update scores automatically.

    +

    I've no signal. Claims made offline are saved on your phone and submitted when you're back online.

    + +

    Your account and data

    +
      +
    • Delete your account: Settings → Delete account. Your personal data is deleted and your claims anonymised straight away.
    • +
    • Privacy: see the Privacy Policy, including how to turn off analytics and crash reporting.
    • +
    +
    + + +