diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index dbbac50..8f80433 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -24,7 +24,7 @@ If applicable, add screenshots to help explain the problem. **Environment:** - Device: [e.g., iPhone 15 Pro] -- iOS version: [e.g., 26.5] +- iOS version: [e.g., 26.0] - SunHat version: [e.g., 1.0] **Console logs** diff --git a/.gitignore b/.gitignore index bea0906..f7f85fe 100644 --- a/.gitignore +++ b/.gitignore @@ -74,4 +74,5 @@ CLAUDE.md AppStore/ ## Internal audit/process docs (not needed in public repo) -docs/ +docs/open-source-readiness-audit.md +docs/open-source-release-report.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9b0de6d..90e3f96 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,9 +24,9 @@ These aren't style preferences. Breaking them is a correctness bug and the chang ## Branching -`main` is always deployable. Every change, including your own, goes through a short-lived `feature/` branch (e.g. `feature/dry-period-fix`) merged back via PR — no direct commits to `main`. Bug fixes use `fix/` instead of `feature/`. Delete the branch after merge. +`main` is always deployable. Every change, including your own, goes through a short-lived `feature/` branch (e.g. `feature/dry-period-fix`) merged back via PR, no direct commits to `main`. Bug fixes use `fix/` instead of `feature/`. Delete the branch after merge. -This project doesn't use `develop`/`release`/`hotfix` branches — there's no release train to coordinate, so that overhead isn't worth it here. Revisit if the project grows a real release cadence or more contributors. +This project doesn't use `develop`/`release`/`hotfix` branches, there's no release train to coordinate, so that overhead isn't worth it here. Revisit if the project grows a real release cadence or more contributors. ## Before you open a PR diff --git a/README.md b/README.md index 393f0b5..139a015 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ Captured on iOS 26 (iPhone). SunHat is iPhone-first. iPad, widget, and watch sur |---|---| | Xcode | 26+ | | Swift | 6.2 | -| Minimum iOS | 26.5 | +| Minimum iOS | 26.0 | | Dependencies | Google Mobile Ads + User Messaging Platform (SPM), for the ad-supported free tier. Everything else is Apple frameworks. | No CocoaPods, no Carthage. The only SPM packages are Google Mobile Ads and its diff --git a/SunHat.xcodeproj/project.pbxproj b/SunHat.xcodeproj/project.pbxproj index 998fe36..74a5c62 100644 --- a/SunHat.xcodeproj/project.pbxproj +++ b/SunHat.xcodeproj/project.pbxproj @@ -90,7 +90,7 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 62688EDB10354F069788083D /* Set Build Number from Git */ = { + 62688EDB10354F069788083D /* Set Build Number */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; buildActionMask = 2147483647; @@ -100,14 +100,14 @@ ); inputPaths = ( ); - name = "Set Build Number from Git"; + name = "Set Build Number"; outputFileListPaths = ( ); outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "# Build-number policy:\n# - Xcode Cloud (CI_BUILD_NUMBER set): CFBundleVersion = CI_BUILD_NUMBER.\n# Xcode Cloud numbers are per-workflow and strictly increasing, and this\n# workflow is the only one that archives, so App Store Connect uploads\n# never collide. The CI checkout is never modified: buildnumber.txt is a\n# local-only file (gitignored).\n# - Local Release archives: read buildnumber.txt, increment by 1, write the\n# new number back to the file AND into the built product's Info.plist.\n# Local numbers are throwaway; Xcode Cloud is authoritative for uploads.\nif [ \"$CONFIGURATION\" != \"Release\" ]; then\n exit 0\nfi\n\nPLIST=\"$TARGET_BUILD_DIR/$INFOPLIST_PATH\"\n\nif [ -n \"$CI_BUILD_NUMBER\" ]; then\n echo \"[SunHat] Xcode Cloud build: CFBundleVersion = $CI_BUILD_NUMBER\"\n /usr/libexec/PlistBuddy -c \"Set :CFBundleVersion $CI_BUILD_NUMBER\" \"$PLIST\"\n exit 0\nfi\n\nBUILD_NUMBER_FILE=\"$SRCROOT/buildnumber.txt\"\nif [ ! -f \"$BUILD_NUMBER_FILE\" ]; then\n echo \"1\" > \"$BUILD_NUMBER_FILE\"\nfi\n\nCURRENT=$(cat \"$BUILD_NUMBER_FILE\")\nBUILD_NUMBER=$((CURRENT + 1))\necho \"$BUILD_NUMBER\" > \"$BUILD_NUMBER_FILE\"\n\necho \"[SunHat] Build number: $CURRENT -> $BUILD_NUMBER\"\n/usr/libexec/PlistBuddy -c \"Set :CFBundleVersion $BUILD_NUMBER\" \"$PLIST\"\n"; + shellScript = "# Release archives read buildnumber.txt, increment it, and write the new\n# number into the built product's Info.plist. buildnumber.txt is gitignored\n# and local to this machine. Every App Store Connect upload needs a unique\n# build number for a given MARKETING_VERSION, so if an upload is rejected as\n# a duplicate, bump the file and archive again.\nif [ \"$CONFIGURATION\" != \"Release\" ]; then\n exit 0\nfi\n\nPLIST=\"$TARGET_BUILD_DIR/$INFOPLIST_PATH\"\nBUILD_NUMBER_FILE=\"$SRCROOT/buildnumber.txt\"\n\nif [ ! -f \"$BUILD_NUMBER_FILE\" ]; then\n echo \"1\" > \"$BUILD_NUMBER_FILE\"\nfi\n\nCURRENT=$(cat \"$BUILD_NUMBER_FILE\")\nBUILD_NUMBER=$((CURRENT + 1))\necho \"$BUILD_NUMBER\" > \"$BUILD_NUMBER_FILE\"\n\necho \"[SunHat] Build number: $CURRENT -> $BUILD_NUMBER\"\n/usr/libexec/PlistBuddy -c \"Set :CFBundleVersion $BUILD_NUMBER\" \"$PLIST\"\n"; }; /* End PBXShellScriptBuildPhase section */ @@ -142,7 +142,7 @@ 64120D182E2D64CB0034CB20 /* Sources */, 64120D192E2D64CB0034CB20 /* Frameworks */, 64120D1A2E2D64CB0034CB20 /* Resources */, - 62688EDB10354F069788083D /* Set Build Number from Git */, + 62688EDB10354F069788083D /* Set Build Number */, ); buildRules = ( ); @@ -334,7 +334,7 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 26.5; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -371,7 +371,7 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 26.5; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -441,7 +441,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 26.5; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; @@ -500,7 +500,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 26.5; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; @@ -519,7 +519,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = HD39MR492X; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 26.5; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.wesley.sunhatTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -540,7 +540,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = HD39MR492X; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 26.5; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.wesley.sunhatTests; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/SunHat/Resources/Localizable.xcstrings b/SunHat/Resources/Localizable.xcstrings index e98f795..1e74df1 100644 --- a/SunHat/Resources/Localizable.xcstrings +++ b/SunHat/Resources/Localizable.xcstrings @@ -813,14 +813,14 @@ } } }, - "A renewal payment failed. Ads stay off while Apple retries — update your billing details to keep Ad-Free." : { + "A renewal payment failed. Ads stay off while Apple retries. Update your billing details to keep Ad-Free." : { "comment" : "Settings footer during the billing grace period", "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "A renewal payment failed. Ads stay off while Apple retries — update your billing details to keep Ad-Free." + "value" : "A renewal payment failed. Ads stay off while Apple retries. Update your billing details to keep Ad-Free." } }, "es" : { @@ -993,14 +993,14 @@ } } }, - "Ads are off. Cancel anytime — you keep Ad-Free until the end of the period you already paid for." : { + "Ads are off. Cancel anytime and you keep Ad-Free until the end of the period you already paid for." : { "comment" : "Settings footer while the Ad-Free subscription is active", "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Ads are off. Cancel anytime — you keep Ad-Free until the end of the period you already paid for." + "value" : "Ads are off. Cancel anytime and you keep Ad-Free until the end of the period you already paid for." } }, "es" : { diff --git a/SunHat/Utilities/AppSupportLinks.swift b/SunHat/Utilities/AppSupportLinks.swift index ddd77d2..d09b137 100644 --- a/SunHat/Utilities/AppSupportLinks.swift +++ b/SunHat/Utilities/AppSupportLinks.swift @@ -10,8 +10,8 @@ enum AppSupportLinks { static let feedbackEmail = "weskcode@duck.com" static let privacyEmail = "weskcode@duck.com" - static let privacyPolicyURL = URL(string: "https://sunhat.app/privacy")! - static let termsOfServiceURL = URL(string: "https://sunhat.app/terms")! + static let privacyPolicyURL = URL(string: "https://sunhat.apphq.online/privacy")! + static let termsOfServiceURL = URL(string: "https://sunhat.apphq.online/terms")! static func mailURL(to email: String, subject: String, body: String? = nil) -> URL? { var components = URLComponents() diff --git a/SunHat/Views/Settings/AdFreeSettingsSection.swift b/SunHat/Views/Settings/AdFreeSettingsSection.swift index 9f18b6e..67c3371 100644 --- a/SunHat/Views/Settings/AdFreeSettingsSection.swift +++ b/SunHat/Views/Settings/AdFreeSettingsSection.swift @@ -153,9 +153,9 @@ struct AdFreeSettingsSection: View { private var footerText: String { switch storeManager.entitlementState { case .active: - String(localized: "Ads are off. Cancel anytime — you keep Ad-Free until the end of the period you already paid for.", comment: "Settings footer while the Ad-Free subscription is active") + String(localized: "Ads are off. Cancel anytime and you keep Ad-Free until the end of the period you already paid for.", comment: "Settings footer while the Ad-Free subscription is active") case .gracePeriod: - String(localized: "A renewal payment failed. Ads stay off while Apple retries — update your billing details to keep Ad-Free.", comment: "Settings footer during the billing grace period") + String(localized: "A renewal payment failed. Ads stay off while Apple retries. Update your billing details to keep Ad-Free.", comment: "Settings footer during the billing grace period") case .billingRetry: String(localized: "Your Ad-Free subscription is paused because a renewal payment failed. Update your billing details to restore it.", comment: "Settings footer during billing retry after the grace period lapsed") case .notEntitled, .unknown: diff --git a/SunHat/sunhat.swift b/SunHat/sunhat.swift index 8e2cf36..032666b 100644 --- a/SunHat/sunhat.swift +++ b/SunHat/sunhat.swift @@ -250,7 +250,7 @@ final class StoreRecoveryState: ObservableObject { } nonisolated func reportRecoveryFailure(_ error: Error) { - let message = String(localized: "SunHat couldn't load your data and is running in temporary recovery mode. Creating and editing reminders is disabled until this is fixed. Restart SunHat to try again, or contact support@sunhat.app for help.", comment: "Banner shown when the app is running on a temporary in-memory database") + let message = String(localized: "SunHat couldn't load your data and is running in temporary recovery mode. Creating and editing reminders is disabled until this is fixed. Restart SunHat to try again, or contact weskcode@duck.com for help.", comment: "Banner shown when the app is running on a temporary in-memory database") logger.error("Persistent store recovery failed: \(error.localizedDescription)") Task { @MainActor in recoveryMessage = message diff --git a/docs/APP_STORE_RELEASE_KIT.md b/docs/APP_STORE_RELEASE_KIT.md new file mode 100644 index 0000000..6385cbb --- /dev/null +++ b/docs/APP_STORE_RELEASE_KIT.md @@ -0,0 +1,456 @@ +# SunHat 1.0: App Store release kit + +Everything App Store Connect asks for, in the order it asks. Every block +between `---8<---` markers is meant to be copied verbatim into the matching +field. Character counts are validated against Apple's limits. + +Written for the 1.0 submission. Bundle ID `org.wesley.sunhat`. + +**Companion docs** +- [`MARKETING_KIT.md`](MARKETING_KIT.md): pitches, social copy, Product Hunt, + press release, objection handling +- [`TESTFLIGHT_RELEASE.md`](TESTFLIGHT_RELEASE.md): beta metadata, the What + to Test message, archive and upload steps +- [`MONETIZATION_GOLIVE.md`](MONETIZATION_GOLIVE.md): every test value that + must be swapped for a live one, and where it lives +- [`PRIVACY_POLICY.md`](PRIVACY_POLICY.md) and [`TERMS_OF_USE.md`](TERMS_OF_USE.md): + the source text for the two hosted legal pages, kept here so the app repo + and the live site can't drift apart unnoticed + +--- + +## 1. App information (set once, not per-version) + +| Field | Value | +|---|---| +| **Name** | `SunHat: Weather Reminders` (25/30) | +| **Subtitle** | `Forecast alerts for your plans` (30/30) | +| **Bundle ID** | `org.wesley.sunhat` | +| **SKU** | `SUNHAT-IOS-001` | +| **Primary category** | Weather | +| **Secondary category** | Productivity | +| **Primary language** | English (U.S.) | +| **Additional localization** | Spanish (Mexico), the app ships `en` + `es` | +| **Age rating** | 4+ | +| **Copyright** | `2026 Wesley Keetch` | +| **Price** | Free (with In-App Purchases) | +| **Availability** | All territories | + +### Age rating questionnaire + +Answer **None** to every content category. The two that need care: + +| Question | Answer | Why | +|---|---|---| +| Does your app contain, show, or access third-party advertising? | **Yes** | Google AdMob banners in the free tier | +| Is this app for kids (Kids Category)? | **No** | Do not opt in, the Kids Category forbids third-party ads and ATT | +| Unrestricted web access | **No** | No in-app browser | +| Gambling / contests | **No** | | + +Result: **4+**. + +### URLs + +| Field | Value | Required? | +|---|---|---| +| Support URL | `https://sunhat.apphq.online` | Required | +| Marketing URL | `https://sunhat.apphq.online` | Optional | +| Privacy Policy URL | `https://sunhat.apphq.online/privacy` | Required | +| Terms of Use (EULA) | `https://sunhat.apphq.online/terms` | **Required, auto-renewable subscription** | + +There is no dedicated `/support` page on the site, so the Support URL field +uses the homepage, which lists a contact email in its footer. That satisfies +Apple's requirement (a working page where a user can reach you), but a real +support page with an FAQ is worth adding later. + +> Apple rejects subscription apps whose Privacy Policy and Terms URLs 404 or +> don't describe the subscription. Verify both resolve before submitting. + +**Two things to fix on the live site before submitting, neither of them +code changes:** +1. `/privacy` is live and correct. `/terms` does not exist yet. + [`TERMS_OF_USE.md`](TERMS_OF_USE.md) in this repo is ready to publish + there as-is. +2. The homepage footer still shows an old contact address. It should read + `weskcode@duck.com` to match the privacy policy, the terms, and the app + itself. + +--- + +## 2. Version information (per-release) + +### Promotional text: 153/170 + +Editable without a new build. Use it for seasonal hooks. + +``` +---8<--- +Set a reminder for "when it's above 68°F and clear" and SunHat watches the forecast for you. No accounts, no sign-up. Free with ads, go ad-free anytime. +---8<--- +``` + +### Description: 2467/4000 + +``` +---8<--- +Most reminder apps ask when. SunHat asks what conditions. + +A calendar reminder for "go for a run" fires whether it's sunny or sleeting. But some plans don't depend on the clock, they depend on the weather. SunHat lets you describe the conditions you're waiting for, then watches the forecast and tells you the moment reality matches. + +SET IT AND FORGET IT + +• "Go for a run when it's 55–70°F and clear" +• "Water the garden when it's been dry for 48 hours" +• "Beach day when it's above 80°F with no rain coming" +• "Golden hour photo walk when it's above 68°F and sunny" + +SEVEN WAYS TO DESCRIBE A DAY + +Exact temperature. Temperature range. Sky conditions. Feels-like. Dry period. Consecutive days. Or composite triggers that combine temperature, humidity and wind. + +WHAT YOU GET + +• Forecast-based predictions with confidence scoring, so you can see what's coming +• Background monitoring that checks conditions and notifies you when they line up +• Real hourly forecast data from Apple WeatherKit +• Temperature history with yesterday, last week and monthly trend charts +• Quiet hours and daily notification limits, so it never nags +• GPS or pick any city manually +• Siri and Shortcuts support for creating reminders by voice +• Spotlight search across your reminders +• Full data export and one-tap deletion of everything +• Native iOS 26 Liquid Glass design, in light and dark + +HONEST ABOUT DATA + +SunHat has no accounts and no sign-up. Your location is used to fetch a forecast and nothing else. There is no SunHat analytics SDK, no behavioral profile, no data sale. The free version shows banner ads through Google AdMob, and you can turn those off permanently with the optional SunHat Ad-Free subscription. Everything else in the app is free, forever, with no feature gates. + +Full data export and deletion are built in, not buried. + +SUNHAT AD-FREE + +An optional auto-renewable subscription that removes all ads. $1.00/month or $10.00/year (two months free on the annual plan). Both plans unlock the same thing, and you can switch between them at any time. + +Payment is charged to your Apple Account at confirmation of purchase. The subscription renews automatically unless cancelled at least 24 hours before the end of the current period. Manage or cancel anytime in your Apple Account settings. + +Terms of Use: https://sunhat.apphq.online/terms +Privacy Policy: https://sunhat.apphq.online/privacy + +Requires iOS 26 and a device with WeatherKit support. Weather data provided by Apple Weather. +---8<--- +``` + +> The subscription paragraph is not optional boilerplate. Apple's +> Schedule 2 requires price, period, renewal terms, and cancellation +> instructions to appear in the binary **and** in the metadata. + +### Keywords: 97/100 + +Comma-separated, no spaces after commas. Do not repeat words already in the +name or subtitle (`sunhat`, `weather`, `reminders`, `forecast`, `alerts`, +`plans`), Apple already indexes those, so repeating them wastes budget. + +``` +---8<--- +rain,temperature,humidity,outdoor,gardening,running,hiking,todo,task,notify,sunny,tracker,climate +---8<--- +``` + +### What's New: 1.0 + +``` +---8<--- +First release. + +SunHat watches the forecast and reminds you when the weather matches your plans, not when the clock says so. + +• Seven trigger types, from a simple temperature range to composite temperature + humidity + wind +• Background monitoring with quiet hours and daily notification limits +• Real WeatherKit hourly data and temperature history +• Siri, Shortcuts and Spotlight support +• Full data export and deletion +• English and Spanish +---8<--- +``` + +--- + +## 3. Spanish (Mexico) localization + +App Store Connect will not let a Spanish-localized app ship without these. + +| Field | Value | +|---|---| +| **Name** | `SunHat: Clima y Recordatorios` (29/30) | +| **Subtitle** | `Avisos según el pronóstico` (26/30) | +| **Keywords** | `lluvia,temperatura,humedad,jardin,correr,senderismo,tarea,pendiente,soleado,clima,aire libre` | + +``` +---8<--- +La mayoría de los recordatorios preguntan cuándo. SunHat pregunta con qué clima. + +Un recordatorio de calendario para "salir a correr" suena igual haga sol o caiga aguanieve. Pero algunos planes no dependen del reloj: dependen del clima. Con SunHat describes las condiciones que estás esperando, y la app vigila el pronóstico y te avisa en cuanto la realidad coincide. + +ALGUNOS EJEMPLOS + +• "Salir a correr cuando esté entre 13 y 21 °C y despejado" +• "Regar el jardín cuando lleve 48 horas sin llover" +• "Día de playa cuando pase de 27 °C y no venga lluvia" + +SIETE FORMAS DE DESCRIBIR UN DÍA + +Temperatura exacta. Rango de temperatura. Estado del cielo. Sensación térmica. Periodo seco. Días consecutivos. O disparadores compuestos que combinan temperatura, humedad y viento. + +LO QUE INCLUYE + +• Predicciones con nivel de confianza basadas en el pronóstico +• Monitoreo en segundo plano que te avisa cuando se cumplen las condiciones +• Datos por hora reales de Apple WeatherKit +• Historial de temperatura con tendencias de ayer, la semana pasada y el mes +• Horas de silencio y límite diario de notificaciones +• GPS o selección manual de ciudad +• Compatible con Siri, Atajos y Spotlight +• Exportación y borrado total de tus datos +• Diseño nativo Liquid Glass de iOS 26, en claro y oscuro + +TRANSPARENCIA CON TUS DATOS + +SunHat no tiene cuentas ni registro. Tu ubicación se usa para obtener el pronóstico y nada más. No hay SDK de analítica propio, ni perfilado, ni venta de datos. La versión gratuita muestra anuncios de Google AdMob, y puedes quitarlos de forma permanente con la suscripción opcional SunHat Ad-Free. Todo lo demás es gratis, siempre, sin funciones bloqueadas. + +SUNHAT AD-FREE + +Suscripción opcional de renovación automática que elimina todos los anuncios. $1.00 al mes o $10.00 al año (dos meses gratis en el plan anual). Ambos planes desbloquean lo mismo y puedes cambiar entre ellos cuando quieras. + +El pago se carga a tu cuenta de Apple al confirmar la compra. La suscripción se renueva automáticamente salvo que la canceles al menos 24 horas antes de que termine el periodo en curso. Puedes gestionarla o cancelarla en los ajustes de tu cuenta de Apple. + +Términos de uso: https://sunhat.apphq.online/terms +Política de privacidad: https://sunhat.apphq.online/privacy + +Requiere iOS 26 y un dispositivo compatible con WeatherKit. Datos meteorológicos proporcionados por Apple Weather. +---8<--- +``` + +--- + +## 4. Screenshots + +Required sizes for an iPhone-only app. App Store Connect accepts one set and +scales down, but supplying both avoids letterboxing complaints. + +| Display | Resolution | Device to capture on | Required | +|---|---|---|---| +| 6.9" | 1320 × 2868 | iPhone 17 Pro Max | **Yes** | +| 6.5" | 1242 × 2688 | iPhone 11 Pro Max sim | Recommended | + +Up to 10 per size. Suggested order, the first two are what most people +actually see, so they carry the whole pitch: + +1. **Dashboard, a reminder ready now**: caption "Know the moment conditions match" +2. **Reminder creation, temperature range**: "Describe the day you're waiting for" +3. **Weather tab with predictions**: "See what's coming, with confidence" +4. **Reminders list**: "Seven ways to describe a day" +5. **Settings / quiet hours**: "It never nags" +6. **Paywall**: "Optional. Everything else is free" + +### Capture procedure + +`Screenshots/` and `Screenshots/Light/` hold README captures taken on an +iPhone 17 Pro (6.3"). **Those cannot be used for the App Store**, Connect's +required slot is 6.9", and no Pro Max simulator exists on this machine yet. + +```bash +xcrun simctl create "iPhone 17 Pro Max" \ + com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro-Max \ + com.apple.CoreSimulator.SimRuntime.iOS-26-5 +``` + +Then capture, with the app in a known state (a reminder that reads as ready, +a couple in the list) so every shot shows real content rather than empty +states: + +```bash +SIM=$(xcrun simctl list devices | grep "iPhone 17 Pro Max" | grep -oE '[0-9A-F-]{36}' | head -1) +xcrun simctl boot "$SIM" +xcrun simctl io "$SIM" screenshot ~/Desktop/sunhat-appstore/01-dashboard.png +``` + +Uninstall the app between light and dark passes, appearance is read at first +launch and a warm relaunch keeps the old one. + +`VisualQAScreenshotTests` writes light, dark, AX-XXXL and Spanish captures to +`/tmp/sunhat-shots` on whichever simulator it runs on. Those are for design +review, not the store: they're 6.3" and they include the four-variant matrix +rather than a curated marketing sequence. + +> **Do not** add device frames, drop shadows, or marketing chrome that +> misrepresents the UI. Apple rejects screenshots showing UI the app +> doesn't have. Captions rendered *above* the device shot are fine and +> standard; painted-on fake status bars and invented UI are not. + +--- + +## 5. App Privacy (nutrition labels) + +The app's own manifest is `SunHat/PrivacyInfo.xcprivacy`, which sets +`NSPrivacyTracking = true`. Declare in App Store Connect: + +### Data Used to Track You + +| Type | Collected by | Purpose | +|---|---|---| +| Device ID (advertising identifier) | Google Mobile Ads | Third-party advertising | +| Advertising Data | Google Mobile Ads | Third-party advertising | + +### Data Not Linked to You + +| Type | Collected by | Purpose | +|---|---|---| +| Precise Location | SunHat | App functionality (fetching a forecast) | +| Coarse Location | SunHat | App functionality | +| Advertising Data, Product Interaction, Crash Data, Performance Data | Google Mobile Ads | Analytics, advertising | + +Cross-check Google's own publisher guidance at + at submission time, the +list changes. + +### Data Linked to You + +None. SunHat has no accounts. + +🔴 **Blocker:** `NSPrivacyTrackingDomains` in `SunHat/PrivacyInfo.xcprivacy` +is still **empty**. Apple expects a non-empty domain list whenever +`NSPrivacyTracking` is `true`. Populate from Google's current published list +at , read it at go-live +rather than reusing an old copy. Verified that GoogleMobileAds 12.14.0 and +UserMessagingPlatform 3.1.0 declare neither key in their own manifests, so +this file is the app's only tracking declaration. + +--- + +## 6. Subscriptions + +Product IDs are already final in code and in `SunHat.storekit`. Create them +**verbatim** or the app will not find them. + +**Group:** `SunHat Ad-Free`, group display name (user-visible) `SunHat Ad-Free` + +Both products at **the same group level (rank 1)** so switching plans is a +crossgrade, matching the local StoreKit config. + +### `org.wesley.sunhat.adfree.monthly` + +| Field | Value | +|---|---| +| Reference name | `Ad-Free Monthly` | +| Duration | 1 month | +| Price | $1.00 (US base tier) | +| Display name (en) | `Ad-Free Monthly` | +| Description (en) | `Removes all ads from SunHat.` | +| Display name (es) | `Sin anuncios, Mensual` | +| Description (es) | `Elimina todos los anuncios de SunHat.` | + +### `org.wesley.sunhat.adfree.yearly` + +| Field | Value | +|---|---| +| Reference name | `Ad-Free Annual` | +| Duration | 1 year | +| Price | $10.00 (US base tier) | +| Display name (en) | `Ad-Free Annual` | +| Description (en) | `Best value, two months free vs. monthly.` | +| Display name (es) | `Sin anuncios, Anual` | +| Description (es) | `La mejor oferta: dos meses gratis frente al plan mensual.` | + +### Required before review + +- [ ] Paid Applications agreement signed, banking and tax complete +- [ ] Group display name set +- [ ] Localized display name + description for **both** `en` and `es` on + **both** products, a missing localization is an automatic flag +- [ ] Price set in every storefront (set the base tier; ASC generates the rest) +- [ ] **A review screenshot of the paywall attached to each product**: Apple + requires one per subscription. Capture from `PaywallScreenshotTests` + (`/tmp/sunhat-shots/paywall-sheet.png`) + +--- + +## 7. App Review information + +### Notes to reviewer + +``` +---8<--- +SunHat is a weather-triggered reminder app. You describe weather conditions; the app watches the forecast and notifies you when they match. + +No account or login is required. There is nothing to sign in to. + +HOW TO SEE THE CORE FEATURE +1. Allow location and notifications when prompted (both are optional; the app works with a manually chosen city). +2. Tap the + button in the tab bar to create a reminder. +3. Choose a temperature range that matches current conditions where you are, so the trigger fires immediately. +4. The Dashboard shows it as ready, and a notification is sent. + +MONETIZATION +The app is free and shows Google AdMob banner ads on the Dashboard and Weather tabs only. The optional auto-renewable subscription "SunHat Ad-Free" (monthly or annual, same entitlement, crossgradeable) removes them. There are no other paid features, nothing is gated behind the subscription. + +Restore Purchases is at Settings > SunHat Ad-Free > Restore Purchases. + +App Tracking Transparency is requested from the second session onward, for ad personalization only. Declining ATT does not reduce functionality; ads simply become non-personalized. In the EEA/UK, Google's UMP consent form is shown first, and no ad request is made until consent is resolved. + +WEATHER DATA +Weather comes from Apple WeatherKit. The app never fabricates weather values, if a forecast is unavailable it says so rather than showing placeholder numbers. +---8<--- +``` + +- **Demo account:** not required: leave blank, and tick "Sign-in required: No" +- **Contact:** Wesley Keetch, `weskcode@duck.com` +- **Attachment:** none needed + +### Export compliance + +`ITSAppUsesNonExemptEncryption` is already `false` in `SunHat/Info.plist`, so +App Store Connect will not ask. The app uses only HTTPS via system APIs. + +### Content rights + +The app contains no third-party content requiring rights documentation. +Weather data is Apple WeatherKit, which requires the attribution the app +already displays. + +--- + +## 8. Attribution requirements + +Apple's WeatherKit terms require attribution. Verify before submitting that +the app shows the Apple Weather mark and a link to the legal attribution page +on any screen displaying weather data. + +--- + +## 9. Pre-submission checklist + +### Owned by the developer (cannot be automated) + +- [ ] Create AdMob app + 2 banner units; paste the 3 IDs (see + `docs/MONETIZATION_GOLIVE.md` §1) +- [ ] Publish the AdMob GDPR consent message: without it, EEA/UK users get + no ads at all (the app fails safe) +- [ ] Populate `NSPrivacyTrackingDomains` +- [ ] Create both subscription products with full `en` + `es` metadata and + paywall review screenshots +- [ ] Bring `sunhat.apphq.online/privacy` and `/terms` in line with the in-app policy + (advertising section, subscription terms) +- [ ] WeatherKit entitlement provisioning on the real App ID +- [ ] Verify `weskcode@duck.com` receives mail (App Review uses it) +- [ ] Capture 6.9" screenshots on an iPhone 17 Pro Max + +### Verified in this repo + +- [x] Deployment target iOS 26.0 +- [x] `ITSAppUsesNonExemptEncryption = false` +- [x] All location usage descriptions present and specific +- [x] `NSUserTrackingUsageDescription` present +- [x] Background modes + `BGTaskSchedulerPermittedIdentifiers` declared +- [x] Release build succeeds with zero warnings +- [x] Full data export and deletion, covered by schema-parity tests +- [x] English + Spanish string catalogs complete diff --git a/docs/IOS_VERSION_STRATEGY.md b/docs/IOS_VERSION_STRATEGY.md new file mode 100644 index 0000000..67e1e69 --- /dev/null +++ b/docs/IOS_VERSION_STRATEGY.md @@ -0,0 +1,177 @@ +# iOS version strategy: shipping on 26, preparing for 27 + +**Status as of September 1, 2026.** iOS 26.6.1 is the current public release; +iOS 27 is in developer beta with a public release expected within weeks. +SunHat ships on iOS 26 and treats iOS 27 as forward-compatibility work. + +--- + +## 1. The decisions + +| Decision | Choice | Why | +|---|---|---| +| Minimum iOS (deployment target) | **26.0** | Lowered from 26.5 on Sept 7, 2026 for the App Store 1.0 submission. Nothing in the codebase requires a 26.x point release, the only version gates are `@available(iOS 26, *)` and two `#available(iOS 18.0, *)` checks, and all 25 Liquid Glass call sites are valid from 26.0. Holding at 26.5 excluded every 26.0-26.4 install for no compile-time reason. | +| Build SDK / toolchain | **Release Xcode 26.x (iOS 26 SDK)** | What the App Store actually cares about. Beta Xcode builds are rejected at submission. | +| Branching | **Single `main` line + short-lived `feature/ios27-*` branches** | Avoids the long-lived-divergence failure mode; iOS 27 work here is additive, not a port. | + +### Why not a long-lived iOS 27 branch + +A parallel OS branch is the right tool when two lines genuinely diverge, +different SDKs, different APIs, different products. That is not this +situation: + +- An app built with the **iOS 26 SDK runs on iOS 27**. Forward compatibility + is the platform norm, not something we port to. +- Our iOS 27 work is *verification plus optional adoption*, and optional + adoption is expressible in one codebase with `if #available(iOS 27, *)`. +- Every long-lived branch duplicates each bug fix and accrues merge debt. + With one developer and a submission pending, that cost is not repaid. + +**When to revisit:** if we decide to adopt an iOS 27-only API that requires +compiling against the iOS 27 SDK, *then* cut `release/ios27` as a real second +line, after iOS 27 is public, and with a dated note in this file. + +--- + +## 2. Toolchain requirements (submission blocker) + +> **Apple rejects App Store builds made with a beta Xcode or beta SDK.** + +**RESOLVED Sept 1, 2026.** Release **Xcode 26.6 (17F113)** is installed at +`/Applications/Xcode-26.6.0.app` and selected. Xcode 27 beta remains at +`/Applications/Xcode-beta.app` for iOS 27 work only. Verify with +`xcodebuild -version`; switch with `sudo xcodes select 26.6`. + +Release build for a generic iOS device against the iOS 26 SDK is verified +(`BUILD SUCCEEDED`), so the toolchain half of submission readiness is done. + +### Toolchain issues and their real causes (updated Sept 1, 2026) + +Recorded so they are not re-debugged. Two were previously misattributed to the +iOS 27 beta; both had different real causes. + +- **StoreKit-Testing storefront missing in UI tests (FIXED).** Not a beta bug: + the scheme's `TestAction` was missing its `StoreKitConfigurationFileReference` + (it was set only on `LaunchAction`). Fixed in 87580fc. +- **`SKTestSession` fails on the iOS 26.x simulator (OPEN: Apple bug).** + On the iOS 26.5 simulator runtime, every `SKTestSession` instance method + returns `SKInternalErrorDomain Code=3` ("Error saving configuration file"), + and `Product.products` returns empty. Cause: `xcodebuild test` from the + command line does not push the scheme's StoreKit configuration into the + destination simulator's `storekitd` container, verified here, the device's + `data/tmp/com.apple.storekit` directory is created but stays empty. This is + reported by others against iOS 26.3/26.4/26.5 runtimes (see the Flutter + issue `flutter/flutter#184678` and Apple's developer forums). + - **Not** caused by our `.storekit` file, our scheme, or stale simulator + state, verified by removing the TestAction config (still failed) and by + `simctl erase` (still failed). + - **Workaround:** open the project in Xcode, Run (Cmd+R) on the target + simulator, wait ~20–30s, Stop (Cmd+.), then Test (Cmd+U). Running once + from the IDE seeds `storekitd`, after which command-line runs work for + that simulator. There is no headless equivalent, `simctl` has no + `storekit` subcommand. + - **Scope is the RUNTIME, not the toolchain (proven Sept 1-2).** The same + Xcode 26.6 build runs all 6 `StoreManagerStoreKitTests` green on the + **iOS 27.0** simulator, and all 332 unit tests pass there. Only the iOS + 26.5 simulator runtime is affected. So the purchase pipeline IS verified + on the shipping toolchain, use the iOS 27 sim + (`1C67F44E-0EFC-46DF-8797-D3AEA5BCAF03`) for StoreKit test runs until + Apple fixes the 26.x runtime. + - **Ordering dependency:** `testPurchaseUnlocksAdFreeAndPersists` only gets a + storefront when the unit tests run FIRST in the same invocation (their + `SKTestSession` warms `storekitd`). Run it alone and the paywall never + loads. Giving the UI test its own `SKTestSession` was tried and REVERTED: + two competing sessions broke 5 unit tests (they slowed from ~0.05s to + ~5.6s, then failed). +- **Simulator instability / hung `xcodebuild`** on the beta remains real; also + check `uptime`, since concurrent sessions have driven load average past 1000 + and silently killed test runs. +- **SwiftPM binary artifacts:** truncated XCFramework downloads cached in + `~/Library/Caches/org.swift.swiftpm/artifacts` poison every later extract + until the cache is purged. + +### Xcode 26.6 migration notes (Sept 1, 2026) + +Installed with `xcodes install 26.6` → `/Applications/Xcode-26.6.0.app`, +selected via `sudo xcodes select 26.6`. Steps that were not obvious: + +1. `xcodebuild -downloadPlatform iOS` is required after switching: otherwise + xcodebuild reports "iOS 26.5 is not installed" and offers zero destinations, + even though `simctl` lists the runtime and the SDK is on disk. +2. That installs runtime build **23F77**, distinct from the **23F73** that + Xcode 27 beta installed. Both share the identifier + `com.apple.CoreSimulator.SimRuntime.iOS-26-5`, so `simctl create` can land + on the wrong one; a device on 23F73 fails asset compilation with + "No simulator runtime version ... available to use with iphonesimulator SDK + version 23F81a". +3. The new runtime does not register until Xcode 26.6 is opened once in the GUI. +4. Working simulator: `SunHat-iOS265` = + `FE834F9C-0251-4AB1-9301-5EEA61AFE809`. + +**Verified on Xcode 26.6:** `BUILD SUCCEEDED` for Release / generic iOS device +against the iOS 26 SDK, the actual App Store requirement, plus +`TEST BUILD SUCCEEDED` and 327/332 unit tests passing. + +--- + +## 3. iOS 27 support plan + +Work in short-lived `feature/ios27-*` branches off `main`, merged back when +each item is verified. Nothing here should raise the deployment target or +require the iOS 27 SDK. + +### Phase A: Compatibility verification (can start now, on the beta) + +Run the app on an iOS 27 simulator built with the **iOS 26 SDK**, this is +exactly what a user on iOS 27 will run after installing from the App Store. + +- [ ] Full unit + UI suite green on an iOS 27 simulator. +- [ ] Visual pass on every screen: Liquid Glass rendering, tab bar + minimize-on-scroll, Dynamic Type, light/dark. +- [ ] Weather pipeline: WeatherKit auth and fetches on iOS 27. +- [ ] Background work: `BGAppRefreshTask` / `BGContinuedProcessingTask` + scheduling and delivery. +- [ ] Notifications: categories, actions, deep links, quiet hours. +- [ ] Monetization: ad slots render and respect entitlement; paywall, + purchase, restore, and manage-subscription all work. +- [ ] SwiftData: store opens, migrates, and survives relaunch. + +### Phase B: Deprecations and behavior changes + +- [ ] Build against the iOS 27 SDK **in a scratch branch only** and triage every + new deprecation warning. Record findings here; do not merge SDK-dependent + changes into `main`. +- [ ] Review the iOS 27 release notes for behavior changes affecting + CoreLocation permissions, `BackgroundTasks` budgets, `UNUserNotification` + presentation, App Tracking Transparency, and StoreKit. +- [ ] Confirm Google Mobile Ads and UMP publish iOS 27-compatible releases; + bump the SPM pins on a `feature/ios27-sdk-bumps` branch. + +### Phase C: Optional adoption (only after iOS 27 is public) + +Adopt new APIs only where they earn their place, always behind +`if #available(iOS 27, *)` with the existing iOS 26 path intact. + +- [ ] Evaluate new SwiftUI/WidgetKit affordances against the deferred + widget and watchOS plans. +- [ ] Re-evaluate whether anything justifies moving the deployment target. + +### Phase D: Release + +- [ ] Ship the iOS 26 build first; do not block submission on iOS 27 work. +- [ ] After iOS 27 is public, run Phase A once more against the release build. +- [ ] Ship an iOS 27-verified update, noting compatibility in the release notes. + +--- + +## 4. Branch and tag conventions + +Extends the lightweight Gitflow in `CONTRIBUTING.md`, no `develop`, +no `release`, no `hotfix` branches. + +- `main`: always the submission-ready iOS 26 line. +- `feature/*`, `fix/*`: short-lived, merged via PR, deleted after merge. +- `feature/ios27-*`: iOS 27 compatibility work; same lifecycle, merged into + `main` behind availability guards. +- **Tag the submitted commit** (`v1.0-ios26`) so the exact shipped tree is + recoverable independent of later iOS 27 changes. diff --git a/docs/MARKETING_KIT.md b/docs/MARKETING_KIT.md new file mode 100644 index 0000000..25ddbc9 --- /dev/null +++ b/docs/MARKETING_KIT.md @@ -0,0 +1,276 @@ +# SunHat: marketing and pitch kit + +Copy for launch. Everything between `---8<---` markers is ready to paste. + +The core idea, in one sentence, is the thing to keep repeating: **most +reminder apps ask *when*; SunHat asks *what conditions*.** Every piece of copy +below is a different length of that same idea. + +--- + +## 1. Pitches by length + +### Six words + +``` +---8<--- +Reminders that wait for the weather. +---8<--- +``` + +### One line (App Store subtitle territory) + +``` +---8<--- +Set a reminder for "when it's above 68°F and clear" and SunHat watches the forecast for you. +---8<--- +``` + +### Elevator, ~30 seconds + +``` +---8<--- +Calendar reminders assume you know when you'll want to do something. But a lot of plans don't work that way, you don't want to water the garden on Thursday, you want to water it when it hasn't rained in two days. You don't want to run at 6pm, you want to run when it's between 55 and 70 and not pouring. + +SunHat lets you describe the conditions instead of the time. It watches the forecast in the background and notifies you the moment reality matches what you asked for. Seven trigger types, from a simple temperature range to combinations of temperature, humidity and wind. + +No accounts, no sign-up. It's free, banner ads fund it, and a dollar a month removes them. Nothing else is gated. +---8<--- +``` + +### The problem statement, for a deck + +``` +---8<--- +Reminder apps have one input: time. That works when the constraint is your +schedule. It fails when the constraint is the world. + +"Go for a run" at 6pm fires during a thunderstorm. "Water the garden" on +Thursday fires the morning after it rained. The reminder is technically +correct and practically useless, so people stop trusting it and turn it off. + +SunHat changes the input from a timestamp to a condition, and does the +watching for you. +---8<--- +``` + +--- + +## 2. Who it's for + +Lead with the audience, not the feature. Each of these is a different opening +line for the same app. + +| Audience | The hook | Trigger they'd set | +|---|---|---| +| **Gardeners** | Watering on a schedule wastes water and drowns plants | Dry period, 48 hours | +| **Runners and cyclists** | The weather decides whether the run happens | Temperature range + clear | +| **Photographers** | Golden hour is worthless if it's overcast | Above 68°F + sunny, evening | +| **Parents** | "Is today a park day?" answered before you're asked | Above 70°F, no rain | +| **Anyone with a grill** | The one weekend day worth cooking outside | Consecutive clear days | +| **Allergy sufferers** | Wind and dry stretches drive pollen | Composite: wind + dry period | + +--- + +## 3. Social launch copy + +### X / Twitter: launch post + +``` +---8<--- +I built SunHat because my reminder app kept telling me to go running during thunderstorms. + +Instead of "remind me at 6pm," you set "remind me when it's 55–70°F and clear." It watches the forecast and pings you when the weather actually cooperates. + +Free on the App Store. No accounts. +---8<--- +``` + +### X / Twitter: the feature thread opener + +``` +---8<--- +Seven ways to describe a day in SunHat: + +exact temperature +temperature range +sky conditions +feels-like +dry period +consecutive days +composite (temp + humidity + wind) + +Pick one, describe the day you're waiting for, and stop checking the forecast yourself. +---8<--- +``` + +### Mastodon / Bluesky + +``` +---8<--- +Shipped SunHat today, a reminder app that waits for weather instead of a clock. + +"Water the garden when it's been dry 48 hours." "Beach day when it's above 80 with no rain coming." + +No accounts, no analytics SDK, full data export and deletion built in. Free with ads, $1/mo to remove them, nothing else gated. +---8<--- +``` + +### Instagram / threads caption + +``` +---8<--- +Your calendar doesn't know it's raining. ☔️ + +SunHat is a reminder app that waits for the right weather instead of the right time. Describe the day you're waiting for, it watches the forecast and tells you when it shows up. + +Free on iPhone. Link in bio. +---8<--- +``` + +--- + +## 4. Product Hunt + +**Tagline** (60 char limit) + +``` +---8<--- +Reminders that wait for the right weather, not the clock +---8<--- +``` + +**First comment / maker's note** + +``` +---8<--- +Hi Product Hunt 👋 + +SunHat came out of a small, dumb frustration: my reminder app told me to go running during a thunderstorm, because 6pm is 6pm regardless of what's happening outside. + +A lot of plans are like that. Watering the garden. Photo walks. Beach days. Grilling. The constraint isn't your schedule, it's the weather, but every reminder app only takes a timestamp. + +So SunHat takes conditions instead. You describe the day you're waiting for, like "55 to 70 and clear" or "dry for 48 hours," and it watches the forecast in the background and notifies you when reality matches. + +Some things I care about that are worth calling out: + +• It never invents weather. If the forecast isn't available, it says so. It will not show you a plausible-looking number it made up. There are tests guarding this. +• No accounts, no sign-up, no SunHat analytics SDK. Your location goes to the weather provider to fetch a forecast, and nowhere else. +• Full data export and one-tap deletion, built in rather than buried. +• Free, funded by banner ads on two screens. A dollar a month removes them. Nothing else is behind the subscription, no feature gates. + +Built in SwiftUI with WeatherKit, iPhone-first. iPad, widgets and a watch app are next. + +Happy to answer anything. +---8<--- +``` + +--- + +## 5. Reddit (r/iosapps, r/apple, r/gardening) + +Reddit punishes marketing language. Lead with the problem and be specific. + +``` +---8<--- +I made a reminder app that waits for weather instead of time + +The problem I had: every reminder app takes a timestamp. That's fine for "call the dentist," but useless for anything where the weather is the actual constraint. "Water the garden every Thursday" fires the morning after it rained. "Go for a run at 6" fires during a storm. + +SunHat lets you set the condition instead. Temperature range, sky conditions, feels-like, dry period, consecutive days, or a combination of temperature + humidity + wind. It checks the forecast in the background and notifies you when the conditions actually show up. + +A few implementation notes since this sub cares: +- WeatherKit for data. If a forecast isn't available the UI says so rather than filling in a plausible number. +- SwiftData locally, no server, no account. +- Notifications respect quiet hours and a daily cap so it can't spam you. +- Siri/Shortcuts and Spotlight support. +- Data export and deletion are real, not decorative. + +Free with banner ads on two screens, $1/mo or $10/yr to remove them. Nothing else is gated, there's no pro tier. + +iPhone only right now, iOS 26+. Happy to take feature requests. +---8<--- +``` + +--- + +## 6. Short press release + +``` +---8<--- +SunHat brings weather-triggered reminders to iPhone + +SunHat, a new iPhone app, replaces the timestamp at the heart of every +reminder app with something more useful for outdoor plans: the weather +itself. + +Instead of scheduling "go for a run" at a fixed time, SunHat users describe +the conditions they are waiting for, a temperature range, clear skies, a dry +stretch of days, and the app monitors the forecast in the background, +sending a notification when those conditions arrive. + +The app supports seven trigger types, from a single temperature threshold to +composite conditions combining temperature, humidity and wind. It draws +forecast data from Apple WeatherKit and includes hourly forecasts, +temperature history, quiet hours, notification limits, Siri and Shortcuts +integration, and Spotlight search. + +SunHat requires no account and collects no analytics of its own. Location +data is used to retrieve a forecast and for nothing else. Full data export +and deletion are built into the app. + +SunHat is free on the App Store with banner advertising. An optional +subscription, SunHat Ad-Free, removes advertising for $1.00 per month or +$10.00 per year. No other features are restricted. + +SunHat requires iOS 26 or later. Available now on the App Store. + +Contact: weskcode@duck.com +---8<--- +``` + +--- + +## 7. Objections, answered + +Useful for support replies, review responses, and FAQ pages. + +| Objection | Answer | +|---|---| +| "Why not just check the weather app?" | You can. SunHat is for the plans you'd otherwise forget to check for, the garden you meant to water, the photo walk you keep missing. It watches so you don't have to remember to. | +| "Why does it need my location?" | To fetch a forecast for where you are. You can skip it entirely and pick a city by hand. The coordinate goes to the weather provider and nowhere else. | +| "Ads in a weather app, really?" | On two screens, at the bottom, never covering a control. One dollar removes them permanently. Everything else is free, there's no pro tier hiding behind the subscription. | +| "Is my data being sold?" | No. There is no SunHat analytics SDK and no account to attach data to. The ad SDK is Google's and is disclosed in the privacy labels; the subscription turns it off entirely. | +| "Will it drain my battery?" | Background checks are rate-limited and coalesced. It should not appear near the top of your battery list, if it does, that's a bug worth reporting. | +| "Does it work without notifications?" | Yes, the Dashboard still shows what's ready. But notifications are the point, so it's worth allowing them. | + +--- + +## 8. App icon and store assets + +| Asset | Spec | Status | +|---|---|---| +| App icon | 1024 × 1024 PNG, no alpha, no rounded corners | In `Assets.xcassets` | +| App Store screenshots | See `APP_STORE_RELEASE_KIT.md` §4 | Capture required | +| App preview video | Optional, 15–30s, up to 3 per size | Not planned for 1.0 | + +An app preview video is the single highest-leverage store asset after the +first two screenshots, but it is not required for 1.0 and a bad one is worse +than none. Skip it until the screenshots are settled. + +--- + +## 9. Positioning against the alternatives + +Do not name competitors in App Store metadata, Apple rejects it. This is for +your own copy, on your own site. + +| | Apple Reminders | Weather apps | SunHat | +|---|---|---|---| +| Input | Time or place | Nothing, you read it | Weather conditions | +| Tells you when to act | On a schedule | No | When conditions match | +| Watches the forecast for you | No | You watch it | Yes | +| Needs an account | iCloud | Usually | No | + +The honest framing: SunHat is not a replacement for either. It's the small +missing piece between them. diff --git a/docs/MONETIZATION_GOLIVE.md b/docs/MONETIZATION_GOLIVE.md new file mode 100644 index 0000000..1f9c0c9 --- /dev/null +++ b/docs/MONETIZATION_GOLIVE.md @@ -0,0 +1,120 @@ +# Monetization go-live handoff + +Everything in the app currently runs on **test/sandbox values**, Google's public +test ad IDs and a local StoreKit configuration, so the whole pipeline (ads +show → purchase → ads gone → restore) is verifiable without any live accounts. +This document lists exactly what to create and where each real value goes. +Written 2026-08-31 at the end of the ads + Ad-Free subscription build +(branch `feature/ads-and-iap`). + +## 1. AdMob console: create, then paste + +Create at https://apps.admob.com: + +| Create | Replaces | Goes in | +|---|---|---| +| iOS app → **App ID** (`ca-app-pub-XXXX~YYYY`) | `ca-app-pub-3940256099942544~1458002511` | `SunHat/Info.plist` → `GADApplicationIdentifier` | +| Banner ad unit "Dashboard Banner" | `ca-app-pub-3940256099942544/2435281174` | `SunHat/Views/Ads/BannerAdView.swift` → `AdConfig.dashboardBannerUnitID` | +| Banner ad unit "Weather Banner" | `ca-app-pub-3940256099942544/2435281174` | `SunHat/Views/Ads/BannerAdView.swift` → `AdConfig.weatherBannerUnitID` | + +Those are the only three Google IDs in the codebase (grep `3940256099942544` +to verify nothing is missed). + +Also in AdMob → **Privacy & messaging**: publish a **GDPR (European +regulations) message** and an **ATT message is optional**. The app already +integrates the UMP SDK (`AdManager.gatherConsentIfNeeded()`), but the consent +form only serves once a message is published in the console. Without it, +EEA/UK users get no ads (the app fails safe via `canRequestAds`). + +Note: new AdMob apps serve limited ads until Google's app review completes, +blank banners in the first days after go-live are expected, not a bug. + +## 2. App Store Connect: subscriptions + +The product IDs are already final in code and in `SunHat.storekit`; create +them **verbatim**: + +- Subscription group: **SunHat Ad-Free** + - `org.wesley.sunhat.adfree.monthly`: "Ad-Free Monthly", $0.99–$1.00/month + price point, description: *Removes all ads from SunHat.* + - `org.wesley.sunhat.adfree.yearly`: "Ad-Free Annual", $9.99–$10.00/year + price point, description: *Best value, two months free vs. monthly.* + - **Both at the same group level (rank 1)** so switching plans is a + crossgrade, matching the local config. +- Sign the **Paid Applications agreement** and set up banking/tax first. +- No code changes needed at this step: `StoreManager.ProductID` already uses + these IDs; the `.storekit` file stays in the repo for local testing. +- Review notes suggestion: "Free app with banner ads; the auto-renewable + 'SunHat Ad-Free' subscription (monthly/annual, same entitlement) removes + them. Restore Purchases is in Settings → SunHat Ad-Free. ATT is requested + from the second session for ad personalization only." + +## 2a. Subscription metadata in App Store Connect (required before review) + +Creating the product IDs is not enough, App Store Connect will not let the +subscription go to review until each of these exists: + +- **Subscription group display name** (user-visible; "SunHat Ad-Free"). +- **Per-product localized display name and description** for every locale you + ship (SunHat ships **en** and **es**, both are required, or review will + flag the missing localization). +- **Price** for each product in every storefront (set the base tier; App Store + Connect generates the rest). +- **Subscription duration**: 1 month / 1 year, matching `SunHat.storekit`. +- **A review screenshot of the paywall** for each subscription: Apple + requires one per product. Capture from `PaywallScreenshotTests` + (`/tmp/sunhat-shots/paywall-*.png`). +- **Terms of Use (EULA) and Privacy Policy URLs** on the app record. Apple + requires functional links for auto-renewable subscriptions; the app already + points at `https://sunhat.apphq.online/terms` and `/privacy`, and both pages must + describe the subscription (see §4). + +## 3. Privacy nutrition labels (App Store Connect) + +> **Blocker: `NSPrivacyTrackingDomains` is empty.** Verified that +> GoogleMobileAds 12.14.0 and UserMessagingPlatform 3.1.0 declare neither +> `NSPrivacyTracking` nor `NSPrivacyTrackingDomains` in their bundled +> manifests, so `SunHat/PrivacyInfo.xcprivacy` is the app's only tracking +> declaration. Apple expects a non-empty domain list whenever +> `NSPrivacyTracking` is `true`. Populate it from Google's current published +> list at before submitting, +> the values change, so read them at go-live rather than reusing an old list. + +With AdMob + ATT, the previous "no tracking" posture changes. Declare: + +- **Data Used to Track You**: Device ID (advertising identifier), Advertising + Data, collected by Google Mobile Ads. +- **Data Not Linked to You**: Precise Location, Coarse Location (app + functionality, weather; unchanged), plus Google's ad-performance data per + https://support.google.com/admob/answer/10787689 (Google's own label + guidance for AdMob publishers). +- The app-level privacy manifest (`SunHat/PrivacyInfo.xcprivacy`) now sets + `NSPrivacyTracking = true`; Google's SDK ships its own manifest declaring + its domains/data, and Xcode aggregates both into the privacy report. + +## 4. Hosted legal pages + +The in-app Privacy Policy (`PrivacyPolicyView`) now discloses AdMob ads, ATT, +and the Ad-Free subscription. The hosted pages must be brought in line before +submission: + +- https://sunhat.apphq.online/privacy: add the same Advertising section. +- https://sunhat.apphq.online/terms: add auto-renewable subscription terms (price, + period, renewal, cancellation via Apple Account settings). + +## 5. Switching the app itself to live values + +1. Paste the three AdMob IDs (table above). +2. Nothing else changes: the entitlement pipeline, paywall, and consent flow + are identical against live App Store / live AdMob. +3. Run the integration checks once against TestFlight/sandbox: ads visible → + purchase either tier → ads gone immediately → relaunch still ad-free → + Restore Purchases on a re-install. + +## What stays test-only (deliberately) + +- `SunHat.storekit` + the scheme's StoreKit configuration: local purchase + testing forever; ignored by App Store builds. +- `SunHatUITests/AdFreeIntegrationUITests.swift`: automated end-to-end loop + against the local store (purchases persist in the simulator's StoreKit test + store; reset with `xcrun simctl uninstall org.wesley.sunhat`). diff --git a/docs/PRIVACY_POLICY.md b/docs/PRIVACY_POLICY.md new file mode 100644 index 0000000..52efad9 --- /dev/null +++ b/docs/PRIVACY_POLICY.md @@ -0,0 +1,211 @@ +# Privacy Policy + +**Effective date: September 8, 2026** + +SunHat is a weather-triggered reminder app for iPhone. This policy explains +what data the app handles, what leaves your device, and what you can do +about it. + +The short version: SunHat has no accounts and no servers. Your reminders, +preferences and cached weather stay on your iPhone. The only data that +leaves your device is the coordinate sent to a weather provider to fetch a +forecast. In the free, ad-supported version, it also includes the data Google's +advertising SDK collects to serve and measure ads. + +--- + +## Who we are + +SunHat is developed by Wesley Keetch, an independent developer. + +Contact: **weskcode@duck.com** + +## We have no account system and no backend + +SunHat does not ask you to sign up, sign in, or provide an email address. +There is no SunHat server that receives your data, because there is no SunHat +server. Nothing you create in the app is transmitted to us, and we could not +retrieve it if we wanted to. + +## Data stored on your device + +The following is stored locally on your iPhone using Apple's SwiftData +framework, and never sent to us: + +- **Reminders you create**: titles, notes, icons, colors, and the weather + conditions you chose +- **App preferences**: units, quiet hours, notification limits, appearance +- **Cached weather data**: recent forecasts, so the app works offline and + makes fewer network requests +- **Saved locations**: any city you pick manually + +This data is included in your device backup if you back up your iPhone to +iCloud or a computer. That backup is governed by Apple's privacy policy, not +this one. + +## Location + +If you grant location permission, SunHat uses your coordinates to fetch a +weather forecast for where you are. + +- Your coordinate is sent **to the weather provider only** (Apple WeatherKit), + for the sole purpose of retrieving a forecast. +- It is not sent to us, not stored on any server we control, and not used to + build a profile of your movements. +- Location permission is **optional**. You can decline it and pick a city + manually instead; the app works either way. +- You can grant reduced (approximate) accuracy in iOS Settings. SunHat will + use it, though forecasts will be less specific to your exact area. +- You can revoke location access at any time in **iOS Settings → Privacy & + Security → Location Services → SunHat**. + +## Notifications + +If you allow notifications, SunHat sends them locally from your device when +your conditions are met. Notifications are scheduled and delivered on-device +through iOS. No push server is involved, and no notification content is +transmitted anywhere. + +## Weather data + +Weather is provided by **Apple WeatherKit**. Apple receives the location +coordinate needed to answer the forecast request. Apple's handling of that +request is governed by Apple's privacy policy: + + +SunHat does not fabricate weather data. When a forecast is unavailable, the +app says so rather than displaying an estimated value. + +## Advertising (free version only) + +The free version of SunHat displays banner advertisements supplied by +**Google AdMob**, on the Dashboard and Weather screens only. + +To serve and measure those ads, Google's SDK may collect: + +- Your device's **advertising identifier (IDFA)**, if you permit tracking +- **Advertising data**: ad impressions, clicks, and related interaction +- **Coarse location, device and app information, and diagnostic data**, as + described in Google's own documentation + +This data is collected by Google, not by us. We do not receive it, and we +have no access to it beyond aggregate revenue reporting. Google's handling +is governed by Google's privacy policy: + + +### App Tracking Transparency + +Before any cross-app tracking occurs, iOS asks for your permission through +Apple's App Tracking Transparency prompt. SunHat does not show this prompt on +your first session. It waits until you have had a chance to use the app. + +**If you decline, nothing in the app stops working.** Ads simply become +non-personalized. + +You can change this at any time in **iOS Settings → Privacy & Security → +Tracking**. + +### Consent in the EEA, UK and Switzerland + +If you are in a region covered by the GDPR or equivalent rules, SunHat +presents Google's certified consent form before any ad request is made. If +you have not consented, no ad request is sent. You can reopen the consent +form and change your choice at any time from **Settings → Privacy → Ad +Privacy Options** inside the app. + +### Removing ads entirely + +The optional **SunHat Ad-Free** subscription removes all advertising. When +the subscription is active, the advertising SDK makes no ad requests at all. + +## Subscriptions and payment + +SunHat Ad-Free is an auto-renewable subscription sold through Apple's App +Store. + +- **We never see your payment details.** Apple processes the transaction; we + receive only an anonymous receipt confirming that a subscription is active. +- We do not receive your name, email address, billing address, or card + information. +- Manage or cancel your subscription in **iOS Settings → your name → + Subscriptions**, or in the App Store app. + +Apple's handling of your purchase is governed by Apple's privacy policy. + +## Analytics + +SunHat contains no analytics SDK of its own. We do not track which screens +you visit, which features you use, how often you open the app, or what your +reminders say. + +Apple provides aggregate, anonymized App Store metrics (downloads, crash +counts) that cannot be tied to an individual. Google provides aggregate ad +performance reporting. Neither identifies you to us. + +## Your rights and controls + +Because your data is on your device, you control it directly: + +| What you want | Where | +|---|---| +| Export everything SunHat holds | Settings → Privacy → Export My Data | +| Delete everything, permanently | Settings → Privacy → Delete All Data | +| Turn location off | iOS Settings → Privacy & Security → Location Services | +| Turn off ad tracking | iOS Settings → Privacy & Security → Tracking | +| Change ad consent (EEA/UK) | Settings → Privacy → Ad Privacy Options | +| Remove ads entirely | Settings → SunHat Ad-Free | +| Remove all data at once | Delete the app | + +Deleting the app removes all locally stored data. Deletion is immediate and +irreversible; we hold no copy to restore from. + +If you are covered by the **GDPR**, your rights of access, rectification, +erasure, restriction, portability and objection are satisfied by the export +and delete controls above, since we hold no personal data about you on any +system we operate. Our lawful basis for processing location is your consent, +which you may withdraw at any time by revoking the permission. + +If you are covered by the **CCPA/CPRA**, note that we do not sell or share +personal information as those terms are defined. The advertising identifier +handled by Google in the free version may constitute "sharing" for +cross-context behavioral advertising under some interpretations; declining +App Tracking Transparency, or subscribing to SunHat Ad-Free, prevents it. + +## Children + +SunHat is not directed to children under 13, and we do not knowingly collect +personal information from them. The app is not submitted to the App Store's +Kids Category, because the Kids Category prohibits the third-party +advertising the free version relies on. + +## International transfers + +We operate no servers, so we transfer nothing internationally. Apple and +Google may process data in countries other than yours, as described in their +respective privacy policies. + +## Data retention + +We retain nothing, because we receive nothing. Data on your device persists +until you delete it or remove the app. Apple and Google apply their own +retention periods to the data they collect. + +## Security + +Data on your device is protected by iOS file-system encryption and your +device passcode. All network requests use HTTPS. Because there is no account +and no server, there is no credential to steal and no central database to +breach. + +## Changes to this policy + +If this policy changes materially, the updated version will be posted here +with a new effective date, and the in-app policy will be updated to match. If +a change expands what is collected, it will be described in the release notes +for the version that introduces it. + +## Contact + +Questions about this policy, or about your data: + +**weskcode@duck.com** diff --git a/docs/TERMS_OF_USE.md b/docs/TERMS_OF_USE.md new file mode 100644 index 0000000..423ad6c --- /dev/null +++ b/docs/TERMS_OF_USE.md @@ -0,0 +1,199 @@ +# Terms of Use + +**Effective date: September 8, 2026** + +These terms are an agreement between you and Wesley Keetch, an independent +developer, covering your use of the SunHat app for iPhone. By downloading or +using SunHat, you agree to them. If you do not agree, please do not use the +app. + +## The app + +SunHat is a reminder app that watches the weather forecast and notifies you +when conditions you have described are met. It requires iOS 26 or later and a +device that supports Apple WeatherKit. + +SunHat is free to download. The free version displays banner advertisements. +An optional subscription removes them. + +## SunHat Ad-Free subscription + +SunHat Ad-Free is an auto-renewable subscription that removes all advertising +from the app. It is the only paid item in SunHat. No other feature is +restricted, and nothing else is behind a paywall. + +**Plans and pricing** + +| Plan | Price | Period | +|---|---|---| +| Ad-Free Monthly | $1.00 USD | 1 month | +| Ad-Free Annual | $10.00 USD | 1 year | + +Prices are shown in US dollars. In other regions, the price is the local +equivalent set by the App Store, and the price shown in the app at the time of +purchase is the price that applies. + +Both plans unlock exactly the same thing. You can switch between them at any +time, and the App Store handles the proration. + +**Billing and renewal** + +Payment is charged to your Apple Account when you confirm the purchase. + +The subscription renews automatically at the same price and for the same +period unless you cancel it at least 24 hours before the end of the current +period. Your account is charged for renewal within 24 hours before the end of +the current period. + +**Managing and cancelling** + +You can manage or cancel your subscription at any time in your Apple Account +settings, at **Settings > your name > Subscriptions** on your device, or in +the App Store app. + +Cancelling stops the next renewal. It does not end the current period, and +you keep ad-free access until that period runs out. Deleting the app does not +cancel a subscription. + +**Refunds** + +All purchases are processed by Apple, so refunds are handled by Apple under +their policy, not by us. We cannot issue, approve, or deny a refund. Request +one at . + +**Free trials** + +SunHat does not currently offer a free trial. If one is added, any unused +portion of a free period is forfeited when you purchase a subscription, as +required by the App Store. + +**Restoring a purchase** + +If you reinstall SunHat or move to a new device, restore your subscription at +**Settings > SunHat Ad-Free > Restore Purchases** inside the app. + +## Advertising + +The free version displays banner advertisements supplied by Google AdMob on +two screens. Advertisements are served by Google, and we do not choose, +review, or endorse individual advertisements or the products in them. + +Before any cross-app tracking occurs, iOS asks for your permission through +Apple's App Tracking Transparency prompt. Declining changes nothing about how +the app works. Advertisements simply become non-personalized. + +How advertising data is handled is described in the +[Privacy Policy](https://sunhat.apphq.online/privacy). + +## Your content + +Reminders, notes, and preferences you create in SunHat are yours. They are +stored on your device. We have no server, no account system, and no access to +them. + +You are responsible for your own data. Because everything is stored locally, +deleting the app deletes your reminders, and we hold no copy to restore from. +Use your device backup if you want to keep them. + +## Acceptable use + +Do not use SunHat to break the law, and do not attempt to reverse engineer, +decompile, or interfere with the app or the services it relies on beyond what +applicable law permits. + +## Weather data and how to rely on it + +Weather information comes from Apple WeatherKit. Forecasts are predictions and +they are sometimes wrong. + +**SunHat is not a safety or emergency service.** Its notifications are +personal reminders about ordinary plans. They are not weather warnings, they +are not issued by any meteorological or government authority, and they must +not be relied on for decisions about severe weather, travel safety, health, or +anything else where being wrong could cause harm. For official warnings, +consult your national weather service and local emergency authorities. + +Notifications depend on iOS background execution and on your device having a +network connection. They can be delayed or missed for reasons outside our +control, including Low Power Mode, Focus modes, notification permissions, +background app refresh settings, and network conditions. Do not use SunHat for +anything where a missed notification would matter. + +## Availability + +We may update, change, or discontinue SunHat or any of its features. We will +not knowingly break an active subscription without offering a remedy through +the App Store. + +## Disclaimer of warranties + +SunHat is provided "as is" and "as available", without warranties of any kind, +express or implied, including any implied warranty of merchantability, fitness +for a particular purpose, or non-infringement. We do not warrant that the app +will be uninterrupted, error free, or that forecasts will be accurate. + +Some jurisdictions do not allow the exclusion of implied warranties, so parts +of this section may not apply to you. + +## Limitation of liability + +To the maximum extent permitted by law, we are not liable for any indirect, +incidental, special, consequential, or punitive damages, or for any loss of +data, profits, or goodwill, arising out of your use of SunHat. + +Our total liability for any claim relating to SunHat is limited to the amount +you paid us for the app in the twelve months before the claim, which for most +users is the subscription price or nothing at all. + +Some jurisdictions do not allow these limitations, so parts of this section +may not apply to you. Nothing in these terms limits liability that cannot be +limited by law, including liability for death or personal injury caused by +negligence, or for fraud. + +## Third-party services + +SunHat relies on Apple WeatherKit and, in the free version, Google AdMob. Your +use of those services is subject to their own terms: + +- Apple: +- Google: + +## Apple as a third-party beneficiary + +You acknowledge that these terms are between you and us, not with Apple. Apple +is not responsible for SunHat or its content. Apple has no obligation to +provide maintenance or support for SunHat. + +If SunHat fails to conform to any applicable warranty, you may notify Apple, +and Apple will refund the purchase price if applicable. To the maximum extent +permitted by law, Apple has no other warranty obligation with respect to +SunHat. Apple is not responsible for addressing any claim by you or a third +party relating to SunHat, including product liability claims, claims that +SunHat fails to conform to a legal or regulatory requirement, and claims +arising under consumer protection or similar legislation. + +You represent that you are not located in a country subject to a US Government +embargo or designated as a terrorist supporting country, and that you are not +on any US Government list of prohibited or restricted parties. + +Apple and its subsidiaries are third-party beneficiaries of these terms and +may enforce them against you. + +## Changes to these terms + +If these terms change materially, the updated version will be posted here with +a new effective date, and the change will be noted in the release notes of the +version that introduces it. Continuing to use SunHat after that means you +accept the updated terms. + +## Governing law + +These terms are governed by the laws of the State of Utah, United States, +without regard to its conflict of law rules. This does not deprive you of the +protection of mandatory consumer law in your country of residence. + +## Contact + +Questions about these terms: + +**weskcode@duck.com** diff --git a/docs/TESTFLIGHT_RELEASE.md b/docs/TESTFLIGHT_RELEASE.md new file mode 100644 index 0000000..8e51e8e --- /dev/null +++ b/docs/TESTFLIGHT_RELEASE.md @@ -0,0 +1,190 @@ +# SunHat: TestFlight release guide + +What to fill into App Store Connect's TestFlight tab, the message to send +testers, and the sequence for getting a build up. + +--- + +## 1. Beta App Information (set once) + +| Field | Value | +|---|---| +| **Beta App Description** | see block below | +| **Feedback Email** | `weskcode@duck.com` | +| **Marketing URL** | `https://sunhat.apphq.online` | +| **Privacy Policy URL** | `https://sunhat.apphq.online/privacy` | +| **Sign-in required** | No | +| **Demo account** | Not applicable | + +### Beta App Description + +``` +---8<--- +SunHat is a weather-triggered reminder app. Instead of "remind me at 5pm," you set "remind me when it's above 68°F and clear," and SunHat watches the forecast and tells you when conditions match. + +This build is feature-complete for 1.0. The free tier shows banner ads; an optional subscription removes them. Ads and purchases run against test servers in TestFlight, so nothing is charged and no real ad revenue is involved. + +No account or sign-up. Location and notifications are both optional, though the app is much more useful with them on. +---8<--- +``` + +--- + +## 2. What to Test: send this to testers + +Paste into the **What to Test** field for the build (4000 char limit). + +``` +---8<--- +Thanks for testing SunHat. This is the 1.0 candidate, feature-complete, and I'm looking for anything broken, confusing, or ugly before it goes to the App Store. + +WHAT SUNHAT DOES +Set a reminder that waits for weather instead of a clock. "Water the garden when it's been dry 48 hours." "Run when it's 55-70°F and clear." SunHat watches the forecast and notifies you when reality matches. + +═══ THE 5-MINUTE PASS ═══ + +1. ONBOARDING + Go through the first-run flow. Allow location and notifications when asked. + → Did anything feel like it asked for too much, too early? + +2. MAKE ONE THAT FIRES NOW + Tap + in the tab bar. Set a temperature range that matches your actual + weather right now, so it triggers immediately. + → Did the reminder appear as "ready" on the Dashboard? + → Did you get a notification? + +3. MAKE ONE THAT WAITS + Create a second one for conditions that are days away. + → Does the Weather tab's prediction card show a sensible confidence? + +4. LOOK AROUND + Visit all four tabs. Check the hourly forecast and the temperature + history charts. + → Any number that looks made up or obviously wrong? + +═══ WHAT I MOST NEED EYES ON ═══ + +• NOTIFICATIONS ACTUALLY ARRIVING. This is the whole product. Leave a + reminder set overnight and tell me if it fired when it should have, or + fired when it shouldn't have. + +• ACCURACY. SunHat should never invent weather. If a forecast isn't + available it's supposed to say so, not show a plausible-looking number. + If you ever see a temperature you don't believe, screenshot it. + +• THE ADS. Banners appear at the bottom of the Dashboard and Weather tabs + only. They should never cover a button, never shift the layout as they + load, and never appear on other screens. + → Did a banner ever get in your way or push content around? + +• THE SUBSCRIPTION. Settings > SunHat Ad-Free > Get Ad-Free. In TestFlight + this uses Apple's sandbox, so YOU WILL NOT BE CHARGED. + → Buy either plan. Do the ads disappear immediately? + → Force-quit and reopen. Still ad-free? + → Try Restore Purchases. + → Try switching monthly ↔ annual. + +• DARK MODE AND BIG TEXT. Settings > Display & Brightness > Dark, and + Accessibility > Display & Text Size > Larger Text, cranked to maximum. + → Any text cut off, overlapping, or unreadable? + +• VOICEOVER, if you use it. I especially want to know about unlabeled + buttons. + +• BATTERY. Check Settings > Battery after a day. SunHat does background + weather checks and shouldn't be near the top of that list. + +═══ ALSO WORTH POKING ═══ + +• Siri: "Hey Siri, create a SunHat reminder" +• Spotlight: swipe down, search for a reminder you made +• Manual city: Weather tab > location button, pick somewhere else +• Quiet hours: Settings > Notifications +• Airplane mode: does it degrade gracefully or show a confusing error? +• Settings > Privacy > Export My Data, and Delete All Data + +═══ KNOWN AND EXPECTED ═══ + +• Ads are Google's TEST ads, placeholder creative, not real inventory. +• Purchases are sandbox. Nothing is charged. Sandbox subscriptions renew + on an accelerated clock, so a "monthly" plan may renew every few minutes. +• iPad works but is not optimized, it's an iPhone layout scaled up. + iPad, widgets and a watch app are planned after 1.0. +• Spanish is supported. If your device is in Spanish and something reads + awkwardly, tell me. + +═══ HOW TO REPORT ═══ + +Screenshot the problem, then use TestFlight's built-in feedback (shake the +device, or the Send Beta Feedback button). Tell me what you expected and +what happened. "This felt weird" is genuinely useful, I want to hear about +confusing as much as broken. +---8<--- +``` + +--- + +## 3. Build and upload sequence + +TestFlight uploads need your Apple ID and an App Store Connect app record; +they cannot be automated from this repo without your credentials. + +### One-time setup + +1. Create the app record in App Store Connect with bundle ID + `org.wesley.sunhat`. +2. Confirm the App ID has the **WeatherKit** capability, plus Push + Notifications and Background Modes. +3. Sign the Paid Applications agreement (required before subscriptions work + at all, even in sandbox). + +### Per build + +```bash +xcodebuild -scheme SunHat -configuration Release \ + -destination 'generic/platform=iOS' \ + -archivePath build/SunHat.xcarchive archive +``` + +Then Xcode → Window → Organizer → Distribute App → TestFlight & App Store. +Or use `xcodebuild -exportArchive` with an `ExportOptions.plist` once you +have a distribution certificate. + +Build numbers: a Release-only build phase reads `buildnumber.txt`, +increments it, and writes the result into the built product's +`CFBundleVersion`. `buildnumber.txt` is gitignored and local to your +machine. Every upload needs a unique build number against the same +`MARKETING_VERSION`, if an upload is rejected as a duplicate, bump the +file and archive again. + +### Export compliance + +`ITSAppUsesNonExemptEncryption = false` is already in `Info.plist`, so +TestFlight won't ask on each upload. + +--- + +## 4. Tester groups + +| Group | Who | Notes | +|---|---|---| +| **Internal** | Your own devices | Up to 100 testers, no review, available in minutes | +| **External, Friends** | Invited by email | Needs Beta App Review on the first build of each version | + +External TestFlight builds go through a lighter review than App Store +submission, but the same rules apply: working URLs, accurate description, +and no placeholder content. + +--- + +## 5. Before promoting a TestFlight build to the App Store + +- [ ] Swap the 3 AdMob test IDs for real ones + (`docs/MONETIZATION_GOLIVE.md` §1) +- [ ] Populate `NSPrivacyTrackingDomains` +- [ ] Create both subscription products in App Store Connect with `en` + `es` + metadata and paywall review screenshots +- [ ] Verify the purchase loop against the real sandbox once: ads visible → + buy → ads gone → relaunch still ad-free → reinstall → Restore works +- [ ] Capture 6.9" screenshots on an iPhone 17 Pro Max +- [ ] Full checklist: `docs/APP_STORE_RELEASE_KIT.md` §9