# ClearCast Classic — build spec

A high-contrast, subscriber-respecting weather app that keeps forecast layers, wind gusts, 14-day outlooks, webcams, widgets, and offline recent forecasts visible instead of hiding them behind a redesign.

## Project context

This spec describes an independent, alternative Android app you are building from scratch to compete with an existing incumbent app on Google Play — not a modification, clone, or reskin of the incumbent's own code, assets, or branding. Use the incumbent only as a market reference (via the report data below), and design working_name/package_id/store_listing/design_system so the result is clearly its own product.

## Incumbent app

- **Name:** Weather forecast & live radar
- **Package id:** `com.meteoblue.droid`
- **Google Play:** https://play.google.com/store/apps/details?id=com.meteoblue.droid
- **appy.fyi report:** https://appy.fyi/report/com.meteoblue.droid
- **Category:** Weather

## Overview

- **Working name:** ClearCast Classic (trademark cleared: no)
- **Package id:** `fyi.appy.clearcastclassic`
- **Min / target SDK:** 26 / 35
- **Backend:** none
- **Estimated build time:** 7 weeks
- **Pricing:** subscription, $1.99 via `revenuecat`
- **Runtime AI:** none
- **Permissions:** `INTERNET`

## Non-goals (out of scope for v1)

- No proprietary weather-model infrastructure or claims of better forecast accuracy than public weather data provides.
- No user accounts, cross-device sync, social features, or cloud backup in v1.
- No full-screen, interstitial, unskippable, or tracking-partner ad SDKs in v1.
- No AI-generated forecast summaries in v1 because the report says AI does not close the core gap.
- No severe-weather alerting system beyond displaying forecast data from the selected public API.

## Tech stack

- **Language / UI:** Kotlin, Jetpack Compose
- **Kotlin:** 2.0.21
- **Compose BOM:** 2024.10.01
- **Gradle:** 8.9

| Purpose | Gradle coordinate |
| --- | --- |
| Compose Material 3 UI components and theming | `androidx.compose.material3:material3:1.3.0` |
| Compose activity integration | `androidx.activity:activity-compose:1.9.3` |
| Compose navigation graph | `androidx.navigation:navigation-compose:2.8.3` |
| Lifecycle-aware ViewModel state collection in Compose | `androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6` |
| Lifecycle runtime Compose helpers | `androidx.lifecycle:lifecycle-runtime-compose:2.8.6` |
| Local Room database runtime for favorites, caches, widget config, entitlement cache, and privacy preference | `androidx.room:room-runtime:2.6.1` |
| Room Kotlin coroutine extensions | `androidx.room:room-ktx:2.6.1` |
| Room annotation processor | `androidx.room:room-compiler:2.6.1` |
| HTTP client for weather, geocoding, RainViewer, and Overpass requests | `com.squareup.retrofit2:retrofit:2.11.0` |
| Moshi converter for Retrofit JSON parsing | `com.squareup.retrofit2:converter-moshi:2.11.0` |
| Moshi Kotlin reflection adapter | `com.squareup.moshi:moshi-kotlin:1.15.1` |
| OkHttp logging during debug API integration | `com.squareup.okhttp3:logging-interceptor:4.12.0` |
| Coroutine support on Android dispatchers | `org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0` |
| Kotlin Instant and date/time handling for forecast cache expiration and daily forecasts | `org.jetbrains.kotlinx:kotlinx-datetime:0.6.1` |
| Image loading for weather icons and webcam preview image URLs when available | `io.coil-kt:coil-compose:2.7.0` |
| OpenStreetMap MapView and XY tile overlays for radar and satellite layers | `org.osmdroid:osmdroid-android:6.1.18` |
| Home-screen weather widgets using Jetpack Glance | `androidx.glance:glance-appwidget:1.1.1` |
| Reliable scheduled widget refresh and cache warmup work | `androidx.work:work-runtime-ktx:2.9.1` |
| Subscription purchase and entitlement handling for the $1.99/month ad-free plan | `com.revenuecat.purchases:purchases:8.11.0` |

## Design system

