Skip to content

Commit c39ccc1

Browse files
committed
Implement WeatherApp MVVM architecture
1 parent 8e8cb25 commit c39ccc1

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

49 files changed

+1588
-32
lines changed

.github/workflows/ios.yml

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
name: iOS CI
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
- "codex/**"
8+
pull_request:
9+
10+
jobs:
11+
test-and-analyze:
12+
runs-on: macos-latest
13+
14+
steps:
15+
- uses: actions/checkout@v4
16+
17+
- name: Select Xcode
18+
uses: maxim-lobanov/setup-xcode@v1
19+
with:
20+
xcode-version: latest-stable
21+
22+
- name: Run tests with coverage
23+
run: |
24+
xcodebuild test \
25+
-project WeatherApp.xcodeproj \
26+
-scheme WeatherApp \
27+
-destination 'platform=iOS Simulator,name=iPhone 16' \
28+
-enableCodeCoverage YES \
29+
-resultBundlePath TestResults.xcresult
30+
31+
- name: Run static analysis
32+
run: |
33+
xcodebuild analyze \
34+
-project WeatherApp.xcodeproj \
35+
-scheme WeatherApp \
36+
-destination 'platform=iOS Simulator,name=iPhone 16'
37+
38+
- name: Export coverage report
39+
run: |
40+
xcrun xccov view --report TestResults.xcresult > coverage.txt
41+
42+
- name: Upload coverage artifact
43+
uses: actions/upload-artifact@v4
44+
with:
45+
name: weatherapp-coverage
46+
path: coverage.txt

README.md

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# WeatherApp
2+
3+
WeatherApp is a SwiftUI weather client that fetches a 5-day forecast from OpenWeather using the user's current location, then displays a weather-aware background with overlay copy. The codebase is intentionally split into small views and focused services instead of a single monolithic screen.
4+
5+
## Conventions And Architecture
6+
7+
- Architecture: MVVM with feature-oriented folders and protocol-backed services.
8+
- UI framework: SwiftUI only for app presentation. No functional UIKit screens are introduced.
9+
- Networking: `URLSession` + `Codable`; no third-party HTTP or JSON libraries.
10+
- State management: `ObservableObject` + `@Published` in the feature view model.
11+
- Dependency direction: Views depend on view models, view models depend on protocols, services depend on infrastructure helpers, and domain models stay framework-light.
12+
- Scope: one application target and one unit-test target.
13+
14+
## Project Structure
15+
16+
```text
17+
WeatherApp/
18+
App/
19+
Domain/Models/
20+
Features/Forecast/ViewModels/
21+
Features/Forecast/Views/
22+
Services/Location/
23+
Services/Networking/
24+
Services/OverlayText/
25+
Services/Weather/
26+
Shared/
27+
WeatherAppTests/
28+
docs/
29+
```
30+
31+
See [docs/Architecture.md](/Users/blessingmabunda/Documents/WeatherApp/docs/Architecture.md) for the detailed architecture breakdown.
32+
33+
## Third-Party Dependencies
34+
35+
No third-party dependencies are currently used.
36+
37+
- OpenWeather is used as an external HTTP API, not as a bundled SDK.
38+
- If cross-cutting tooling is added later, it must stay limited to concerns such as linting, logging, or CI support.
39+
40+
## Configuration
41+
42+
The current testing configuration is defined in [AppConfiguration.swift](/Users/blessingmabunda/Documents/WeatherApp/WeatherApp/App/AppConfiguration.swift).
43+
44+
- `openWeatherAPIKey`
45+
- `overlayTextAPIURL`
46+
47+
The OpenWeather key is currently stored on the client for testing, and the overlay endpoint is left unset so the app uses the development stub overlay provider.
48+
49+
## Build And Run
50+
51+
1. Open [WeatherApp.xcodeproj](/Users/blessingmabunda/Documents/WeatherApp/WeatherApp.xcodeproj).
52+
2. Select the `WeatherApp` scheme.
53+
3. Edit [AppConfiguration.swift](/Users/blessingmabunda/Documents/WeatherApp/WeatherApp/App/AppConfiguration.swift) with your testing values.
54+
4. Run on an iPhone simulator or device with location permissions enabled.
55+
56+
CLI examples:
57+
58+
```bash
59+
xcodebuild build -project WeatherApp.xcodeproj -scheme WeatherApp -destination 'platform=iOS Simulator,name=iPhone 16'
60+
xcodebuild test -project WeatherApp.xcodeproj -scheme WeatherApp -destination 'platform=iOS Simulator,name=iPhone 16'
61+
```
62+
63+
## Testing Strategy
64+
65+
- Unit tests cover:
66+
- OpenWeather response mapping into app-owned models
67+
- theme/background mapping
68+
- forecast view-model state transitions
69+
- presentation formatting helpers
70+
- Protocol-based test doubles isolate location, network, weather, and overlay flows.
71+
- UI verification is handled through SwiftUI previews plus view-model coverage rather than snapshot tooling.
72+
73+
## CI/CD And Coverage
74+
75+
- GitHub Actions is configured in [ios.yml](/Users/blessingmabunda/Documents/WeatherApp/.github/workflows/ios.yml).
76+
- The workflow runs:
77+
- `xcodebuild test`
78+
- `xcodebuild analyze`
79+
- `xccov` coverage export
80+
- Coverage is emitted as an artifact so it can be inspected from CI runs.
81+
82+
## Static Analysis
83+
84+
- Native compiler warnings are left enabled in the Xcode project.
85+
- CI runs `xcodebuild analyze` to catch analyzer issues early.
86+
- No third-party static-analysis tool is required for this version of the app.
87+
88+
## Security Consideration
89+
90+
The API key is stored on the client side, which is unsecure, and any bad actors can unbundle the app and gain access to the API key. This is only for testing purposes. If the app was to be mass distributed, the API key would be moved to Firebase Secrets Manager.

