diff --git a/example_projects/Weather/.xcodebuildmcp/config.yaml b/example_projects/Weather/.xcodebuildmcp/config.yaml new file mode 100644 index 00000000..6663899f --- /dev/null +++ b/example_projects/Weather/.xcodebuildmcp/config.yaml @@ -0,0 +1,13 @@ +schemaVersion: 1 +enabledWorkflows: + - simulator + - ui-automation +debug: false +sentryDisabled: false +sessionDefaults: + projectPath: Weather.xcodeproj + scheme: Weather + simulatorName: iPhone 17 Pro +setupPreferences: + platforms: + - iOS diff --git a/example_projects/Weather/AGENTS.md b/example_projects/Weather/AGENTS.md new file mode 100644 index 00000000..1a9b266e --- /dev/null +++ b/example_projects/Weather/AGENTS.md @@ -0,0 +1,3 @@ +# AGENTS.md + +- If using XcodeBuildMCP, use the installed XcodeBuildMCP skill before calling XcodeBuildMCP tools. diff --git a/example_projects/Weather/README.md b/example_projects/Weather/README.md new file mode 100644 index 00000000..8becf810 --- /dev/null +++ b/example_projects/Weather/README.md @@ -0,0 +1,101 @@ +# Atmos Weather + +Atmos Weather is a native SwiftUI weather app prototype for iOS. + +## Launch with mock weather data + +Build and run the app with XcodeBuildMCP first: + +```bash +../../build/cli.js simulator build-and-run +``` + +Then relaunch the installed app with the mock API argument: + +```bash +../../build/cli.js simulator launch-app \ + --bundle-id com.sentry.weather.Weather \ + --args=--mock-weather-api +``` + +## JSON fixtures + +Fixture JSON files live in: + +```text +WeatherTests/Fixtures/ +``` + +Current fixtures: + +- `WeatherTests/Fixtures/default-locations.json` +- `WeatherTests/Fixtures/search-locations.json` +- `WeatherTests/Fixtures/weather-report-loc-current-san-francisco.json` + +## API schemas + +OpenAI-compatible API schema files live in: + +```text +Schemas/ +``` + +Current schemas: + +- `Schemas/default-locations.schema.json` +- `Schemas/search-locations.schema.json` +- `Schemas/weather-report.schema.json` + +These schemas describe the JSON response shape expected by the DTO layer. + +## Expected API endpoints + +The production client is `URLSessionWeatherAPIClient`. It currently expects a JSON API rooted at: + +```text +https://api.atmosweather.example/v1 +``` + +All endpoints are `GET` requests. + +| Purpose | Method | Path | Request shape | Schema | +| --- | --- | --- | --- | --- | +| Default saved locations | `GET` | `/locations/default` | No path params, query params, or body. | `Schemas/default-locations.schema.json` | +| Search locations | `GET` | `/locations/search` | Query string: `query=` | `Schemas/search-locations.schema.json` | +| Weather report for a location | `GET` | `/weather/{locationID}` | Path param: `locationID=` | `Schemas/weather-report.schema.json` | + +### Request examples + +Default locations: + +```http +GET /v1/locations/default +``` + +Search locations: + +```http +GET /v1/locations/search?query=San%20Francisco +``` + +Weather report: + +```http +GET /v1/weather/loc-current-san-francisco +``` + +### Response expectations + +- Responses must be JSON. +- Successful responses should use a `2xx` HTTP status code. +- Non-`2xx` responses are treated as API failures. + +## Tests + +Run the app test suite through XcodeBuildMCP: + +```bash +../../build/cli.js simulator test +``` + +UI tests inject `--mock-weather-api` themselves so they do not depend on the production API endpoint. \ No newline at end of file diff --git a/example_projects/Weather/Schemas/default-locations.schema.json b/example_projects/Weather/Schemas/default-locations.schema.json new file mode 100644 index 00000000..e733884d --- /dev/null +++ b/example_projects/Weather/Schemas/default-locations.schema.json @@ -0,0 +1,93 @@ +{ + "type": "json_schema", + "json_schema": { + "name": "default_locations_response", + "strict": true, + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "locations": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "subtitle": { + "type": "string" + }, + "country": { + "type": [ + "string", + "null" + ] + }, + "temperatureC": { + "type": "integer" + }, + "highC": { + "type": "integer" + }, + "lowC": { + "type": "integer" + }, + "condition": { + "type": "string", + "enum": [ + "sunny", + "mostly_sunny", + "partly_cloudy", + "cloudy", + "clear_day", + "clear_night", + "light_rain", + "heavy_rain", + "light_snow", + "snow_showers", + "thunderstorms", + "hazy" + ] + }, + "localTime": { + "type": "object", + "additionalProperties": false, + "properties": { + "hour": { + "type": "integer" + }, + "minute": { + "type": "integer" + } + }, + "required": [ + "hour", + "minute" + ] + } + }, + "required": [ + "id", + "name", + "subtitle", + "country", + "temperatureC", + "highC", + "lowC", + "condition", + "localTime" + ] + } + } + }, + "required": [ + "locations" + ] + } + } +} diff --git a/example_projects/Weather/Schemas/search-locations.schema.json b/example_projects/Weather/Schemas/search-locations.schema.json new file mode 100644 index 00000000..9a7c5c21 --- /dev/null +++ b/example_projects/Weather/Schemas/search-locations.schema.json @@ -0,0 +1,93 @@ +{ + "type": "json_schema", + "json_schema": { + "name": "search_locations_response", + "strict": true, + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "locations": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "subtitle": { + "type": "string" + }, + "country": { + "type": [ + "string", + "null" + ] + }, + "temperatureC": { + "type": "integer" + }, + "highC": { + "type": "integer" + }, + "lowC": { + "type": "integer" + }, + "condition": { + "type": "string", + "enum": [ + "sunny", + "mostly_sunny", + "partly_cloudy", + "cloudy", + "clear_day", + "clear_night", + "light_rain", + "heavy_rain", + "light_snow", + "snow_showers", + "thunderstorms", + "hazy" + ] + }, + "localTime": { + "type": "object", + "additionalProperties": false, + "properties": { + "hour": { + "type": "integer" + }, + "minute": { + "type": "integer" + } + }, + "required": [ + "hour", + "minute" + ] + } + }, + "required": [ + "id", + "name", + "subtitle", + "country", + "temperatureC", + "highC", + "lowC", + "condition", + "localTime" + ] + } + } + }, + "required": [ + "locations" + ] + } + } +} diff --git a/example_projects/Weather/Schemas/weather-report.schema.json b/example_projects/Weather/Schemas/weather-report.schema.json new file mode 100644 index 00000000..1eb15117 --- /dev/null +++ b/example_projects/Weather/Schemas/weather-report.schema.json @@ -0,0 +1,518 @@ +{ + "type": "json_schema", + "json_schema": { + "name": "weather_report_response", + "strict": true, + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "current": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "temperatureC": { + "type": "integer" + }, + "highC": { + "type": "integer" + }, + "lowC": { + "type": "integer" + }, + "feelsLikeC": { + "type": "integer" + }, + "dewPointC": { + "type": "integer" + }, + "condition": { + "type": "string", + "enum": [ + "sunny", + "mostly_sunny", + "partly_cloudy", + "cloudy", + "clear_day", + "clear_night", + "light_rain", + "heavy_rain", + "light_snow", + "snow_showers", + "thunderstorms", + "hazy" + ] + }, + "solarProgress": { + "type": "object", + "additionalProperties": false, + "properties": { + "kind": { + "type": "string", + "enum": [ + "before_sunrise", + "daylight", + "after_sunset" + ] + }, + "daylightFraction": { + "type": [ + "number", + "null" + ] + } + }, + "required": [ + "kind", + "daylightFraction" + ] + }, + "sunrise": { + "type": "object", + "additionalProperties": false, + "properties": { + "hour": { + "type": "integer" + }, + "minute": { + "type": "integer" + } + }, + "required": [ + "hour", + "minute" + ] + }, + "sunset": { + "type": "object", + "additionalProperties": false, + "properties": { + "hour": { + "type": "integer" + }, + "minute": { + "type": "integer" + } + }, + "required": [ + "hour", + "minute" + ] + }, + "airQualityIndex": { + "type": "integer" + }, + "airQualityCategory": { + "type": "string", + "enum": [ + "good", + "moderate", + "unhealthy_for_sensitive_groups", + "unhealthy", + "very_unhealthy", + "hazardous" + ] + }, + "uvIndex": { + "type": "integer" + }, + "uvCategory": { + "type": "string", + "enum": [ + "none", + "low", + "moderate", + "high", + "very_high", + "extreme" + ] + }, + "windKph": { + "type": "integer" + }, + "windDirectionDegrees": { + "type": "number" + }, + "humidity": { + "type": "integer" + }, + "visibilityKilometers": { + "type": "number" + }, + "pressureMillibars": { + "type": "integer" + }, + "pressureTrend": { + "type": "string", + "enum": [ + "rising", + "steady", + "falling" + ] + }, + "precipChance": { + "type": "integer" + } + }, + "required": [ + "id", + "temperatureC", + "highC", + "lowC", + "feelsLikeC", + "dewPointC", + "condition", + "solarProgress", + "sunrise", + "sunset", + "airQualityIndex", + "airQualityCategory", + "uvIndex", + "uvCategory", + "windKph", + "windDirectionDegrees", + "humidity", + "visibilityKilometers", + "pressureMillibars", + "pressureTrend", + "precipChance" + ] + }, + "hourly": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "hour": { + "type": "object", + "additionalProperties": false, + "properties": { + "kind": { + "type": "string", + "enum": [ + "current", + "clock" + ] + }, + "hour": { + "type": [ + "integer", + "null" + ] + }, + "minute": { + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "kind", + "hour", + "minute" + ] + }, + "temperatureC": { + "type": "integer" + }, + "condition": { + "type": "string", + "enum": [ + "sunny", + "mostly_sunny", + "partly_cloudy", + "cloudy", + "clear_day", + "clear_night", + "light_rain", + "heavy_rain", + "light_snow", + "snow_showers", + "thunderstorms", + "hazy" + ] + } + }, + "required": [ + "id", + "hour", + "temperatureC", + "condition" + ] + } + }, + "daily": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "day": { + "type": "object", + "additionalProperties": false, + "properties": { + "kind": { + "type": "string", + "enum": [ + "today", + "weekday" + ] + }, + "weekdayRawValue": { + "type": [ + "integer", + "null" + ], + "enum": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + null + ] + } + }, + "required": [ + "kind", + "weekdayRawValue" + ] + }, + "condition": { + "type": "string", + "enum": [ + "sunny", + "mostly_sunny", + "partly_cloudy", + "cloudy", + "clear_day", + "clear_night", + "light_rain", + "heavy_rain", + "light_snow", + "snow_showers", + "thunderstorms", + "hazy" + ] + }, + "lowC": { + "type": "integer" + }, + "highC": { + "type": "integer" + }, + "weekLowC": { + "type": "integer" + }, + "weekHighC": { + "type": "integer" + } + }, + "required": [ + "id", + "day", + "condition", + "lowC", + "highC", + "weekLowC", + "weekHighC" + ] + } + }, + "precipitationDetailCurrent": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "temperatureC": { + "type": "integer" + }, + "highC": { + "type": "integer" + }, + "lowC": { + "type": "integer" + }, + "feelsLikeC": { + "type": "integer" + }, + "dewPointC": { + "type": "integer" + }, + "condition": { + "type": "string", + "enum": [ + "sunny", + "mostly_sunny", + "partly_cloudy", + "cloudy", + "clear_day", + "clear_night", + "light_rain", + "heavy_rain", + "light_snow", + "snow_showers", + "thunderstorms", + "hazy" + ] + }, + "solarProgress": { + "type": "object", + "additionalProperties": false, + "properties": { + "kind": { + "type": "string", + "enum": [ + "before_sunrise", + "daylight", + "after_sunset" + ] + }, + "daylightFraction": { + "type": [ + "number", + "null" + ] + } + }, + "required": [ + "kind", + "daylightFraction" + ] + }, + "sunrise": { + "type": "object", + "additionalProperties": false, + "properties": { + "hour": { + "type": "integer" + }, + "minute": { + "type": "integer" + } + }, + "required": [ + "hour", + "minute" + ] + }, + "sunset": { + "type": "object", + "additionalProperties": false, + "properties": { + "hour": { + "type": "integer" + }, + "minute": { + "type": "integer" + } + }, + "required": [ + "hour", + "minute" + ] + }, + "airQualityIndex": { + "type": "integer" + }, + "airQualityCategory": { + "type": "string", + "enum": [ + "good", + "moderate", + "unhealthy_for_sensitive_groups", + "unhealthy", + "very_unhealthy", + "hazardous" + ] + }, + "uvIndex": { + "type": "integer" + }, + "uvCategory": { + "type": "string", + "enum": [ + "none", + "low", + "moderate", + "high", + "very_high", + "extreme" + ] + }, + "windKph": { + "type": "integer" + }, + "windDirectionDegrees": { + "type": "number" + }, + "humidity": { + "type": "integer" + }, + "visibilityKilometers": { + "type": "number" + }, + "pressureMillibars": { + "type": "integer" + }, + "pressureTrend": { + "type": "string", + "enum": [ + "rising", + "steady", + "falling" + ] + }, + "precipChance": { + "type": "integer" + } + }, + "required": [ + "id", + "temperatureC", + "highC", + "lowC", + "feelsLikeC", + "dewPointC", + "condition", + "solarProgress", + "sunrise", + "sunset", + "airQualityIndex", + "airQualityCategory", + "uvIndex", + "uvCategory", + "windKph", + "windDirectionDegrees", + "humidity", + "visibilityKilometers", + "pressureMillibars", + "pressureTrend", + "precipChance" + ] + } + }, + "required": [ + "current", + "hourly", + "daily", + "precipitationDetailCurrent" + ] + } + } +} diff --git a/example_projects/Weather/Weather.xcodeproj/project.pbxproj b/example_projects/Weather/Weather.xcodeproj/project.pbxproj new file mode 100644 index 00000000..3a05234d --- /dev/null +++ b/example_projects/Weather/Weather.xcodeproj/project.pbxproj @@ -0,0 +1,622 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXContainerItemProxy section */ + 8B92914F2FA3FCC400B2E371 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 8B9291392FA3FCC300B2E371 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 8B9291402FA3FCC300B2E371; + remoteInfo = Weather; + }; + 8B9291592FA3FCC400B2E371 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 8B9291392FA3FCC300B2E371 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 8B9291402FA3FCC300B2E371; + remoteInfo = Weather; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 8B9291412FA3FCC300B2E371 /* Weather.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Weather.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 8B92914E2FA3FCC400B2E371 /* WeatherTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = WeatherTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 8B9291582FA3FCC400B2E371 /* WeatherUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = WeatherUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 8B9291432FA3FCC300B2E371 /* Weather */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Weather; + sourceTree = ""; + }; + 8B9291512FA3FCC400B2E371 /* WeatherTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = WeatherTests; + sourceTree = ""; + }; + 8B92915B2FA3FCC400B2E371 /* WeatherUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = WeatherUITests; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 8B92913E2FA3FCC300B2E371 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 8B92914B2FA3FCC400B2E371 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 8B9291552FA3FCC400B2E371 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 8B9291382FA3FCC300B2E371 = { + isa = PBXGroup; + children = ( + 8B9291432FA3FCC300B2E371 /* Weather */, + 8B9291512FA3FCC400B2E371 /* WeatherTests */, + 8B92915B2FA3FCC400B2E371 /* WeatherUITests */, + 8B9291422FA3FCC300B2E371 /* Products */, + ); + sourceTree = ""; + }; + 8B9291422FA3FCC300B2E371 /* Products */ = { + isa = PBXGroup; + children = ( + 8B9291412FA3FCC300B2E371 /* Weather.app */, + 8B92914E2FA3FCC400B2E371 /* WeatherTests.xctest */, + 8B9291582FA3FCC400B2E371 /* WeatherUITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 8B9291402FA3FCC300B2E371 /* Weather */ = { + isa = PBXNativeTarget; + buildConfigurationList = 8B9291622FA3FCC400B2E371 /* Build configuration list for PBXNativeTarget "Weather" */; + buildPhases = ( + 8B92913D2FA3FCC300B2E371 /* Sources */, + 8B92913E2FA3FCC300B2E371 /* Frameworks */, + 8B92913F2FA3FCC300B2E371 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 8B9291432FA3FCC300B2E371 /* Weather */, + ); + name = Weather; + packageProductDependencies = ( + ); + productName = Weather; + productReference = 8B9291412FA3FCC300B2E371 /* Weather.app */; + productType = "com.apple.product-type.application"; + }; + 8B92914D2FA3FCC400B2E371 /* WeatherTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 8B9291652FA3FCC400B2E371 /* Build configuration list for PBXNativeTarget "WeatherTests" */; + buildPhases = ( + 8B92914A2FA3FCC400B2E371 /* Sources */, + 8B92914B2FA3FCC400B2E371 /* Frameworks */, + 8B92914C2FA3FCC400B2E371 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 8B9291502FA3FCC400B2E371 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 8B9291512FA3FCC400B2E371 /* WeatherTests */, + ); + name = WeatherTests; + packageProductDependencies = ( + ); + productName = WeatherTests; + productReference = 8B92914E2FA3FCC400B2E371 /* WeatherTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 8B9291572FA3FCC400B2E371 /* WeatherUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 8B9291682FA3FCC400B2E371 /* Build configuration list for PBXNativeTarget "WeatherUITests" */; + buildPhases = ( + 8B9291542FA3FCC400B2E371 /* Sources */, + 8B9291552FA3FCC400B2E371 /* Frameworks */, + 8B9291562FA3FCC400B2E371 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 8B92915A2FA3FCC400B2E371 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 8B92915B2FA3FCC400B2E371 /* WeatherUITests */, + ); + name = WeatherUITests; + packageProductDependencies = ( + ); + productName = WeatherUITests; + productReference = 8B9291582FA3FCC400B2E371 /* WeatherUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 8B9291392FA3FCC300B2E371 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2640; + LastUpgradeCheck = 2640; + TargetAttributes = { + 8B9291402FA3FCC300B2E371 = { + CreatedOnToolsVersion = 26.4; + }; + 8B92914D2FA3FCC400B2E371 = { + CreatedOnToolsVersion = 26.4; + TestTargetID = 8B9291402FA3FCC300B2E371; + }; + 8B9291572FA3FCC400B2E371 = { + CreatedOnToolsVersion = 26.4; + TestTargetID = 8B9291402FA3FCC300B2E371; + }; + }; + }; + buildConfigurationList = 8B92913C2FA3FCC300B2E371 /* Build configuration list for PBXProject "Weather" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 8B9291382FA3FCC300B2E371; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 8B9291422FA3FCC300B2E371 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 8B9291402FA3FCC300B2E371 /* Weather */, + 8B92914D2FA3FCC400B2E371 /* WeatherTests */, + 8B9291572FA3FCC400B2E371 /* WeatherUITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 8B92913F2FA3FCC300B2E371 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 8B92914C2FA3FCC400B2E371 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 8B9291562FA3FCC400B2E371 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 8B92913D2FA3FCC300B2E371 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 8B92914A2FA3FCC400B2E371 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 8B9291542FA3FCC400B2E371 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 8B9291502FA3FCC400B2E371 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 8B9291402FA3FCC300B2E371 /* Weather */; + targetProxy = 8B92914F2FA3FCC400B2E371 /* PBXContainerItemProxy */; + }; + 8B92915A2FA3FCC400B2E371 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 8B9291402FA3FCC300B2E371 /* Weather */; + targetProxy = 8B9291592FA3FCC400B2E371 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 8B9291602FA3FCC400B2E371 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 8B9291612FA3FCC400B2E371 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_COMPILATION_MODE = wholemodule; + }; + name = Release; + }; + 8B9291632FA3FCC400B2E371 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; + ENABLE_APP_SANDBOX = YES; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SELECTED_FILES = readonly; + GENERATE_INFOPLIST_FILE = YES; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; + "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 26.4; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; + "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 26.3; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.sentry.weather.Weather; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + SDKROOT = auto; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + XROS_DEPLOYMENT_TARGET = 26.4; + }; + name = Debug; + }; + 8B9291642FA3FCC400B2E371 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; + ENABLE_APP_SANDBOX = YES; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SELECTED_FILES = readonly; + GENERATE_INFOPLIST_FILE = YES; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; + "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 26.4; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; + "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 26.3; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.sentry.weather.Weather; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + SDKROOT = auto; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + XROS_DEPLOYMENT_TARGET = 26.4; + }; + name = Release; + }; + 8B9291662FA3FCC400B2E371 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.4; + MACOSX_DEPLOYMENT_TARGET = 26.3; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.sentry.weather.WeatherTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Weather.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Weather"; + XROS_DEPLOYMENT_TARGET = 26.4; + }; + name = Debug; + }; + 8B9291672FA3FCC400B2E371 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.4; + MACOSX_DEPLOYMENT_TARGET = 26.3; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.sentry.weather.WeatherTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Weather.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Weather"; + XROS_DEPLOYMENT_TARGET = 26.4; + }; + name = Release; + }; + 8B9291692FA3FCC400B2E371 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.4; + MACOSX_DEPLOYMENT_TARGET = 26.3; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.sentry.weather.WeatherUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + TEST_TARGET_NAME = Weather; + XROS_DEPLOYMENT_TARGET = 26.4; + }; + name = Debug; + }; + 8B92916A2FA3FCC400B2E371 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.4; + MACOSX_DEPLOYMENT_TARGET = 26.3; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.sentry.weather.WeatherUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + TEST_TARGET_NAME = Weather; + XROS_DEPLOYMENT_TARGET = 26.4; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 8B92913C2FA3FCC300B2E371 /* Build configuration list for PBXProject "Weather" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 8B9291602FA3FCC400B2E371 /* Debug */, + 8B9291612FA3FCC400B2E371 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 8B9291622FA3FCC400B2E371 /* Build configuration list for PBXNativeTarget "Weather" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 8B9291632FA3FCC400B2E371 /* Debug */, + 8B9291642FA3FCC400B2E371 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 8B9291652FA3FCC400B2E371 /* Build configuration list for PBXNativeTarget "WeatherTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 8B9291662FA3FCC400B2E371 /* Debug */, + 8B9291672FA3FCC400B2E371 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 8B9291682FA3FCC400B2E371 /* Build configuration list for PBXNativeTarget "WeatherUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 8B9291692FA3FCC400B2E371 /* Debug */, + 8B92916A2FA3FCC400B2E371 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 8B9291392FA3FCC300B2E371 /* Project object */; +} diff --git a/example_projects/Weather/Weather.xcodeproj/xcshareddata/xcschemes/Weather.xcscheme b/example_projects/Weather/Weather.xcodeproj/xcshareddata/xcschemes/Weather.xcscheme new file mode 100644 index 00000000..42209977 --- /dev/null +++ b/example_projects/Weather/Weather.xcodeproj/xcshareddata/xcschemes/Weather.xcscheme @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example_projects/Weather/Weather/Assets.xcassets/AccentColor.colorset/Contents.json b/example_projects/Weather/Weather/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/example_projects/Weather/Weather/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/example_projects/Weather/Weather/Assets.xcassets/AppIcon.appiconset/Contents.json b/example_projects/Weather/Weather/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..ffdfe150 --- /dev/null +++ b/example_projects/Weather/Weather/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,85 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/example_projects/Weather/Weather/Assets.xcassets/Contents.json b/example_projects/Weather/Weather/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/example_projects/Weather/Weather/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/example_projects/Weather/Weather/ContentView.swift b/example_projects/Weather/Weather/ContentView.swift new file mode 100644 index 00000000..33fa02da --- /dev/null +++ b/example_projects/Weather/Weather/ContentView.swift @@ -0,0 +1,218 @@ +// +// ContentView.swift +// Weather +// +// Created by Cameron on 30/04/2026. +// + +import SwiftUI + +enum WeatherSheet: Identifiable { + case locations + case settings + case precipitation(current: CurrentWeather, rainyCurrent: CurrentWeather) + + var id: String { + switch self { + case .locations: "locations" + case .settings: "settings" + case .precipitation: "precipitation" + } + } + + var detents: Set { + switch self { + case .locations: [.medium, .large] + case .settings: [.fraction(0.62), .large] + case .precipitation: [.large] + } + } +} + +struct ContentView: View { + private let weatherService: WeatherService + + @State private var selectedLocation: WeatherLocation? + @State private var report: WeatherReport? + @State private var activeSheet: WeatherSheet? + @State private var units: WeatherUnits + @State private var savedLocations: [WeatherLocation] + @State private var isLoadingWeather = false + @State private var weatherErrorMessage: String? + + init(weatherService: WeatherService = .production) { + self.weatherService = weatherService + _units = State(initialValue: WeatherUnits()) + _savedLocations = State(initialValue: []) + } + + var body: some View { + ZStack { + if let report, let selectedLocation { + AtmosWeatherScreen( + locationName: selectedLocation.name, + locationSubtitle: selectedLocation.subtitle, + current: report.current, + hourly: report.hourly, + daily: report.daily, + units: units, + onOpenLocations: openLocations, + onOpenSettings: openSettings, + onOpenPrecipitation: openPrecipitation + ) + } else { + WeatherLoadingScreen() + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay(alignment: .top) { + WeatherLoadingBanner(isLoading: isLoadingWeather && report != nil, message: weatherErrorMessage) + .padding(.top, 92) + } + .animation(.easeInOut(duration: 0.18), value: isLoadingWeather) + .sheet(item: $activeSheet) { sheet in + presentedSheet(sheet) + .presentationDetents(sheet.detents) + .presentationDragIndicator(.visible) + .presentationCornerRadius(36) + } + .task { + await loadDefaultLocations() + } + .task(id: selectedLocation?.id) { + await loadSelectedWeather() + } + .environment(\.atmosReduceTransparency, units.reduceTransparency) + } + + @ViewBuilder + private func presentedSheet(_ sheet: WeatherSheet) -> some View { + switch sheet { + case .locations: + LocationPickerView( + savedLocations: $savedLocations, + units: units, + weatherService: weatherService, + onSelectSaved: selectLocation, + onPreviewSearchResult: previewLocation + ) + case .settings: + SettingsSheetView(units: $units) + case let .precipitation(current, rainyCurrent): + PrecipitationDetailView( + current: current, + rainyCurrent: rainyCurrent, + units: units + ) + } + } + + private func openLocations() { + activeSheet = .locations + } + + private func openSettings() { + activeSheet = .settings + } + + private func openPrecipitation() { + guard let report else { return } + activeSheet = .precipitation(current: report.current, rainyCurrent: report.precipitationDetailCurrent) + } + + private func selectLocation(_ location: WeatherLocation) { + selectedLocation = location + } + + private func previewLocation(_ location: WeatherLocation) { + selectedLocation = location + } + + private func loadDefaultLocations() async { + guard savedLocations.isEmpty else { return } + + do { + let locations = try await weatherService.defaultLocations() + savedLocations = locations + selectedLocation = selectedLocation ?? locations.first + } catch is CancellationError { + } catch { + weatherErrorMessage = "Locations unavailable" + } + } + + private func loadSelectedWeather() async { + guard let selectedLocation else { return } + await loadWeather(for: selectedLocation.id) + } + + private func loadWeather(for locationID: WeatherLocation.ID) async { + isLoadingWeather = true + weatherErrorMessage = nil + defer { + if selectedLocation?.id == locationID { + isLoadingWeather = false + } + } + + do { + let loadedReport = try await weatherService.weather(for: locationID) + guard selectedLocation?.id == locationID else { return } + report = loadedReport + } catch is CancellationError { + } catch { + guard selectedLocation?.id == locationID else { return } + weatherErrorMessage = "Weather unavailable" + } + } +} + +private struct WeatherLoadingScreen: View { + var body: some View { + ZStack { + LinearGradient( + colors: [Color(hex: "#FFD89B"), Color(hex: "#7B6FD9"), Color(hex: "#1F2D6F")], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .ignoresSafeArea() + + VStack(spacing: 14) { + ProgressView() + .tint(.white) + Text("Loading weather…") + .font(.system(size: 15, weight: .semibold)) + } + .foregroundStyle(.white) + } + } +} + +private struct WeatherLoadingBanner: View { + let isLoading: Bool + let message: String? + + var body: some View { + HStack(spacing: 8) { + if isLoading { + ProgressView() + .controlSize(.small) + Text("Updating weather…") + } else if let message { + Image(systemName: "exclamationmark.triangle.fill") + Text(message) + } + } + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(.black.opacity(isLoading || message != nil ? 0.22 : 0), in: Capsule()) + .opacity(isLoading || message != nil ? 1 : 0) + .allowsHitTesting(false) + } +} + +#Preview { + ContentView(weatherService: .mock) +} diff --git a/example_projects/Weather/Weather/Models/WeatherModels.swift b/example_projects/Weather/Weather/Models/WeatherModels.swift new file mode 100644 index 00000000..c6189f92 --- /dev/null +++ b/example_projects/Weather/Weather/Models/WeatherModels.swift @@ -0,0 +1,210 @@ +import Foundation + +/// Predominant weather reported by the data source; UI labels and symbols are derived in the view layer. +enum WeatherCondition: String, CaseIterable, Sendable { + case sunny + case mostlySunny = "mostly_sunny" + case partlyCloudy = "partly_cloudy" + case cloudy + case clearDay = "clear_day" + case clearNight = "clear_night" + case lightRain = "light_rain" + case heavyRain = "heavy_rain" + case lightSnow = "light_snow" + case snowShowers = "snow_showers" + case thunderstorms + case hazy +} + +/// Air quality bucket paired with the numeric AQI value. +enum AirQualityCategory: String, CaseIterable, Sendable { + case good + case moderate + case unhealthyForSensitiveGroups = "unhealthy_for_sensitive_groups" + case unhealthy + case veryUnhealthy = "very_unhealthy" + case hazardous +} + +/// UV exposure bucket paired with the numeric UV index. +enum UVIndexCategory: String, CaseIterable, Sendable { + case none + case low + case moderate + case high + case veryHigh = "very_high" + case extreme +} + +/// Direction of pressure movement over the recent observation window. +enum PressureTrend: String, CaseIterable, Sendable { + case rising + case steady + case falling +} + +/// Wind bearing in meteorological degrees, normalized to the 0..<360 range. +struct WindDirection: Equatable, Hashable, Sendable { + let degrees: Double + + init(degrees: Double) { + precondition(degrees >= 0 && degrees < 360, "wind direction degrees must be in the 0..<360 range") + self.degrees = degrees + } +} + +/// Local civil time for a weather location, stored in 24-hour time without presentation formatting. +struct LocalClockTime: Equatable, Hashable, Sendable { + let hour: Int + let minute: Int + + init(hour: Int, minute: Int = 0) { + precondition((0...23).contains(hour), "hour must be in the 0...23 range") + precondition((0...59).contains(minute), "minute must be in the 0...59 range") + self.hour = hour + self.minute = minute + } +} + +/// Relative or local-clock slot for an hourly forecast. +enum ForecastHour: Equatable, Hashable, Sendable { + case current + case clock(LocalClockTime) +} + +enum Weekday: Int, CaseIterable, Sendable { + case sunday = 1 + case monday + case tuesday + case wednesday + case thursday + case friday + case saturday +} + +/// Day identity for a daily forecast independent of display labels. +enum ForecastDay: Equatable, Hashable, Sendable { + case today + case weekday(Weekday) +} + +/// Position in the local solar day; daylight fractions are normalized to 0...1 from sunrise to sunset. +enum SolarDayProgress: Equatable, Sendable { + case beforeSunrise + case daylight(fraction: Double) + case afterSunset + + static func daylightFraction(_ fraction: Double) -> SolarDayProgress { + precondition((0...1).contains(fraction), "daylight fraction must be normalized to the 0...1 range") + return .daylight(fraction: fraction) + } +} + +struct CurrentWeather: Equatable, Identifiable, Sendable { + let id: String + let temperatureC: Int + let highC: Int + let lowC: Int + let feelsLikeC: Int + let dewPointC: Int + let condition: WeatherCondition + let solarProgress: SolarDayProgress + let sunrise: LocalClockTime + let sunset: LocalClockTime + let airQualityIndex: Int + let airQualityCategory: AirQualityCategory + let uvIndex: Int + let uvCategory: UVIndexCategory + let windKph: Int + let windDirection: WindDirection + let humidity: Int + let visibilityKilometers: Double + let pressureMillibars: Int + let pressureTrend: PressureTrend + let precipChance: Int +} + +struct HourlyForecast: Equatable, Identifiable, Sendable { + let id: String + let hour: ForecastHour + let temperatureC: Int + let condition: WeatherCondition +} + +struct DailyForecast: Equatable, Identifiable, Sendable { + let id: String + let day: ForecastDay + let condition: WeatherCondition + let lowC: Int + let highC: Int + let weekLowC: Int + let weekHighC: Int +} + +struct WeatherLocation: Identifiable, Equatable, Sendable { + let id: String + let name: String + let subtitle: String + let country: String? + let temperatureC: Int + let highC: Int + let lowC: Int + let condition: WeatherCondition + let localTime: LocalClockTime +} + +struct WeatherReport: Equatable, Sendable { + let current: CurrentWeather + let hourly: [HourlyForecast] + let daily: [DailyForecast] + let precipitationDetailCurrent: CurrentWeather +} + +enum TemperatureUnit: String, CaseIterable, Identifiable, Sendable { + case fahrenheit + case celsius + + var id: String { rawValue } + var label: String { self == .fahrenheit ? "°F" : "°C" } +} + +enum WindUnit: String, CaseIterable, Identifiable, Sendable { + case mph + case kmh + case metersPerSecond + + var id: String { rawValue } + var label: String { + switch self { + case .mph: "mph" + case .kmh: "km/h" + case .metersPerSecond: "m/s" + } + } +} + +enum PressureUnit: String, CaseIterable, Identifiable, Sendable { + case millibars + case inchesMercury + + var id: String { rawValue } + var label: String { self == .millibars ? "mb" : "inHg" } +} + +enum DistanceUnit: String, CaseIterable, Identifiable, Sendable { + case miles + case kilometers + + var id: String { rawValue } + var label: String { self == .miles ? "mi" : "km" } +} + +struct WeatherUnits: Equatable, Sendable { + var temperature: TemperatureUnit = .fahrenheit + var wind: WindUnit = .mph + var pressure: PressureUnit = .millibars + var distance: DistanceUnit = .miles + var animationsEnabled = true + var alertsEnabled = true + var reduceTransparency = false +} diff --git a/example_projects/Weather/Weather/Services/MockWeatherAPIClient.swift b/example_projects/Weather/Weather/Services/MockWeatherAPIClient.swift new file mode 100644 index 00000000..6217871d --- /dev/null +++ b/example_projects/Weather/Weather/Services/MockWeatherAPIClient.swift @@ -0,0 +1,42 @@ +import Foundation + +struct MockWeatherAPIClient: WeatherAPIClient, Sendable { + private let fixtures: MockWeatherDTOFixtures + + init(fixtures: MockWeatherDTOFixtures = MockWeatherDTOFixtures()) { + self.fixtures = fixtures + } + + func defaultLocations() async throws -> [WeatherLocationDTO] { + fixtures.locations + } + + func weather(for locationID: WeatherLocation.ID) async throws -> WeatherReportDTO { + guard let scenario = fixtures.scenarioByLocationID[locationID] else { + throw MockWeatherAPIClientError.unknownLocation + } + + return WeatherReportDTO( + current: CurrentWeatherDTO.mock(for: scenario), + hourly: HourlyForecastDTO.mockForecast(for: scenario), + daily: DailyForecastDTO.mockForecast(for: scenario), + precipitationDetailCurrent: CurrentWeatherDTO.mock(for: .rainy) + ) + } + + func searchLocations(matching query: String) async throws -> [WeatherLocationDTO] { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return [] } + + let needle = trimmed.localizedLowercase + return fixtures.searchPool.filter { location in + location.name.localizedLowercase.contains(needle) + || location.subtitle.localizedLowercase.contains(needle) + || (location.country?.localizedLowercase.contains(needle) ?? false) + } + } +} + +private enum MockWeatherAPIClientError: Error { + case unknownLocation +} diff --git a/example_projects/Weather/Weather/Services/MockWeatherDTOFactories.swift b/example_projects/Weather/Weather/Services/MockWeatherDTOFactories.swift new file mode 100644 index 00000000..3093096d --- /dev/null +++ b/example_projects/Weather/Weather/Services/MockWeatherDTOFactories.swift @@ -0,0 +1,247 @@ +import Foundation + +enum MockWeatherScenario: Sendable { + case clearDay + case rainy + case snowy + case night + case stormy +} + +struct MockWeatherDTOFixtures: Sendable { + let locations: [WeatherLocationDTO] + let searchPool: [WeatherLocationDTO] + let scenarioByLocationID: [WeatherLocation.ID: MockWeatherScenario] + + init() { + locations = [ + .mock(id: "loc-current-san-francisco", name: "San Francisco", subtitle: "Current Location", country: nil, temperature: 18, high: 20, low: 12, condition: .mostlySunny, time: clock(13, 24)), + .mock(id: "loc-us-or-portland", name: "Portland", subtitle: "Oregon, USA", country: nil, temperature: 11, high: 13, low: 9, condition: .lightRain, time: clock(13, 24)), + .mock(id: "loc-us-co-aspen", name: "Aspen", subtitle: "Colorado, USA", country: nil, temperature: -4, high: -2, low: -10, condition: .lightSnow, time: clock(14, 24)), + .mock(id: "loc-is-reykjavik", name: "Reykjavík", subtitle: "Iceland", country: nil, temperature: 3, high: 6, low: 1, condition: .clearNight, time: clock(20, 24)), + .mock(id: "loc-us-la-new-orleans", name: "New Orleans", subtitle: "Louisiana, USA", country: nil, temperature: 22, high: 26, low: 20, condition: .thunderstorms, time: clock(15, 24)), + .mock(id: "loc-jp-tokyo", name: "Tokyo", subtitle: "Japan", country: nil, temperature: 14, high: 17, low: 11, condition: .partlyCloudy, time: clock(5, 24)), + .mock(id: "loc-pt-lisbon", name: "Lisbon", subtitle: "Portugal", country: nil, temperature: 19, high: 22, low: 14, condition: .sunny, time: clock(21, 24)), + ] + + searchPool = [ + .mock(id: "loc-fr-paris", name: "Paris", subtitle: "Île-de-France, France", country: "FR", temperature: 15, high: 18, low: 11, condition: .partlyCloudy, time: clock(22, 24)), + .mock(id: "loc-gb-london", name: "London", subtitle: "England, United Kingdom", country: "GB", temperature: 13, high: 16, low: 9, condition: .lightRain, time: clock(21, 24)), + .mock(id: "loc-de-berlin", name: "Berlin", subtitle: "Germany", country: "DE", temperature: 11, high: 14, low: 7, condition: .cloudy, time: clock(22, 24)), + .mock(id: "loc-us-ny-new-york", name: "New York", subtitle: "New York, USA", country: "US", temperature: 16, high: 19, low: 12, condition: .sunny, time: clock(16, 24)), + .mock(id: "loc-au-sydney", name: "Sydney", subtitle: "New South Wales, Australia", country: "AU", temperature: 22, high: 25, low: 18, condition: .sunny, time: clock(6, 24)), + .mock(id: "loc-sg-singapore", name: "Singapore", subtitle: "Singapore", country: "SG", temperature: 29, high: 31, low: 26, condition: .thunderstorms, time: clock(4, 24)), + .mock(id: "loc-in-mumbai", name: "Mumbai", subtitle: "Maharashtra, India", country: "IN", temperature: 31, high: 33, low: 26, condition: .hazy, time: clock(1, 54)), + .mock(id: "loc-eg-cairo", name: "Cairo", subtitle: "Egypt", country: "EG", temperature: 28, high: 32, low: 19, condition: .sunny, time: clock(23, 24)), + .mock(id: "loc-za-cape-town", name: "Cape Town", subtitle: "Western Cape, South Africa", country: "ZA", temperature: 20, high: 23, low: 14, condition: .mostlySunny, time: clock(23, 24)), + .mock(id: "loc-is-capital-reykjavik", name: "Reykjavík", subtitle: "Capital Region, Iceland", country: "IS", temperature: 3, high: 6, low: 1, condition: .clearNight, time: clock(20, 24)), + .mock(id: "loc-no-oslo", name: "Oslo", subtitle: "Norway", country: "NO", temperature: 5, high: 8, low: 1, condition: .snowShowers, time: clock(22, 24)), + .mock(id: "loc-se-stockholm", name: "Stockholm", subtitle: "Sweden", country: "SE", temperature: 6, high: 9, low: 2, condition: .partlyCloudy, time: clock(22, 24)), + .mock(id: "loc-ca-vancouver", name: "Vancouver", subtitle: "British Columbia, Canada", country: "CA", temperature: 9, high: 12, low: 6, condition: .lightRain, time: clock(13, 24)), + .mock(id: "loc-ca-toronto", name: "Toronto", subtitle: "Ontario, Canada", country: "CA", temperature: 8, high: 12, low: 5, condition: .cloudy, time: clock(16, 24)), + .mock(id: "loc-mx-mexico-city", name: "Mexico City", subtitle: "Mexico", country: "MX", temperature: 22, high: 26, low: 13, condition: .sunny, time: clock(14, 24)), + .mock(id: "loc-ar-buenos-aires", name: "Buenos Aires", subtitle: "Argentina", country: "AR", temperature: 18, high: 21, low: 13, condition: .partlyCloudy, time: clock(17, 24)), + .mock(id: "loc-kr-seoul", name: "Seoul", subtitle: "South Korea", country: "KR", temperature: 13, high: 17, low: 8, condition: .clearDay, time: clock(5, 24)), + .mock(id: "loc-th-bangkok", name: "Bangkok", subtitle: "Thailand", country: "TH", temperature: 32, high: 34, low: 26, condition: .thunderstorms, time: clock(3, 24)), + .mock(id: "loc-ae-dubai", name: "Dubai", subtitle: "United Arab Emirates", country: "AE", temperature: 33, high: 37, low: 26, condition: .sunny, time: clock(0, 24)), + .mock(id: "loc-es-madrid", name: "Madrid", subtitle: "Spain", country: "ES", temperature: 19, high: 22, low: 12, condition: .sunny, time: clock(22, 24)), + ] + + scenarioByLocationID = [ + "loc-current-san-francisco": .clearDay, + "loc-us-or-portland": .rainy, + "loc-us-co-aspen": .snowy, + "loc-is-reykjavik": .night, + "loc-us-la-new-orleans": .stormy, + "loc-jp-tokyo": .clearDay, + "loc-pt-lisbon": .clearDay, + "loc-fr-paris": .clearDay, + "loc-gb-london": .rainy, + "loc-de-berlin": .rainy, + "loc-us-ny-new-york": .clearDay, + "loc-au-sydney": .clearDay, + "loc-sg-singapore": .stormy, + "loc-in-mumbai": .clearDay, + "loc-eg-cairo": .clearDay, + "loc-za-cape-town": .clearDay, + "loc-is-capital-reykjavik": .night, + "loc-no-oslo": .snowy, + "loc-se-stockholm": .rainy, + "loc-ca-vancouver": .rainy, + "loc-ca-toronto": .rainy, + "loc-mx-mexico-city": .clearDay, + "loc-ar-buenos-aires": .clearDay, + "loc-kr-seoul": .clearDay, + "loc-th-bangkok": .stormy, + "loc-ae-dubai": .clearDay, + "loc-es-madrid": .clearDay, + ] + } +} + +extension CurrentWeatherDTO { + static func mock(for scenario: MockWeatherScenario) -> CurrentWeatherDTO { + switch scenario { + case .clearDay: + CurrentWeatherDTO( + id: "weather-current-loc-current-san-francisco", + temperatureC: 18, highC: 20, lowC: 12, feelsLikeC: 17, dewPointC: 9, condition: .mostlySunny, + solarProgress: .daylight(0.62), sunrise: clock(6, 18), sunset: clock(19, 42), airQualityIndex: 38, airQualityCategory: .good, + uvIndex: 6, uvCategory: .high, windKph: 13, windDirectionDegrees: 292, humidity: 64, + visibilityKilometers: 16.1, pressureMillibars: 1018, pressureTrend: .rising, precipChance: 5 + ) + case .rainy: + CurrentWeatherDTO( + id: "weather-current-loc-us-or-portland", + temperatureC: 11, highC: 13, lowC: 9, feelsLikeC: 9, dewPointC: 8, condition: .lightRain, + solarProgress: .daylight(0.45), sunrise: clock(6, 42), sunset: clock(19, 18), airQualityIndex: 22, airQualityCategory: .good, + uvIndex: 1, uvCategory: .low, windKph: 23, windDirectionDegrees: 225, humidity: 89, + visibilityKilometers: 9.7, pressureMillibars: 1006, pressureTrend: .falling, precipChance: 78 + ) + case .snowy: + CurrentWeatherDTO( + id: "weather-current-loc-us-co-aspen", + temperatureC: -4, highC: -2, lowC: -10, feelsLikeC: -8, dewPointC: -7, condition: .lightSnow, + solarProgress: .daylight(0.50), sunrise: clock(7, 14), sunset: clock(17, 38), airQualityIndex: 18, airQualityCategory: .good, + uvIndex: 2, uvCategory: .low, windKph: 10, windDirectionDegrees: 0, humidity: 78, + visibilityKilometers: 6.4, pressureMillibars: 1022, pressureTrend: .steady, precipChance: 65 + ) + case .night: + CurrentWeatherDTO( + id: "weather-current-loc-is-reykjavik", + temperatureC: 3, highC: 6, lowC: 1, feelsLikeC: 1, dewPointC: 0, condition: .clearNight, + solarProgress: .afterSunset, sunrise: clock(5, 46), sunset: clock(20, 24), airQualityIndex: 12, airQualityCategory: .good, + uvIndex: 0, uvCategory: .none, windKph: 6, windDirectionDegrees: 45, humidity: 71, + visibilityKilometers: 16.1, pressureMillibars: 1014, pressureTrend: .steady, precipChance: 8 + ) + case .stormy: + CurrentWeatherDTO( + id: "weather-current-loc-us-la-new-orleans", + temperatureC: 22, highC: 26, lowC: 20, feelsLikeC: 24, dewPointC: 19, condition: .thunderstorms, + solarProgress: .daylight(0.78), sunrise: clock(6, 8), sunset: clock(19, 52), airQualityIndex: 55, airQualityCategory: .moderate, + uvIndex: 3, uvCategory: .moderate, windKph: 35, windDirectionDegrees: 180, humidity: 86, + visibilityKilometers: 4.8, pressureMillibars: 998, pressureTrend: .falling, precipChance: 92 + ) + } + } +} + +extension HourlyForecastDTO { + static func mockForecast(for scenario: MockWeatherScenario) -> [HourlyForecastDTO] { + switch scenario { + case .clearDay: + [mock(.current, 18, .sunny), mock(.clock(clock(14)), 19, .sunny), mock(.clock(clock(15)), 19, .sunny), mock(.clock(clock(16)), 20, .sunny), mock(.clock(clock(17)), 19, .sunny), mock(.clock(clock(18)), 18, .partlyCloudy), mock(.clock(clock(19)), 17, .partlyCloudy), mock(.clock(clock(20)), 16, .clearNight), mock(.clock(clock(21)), 14, .clearNight), mock(.clock(clock(22)), 13, .clearNight), mock(.clock(clock(23)), 13, .clearNight), mock(.clock(clock(0)), 12, .clearNight)] + case .rainy: + [mock(.current, 11, .lightRain), mock(.clock(clock(14)), 12, .lightRain), mock(.clock(clock(15)), 12, .lightRain), mock(.clock(clock(16)), 13, .heavyRain), mock(.clock(clock(17)), 12, .heavyRain), mock(.clock(clock(18)), 12, .lightRain), mock(.clock(clock(19)), 11, .lightRain), mock(.clock(clock(20)), 11, .cloudy), mock(.clock(clock(21)), 10, .cloudy), mock(.clock(clock(22)), 9, .cloudy), mock(.clock(clock(23)), 9, .lightRain), mock(.clock(clock(0)), 9, .lightRain)] + case .snowy: + [mock(.current, -4, .lightSnow), mock(.clock(clock(14)), -3, .lightSnow), mock(.clock(clock(15)), -3, .lightSnow), mock(.clock(clock(16)), -2, .cloudy), mock(.clock(clock(17)), -3, .cloudy), mock(.clock(clock(18)), -4, .lightSnow), mock(.clock(clock(19)), -6, .lightSnow), mock(.clock(clock(20)), -7, .lightSnow), mock(.clock(clock(21)), -8, .lightSnow), mock(.clock(clock(22)), -9, .cloudy), mock(.clock(clock(23)), -9, .cloudy), mock(.clock(clock(0)), -10, .clearNight)] + case .night: + [mock(.current, 3, .clearNight), mock(.clock(clock(23)), 3, .clearNight), mock(.clock(clock(0)), 2, .clearNight), mock(.clock(clock(1)), 2, .clearNight), mock(.clock(clock(2)), 1, .clearNight), mock(.clock(clock(3)), 1, .clearNight), mock(.clock(clock(4)), 1, .clearNight), mock(.clock(clock(5)), 1, .clearNight), mock(.clock(clock(6)), 2, .partlyCloudy), mock(.clock(clock(7)), 3, .sunny), mock(.clock(clock(8)), 4, .sunny), mock(.clock(clock(9)), 6, .sunny)] + case .stormy: + [mock(.current, 22, .thunderstorms), mock(.clock(clock(14)), 23, .thunderstorms), mock(.clock(clock(15)), 24, .heavyRain), mock(.clock(clock(16)), 24, .heavyRain), mock(.clock(clock(17)), 26, .thunderstorms), mock(.clock(clock(18)), 26, .thunderstorms), mock(.clock(clock(19)), 25, .lightRain), mock(.clock(clock(20)), 23, .lightRain), mock(.clock(clock(21)), 22, .cloudy), mock(.clock(clock(22)), 21, .cloudy), mock(.clock(clock(23)), 21, .cloudy), mock(.clock(clock(0)), 20, .cloudy)] + } + } + + static func mock(_ hour: ForecastHourDTO, _ temperature: Int, _ condition: WeatherConditionDTO) -> HourlyForecastDTO { + HourlyForecastDTO(id: "hourly-\(hour.idComponent)-\(temperature)-\(condition.rawValue)", hour: hour, temperatureC: temperature, condition: condition) + } +} + +extension DailyForecastDTO { + static func mockForecast(for scenario: MockWeatherScenario) -> [DailyForecastDTO] { + switch scenario { + case .clearDay: + [mock(.today, .sunny, 12, 20, 9, 23), mock(.weekday(.wednesday), .sunny, 13, 21, 9, 23), mock(.weekday(.thursday), .partlyCloudy, 13, 21, 9, 23), mock(.weekday(.friday), .cloudy, 11, 19, 9, 23), mock(.weekday(.saturday), .lightRain, 9, 16, 9, 23), mock(.weekday(.sunday), .lightRain, 9, 14, 9, 23), mock(.weekday(.monday), .sunny, 11, 19, 9, 23)] + case .rainy: + [mock(.today, .lightRain, 9, 13, 6, 17), mock(.weekday(.wednesday), .lightRain, 8, 12, 6, 17), mock(.weekday(.thursday), .cloudy, 8, 13, 6, 17), mock(.weekday(.friday), .cloudy, 9, 14, 6, 17), mock(.weekday(.saturday), .partlyCloudy, 10, 17, 6, 17), mock(.weekday(.sunday), .sunny, 9, 16, 6, 17), mock(.weekday(.monday), .lightRain, 6, 12, 6, 17)] + case .snowy: + [mock(.today, .lightSnow, -10, -2, -13, 1), mock(.weekday(.wednesday), .lightSnow, -11, -3, -13, 1), mock(.weekday(.thursday), .cloudy, -9, -1, -13, 1), mock(.weekday(.friday), .partlyCloudy, -7, 1, -13, 1), mock(.weekday(.saturday), .sunny, -8, 0, -13, 1), mock(.weekday(.sunday), .lightSnow, -12, -6, -13, 1), mock(.weekday(.monday), .lightSnow, -13, -8, -13, 1)] + case .night: + [mock(.today, .clearNight, 1, 6, -1, 9), mock(.weekday(.wednesday), .sunny, 2, 8, -1, 9), mock(.weekday(.thursday), .cloudy, 2, 7, -1, 9), mock(.weekday(.friday), .lightRain, 1, 5, -1, 9), mock(.weekday(.saturday), .lightRain, 0, 4, -1, 9), mock(.weekday(.sunday), .sunny, 2, 8, -1, 9), mock(.weekday(.monday), .sunny, 3, 9, -1, 9)] + case .stormy: + [mock(.today, .thunderstorms, 20, 26, 18, 31), mock(.weekday(.wednesday), .lightRain, 21, 28, 18, 31), mock(.weekday(.thursday), .cloudy, 22, 29, 18, 31), mock(.weekday(.friday), .partlyCloudy, 22, 30, 18, 31), mock(.weekday(.saturday), .sunny, 23, 31, 18, 31), mock(.weekday(.sunday), .sunny, 21, 29, 18, 31), mock(.weekday(.monday), .thunderstorms, 19, 24, 18, 31)] + } + } + + static func mock(_ day: ForecastDayDTO, _ condition: WeatherConditionDTO, _ low: Int, _ high: Int, _ weekLow: Int, _ weekHigh: Int) -> DailyForecastDTO { + DailyForecastDTO(id: "daily-\(day.idComponent)-\(condition.rawValue)-\(low)-\(high)", day: day, condition: condition, lowC: low, highC: high, weekLowC: weekLow, weekHighC: weekHigh) + } +} + +private extension WeatherLocationDTO { + static func mock( + id: String, + name: String, + subtitle: String, + country: String?, + temperature: Int, + high: Int, + low: Int, + condition: WeatherConditionDTO, + time: LocalClockTimeDTO, + ) -> WeatherLocationDTO { + WeatherLocationDTO( + id: id, + name: name, + subtitle: subtitle, + country: country, + temperatureC: temperature, + highC: high, + lowC: low, + condition: condition, + localTime: time + ) + } +} + +private extension SolarDayProgressDTO { + static func daylight(_ fraction: Double) -> SolarDayProgressDTO { + SolarDayProgressDTO(kind: .daylight, daylightFraction: fraction) + } + + static var afterSunset: SolarDayProgressDTO { + SolarDayProgressDTO(kind: .afterSunset, daylightFraction: nil) + } +} + +private extension ForecastHourDTO { + static var current: ForecastHourDTO { + ForecastHourDTO(kind: .current, hour: nil, minute: nil) + } + + static func clock(_ time: LocalClockTimeDTO) -> ForecastHourDTO { + ForecastHourDTO(kind: .clock, hour: time.hour, minute: time.minute) + } + + var idComponent: String { + switch kind { + case .current: + "now" + case .clock: + "\(hour ?? 0)-\(minute ?? 0)" + } + } +} + +private extension ForecastDayDTO { + static var today: ForecastDayDTO { + ForecastDayDTO(kind: .today, weekdayRawValue: nil) + } + + static func weekday(_ weekday: Weekday) -> ForecastDayDTO { + ForecastDayDTO(kind: .weekday, weekdayRawValue: weekday.rawValue) + } + + var idComponent: String { + switch kind { + case .today: + "today" + case .weekday: + "weekday-\(weekdayRawValue ?? 0)" + } + } +} + +private func clock(_ hour: Int, _ minute: Int = 0) -> LocalClockTimeDTO { + LocalClockTimeDTO(hour: hour, minute: minute) +} diff --git a/example_projects/Weather/Weather/Services/WeatherAPIClient.swift b/example_projects/Weather/Weather/Services/WeatherAPIClient.swift new file mode 100644 index 00000000..172e0c79 --- /dev/null +++ b/example_projects/Weather/Weather/Services/WeatherAPIClient.swift @@ -0,0 +1,77 @@ +import Foundation + +protocol WeatherAPIClient: Sendable { + func defaultLocations() async throws -> [WeatherLocationDTO] + func weather(for locationID: WeatherLocation.ID) async throws -> WeatherReportDTO + func searchLocations(matching query: String) async throws -> [WeatherLocationDTO] +} + +struct WeatherAPIConfiguration: Sendable { + let baseURL: URL + + static let production = WeatherAPIConfiguration( + baseURL: URL(string: "https://api.atmosweather.example/v1")! + ) +} + +struct URLSessionWeatherAPIClient: WeatherAPIClient { + private let configuration: WeatherAPIConfiguration + private let session: URLSession + + init(configuration: WeatherAPIConfiguration, session: URLSession = .shared) { + self.configuration = configuration + self.session = session + } + + func defaultLocations() async throws -> [WeatherLocationDTO] { + try await request(WeatherLocationsResponseDTO.self, path: "locations/default").locations + } + + func weather(for locationID: WeatherLocation.ID) async throws -> WeatherReportDTO { + try await request(WeatherReportDTO.self, path: "weather/\(locationID)") + } + + func searchLocations(matching query: String) async throws -> [WeatherLocationDTO] { + try await request( + WeatherLocationsResponseDTO.self, + path: "locations/search", + queryItems: [URLQueryItem(name: "query", value: query)] + ).locations + } + + private func request( + _ responseType: Response.Type, + path: String, + queryItems: [URLQueryItem] = [] + ) async throws -> Response { + let url = try endpointURL(path: path, queryItems: queryItems) + let (data, response) = try await session.data(from: url) + + guard let httpResponse = response as? HTTPURLResponse else { + throw WeatherAPIClientError.invalidResponse + } + guard (200..<300).contains(httpResponse.statusCode) else { + throw WeatherAPIClientError.unsuccessfulStatusCode(httpResponse.statusCode) + } + + return try JSONDecoder().decode(Response.self, from: data) + } + + private func endpointURL(path: String, queryItems: [URLQueryItem]) throws -> URL { + let url = configuration.baseURL.appending(path: path) + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + throw WeatherAPIClientError.invalidURL + } + components.queryItems = queryItems.isEmpty ? nil : queryItems + guard let endpointURL = components.url else { + throw WeatherAPIClientError.invalidURL + } + return endpointURL + } +} + +enum WeatherAPIClientError: Error, Equatable { + case invalidURL + case invalidResponse + case unsuccessfulStatusCode(Int) +} diff --git a/example_projects/Weather/Weather/Services/WeatherClientDTOs.swift b/example_projects/Weather/Weather/Services/WeatherClientDTOs.swift new file mode 100644 index 00000000..0a1f39de --- /dev/null +++ b/example_projects/Weather/Weather/Services/WeatherClientDTOs.swift @@ -0,0 +1,364 @@ +import Foundation + +struct WeatherLocationsResponseDTO: Codable, Equatable, Sendable { + let locations: [WeatherLocationDTO] +} + +struct WeatherLocationDTO: Codable, Equatable, Sendable { + let id: String + let name: String + let subtitle: String + let country: String? + let temperatureC: Int + let highC: Int + let lowC: Int + let condition: WeatherConditionDTO + let localTime: LocalClockTimeDTO +} + +struct LocalClockTimeDTO: Codable, Equatable, Sendable { + let hour: Int + let minute: Int +} + +struct WeatherReportDTO: Codable, Equatable, Sendable { + let current: CurrentWeatherDTO + let hourly: [HourlyForecastDTO] + let daily: [DailyForecastDTO] + let precipitationDetailCurrent: CurrentWeatherDTO +} + +struct CurrentWeatherDTO: Codable, Equatable, Sendable { + let id: String + let temperatureC: Int + let highC: Int + let lowC: Int + let feelsLikeC: Int + let dewPointC: Int + let condition: WeatherConditionDTO + let solarProgress: SolarDayProgressDTO + let sunrise: LocalClockTimeDTO + let sunset: LocalClockTimeDTO + let airQualityIndex: Int + let airQualityCategory: AirQualityCategoryDTO + let uvIndex: Int + let uvCategory: UVIndexCategoryDTO + let windKph: Int + let windDirectionDegrees: Double + let humidity: Int + let visibilityKilometers: Double + let pressureMillibars: Int + let pressureTrend: PressureTrendDTO + let precipChance: Int +} + +struct SolarDayProgressDTO: Codable, Equatable, Sendable { + let kind: SolarDayProgressKindDTO + let daylightFraction: Double? +} + +struct HourlyForecastDTO: Codable, Equatable, Sendable { + let id: String + let hour: ForecastHourDTO + let temperatureC: Int + let condition: WeatherConditionDTO +} + +struct ForecastHourDTO: Codable, Equatable, Sendable { + let kind: ForecastHourKindDTO + let hour: Int? + let minute: Int? +} + +struct DailyForecastDTO: Codable, Equatable, Sendable { + let id: String + let day: ForecastDayDTO + let condition: WeatherConditionDTO + let lowC: Int + let highC: Int + let weekLowC: Int + let weekHighC: Int +} + +struct ForecastDayDTO: Codable, Equatable, Sendable { + let kind: ForecastDayKindDTO + let weekdayRawValue: Int? +} + +enum WeatherConditionDTO: String, Codable, CaseIterable, Sendable { + case sunny + case mostlySunny = "mostly_sunny" + case partlyCloudy = "partly_cloudy" + case cloudy + case clearDay = "clear_day" + case clearNight = "clear_night" + case lightRain = "light_rain" + case heavyRain = "heavy_rain" + case lightSnow = "light_snow" + case snowShowers = "snow_showers" + case thunderstorms + case hazy +} + +enum AirQualityCategoryDTO: String, Codable, CaseIterable, Sendable { + case good + case moderate + case unhealthyForSensitiveGroups = "unhealthy_for_sensitive_groups" + case unhealthy + case veryUnhealthy = "very_unhealthy" + case hazardous +} + +enum UVIndexCategoryDTO: String, Codable, CaseIterable, Sendable { + case none + case low + case moderate + case high + case veryHigh = "very_high" + case extreme +} + +enum PressureTrendDTO: String, Codable, CaseIterable, Sendable { + case rising + case steady + case falling +} + +enum SolarDayProgressKindDTO: String, Codable, CaseIterable, Sendable { + case beforeSunrise = "before_sunrise" + case daylight + case afterSunset = "after_sunset" +} + +enum ForecastHourKindDTO: String, Codable, CaseIterable, Sendable { + case current + case clock +} + +enum ForecastDayKindDTO: String, Codable, CaseIterable, Sendable { + case today + case weekday +} + +enum WeatherDTOMappingError: Error, Equatable { + case missingDaylightFraction + case invalidDaylightFraction(Double) + case missingClockTime + case invalidClockTime(hour: Int, minute: Int) + case missingWeekday + case invalidWeekday(Int) + case invalidWindDirection(Double) +} + +extension WeatherReport { + init(dto: WeatherReportDTO) throws { + var hourly: [HourlyForecast] = [] + hourly.reserveCapacity(dto.hourly.count) + for forecast in dto.hourly { + hourly.append(try HourlyForecast(dto: forecast)) + } + + var daily: [DailyForecast] = [] + daily.reserveCapacity(dto.daily.count) + for forecast in dto.daily { + daily.append(try DailyForecast(dto: forecast)) + } + + self.init( + current: try CurrentWeather(dto: dto.current), + hourly: hourly, + daily: daily, + precipitationDetailCurrent: try CurrentWeather(dto: dto.precipitationDetailCurrent) + ) + } +} + +extension WeatherLocation { + init(dto: WeatherLocationDTO) throws { + self.init( + id: dto.id, + name: dto.name, + subtitle: dto.subtitle, + country: dto.country, + temperatureC: dto.temperatureC, + highC: dto.highC, + lowC: dto.lowC, + condition: WeatherCondition(dto: dto.condition), + localTime: try LocalClockTime(dto: dto.localTime) + ) + } +} + +extension CurrentWeather { + init(dto: CurrentWeatherDTO) throws { + guard (0...360).contains(dto.windDirectionDegrees) else { + throw WeatherDTOMappingError.invalidWindDirection(dto.windDirectionDegrees) + } + + let windDirectionDegrees = dto.windDirectionDegrees == 360 ? 0 : dto.windDirectionDegrees + + self.init( + id: dto.id, + temperatureC: dto.temperatureC, + highC: dto.highC, + lowC: dto.lowC, + feelsLikeC: dto.feelsLikeC, + dewPointC: dto.dewPointC, + condition: WeatherCondition(dto: dto.condition), + solarProgress: try SolarDayProgress(dto: dto.solarProgress), + sunrise: try LocalClockTime(dto: dto.sunrise), + sunset: try LocalClockTime(dto: dto.sunset), + airQualityIndex: dto.airQualityIndex, + airQualityCategory: AirQualityCategory(dto: dto.airQualityCategory), + uvIndex: dto.uvIndex, + uvCategory: UVIndexCategory(dto: dto.uvCategory), + windKph: dto.windKph, + windDirection: WindDirection(degrees: windDirectionDegrees), + humidity: dto.humidity, + visibilityKilometers: dto.visibilityKilometers, + pressureMillibars: dto.pressureMillibars, + pressureTrend: PressureTrend(dto: dto.pressureTrend), + precipChance: dto.precipChance + ) + } +} + +extension HourlyForecast { + init(dto: HourlyForecastDTO) throws { + self.init( + id: dto.id, + hour: try ForecastHour(dto: dto.hour), + temperatureC: dto.temperatureC, + condition: WeatherCondition(dto: dto.condition) + ) + } +} + +extension DailyForecast { + init(dto: DailyForecastDTO) throws { + self.init( + id: dto.id, + day: try ForecastDay(dto: dto.day), + condition: WeatherCondition(dto: dto.condition), + lowC: dto.lowC, + highC: dto.highC, + weekLowC: dto.weekLowC, + weekHighC: dto.weekHighC + ) + } +} + +private extension LocalClockTime { + init(dto: LocalClockTimeDTO) throws { + guard (0...23).contains(dto.hour), (0...59).contains(dto.minute) else { + throw WeatherDTOMappingError.invalidClockTime(hour: dto.hour, minute: dto.minute) + } + + self.init(hour: dto.hour, minute: dto.minute) + } +} + +private extension SolarDayProgress { + init(dto: SolarDayProgressDTO) throws { + switch dto.kind { + case .beforeSunrise: + self = .beforeSunrise + case .daylight: + guard let fraction = dto.daylightFraction else { + throw WeatherDTOMappingError.missingDaylightFraction + } + guard (0...1).contains(fraction) else { + throw WeatherDTOMappingError.invalidDaylightFraction(fraction) + } + self = .daylightFraction(fraction) + case .afterSunset: + self = .afterSunset + } + } +} + +private extension ForecastHour { + init(dto: ForecastHourDTO) throws { + switch dto.kind { + case .current: + self = .current + case .clock: + guard let hour = dto.hour, let minute = dto.minute else { + throw WeatherDTOMappingError.missingClockTime + } + self = .clock(try LocalClockTime(dto: LocalClockTimeDTO(hour: hour, minute: minute))) + } + } +} + +private extension ForecastDay { + init(dto: ForecastDayDTO) throws { + switch dto.kind { + case .today: + self = .today + case .weekday: + guard let weekdayRawValue = dto.weekdayRawValue else { + throw WeatherDTOMappingError.missingWeekday + } + guard let weekday = Weekday(rawValue: weekdayRawValue) else { + throw WeatherDTOMappingError.invalidWeekday(weekdayRawValue) + } + self = .weekday(weekday) + } + } +} + +private extension WeatherCondition { + init(dto: WeatherConditionDTO) { + switch dto { + case .sunny: self = .sunny + case .mostlySunny: self = .mostlySunny + case .partlyCloudy: self = .partlyCloudy + case .cloudy: self = .cloudy + case .clearDay: self = .clearDay + case .clearNight: self = .clearNight + case .lightRain: self = .lightRain + case .heavyRain: self = .heavyRain + case .lightSnow: self = .lightSnow + case .snowShowers: self = .snowShowers + case .thunderstorms: self = .thunderstorms + case .hazy: self = .hazy + } + } +} + +private extension AirQualityCategory { + init(dto: AirQualityCategoryDTO) { + switch dto { + case .good: self = .good + case .moderate: self = .moderate + case .unhealthyForSensitiveGroups: self = .unhealthyForSensitiveGroups + case .unhealthy: self = .unhealthy + case .veryUnhealthy: self = .veryUnhealthy + case .hazardous: self = .hazardous + } + } +} + +private extension UVIndexCategory { + init(dto: UVIndexCategoryDTO) { + switch dto { + case .none: self = .none + case .low: self = .low + case .moderate: self = .moderate + case .high: self = .high + case .veryHigh: self = .veryHigh + case .extreme: self = .extreme + } + } +} + +private extension PressureTrend { + init(dto: PressureTrendDTO) { + switch dto { + case .rising: self = .rising + case .steady: self = .steady + case .falling: self = .falling + } + } +} diff --git a/example_projects/Weather/Weather/Services/WeatherService.swift b/example_projects/Weather/Weather/Services/WeatherService.swift new file mode 100644 index 00000000..24644e60 --- /dev/null +++ b/example_projects/Weather/Weather/Services/WeatherService.swift @@ -0,0 +1,36 @@ +import Foundation + +struct WeatherService: Sendable { + private let apiClient: any WeatherAPIClient + + init(apiClient: any WeatherAPIClient) { + self.apiClient = apiClient + } + + func defaultLocations() async throws -> [WeatherLocation] { + try await apiClient.defaultLocations().map { dto in + try WeatherLocation(dto: dto) + } + } + + func weather(for locationID: WeatherLocation.ID) async throws -> WeatherReport { + let dto = try await apiClient.weather(for: locationID) + return try WeatherReport(dto: dto) + } + + func searchLocations(matching query: String) async throws -> [WeatherLocation] { + try await apiClient.searchLocations(matching: query).map { dto in + try WeatherLocation(dto: dto) + } + } +} + +extension WeatherService { + static var production: WeatherService { + WeatherService(apiClient: URLSessionWeatherAPIClient(configuration: .production)) + } + + static var mock: WeatherService { + WeatherService(apiClient: MockWeatherAPIClient()) + } +} diff --git a/example_projects/Weather/Weather/Services/WeatherUnitFormatter.swift b/example_projects/Weather/Weather/Services/WeatherUnitFormatter.swift new file mode 100644 index 00000000..dcc374ab --- /dev/null +++ b/example_projects/Weather/Weather/Services/WeatherUnitFormatter.swift @@ -0,0 +1,59 @@ +import Foundation + +struct FormattedMeasurement: Equatable { + let value: String + let unit: String +} + +enum WeatherUnitFormatter { + static func temperature(_ celsius: Int, units: WeatherUnits) -> Int { + switch units.temperature { + case .fahrenheit: + Int((Double(celsius) * 9 / 5 + 32).rounded()) + case .celsius: + celsius + } + } + + static func temperatureString(_ celsius: Int, units: WeatherUnits) -> String { + "\(temperature(celsius, units: units))°" + } + + static func wind(_ kph: Int, units: WeatherUnits) -> FormattedMeasurement { + switch units.wind { + case .mph: + FormattedMeasurement(value: "\(Int((Double(kph) / 1.60934).rounded()))", unit: "mph") + case .kmh: + FormattedMeasurement(value: "\(kph)", unit: "km/h") + case .metersPerSecond: + FormattedMeasurement(value: oneDecimal(Double(kph) / 3.6), unit: "m/s") + } + } + + static func pressure(_ millibars: Int, units: WeatherUnits) -> FormattedMeasurement { + switch units.pressure { + case .millibars: + FormattedMeasurement(value: "\(millibars)", unit: "mb") + case .inchesMercury: + FormattedMeasurement(value: String(format: "%.2f", Double(millibars) * 0.02953), unit: "inHg") + } + } + + static func distance(_ kilometers: Double, units: WeatherUnits) -> FormattedMeasurement { + switch units.distance { + case .miles: + let miles = kilometers / 1.60934 + return FormattedMeasurement(value: miles == floor(miles) ? "\(Int(miles))" : oneDecimal(miles), unit: "mi") + case .kilometers: + return FormattedMeasurement(value: oneDecimal(kilometers), unit: "km") + } + } + + private static func oneDecimal(_ value: Double) -> String { + let rounded = (value * 10).rounded() / 10 + if rounded == floor(rounded) { + return "\(Int(rounded))" + } + return String(format: "%.1f", rounded) + } +} diff --git a/example_projects/Weather/Weather/Views/AtmosWeatherScreen.swift b/example_projects/Weather/Weather/Views/AtmosWeatherScreen.swift new file mode 100644 index 00000000..ce15185f --- /dev/null +++ b/example_projects/Weather/Weather/Views/AtmosWeatherScreen.swift @@ -0,0 +1,82 @@ +import SwiftUI + +struct AtmosWeatherScreen: View { + let locationName: String + let locationSubtitle: String + let current: CurrentWeather + let hourly: [HourlyForecast] + let daily: [DailyForecast] + let units: WeatherUnits + let onOpenLocations: () -> Void + let onOpenSettings: () -> Void + let onOpenPrecipitation: () -> Void + + var body: some View { + GeometryReader { proxy in + ZStack(alignment: .top) { + AtmosBackground(current: current, animationsEnabled: units.animationsEnabled) + + ScrollView { + VStack(spacing: 0) { + WeatherHeroView(locationName: locationName, locationSubtitle: locationSubtitle, current: current, units: units) + + VStack(spacing: 10) { + HourlyForecastCard(forecasts: hourly, current: current, units: units) + DailyForecastCard(forecasts: daily, current: current, units: units) + ConditionGrid(current: current, units: units, onOpenPrecipitation: onOpenPrecipitation) + + Text("Updated just now") + .font(.system(size: 11)) + .tracking(0.3) + .foregroundStyle(current.theme.foregroundFaint) + .padding(.top, 14) + .padding(.bottom, 4) + } + .padding(.horizontal, 12) + .padding(.top, 14) + } + .padding(.top, 84) + .padding(.bottom, 90) + } + .scrollIndicators(.hidden) + .accessibilityIdentifier("weather.mainScrollView") + .frame(width: proxy.size.width, height: proxy.size.height) + + topScrim(topInset: proxy.safeAreaInsets.top) + + WeatherTopBar( + locationName: locationName, + current: current, + onOpenLocations: onOpenLocations, + onOpenSettings: onOpenSettings + ) + .padding(.top, 12) + } + .frame(width: proxy.size.width, height: proxy.size.height) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .foregroundStyle(current.theme.foreground) + .preferredColorScheme(current.theme.statusDark ? .dark : .light) + } + + private func topScrim(topInset: CGFloat) -> some View { + LinearGradient( + stops: [ + .init(color: firstBackgroundStop.opacity(1), location: 0), + .init(color: firstBackgroundStop.opacity(0.80), location: 0.38), + .init(color: firstBackgroundStop.opacity(0.40), location: 0.72), + .init(color: firstBackgroundStop.opacity(0), location: 1), + ], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 96 + topInset) + .offset(y: -topInset) + .blur(radius: 2) + .allowsHitTesting(false) + } + + private var firstBackgroundStop: Color { + current.theme.backgroundStops.first?.color ?? .clear + } +} diff --git a/example_projects/Weather/Weather/Views/Backgrounds/AtmosBackground.swift b/example_projects/Weather/Weather/Views/Backgrounds/AtmosBackground.swift new file mode 100644 index 00000000..70635487 --- /dev/null +++ b/example_projects/Weather/Weather/Views/Backgrounds/AtmosBackground.swift @@ -0,0 +1,237 @@ +import SwiftUI + +struct AtmosBackground: View { + let current: CurrentWeather + let animationsEnabled: Bool + var forcedParticle: AtmosphericParticle? + + private var particleSeed: Int { + current.id.unicodeScalars.reduce(0) { seed, scalar in + seed &* 31 &+ Int(scalar.value) + } + } + + var body: some View { + GeometryReader { proxy in + let size = proxy.size + + ZStack { + LinearGradient( + stops: current.theme.backgroundStops, + startPoint: .top, + endPoint: .bottom + ) + meshBlobs(size: size) + noise + + TimelineView(.animation(minimumInterval: animationsEnabled ? 1 / 30 : nil, paused: !animationsEnabled)) { timeline in + let time = timeline.date.timeIntervalSinceReferenceDate + ZStack { + particleLayer(size: size, time: time) + cloudLayer(size: size, time: time) + } + } + } + .animation(.easeInOut(duration: 0.45), value: current.id) + } + .ignoresSafeArea() + .allowsHitTesting(false) + } + + private func meshBlobs(size: CGSize) -> some View { + ZStack { + Ellipse() + .fill( + RadialGradient( + colors: [current.theme.accent.opacity(0.33), .clear], + center: .center, + startRadius: 0, + endRadius: max(size.width, size.height) * 0.45 + ) + ) + .frame(width: size.width * 1.2, height: size.height * 0.6) + .position(x: size.width * 0.25, y: size.height * 0.12) + .blur(radius: 40) + + Ellipse() + .fill( + RadialGradient( + colors: [current.theme.backgroundStops[safe: 2]?.color.opacity(0.53) ?? .clear, .clear], + center: .center, + startRadius: 0, + endRadius: max(size.width, size.height) * 0.38 + ) + ) + .frame(width: size.width, height: size.height * 0.5) + .position(x: size.width * 0.85, y: size.height * 0.88) + .blur(radius: 50) + } + } + + private var noise: some View { + Canvas { context, size in + for x in stride(from: 0, through: size.width, by: 18) { + for y in stride(from: 0, through: size.height, by: 18) { + let alpha = WeatherMetricHelpers.deterministicPercent(seed: Int(x + y), index: Int(x * 3 + y)) * 0.006 + context.fill( + Path(CGRect(x: x, y: y, width: 1, height: 1)), + with: .color(.white.opacity(alpha)) + ) + } + } + } + .blendMode(.overlay) + .opacity(0.6) + } + + @ViewBuilder private func particleLayer(size: CGSize, time: TimeInterval) -> some View { + switch forcedParticle ?? current.atmosphericParticle { + case .sun: + sunRays(size: size, time: time) + case .rain: + rain(size: size, time: time) + case .snow: + snow(size: size, time: time) + case .stars: + stars(size: size, time: time) + case .storm: + stormFlash(time: time) + } + } + + private func sunRays(size: CGSize, time: TimeInterval) -> some View { + let phase = animationsEnabled ? (sin(time * .pi / 4) + 1) / 2 : 0.5 + return Circle() + .fill( + RadialGradient( + colors: [Color(hex: "#FFE4AA").opacity(0.35), .clear], + center: .center, + startRadius: 0, + endRadius: 300 + ) + ) + .frame(width: 600, height: 600) + .scaleEffect(1 + phase * 0.06) + .opacity(0.85 + phase * 0.15) + .position(x: size.width / 2, y: size.height * 0.02) + .blur(radius: 20) + } + + private func rain(size: CGSize, time: TimeInterval) -> some View { + Canvas { context, canvasSize in + let seed = particleSeed + let baseColor = Color(hex: "#DCEBFF") + for index in 0..<70 { + let x = WeatherMetricHelpers.deterministicPercent(seed: seed, index: index) * canvasSize.width + let duration = 0.7 + WeatherMetricHelpers.deterministicPercent(seed: seed, index: index + 100) * 0.6 + let delay = WeatherMetricHelpers.deterministicPercent(seed: seed, index: index + 200) * 1.2 + let progress = animationsEnabled + ? ((time + delay).truncatingRemainder(dividingBy: duration) / duration) + : WeatherMetricHelpers.deterministicPercent(seed: seed, index: index + 300) + let length = 14 + WeatherMetricHelpers.deterministicPercent(seed: seed, index: index + 400) * 18 + let opacity = 0.3 + WeatherMetricHelpers.deterministicPercent(seed: seed, index: index + 500) * 0.5 + let centerY = -30 + progress * (canvasSize.height + 80) + let rect = CGRect(x: x - 0.5, y: centerY - length / 2, width: 1, height: length) + + context.fill( + Path(rect), + with: .linearGradient( + Gradient(colors: [.clear, baseColor.opacity(opacity)]), + startPoint: CGPoint(x: rect.midX, y: rect.minY), + endPoint: CGPoint(x: rect.midX, y: rect.maxY) + ) + ) + } + } + } + + private func snow(size: CGSize, time: TimeInterval) -> some View { + Canvas { context, canvasSize in + let seed = particleSeed + for index in 0..<50 { + let x = WeatherMetricHelpers.deterministicPercent(seed: seed, index: index) * canvasSize.width + let duration = 6 + WeatherMetricHelpers.deterministicPercent(seed: seed, index: index + 100) * 5 + let delay = WeatherMetricHelpers.deterministicPercent(seed: seed, index: index + 200) * 6 + let progress = animationsEnabled + ? ((time + delay).truncatingRemainder(dividingBy: duration) / duration) + : WeatherMetricHelpers.deterministicPercent(seed: seed, index: index + 300) + let drift = -10 + WeatherMetricHelpers.deterministicPercent(seed: seed, index: index + 400) * 20 + let sizeValue = 2 + WeatherMetricHelpers.deterministicPercent(seed: seed, index: index + 500) * 3 + let opacity = 0.4 + WeatherMetricHelpers.deterministicPercent(seed: seed, index: index + 600) * 0.5 + let centerX = x + drift * progress + let centerY = -10 + progress * (canvasSize.height + 40) + let rect = CGRect(x: centerX - sizeValue / 2, y: centerY - sizeValue / 2, width: sizeValue, height: sizeValue) + + context.fill(Path(ellipseIn: rect), with: .color(.white.opacity(opacity))) + } + } + } + + private func stars(size: CGSize, time: TimeInterval) -> some View { + Canvas { context, canvasSize in + let seed = particleSeed + for index in 0..<80 { + let x = WeatherMetricHelpers.deterministicPercent(seed: seed, index: index) * canvasSize.width + let y = WeatherMetricHelpers.deterministicPercent(seed: seed, index: index + 100) * canvasSize.height * 0.75 + let large = WeatherMetricHelpers.deterministicPercent(seed: seed, index: index + 200) > 0.85 + let base = 0.4 + WeatherMetricHelpers.deterministicPercent(seed: seed, index: index + 300) * 0.6 + let pulse = animationsEnabled ? (sin(time * (1.2 + Double(index % 5) * 0.25) + Double(index)) + 1) / 2 : 0.6 + let diameter: CGFloat = large ? 2 : 1 + let rect = CGRect(x: x - diameter / 2, y: y - diameter / 2, width: diameter, height: diameter) + let fill = Color.white.opacity(0.25 + base * pulse) + + if large { + context.drawLayer { layer in + layer.addFilter(.shadow(color: .white.opacity(0.8), radius: 4)) + layer.fill(Path(ellipseIn: rect), with: .color(fill)) + } + } else { + context.fill(Path(ellipseIn: rect), with: .color(fill)) + } + } + } + } + + private func stormFlash(time: TimeInterval) -> some View { + let phase = animationsEnabled ? time.truncatingRemainder(dividingBy: 7) / 7 : 0 + let opacity = phase > 0.93 && phase < 0.94 ? 0.30 : phase > 0.95 && phase < 0.965 ? 0.18 : 0 + return Color(hex: "#F5C77E").opacity(opacity) + } + + @ViewBuilder private func cloudLayer(size: CGSize, time: TimeInterval) -> some View { + let opacity: Double? = switch forcedParticle ?? current.atmosphericParticle { + case .sun: 0.10 + case .rain: 0.22 + case .storm: 0.18 + default: nil + } + + if let opacity { + let offset1 = animationsEnabled ? CGFloat(time.truncatingRemainder(dividingBy: 38) / 38) * (size.width * 1.8) : size.width * 0.35 + let offset2 = animationsEnabled ? CGFloat((time - 10).truncatingRemainder(dividingBy: 52) / 52) * (size.width * 1.8) : size.width * 0.55 + + Group { + cloud(width: size.width * 0.7, height: 100, opacity: opacity) + .position(x: -size.width * 0.3 + offset1, y: size.height * 0.08) + cloud(width: size.width * 0.6, height: 80, opacity: opacity * 0.8) + .position(x: -size.width * 0.4 + offset2, y: size.height * 0.22) + } + } + } + + private func cloud(width: CGFloat, height: CGFloat, opacity: Double) -> some View { + Ellipse() + .fill( + RadialGradient( + colors: [.white.opacity(0.6), .clear], + center: .center, + startRadius: 0, + endRadius: width * 0.4 + ) + ) + .frame(width: width, height: height) + .blur(radius: 20) + .opacity(opacity) + } +} + diff --git a/example_projects/Weather/Weather/Views/Chrome/WeatherTopBar.swift b/example_projects/Weather/Weather/Views/Chrome/WeatherTopBar.swift new file mode 100644 index 00000000..9b9e52a6 --- /dev/null +++ b/example_projects/Weather/Weather/Views/Chrome/WeatherTopBar.swift @@ -0,0 +1,50 @@ +import SwiftUI + +struct WeatherTopBar: View { + let locationName: String + let current: CurrentWeather + let onOpenLocations: () -> Void + let onOpenSettings: () -> Void + + var body: some View { + AtmosGlassContainer { + HStack { + AtmosGlassPill(theme: current.theme, cornerRadius: 20, padding: 0, action: onOpenLocations) { + HStack(spacing: 8) { + Circle() + .fill(current.theme.accent) + .frame(width: 7, height: 7) + .shadow(color: current.theme.accent.opacity(0.35), radius: 3) + + Image(systemName: "mappin") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(current.theme.foreground.opacity(0.85)) + + Text(locationName) + .font(.system(size: 14.5, weight: .medium)) + .tracking(-0.1) + + Image(systemName: "chevron.down") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(current.theme.foregroundMuted) + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + } + .accessibilityIdentifier("weather.locationButton") + + Spacer() + + AtmosGlassPill(theme: current.theme, cornerRadius: 19, padding: 0, action: onOpenSettings) { + Image(systemName: "gearshape") + .font(.system(size: 20, weight: .medium)) + .frame(width: 38, height: 38) + } + .accessibilityIdentifier("weather.settingsButton") + .accessibilityLabel("Settings") + } + } + .padding(.horizontal, 12) + .frame(height: 48) + } +} diff --git a/example_projects/Weather/Weather/Views/Overlays/LocationPickerView.swift b/example_projects/Weather/Weather/Views/Overlays/LocationPickerView.swift new file mode 100644 index 00000000..7b643f61 --- /dev/null +++ b/example_projects/Weather/Weather/Views/Overlays/LocationPickerView.swift @@ -0,0 +1,252 @@ +import SwiftUI + +struct LocationPickerView: View { + @Environment(\.dismiss) private var dismiss + + @Binding var savedLocations: [WeatherLocation] + let units: WeatherUnits + let weatherService: WeatherService + let onSelectSaved: (WeatherLocation) -> Void + let onPreviewSearchResult: (WeatherLocation) -> Void + + @State private var query = "" + @State private var isLoading = false + @State private var results: [WeatherLocation] = [] + @State private var searchErrorMessage: String? + @State private var isEditing = false + @State private var justAddedID: String? + + var body: some View { + VStack(spacing: 0) { + header + searchField + if !showingSearch { + currentLocationButton + } + sectionHeader + locationList + } + .padding(.horizontal, 20) + .padding(.top, 18) + .padding(.bottom, 30) + .frame(maxWidth: .infinity) + .frame(maxHeight: .infinity) + .background(sheetBackground) + .accessibilityIdentifier("weather.locationsSheet") + .task(id: query) { + await search() + } + .task(id: justAddedID) { + await clearAddedIndicator() + } + } + + private var showingSearch: Bool { + !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private var header: some View { + HStack { + Text("Locations") + .font(.system(size: 22, weight: .bold)) + .tracking(-0.3) + Spacer() + if !showingSearch { + Button(isEditing ? "Done" : "Edit") { + isEditing.toggle() + } + .font(.system(size: 13, weight: .semibold)) + .padding(.horizontal, 12) + .frame(height: 30) + .background(isEditing ? Color(hex: "#78B4FF").opacity(0.35) : .white.opacity(0.15), in: Capsule()) + } + Button(action: dismiss.callAsFunction) { + Image(systemName: "xmark") + .font(.system(size: 13, weight: .semibold)) + .frame(width: 30, height: 30) + .background(.white.opacity(0.15), in: Circle()) + } + .accessibilityLabel("Close") + } + .foregroundStyle(.white) + .padding(.bottom, 12) + } + + private var searchField: some View { + HStack(spacing: 8) { + if isLoading { + ProgressView().tint(.white).frame(width: 16, height: 16) + } else { + Image(systemName: "magnifyingglass") + .foregroundStyle(.white.opacity(0.7)) + } + TextField("Search for a city, airport, or country", text: $query) + .textFieldStyle(.plain) + .font(.system(size: 15)) + .foregroundStyle(.white) + if !query.isEmpty { + Button { + query = "" + } label: { + Image(systemName: "xmark") + .font(.system(size: 10, weight: .bold)) + .frame(width: 18, height: 18) + .background(.white.opacity(0.2), in: Circle()) + } + .accessibilityLabel("Clear search") + } + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(.white.opacity(0.10), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .padding(.bottom, 10) + } + + private var currentLocationButton: some View { + Button(action: {}) { + HStack(spacing: 12) { + Image(systemName: "location.fill") + .font(.system(size: 14)) + .foregroundStyle(Color(hex: "#7ABFFF")) + .frame(width: 28, height: 28) + .background(Color(hex: "#78B4FF").opacity(0.25), in: Circle()) + Text("Use current location") + .font(.system(size: 15, weight: .medium)) + Spacer() + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .background(.white.opacity(0.08), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + .buttonStyle(.plain) + .padding(.bottom, 10) + } + + private var sectionHeader: some View { + Text(showingSearch ? (isLoading ? "SEARCHING…" : "\(results.count) RESULT\(results.count == 1 ? "" : "S")") : "MY LOCATIONS · \(savedLocations.count)") + .font(.system(size: 11, weight: .semibold)) + .tracking(1.4) + .foregroundStyle(.white.opacity(0.55)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 4) + .padding(.bottom, 8) + } + + private var locationList: some View { + ScrollView { + LazyVStack(spacing: 8) { + if !showingSearch { + ForEach(savedLocations) { location in + SavedLocationRow( + location: location, + units: units, + isCurrentLocation: isCurrentLocation(location), + isEditing: isEditing && !isCurrentLocation(location), + onSelect: { select(location) }, + onRemove: { remove(location) } + ) + } + } else if isLoading { + ForEach(0..<3, id: \.self) { _ in SearchSkeletonRow() } + } else if results.isEmpty { + noMatches + } else { + ForEach(results) { location in + SearchLocationRow( + location: location, + units: units, + saved: isSaved(location), + added: justAddedID == location.id, + onPreview: { preview(location) }, + onAdd: { add(location) } + ) + } + } + } + .padding(.bottom, 8) + } + } + + private var noMatches: some View { + VStack(spacing: 4) { + Text("No matches").font(.system(size: 15, weight: .medium)) + Text(searchErrorMessage ?? "Try a different city or country.").font(.system(size: 13)) + } + .foregroundStyle(.white.opacity(0.5)) + .frame(maxWidth: .infinity) + .padding(.vertical, 40) + } + + private var sheetBackground: some View { + ZStack { + Rectangle().fill(Color(red: 28 / 255, green: 28 / 255, blue: 30 / 255).opacity(0.82)) + Rectangle().fill(.ultraThinMaterial) + } + .overlay(alignment: .top) { + Rectangle().fill(.white.opacity(0.12)).frame(height: 0.5) + } + } + + private func search() async { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + isLoading = false + results = [] + return + } + isLoading = true + searchErrorMessage = nil + let currentQuery = query + defer { + if currentQuery == query { + isLoading = false + } + } + + do { + let matches = try await weatherService.searchLocations(matching: currentQuery) + guard !Task.isCancelled, currentQuery == query else { return } + results = matches + } catch is CancellationError { + } catch { + guard !Task.isCancelled, currentQuery == query else { return } + results = [] + searchErrorMessage = "Search is unavailable right now." + } + } + + private func isSaved(_ location: WeatherLocation) -> Bool { + savedLocations.contains { $0.id == location.id } + } + + private func isCurrentLocation(_ location: WeatherLocation) -> Bool { + location.id == savedLocations.first?.id + } + + private func add(_ location: WeatherLocation) { + guard !isSaved(location) else { return } + savedLocations.append(location) + justAddedID = location.id + } + + private func clearAddedIndicator() async { + guard let id = justAddedID else { return } + try? await Task.sleep(for: .milliseconds(1_400)) + guard !Task.isCancelled, justAddedID == id else { return } + justAddedID = nil + } + + private func preview(_ location: WeatherLocation) { + onPreviewSearchResult(location) + } + + private func select(_ location: WeatherLocation) { + onSelectSaved(location) + dismiss() + } + + private func remove(_ location: WeatherLocation) { + guard !isCurrentLocation(location) else { return } + savedLocations.removeAll { $0.id == location.id } + } +} diff --git a/example_projects/Weather/Weather/Views/Overlays/LocationRows.swift b/example_projects/Weather/Weather/Views/Overlays/LocationRows.swift new file mode 100644 index 00000000..a6412cfb --- /dev/null +++ b/example_projects/Weather/Weather/Views/Overlays/LocationRows.swift @@ -0,0 +1,144 @@ +import SwiftUI + +struct SavedLocationRow: View { + let location: WeatherLocation + let units: WeatherUnits + let isCurrentLocation: Bool + let isEditing: Bool + let onSelect: () -> Void + let onRemove: () -> Void + + var body: some View { + HStack(spacing: 8) { + Button(action: onSelect) { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + if isCurrentLocation { + Text("MY LOCATION") + .font(.system(size: 11, weight: .semibold)) + .tracking(1) + .foregroundStyle(.white.opacity(0.7)) + } + Text(location.name) + .font(.system(size: 19, weight: .semibold)) + .tracking(-0.3) + Text("\(location.localTimeLabel) · \(location.conditionLabel)") + .font(.system(size: 12)) + .foregroundStyle(.white.opacity(0.65)) + } + Spacer() + WeatherIconView(kind: location.iconKind, size: 28, foreground: .white, accent: Color(hex: "#FFD89B")) + VStack(alignment: .trailing, spacing: 2) { + Text(WeatherUnitFormatter.temperatureString(location.temperatureC, units: units)) + .font(.system(size: 38, weight: .thin)) + .tracking(-1.5) + .monospacedDigit() + Text("H:\(WeatherUnitFormatter.temperature(location.highC, units: units))° L:\(WeatherUnitFormatter.temperature(location.lowC, units: units))°") + .font(.system(size: 11)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.65)) + } + } + .foregroundStyle(.white) + .padding(14) + .background( + LinearGradient( + colors: [ + Color(red: 80 / 255, green: 100 / 255, blue: 140 / 255).opacity(0.42), + Color(red: 40 / 255, green: 50 / 255, blue: 80 / 255).opacity(0.30), + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + in: RoundedRectangle(cornerRadius: 18, style: .continuous) + ) + } + .buttonStyle(.plain) + + if isEditing { + Button(action: onRemove) { + Image(systemName: "trash") + .font(.system(size: 17, weight: .semibold)) + .frame(width: 52) + .frame(maxHeight: .infinity) + .background(Color(red: 255 / 255, green: 69 / 255, blue: 58 / 255).opacity(0.22), in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + .foregroundStyle(Color(hex: "#FF6B61")) + } + .accessibilityLabel("Remove") + } + } + } +} + +struct SearchLocationRow: View { + let location: WeatherLocation + let units: WeatherUnits + let saved: Bool + let added: Bool + let onPreview: () -> Void + let onAdd: () -> Void + + var body: some View { + HStack(spacing: 12) { + Button(action: onPreview) { + VStack(alignment: .leading, spacing: 2) { + Text(location.name) + .font(.system(size: 16, weight: .semibold)) + .tracking(-0.2) + Text(location.subtitle) + .font(.system(size: 12)) + .foregroundStyle(.white.opacity(0.6)) + Text("\(location.localTimeLabel) · \(location.conditionLabel)") + .font(.system(size: 12)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.55)) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + + VStack(alignment: .trailing, spacing: 3) { + Text(WeatherUnitFormatter.temperatureString(location.temperatureC, units: units)) + .font(.system(size: 22, weight: .light)) + .tracking(-0.5) + .monospacedDigit() + Text("H:\(WeatherUnitFormatter.temperature(location.highC, units: units))° L:\(WeatherUnitFormatter.temperature(location.lowC, units: units))°") + .font(.system(size: 10)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.55)) + } + + Button(action: onAdd) { + Image(systemName: saved || added ? "checkmark" : "plus") + .font(.system(size: 16, weight: .bold)) + .frame(width: 36, height: 36) + .background(saved || added ? Color(hex: "#34C759").opacity(0.85) : Color(hex: "#78B4FF").opacity(0.30), in: Circle()) + } + .disabled(saved) + .accessibilityLabel(saved ? "Saved" : "Add") + } + .foregroundStyle(.white) + .padding(.horizontal, 14) + .padding(.vertical, 12) + .background(.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 16, style: .continuous).stroke(.white.opacity(0.10), lineWidth: 0.5)) + } +} + +struct SearchSkeletonRow: View { + var body: some View { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 6) { + Capsule().fill(.white.opacity(0.10)).frame(width: 120, height: 14) + Capsule().fill(.white.opacity(0.07)).frame(width: 170, height: 11) + Capsule().fill(.white.opacity(0.07)).frame(width: 95, height: 11) + } + Spacer() + Circle().fill(.white.opacity(0.08)).frame(width: 36, height: 36) + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .background(.white.opacity(0.04), in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 16, style: .continuous).stroke(.white.opacity(0.06), lineWidth: 0.5)) + } +} diff --git a/example_projects/Weather/Weather/Views/Overlays/PrecipitationDetailView.swift b/example_projects/Weather/Weather/Views/Overlays/PrecipitationDetailView.swift new file mode 100644 index 00000000..92dbe016 --- /dev/null +++ b/example_projects/Weather/Weather/Views/Overlays/PrecipitationDetailView.swift @@ -0,0 +1,172 @@ +import SwiftUI + +struct PrecipitationDetailView: View { + @Environment(\.dismiss) private var dismiss + + let current: CurrentWeather + let rainyCurrent: CurrentWeather + let units: WeatherUnits + + var body: some View { + ZStack { + AtmosBackground(current: rainyCurrent, animationsEnabled: units.animationsEnabled, forcedParticle: .rain) + + ScrollView { + VStack(alignment: .leading, spacing: 12) { + header + + Text("PRECIPITATION") + .font(.system(size: 11, weight: .semibold)) + .tracking(1.4) + .foregroundStyle(visualTheme.foregroundMuted) + + Text("\(current.precipChance)%") + .font(.system(size: 48, weight: .thin)) + .tracking(-1.5) + .monospacedDigit() + + Text("chance over the next 24 hours") + .font(.system(size: 16)) + .foregroundStyle(visualTheme.foregroundMuted) + .padding(.bottom, 8) + + AtmosGlassCard(theme: visualTheme, padding: 16) { + VStack(alignment: .leading, spacing: 12) { + Text("NEXT 24 HOURS") + .font(.system(size: 11, weight: .semibold)) + .tracking(1.4) + .foregroundStyle(visualTheme.foregroundMuted) + precipChart + HStack { + Text("Now") + Spacer() + Text("6h") + Spacer() + Text("12h") + Spacer() + Text("18h") + Spacer() + Text("24h") + } + .font(.system(size: 11)) + .monospacedDigit() + .foregroundStyle(visualTheme.foregroundMuted) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + AtmosGlassCard(theme: visualTheme, padding: 16) { + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 14) { + Stat(label: "Total expected", value: totalExpected) + Stat(label: "Hours of rain", value: "6 hrs") + Stat(label: "Storm distance", value: "\(WeatherUnitFormatter.distance(14, units: units).value) \(WeatherUnitFormatter.distance(14, units: units).unit)") + Stat(label: "Lightning", value: "None") + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + AtmosGlassCard(theme: visualTheme, padding: 16) { + VStack(alignment: .leading, spacing: 8) { + Text("ABOUT") + .font(.system(size: 11, weight: .semibold)) + .tracking(1.4) + .foregroundStyle(visualTheme.foregroundMuted) + Text("Light rain is expected to begin around 2 PM and continue intermittently through the evening. Total rainfall is forecast to be modest, with the heaviest period between 4 and 6 PM. No thunderstorm activity is expected.") + .font(.system(size: 14)) + .lineSpacing(4) + .foregroundStyle(visualTheme.foreground.opacity(0.92)) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(.top, 54) + .padding(.horizontal, 16) + .padding(.bottom, 60) + } + } + .foregroundStyle(visualTheme.foreground) + .preferredColorScheme(visualTheme.statusDark ? .dark : .light) + .accessibilityIdentifier("weather.precipitationDetail") + } + + private var header: some View { + HStack { + AtmosGlassPill(theme: visualTheme, cornerRadius: 20, padding: 0, action: dismiss.callAsFunction) { + HStack(spacing: 6) { + Image(systemName: "chevron.left") + Text("Back") + } + .font(.system(size: 14, weight: .medium)) + .padding(.leading, 10) + .padding(.trailing, 14) + .padding(.vertical, 8) + } + Spacer() + AtmosGlassPill(theme: visualTheme, cornerRadius: 16, padding: 0, action: dismiss.callAsFunction) { + Image(systemName: "xmark") + .font(.system(size: 13, weight: .semibold)) + .frame(width: 32, height: 32) + } + .accessibilityLabel("Close") + } + .padding(.bottom, 10) + } + + private var precipChart: some View { + HStack(alignment: .bottom, spacing: 3) { + ForEach(Array(precipValues.enumerated()), id: \.offset) { _, value in + RoundedRectangle(cornerRadius: 2, style: .continuous) + .fill( + LinearGradient( + colors: [visualTheme.accent.opacity(0.93), visualTheme.accent.opacity(0.40)], + startPoint: .top, + endPoint: .bottom + ) + ) + .frame(maxWidth: .infinity) + .frame(height: max(3, 120 * CGFloat(value) / 100)) + } + } + .frame(height: 120, alignment: .bottom) + } + + private var precipValues: [Int] { + (0..<24).map { index in + let x = Double(index) / 23 + let noise = WeatherMetricHelpers.deterministicPercent(seed: current.precipChance * 97, index: index) * 0.2 + let value = Double(current.precipChance) * (0.4 + sin(x * .pi * 2.2) * 0.4 + noise) + return Int(max(0, min(100, value)).rounded()) + } + } + + private var visualTheme: WeatherTheme { + rainyCurrent.theme + } + + private var totalExpected: String { + if units.distance == .kilometers { + return String(format: "%.1f mm", 0.42 * 25.4) + } + return "0.42″" + } + + private struct Stat: View { + let label: String + let value: String + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(label.uppercased()) + .font(.system(size: 11, weight: .semibold)) + .tracking(1.2) + .foregroundStyle(.secondary) + Text(value) + .font(.system(size: 22, weight: .light)) + .tracking(-0.4) + .monospacedDigit() + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} diff --git a/example_projects/Weather/Weather/Views/Overlays/SettingsSheetView.swift b/example_projects/Weather/Weather/Views/Overlays/SettingsSheetView.swift new file mode 100644 index 00000000..1e8daa39 --- /dev/null +++ b/example_projects/Weather/Weather/Views/Overlays/SettingsSheetView.swift @@ -0,0 +1,153 @@ +import SwiftUI + +struct SettingsSheetView: View { + @Environment(\.dismiss) private var dismiss + + @Binding var units: WeatherUnits + + var body: some View { + ScrollView { + VStack(spacing: 0) { + HStack { + Text("Settings") + .font(.system(size: 22, weight: .bold)) + .tracking(-0.3) + Spacer() + Button(action: dismiss.callAsFunction) { + Image(systemName: "xmark") + .font(.system(size: 13, weight: .semibold)) + .frame(width: 30, height: 30) + .background(.white.opacity(0.15), in: Circle()) + } + .accessibilityLabel("Close") + } + .padding(.horizontal, 20) + .padding(.bottom, 26) + + SettingsGroup(title: "UNITS") { + SegmentRow(label: "Temperature", selection: $units.temperature, options: TemperatureUnit.allCases) + SegmentRow(label: "Wind speed", selection: $units.wind, options: WindUnit.allCases) + SegmentRow(label: "Pressure", selection: $units.pressure, options: PressureUnit.allCases) + SegmentRow(label: "Distance", selection: $units.distance, options: DistanceUnit.allCases) + } + + Spacer().frame(height: 22) + + SettingsGroup(title: "DISPLAY") { + ToggleRow(label: "Atmospheric animations", value: $units.animationsEnabled) + ToggleRow(label: "Severe weather alerts", value: $units.alertsEnabled) + ToggleRow(label: "Reduce transparency", value: $units.reduceTransparency) + } + + Text("Atmos · v1.0") + .font(.system(size: 12)) + .foregroundStyle(.white.opacity(0.4)) + .padding(.top, 28) + .padding(.bottom, 10) + } + .foregroundStyle(.white) + .padding(.top, 44) + .padding(.bottom, 28) + .frame(maxWidth: .infinity) + } + .scrollIndicators(.hidden) + .background(sheetBackground) + .accessibilityIdentifier("weather.settingsSheet") + } + + private var sheetBackground: some View { + ZStack { + Rectangle().fill(Color(red: 28 / 255, green: 28 / 255, blue: 30 / 255).opacity(0.85)) + Rectangle().fill(.ultraThinMaterial) + } + .overlay(alignment: .top) { + Rectangle().fill(.white.opacity(0.12)).frame(height: 0.5) + } + } +} + +private struct SettingsGroup: View { + let title: String + @ViewBuilder let content: Content + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.system(size: 11, weight: .semibold)) + .tracking(1.2) + .foregroundStyle(.white.opacity(0.55)) + .padding(.horizontal, 16) + + VStack(spacing: 0) { + content + } + .background(.white.opacity(0.05), in: groupedShape) + .padding(.horizontal, 20) + } + } + + private var groupedShape: some Shape { + if #available(iOS 26.0, *) { + AnyShape(ConcentricRectangle(corners: .concentric, isUniform: true)) + } else { + AnyShape(ContainerRelativeShape()) + } + } +} + +private struct SegmentRow: View { + let label: String + @Binding var selection: Option + let options: [Option] + + var body: some View { + HStack { + Text(label) + .font(.system(size: 15)) + Spacer() + HStack(spacing: 2) { + ForEach(options) { option in + Button(optionLabel(option)) { + selection = option + } + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(selection == option ? .black : .white) + .padding(.horizontal, 14) + .padding(.vertical, 6) + .background(selection == option ? .white.opacity(0.95) : .clear, in: RoundedRectangle(cornerRadius: 7, style: .continuous)) + } + } + .padding(2) + .background(Color(red: 120 / 255, green: 120 / 255, blue: 128 / 255).opacity(0.32), in: RoundedRectangle(cornerRadius: 9, style: .continuous)) + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + .overlay(alignment: .bottom) { + Rectangle().fill(.white.opacity(0.08)).frame(height: 0.5) + } + } + + private func optionLabel(_ option: Option) -> String { + if let option = option as? TemperatureUnit { return option.label } + if let option = option as? WindUnit { return option.label } + if let option = option as? PressureUnit { return option.label } + if let option = option as? DistanceUnit { return option.label } + return "\(option.id)" + } +} + +private struct ToggleRow: View { + let label: String + @Binding var value: Bool + + var body: some View { + Toggle(label, isOn: $value) + .font(.system(size: 15)) + .tint(Color(hex: "#34C759")) + .padding(.horizontal, 16) + .padding(.vertical, 14) + .overlay(alignment: .bottom) { + Rectangle().fill(.white.opacity(0.08)).frame(height: 0.5) + } + } +} diff --git a/example_projects/Weather/Weather/Views/Sections/ConditionGrid.swift b/example_projects/Weather/Weather/Views/Sections/ConditionGrid.swift new file mode 100644 index 00000000..d351a24d --- /dev/null +++ b/example_projects/Weather/Weather/Views/Sections/ConditionGrid.swift @@ -0,0 +1,369 @@ +import SwiftUI + +struct ConditionGrid: View { + let current: CurrentWeather + let units: WeatherUnits + let onOpenPrecipitation: () -> Void + + private let columns = [GridItem(.flexible(), spacing: 10), GridItem(.flexible(), spacing: 10)] + + var body: some View { + VStack(spacing: 10) { + WindCard(current: current, units: units) + + LazyVGrid(columns: columns, spacing: 10) { + ConditionTile(title: "UV INDEX", value: "\(current.uvIndex)", caption: current.uvLabel, current: current) { + UVViz(value: current.uvIndex, theme: current.theme) + } + ConditionTile(title: "HUMIDITY", value: "\(current.humidity)%", caption: "Dew point: \(WeatherUnitFormatter.temperature(current.dewPointC, units: units))°", current: current) { + FilledBar(value: Double(current.humidity) / 100, theme: current.theme) + } + ConditionTile( + title: "PRECIP.", + value: "\(current.precipChance)%", + caption: "Next 24 hours", + current: current, + action: onOpenPrecipitation, + accessibilityIdentifier: "weather.precipitationCard" + ) { + PrecipBars(value: current.precipChance, theme: current.theme) + } + ConditionTile(title: "VISIBILITY", value: visibility.value + " " + visibility.unit, caption: current.visibilityKilometers >= 13 ? "Clear view" : "Reduced", current: current) { + VisibilityViz(value: current.visibilityKilometers, theme: current.theme) + } + PressureTile(current: current, pressure: pressure) + SunMiniCard(current: current) + } + } + } + + private var visibility: FormattedMeasurement { + WeatherUnitFormatter.distance(current.visibilityKilometers, units: units) + } + + private var pressure: FormattedMeasurement { + WeatherUnitFormatter.pressure(current.pressureMillibars, units: units) + } +} + +private struct ConditionTile: View { + let title: String + let value: String + var unit: String? + let caption: String + let current: CurrentWeather + var action: (() -> Void)? + var accessibilityIdentifier: String? + @ViewBuilder let visual: Visual + + @ViewBuilder + var body: some View { + if let action { + Button(action: action) { + card + .contentShape(RoundedRectangle(cornerRadius: 22, style: .continuous)) + } + .buttonStyle(.plain) + .accessibilityIdentifier(accessibilityIdentifier ?? "") + } else if let accessibilityIdentifier { + card + .accessibilityIdentifier(accessibilityIdentifier) + } else { + card + } + } + + private var card: some View { + AtmosGlassCard(theme: current.theme, padding: 14, isInteractive: action != nil) { + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.system(size: 10.5, weight: .semibold)) + .tracking(1.3) + .foregroundStyle(current.theme.foregroundMuted) + + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(value) + .font(.system(size: 28, weight: .light)) + .tracking(-0.6) + .monospacedDigit() + if let unit { + Text(unit) + .font(.system(size: 12)) + .foregroundStyle(current.theme.foregroundMuted) + } + } + + Spacer() + visual + Text(caption) + .font(.system(size: 12)) + .foregroundStyle(current.theme.foregroundMuted) + .textCase(.none) + } + .frame(height: 124) + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} + +private struct UVViz: View { + let value: Int + let theme: WeatherTheme + + var body: some View { + GeometryReader { proxy in + Capsule() + .fill( + LinearGradient( + colors: [Color(hex: "#6FCF97"), Color(hex: "#F2C94C"), Color(hex: "#F2994A"), Color(hex: "#EB5757"), Color(hex: "#BB6BD9")], + startPoint: .leading, + endPoint: .trailing + ) + ) + .frame(height: 4) + .overlay(alignment: .leading) { + Circle() + .fill(theme.foreground) + .frame(width: 12, height: 12) + .overlay(Circle().stroke(theme.backgroundStops[safe: 1]?.color ?? .clear, lineWidth: 2)) + .offset(x: proxy.size.width * min(1, Double(value) / 11) - 6) + } + .frame(maxHeight: .infinity, alignment: .center) + } + .frame(height: 12) + } +} + +private struct FilledBar: View { + let value: Double + let theme: WeatherTheme + + var body: some View { + GeometryReader { proxy in + Capsule() + .fill(theme.cardBorder) + .overlay(alignment: .leading) { + Capsule() + .fill(theme.accent) + .frame(width: proxy.size.width * value) + } + } + .frame(height: 4) + } +} + +private struct PrecipBars: View { + let value: Int + let theme: WeatherTheme + private let heights: [Double] = [0.20, 0.40, 0.80, 0.95, 0.70, 0.30, 0.15, 0.10] + + var body: some View { + HStack(alignment: .bottom, spacing: 2) { + ForEach(Array(heights.enumerated()), id: \.offset) { index, height in + RoundedRectangle(cornerRadius: 1.5, style: .continuous) + .fill(isFilled(index: index) ? theme.accent : theme.cardBorder) + .frame(maxWidth: .infinity) + .frame(height: 22 * height) + } + } + .frame(height: 22) + } + + private func isFilled(index: Int) -> Bool { + let clampedValue = min(100, max(0, value)) + return Double(index) < Double(clampedValue) / 100 * Double(heights.count) + } +} + +private struct VisibilityViz: View { + let value: Double + let theme: WeatherTheme + + var body: some View { + let filled = Int((value / 16.1 * 5).rounded()) + HStack(spacing: 3) { + ForEach(0..<5, id: \.self) { index in + Capsule() + .fill(index < filled ? theme.foreground.opacity(0.85) : theme.cardBorder) + .frame(height: 3) + } + } + } +} + +private struct PressureTile: View { + let current: CurrentWeather + let pressure: FormattedMeasurement + + private let standardPressureMillibars = 1013 + + var body: some View { + AtmosGlassCard(theme: current.theme, padding: 14, isInteractive: false) { + VStack(alignment: .leading, spacing: 6) { + Text("PRESSURE") + .font(.system(size: 10.5, weight: .semibold)) + .tracking(1.3) + .foregroundStyle(current.theme.foregroundMuted) + + PressureGauge( + pressureMillibars: current.pressureMillibars, + pressure: pressure, + theme: current.theme + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(current.pressureTrend.arrowSymbol) + .font(.system(size: 12)) + .foregroundStyle(current.theme.accent) + Text(current.pressureTrend.displayLabel.capitalized) + .font(.system(size: 12)) + .foregroundStyle(current.theme.foregroundMuted) + .textCase(.none) + } + } + .frame(height: 124) + .frame(maxWidth: .infinity, alignment: .leading) + } + .accessibilityElement(children: .combine) + .accessibilityLabel("Pressure \(pressure.value) \(spokenUnit), \(current.pressureTrend.displayLabel)") + .accessibilityValue(standardOffsetDescription) + } + + private var spokenUnit: String { + pressure.unit == "inHg" ? "inches of mercury" : "millibars" + } + + private var standardOffsetDescription: String { + let delta = current.pressureMillibars - standardPressureMillibars + if delta == 0 { + return "At standard" + } + let direction = delta > 0 ? "above" : "below" + return "\(abs(delta)) \(direction) standard" + } +} + +private struct PressureGauge: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + let pressureMillibars: Int + let pressure: FormattedMeasurement + let theme: WeatherTheme + + private let minPressure = 970.0 + private let maxPressure = 1050.0 + + var body: some View { + ZStack { + Canvas { context, size in + let center = CGPoint(x: size.width / 2, y: size.height - 7) + let radius = min((size.width - 10) / 2, size.height - 18) + + context.stroke( + arcPath(center: center, radius: radius, from: 0, to: 1), + with: .color(theme.cardBorder), + style: StrokeStyle(lineWidth: 1, lineCap: .round) + ) + + for tickIndex in 0...48 { + let progress = Double(tickIndex) / 48 + let majorTick = tickIndex.isMultiple(of: 12) + let tickLength = majorTick ? 9.0 : 5.0 + let tickWidth = majorTick ? 1.2 : 0.8 + let tickColor = majorTick ? theme.foreground.opacity(0.75) : theme.foregroundMuted.opacity(0.55) + let angle = angleFor(progress) + + var tickPath = Path() + tickPath.move(to: point(center: center, radius: radius - tickLength, angle: angle)) + tickPath.addLine(to: point(center: center, radius: radius, angle: angle)) + context.stroke(tickPath, with: .color(tickColor), style: StrokeStyle(lineWidth: tickWidth, lineCap: .round)) + } + + let markerCenter = point(center: center, radius: radius, angle: angleFor(normalizedPressure)) + context.fill( + Path(ellipseIn: CGRect(x: markerCenter.x - 6.5, y: markerCenter.y - 6.5, width: 13, height: 13)), + with: .color(theme.accent.opacity(0.35)) + ) + + let dotPath = Path(ellipseIn: CGRect(x: markerCenter.x - 3.5, y: markerCenter.y - 3.5, width: 7, height: 7)) + context.fill(dotPath, with: .color(theme.accent)) + context.stroke(dotPath, with: .color(theme.cardBorder), style: StrokeStyle(lineWidth: 1)) + } + + VStack(spacing: 1) { + Text(pressure.value) + .font(.system(size: pressure.unit == "inHg" ? 22 : 24, weight: .light)) + .tracking(-0.5) + .monospacedDigit() + .contentTransition(.numericText()) + .foregroundStyle(theme.foreground) + + Text(pressure.unit) + .font(.system(size: 11)) + .foregroundStyle(theme.foregroundMuted) + } + .offset(y: 12) + + GeometryReader { proxy in + let center = CGPoint(x: proxy.size.width / 2, y: proxy.size.height - 7) + let radius = min((proxy.size.width - 10) / 2, proxy.size.height - 18) + Text("L") + .font(.system(size: 11, weight: .semibold)) + .tracking(0.6) + .foregroundStyle(theme.foregroundMuted.opacity(0.65)) + .position(labelPoint(center: center, radius: radius, progress: 0)) + Text("H") + .font(.system(size: 11, weight: .semibold)) + .tracking(0.6) + .foregroundStyle(theme.foregroundMuted.opacity(0.65)) + .position(labelPoint(center: center, radius: radius, progress: 1)) + } + .allowsHitTesting(false) + .accessibilityHidden(true) + } + .frame(height: 88) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .accessibilityHidden(true) + .animation(reduceMotion ? nil : .easeInOut(duration: 0.45), value: pressureMillibars) + } + + private var normalizedPressure: Double { + let rawValue = (Double(pressureMillibars) - minPressure) / (maxPressure - minPressure) + return min(1, max(0, rawValue)) + } + + private func angleFor(_ progress: Double) -> Double { + .pi + .pi * progress + } + + private func point(center: CGPoint, radius: Double, angle: Double) -> CGPoint { + CGPoint( + x: center.x + CGFloat(cos(angle) * radius), + y: center.y + CGFloat(sin(angle) * radius) + ) + } + + private func labelPoint(center: CGPoint, radius: Double, progress: Double) -> CGPoint { + let base = point(center: center, radius: radius + 8, angle: angleFor(progress)) + return CGPoint(x: base.x, y: base.y + 4) + } + + private func arcPath(center: CGPoint, radius: Double, from start: Double, to end: Double) -> Path { + let steps = max(2, Int((end - start) * 48)) + var path = Path() + + for index in 0...steps { + let progress = start + (end - start) * Double(index) / Double(steps) + let nextPoint = point(center: center, radius: radius, angle: angleFor(progress)) + if index == 0 { + path.move(to: nextPoint) + } else { + path.addLine(to: nextPoint) + } + } + + return path + } +} + diff --git a/example_projects/Weather/Weather/Views/Sections/DailyForecastCard.swift b/example_projects/Weather/Weather/Views/Sections/DailyForecastCard.swift new file mode 100644 index 00000000..30b6ae56 --- /dev/null +++ b/example_projects/Weather/Weather/Views/Sections/DailyForecastCard.swift @@ -0,0 +1,113 @@ +import SwiftUI + +struct DailyForecastCard: View { + let forecasts: [DailyForecast] + let current: CurrentWeather + let units: WeatherUnits + + var body: some View { + AtmosGlassCard(theme: current.theme, padding: 0) { + VStack(spacing: 0) { + Text("7-DAY FORECAST") + .font(.system(size: 11, weight: .semibold)) + .tracking(1.4) + .foregroundStyle(current.theme.foregroundMuted) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.top, 14) + .padding(.bottom, 10) + + Divider().overlay(current.theme.cardBorder) + + ForEach(Array(forecasts.enumerated()), id: \.element.id) { index, forecast in + DailyRow(forecast: forecast, current: current, units: units) + if index < forecasts.count - 1 { + Divider().overlay(current.theme.cardBorder).padding(.leading, 16) + } + } + } + } + } +} + +private struct DailyRow: View { + let forecast: DailyForecast + let current: CurrentWeather + let units: WeatherUnits + + var body: some View { + Grid(horizontalSpacing: 12, verticalSpacing: 0) { + GridRow { + Text(forecast.dayLabel) + .font(.system(size: 17, weight: forecast.isToday ? .semibold : .regular)) + .frame(width: 50, alignment: .leading) + + WeatherIconView( + kind: forecast.iconKind, + size: 26, + foreground: current.theme.foreground, + accent: current.theme.accent + ) + .frame(width: 32) + + Text(WeatherUnitFormatter.temperatureString(forecast.lowC, units: units)) + .font(.system(size: 16)) + .monospacedDigit() + .foregroundStyle(current.theme.foregroundMuted) + .frame(width: 38, alignment: .trailing) + + RangeBar(forecast: forecast, currentTemperatureC: current.temperatureC, theme: current.theme) + + Text(WeatherUnitFormatter.temperatureString(forecast.highC, units: units)) + .font(.system(size: 16)) + .monospacedDigit() + .frame(width: 38, alignment: .trailing) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } +} + +private struct RangeBar: View { + let forecast: DailyForecast + let currentTemperatureC: Int + let theme: WeatherTheme + + var body: some View { + GeometryReader { proxy in + let range = Double(max(forecast.weekHighC - forecast.weekLowC, 1)) + let left = Double(forecast.lowC - forecast.weekLowC) / range + let width = Double(forecast.highC - forecast.lowC) / range + + ZStack(alignment: .leading) { + Capsule().fill(theme.cardBorder) + Capsule() + .fill( + LinearGradient( + colors: [Color(hex: "#6BB6FF"), theme.accent, Color(hex: "#FF8A6B")], + startPoint: .leading, + endPoint: .trailing + ) + ) + .frame(width: max(6, proxy.size.width * width)) + .offset(x: proxy.size.width * left) + + if forecast.isToday { + let currentPosition = Double(currentTemperatureC - forecast.weekLowC) / range + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(theme.foreground) + .frame(width: 8, height: 12) + .overlay( + RoundedRectangle(cornerRadius: 4, style: .continuous) + .stroke(theme.backgroundStops[safe: 1]?.color ?? .clear, lineWidth: 2) + ) + .shadow(color: .black.opacity(0.15), radius: 1) + .offset(x: proxy.size.width * currentPosition - 4) + } + } + } + .frame(height: 12) + } +} + diff --git a/example_projects/Weather/Weather/Views/Sections/HourlyForecastCard.swift b/example_projects/Weather/Weather/Views/Sections/HourlyForecastCard.swift new file mode 100644 index 00000000..e329c6e7 --- /dev/null +++ b/example_projects/Weather/Weather/Views/Sections/HourlyForecastCard.swift @@ -0,0 +1,139 @@ +import SwiftUI + +struct HourlyForecastCard: View { + let forecasts: [HourlyForecast] + let current: CurrentWeather + let units: WeatherUnits + + private let cellWidth: CGFloat = 56 + + var body: some View { + AtmosGlassCard(theme: current.theme, padding: 0) { + VStack(alignment: .leading, spacing: 0) { + sectionLabel("HOURLY FORECAST") + .padding(.horizontal, 16) + .padding(.top, 12) + .padding(.bottom, 6) + + ScrollView(.horizontal) { + ZStack(alignment: .topLeading) { + HourlyCurve(forecasts: forecasts, theme: current.theme) + .frame(width: totalWidth, height: 56) + .offset(y: 26) + + HStack(spacing: 0) { + ForEach(forecasts) { forecast in + VStack(spacing: 4) { + Text(forecast.hourLabel) + .font(.system(size: 13)) + .tracking(-0.1) + .foregroundStyle(current.theme.foregroundMuted) + .monospacedDigit() + Spacer() + WeatherIconView( + kind: forecast.iconKind, + size: 22, + foreground: current.theme.foreground, + accent: current.theme.accent + ) + Spacer() + Text(WeatherUnitFormatter.temperatureString(forecast.temperatureC, units: units)) + .font(.system(size: 17, weight: .medium)) + .monospacedDigit() + } + .frame(width: cellWidth, height: 116) + .padding(.top, 6) + .padding(.bottom, 8) + } + } + } + .frame(width: totalWidth, height: 116) + } + .scrollIndicators(.hidden) + .padding(.bottom, 4) + } + } + } + + private var totalWidth: CGFloat { + CGFloat(forecasts.count) * cellWidth + } + + private func sectionLabel(_ text: String) -> some View { + Text(text) + .font(.system(size: 11, weight: .semibold)) + .tracking(1.4) + .foregroundStyle(current.theme.foregroundMuted) + } +} + +private struct HourlyCurve: View { + let forecasts: [HourlyForecast] + let theme: WeatherTheme + + var body: some View { + GeometryReader { proxy in + let points = curvePoints(size: proxy.size) + ZStack { + filledPath(points: points, size: proxy.size) + .fill( + LinearGradient( + colors: [theme.accent.opacity(0.32), theme.accent.opacity(0)], + startPoint: .top, + endPoint: .bottom + ) + ) + linePath(points: points) + .stroke(theme.accent, style: StrokeStyle(lineWidth: 1.5, lineCap: .round)) + ForEach(Array(points.enumerated()), id: \.offset) { offset, point in + Circle() + .fill(theme.foreground.opacity(offset == 0 ? 1 : 0.55)) + .frame(width: offset == 0 ? 6 : 3.2, height: offset == 0 ? 6 : 3.2) + .position(point) + } + } + } + } + + private func curvePoints(size: CGSize) -> [CGPoint] { + let temperatures = forecasts.map(\.temperatureC) + let minTemp = temperatures.min() ?? 0 + let maxTemp = temperatures.max() ?? minTemp + 1 + let range = max(maxTemp - minTemp, 1) + let step = size.width / CGFloat(max(forecasts.count, 1)) + let pad: CGFloat = 8 + + return forecasts.enumerated().map { index, forecast in + let normalized = CGFloat(forecast.temperatureC - minTemp) / CGFloat(range) + return CGPoint( + x: (CGFloat(index) + 0.5) * step, + y: pad + (1 - normalized) * (size.height - pad * 2) + ) + } + } + + private func linePath(points: [CGPoint]) -> Path { + Path { path in + guard let first = points.first else { return } + path.move(to: first) + for index in points.indices.dropFirst() { + let previous = points[index - 1] + let current = points[index] + let midX = previous.x + (current.x - previous.x) / 2 + path.addCurve( + to: current, + control1: CGPoint(x: midX, y: previous.y), + control2: CGPoint(x: midX, y: current.y) + ) + } + } + } + + private func filledPath(points: [CGPoint], size: CGSize) -> Path { + var path = linePath(points: points) + path.addLine(to: CGPoint(x: size.width, y: size.height)) + path.addLine(to: CGPoint(x: 0, y: size.height)) + path.closeSubpath() + return path + } +} diff --git a/example_projects/Weather/Weather/Views/Sections/SunMiniCard.swift b/example_projects/Weather/Weather/Views/Sections/SunMiniCard.swift new file mode 100644 index 00000000..0783c1ce --- /dev/null +++ b/example_projects/Weather/Weather/Views/Sections/SunMiniCard.swift @@ -0,0 +1,147 @@ +import SwiftUI + +struct SunMiniCard: View { + let current: CurrentWeather + + var body: some View { + AtmosGlassCard(theme: current.theme, padding: 14) { + VStack(alignment: .leading, spacing: 6) { + Text(primaryLabel) + .font(.system(size: 10.5, weight: .semibold)) + .tracking(1.3) + .foregroundStyle(current.theme.foregroundMuted) + + Text(primaryTime) + .font(.system(size: 22, weight: .light)) + .tracking(-0.5) + .monospacedDigit() + + Spacer(minLength: 4) + SunArcMini(current: current) + .frame(height: 56) + .padding(.horizontal, -6) + + Text("\(secondaryLabel) \(secondaryTime)") + .font(.system(size: 11)) + .monospacedDigit() + .foregroundStyle(current.theme.foregroundMuted) + .lineLimit(1) + } + .frame(height: 124) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private var primaryLabel: String { + switch current.solarProgress { + case .beforeSunrise: + "SUNRISE" + case .daylight: + "SUNSET" + case .afterSunset: + "SUNRISE" + } + } + + private var primaryTime: String { + switch current.solarProgress { + case .beforeSunrise, .afterSunset: + current.sunrise.fullClockLabel + case .daylight: + current.sunset.fullClockLabel + } + } + + private var secondaryLabel: String { + switch current.solarProgress { + case .beforeSunrise: + "Sunset at" + case .daylight: + "Sunrise was" + case .afterSunset: + "Sunset was" + } + } + + private var secondaryTime: String { + switch current.solarProgress { + case .beforeSunrise: + current.sunset.fullClockLabel + case .daylight: + current.sunrise.fullClockLabel + case .afterSunset: + current.sunset.fullClockLabel + } + } +} + +private struct SunArcMini: View { + let current: CurrentWeather + + var body: some View { + Canvas { context, size in + let pad: CGFloat = 8 + let centerX = size.width / 2 + let centerY = size.height - 10 + let radius = min((size.width - pad * 2) / 2, centerY - pad - 4) + let daylightFraction = current.solarProgress.daylightFraction + let point = daylightFraction.map { fraction in + CGPoint( + x: centerX - radius * cos(.pi * fraction), + y: centerY - radius * sin(.pi * fraction) + ) + } + + var horizon = Path() + horizon.move(to: CGPoint(x: pad, y: centerY)) + horizon.addLine(to: CGPoint(x: size.width - pad, y: centerY)) + context.stroke(horizon, with: .color(current.theme.cardBorder), style: StrokeStyle(lineWidth: 1, dash: [2, 3])) + + var arc = Path() + arc.addArc(center: CGPoint(x: centerX, y: centerY), radius: radius, startAngle: .degrees(180), endAngle: .degrees(0), clockwise: false) + context.stroke(arc, with: .color(current.theme.cardBorder), lineWidth: 1.2) + + if let daylightFraction, daylightFraction > 0 { + let travelled = sunArcPath(center: CGPoint(x: centerX, y: centerY), radius: radius, from: 0, to: daylightFraction) + context.stroke(travelled, with: .color(current.theme.accent.opacity(0.85)), style: StrokeStyle(lineWidth: 2, lineCap: .round)) + } + + if let point { + context.fill(Path(ellipseIn: CGRect(x: point.x - 8, y: point.y - 8, width: 16, height: 16)), with: .color(current.theme.accent.opacity(0.22))) + context.fill(Path(ellipseIn: CGRect(x: point.x - 3, y: point.y - 3, width: 6, height: 6)), with: .color(current.theme.accent)) + } + } + } +} + +private func sunArcPath(center: CGPoint, radius: CGFloat, from startFraction: Double, to endFraction: Double) -> Path { + let steps = max(2, Int((endFraction - startFraction) * 48)) + var path = Path() + + for index in 0...steps { + let fraction = startFraction + (endFraction - startFraction) * Double(index) / Double(steps) + let point = CGPoint( + x: center.x - radius * cos(.pi * fraction), + y: center.y - radius * sin(.pi * fraction) + ) + + if index == 0 { + path.move(to: point) + } else { + path.addLine(to: point) + } + } + + return path +} + +private extension SolarDayProgress { + var daylightFraction: Double? { + switch self { + case let .daylight(fraction): + fraction + case .beforeSunrise, .afterSunset: + nil + } + } +} diff --git a/example_projects/Weather/Weather/Views/Sections/WeatherHeroView.swift b/example_projects/Weather/Weather/Views/Sections/WeatherHeroView.swift new file mode 100644 index 00000000..c1f1b833 --- /dev/null +++ b/example_projects/Weather/Weather/Views/Sections/WeatherHeroView.swift @@ -0,0 +1,59 @@ +import SwiftUI + +struct WeatherHeroView: View { + let locationName: String + let locationSubtitle: String + let current: CurrentWeather + let units: WeatherUnits + + var body: some View { + VStack(spacing: 0) { + Text(locationName) + .font(.system(size: 22, weight: .medium)) + .tracking(-0.3) + .accessibilityIdentifier("weather.heroLocation") + + Text(locationSubtitle) + .font(.system(size: 13)) + .tracking(0.2) + .foregroundStyle(current.theme.foregroundMuted) + .padding(.top, 2) + + HStack(alignment: .top, spacing: 0) { + Text("\(WeatherUnitFormatter.temperature(current.temperatureC, units: units))") + .font(.system(size: 132, weight: .ultraLight)) + .tracking(-6) + .monospacedDigit() + .lineLimit(1) + .minimumScaleFactor(0.65) + + Text("°") + .font(.system(size: 36, weight: .thin)) + .padding(.top, 18) + .padding(.leading, -6) + } + .lineSpacing(0) + .padding(.top, -2) + + Text(current.conditionLabel) + .font(.system(size: 19)) + .foregroundStyle(current.theme.foreground.opacity(0.92)) + + Text("H:\(WeatherUnitFormatter.temperature(current.highC, units: units))° L:\(WeatherUnitFormatter.temperature(current.lowC, units: units))°") + .font(.system(size: 15)) + .monospacedDigit() + .foregroundStyle(current.theme.foregroundMuted) + .padding(.top, 2) + + Text("\"\(current.heroPhrase)\"") + .font(.system(size: 14, weight: .light).italic()) + .tracking(0.1) + .foregroundStyle(current.theme.foregroundMuted) + .padding(.top, 10) + } + .multilineTextAlignment(.center) + .padding(.top, 12) + .padding(.horizontal, 24) + .padding(.bottom, 6) + } +} diff --git a/example_projects/Weather/Weather/Views/Sections/WindCard.swift b/example_projects/Weather/Weather/Views/Sections/WindCard.swift new file mode 100644 index 00000000..7a5fa128 --- /dev/null +++ b/example_projects/Weather/Weather/Views/Sections/WindCard.swift @@ -0,0 +1,202 @@ +import SwiftUI + +struct WindCard: View { + let current: CurrentWeather + let units: WeatherUnits + + var body: some View { + AtmosGlassCard(theme: current.theme, padding: 18) { + VStack(alignment: .leading, spacing: 6) { + Text("WIND") + .font(.system(size: 10.5, weight: .semibold)) + .tracking(1.3) + .foregroundStyle(current.theme.foregroundMuted) + + HStack(spacing: 18) { + WindCompass(current: current, units: units) + .frame(width: 168, height: 168) + + VStack(spacing: 14) { + WindStat( + label: "LULL", + kph: max(0, Int((Double(current.windKph) * 0.55).rounded())), + color: current.theme.foregroundFaint, + units: units, + theme: current.theme, + muted: true + ) + WindStat( + label: "NOW", + kph: current.windKph, + color: current.theme.accent, + units: units, + theme: current.theme + ) + WindStat( + label: "GUST", + kph: Int((Double(current.windKph) * 1.65).rounded()), + color: current.theme.foreground, + units: units, + theme: current.theme + ) + + Text("From the \(WeatherMetricHelpers.longDirection(current.windDirection)) · \(WeatherMetricHelpers.beaufortLabel(current.windKph))") + .font(.system(size: 11)) + .tracking(0.2) + .foregroundStyle(current.theme.foregroundMuted) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + } + } +} + +private struct WindCompass: View { + let current: CurrentWeather + let units: WeatherUnits + + var body: some View { + ZStack { + Canvas { context, size in + let center = CGPoint(x: size.width / 2, y: size.height / 2) + let outer: CGFloat = 76 + let inner: CGFloat = 56 + strokeCircle(context: &context, center: center, radius: outer, opacity: 0.18) + strokeCircle(context: &context, center: center, radius: inner, opacity: 0.10) + drawTicks(context: &context, center: center, outer: outer) + drawWindArc(context: &context, center: center, radius: inner + 8) + drawNeedle(context: &context, center: center, outer: outer, inner: inner) + } + + ForEach(["N", "E", "S", "W"], id: \.self) { label in + Text(label) + .font(.system(size: 11, weight: .semibold)) + .tracking(1) + .foregroundStyle(label == "N" ? current.theme.accent : current.theme.foreground.opacity(0.55)) + .position(labelPosition(label)) + } + + VStack(spacing: 2) { + Text(WeatherMetricHelpers.compassAbbreviation(current.windDirection)) + .font(.system(size: 11, weight: .semibold)) + .tracking(1.4) + .foregroundStyle(current.theme.foregroundMuted) + Text(WeatherUnitFormatter.wind(current.windKph, units: units).value) + .font(.system(size: 30, weight: .thin)) + .tracking(-1) + .monospacedDigit() + Text(WeatherUnitFormatter.wind(current.windKph, units: units).unit) + .font(.system(size: 10.5)) + .foregroundStyle(current.theme.foregroundMuted) + } + } + } + + private func labelPosition(_ label: String) -> CGPoint { + let degrees = cardinalDegrees(label) + let radians = (degrees - 90) * .pi / 180 + return CGPoint(x: 84 + cos(radians) * 90, y: 88 + sin(radians) * 90) + } + + private func cardinalDegrees(_ label: String) -> Double { + switch label { + case "N": 0 + case "E": 90 + case "S": 180 + case "W": 270 + default: 0 + } + } + + private func strokeCircle(context: inout GraphicsContext, center: CGPoint, radius: CGFloat, opacity: Double) { + var path = Path() + path.addEllipse(in: CGRect(x: center.x - radius, y: center.y - radius, width: radius * 2, height: radius * 2)) + context.stroke(path, with: .color(current.theme.foreground.opacity(opacity)), lineWidth: 1) + } + + private func drawTicks(context: inout GraphicsContext, center: CGPoint, outer: CGFloat) { + for angle in stride(from: 0, to: 360, by: 15) { + let cardinal = angle % 90 == 0 + let intercardinal = angle % 45 == 0 + let length: CGFloat = cardinal ? 12 : intercardinal ? 8 : 5 + let width: CGFloat = cardinal ? 1.6 : 1 + let opacity = cardinal ? 0.55 : intercardinal ? 0.32 : 0.16 + let radians = (Double(angle) - 90) * .pi / 180 + var path = Path() + path.move(to: CGPoint(x: center.x + cos(radians) * (outer - length), y: center.y + sin(radians) * (outer - length))) + path.addLine(to: CGPoint(x: center.x + cos(radians) * outer, y: center.y + sin(radians) * outer)) + context.stroke(path, with: .color(current.theme.foreground.opacity(opacity)), style: StrokeStyle(lineWidth: width, lineCap: .round)) + } + } + + private func drawWindArc(context: inout GraphicsContext, center: CGPoint, radius: CGFloat) { + let angle = current.windDirection.degrees + var path = Path() + path.addArc( + center: center, + radius: radius, + startAngle: .degrees(angle - 28 - 90), + endAngle: .degrees(angle + 28 - 90), + clockwise: false + ) + context.stroke(path, with: .color(current.theme.accent.opacity(0.85)), style: StrokeStyle(lineWidth: 3, lineCap: .round)) + } + + private func drawNeedle(context: inout GraphicsContext, center: CGPoint, outer: CGFloat, inner: CGFloat) { + let angle = current.windDirection.degrees + let start = point(center: center, radius: outer - 18, degrees: angle) + let end = point(center: center, radius: inner - 18, degrees: angle + 180) + var path = Path() + path.move(to: start) + path.addLine(to: end) + context.stroke(path, with: .color(current.theme.accent.opacity(0.9)), style: StrokeStyle(lineWidth: 2, lineCap: .round)) + var dot = Path() + dot.addEllipse(in: CGRect(x: center.x - 3.5, y: center.y - 3.5, width: 7, height: 7)) + context.fill(dot, with: .color(current.theme.accent)) + } + + private func point(center: CGPoint, radius: CGFloat, degrees: Double) -> CGPoint { + let radians = (degrees - 90) * .pi / 180 + return CGPoint(x: center.x + cos(radians) * radius, y: center.y + sin(radians) * radius) + } +} + +private struct WindStat: View { + let label: String + let kph: Int + let color: Color + let units: WeatherUnits + let theme: WeatherTheme + var muted = false + + var body: some View { + let value = WeatherUnitFormatter.wind(kph, units: units) + VStack(spacing: 4) { + HStack(alignment: .firstTextBaseline) { + Text(label) + .font(.system(size: 10, weight: .semibold)) + .tracking(1.3) + .foregroundStyle(theme.foregroundMuted) + Spacer() + Text(value.value) + .font(.system(size: 17, weight: .medium)) + .monospacedDigit() + Text(value.unit) + .font(.system(size: 11)) + .foregroundStyle(theme.foregroundMuted) + } + + GeometryReader { proxy in + Capsule() + .fill(theme.cardBorder) + .overlay(alignment: .leading) { + Capsule() + .fill(color.opacity(muted ? 0.55 : 1)) + .frame(width: proxy.size.width * min(1, max(0.04, Double(kph) / 100))) + } + } + .frame(height: 4) + } + } +} diff --git a/example_projects/Weather/Weather/Views/Shared/AtmosGlass.swift b/example_projects/Weather/Weather/Views/Shared/AtmosGlass.swift new file mode 100644 index 00000000..8ba98d48 --- /dev/null +++ b/example_projects/Weather/Weather/Views/Shared/AtmosGlass.swift @@ -0,0 +1,160 @@ +import SwiftUI + +private struct AtmosReduceTransparencyKey: EnvironmentKey { + static let defaultValue = false +} + +extension EnvironmentValues { + var atmosReduceTransparency: Bool { + get { self[AtmosReduceTransparencyKey.self] } + set { self[AtmosReduceTransparencyKey.self] = newValue } + } +} + +struct AtmosGlassCard: View { + let theme: WeatherTheme + var cornerRadius: CGFloat = 22 + var padding: CGFloat = 16 + var isInteractive: Bool = false + var action: (() -> Void)? + @ViewBuilder let content: Content + + var body: some View { + AtmosGlassSurface( + theme: theme, + cornerRadius: cornerRadius, + padding: padding, + isInteractive: isInteractive || action != nil, + action: action, + content: { content } + ) + } +} + +struct AtmosGlassPill: View { + let theme: WeatherTheme + var cornerRadius: CGFloat = 20 + var padding: CGFloat = 0 + var isInteractive: Bool = false + var action: (() -> Void)? + @ViewBuilder let content: Content + + var body: some View { + AtmosGlassSurface( + theme: theme, + cornerRadius: cornerRadius, + padding: padding, + isInteractive: isInteractive || action != nil, + action: action, + content: { content } + ) + } +} + +private struct AtmosGlassSurface: View { + @Environment(\.accessibilityReduceTransparency) private var accessibilityReduceTransparency + @Environment(\.atmosReduceTransparency) private var atmosReduceTransparency + + let theme: WeatherTheme + let cornerRadius: CGFloat + let padding: CGFloat + let isInteractive: Bool + let action: (() -> Void)? + @ViewBuilder let content: Content + + @ViewBuilder + var body: some View { + if let action { + Button(action: action) { + surface + } + .buttonStyle(.plain) + } else { + surface + } + } + + @ViewBuilder + private var surface: some View { + let paddedContent = content + .padding(padding) + .clipShape(shape) + + if shouldReduceTransparency { + paddedContent + .background(reducedTransparencyBackground) + .overlay(border) + .clipShape(shape) + } else if #available(iOS 26.0, macOS 26.0, visionOS 26.0, *) { + nativeGlassSurface(paddedContent) + } else { + paddedContent + .background(fallbackMaterialBackground) + .overlay(border) + .overlay(highlight) + .shadow(color: .black.opacity(0.10), radius: 9, x: 0, y: 4) + .clipShape(shape) + } + } + + @ViewBuilder + private func nativeGlassSurface(_ content: some View) -> some View { + if isInteractive { + content + .glassEffect(.regular.tint(theme.cardBackground).interactive(), in: .rect(cornerRadius: cornerRadius)) + .overlay(border) + .shadow(color: .black.opacity(0.08), radius: 8, x: 0, y: 3) + } else { + content + .glassEffect(.regular.tint(theme.cardBackground), in: .rect(cornerRadius: cornerRadius)) + .overlay(border) + .shadow(color: .black.opacity(0.08), radius: 8, x: 0, y: 3) + } + } + + private var reducedTransparencyBackground: some View { + shape.fill(Color.white.opacity(min(0.95, theme.cardBackgroundOpacity * 4))) + } + + private var fallbackMaterialBackground: some View { + shape + .fill(.ultraThinMaterial) + .overlay(shape.fill(theme.cardBackground)) + } + + private var border: some View { + shape.strokeBorder(theme.cardBorder, lineWidth: 0.5) + } + + private var highlight: some View { + LinearGradient( + colors: [.white.opacity(0.18), .clear], + startPoint: .top, + endPoint: UnitPoint(x: 0.5, y: 0.06) + ) + .clipShape(shape) + .allowsHitTesting(false) + } + + private var shape: RoundedRectangle { + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + } + + private var shouldReduceTransparency: Bool { + accessibilityReduceTransparency || atmosReduceTransparency + } +} + +struct AtmosGlassContainer: View { + @ViewBuilder let content: Content + + var body: some View { + if #available(iOS 26.0, macOS 26.0, visionOS 26.0, *) { + GlassEffectContainer(spacing: 12) { + content + } + } else { + content + } + } +} diff --git a/example_projects/Weather/Weather/Views/Shared/MetricHelpers.swift b/example_projects/Weather/Weather/Views/Shared/MetricHelpers.swift new file mode 100644 index 00000000..c234e0ec --- /dev/null +++ b/example_projects/Weather/Weather/Views/Shared/MetricHelpers.swift @@ -0,0 +1,102 @@ +import Foundation + +enum WeatherMetricHelpers { + static func compassAbbreviation(_ direction: WindDirection) -> String { + compassPoint(for: direction).abbreviation + } + + static func longDirection(_ direction: WindDirection) -> String { + compassPoint(for: direction).longLabel + } + + static func beaufortLabel(_ kph: Int) -> String { + switch kph { + case ..<2: "Calm" + case ..<6: "Light air" + case ..<12: "Light breeze" + case ..<20: "Gentle breeze" + case ..<29: "Moderate breeze" + case ..<39: "Fresh breeze" + case ..<50: "Strong breeze" + case ..<63: "Near gale" + default: "Gale" + } + } + + static func deterministicPercent(seed: Int, index: Int) -> Double { + let value = abs((seed &* 1_103_515_245 &+ index &* 12_345) % 10_000) + return Double(value) / 10_000 + } + + private static func compassPoint(for direction: WindDirection) -> CompassPoint { + let index = Int(((direction.degrees + 11.25) / 22.5).rounded(.down)) % CompassPoint.allCases.count + return CompassPoint.allCases[index] + } +} + +extension Array { + subscript(safe index: Int) -> Element? { + indices.contains(index) ? self[index] : nil + } +} + +private enum CompassPoint: CaseIterable { + case north + case northNortheast + case northeast + case eastNortheast + case east + case eastSoutheast + case southeast + case southSoutheast + case south + case southSouthwest + case southwest + case westSouthwest + case west + case westNorthwest + case northwest + case northNorthwest + + var abbreviation: String { + switch self { + case .north: "N" + case .northNortheast: "NNE" + case .northeast: "NE" + case .eastNortheast: "ENE" + case .east: "E" + case .eastSoutheast: "ESE" + case .southeast: "SE" + case .southSoutheast: "SSE" + case .south: "S" + case .southSouthwest: "SSW" + case .southwest: "SW" + case .westSouthwest: "WSW" + case .west: "W" + case .westNorthwest: "WNW" + case .northwest: "NW" + case .northNorthwest: "NNW" + } + } + + var longLabel: String { + switch self { + case .north: "north" + case .northNortheast: "north-northeast" + case .northeast: "northeast" + case .eastNortheast: "east-northeast" + case .east: "east" + case .eastSoutheast: "east-southeast" + case .southeast: "southeast" + case .southSoutheast: "south-southeast" + case .south: "south" + case .southSouthwest: "south-southwest" + case .southwest: "southwest" + case .westSouthwest: "west-southwest" + case .west: "west" + case .westNorthwest: "west-northwest" + case .northwest: "northwest" + case .northNorthwest: "north-northwest" + } + } +} diff --git a/example_projects/Weather/Weather/Views/Shared/WeatherIconView.swift b/example_projects/Weather/Weather/Views/Shared/WeatherIconView.swift new file mode 100644 index 00000000..2f0f4b8c --- /dev/null +++ b/example_projects/Weather/Weather/Views/Shared/WeatherIconView.swift @@ -0,0 +1,50 @@ +import SwiftUI + +struct WeatherIconView: View { + let kind: WeatherIconKind + let size: CGFloat + let foreground: Color + let accent: Color + + var body: some View { + Image(systemName: symbol) + .symbolRenderingMode(.palette) + .foregroundStyle(primary, secondary) + .font(.system(size: size, weight: .semibold)) + .frame(width: size, height: size) + .accessibilityHidden(true) + } + + private var symbol: String { + switch kind { + case .sun: "sun.max.fill" + case .sunLow: "sun.horizon.fill" + case .moon: "moon.stars.fill" + case .cloud: "cloud.fill" + case .rain: "cloud.rain.fill" + case .heavyRain: "cloud.heavyrain.fill" + case .snow: "cloud.snow.fill" + case .storm: "cloud.bolt.rain.fill" + } + } + + private var primary: Color { + switch kind { + case .sun, .sunLow, .storm: + accent + default: + foreground.opacity(0.95) + } + } + + private var secondary: Color { + switch kind { + case .rain, .heavyRain: + Color(hex: "#9DB7D6") + case .snow: + .white + default: + foreground.opacity(0.70) + } + } +} diff --git a/example_projects/Weather/Weather/Views/Shared/WeatherPresentation.swift b/example_projects/Weather/Weather/Views/Shared/WeatherPresentation.swift new file mode 100644 index 00000000..453cd320 --- /dev/null +++ b/example_projects/Weather/Weather/Views/Shared/WeatherPresentation.swift @@ -0,0 +1,384 @@ +import SwiftUI + +enum AtmosphericParticle: String, Sendable { + case sun + case rain + case snow + case stars + case storm +} + +enum WeatherIconKind: Sendable { + case sun + case sunLow + case moon + case cloud + case rain + case heavyRain + case snow + case storm +} + +struct WeatherTheme { + let backgroundStops: [Gradient.Stop] + let accent: Color + let foreground: Color + let foregroundMuted: Color + let foregroundFaint: Color + let cardBackground: Color + let cardBackgroundOpacity: Double + let cardBorder: Color + let statusDark: Bool +} + +extension CurrentWeather { + var theme: WeatherTheme { + WeatherPresentation.style(for: condition).theme + } + + var atmosphericParticle: AtmosphericParticle { + WeatherPresentation.style(for: condition).particle + } + + var conditionLabel: String { + condition.displayLabel + } + + var heroPhrase: String { + condition.heroPhrase + } + + var iconKind: WeatherIconKind { + condition.iconKind + } + + var airQualityLabel: String { + airQualityCategory.displayLabel + } + + var uvLabel: String { + uvCategory.displayLabel + } + + var pressureTrendLabel: String { + pressureTrend.displayLabel + } +} + +extension WeatherLocation { + var conditionLabel: String { + condition.displayLabel + } + + var iconKind: WeatherIconKind { + condition.iconKind + } + + var localTimeLabel: String { + localTime.fullClockLabel + } +} + +extension HourlyForecast { + var hourLabel: String { + hour.displayLabel + } + + var iconKind: WeatherIconKind { + condition.iconKind + } +} + +extension DailyForecast { + var dayLabel: String { + day.displayLabel + } + + var isToday: Bool { + day == .today + } + + var iconKind: WeatherIconKind { + condition.iconKind + } +} + +extension WeatherCondition { + var displayLabel: String { + switch self { + case .sunny: "Sunny" + case .mostlySunny: "Mostly Sunny" + case .partlyCloudy: "Partly Cloudy" + case .cloudy: "Cloudy" + case .clearDay, .clearNight: "Clear" + case .lightRain: "Light Rain" + case .heavyRain: "Heavy Rain" + case .lightSnow: "Light Snow" + case .snowShowers: "Snow Showers" + case .thunderstorms: "Thunderstorms" + case .hazy: "Hazy" + } + } + + var heroPhrase: String { + switch self { + case .sunny, .mostlySunny, .clearDay, .partlyCloudy, .hazy: + "Crisp and clear" + case .cloudy: + "Soft clouds overhead" + case .clearNight: + "Still and starlit" + case .lightRain, .heavyRain: + "A soft, steady rain" + case .lightSnow, .snowShowers: + "A quiet hush of snow" + case .thunderstorms: + "Thunder rolling in" + } + } + + var iconKind: WeatherIconKind { + switch self { + case .sunny, .mostlySunny, .hazy, .clearDay: + .sun + case .partlyCloudy: + .sunLow + case .clearNight: + .moon + case .cloudy: + .cloud + case .lightRain: + .rain + case .heavyRain: + .heavyRain + case .lightSnow, .snowShowers: + .snow + case .thunderstorms: + .storm + } + } +} + +extension LocalClockTime { + var hourMinuteLabel: String { + "\(hour12):\(String(format: "%02d", minute))" + } + + var fullClockLabel: String { + "\(hourMinuteLabel) \(meridiem)" + } + + var compactClockLabel: String { + if minute == 0 { + "\(hour12)\(meridiem.lowercased())" + } else { + fullClockLabel + } + } + + private var hour12: Int { + let adjusted = hour % 12 + return adjusted == 0 ? 12 : adjusted + } + + private var meridiem: String { + hour < 12 ? "AM" : "PM" + } +} + +extension ForecastHour { + var displayLabel: String { + switch self { + case .current: + "Now" + case let .clock(time): + time.compactClockLabel + } + } +} + +extension ForecastDay { + var displayLabel: String { + switch self { + case .today: + "Today" + case let .weekday(weekday): + weekday.displayLabel + } + } +} + +extension Weekday { + var displayLabel: String { + switch self { + case .sunday: "Sun" + case .monday: "Mon" + case .tuesday: "Tue" + case .wednesday: "Wed" + case .thursday: "Thu" + case .friday: "Fri" + case .saturday: "Sat" + } + } +} + +extension UVIndexCategory { + var displayLabel: String { + switch self { + case .none: "—" + case .low: "Low" + case .moderate: "Moderate" + case .high: "High" + case .veryHigh: "Very High" + case .extreme: "Extreme" + } + } +} + +extension AirQualityCategory { + var displayLabel: String { + switch self { + case .good: "Good" + case .moderate: "Moderate" + case .unhealthyForSensitiveGroups: "Unhealthy for Sensitive Groups" + case .unhealthy: "Unhealthy" + case .veryUnhealthy: "Very Unhealthy" + case .hazardous: "Hazardous" + } + } +} + +extension PressureTrend { + var displayLabel: String { + switch self { + case .rising: "rising" + case .steady: "steady" + case .falling: "falling" + } + } + + var arrowSymbol: String { + switch self { + case .rising: "↑" + case .steady: "→" + case .falling: "↓" + } + } +} + +private enum WeatherPresentationStyle { + case clearDay + case rainy + case snowy + case night + case stormy + + var particle: AtmosphericParticle { + switch self { + case .clearDay: .sun + case .rainy: .rain + case .snowy: .snow + case .night: .stars + case .stormy: .storm + } + } + + var theme: WeatherTheme { + switch self { + case .clearDay: + WeatherTheme( + backgroundStops: gradientStops([("#FFD89B", 0), ("#FF9E7A", 0.32), ("#7B6FD9", 0.70), ("#1F2D6F", 1)]), + accent: Color(hex: "#FFD89B"), + foreground: Color(hex: "#FFFFFF"), + foregroundMuted: Color(hex: "#FFFFFF", opacity: 0.72), + foregroundFaint: Color(hex: "#FFFFFF", opacity: 0.45), + cardBackground: Color.white.opacity(0.13), + cardBackgroundOpacity: 0.13, + cardBorder: Color.white.opacity(0.20), + statusDark: true + ) + case .rainy: + WeatherTheme( + backgroundStops: gradientStops([("#6B7C8E", 0), ("#4A5A6F", 0.40), ("#2E3B4E", 0.75), ("#1A2330", 1)]), + accent: Color(hex: "#9DB7D6"), + foreground: Color(hex: "#FFFFFF"), + foregroundMuted: Color(hex: "#FFFFFF", opacity: 0.70), + foregroundFaint: Color(hex: "#FFFFFF", opacity: 0.42), + cardBackground: Color.white.opacity(0.10), + cardBackgroundOpacity: 0.10, + cardBorder: Color.white.opacity(0.16), + statusDark: true + ) + case .snowy: + WeatherTheme( + backgroundStops: gradientStops([("#D4DFEA", 0), ("#A6BACE", 0.38), ("#6E84A0", 0.74), ("#3E4E66", 1)]), + accent: Color(hex: "#EAF2FB"), + foreground: Color(hex: "#1F2A3A"), + foregroundMuted: Color(hex: "#1F2A3A", opacity: 0.70), + foregroundFaint: Color(hex: "#1F2A3A", opacity: 0.40), + cardBackground: Color.white.opacity(0.32), + cardBackgroundOpacity: 0.32, + cardBorder: Color.white.opacity(0.55), + statusDark: false + ) + case .night: + WeatherTheme( + backgroundStops: gradientStops([("#1B1F3A", 0), ("#221A48", 0.32), ("#0E1130", 0.70), ("#05071A", 1)]), + accent: Color(hex: "#B8C7FF"), + foreground: Color(hex: "#FFFFFF"), + foregroundMuted: Color(hex: "#FFFFFF", opacity: 0.66), + foregroundFaint: Color(hex: "#FFFFFF", opacity: 0.38), + cardBackground: Color.white.opacity(0.07), + cardBackgroundOpacity: 0.07, + cardBorder: Color.white.opacity(0.13), + statusDark: true + ) + case .stormy: + WeatherTheme( + backgroundStops: gradientStops([("#3A3144", 0), ("#272036", 0.36), ("#161526", 0.72), ("#080812", 1)]), + accent: Color(hex: "#F5C77E"), + foreground: Color(hex: "#FFFFFF"), + foregroundMuted: Color(hex: "#FFFFFF", opacity: 0.66), + foregroundFaint: Color(hex: "#FFFFFF", opacity: 0.38), + cardBackground: Color.white.opacity(0.09), + cardBackgroundOpacity: 0.09, + cardBorder: Color.white.opacity(0.16), + statusDark: true + ) + } + } + + private func gradientStops(_ stops: [(String, Double)]) -> [Gradient.Stop] { + stops.map { Gradient.Stop(color: Color(hex: $0.0), location: $0.1) } + } +} + +private enum WeatherPresentation { + static func style(for condition: WeatherCondition) -> WeatherPresentationStyle { + switch condition { + case .clearNight: + .night + case .thunderstorms: + .stormy + case .lightSnow, .snowShowers: + .snowy + case .lightRain, .heavyRain: + .rainy + case .sunny, .mostlySunny, .partlyCloudy, .cloudy, .clearDay, .hazy: + .clearDay + } + } +} + +extension Color { + init(hex: String, opacity: Double = 1) { + let value = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#")) + let scanner = Scanner(string: value) + var rgb: UInt64 = 0 + scanner.scanHexInt64(&rgb) + let red = Double((rgb >> 16) & 0xFF) / 255 + let green = Double((rgb >> 8) & 0xFF) / 255 + let blue = Double(rgb & 0xFF) / 255 + self.init(.sRGB, red: red, green: green, blue: blue, opacity: opacity) + } +} diff --git a/example_projects/Weather/Weather/WeatherApp.swift b/example_projects/Weather/Weather/WeatherApp.swift new file mode 100644 index 00000000..f0570e30 --- /dev/null +++ b/example_projects/Weather/Weather/WeatherApp.swift @@ -0,0 +1,33 @@ +// +// WeatherApp.swift +// Weather +// +// Created by Cameron on 30/04/2026. +// + +import SwiftUI + +@main +struct WeatherApp: App { + private let weatherService: WeatherService + + init() { + weatherService = AppWeatherServiceFactory.makeService() + } + + var body: some Scene { + WindowGroup { + ContentView(weatherService: weatherService) + } + } +} + +private enum AppWeatherServiceFactory { + static func makeService(arguments: [String] = ProcessInfo.processInfo.arguments) -> WeatherService { + if arguments.contains("--mock-weather-api") { + return .mock + } + + return .production + } +} diff --git a/example_projects/Weather/WeatherTests/Fixtures/default-locations.json b/example_projects/Weather/WeatherTests/Fixtures/default-locations.json new file mode 100644 index 00000000..4dd56c59 --- /dev/null +++ b/example_projects/Weather/WeatherTests/Fixtures/default-locations.json @@ -0,0 +1,32 @@ +{ + "locations": [ + { + "id": "loc-current-san-francisco", + "name": "San Francisco", + "subtitle": "Current Location", + "country": null, + "temperatureC": 18, + "highC": 20, + "lowC": 12, + "condition": "mostly_sunny", + "localTime": { + "hour": 13, + "minute": 24 + } + }, + { + "id": "loc-us-or-portland", + "name": "Portland", + "subtitle": "Oregon, USA", + "country": null, + "temperatureC": 11, + "highC": 13, + "lowC": 9, + "condition": "light_rain", + "localTime": { + "hour": 13, + "minute": 24 + } + } + ] +} diff --git a/example_projects/Weather/WeatherTests/Fixtures/search-locations.json b/example_projects/Weather/WeatherTests/Fixtures/search-locations.json new file mode 100644 index 00000000..3fd40824 --- /dev/null +++ b/example_projects/Weather/WeatherTests/Fixtures/search-locations.json @@ -0,0 +1,32 @@ +{ + "locations": [ + { + "id": "loc-gb-london", + "name": "London", + "subtitle": "England, United Kingdom", + "country": "GB", + "temperatureC": 13, + "highC": 16, + "lowC": 9, + "condition": "light_rain", + "localTime": { + "hour": 21, + "minute": 24 + } + }, + { + "id": "loc-fr-paris", + "name": "Paris", + "subtitle": "Île-de-France, France", + "country": "FR", + "temperatureC": 15, + "highC": 18, + "lowC": 11, + "condition": "partly_cloudy", + "localTime": { + "hour": 22, + "minute": 24 + } + } + ] +} diff --git a/example_projects/Weather/WeatherTests/Fixtures/weather-report-loc-current-san-francisco.json b/example_projects/Weather/WeatherTests/Fixtures/weather-report-loc-current-san-francisco.json new file mode 100644 index 00000000..94db0573 --- /dev/null +++ b/example_projects/Weather/WeatherTests/Fixtures/weather-report-loc-current-san-francisco.json @@ -0,0 +1,114 @@ +{ + "current": { + "id": "weather-current-loc-current-san-francisco", + "temperatureC": 18, + "highC": 20, + "lowC": 12, + "feelsLikeC": 17, + "dewPointC": 9, + "condition": "mostly_sunny", + "solarProgress": { + "kind": "daylight", + "daylightFraction": 0.62 + }, + "sunrise": { + "hour": 6, + "minute": 18 + }, + "sunset": { + "hour": 19, + "minute": 42 + }, + "airQualityIndex": 38, + "airQualityCategory": "good", + "uvIndex": 6, + "uvCategory": "high", + "windKph": 13, + "windDirectionDegrees": 292, + "humidity": 64, + "visibilityKilometers": 16.1, + "pressureMillibars": 1018, + "pressureTrend": "rising", + "precipChance": 5 + }, + "hourly": [ + { + "id": "hourly-now-18-sunny", + "hour": { + "kind": "current", + "hour": null, + "minute": null + }, + "temperatureC": 18, + "condition": "sunny" + }, + { + "id": "hourly-14-0-19-sunny", + "hour": { + "kind": "clock", + "hour": 14, + "minute": 0 + }, + "temperatureC": 19, + "condition": "sunny" + } + ], + "daily": [ + { + "id": "daily-today-sunny-12-20", + "day": { + "kind": "today", + "weekdayRawValue": null + }, + "condition": "sunny", + "lowC": 12, + "highC": 20, + "weekLowC": 9, + "weekHighC": 23 + }, + { + "id": "daily-weekday-5-partly_cloudy-13-21", + "day": { + "kind": "weekday", + "weekdayRawValue": 5 + }, + "condition": "partly_cloudy", + "lowC": 13, + "highC": 21, + "weekLowC": 9, + "weekHighC": 23 + } + ], + "precipitationDetailCurrent": { + "id": "weather-current-loc-us-or-portland", + "temperatureC": 11, + "highC": 13, + "lowC": 9, + "feelsLikeC": 9, + "dewPointC": 8, + "condition": "light_rain", + "solarProgress": { + "kind": "daylight", + "daylightFraction": 0.45 + }, + "sunrise": { + "hour": 6, + "minute": 42 + }, + "sunset": { + "hour": 19, + "minute": 18 + }, + "airQualityIndex": 22, + "airQualityCategory": "good", + "uvIndex": 1, + "uvCategory": "low", + "windKph": 23, + "windDirectionDegrees": 225, + "humidity": 89, + "visibilityKilometers": 9.7, + "pressureMillibars": 1006, + "pressureTrend": "falling", + "precipChance": 78 + } +} diff --git a/example_projects/Weather/WeatherTests/WeatherTests.swift b/example_projects/Weather/WeatherTests/WeatherTests.swift new file mode 100644 index 00000000..1a8d8f9b --- /dev/null +++ b/example_projects/Weather/WeatherTests/WeatherTests.swift @@ -0,0 +1,325 @@ +// +// WeatherTests.swift +// WeatherTests +// +// Created by Cameron on 30/04/2026. +// + +import Foundation +import Testing +@testable import Weather + +@MainActor +struct WeatherTests { + + @Test func temperatureFormattingMatchesPrototypeRules() { + var units = WeatherUnits() + #expect(WeatherUnitFormatter.temperature(20, units: units) == 68) + + units.temperature = .celsius + #expect(WeatherUnitFormatter.temperature(20, units: units) == 20) + #expect(WeatherUnitFormatter.temperature(11, units: units) == 11) + } + + @Test func windPressureAndDistanceFormattingMatchPrototypeRules() { + var units = WeatherUnits() + units.wind = .mph + #expect(WeatherUnitFormatter.wind(23, units: units) == FormattedMeasurement(value: "14", unit: "mph")) + + units.wind = .metersPerSecond + #expect(WeatherUnitFormatter.wind(23, units: units) == FormattedMeasurement(value: "6.4", unit: "m/s")) + + units.pressure = .inchesMercury + #expect(WeatherUnitFormatter.pressure(1018, units: units) == FormattedMeasurement(value: "30.06", unit: "inHg")) + + units.distance = .miles + #expect(WeatherUnitFormatter.distance(16.1, units: units) == FormattedMeasurement(value: "10", unit: "mi")) + } + + @Test func mockSearchIsCaseInsensitiveAcrossNameSubtitleAndCountry() async throws { + let service = WeatherService(apiClient: MockWeatherAPIClient()) + + let byName = try await service.searchLocations(matching: "london") + #expect(byName.contains { $0.name == "London" }) + + let bySubtitle = try await service.searchLocations(matching: "united kingdom") + #expect(bySubtitle.map(\.name).contains("London")) + + let byCountry = try await service.searchLocations(matching: "gb") + #expect(byCountry.map(\.name).contains("London")) + } + + @Test func emptySearchReturnsNoResults() async throws { + let results = try await WeatherService(apiClient: MockWeatherAPIClient()).searchLocations(matching: " ") + #expect(results.isEmpty) + } + + @Test func weatherServiceMapsDTOsToDomainModels() async throws { + let service = WeatherService(apiClient: MockWeatherAPIClient()) + + let locations = try await service.defaultLocations() + #expect(locations.first == WeatherLocation( + id: "loc-current-san-francisco", + name: "San Francisco", + subtitle: "Current Location", + country: nil, + temperatureC: 18, + highC: 20, + lowC: 12, + condition: .mostlySunny, + localTime: LocalClockTime(hour: 13, minute: 24) + )) + + let report = try await service.weather(for: "loc-current-san-francisco") + #expect(report.current == CurrentWeather( + id: "weather-current-loc-current-san-francisco", + temperatureC: 18, + highC: 20, + lowC: 12, + feelsLikeC: 17, + dewPointC: 9, + condition: .mostlySunny, + solarProgress: .daylightFraction(0.62), + sunrise: LocalClockTime(hour: 6, minute: 18), + sunset: LocalClockTime(hour: 19, minute: 42), + airQualityIndex: 38, + airQualityCategory: .good, + uvIndex: 6, + uvCategory: .high, + windKph: 13, + windDirection: WindDirection(degrees: 292), + humidity: 64, + visibilityKilometers: 16.1, + pressureMillibars: 1018, + pressureTrend: .rising, + precipChance: 5 + )) + #expect(report.hourly.first?.condition == .sunny) + #expect(report.daily.first?.day == .today) + } + + @Test func defaultLocationsFixtureDecodesAsExpectedDTOs() throws { + let decoded: WeatherLocationsResponseDTO = try decodeFixture(named: "default-locations") + + #expect(decoded.locations == [ + WeatherLocationDTO( + id: "loc-current-san-francisco", + name: "San Francisco", + subtitle: "Current Location", + country: nil, + temperatureC: 18, + highC: 20, + lowC: 12, + condition: .mostlySunny, + localTime: LocalClockTimeDTO(hour: 13, minute: 24) + ), + WeatherLocationDTO( + id: "loc-us-or-portland", + name: "Portland", + subtitle: "Oregon, USA", + country: nil, + temperatureC: 11, + highC: 13, + lowC: 9, + condition: .lightRain, + localTime: LocalClockTimeDTO(hour: 13, minute: 24) + ), + ]) + } + + @Test func searchLocationsFixtureDecodesAsExpectedDTOs() throws { + let decoded: WeatherLocationsResponseDTO = try decodeFixture(named: "search-locations") + + #expect(decoded.locations == [ + WeatherLocationDTO( + id: "loc-gb-london", + name: "London", + subtitle: "England, United Kingdom", + country: "GB", + temperatureC: 13, + highC: 16, + lowC: 9, + condition: .lightRain, + localTime: LocalClockTimeDTO(hour: 21, minute: 24) + ), + WeatherLocationDTO( + id: "loc-fr-paris", + name: "Paris", + subtitle: "Île-de-France, France", + country: "FR", + temperatureC: 15, + highC: 18, + lowC: 11, + condition: .partlyCloudy, + localTime: LocalClockTimeDTO(hour: 22, minute: 24) + ), + ]) + } + + @Test func solarProgressKindDecodesSnakeCaseValues() throws { + let decoder = JSONDecoder() + + let beforeSunrise = try decoder.decode( + SolarDayProgressDTO.self, + from: Data(#"{"kind":"before_sunrise","daylightFraction":null}"#.utf8) + ) + #expect(beforeSunrise == SolarDayProgressDTO(kind: .beforeSunrise, daylightFraction: nil)) + + let afterSunset = try decoder.decode( + SolarDayProgressDTO.self, + from: Data(#"{"kind":"after_sunset","daylightFraction":null}"#.utf8) + ) + #expect(afterSunset == SolarDayProgressDTO(kind: .afterSunset, daylightFraction: nil)) + } + + @Test func invalidClockTimeThrowsMappingError() throws { + let location = WeatherLocationDTO( + id: "invalid-time", + name: "Invalid Time", + subtitle: "Fixture", + country: nil, + temperatureC: 18, + highC: 20, + lowC: 12, + condition: .sunny, + localTime: LocalClockTimeDTO(hour: 25, minute: 0) + ) + + #expect(throws: WeatherDTOMappingError.invalidClockTime(hour: 25, minute: 0)) { + _ = try WeatherLocation(dto: location) + } + } + + @Test func windDirection360MapsToNorth() throws { + let dto = CurrentWeatherDTO( + id: "north-wind", + temperatureC: 10, + highC: 12, + lowC: 8, + feelsLikeC: 9, + dewPointC: 8, + condition: .sunny, + solarProgress: SolarDayProgressDTO(kind: .daylight, daylightFraction: 0.5), + sunrise: LocalClockTimeDTO(hour: 6, minute: 0), + sunset: LocalClockTimeDTO(hour: 18, minute: 0), + airQualityIndex: 10, + airQualityCategory: .good, + uvIndex: 1, + uvCategory: .low, + windKph: 12, + windDirectionDegrees: 360, + humidity: 50, + visibilityKilometers: 10, + pressureMillibars: 1_013, + pressureTrend: .steady, + precipChance: 0 + ) + + let current = try CurrentWeather(dto: dto) + + #expect(current.windDirection == WindDirection(degrees: 0)) + } + + @Test func weatherFixtureDecodesAsExpectedDTO() throws { + let decoded: WeatherReportDTO = try decodeFixture(named: "weather-report-loc-current-san-francisco") + + #expect(decoded == WeatherReportDTO( + current: CurrentWeatherDTO( + id: "weather-current-loc-current-san-francisco", + temperatureC: 18, + highC: 20, + lowC: 12, + feelsLikeC: 17, + dewPointC: 9, + condition: .mostlySunny, + solarProgress: SolarDayProgressDTO(kind: .daylight, daylightFraction: 0.62), + sunrise: LocalClockTimeDTO(hour: 6, minute: 18), + sunset: LocalClockTimeDTO(hour: 19, minute: 42), + airQualityIndex: 38, + airQualityCategory: .good, + uvIndex: 6, + uvCategory: .high, + windKph: 13, + windDirectionDegrees: 292, + humidity: 64, + visibilityKilometers: 16.1, + pressureMillibars: 1018, + pressureTrend: .rising, + precipChance: 5 + ), + hourly: [ + HourlyForecastDTO( + id: "hourly-now-18-sunny", + hour: ForecastHourDTO(kind: .current, hour: nil, minute: nil), + temperatureC: 18, + condition: .sunny + ), + HourlyForecastDTO( + id: "hourly-14-0-19-sunny", + hour: ForecastHourDTO(kind: .clock, hour: 14, minute: 0), + temperatureC: 19, + condition: .sunny + ), + ], + daily: [ + DailyForecastDTO( + id: "daily-today-sunny-12-20", + day: ForecastDayDTO(kind: .today, weekdayRawValue: nil), + condition: .sunny, + lowC: 12, + highC: 20, + weekLowC: 9, + weekHighC: 23 + ), + DailyForecastDTO( + id: "daily-weekday-5-partly_cloudy-13-21", + day: ForecastDayDTO(kind: .weekday, weekdayRawValue: 5), + condition: .partlyCloudy, + lowC: 13, + highC: 21, + weekLowC: 9, + weekHighC: 23 + ), + ], + precipitationDetailCurrent: CurrentWeatherDTO( + id: "weather-current-loc-us-or-portland", + temperatureC: 11, + highC: 13, + lowC: 9, + feelsLikeC: 9, + dewPointC: 8, + condition: .lightRain, + solarProgress: SolarDayProgressDTO(kind: .daylight, daylightFraction: 0.45), + sunrise: LocalClockTimeDTO(hour: 6, minute: 42), + sunset: LocalClockTimeDTO(hour: 19, minute: 18), + airQualityIndex: 22, + airQualityCategory: .good, + uvIndex: 1, + uvCategory: .low, + windKph: 23, + windDirectionDegrees: 225, + humidity: 89, + visibilityKilometers: 9.7, + pressureMillibars: 1006, + pressureTrend: .falling, + precipChance: 78 + ) + )) + } + + private func decodeFixture(named fileName: String, as type: T.Type = T.self) throws -> T { + let fixtureObject = try loadFixtureJSONObject(named: fileName) + let decoderData = try JSONSerialization.data(withJSONObject: fixtureObject) + return try JSONDecoder().decode(type, from: decoderData) + } + + + private func loadFixtureJSONObject(named fileName: String) throws -> Any { + let url = try #require(Bundle(for: FixtureBundleToken.self).url(forResource: fileName, withExtension: "json")) + let data = try Data(contentsOf: url) + return try JSONSerialization.jsonObject(with: data) + } + +} + +private final class FixtureBundleToken {} diff --git a/example_projects/Weather/WeatherUITests/WeatherUITests.swift b/example_projects/Weather/WeatherUITests/WeatherUITests.swift new file mode 100644 index 00000000..75c98bb4 --- /dev/null +++ b/example_projects/Weather/WeatherUITests/WeatherUITests.swift @@ -0,0 +1,76 @@ +// +// WeatherUITests.swift +// WeatherUITests +// +// Created by Cameron on 30/04/2026. +// + +import XCTest + +final class WeatherUITests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + + // In UI tests it is usually best to stop immediately when a failure occurs. + continueAfterFailure = false + + // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + @MainActor + func testLocationPickerOpens() throws { + let app = launchApp() + + XCTAssertTrue(app.staticTexts["San Francisco"].waitForExistence(timeout: 5)) + + app.buttons["weather.locationButton"].tap() + XCTAssertTrue(app.staticTexts["Locations"].waitForExistence(timeout: 2)) + } + + @MainActor + func testSettingsSheetOpens() throws { + let app = launchApp() + + XCTAssertTrue(app.staticTexts["San Francisco"].waitForExistence(timeout: 5)) + app.buttons["weather.settingsButton"].tap() + XCTAssertTrue(app.staticTexts["Settings"].waitForExistence(timeout: 2)) + } + + @MainActor + func testPrecipitationDetailOpens() throws { + let app = launchApp() + + XCTAssertTrue(app.staticTexts["San Francisco"].waitForExistence(timeout: 5)) + let precipitationCard = app.buttons["weather.precipitationCard"] + let mainScrollView = app.scrollViews["weather.mainScrollView"] + XCTAssertTrue(mainScrollView.waitForExistence(timeout: 2)) + + for _ in 0..<5 where !precipitationCard.exists { + mainScrollView.swipeUp() + } + XCTAssertTrue(precipitationCard.waitForExistence(timeout: 1)) + precipitationCard.tap() + XCTAssertTrue(app.staticTexts["PRECIPITATION"].waitForExistence(timeout: 2)) + } + + @MainActor + func testLaunchPerformance() throws { + // This measures how long it takes to launch your application. + measure(metrics: [XCTApplicationLaunchMetric()]) { + _ = launchApp() + } + } + + @MainActor + private func launchApp() -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments.append("--mock-weather-api") + app.launch() + return app + } +} diff --git a/example_projects/Weather/WeatherUITests/WeatherUITestsLaunchTests.swift b/example_projects/Weather/WeatherUITests/WeatherUITestsLaunchTests.swift new file mode 100644 index 00000000..9b7f410f --- /dev/null +++ b/example_projects/Weather/WeatherUITests/WeatherUITestsLaunchTests.swift @@ -0,0 +1,36 @@ +// +// WeatherUITestsLaunchTests.swift +// WeatherUITests +// +// Created by Cameron on 30/04/2026. +// + +import XCTest + +final class WeatherUITestsLaunchTests: XCTestCase { + + override class var runsForEachTargetApplicationUIConfiguration: Bool { + true + } + + override func setUpWithError() throws { + continueAfterFailure = false + } + + @MainActor + func testLaunch() throws { + let app = XCUIApplication() + app.launchArguments.append("--mock-weather-api") + app.launch() + + // Insert steps here to perform after app launch but before taking a screenshot, + // such as logging into a test account or navigating somewhere in the app + // XCUIAutomation Documentation + // https://developer.apple.com/documentation/xcuiautomation + + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "Launch Screen" + attachment.lifetime = .keepAlways + add(attachment) + } +}