- **Primary color:** `#006D77`
- **Background color:** `#071A24`
- **Error color:** `#D32F2F`
- **Typography:** Material 3 default type scale, no custom font; use FontWeight.Bold for current temperature, location name, and layer tab labels.
- **Launcher icon glyph:** Phosphor `cloud-sun` (regular weight)
- **Theme notes:** Default to dark high-contrast weather-console styling. Light theme is supported only when the system forces it, using the same primary hue on #FAFCFE background. Temperature colors must use a high-contrast stepped scale: <=0°C #4FC3F7, 1-10°C #00ACC1, 11-20°C #43A047, 21-30°C #F9A825, >30°C #E53935. Never use pastel temperature text on dark or light backgrounds.

## Screens

### ForecastHome
- **Route:** `forecast`
- **Purpose:** Main classic forecast dashboard for the selected favorite location, including current conditions, 48-hour hourly strip, 14-day outlook, wind gusts, and visible entry points to map layers and webcams.
- **Reached via:** app launch; tap a favorite location chip; return from LocationSearch after saving a location; tap widget forecast area
- **Key UI elements:** Top favorite-location switcher with consistent horizontal chips; Current temperature card with high-contrast temperature color; Wind row showing wind speed and wind gust side by side; 48-hour hourly forecast strip; 14-day daily forecast list; Heat-map toggle for temperature-colored hourly cells; Satellite/Radar/Webcams buttons always visible above the fold; Offline cached-data banner when showing cached data; Subscriber or free-tier status chip
- **States:** no_locations, loading_forecast, populated, offline_cached, error

### LocationSearch
- **Route:** `search`
- **Purpose:** Search and add forecast locations without requiring device location permission.
- **Reached via:** tap Add location on ForecastHome; tap Change location on WidgetConfig
- **Key UI elements:** Search text field; Results list with city, region, country, latitude, and longitude; Add favorite button per result; Empty-state text explaining no location permission is needed; Retry button on network error
- **States:** initial, searching, results, no_results, error

### LayerMap
- **Route:** `map/{locationId}`
- **Purpose:** Map screen with visible radar, satellite, wind-gust, heat-map, and webcam layer controls for the selected location.
- **Reached via:** tap Satellite button on ForecastHome; tap Radar button on ForecastHome; tap Webcams button on ForecastHome
- **Key UI elements:** OSM map centered on selected favorite; Segmented layer control: Radar, Satellite, Wind gusts, Heat, Webcams; RainViewer time slider for radar/satellite frames; Legend for active layer colors; Webcam list bottom sheet with camera title and open-link action; No layer available message when a provider returns no frames
- **States:** loading_layers, populated, no_layer_available, offline_unavailable, error

### Subscription
- **Route:** `subscription`
- **Purpose:** Purchase or manage the $1.99/month ad-free subscription and confirm that subscribers see no promotional placements.
- **Reached via:** tap free-tier status chip on ForecastHome; tap Manage subscription in Settings; tap in-house upgrade card when user is not subscribed
- **Key UI elements:** Plan card showing $1.99/month; Never full-screen ads promise text; Subscribe button; Restore purchases button; Subscribed confirmation state; Error text with retry
- **States:** loading_product, free, purchase_pending, subscribed, error

### Settings
- **Route:** `settings`
- **Purpose:** Preferences and privacy controls, including proof that the app does not use tracking-partner consent prompts.
- **Reached via:** tap settings icon on ForecastHome
- **Key UI elements:** Privacy summary stating no tracking-partner ad SDK is included; Forecast cache retention row; Clear cached forecasts button; Manage subscription row; About data sources row listing Open-Meteo, RainViewer, OpenStreetMap, and Overpass
- **States:** populated, clearing_cache, cache_cleared, error

### WidgetConfig
- **Route:** `widget-config/{appWidgetId}`
- **Purpose:** Configure a home-screen widget to use one of the saved favorite locations and refresh from cached/latest forecast data.
- **Reached via:** Android app widget configuration activity; tap widget settings affordance if provided by launcher
- **Key UI elements:** Favorite location picker; Widget preview with current temperature and gust line; Save widget button; Add location shortcut if there are no favorites; Last successful refresh explanation
- **States:** loading, no_locations, populated, saving, error