WeatherApp.xcodeproj/project.pbxproj

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
/* Begin PBXFileReference section */
1010
EFA0C1A92F7D7234005F6B1F /* WeatherApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = WeatherApp.app; sourceTree = BUILT_PRODUCTS_DIR; };
11+
EFB0C1A92F7D7234005F6B1F /* WeatherAppTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = WeatherAppTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
1112
/* End PBXFileReference section */
1213

1314
/* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -16,6 +17,11 @@
1617
path = WeatherApp;
1718
sourceTree = "<group>";
1819
};
20+
EFB0C1AB2F7D7234005F6B1F /* WeatherAppTests */ = {
21+
isa = PBXFileSystemSynchronizedRootGroup;
22+
path = WeatherAppTests;
23+
sourceTree = "<group>";
24+
};
1925
/* End PBXFileSystemSynchronizedRootGroup section */
2026

2127
/* Begin PBXFrameworksBuildPhase section */
@@ -26,13 +32,21 @@
2632
);
2733
runOnlyForDeploymentPostprocessing = 0;
2834
};
35+
EFB0C1A62F7D7234005F6B1F /* Frameworks */ = {
36+
isa = PBXFrameworksBuildPhase;
37+
buildActionMask = 2147483647;
38+
files = (
39+
);
40+
runOnlyForDeploymentPostprocessing = 0;
41+
};
2942
/* End PBXFrameworksBuildPhase section */
3043

3144
/* Begin PBXGroup section */
3245
EFA0C1A02F7D7234005F6B1F = {
3346
isa = PBXGroup;
3447
children = (
3548
EFA0C1AB2F7D7234005F6B1F /* WeatherApp */,
49+
EFB0C1AB2F7D7234005F6B1F /* WeatherAppTests */,
3650
EFA0C1AA2F7D7234005F6B1F /* Products */,
3751
);
3852
sourceTree = "<group>";
@@ -41,12 +55,23 @@
4155
isa = PBXGroup;
4256
children = (
4357
EFA0C1A92F7D7234005F6B1F /* WeatherApp.app */,
58+
EFB0C1A92F7D7234005F6B1F /* WeatherAppTests.xctest */,
4459
);
4560
name = Products;
4661
sourceTree = "<group>";
4762
};
4863
/* End PBXGroup section */
4964