## Data model

### FavoriteLocation (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Long` | primary key, autogenerate |
| name | `String` | display name returned by geocoding API |
| admin1 | `String?` | nullable region/state from geocoding API |
| country | `String` | country name from geocoding API |
| latitude | `Double` | decimal degrees |
| longitude | `Double` | decimal degrees |
| sortOrder | `Int` | lower values appear first in the favorite chip row |
| lastViewedAt | `Instant` | updated when user selects this favorite |

### ForecastCache (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| locationId | `Long` | primary key and foreign key to FavoriteLocation.id |
| fetchedAt | `Instant` | time API response was stored |
| expiresAt | `Instant` | fetchedAt plus 60 minutes for normal forecast refresh |
| timezone | `String` | timezone string returned by forecast API |
| currentJson | `String` | raw current conditions JSON subset used by UI |
| hourlyJson | `String` | raw hourly forecast JSON for the next 48 hours plus fields needed for heat colors and gusts |
| dailyJson | `String` | raw daily forecast JSON for 14 days |
| source | `String` | constant value Open-Meteo for v1 |

### WidgetConfigEntity (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| appWidgetId | `Int` | primary key from AppWidgetManager |
| locationId | `Long` | foreign key to FavoriteLocation.id |
| displayName | `String` | copied location label for rendering fallback |
| lastSuccessfulRefreshAt | `Instant?` | nullable until first worker refresh succeeds |

### SubscriptionEntitlement (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key; use constant default |
| active | `Boolean` | true when RevenueCat customer info has active monthly entitlement |
| productId | `String?` | nullable; expected product id clearcast_monthly_199 |
| expiresAt | `Instant?` | nullable if RevenueCat does not provide an expiration date |
| lastCheckedAt | `Instant` | time entitlement was last refreshed from RevenueCat |

### PrivacyPreference (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| key | `String` | primary key; v1 uses privacy_notice_seen |
| value | `Boolean` | stored acknowledgment state |
| updatedAt | `Instant` | time preference was written |

## Features

### Searchable favorite locations with public forecast data

Lets users add named locations and view public forecast data without requiring device location permission.

- **Answers complaint:** baseline parity

- **Screens:** ForecastHome, LocationSearch

- **Estimated hours:** 50

**Implementation notes:** Use Retrofit with Open-Meteo Geocoding API GET https://geocoding-api.open-meteo.com/v1/search?name={query}&count=10&language=en&format=json. Save chosen results as FavoriteLocation rows. For each selected location call Open-Meteo Forecast API GET https://api.open-meteo.com/v1/forecast with latitude, longitude, timezone=auto, forecast_days=14, current=temperature_2m,weather_code,wind_speed_10m,wind_gusts_10m and hourly=temperature_2m,precipitation_probability,weather_code,wind_speed_10m,wind_gusts_10m,wind_direction_10m,cloud_cover,relative_humidity_2m and daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,wind_speed_10m_max,wind_gusts_10m_max,sunrise,sunset. Cache the raw parsed subsets into ForecastCache inside a Room transaction after every successful response. If the selected location has no unexpired cache, show loading until the network call completes; if the call fails but any cache exists, render offline_cached instead of error.

**Acceptance criteria:**
- Entering 'Basel' in LocationSearch sends one geocoding request and displays up to 10 results with name, country, latitude, and longitude.
- Tapping a search result inserts a FavoriteLocation and navigates to ForecastHome with that location selected.
- ForecastHome requests 14 forecast days and hourly wind_gusts_10m for the selected latitude and longitude.
- If a forecast request succeeds, ForecastCache for that location is replaced and fetchedAt is updated.
- If a forecast request fails while a previous cache exists, ForecastHome displays the cached forecast with an offline banner instead of a blank screen.

### Classic high-contrast forecast dashboard

Displays weather in a stable, glanceable layout with strong contrast and consistent favorite switching.

- **Answers complaint:** Redesigned UI, hidden features Low-contrast colors and hidden/removed features (satellite, webcams, heat maps, wind gusts)

- **Screens:** ForecastHome

- **Estimated hours:** 36

**Implementation notes:** Build ForecastHome as a single LazyColumn with the favorite chip row pinned at the top of content, followed by current conditions, wind, hourly, and 14-day sections in that fixed order. Use the design_system stepped temperature color scale for all temperature text and hourly heat cells; calculate the color from Celsius values after parsing Open-Meteo units. The favorite row must always render location chips horizontally with wind summary inside the selected forecast card, never moving wind speed left/below based on selected favorite. Use MaterialTheme colorScheme with #071A24 background and white primary text at alpha 1.0; secondary labels must not be below 0.78 alpha on the dark background.

**Acceptance criteria:**
- Current temperature contrast ratio against #071A24 is at least 4.5:1 for every temperature color used in the defined scale.
- Switching between two favorite locations does not change the relative order of current temperature, wind speed, wind gust, hourly forecast, and 14-day sections.
- Wind speed and wind gust are both visible without opening another screen.
- The 14-day section is visible on ForecastHome and contains 14 daily rows when the API returns 14 days.
- No pastel temperature palette is used; all temperature colors match the five hex values in design_system.theme_notes.

### Visible power-user weather details

Keeps satellite, webcams, heat maps, wind gusts, and 14-day forecasts exposed as first-class controls instead of burying them.

- **Answers complaint:** Keep power-user features visible by default. Satellite view, webcams, wind gusts, 14-day, heat maps — trimmed for a cleaner first run is exactly what alienated the core audience.

- **Screens:** ForecastHome, LayerMap

- **Estimated hours:** 36

**Implementation notes:** On ForecastHome, render three filled buttons labeled Satellite, Radar, and Webcams directly below the current-conditions card, plus a Heat map toggle above the hourly strip. Wind gusts come from hourly.wind_gusts_10m and daily.wind_gusts_10m_max and must be displayed in both the current wind row and each daily row. The Heat map toggle changes hourly cell backgrounds from neutral cards to the stepped temperature color scale while keeping text white or black based on luminance. The buttons navigate to LayerMap with the selected location id and preselect the corresponding layer via a saved ViewModel argument.

**Acceptance criteria:**
- Satellite, Radar, and Webcams buttons are visible on ForecastHome before the hourly list starts.
- Heat map toggle changes hourly cell background colors without hiding temperature text.
- Every daily forecast row includes max/min temperature and max wind gust.
- Tapping Satellite opens LayerMap with Satellite selected.
- Tapping Webcams opens LayerMap with the Webcams panel selected.

### Radar and satellite map layers

Shows live public radar and satellite tiles on a map centered on the selected favorite location.

- **Answers complaint:** Live radar/satellite map layer

- **Screens:** LayerMap

- **Estimated hours:** 56

**Implementation notes:** Use osmdroid MapView inside Compose AndroidView. Configure OpenStreetMap as the base layer and center the map on FavoriteLocation.latitude/longitude at zoom 7. Fetch RainViewer metadata from https://api.rainviewer.com/public/weather-maps.json. For Radar, use the latest item from radar.past plus radar.nowcast when available and create an XYTileSource URL shaped like https://tilecache.rainviewer.com/v2/radar/{time}/256/{z}/{x}/{y}/2/1_1.png. For Satellite, use the latest satellite infrared frame and URL shaped like https://tilecache.rainviewer.com/v2/satellite/{time}/256/{z}/{x}/{y}/0/0_0.png. Implement the time slider as a list of available frame timestamps from the metadata; replacing the selected frame removes the old tile overlay and inserts the new one above the base map. If metadata contains no frames for a selected layer, show no_layer_available with the map still visible.

**Acceptance criteria:**
- LayerMap loads an OSM base map centered within 0.5 degrees of the selected favorite coordinates.
- Selecting Radar adds a RainViewer radar tile overlay above the base map.
- Selecting Satellite replaces any radar overlay with a RainViewer satellite tile overlay.
- The time slider contains one tick per frame returned by RainViewer for the selected layer.
- If the RainViewer response has an empty frame list for the selected layer, the screen shows the no-layer message and does not crash.

### Nearby webcams panel

Makes webcams discoverable from the main flow using public map metadata and clear fallback behavior.