65+
/* Begin PBXContainerItemProxy section */
66+
EFB0C1AD2F7D7234005F6B1F /* PBXContainerItemProxy */ = {
67+
isa = PBXContainerItemProxy;
68+
containerPortal = EFA0C1A12F7D7234005F6B1F /* Project object */;
69+
proxyType = 1;
70+
remoteGlobalIDString = EFA0C1A82F7D7234005F6B1F;
71+
remoteInfo = WeatherApp;
72+
};
73+
/* End PBXContainerItemProxy section */
74+
5075
/* Begin PBXNativeTarget section */
5176
EFA0C1A82F7D7234005F6B1F /* WeatherApp */ = {
5277
isa = PBXNativeTarget;
@@ -70,6 +95,29 @@
7095
productReference = EFA0C1A92F7D7234005F6B1F /* WeatherApp.app */;
7196
productType = "com.apple.product-type.application";
7297
};
98+
EFB0C1A82F7D7234005F6B1F /* WeatherAppTests */ = {
99+
isa = PBXNativeTarget;
100+
buildConfigurationList = EFB0C1B42F7D7236005F6B1F /* Build configuration list for PBXNativeTarget "WeatherAppTests" */;
101+
buildPhases = (
102+
EFB0C1A52F7D7234005F6B1F /* Sources */,
103+
EFB0C1A62F7D7234005F6B1F /* Frameworks */,
104+
EFB0C1A72F7D7234005F6B1F /* Resources */,
105+
);
106+
buildRules = (
107+
);
108+
dependencies = (
109+
EFB0C1AE2F7D7234005F6B1F /* PBXTargetDependency */,
110+
);
111+
fileSystemSynchronizedGroups = (
112+
EFB0C1AB2F7D7234005F6B1F /* WeatherAppTests */,
113+
);
114+
name = WeatherAppTests;
115+
packageProductDependencies = (
116+
);
117+
productName = WeatherAppTests;
118+
productReference = EFB0C1A92F7D7234005F6B1F /* WeatherAppTests.xctest */;
119+
productType = "com.apple.product-type.bundle.unit-test";
120+
};
73121
/* End PBXNativeTarget section */
74122

75123
/* Begin PBXProject section */
@@ -100,6 +148,7 @@
100148
projectRoot = "";
101149
targets = (
102150
EFA0C1A82F7D7234005F6B1F /* WeatherApp */,
151+
EFB0C1A82F7D7234005F6B1F /* WeatherAppTests */,
103152
);
104153
};
105154
/* End PBXProject section */
@@ -112,6 +161,13 @@
112161
);
113162
runOnlyForDeploymentPostprocessing = 0;
114163
};
164+
EFB0C1A72F7D7234005F6B1F /* Resources */ = {
165+
isa = PBXResourcesBuildPhase;
166+
buildActionMask = 2147483647;
167+
files = (
168+
);
169+
runOnlyForDeploymentPostprocessing = 0;
170+
};
115171
/* End PBXResourcesBuildPhase section */
116172

117173
/* Begin PBXSourcesBuildPhase section */
@@ -122,8 +178,23 @@
122178
);
123179
runOnlyForDeploymentPostprocessing = 0;
124180
};
181+
EFB0C1A52F7D7234005F6B1F /* Sources */ = {
182+
isa = PBXSourcesBuildPhase;
183+
buildActionMask = 2147483647;
184+
files = (
185+
);
186+
runOnlyForDeploymentPostprocessing = 0;
187+
};
125188
/* End PBXSourcesBuildPhase section */
126189