- **Answers complaint:** Redesigned UI, hidden features Low-contrast colors and hidden/removed features (satellite, webcams, heat maps, wind gusts)

- **Screens:** LayerMap

- **Estimated hours:** 14

**Implementation notes:** When Webcams is selected on LayerMap, query Overpass API POST https://overpass-api.de/api/interpreter with a bounding-box query around the selected location: [out:json][timeout:10];(node(around:50000,{lat},{lon})[webcam];node(around:50000,{lat},{lon})['camera:type'='webcam'];node(around:50000,{lat},{lon})['surveillance:type'='webcam'];);out tags center 25;. Parse each element's tags.name, tags.url, tags.website, tags.image, and coordinates. Show up to 25 results in a bottom sheet sorted by distance using the haversine formula. If image is present, load it with Coil; otherwise show a camera placeholder. The Open button launches ACTION_VIEW for url or website. If no usable URL exists, omit that row. If no rows remain, show an empty state with a browser search button opening https://www.google.com/search?q=webcams+near+{encodedLocationName}.

**Acceptance criteria:**
- Selecting Webcams sends one Overpass request using the selected favorite latitude and longitude.
- Rows without either url or website are not displayed as tappable webcam entries.
- Displayed webcam rows are sorted nearest first by haversine distance.
- Tapping Open on a webcam row fires an ACTION_VIEW intent for that row's URL.
- If Overpass returns no usable webcam URLs, the bottom sheet shows a Search web fallback button instead of an empty blank panel.

### Offline recent forecasts and reliable widgets

Keeps recently viewed forecasts usable offline and updates home-screen widgets from cached or freshly fetched data.

- **Answers complaint:** Update reliability Widgets breaking and offline access to recently viewed forecasts removed after updates.

- **Screens:** ForecastHome, WidgetConfig

- **Estimated hours:** 48

**Implementation notes:** ForecastCache is the offline source of truth. ForecastHome reads Room first; if cached data exists it renders immediately with offline_cached when network is unavailable, then refreshes in the background. Use Glance AppWidget for a compact widget showing location name, current temperature, weather code label, wind speed, and wind gust. WidgetConfig saves appWidgetId to WidgetConfigEntity. A WorkManager PeriodicWorkRequest named weather_widget_refresh runs every 3 hours with NetworkType.CONNECTED, fetches each widget location forecast using the same Open-Meteo endpoint, updates ForecastCache, then calls GlanceAppWidget.update for affected widget ids. If the worker fails to fetch, it renders the latest cache and preserves lastSuccessfulRefreshAt rather than deleting widget content.

**Acceptance criteria:**
- After viewing a forecast once, disabling network and reopening ForecastHome for that location shows cached weather with an offline banner.
- Adding a widget opens WidgetConfig and requires choosing one saved FavoriteLocation before saving.
- The widget displays temperature and wind gust after configuration when cached forecast data exists.
- The PeriodicWorkRequest is enqueued with the unique name weather_widget_refresh and ExistingPeriodicWorkPolicy.UPDATE.
- If the widget refresh network call fails, the widget keeps showing the previous cached forecast instead of an error-only or blank widget.

### Subscriber entitlement with no ads for paid users

Implements the $1.99/month subscription and ensures active subscribers never see promotional placements.

- **Answers complaint:** Never show ads to a paying subscriber. Ad-load complaints are as loud as the redesign ones, and the two combined are driving churn.

- **Screens:** ForecastHome, Subscription, Settings

- **Estimated hours:** 32

**Implementation notes:** Use RevenueCat Purchases SDK with a monthly product id clearcast_monthly_199 and an entitlement id ad_free. On app start and when Subscription opens, call Purchases.sharedInstance.getCustomerInfo; set SubscriptionEntitlement.active to true when customerInfo.entitlements['ad_free']?.isActive == true. The app must not include a third-party ad SDK. Free users may see only first-party in-app upgrade cards in ForecastHome and Subscription, never full-screen or interstitial placements. Gate every upgrade-card Composable behind entitlement.active == false. After purchase or restore succeeds, refresh customer info, update Room, and recompute UI state so upgrade cards disappear without restarting the app.