190+
/* Begin PBXTargetDependency section */
191+
EFB0C1AE2F7D7234005F6B1F /* PBXTargetDependency */ = {
192+
isa = PBXTargetDependency;
193+
target = EFA0C1A82F7D7234005F6B1F /* WeatherApp */;
194+
targetProxy = EFB0C1AD2F7D7234005F6B1F /* PBXContainerItemProxy */;
195+
};
196+
/* End PBXTargetDependency section */
197+
127198
/* Begin XCBuildConfiguration section */
128199
EFA0C1B22F7D7236005F6B1F /* Debug */ = {
129200
isa = XCBuildConfiguration;
@@ -259,6 +330,7 @@
259330
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
260331
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
261332
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
333+
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "WeatherApp uses your location to show the current forecast for where you are.";
262334
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
263335
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
264336
LD_RUNPATH_SEARCH_PATHS = (
@@ -291,6 +363,7 @@
291363
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
292364
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
293365
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
366+
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "WeatherApp uses your location to show the current forecast for where you are.";
294367
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
295368
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
296369
LD_RUNPATH_SEARCH_PATHS = (
@@ -310,6 +383,57 @@
310383
};
311384
name = Release;
312385
};
386+
EFB0C1B52F7D7236005F6B1F /* Debug */ = {
387+
isa = XCBuildConfiguration;
388+
buildSettings = {
389+
BUNDLE_LOADER = "$(TEST_HOST)";
390+
CODE_SIGN_STYLE = Automatic;
391+
CURRENT_PROJECT_VERSION = 1;
392+
DEVELOPMENT_TEAM = 5X4NN3D5UF;
393+
GENERATE_INFOPLIST_FILE = YES;
394+
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
395+
LD_RUNPATH_SEARCH_PATHS = (
396+
"$(inherited)",
397+
"@loader_path/Frameworks",
398+
"@executable_path/Frameworks",
399+
);
400+
MARKETING_VERSION = 1.0;
401+
PRODUCT_BUNDLE_IDENTIFIER = ME.WeatherAppTests;
402+
PRODUCT_NAME = "$(TARGET_NAME)";
403+
STRING_CATALOG_GENERATE_SYMBOLS = YES;
404+
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
405+
SWIFT_EMIT_LOC_STRINGS = NO;
406+
SWIFT_VERSION = 5.0;
407+
TARGETED_DEVICE_FAMILY = "1,2";
408+
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/WeatherApp.app/WeatherApp";
409+
};
410+
name = Debug;
411+
};
412+
EFB0C1B62F7D7236005F6B1F /* Release */ = {
413+
isa = XCBuildConfiguration;
414+
buildSettings = {
415+
BUNDLE_LOADER = "$(TEST_HOST)";
416+
CODE_SIGN_STYLE = Automatic;
417+
CURRENT_PROJECT_VERSION = 1;
418+
DEVELOPMENT_TEAM = 5X4NN3D5UF;
419+
GENERATE_INFOPLIST_FILE = YES;
420+
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
421+
LD_RUNPATH_SEARCH_PATHS = (
422+
"$(inherited)",
423+
"@loader_path/Frameworks",
424+
"@executable_path/Frameworks",
425+
);
426+
MARKETING_VERSION = 1.0;
427+
PRODUCT_BUNDLE_IDENTIFIER = ME.WeatherAppTests;
428+
PRODUCT_NAME = "$(TARGET_NAME)";
429+
STRING_CATALOG_GENERATE_SYMBOLS = YES;
430+
SWIFT_EMIT_LOC_STRINGS = NO;
431+
SWIFT_VERSION = 5.0;
432+
TARGETED_DEVICE_FAMILY = "1,2";
433+
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/WeatherApp.app/WeatherApp";
434+
};
435+
name = Release;
436+
};
313437
/* End XCBuildConfiguration section */
314438

315439
/* Begin XCConfigurationList section */
@@ -331,6 +455,15 @@
331455
defaultConfigurationIsVisible = 0;
332456
defaultConfigurationName = Release;
333457
};
458+
EFB0C1B42F7D7236005F6B1F /* Build configuration list for PBXNativeTarget "WeatherAppTests" */ = {
459+
isa = XCConfigurationList;
460+
buildConfigurations = (
461+
EFB0C1B52F7D7236005F6B1F /* Debug */,
462+
EFB0C1B62F7D7236005F6B1F /* Release */,
463+
);
464+
defaultConfigurationIsVisible = 0;
465+
defaultConfigurationName = Release;
466+
};
334467
/* End XCConfigurationList section */
335468
};
336469
rootObject = EFA0C1A12F7D7234005F6B1F /* Project object */;
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import Foundation
2+
3+
struct AppConfiguration {
4+
let openWeatherAPIKey: String
5+
let overlayTextAPIURL: URL?
6+
7+
static func load() -> AppConfiguration {
8+
return AppConfiguration(
9+
openWeatherAPIKey: "2d86ff63cd175eb025634694495e1b1b",
10+
overlayTextAPIURL: nil
11+
)
12+
}
13+
}

0 commit comments

Comments
 (0)