**Acceptance criteria:**
- The dependency graph contains no Google Mobile Ads SDK or consent/ad network SDK.
- When SubscriptionEntitlement.active is true, ForecastHome contains no upgrade card or ad placeholder Composable.
- After a successful purchase callback with active ad_free entitlement, the subscribed state is rendered and free-tier promotional UI disappears in the same session.
- Restore purchases calls RevenueCat restorePurchases and updates SubscriptionEntitlement from returned customer info.
- No full-screen ad, interstitial ad, or unskippable ad UI exists in any navigation route.

### No tracking-partner consent prompts

Avoids repeated data-sharing prompts by excluding tracking partners and showing only a static privacy summary.

- **Answers complaint:** Data sharing prompts Excessive tracking-partner consent prompts shown even to paying subscribers.

- **Screens:** Settings, ForecastHome

- **Estimated hours:** 8

**Implementation notes:** Do not add Google User Messaging Platform, ad-network consent SDKs, analytics SDKs, or tracking-partner libraries. On first app launch, insert PrivacyPreference privacy_notice_seen=false if absent, but do not block the user with a modal. Settings shows a static privacy summary listing the APIs used and explaining that selected coordinates are sent to weather/map providers to return forecasts and layers. When the user taps 'Got it' in Settings, set privacy_notice_seen=true. This preference never controls network access and is never shown as a repeated prompt on ForecastHome.

**Acceptance criteria:**
- App launch never displays a consent modal before ForecastHome.
- Settings displays the privacy summary in populated state.
- Tapping Got it writes PrivacyPreference key privacy_notice_seen with value true.
- Reopening the app after setting privacy_notice_seen=true does not show any privacy or data-sharing prompt on ForecastHome.
- The Gradle dependency list contains no user-messaging-platform, Firebase Analytics, or ad-network consent artifact.

## Store listing

- **Title:** ClearCast Classic
- **Short description:** High-contrast weather, visible layers, reliable widgets, no paid-user ads.
- **Category:** Weather
- **Keywords:** weather, forecast, radar, satellite, wind gusts, weather widget, offline forecast, 14 day forecast, webcams, heat map
- **Icon prompt:** Create a square Android launcher icon for a weather app named ClearCast Classic: dark navy background (#071A24), bold flat vector white cloud with a golden sun emerging behind it, subtle teal accent ring (#006D77), high-contrast, no text, no gradients, centered glyph, Material-style rounded icon composition.

**Long description:**

ClearCast Classic is a clean weather app for people who want the forecast to stay readable and predictable. It puts the essentials back up front: high-contrast temperatures, wind gusts, 14-day forecasts, radar, satellite, heat-map views, webcams, offline recent forecasts, and home-screen widgets.

The app uses public weather and map data sources, saves your favorite places locally, and avoids full-screen or unskippable ads. Subscribers get the promised ad-free experience with no promotional placements after purchase.

Built for users who prefer a classic, glanceable weather dashboard over hidden controls and low-contrast redesigns.

## Legal

- **Regulated category:** none
- **Privacy policy URL:** https://clearcastclassic.example.com/privacy (privacy claims verified: no)
- **Data collected:** Favorite location names and coordinates are stored locally on the device.; Selected forecast coordinates are sent to public weather and map APIs to retrieve forecasts and layers.; Subscription status and purchase identifiers are processed by RevenueCat for entitlement checks.

## Test plan

### 1. Low-contrast colors made temperature harder to tell at a glance. (instrumented)

1. Launch the app with a seeded ForecastCache containing hourly temperatures -5, 5, 15, 25, and 35 Celsius for one FavoriteLocation.
2. Open ForecastHome for that FavoriteLocation.
3. Read the background or text color applied to each corresponding hourly temperature cell.
4. Compute contrast ratio for each temperature label against the screen background or cell background used by that state.

**Expected:** Each temperature uses one of #4FC3F7, #00ACC1, #43A047, #F9A825, or #E53935 and the visible temperature text contrast ratio is at least 4.5:1.

### 2. Layout changes made switching between favorite locations move wind speed left or below forecast. (instrumented)

1. Seed two FavoriteLocation rows with different cached forecasts and wind values.
2. Open ForecastHome and record the semantics order of current temperature, wind speed, wind gust, hourly strip, and 14-day list.
3. Tap the second favorite chip.
4. Record the same semantics order again.

**Expected:** The section order is identical before and after switching favorites, and wind speed plus wind gust remain visible in the wind row for both locations.

### 3. Hidden/removed power-user features: satellite, webcams, heat maps, wind gusts, and 14-day forecast. (instrumented)

1. Seed one FavoriteLocation and a complete 14-day ForecastCache including wind_gusts_10m_max.
2. Open ForecastHome.
3. Query Compose nodes with text Satellite, Radar, Webcams, Heat map, Wind gust, and 14-day.
4. Tap Satellite and then navigate back; tap Webcams and navigate back; toggle Heat map.

**Expected:** All named controls or labels are present on ForecastHome, Satellite and Webcams navigate to LayerMap, and Heat map changes hourly cell colors while retaining visible temperature text.

### 4. Frequent full-screen, sometimes unskippable ads, including reports of ads still showing after paying for the ad-free subscription. (manual)

1. Install a debug build configured with a RevenueCat test customer whose ad_free entitlement is active.
2. Launch the app and browse ForecastHome, LayerMap, Settings, and Subscription for at least two minutes.
3. Switch airplane mode on and off and reopen the app.
4. Inspect every screen for interstitial, full-screen, unskippable, banner, or upgrade-card ad UI.

**Expected:** No third-party ad, full-screen ad, interstitial ad, unskippable ad, banner ad, or first-party upgrade card is shown while the ad_free entitlement is active.

### 5. Offline access to recently viewed forecasts removed after updates. (unit)

1. Create an in-memory Room database with one FavoriteLocation and one ForecastCache whose expiresAt is in the past but contains valid current, hourly, and daily JSON.
2. Configure the forecast repository fake network client to throw IOException.
3. Request the forecast for that location from the repository.
4. Inspect the returned domain result.

**Expected:** The repository returns a cached/offline result containing the stored forecast instead of an error result.

### 6. Widgets breaking after updates. (instrumented)

1. Seed one FavoriteLocation and ForecastCache.
2. Launch WidgetConfig with appWidgetId 42.
3. Select the seeded location and tap Save widget.
4. Run the weather_widget_refresh worker with a fake network failure.
5. Read the Glance widget state or rendered RemoteViews text for appWidgetId 42.

**Expected:** The widget remains configured for appWidgetId 42 and displays the cached location name, temperature, and wind gust after the failed refresh.

### 7. Excessive tracking-partner consent prompts shown even to paying subscribers. (instrumented)

1. Clear app data and launch the app fresh.
2. Observe the first rendered route.
3. Navigate to Settings and tap Got it in the privacy summary.
4. Close and relaunch the app.
5. Navigate through ForecastHome and LayerMap.

**Expected:** No modal consent prompt appears on first launch or relaunch; ForecastHome is usable immediately, and privacy_notice_seen is stored only after tapping Got it in Settings.

### 8. Baseline parity: 14-day public forecast request includes the fields needed by the UI. (unit)

1. Build the Open-Meteo forecast request for latitude 47.5596 and longitude 7.5886.
2. Inspect the generated query parameters before executing the request.

**Expected:** The request contains forecast_days=14, timezone=auto, hourly includes temperature_2m and wind_gusts_10m, daily includes temperature_2m_max, temperature_2m_min, and wind_gusts_10m_max.

## Build instructions

```sh
./gradlew clean
./gradlew testDebugUnitTest
./gradlew connectedDebugAndroidTest
keytool -genkeypair -v -keystore release-upload.jks -storepass changeit -keypass changeit -alias upload -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=ClearCast Classic Upload,O=Solo Builder,C=US"
RELEASE_STORE_FILE=$PWD/release-upload.jks RELEASE_STORE_PASSWORD=changeit RELEASE_KEY_ALIAS=upload RELEASE_KEY_PASSWORD=changeit ./gradlew bundleRelease
```

## Human gates still required

- `trademark_and_privacy_review`
- `closed_testing_recruitment`
