├── .github
├── FUNDING.yml
└── workflows
│ └── lint.yml
├── .gitignore
├── .swiftformat
├── .swiftlint.yml
├── CONTRIBUTING.md
├── Config.xcconfig
├── DatWeatherDoe.xcodeproj
├── project.pbxproj
├── project.xcworkspace
│ ├── contents.xcworkspacedata
│ └── xcshareddata
│ │ ├── IDEWorkspaceChecks.plist
│ │ └── swiftpm
│ │ └── Package.resolved
└── xcshareddata
│ └── xcschemes
│ └── DatWeatherDoe.xcscheme
├── DatWeatherDoe
├── API
│ ├── NetworkClient.swift
│ ├── Repository
│ │ └── Zip Code
│ │ │ ├── ZipCodeValidator.swift
│ │ │ ├── ZipCodeWeatherRepository.swift
│ │ │ └── ZipCodeWeatherURLBuilder.swift
│ ├── Response
│ │ ├── AirQuality.swift
│ │ ├── ForecastData.swift
│ │ ├── ForecastTemperatureData.swift
│ │ ├── SunriseSunsetData.swift
│ │ ├── TemperatureData.swift
│ │ ├── WeatherAPIResponse.swift
│ │ ├── WeatherAPIResponseParser.swift
│ │ └── WindData.swift
│ ├── WeatherData.swift
│ └── WeatherError.swift
├── Config
│ ├── APIKeyParser.swift
│ ├── ConfigManager.swift
│ └── ConfigOptions.swift
├── DatWeatherDoeApp.swift
├── Localization
│ └── en.xcloc
│ │ ├── Localized Contents
│ │ └── en.xliff
│ │ ├── Source Contents
│ │ └── DatWeatherDoe
│ │ │ ├── Resources
│ │ │ └── en.lproj
│ │ │ │ └── InfoPlist.strings
│ │ │ └── UI
│ │ │ └── Base.lproj
│ │ │ └── MainMenu.xib
│ │ └── contents.json
├── Reachability
│ └── WeatherReachability.swift
├── Resources
│ ├── Assets.xcassets
│ │ ├── AppIcon.appiconset
│ │ │ ├── 1024.png
│ │ │ ├── 128.png
│ │ │ ├── 16.png
│ │ │ ├── 256-1.png
│ │ │ ├── 256.png
│ │ │ ├── 32-1.png
│ │ │ ├── 32.png
│ │ │ ├── 512-1.png
│ │ │ ├── 512.png
│ │ │ ├── 64.png
│ │ │ └── Contents.json
│ │ └── Contents.json
│ ├── DatWeatherDoe.entitlements
│ ├── DevelopmentAssets
│ │ └── TestData.swift
│ ├── Info.plist
│ └── Localization
│ │ └── Localizable.xcstrings
├── UI
│ ├── Configure
│ │ ├── ConfigureOptionsView.swift
│ │ ├── ConfigureUnitOptionsView.swift
│ │ ├── ConfigureValueSeparatorOptionsView.swift
│ │ ├── ConfigureView.swift
│ │ ├── ConfigureViewModel.swift
│ │ ├── ConfigureWeatherOptionsView.swift
│ │ └── Options
│ │ │ ├── MeasurementUnit.swift
│ │ │ ├── RefreshInterval.swift
│ │ │ ├── TemperatureUnit.swift
│ │ │ ├── WeatherConditionPosition.swift
│ │ │ └── WeatherSource.swift
│ ├── Decorator
│ │ ├── Condition
│ │ │ ├── WeatherCondition.swift
│ │ │ ├── WeatherConditionBuilder.swift
│ │ │ └── WeatherConditionTextMapper.swift
│ │ ├── Text
│ │ │ ├── HumidityTextBuilder.swift
│ │ │ ├── SunriseAndSunsetTextBuilder.swift
│ │ │ ├── Temperature
│ │ │ │ ├── TemperatureForecastTextBuilder.swift
│ │ │ │ ├── TemperatureFormatter.swift
│ │ │ │ ├── TemperatureTextBuilder.swift
│ │ │ │ └── TemperatureWithDegreesCreator.swift
│ │ │ ├── UVIndexTextBuilder.swift
│ │ │ └── WeatherTextBuilder.swift
│ │ └── WeatherDataBuilder.swift
│ ├── Forecaster
│ │ └── WeatherForecaster.swift
│ ├── Menu Bar
│ │ ├── CustomButton.swift
│ │ ├── DropdownIcon.swift
│ │ ├── MenuOptionsView.swift
│ │ ├── MenuView.swift
│ │ ├── NonInteractiveMenuOptionView.swift
│ │ └── WindSpeedFormatter.swift
│ └── Status Bar
│ │ └── StatusBarView.swift
└── ViewModel
│ ├── Parser
│ ├── CityWeatherResultParser.swift
│ └── ZipCodeWeatherResultParser.swift
│ ├── Repository
│ ├── Coordinates
│ │ ├── LocationCoordinatesWeatherRepository.swift
│ │ ├── LocationParser.swift
│ │ └── LocationValidator.swift
│ ├── System
│ │ ├── SystemLocationFetcher.swift
│ │ └── SystemLocationWeatherRepository.swift
│ ├── WeatherRepositoryFactory.swift
│ ├── WeatherRepositoryType.swift
│ ├── WeatherURLBuilder.swift
│ └── WeatherValidatorType.swift
│ ├── WeatherDataFormatter.swift
│ ├── WeatherViewModel.swift
│ └── WeatherViewModelType.swift
├── DatWeatherDoeTests
├── API
│ └── Repository
│ │ ├── Location
│ │ └── Coordinates
│ │ │ └── LocationValidatorTests.swift
│ │ └── WeatherURLBuilderTests.swift
├── Config
│ └── ConfigManagerTests.swift
├── DatWeatherDoe.xctestplan
└── UI
│ └── Configure
│ └── Options
│ ├── RefreshIntervalTests.swift
│ ├── TemperatureUnitTests.swift
│ └── WeatherSourceTests.swift
├── LICENSE
├── README.md
├── location_services_1.png
├── location_services_2.png
├── logo.png
├── screenshot_1.png
└── screenshot_2.png
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: inderdhir
4 |
--------------------------------------------------------------------------------
/.github/workflows/lint.yml:
--------------------------------------------------------------------------------
1 | name: SwiftLint
2 |
3 | on:
4 | pull_request:
5 | paths:
6 | - '.github/workflows/swiftlint.yml'
7 | - '.swiftlint.yml'
8 | - '**/*.swift'
9 |
10 | jobs:
11 | lint:
12 | runs-on: ubuntu-latest
13 | steps:
14 | - uses: actions/checkout@v1
15 | - name: GitHub Action for SwiftLint (Different working directory)
16 | uses: norio-nomura/action-swiftlint@3.2.1
17 | env:
18 | WORKING_DIRECTORY: Source
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Xcode
2 | .DS_Store
3 |
4 | # Backup files
5 | *~
6 |
7 | # JetBrains
8 | .idea/
9 |
10 | ## Build generated
11 | build/
12 | DerivedData
13 |
14 | ## Various settings
15 | *.pbxuser
16 | !default.pbxuser
17 | *.mode1v3
18 | !default.mode1v3
19 | *.mode2v3
20 | !default.mode2v3
21 | *.perspectivev3
22 | !default.perspectivev3
23 | xcuserdata
24 |
25 | ## Other
26 | *.xccheckout
27 | *.moved-aside
28 | *.xcuserstate
29 | *.xcscmblueprint
30 |
31 | ## Obj-C/Swift specific
32 | *.hmap
33 | *.ipa
34 |
35 | # Thumbnails
36 | ._*
37 |
38 | # Files that might appear in the root of a volume
39 | .DocumentRevisions-V100
40 | .fseventsd
41 | .Spotlight-V100
42 | .TemporaryItems
43 | .Trashes
44 | .VolumeIcon.icns
45 |
46 | # Directories potentially created on remote AFP share
47 | .AppleDB
48 | .AppleDesktop
49 | Network Trash Folder
50 | Temporary Items
51 | .apdisk
52 | The above adds CocoaPods,
53 |
54 | Pods/
55 | Podfile.lock
56 |
57 | # Keys
58 | DatWeatherDoe/Resources/Keys.plist
59 |
60 | # Secrets
61 | Config.xcconfig
62 |
--------------------------------------------------------------------------------
/.swiftformat:
--------------------------------------------------------------------------------
1 | --disable trailingCommas
--------------------------------------------------------------------------------
/.swiftlint.yml:
--------------------------------------------------------------------------------
1 | disabled_rules: # rule identifiers to exclude from running
2 | - colon
3 | - comma
4 | - control_statement
5 | - trailing_whitespace
6 | - vertical_parameter_alignment
7 | - opening_brace
8 | opt_in_rules: # some rules are only opt-in
9 | - empty_count
10 | # Find all the available rules by running:
11 | # swiftlint rules
12 | included: # paths to include during linting. `--path` is ignored if present.
13 | - DatWeatherDoe
14 | excluded: # paths to ignore during linting. Takes precedence over `included`.
15 | - Pods
16 | # configurable rules can be customized from this configuration file
17 | # configurable rules can be customized from this configuration file
18 | # binary rules can set their severity level
19 | cyclomatic_complexity:
20 | ignores_case_statements: true
21 | force_cast: warning # implicitly
22 | force_try:
23 | severity: warning # explicitly
24 | # rules that have both warning and error levels, can set just the warning level
25 | # implicitly
26 | line_length: 110
27 | # they can set both implicitly with an array
28 | type_body_length:
29 | - 300 # warning
30 | - 400 # error
31 | # or they can set both explicitly
32 | file_length:
33 | warning: 500
34 | error: 1200
35 | # naming rules can set warnings/errors for min_length and max_length
36 | # additionally they can set excluded names
37 | type_name:
38 | min_length: 4 # only warning
39 | max_length: # warning and error
40 | warning: 40
41 | error: 50
42 | excluded: iPhone # excluded via string
43 | identifier_name:
44 | min_length: # only min_length
45 | error: 2 # only error
46 | excluded: # excluded via string array
47 | - id
48 | - URL
49 | - GlobalAPIKey
50 | function_body_length:
51 | warning: 50
52 | error: 100
53 | reporter: "xcode" # reporter type (xcode, json, csv, checkstyle, junit, html, emoji)
54 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | Contributions / pull requests are welcome!
4 |
5 | Please note that the goal of this project to provide a lightweight menu bar app for weather at a glance on macOS (similar to weather app indicators on Ubuntu).
6 |
7 | **MacOS 11 provides a weather widget that can be used in conjunction with this app so a macOS widget is NOT on the roadmap.**
--------------------------------------------------------------------------------
/Config.xcconfig:
--------------------------------------------------------------------------------
1 | //
2 | // Config.xcconfig
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 2/12/22.
6 | // Copyright © 2022 Inder Dhir. All rights reserved.
7 | //
8 |
9 | // Configuration settings file format documentation can be found at:
10 | // https://help.apple.com/xcode/#/dev745c5c974
11 |
12 | WEATHER_API_KEY=
13 |
--------------------------------------------------------------------------------
/DatWeatherDoe.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/DatWeatherDoe.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/DatWeatherDoe.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "originHash" : "cf72def9a17d22a974c73e8b0f0a4d80bccc1e736e0ce0e89a85e671a37bb5b6",
3 | "pins" : [
4 | {
5 | "identity" : "collectionconcurrencykit",
6 | "kind" : "remoteSourceControl",
7 | "location" : "https://github.com/JohnSundell/CollectionConcurrencyKit.git",
8 | "state" : {
9 | "revision" : "b4f23e24b5a1bff301efc5e70871083ca029ff95",
10 | "version" : "0.2.0"
11 | }
12 | },
13 | {
14 | "identity" : "cryptoswift",
15 | "kind" : "remoteSourceControl",
16 | "location" : "https://github.com/krzyzanowskim/CryptoSwift.git",
17 | "state" : {
18 | "revision" : "678d442c6f7828def400a70ae15968aef67ef52d",
19 | "version" : "1.8.3"
20 | }
21 | },
22 | {
23 | "identity" : "menubarextraaccess",
24 | "kind" : "remoteSourceControl",
25 | "location" : "https://github.com/orchetect/MenuBarExtraAccess",
26 | "state" : {
27 | "revision" : "9ff6cbb0ba373527a19832a0791cea4a41ad7a6f",
28 | "version" : "1.1.3"
29 | }
30 | },
31 | {
32 | "identity" : "reachability.swift",
33 | "kind" : "remoteSourceControl",
34 | "location" : "https://github.com/ashleymills/Reachability.swift",
35 | "state" : {
36 | "revision" : "21d1dc412cfecbe6e34f1f4c4eb88d3f912654a6",
37 | "version" : "5.2.4"
38 | }
39 | },
40 | {
41 | "identity" : "sourcekitten",
42 | "kind" : "remoteSourceControl",
43 | "location" : "https://github.com/jpsim/SourceKitten.git",
44 | "state" : {
45 | "revision" : "fd4df99170f5e9d7cf9aa8312aa8506e0e7a44e7",
46 | "version" : "0.35.0"
47 | }
48 | },
49 | {
50 | "identity" : "swift-argument-parser",
51 | "kind" : "remoteSourceControl",
52 | "location" : "https://github.com/apple/swift-argument-parser.git",
53 | "state" : {
54 | "revision" : "41982a3656a71c768319979febd796c6fd111d5c",
55 | "version" : "1.5.0"
56 | }
57 | },
58 | {
59 | "identity" : "swift-syntax",
60 | "kind" : "remoteSourceControl",
61 | "location" : "https://github.com/swiftlang/swift-syntax.git",
62 | "state" : {
63 | "revision" : "515f79b522918f83483068d99c68daeb5116342d",
64 | "version" : "600.0.0-prerelease-2024-08-14"
65 | }
66 | },
67 | {
68 | "identity" : "swiftformat",
69 | "kind" : "remoteSourceControl",
70 | "location" : "https://github.com/nicklockwood/SwiftFormat",
71 | "state" : {
72 | "revision" : "86ed20990585f478c0daf309af645c2a528b59d8",
73 | "version" : "0.54.6"
74 | }
75 | },
76 | {
77 | "identity" : "swiftlint",
78 | "kind" : "remoteSourceControl",
79 | "location" : "https://github.com/realm/SwiftLint",
80 | "state" : {
81 | "revision" : "168fb98ed1f3e343d703ecceaf518b6cf565207b",
82 | "version" : "0.57.0"
83 | }
84 | },
85 | {
86 | "identity" : "swiftytexttable",
87 | "kind" : "remoteSourceControl",
88 | "location" : "https://github.com/scottrhoyt/SwiftyTextTable.git",
89 | "state" : {
90 | "revision" : "c6df6cf533d120716bff38f8ff9885e1ce2a4ac3",
91 | "version" : "0.9.0"
92 | }
93 | },
94 | {
95 | "identity" : "swxmlhash",
96 | "kind" : "remoteSourceControl",
97 | "location" : "https://github.com/drmohundro/SWXMLHash.git",
98 | "state" : {
99 | "revision" : "a853604c9e9a83ad9954c7e3d2a565273982471f",
100 | "version" : "7.0.2"
101 | }
102 | },
103 | {
104 | "identity" : "yams",
105 | "kind" : "remoteSourceControl",
106 | "location" : "https://github.com/jpsim/Yams.git",
107 | "state" : {
108 | "revision" : "3036ba9d69cf1fd04d433527bc339dc0dc75433d",
109 | "version" : "5.1.3"
110 | }
111 | }
112 | ],
113 | "version" : 3
114 | }
115 |
--------------------------------------------------------------------------------
/DatWeatherDoe.xcodeproj/xcshareddata/xcschemes/DatWeatherDoe.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
30 |
31 |
34 |
35 |
36 |
37 |
47 |
49 |
55 |
56 |
57 |
58 |
64 |
66 |
72 |
73 |
74 |
75 |
77 |
78 |
81 |
82 |
83 |
--------------------------------------------------------------------------------
/DatWeatherDoe/API/NetworkClient.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NetworkClient.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 1/14/22.
6 | // Copyright © 2022 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | protocol NetworkClientType {
12 | func performRequest(url: URL) async throws -> Data
13 | }
14 |
15 | final actor NetworkClient: NetworkClientType {
16 | func performRequest(url: URL) async throws -> Data {
17 | do {
18 | return try await URLSession.shared.data(from: url).0
19 | } catch {
20 | throw WeatherError.networkError
21 | }
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/DatWeatherDoe/API/Repository/Zip Code/ZipCodeValidator.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ZipCodeValidator.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 1/13/22.
6 | // Copyright © 2022 Inder Dhir. All rights reserved.
7 | //
8 |
9 | final class ZipCodeValidator: WeatherValidatorType {
10 | private let zipCode: String
11 |
12 | init(zipCode: String) {
13 | self.zipCode = zipCode
14 | }
15 |
16 | func validate() throws {
17 | let isZipPresent = !zipCode.isEmpty
18 | let isZipPresentWithCountryCode = zipCode.split(separator: ",").count == 2
19 | let isValid = isZipPresent && isZipPresentWithCountryCode
20 | if !isValid {
21 | throw WeatherError.zipCodeIncorrect
22 | }
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/DatWeatherDoe/API/Repository/Zip Code/ZipCodeWeatherRepository.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ZipCodeWeatherRepository.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 1/14/22.
6 | // Copyright © 2022 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | final class ZipCodeWeatherRepository: WeatherRepositoryType {
12 | private let appId: String
13 | private let zipCode: String
14 | private let networkClient: NetworkClient
15 | private let logger: DatWeatherDoeLoggerType
16 |
17 | init(
18 | appId: String,
19 | zipCode: String,
20 | networkClient: NetworkClient,
21 | logger: DatWeatherDoeLoggerType
22 | ) {
23 | self.appId = appId
24 | self.zipCode = zipCode
25 | self.networkClient = networkClient
26 | self.logger = logger
27 | }
28 |
29 | func getWeather(completion: @escaping (Result) -> Void) {
30 | logger.debug("Getting weather via zip code")
31 |
32 | do {
33 | try validateZipCode()
34 | let url = try buildURL()
35 | performRequest(url: url, completion: { [weak self] result in
36 | self?.parseNetworkResult(result: result, completion: completion)
37 | })
38 | } catch {
39 | logger.error("Getting weather via zip code failed. Zip code is incorrect!")
40 |
41 | completion(.failure(error))
42 | }
43 | }
44 |
45 | private func validateZipCode() throws {
46 | try ZipCodeValidator(zipCode: zipCode).validate()
47 | }
48 |
49 | private func buildURL() throws -> URL {
50 | try ZipCodeWeatherURLBuilder(appId: appId, zipCode: zipCode).build()
51 | }
52 |
53 | private func performRequest(url: URL, completion: @escaping (Result) -> Void) {
54 | networkClient.performRequest(url: url, completion: completion)
55 | }
56 |
57 | private func parseNetworkResult(
58 | result: Result,
59 | completion: @escaping (Result) -> Void
60 | ) {
61 | switch result {
62 | case let .success(data):
63 | do {
64 | let weatherData = try parseWeatherData(data)
65 | completion(.success(weatherData))
66 | } catch {
67 | let weatherError = (error as? WeatherError) ?? WeatherError.other
68 | completion(.failure(weatherError))
69 | }
70 | case let .failure(error):
71 | let weatherError = (error as? WeatherError) ?? WeatherError.other
72 | completion(.failure(weatherError))
73 | }
74 | }
75 |
76 | private func parseWeatherData(_ data: Data) throws -> WeatherAPIResponse {
77 | try WeatherAPIResponseParser().parse(data)
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/DatWeatherDoe/API/Repository/Zip Code/ZipCodeWeatherURLBuilder.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ZipCodeWeatherURLBuilder.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 1/16/22.
6 | // Copyright © 2022 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | final class ZipCodeWeatherURLBuilder: WeatherURLBuilder {
12 | private let zipCode: String
13 |
14 | init(appId: String, zipCode: String) {
15 | self.zipCode = zipCode
16 | super.init(appId: appId)
17 | }
18 |
19 | override func build() throws -> URL {
20 | let queryItems: [URLQueryItem] = [
21 | URLQueryItem(name: "appid", value: appId),
22 | URLQueryItem(name: "zip", value: zipCode),
23 | ]
24 |
25 | var urlComps = URLComponents(string: apiUrlString)
26 | urlComps?.queryItems = queryItems
27 | guard let finalUrl = urlComps?.url else {
28 | throw WeatherError.unableToConstructUrl
29 | }
30 | return finalUrl
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/DatWeatherDoe/API/Response/AirQuality.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AirQuality.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 7/1/24.
6 | // Copyright © 2024 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | // US - EPA standard
12 | enum AirQualityIndex: Int, Decodable {
13 | case good = 1
14 | case moderate = 2
15 | case unhealthyForSensitive = 3
16 | case unhealthy = 4
17 | case veryUnhealthy = 5
18 | case hazardous = 6
19 |
20 | var description: String {
21 | switch self {
22 | case .good:
23 | NSLocalizedString(
24 | "Good",
25 | comment: "Air quality index: Good"
26 | )
27 | case .moderate:
28 | NSLocalizedString(
29 | "Moderate",
30 | comment: "Air quality index: Moderate"
31 | )
32 | case .unhealthyForSensitive:
33 | NSLocalizedString(
34 | "Unhealthy for sensitive groups",
35 | comment: "Air quality index: Unhealthy for sensitive groups"
36 | )
37 | case .unhealthy:
38 | NSLocalizedString(
39 | "Unhealthy",
40 | comment: "Air quality index: Unhealthy"
41 | )
42 | case .veryUnhealthy:
43 | NSLocalizedString(
44 | "Very unhealthy",
45 | comment: "Air quality index: Very unhealthy"
46 | )
47 | case .hazardous:
48 | NSLocalizedString(
49 | "Hazardous",
50 | comment: "Air quality index: Hazardous"
51 | )
52 | }
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/DatWeatherDoe/API/Response/ForecastData.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ForecastData.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 6/23/24.
6 | // Copyright © 2024 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | struct Forecast: Decodable {
12 | let dayDataArr: [ForecastDayData]
13 |
14 | private enum CodingKeys: String, CodingKey {
15 | case dayDataArr = "forecastday"
16 | }
17 | }
18 |
19 | struct ForecastDayData: Decodable {
20 | let temp: ForecastTemperatureData
21 | let astro: SunriseSunsetData
22 | let hour: [HourlyUVIndex]
23 |
24 | private enum CodingKeys: String, CodingKey {
25 | case temp = "day"
26 | case astro
27 | case hour
28 | }
29 | }
30 |
31 | struct HourlyUVIndex: Decodable {
32 | let uv: Double
33 | }
34 |
--------------------------------------------------------------------------------
/DatWeatherDoe/API/Response/ForecastTemperatureData.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ForecastTemperatureData.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 6/23/24.
6 | // Copyright © 2024 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | struct ForecastTemperatureData: Decodable {
12 | let maxTempC: Double
13 | let maxTempF: Double
14 | let minTempC: Double
15 | let minTempF: Double
16 |
17 | private enum CodingKeys: String, CodingKey {
18 | case maxTempC = "maxtemp_c"
19 | case maxTempF = "maxtemp_f"
20 | case minTempC = "mintemp_c"
21 | case minTempF = "mintemp_f"
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/DatWeatherDoe/API/Response/SunriseSunsetData.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SunriseSunsetData.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 6/22/24.
6 | // Copyright © 2024 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | struct SunriseSunsetData: Decodable {
12 | let sunrise: String
13 | let sunset: String
14 |
15 | private enum CodingKeys: String, CodingKey {
16 | case sunrise, sunset
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/DatWeatherDoe/API/Response/TemperatureData.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TemperatureData.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 6/23/24.
6 | // Copyright © 2024 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | struct TemperatureData: Decodable {
12 | let tempCelsius: Double
13 | let feelsLikeTempCelsius: Double
14 | let tempFahrenheit: Double
15 | let feelsLikeTempFahrenheit: Double
16 |
17 | private enum CodingKeys: String, CodingKey {
18 | case tempCelsius = "temp_c"
19 | case feelsLikeTempCelsius = "feelslike_c"
20 | case tempFahrenheit = "temp_f"
21 | case feelsLikeTempFahrenheit = "feelslike_f"
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/DatWeatherDoe/API/Response/WeatherAPIResponse.swift:
--------------------------------------------------------------------------------
1 | //
2 | // WeatherAPIResponse.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 2/3/18.
6 | // Copyright © 2018 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | struct WeatherAPIResponse: Decodable {
12 | let locationName: String
13 | let temperatureData: TemperatureData
14 | let isDay: Bool
15 | let weatherConditionCode: Int
16 | let humidity: Int
17 | let windData: WindData
18 | let uvIndex: Double
19 | let forecastDayData: ForecastDayData
20 | let airQualityIndex: AirQualityIndex
21 |
22 | private enum RootKeys: String, CodingKey {
23 | case location, current, forecast
24 | }
25 |
26 | private enum LocationKeys: String, CodingKey {
27 | case name
28 | }
29 |
30 | private enum CurrentKeys: String, CodingKey {
31 | case isDay = "is_day"
32 | case condition, humidity
33 | case airQuality = "air_quality"
34 | case uvIndex = "uv"
35 | }
36 |
37 | private enum WeatherConditionKeys: String, CodingKey {
38 | case code
39 | }
40 |
41 | private enum ForecastKeys: String, CodingKey {
42 | case forecastDay = "forecastday"
43 | }
44 |
45 | private enum ForecastDayKeys: String, CodingKey {
46 | case day, astro
47 | }
48 |
49 | private enum AirQualityKeys: String, CodingKey {
50 | case usEpaIndex = "us-epa-index"
51 | }
52 |
53 | init(from decoder: Decoder) throws {
54 | let container = try decoder.container(keyedBy: RootKeys.self)
55 |
56 | let locationContainer = try container.nestedContainer(keyedBy: LocationKeys.self, forKey: .location)
57 | locationName = try locationContainer.decode(String.self, forKey: .name)
58 | temperatureData = try container.decode(TemperatureData.self, forKey: .current)
59 |
60 | let currentContainer = try container.nestedContainer(keyedBy: CurrentKeys.self, forKey: .current)
61 | let isDayInt = try currentContainer.decode(Int.self, forKey: .isDay)
62 | isDay = isDayInt > 0
63 |
64 | let weatherConditionContainer = try currentContainer.nestedContainer(
65 | keyedBy: WeatherConditionKeys.self,
66 | forKey: .condition
67 | )
68 | weatherConditionCode = try weatherConditionContainer.decode(Int.self, forKey: .code)
69 |
70 | humidity = try currentContainer.decode(Int.self, forKey: .humidity)
71 |
72 | windData = try container.decode(WindData.self, forKey: .current)
73 |
74 | uvIndex = try currentContainer.decode(Double.self, forKey: .uvIndex)
75 |
76 | let forecast = try container.decode(Forecast.self, forKey: .forecast)
77 | if let dayData = forecast.dayDataArr.first {
78 | forecastDayData = dayData
79 | } else {
80 | throw DecodingError.dataCorruptedError(
81 | forKey: .forecast,
82 | in: container,
83 | debugDescription: "Missing forecast day data"
84 | )
85 | }
86 |
87 | let airQualityContainer = try currentContainer.nestedContainer(keyedBy: AirQualityKeys.self, forKey: .airQuality)
88 | airQualityIndex = try airQualityContainer.decode(AirQualityIndex.self, forKey: .usEpaIndex)
89 | }
90 |
91 | init(
92 | locationName: String,
93 | temperatureData: TemperatureData,
94 | isDay: Bool,
95 | weatherConditionCode: Int,
96 | humidity: Int,
97 | windData: WindData,
98 | uvIndex: Double,
99 | forecastDayData: ForecastDayData,
100 | airQualityIndex: AirQualityIndex
101 | ) {
102 | self.locationName = locationName
103 | self.temperatureData = temperatureData
104 | self.isDay = isDay
105 | self.weatherConditionCode = weatherConditionCode
106 | self.humidity = humidity
107 | self.windData = windData
108 | self.uvIndex = uvIndex
109 | self.forecastDayData = forecastDayData
110 | self.airQualityIndex = airQualityIndex
111 | }
112 |
113 | // hour = [0-23]
114 | func getHourlyUVIndex(hour: Int) -> Double {
115 | forecastDayData.hour[safe: hour]?.uv ?? uvIndex
116 | }
117 | }
118 |
119 | private extension Array {
120 | subscript(safe index: Index) -> Element? { indices ~= index ? self[index] : nil }
121 | }
122 |
--------------------------------------------------------------------------------
/DatWeatherDoe/API/Response/WeatherAPIResponseParser.swift:
--------------------------------------------------------------------------------
1 | //
2 | // WeatherAPIResponseParser.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 1/10/22.
6 | // Copyright © 2022 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | protocol WeatherAPIResponseParserType {
12 | func parse(_ data: Data) throws -> WeatherAPIResponse
13 | }
14 |
15 | final class WeatherAPIResponseParser: WeatherAPIResponseParserType {
16 | func parse(_ data: Data) throws -> WeatherAPIResponse {
17 | try JSONDecoder().decode(WeatherAPIResponse.self, from: data)
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/DatWeatherDoe/API/Response/WindData.swift:
--------------------------------------------------------------------------------
1 | //
2 | // WindData.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 6/23/24.
6 | // Copyright © 2024 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | struct WindData: Decodable {
12 | let speedMph: Double
13 | let degrees: Int
14 | let direction: String
15 |
16 | private enum CodingKeys: String, CodingKey {
17 | case speedMph = "wind_mph"
18 | case degrees = "wind_degree"
19 | case direction = "wind_dir"
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/DatWeatherDoe/API/WeatherData.swift:
--------------------------------------------------------------------------------
1 | //
2 | // WeatherData.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 5/22/21.
6 | // Copyright © 2021 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | struct WeatherData {
12 | let showWeatherIcon: Bool
13 | let textualRepresentation: String?
14 | let weatherCondition: WeatherCondition
15 | let response: WeatherAPIResponse
16 | }
17 |
--------------------------------------------------------------------------------
/DatWeatherDoe/API/WeatherError.swift:
--------------------------------------------------------------------------------
1 | //
2 | // WeatherError.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 5/22/21.
6 | // Copyright © 2021 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | enum WeatherError: LocalizedError {
12 | case unableToConstructUrl
13 | case locationError
14 | case latLongIncorrect
15 | case networkError
16 |
17 | var errorDescription: String? {
18 | switch self {
19 | case .unableToConstructUrl:
20 | return "Unable to construct URL"
21 | case .locationError:
22 | return NSLocalizedString("❗️Location", comment: "Location error when fetching weather")
23 | case .latLongIncorrect:
24 | return NSLocalizedString("❗️Lat/Long", comment: "Lat/Long error when fetching weather")
25 | case .networkError:
26 | return "🖧"
27 | }
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/DatWeatherDoe/Config/APIKeyParser.swift:
--------------------------------------------------------------------------------
1 | //
2 | // APIKeyParser.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 1/11/22.
6 | // Copyright © 2022 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | final class APIKeyParser {
12 | func parse() -> String {
13 | guard let apiKey = Bundle.main.infoDictionary?["WEATHER_API_KEY"] as? String else {
14 | fatalError("Unable to find OPENWEATHERMAP_APP_ID in `Config.xcconfig`")
15 | }
16 | return apiKey
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/DatWeatherDoe/Config/ConfigManager.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ConfigManager.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 1/29/16.
6 | // Copyright © 2016 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import SwiftUI
11 |
12 | protocol ConfigManagerType: AnyObject {
13 | var measurementUnit: String { get set }
14 | var weatherSource: String { get set }
15 | var weatherSourceText: String { get set }
16 | var refreshInterval: TimeInterval { get set }
17 | var isShowingWeatherIcon: Bool { get set }
18 | var isShowingHumidity: Bool { get set }
19 | var isShowingUVIndex: Bool { get set }
20 | var isRoundingOffData: Bool { get set }
21 | var isUnitLetterOff: Bool { get set }
22 | var isUnitSymbolOff: Bool { get set }
23 | var valueSeparator: String { get set }
24 | var isWeatherConditionAsTextEnabled: Bool { get set }
25 | var weatherConditionPosition: String { get set }
26 |
27 | func updateWeatherSource(_ source: WeatherSource, sourceText: String)
28 | func setConfigOptions(_ options: ConfigOptions)
29 |
30 | var parsedMeasurementUnit: MeasurementUnit { get }
31 | }
32 |
33 | final class ConfigManager: ConfigManagerType {
34 | @AppStorage("measurementUnit")
35 | public var measurementUnit = MeasurementUnit.imperial.rawValue
36 |
37 | @AppStorage("weatherSource")
38 | public var weatherSource = WeatherSource.location.rawValue
39 |
40 | @AppStorage("weatherSourceText")
41 | public var weatherSourceText = ""
42 |
43 | @AppStorage("refreshInterval")
44 | public var refreshInterval = RefreshInterval.fifteenMinutes.rawValue
45 |
46 | @AppStorage("isShowingWeatherIcon")
47 | public var isShowingWeatherIcon = true
48 |
49 | @AppStorage("isShowingHumidity")
50 | public var isShowingHumidity = false
51 |
52 | @AppStorage("isShowingUVIndex")
53 | public var isShowingUVIndex = false
54 |
55 | @AppStorage("isRoundingOffData")
56 | public var isRoundingOffData = false
57 |
58 | @AppStorage("isUnitLetterOff")
59 | public var isUnitLetterOff = false
60 |
61 | @AppStorage("isUnitSymbolOff")
62 | public var isUnitSymbolOff = false
63 |
64 | @AppStorage("valueSeparator")
65 | public var valueSeparator = "\u{007C}"
66 |
67 | @AppStorage("isWeatherConditionAsTextEnabled")
68 | public var isWeatherConditionAsTextEnabled = false
69 |
70 | @AppStorage("weatherConditionPosition")
71 | public var weatherConditionPosition = WeatherConditionPosition.beforeTemperature.rawValue
72 |
73 | func updateWeatherSource(_ source: WeatherSource, sourceText: String) {
74 | weatherSource = source.rawValue
75 | weatherSourceText = source == .location ? "" : sourceText
76 | }
77 |
78 | func setConfigOptions(_ options: ConfigOptions) {
79 | refreshInterval = options.refreshInterval.rawValue
80 | isShowingHumidity = options.isShowingHumidity
81 | isShowingUVIndex = options.isShowingUVIndex
82 | isRoundingOffData = options.isRoundingOffData
83 | isUnitLetterOff = options.isUnitLetterOff
84 | isUnitSymbolOff = options.isUnitSymbolOff
85 | valueSeparator = options.valueSeparator
86 | isWeatherConditionAsTextEnabled = options.isWeatherConditionAsTextEnabled
87 | }
88 |
89 | var parsedMeasurementUnit: MeasurementUnit {
90 | MeasurementUnit(rawValue: measurementUnit) ?? .imperial
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/DatWeatherDoe/Config/ConfigOptions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ConfigOptions.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 8/3/23.
6 | // Copyright © 2023 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | struct ConfigOptions {
12 | let refreshInterval: RefreshInterval
13 | let isShowingHumidity: Bool
14 | let isShowingUVIndex: Bool
15 | let isRoundingOffData: Bool
16 | let isUnitLetterOff: Bool
17 | let isUnitSymbolOff: Bool
18 | let valueSeparator: String
19 | let isWeatherConditionAsTextEnabled: Bool
20 | }
21 |
--------------------------------------------------------------------------------
/DatWeatherDoe/DatWeatherDoeApp.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DatWeatherDoeApp.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 6/17/24.
6 | // Copyright © 2024 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import MenuBarExtraAccess
10 | import OSLog
11 | import SwiftUI
12 |
13 | @main
14 | struct DatWeatherDoeApp: App {
15 | @State private var configManager: ConfigManager
16 | @ObservedObject private var viewModel: WeatherViewModel
17 | @State private var isMenuPresented: Bool = false
18 | @State private var statusItem: NSStatusItem?
19 |
20 | init() {
21 | configManager = ConfigManager()
22 |
23 | let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "bundleID", category: "main")
24 | viewModel = WeatherViewModel(
25 | locationFetcher: SystemLocationFetcher(logger: logger),
26 | weatherFactory: WeatherRepositoryFactory(
27 | appId: APIKeyParser().parse(),
28 | networkClient: NetworkClient(),
29 | logger: logger
30 | ),
31 | configManager: ConfigManager(),
32 | logger: logger
33 | )
34 | viewModel.setup(with: WeatherDataFormatter(configManager: configManager))
35 | }
36 |
37 | var body: some Scene {
38 | MenuBarExtra(
39 | content: {
40 | MenuView(
41 | viewModel: viewModel,
42 | onSeeWeather: {
43 | viewModel.seeForecastForCurrentCity()
44 | closePopover()
45 | },
46 | onRefresh: {
47 | viewModel.getUpdatedWeatherAfterRefresh()
48 | closePopover()
49 | },
50 | onSave: {
51 | viewModel.getUpdatedWeatherAfterRefresh()
52 | closePopover()
53 | }
54 | )
55 | },
56 | label: {
57 | StatusBarView(weatherResult: viewModel.weatherResult)
58 | .onAppear {
59 | viewModel.getUpdatedWeatherAfterRefresh()
60 | }
61 | }
62 | )
63 | .menuBarExtraAccess(isPresented: $isMenuPresented) { statusItem in
64 | self.statusItem = statusItem
65 | }
66 | .windowStyle(.hiddenTitleBar)
67 | .menuBarExtraStyle(.window)
68 | }
69 |
70 | private func closePopover() {
71 | statusItem?.togglePresented()
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/DatWeatherDoe/Localization/en.xcloc/Localized Contents/en.xliff:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
7 |
8 |
9 | DatWeatherDoe
10 | DatWeatherDoe
11 | Bundle name
12 |
13 |
14 | Copyright © 2016 Inder Dhir. All rights reserved.
15 | Copyright © 2016 Inder Dhir. All rights reserved.
16 | Copyright (human-readable)
17 |
18 |
19 | DatWeatherDoe optionally uses your current location to get the weather
20 | DatWeatherDoe optionally uses your current location to get the weather
21 | Privacy - Location When In Use Usage Description
22 |
23 |
24 |
25 |
26 |
29 |
30 |
31 | Customize Toolbar…
32 | Customize Toolbar…
33 | Class = "NSMenuItem"; title = "Customize Toolbar…"; ObjectID = "1UK-8n-QPP";
34 |
35 |
36 | DatWeatherDoe
37 | DatWeatherDoe
38 | Class = "NSMenuItem"; title = "DatWeatherDoe"; ObjectID = "1Xt-HY-uBw";
39 |
40 |
41 | Find
42 | Find
43 | Class = "NSMenu"; title = "Find"; ObjectID = "1b7-l0-nxx";
44 |
45 |
46 | Lower
47 | Lower
48 | Class = "NSMenuItem"; title = "Lower"; ObjectID = "1tx-W0-xDw";
49 |
50 |
51 | Raise
52 | Raise
53 | Class = "NSMenuItem"; title = "Raise"; ObjectID = "2h7-ER-AoG";
54 |
55 |
56 | Transformations
57 | Transformations
58 | Class = "NSMenuItem"; title = "Transformations"; ObjectID = "2oI-Rn-ZJC";
59 |
60 |
61 | Spelling
62 | Spelling
63 | Class = "NSMenu"; title = "Spelling"; ObjectID = "3IN-sU-3Bg";
64 |
65 |
66 | Use Default
67 | Use Default
68 | Class = "NSMenuItem"; title = "Use Default"; ObjectID = "3Om-Ey-2VK";
69 |
70 |
71 | Speech
72 | Speech
73 | Class = "NSMenu"; title = "Speech"; ObjectID = "3rS-ZA-NoH";
74 |
75 |
76 | Find
77 | Find
78 | Class = "NSMenuItem"; title = "Find"; ObjectID = "4EN-yA-p0u";
79 |
80 |
81 | Quit DatWeatherDoe
82 | Quit DatWeatherDoe
83 | Class = "NSMenuItem"; title = "Quit DatWeatherDoe"; ObjectID = "4sb-4s-VLi";
84 |
85 |
86 | Edit
87 | Edit
88 | Class = "NSMenuItem"; title = "Edit"; ObjectID = "5QF-Oa-p0T";
89 |
90 |
91 | Copy Style
92 | Copy Style
93 | Class = "NSMenuItem"; title = "Copy Style"; ObjectID = "5Vv-lz-BsD";
94 |
95 |
96 | About DatWeatherDoe
97 | About DatWeatherDoe
98 | Class = "NSMenuItem"; title = "About DatWeatherDoe"; ObjectID = "5kV-Vb-QxS";
99 |
100 |
101 | Redo
102 | Redo
103 | Class = "NSMenuItem"; title = "Redo"; ObjectID = "6dh-zS-Vam";
104 |
105 |
106 | Writing Direction
107 | Writing Direction
108 | Class = "NSMenu"; title = "Writing Direction"; ObjectID = "8mr-sm-Yjd";
109 |
110 |
111 | Substitutions
112 | Substitutions
113 | Class = "NSMenuItem"; title = "Substitutions"; ObjectID = "9ic-FL-obx";
114 |
115 |
116 | Smart Copy/Paste
117 | Smart Copy/Paste
118 | Class = "NSMenuItem"; title = "Smart Copy/Paste"; ObjectID = "9yt-4B-nSM";
119 |
120 |
121 | Tighten
122 | Tighten
123 | Class = "NSMenuItem"; title = "Tighten"; ObjectID = "46P-cB-AYj";
124 |
125 |
126 | Correct Spelling Automatically
127 | Correct Spelling Automatically
128 | Class = "NSMenuItem"; title = "Correct Spelling Automatically"; ObjectID = "78Y-hA-62v";
129 |
130 |
131 | Main Menu
132 | Main Menu
133 | Class = "NSMenu"; title = "Main Menu"; ObjectID = "AYu-sK-qS6";
134 |
135 |
136 | Preferences…
137 | Preferences…
138 | Class = "NSMenuItem"; title = "Preferences…"; ObjectID = "BOF-NM-1cW";
139 |
140 |
141 | Left to Right
142 | Left to Right
143 | Class = "NSMenuItem"; title = "\tLeft to Right"; ObjectID = "BgM-ve-c93";
144 |
145 |
146 | Save As…
147 | Save As…
148 | Class = "NSMenuItem"; title = "Save As…"; ObjectID = "Bw7-FT-i3A";
149 |
150 |
151 | Close
152 | Close
153 | Class = "NSMenuItem"; title = "Close"; ObjectID = "DVo-aG-piG";
154 |
155 |
156 | Spelling and Grammar
157 | Spelling and Grammar
158 | Class = "NSMenuItem"; title = "Spelling and Grammar"; ObjectID = "Dv1-io-Yv7";
159 |
160 |
161 | Help
162 | Help
163 | Class = "NSMenu"; title = "Help"; ObjectID = "F2S-fz-NVQ";
164 |
165 |
166 | DatWeatherDoe Help
167 | DatWeatherDoe Help
168 | Class = "NSMenuItem"; title = "DatWeatherDoe Help"; ObjectID = "FKE-Sm-Kum";
169 |
170 |
171 | Text
172 | Text
173 | Class = "NSMenuItem"; title = "Text"; ObjectID = "Fal-I4-PZk";
174 |
175 |
176 | Substitutions
177 | Substitutions
178 | Class = "NSMenu"; title = "Substitutions"; ObjectID = "FeM-D8-WVr";
179 |
180 |
181 | Bold
182 | Bold
183 | Class = "NSMenuItem"; title = "Bold"; ObjectID = "GB9-OM-e27";
184 |
185 |
186 | Format
187 | Format
188 | Class = "NSMenu"; title = "Format"; ObjectID = "GEO-Iw-cKr";
189 |
190 |
191 | Use Default
192 | Use Default
193 | Class = "NSMenuItem"; title = "Use Default"; ObjectID = "GUa-eO-cwY";
194 |
195 |
196 | Font
197 | Font
198 | Class = "NSMenuItem"; title = "Font"; ObjectID = "Gi5-1S-RQB";
199 |
200 |
201 | Writing Direction
202 | Writing Direction
203 | Class = "NSMenuItem"; title = "Writing Direction"; ObjectID = "H1b-Si-o9J";
204 |
205 |
206 | View
207 | View
208 | Class = "NSMenuItem"; title = "View"; ObjectID = "H8h-7b-M4v";
209 |
210 |
211 | Text Replacement
212 | Text Replacement
213 | Class = "NSMenuItem"; title = "Text Replacement"; ObjectID = "HFQ-gK-NFA";
214 |
215 |
216 | Show Spelling and Grammar
217 | Show Spelling and Grammar
218 | Class = "NSMenuItem"; title = "Show Spelling and Grammar"; ObjectID = "HFo-cy-zxI";
219 |
220 |
221 | View
222 | View
223 | Class = "NSMenu"; title = "View"; ObjectID = "HyV-fh-RgO";
224 |
225 |
226 | Subscript
227 | Subscript
228 | Class = "NSMenuItem"; title = "Subscript"; ObjectID = "I0S-gh-46l";
229 |
230 |
231 | Open…
232 | Open…
233 | Class = "NSMenuItem"; title = "Open…"; ObjectID = "IAo-SY-fd9";
234 |
235 |
236 | Justify
237 | Justify
238 | Class = "NSMenuItem"; title = "Justify"; ObjectID = "J5U-5w-g23";
239 |
240 |
241 | Use None
242 | Use None
243 | Class = "NSMenuItem"; title = "Use None"; ObjectID = "J7y-lM-qPV";
244 |
245 |
246 | Revert to Saved
247 | Revert to Saved
248 | Class = "NSMenuItem"; title = "Revert to Saved"; ObjectID = "KaW-ft-85H";
249 |
250 |
251 | Show All
252 | Show All
253 | Class = "NSMenuItem"; title = "Show All"; ObjectID = "Kd2-mp-pUS";
254 |
255 |
256 | Bring All to Front
257 | Bring All to Front
258 | Class = "NSMenuItem"; title = "Bring All to Front"; ObjectID = "LE2-aR-0XJ";
259 |
260 |
261 | Paste Ruler
262 | Paste Ruler
263 | Class = "NSMenuItem"; title = "Paste Ruler"; ObjectID = "LVM-kO-fVI";
264 |
265 |
266 | Left to Right
267 | Left to Right
268 | Class = "NSMenuItem"; title = "\tLeft to Right"; ObjectID = "Lbh-J2-qVU";
269 |
270 |
271 | Copy Ruler
272 | Copy Ruler
273 | Class = "NSMenuItem"; title = "Copy Ruler"; ObjectID = "MkV-Pr-PK5";
274 |
275 |
276 | Services
277 | Services
278 | Class = "NSMenuItem"; title = "Services"; ObjectID = "NMo-om-nkz";
279 |
280 |
281 | Default
282 | Default
283 | Class = "NSMenuItem"; title = "\tDefault"; ObjectID = "Nop-cj-93Q";
284 |
285 |
286 | Minimize
287 | Minimize
288 | Class = "NSMenuItem"; title = "Minimize"; ObjectID = "OY7-WF-poV";
289 |
290 |
291 | Baseline
292 | Baseline
293 | Class = "NSMenuItem"; title = "Baseline"; ObjectID = "OaQ-X3-Vso";
294 |
295 |
296 | Hide DatWeatherDoe
297 | Hide DatWeatherDoe
298 | Class = "NSMenuItem"; title = "Hide DatWeatherDoe"; ObjectID = "Olw-nP-bQN";
299 |
300 |
301 | Find Previous
302 | Find Previous
303 | Class = "NSMenuItem"; title = "Find Previous"; ObjectID = "OwM-mh-QMV";
304 |
305 |
306 | Stop Speaking
307 | Stop Speaking
308 | Class = "NSMenuItem"; title = "Stop Speaking"; ObjectID = "Oyz-dy-DGm";
309 |
310 |
311 | Bigger
312 | Bigger
313 | Class = "NSMenuItem"; title = "Bigger"; ObjectID = "Ptp-SP-VEL";
314 |
315 |
316 | Show Fonts
317 | Show Fonts
318 | Class = "NSMenuItem"; title = "Show Fonts"; ObjectID = "Q5e-8K-NDq";
319 |
320 |
321 | DatWeatherDoe
322 | DatWeatherDoe
323 | Class = "NSWindow"; title = "DatWeatherDoe"; ObjectID = "QvC-M9-y7g";
324 |
325 |
326 | Zoom
327 | Zoom
328 | Class = "NSMenuItem"; title = "Zoom"; ObjectID = "R4o-n2-Eq4";
329 |
330 |
331 | Right to Left
332 | Right to Left
333 | Class = "NSMenuItem"; title = "\tRight to Left"; ObjectID = "RB4-Sm-HuC";
334 |
335 |
336 | Superscript
337 | Superscript
338 | Class = "NSMenuItem"; title = "Superscript"; ObjectID = "Rqc-34-cIF";
339 |
340 |
341 | Select All
342 | Select All
343 | Class = "NSMenuItem"; title = "Select All"; ObjectID = "Ruw-6m-B2m";
344 |
345 |
346 | Jump to Selection
347 | Jump to Selection
348 | Class = "NSMenuItem"; title = "Jump to Selection"; ObjectID = "S0p-oC-mLd";
349 |
350 |
351 | Window
352 | Window
353 | Class = "NSMenu"; title = "Window"; ObjectID = "Td7-aD-5lo";
354 |
355 |
356 | Capitalize
357 | Capitalize
358 | Class = "NSMenuItem"; title = "Capitalize"; ObjectID = "UEZ-Bs-lqG";
359 |
360 |
361 | Center
362 | Center
363 | Class = "NSMenuItem"; title = "Center"; ObjectID = "VIY-Ag-zcb";
364 |
365 |
366 | Hide Others
367 | Hide Others
368 | Class = "NSMenuItem"; title = "Hide Others"; ObjectID = "Vdr-fp-XzO";
369 |
370 |
371 | Italic
372 | Italic
373 | Class = "NSMenuItem"; title = "Italic"; ObjectID = "Vjx-xi-njq";
374 |
375 |
376 | Edit
377 | Edit
378 | Class = "NSMenu"; title = "Edit"; ObjectID = "W48-6f-4Dl";
379 |
380 |
381 | Underline
382 | Underline
383 | Class = "NSMenuItem"; title = "Underline"; ObjectID = "WRG-CD-K1S";
384 |
385 |
386 | New
387 | New
388 | Class = "NSMenuItem"; title = "New"; ObjectID = "Was-JA-tGl";
389 |
390 |
391 | Paste and Match Style
392 | Paste and Match Style
393 | Class = "NSMenuItem"; title = "Paste and Match Style"; ObjectID = "WeT-3V-zwk";
394 |
395 |
396 | Find…
397 | Find…
398 | Class = "NSMenuItem"; title = "Find…"; ObjectID = "Xz5-n4-O0W";
399 |
400 |
401 | Find and Replace…
402 | Find and Replace…
403 | Class = "NSMenuItem"; title = "Find and Replace…"; ObjectID = "YEy-JH-Tfz";
404 |
405 |
406 | Default
407 | Default
408 | Class = "NSMenuItem"; title = "\tDefault"; ObjectID = "YGs-j5-SAR";
409 |
410 |
411 | Start Speaking
412 | Start Speaking
413 | Class = "NSMenuItem"; title = "Start Speaking"; ObjectID = "Ynk-f8-cLZ";
414 |
415 |
416 | Align Left
417 | Align Left
418 | Class = "NSMenuItem"; title = "Align Left"; ObjectID = "ZM1-6Q-yy1";
419 |
420 |
421 | Paragraph
422 | Paragraph
423 | Class = "NSMenuItem"; title = "Paragraph"; ObjectID = "ZvO-Gk-QUH";
424 |
425 |
426 | Print…
427 | Print…
428 | Class = "NSMenuItem"; title = "Print…"; ObjectID = "aTl-1u-JFS";
429 |
430 |
431 | Window
432 | Window
433 | Class = "NSMenuItem"; title = "Window"; ObjectID = "aUF-d1-5bR";
434 |
435 |
436 | Font
437 | Font
438 | Class = "NSMenu"; title = "Font"; ObjectID = "aXa-aM-Jaq";
439 |
440 |
441 | Use Default
442 | Use Default
443 | Class = "NSMenuItem"; title = "Use Default"; ObjectID = "agt-UL-0e3";
444 |
445 |
446 | Show Colors
447 | Show Colors
448 | Class = "NSMenuItem"; title = "Show Colors"; ObjectID = "bgn-CT-cEk";
449 |
450 |
451 | File
452 | File
453 | Class = "NSMenu"; title = "File"; ObjectID = "bib-Uj-vzu";
454 |
455 |
456 | Use Selection for Find
457 | Use Selection for Find
458 | Class = "NSMenuItem"; title = "Use Selection for Find"; ObjectID = "buJ-ug-pKt";
459 |
460 |
461 | Transformations
462 | Transformations
463 | Class = "NSMenu"; title = "Transformations"; ObjectID = "c8a-y6-VQd";
464 |
465 |
466 | Use None
467 | Use None
468 | Class = "NSMenuItem"; title = "Use None"; ObjectID = "cDB-IK-hbR";
469 |
470 |
471 | Selection
472 | Selection
473 | Class = "NSMenuItem"; title = "Selection"; ObjectID = "cqv-fj-IhA";
474 |
475 |
476 | Smart Links
477 | Smart Links
478 | Class = "NSMenuItem"; title = "Smart Links"; ObjectID = "cwL-P1-jid";
479 |
480 |
481 | Make Lower Case
482 | Make Lower Case
483 | Class = "NSMenuItem"; title = "Make Lower Case"; ObjectID = "d9M-CD-aMd";
484 |
485 |
486 | Text
487 | Text
488 | Class = "NSMenu"; title = "Text"; ObjectID = "d9c-me-L2H";
489 |
490 |
491 | File
492 | File
493 | Class = "NSMenuItem"; title = "File"; ObjectID = "dMs-cI-mzQ";
494 |
495 |
496 | Undo
497 | Undo
498 | Class = "NSMenuItem"; title = "Undo"; ObjectID = "dRJ-4n-Yzg";
499 |
500 |
501 | Paste
502 | Paste
503 | Class = "NSMenuItem"; title = "Paste"; ObjectID = "gVA-U4-sdL";
504 |
505 |
506 | Smart Quotes
507 | Smart Quotes
508 | Class = "NSMenuItem"; title = "Smart Quotes"; ObjectID = "hQb-2v-fYv";
509 |
510 |
511 | Check Document Now
512 | Check Document Now
513 | Class = "NSMenuItem"; title = "Check Document Now"; ObjectID = "hz2-CU-CR7";
514 |
515 |
516 | Services
517 | Services
518 | Class = "NSMenu"; title = "Services"; ObjectID = "hz9-B4-Xy5";
519 |
520 |
521 | Smaller
522 | Smaller
523 | Class = "NSMenuItem"; title = "Smaller"; ObjectID = "i1d-Er-qST";
524 |
525 |
526 | Baseline
527 | Baseline
528 | Class = "NSMenu"; title = "Baseline"; ObjectID = "ijk-EB-dga";
529 |
530 |
531 | Kern
532 | Kern
533 | Class = "NSMenuItem"; title = "Kern"; ObjectID = "jBQ-r6-VK2";
534 |
535 |
536 | Right to Left
537 | Right to Left
538 | Class = "NSMenuItem"; title = "\tRight to Left"; ObjectID = "jFq-tB-4Kx";
539 |
540 |
541 | Format
542 | Format
543 | Class = "NSMenuItem"; title = "Format"; ObjectID = "jxT-CU-nIS";
544 |
545 |
546 | Check Grammar With Spelling
547 | Check Grammar With Spelling
548 | Class = "NSMenuItem"; title = "Check Grammar With Spelling"; ObjectID = "mK6-2p-4JG";
549 |
550 |
551 | Ligatures
552 | Ligatures
553 | Class = "NSMenuItem"; title = "Ligatures"; ObjectID = "o6e-r0-MWq";
554 |
555 |
556 | Open Recent
557 | Open Recent
558 | Class = "NSMenu"; title = "Open Recent"; ObjectID = "oas-Oc-fiZ";
559 |
560 |
561 | Loosen
562 | Loosen
563 | Class = "NSMenuItem"; title = "Loosen"; ObjectID = "ogc-rX-tC1";
564 |
565 |
566 | Delete
567 | Delete
568 | Class = "NSMenuItem"; title = "Delete"; ObjectID = "pa3-QI-u2k";
569 |
570 |
571 | Save…
572 | Save…
573 | Class = "NSMenuItem"; title = "Save…"; ObjectID = "pxx-59-PXV";
574 |
575 |
576 | Find Next
577 | Find Next
578 | Class = "NSMenuItem"; title = "Find Next"; ObjectID = "q09-fT-Sye";
579 |
580 |
581 | Page Setup…
582 | Page Setup…
583 | Class = "NSMenuItem"; title = "Page Setup…"; ObjectID = "qIS-W8-SiK";
584 |
585 |
586 | Check Spelling While Typing
587 | Check Spelling While Typing
588 | Class = "NSMenuItem"; title = "Check Spelling While Typing"; ObjectID = "rbD-Rh-wIN";
589 |
590 |
591 | Smart Dashes
592 | Smart Dashes
593 | Class = "NSMenuItem"; title = "Smart Dashes"; ObjectID = "rgM-f4-ycn";
594 |
595 |
596 | Show Toolbar
597 | Show Toolbar
598 | Class = "NSMenuItem"; title = "Show Toolbar"; ObjectID = "snW-S8-Cw5";
599 |
600 |
601 | Data Detectors
602 | Data Detectors
603 | Class = "NSMenuItem"; title = "Data Detectors"; ObjectID = "tRr-pd-1PS";
604 |
605 |
606 | Open Recent
607 | Open Recent
608 | Class = "NSMenuItem"; title = "Open Recent"; ObjectID = "tXI-mr-wws";
609 |
610 |
611 | Kern
612 | Kern
613 | Class = "NSMenu"; title = "Kern"; ObjectID = "tlD-Oa-oAM";
614 |
615 |
616 | DatWeatherDoe
617 | DatWeatherDoe
618 | Class = "NSMenu"; title = "DatWeatherDoe"; ObjectID = "uQy-DD-JDr";
619 |
620 |
621 | Cut
622 | Cut
623 | Class = "NSMenuItem"; title = "Cut"; ObjectID = "uRl-iY-unG";
624 |
625 |
626 | Paste Style
627 | Paste Style
628 | Class = "NSMenuItem"; title = "Paste Style"; ObjectID = "vKC-jM-MkH";
629 |
630 |
631 | Show Ruler
632 | Show Ruler
633 | Class = "NSMenuItem"; title = "Show Ruler"; ObjectID = "vLm-3I-IUL";
634 |
635 |
636 | Clear Menu
637 | Clear Menu
638 | Class = "NSMenuItem"; title = "Clear Menu"; ObjectID = "vNY-rz-j42";
639 |
640 |
641 | Make Upper Case
642 | Make Upper Case
643 | Class = "NSMenuItem"; title = "Make Upper Case"; ObjectID = "vmV-6d-7jI";
644 |
645 |
646 | Ligatures
647 | Ligatures
648 | Class = "NSMenu"; title = "Ligatures"; ObjectID = "w0m-vy-SC9";
649 |
650 |
651 | Align Right
652 | Align Right
653 | Class = "NSMenuItem"; title = "Align Right"; ObjectID = "wb2-vD-lq4";
654 |
655 |
656 | Help
657 | Help
658 | Class = "NSMenuItem"; title = "Help"; ObjectID = "wpr-3q-Mcd";
659 |
660 |
661 | Copy
662 | Copy
663 | Class = "NSMenuItem"; title = "Copy"; ObjectID = "x3v-GG-iWU";
664 |
665 |
666 | Use All
667 | Use All
668 | Class = "NSMenuItem"; title = "Use All"; ObjectID = "xQD-1f-W4t";
669 |
670 |
671 | Speech
672 | Speech
673 | Class = "NSMenuItem"; title = "Speech"; ObjectID = "xrE-MZ-jX0";
674 |
675 |
676 | Show Substitutions
677 | Show Substitutions
678 | Class = "NSMenuItem"; title = "Show Substitutions"; ObjectID = "z6F-FW-3nz";
679 |
680 |
681 |
682 |
683 |
--------------------------------------------------------------------------------
/DatWeatherDoe/Localization/en.xcloc/Source Contents/DatWeatherDoe/Resources/en.lproj/InfoPlist.strings:
--------------------------------------------------------------------------------
1 | /* Bundle name */
2 | "CFBundleName" = "DatWeatherDoe";
3 | /* Copyright (human-readable) */
4 | "NSHumanReadableCopyright" = "Copyright © 2016 Inder Dhir. All rights reserved.";
5 | /* Privacy - Location When In Use Usage Description */
6 | "NSLocationWhenInUseUsageDescription" = "DatWeatherDoe optionally uses your current location to get the weather";
7 |
--------------------------------------------------------------------------------
/DatWeatherDoe/Localization/en.xcloc/contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "developmentRegion" : "en",
3 | "project" : "DatWeatherDoe.xcodeproj",
4 | "targetLocale" : "en",
5 | "toolInfo" : {
6 | "toolBuildNumber" : "12E262",
7 | "toolID" : "com.apple.dt.xcode",
8 | "toolName" : "Xcode",
9 | "toolVersion" : "12.5"
10 | },
11 | "version" : "1.0"
12 | }
--------------------------------------------------------------------------------
/DatWeatherDoe/Reachability/WeatherReachability.swift:
--------------------------------------------------------------------------------
1 | //
2 | // WeatherReachability.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 1/11/22.
6 | // Copyright © 2022 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import OSLog
10 | import Reachability
11 |
12 | final class NetworkReachability {
13 | private let logger: Logger
14 | private var reachability: Reachability?
15 | private var retryWhenReachable = false
16 |
17 | init(
18 | logger: Logger,
19 | onBecomingReachable: @escaping () -> Void
20 | ) {
21 | self.logger = logger
22 |
23 | setupWith(callback: onBecomingReachable)
24 | }
25 |
26 | private func setupWith(callback: @escaping () -> Void) {
27 | do {
28 | reachability = try Reachability()
29 | try reachability?.startNotifier()
30 | updateReachabilityWhenReachable(callback: callback)
31 | updateReachabilityWhenUnreachable()
32 | } catch {
33 | logger.error("Reachability error!")
34 | }
35 | }
36 |
37 | private func updateReachabilityWhenReachable(callback: @escaping () -> Void) {
38 | reachability?.whenReachable = { [weak self] _ in
39 | self?.logger.debug("Reachability status: Reachable")
40 |
41 | if self?.retryWhenReachable == true {
42 | self?.retryWhenReachable = false
43 | callback()
44 | }
45 | }
46 | }
47 |
48 | private func updateReachabilityWhenUnreachable() {
49 | reachability?.whenUnreachable = { [weak self] _ in
50 | self?.logger.debug("Reachability status: Unreachable")
51 |
52 | self?.retryWhenReachable = true
53 | }
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/DatWeatherDoe/Resources/Assets.xcassets/AppIcon.appiconset/1024.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/inderdhir/DatWeatherDoe/6a46616a3021066fc5b31ecab30f48fdeff8a88f/DatWeatherDoe/Resources/Assets.xcassets/AppIcon.appiconset/1024.png
--------------------------------------------------------------------------------
/DatWeatherDoe/Resources/Assets.xcassets/AppIcon.appiconset/128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/inderdhir/DatWeatherDoe/6a46616a3021066fc5b31ecab30f48fdeff8a88f/DatWeatherDoe/Resources/Assets.xcassets/AppIcon.appiconset/128.png
--------------------------------------------------------------------------------
/DatWeatherDoe/Resources/Assets.xcassets/AppIcon.appiconset/16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/inderdhir/DatWeatherDoe/6a46616a3021066fc5b31ecab30f48fdeff8a88f/DatWeatherDoe/Resources/Assets.xcassets/AppIcon.appiconset/16.png
--------------------------------------------------------------------------------
/DatWeatherDoe/Resources/Assets.xcassets/AppIcon.appiconset/256-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/inderdhir/DatWeatherDoe/6a46616a3021066fc5b31ecab30f48fdeff8a88f/DatWeatherDoe/Resources/Assets.xcassets/AppIcon.appiconset/256-1.png
--------------------------------------------------------------------------------
/DatWeatherDoe/Resources/Assets.xcassets/AppIcon.appiconset/256.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/inderdhir/DatWeatherDoe/6a46616a3021066fc5b31ecab30f48fdeff8a88f/DatWeatherDoe/Resources/Assets.xcassets/AppIcon.appiconset/256.png
--------------------------------------------------------------------------------
/DatWeatherDoe/Resources/Assets.xcassets/AppIcon.appiconset/32-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/inderdhir/DatWeatherDoe/6a46616a3021066fc5b31ecab30f48fdeff8a88f/DatWeatherDoe/Resources/Assets.xcassets/AppIcon.appiconset/32-1.png
--------------------------------------------------------------------------------
/DatWeatherDoe/Resources/Assets.xcassets/AppIcon.appiconset/32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/inderdhir/DatWeatherDoe/6a46616a3021066fc5b31ecab30f48fdeff8a88f/DatWeatherDoe/Resources/Assets.xcassets/AppIcon.appiconset/32.png
--------------------------------------------------------------------------------
/DatWeatherDoe/Resources/Assets.xcassets/AppIcon.appiconset/512-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/inderdhir/DatWeatherDoe/6a46616a3021066fc5b31ecab30f48fdeff8a88f/DatWeatherDoe/Resources/Assets.xcassets/AppIcon.appiconset/512-1.png
--------------------------------------------------------------------------------
/DatWeatherDoe/Resources/Assets.xcassets/AppIcon.appiconset/512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/inderdhir/DatWeatherDoe/6a46616a3021066fc5b31ecab30f48fdeff8a88f/DatWeatherDoe/Resources/Assets.xcassets/AppIcon.appiconset/512.png
--------------------------------------------------------------------------------
/DatWeatherDoe/Resources/Assets.xcassets/AppIcon.appiconset/64.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/inderdhir/DatWeatherDoe/6a46616a3021066fc5b31ecab30f48fdeff8a88f/DatWeatherDoe/Resources/Assets.xcassets/AppIcon.appiconset/64.png
--------------------------------------------------------------------------------
/DatWeatherDoe/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "16.png",
5 | "idiom" : "mac",
6 | "scale" : "1x",
7 | "size" : "16x16"
8 | },
9 | {
10 | "filename" : "32-1.png",
11 | "idiom" : "mac",
12 | "scale" : "2x",
13 | "size" : "16x16"
14 | },
15 | {
16 | "filename" : "32.png",
17 | "idiom" : "mac",
18 | "scale" : "1x",
19 | "size" : "32x32"
20 | },
21 | {
22 | "filename" : "64.png",
23 | "idiom" : "mac",
24 | "scale" : "2x",
25 | "size" : "32x32"
26 | },
27 | {
28 | "filename" : "128.png",
29 | "idiom" : "mac",
30 | "scale" : "1x",
31 | "size" : "128x128"
32 | },
33 | {
34 | "filename" : "256-1.png",
35 | "idiom" : "mac",
36 | "scale" : "2x",
37 | "size" : "128x128"
38 | },
39 | {
40 | "filename" : "256.png",
41 | "idiom" : "mac",
42 | "scale" : "1x",
43 | "size" : "256x256"
44 | },
45 | {
46 | "filename" : "512-1.png",
47 | "idiom" : "mac",
48 | "scale" : "2x",
49 | "size" : "256x256"
50 | },
51 | {
52 | "filename" : "512.png",
53 | "idiom" : "mac",
54 | "scale" : "1x",
55 | "size" : "512x512"
56 | },
57 | {
58 | "filename" : "1024.png",
59 | "idiom" : "mac",
60 | "scale" : "2x",
61 | "size" : "512x512"
62 | }
63 | ],
64 | "info" : {
65 | "author" : "xcode",
66 | "version" : 1
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/DatWeatherDoe/Resources/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/DatWeatherDoe/Resources/DatWeatherDoe.entitlements:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | com.apple.security.app-sandbox
6 |
7 | com.apple.security.network.client
8 |
9 | com.apple.security.personal-information.location
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/DatWeatherDoe/Resources/DevelopmentAssets/TestData.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TestData.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 6/25/24.
6 | // Copyright © 2024 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | let uvIndicesFor24Hours: [HourlyUVIndex] = {
12 | var arr: [HourlyUVIndex] = []
13 | for i in 1 ... 24 {
14 | arr.append(HourlyUVIndex(uv: 0))
15 | }
16 | return arr
17 | }()
18 |
19 | let response = WeatherAPIResponse(
20 | locationName: "New York City",
21 | temperatureData: .init(
22 | tempCelsius: 31.1,
23 | feelsLikeTempCelsius: 29.1,
24 | tempFahrenheit: 88.0,
25 | feelsLikeTempFahrenheit: 84.4
26 | ),
27 | isDay: true,
28 | weatherConditionCode: 1000,
29 | humidity: 45,
30 | windData: .init(speedMph: 12.3, degrees: 305, direction: "NW"),
31 | uvIndex: 7.0,
32 | forecastDayData: .init(
33 | temp: .init(
34 | maxTempC: 32.8, maxTempF: 91.0, minTempC: 20.6, minTempF: 69.2
35 | ),
36 | astro: .init(sunrise: "05:26 AM", sunset: "08:31 PM"),
37 | hour: uvIndicesFor24Hours
38 | ),
39 | airQualityIndex: .good
40 | )
41 |
--------------------------------------------------------------------------------
/DatWeatherDoe/Resources/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | WEATHER_API_KEY
6 | ${WEATHER_API_KEY}
7 | CFBundleDevelopmentRegion
8 | en
9 | CFBundleExecutable
10 | $(EXECUTABLE_NAME)
11 | CFBundleIconFile
12 |
13 | CFBundleIdentifier
14 | $(PRODUCT_BUNDLE_IDENTIFIER)
15 | CFBundleInfoDictionaryVersion
16 | 6.0
17 | CFBundleName
18 | $(PRODUCT_NAME)
19 | CFBundlePackageType
20 | APPL
21 | CFBundleShortVersionString
22 | $(MARKETING_VERSION)
23 | CFBundleSignature
24 | ????
25 | CFBundleVersion
26 | $(CURRENT_PROJECT_VERSION)
27 | LSApplicationCategoryType
28 | public.app-category.weather
29 | LSMinimumSystemVersion
30 | $(MACOSX_DEPLOYMENT_TARGET)
31 | LSUIElement
32 |
33 | NSHumanReadableCopyright
34 | Copyright © 2016 Inder Dhir. All rights reserved.
35 | NSLocationWhenInUseUsageDescription
36 | DatWeatherDoe optionally uses your current location to get the weather
37 | NSMainNibFile
38 | MainMenu
39 | NSPrincipalClass
40 | NSApplication
41 |
42 |
43 |
--------------------------------------------------------------------------------
/DatWeatherDoe/UI/Configure/ConfigureOptionsView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ConfigureOptionsView.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 8/3/23.
6 | // Copyright © 2023 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import SwiftUI
10 |
11 | struct ConfigureOptionsView: View {
12 | @ObservedObject var viewModel: ConfigureViewModel
13 |
14 | var body: some View {
15 | Grid(verticalSpacing: 16) {
16 | HStack {
17 | Text(LocalizedStringKey("Unit"))
18 | Spacer()
19 | Picker("", selection: $viewModel.measurementUnit) {
20 | Text(LocalizedStringKey("Metric")).tag(MeasurementUnit.metric)
21 | Text(LocalizedStringKey("Imperial")).tag(MeasurementUnit.imperial)
22 | Text(LocalizedStringKey("All")).tag(MeasurementUnit.all)
23 | }
24 | .frame(width: 120)
25 | }
26 |
27 | ConfigureWeatherOptionsView(viewModel: viewModel)
28 |
29 | HStack {
30 | Text(LocalizedStringKey("Refresh Interval"))
31 | Spacer()
32 | Picker("", selection: $viewModel.refreshInterval) {
33 | Text(LocalizedStringKey("5 min")).tag(RefreshInterval.fiveMinutes)
34 | Text(LocalizedStringKey("15 min")).tag(RefreshInterval.fifteenMinutes)
35 | Text(LocalizedStringKey("30 min")).tag(RefreshInterval.thirtyMinutes)
36 | Text(LocalizedStringKey("60 min")).tag(RefreshInterval.sixtyMinutes)
37 | }
38 | .frame(width: 120)
39 | }
40 |
41 | HStack {
42 | Text(LocalizedStringKey("Show Weather Icon"))
43 | Spacer()
44 | Toggle(isOn: $viewModel.isShowingWeatherIcon) {}
45 | }
46 |
47 | HStack {
48 | Text(LocalizedStringKey("Show Humidity"))
49 | Spacer()
50 | Toggle(isOn: $viewModel.isShowingHumidity) {}
51 | }
52 |
53 | HStack {
54 | Text(LocalizedStringKey("Show UV Index"))
55 | Spacer()
56 | Toggle(isOn: $viewModel.isShowingUVIndex) {}
57 | }
58 |
59 | HStack {
60 | Text(LocalizedStringKey("Round-off Data"))
61 | Spacer()
62 | Toggle(isOn: $viewModel.isRoundingOffData) {}
63 | }
64 |
65 | ConfigureUnitOptionsView(viewModel: viewModel)
66 |
67 | ConfigureValueSeparatorOptionsView(viewModel: viewModel)
68 |
69 | HStack {
70 | Text(LocalizedStringKey("Weather Condition Text"))
71 | Spacer()
72 | Toggle(isOn: $viewModel.isWeatherConditionAsTextEnabled) {}
73 | }
74 |
75 | HStack {
76 | Text(LocalizedStringKey("Weather Condition Position"))
77 | Spacer()
78 | Picker("", selection: $viewModel.weatherConditionPosition) {
79 | Text(LocalizedStringKey("Before Temperature"))
80 | .tag(WeatherConditionPosition.beforeTemperature)
81 | Text(LocalizedStringKey("After Temperature"))
82 | .tag(WeatherConditionPosition.afterTemperature)
83 | }
84 | .frame(maxWidth: 120)
85 | }
86 | .disabled(!viewModel.isWeatherConditionAsTextEnabled)
87 | }
88 | .padding()
89 | }
90 | }
91 |
92 | struct ConfigureOptionsView_Previews: PreviewProvider {
93 | static var previews: some View {
94 | ConfigureOptionsView(
95 | viewModel: .init(configManager: ConfigManager())
96 | )
97 | .frame(width: 380)
98 | }
99 | }
100 |
--------------------------------------------------------------------------------
/DatWeatherDoe/UI/Configure/ConfigureUnitOptionsView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ConfigureUnitOptionsView.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 8/8/23.
6 | // Copyright © 2023 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import SwiftUI
10 |
11 | struct ConfigureUnitOptionsView: View {
12 | @ObservedObject var viewModel: ConfigureViewModel
13 |
14 | var body: some View {
15 | Group {
16 | HStack {
17 | Text(LocalizedStringKey("Hide unit letter"))
18 | Spacer()
19 | Toggle(isOn: $viewModel.isUnitLetterOff) {}
20 | }
21 |
22 | HStack {
23 | Text(LocalizedStringKey("Hide unit ° symbol"))
24 | Spacer()
25 | Toggle(isOn: $viewModel.isUnitSymbolOff) {}
26 | }
27 | }
28 | }
29 | }
30 |
31 | struct ConfigureUnitOptionsView_Previews: PreviewProvider {
32 | static var previews: some View {
33 | Grid {
34 | ConfigureUnitOptionsView(
35 | viewModel: .init(configManager: ConfigManager())
36 | )
37 | }
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/DatWeatherDoe/UI/Configure/ConfigureValueSeparatorOptionsView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ConfigureValueSeparatorOptionsView.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 8/8/23.
6 | // Copyright © 2023 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import SwiftUI
10 |
11 | struct ConfigureValueSeparatorOptionsView: View {
12 | @ObservedObject var viewModel: ConfigureViewModel
13 | let valueSeparatorPlaceholder = "\u{007C}"
14 |
15 | var body: some View {
16 | HStack {
17 | Text(LocalizedStringKey("Separate values with"))
18 | Spacer()
19 | TextField(valueSeparatorPlaceholder, text: $viewModel.valueSeparator)
20 | .font(.body)
21 | .foregroundColor(.primary)
22 | .frame(width: 114)
23 | }
24 | }
25 | }
26 |
27 | struct ConfigureValueSeparatorOptionsView_Previews: PreviewProvider {
28 | static var previews: some View {
29 | ConfigureValueSeparatorOptionsView(
30 | viewModel: .init(configManager: ConfigManager())
31 | )
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/DatWeatherDoe/UI/Configure/ConfigureView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ConfigureView.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 2/18/22.
6 | // Copyright © 2022 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import SwiftUI
10 |
11 | struct ConfigureView: View {
12 | @ObservedObject var viewModel: ConfigureViewModel
13 | let version: String
14 | let onSave: () -> Void
15 | let onQuit: () -> Void
16 |
17 | var body: some View {
18 | VStack {
19 | ConfigureOptionsView(viewModel: viewModel)
20 |
21 | HStack {
22 | Text(version)
23 | .font(.footnote)
24 | .fontWeight(.thin)
25 | .frame(maxWidth: .infinity, alignment: .leading)
26 |
27 | CustomButton(
28 | text: LocalizedStringKey("Done"),
29 | shortcutKey: "d",
30 | onClick: onSave
31 | )
32 | .frame(maxWidth: .infinity, alignment: .center)
33 |
34 | Text(LocalizedStringKey("Quit"))
35 | .foregroundStyle(Color.red)
36 | .onTapGesture(perform: onQuit)
37 | .frame(maxWidth: .infinity, alignment: .trailing)
38 | }
39 | .frame(maxWidth: .infinity)
40 | .padding([.leading, .trailing])
41 | }
42 | .padding(.bottom)
43 | .frame(width: 380)
44 | }
45 | }
46 |
47 | struct ConfigureView_Previews: PreviewProvider {
48 | static var previews: some View {
49 | ConfigureView(
50 | viewModel: .init(configManager: ConfigManager()),
51 | version: "5.0.0",
52 | onSave: {},
53 | onQuit: {}
54 | )
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/DatWeatherDoe/UI/Configure/ConfigureViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ConfigureViewModel.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 3/20/22.
6 | // Copyright © 2022 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import Combine
10 | import Foundation
11 |
12 | final class ConfigureViewModel: ObservableObject {
13 | @Published var measurementUnit: MeasurementUnit {
14 | didSet { configManager.measurementUnit = measurementUnit.rawValue }
15 | }
16 |
17 | @Published var weatherSource: WeatherSource {
18 | didSet { configManager.weatherSource = weatherSource.rawValue }
19 | }
20 |
21 | @Published var weatherSourceText = "" {
22 | didSet { configManager.weatherSourceText = weatherSourceText }
23 | }
24 |
25 | @Published var refreshInterval: RefreshInterval {
26 | didSet { configManager.refreshInterval = refreshInterval.rawValue }
27 | }
28 |
29 | @Published var isShowingWeatherIcon: Bool {
30 | didSet { configManager.isShowingWeatherIcon = isShowingWeatherIcon }
31 | }
32 |
33 | @Published var isShowingHumidity: Bool {
34 | didSet { configManager.isShowingHumidity = isShowingHumidity }
35 | }
36 |
37 | @Published var isShowingUVIndex: Bool {
38 | didSet { configManager.isShowingUVIndex = isShowingUVIndex }
39 | }
40 |
41 | @Published var isRoundingOffData: Bool {
42 | didSet { configManager.isRoundingOffData = isRoundingOffData }
43 | }
44 |
45 | @Published var isUnitLetterOff: Bool {
46 | didSet { configManager.isUnitLetterOff = isUnitLetterOff }
47 | }
48 |
49 | @Published var isUnitSymbolOff: Bool {
50 | didSet { configManager.isUnitSymbolOff = isUnitSymbolOff }
51 | }
52 |
53 | @Published var valueSeparator = "|" {
54 | didSet { configManager.valueSeparator = valueSeparator }
55 | }
56 |
57 | @Published var isWeatherConditionAsTextEnabled: Bool {
58 | didSet { configManager.isWeatherConditionAsTextEnabled = isWeatherConditionAsTextEnabled }
59 | }
60 |
61 | @Published var weatherConditionPosition: WeatherConditionPosition {
62 | didSet { configManager.weatherConditionPosition = weatherConditionPosition.rawValue }
63 | }
64 |
65 | private let configManager: ConfigManagerType
66 |
67 | init(configManager: ConfigManagerType) {
68 | self.configManager = configManager
69 |
70 | measurementUnit = configManager.parsedMeasurementUnit
71 | weatherSource = WeatherSource(rawValue: configManager.weatherSource) ?? .location
72 |
73 | switch configManager.refreshInterval {
74 | case 300: refreshInterval = .fiveMinutes
75 | case 900: refreshInterval = .fifteenMinutes
76 | case 1800: refreshInterval = .thirtyMinutes
77 | case 3600: refreshInterval = .sixtyMinutes
78 | default: refreshInterval = .fifteenMinutes
79 | }
80 |
81 | isShowingWeatherIcon = configManager.isShowingWeatherIcon
82 | isShowingHumidity = configManager.isShowingHumidity
83 | isShowingUVIndex = configManager.isShowingUVIndex
84 | isRoundingOffData = configManager.isRoundingOffData
85 | isUnitLetterOff = configManager.isUnitLetterOff
86 | isUnitSymbolOff = configManager.isUnitSymbolOff
87 | isWeatherConditionAsTextEnabled = configManager.isWeatherConditionAsTextEnabled
88 | weatherConditionPosition = WeatherConditionPosition(rawValue: configManager.weatherConditionPosition)
89 | ?? .beforeTemperature
90 | }
91 |
92 | func saveConfig() {
93 | configManager.updateWeatherSource(weatherSource, sourceText: weatherSourceText)
94 | configManager.setConfigOptions(
95 | .init(
96 | refreshInterval: refreshInterval,
97 | isShowingHumidity: isShowingHumidity,
98 | isShowingUVIndex: isShowingUVIndex,
99 | isRoundingOffData: isRoundingOffData,
100 | isUnitLetterOff: isUnitLetterOff,
101 | isUnitSymbolOff: isUnitSymbolOff,
102 | valueSeparator: valueSeparator,
103 | isWeatherConditionAsTextEnabled: isWeatherConditionAsTextEnabled
104 | )
105 | )
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/DatWeatherDoe/UI/Configure/ConfigureWeatherOptionsView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ConfigureWeatherOptionsView.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 8/8/23.
6 | // Copyright © 2023 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import SwiftUI
10 |
11 | struct ConfigureWeatherOptionsView: View {
12 | @ObservedObject var viewModel: ConfigureViewModel
13 |
14 | var body: some View {
15 | Group {
16 | HStack {
17 | Text(LocalizedStringKey("Weather Source"))
18 | Spacer()
19 | Picker("", selection: $viewModel.weatherSource) {
20 | Text(LocalizedStringKey("Location")).tag(WeatherSource.location)
21 | Text(LocalizedStringKey("Lat/Long")).tag(WeatherSource.latLong)
22 | }
23 | .frame(width: 120)
24 | }
25 |
26 | HStack {
27 | Text(viewModel.weatherSource.textHint)
28 | .font(.caption)
29 | .foregroundColor(.secondary)
30 | Spacer()
31 | TextField(viewModel.weatherSource.placeholder, text: $viewModel.weatherSourceText)
32 | .font(.caption)
33 | .foregroundColor(.secondary)
34 | .disabled(viewModel.weatherSource == .location)
35 | .frame(width: 114)
36 | }
37 | }
38 | }
39 | }
40 |
41 | struct ConfigureWeatherOptionsView_Previews: PreviewProvider {
42 | static var previews: some View {
43 | Grid {
44 | ConfigureWeatherOptionsView(
45 | viewModel: .init(configManager: ConfigManager())
46 | )
47 | }
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/DatWeatherDoe/UI/Configure/Options/MeasurementUnit.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MeasurementUnit.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 2/7/23.
6 | // Copyright © 2023 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | enum MeasurementUnit: String, CaseIterable, Identifiable {
12 | case metric, imperial, all
13 |
14 | var id: Self { self }
15 |
16 | var temperatureUnit: TemperatureUnit {
17 | switch self {
18 | case .metric:
19 | return .celsius
20 | case .imperial:
21 | return .fahrenheit
22 | case .all:
23 | return .all
24 | }
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/DatWeatherDoe/UI/Configure/Options/RefreshInterval.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RefreshInterval.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 5/23/21.
6 | // Copyright © 2021 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | enum RefreshInterval: TimeInterval, CaseIterable, Identifiable {
12 | case fiveMinutes = 300
13 | case fifteenMinutes = 900
14 | case thirtyMinutes = 1800
15 | case sixtyMinutes = 3600
16 |
17 | var id: Self { self }
18 |
19 | var title: String {
20 | switch self {
21 | case .fiveMinutes:
22 | return NSLocalizedString("5 min", comment: "5 min refresh interval")
23 | case .fifteenMinutes:
24 | return NSLocalizedString("15 min", comment: "15 min refresh interval")
25 | case .thirtyMinutes:
26 | return NSLocalizedString("30 min", comment: "30 min refresh interval")
27 | case .sixtyMinutes:
28 | return NSLocalizedString("60 min", comment: "60 min refresh interval")
29 | }
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/DatWeatherDoe/UI/Configure/Options/TemperatureUnit.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TemperatureUnit.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 8/8/23.
6 | // Copyright © 2023 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | enum TemperatureUnit: String, CaseIterable, Identifiable {
12 | case fahrenheit, celsius, all
13 |
14 | var id: Self { self }
15 |
16 | var unitString: String {
17 | switch self {
18 | case .fahrenheit:
19 | return "F"
20 | case .celsius:
21 | return "C"
22 | case .all:
23 | return "All"
24 | }
25 | }
26 |
27 | var degreesString: String {
28 | "\u{00B0}\(unitString)"
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/DatWeatherDoe/UI/Configure/Options/WeatherConditionPosition.swift:
--------------------------------------------------------------------------------
1 | //
2 | // WeatherConditionPosition.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 3/27/24.
6 | // Copyright © 2024 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | enum WeatherConditionPosition: String, Identifiable {
12 | case beforeTemperature, afterTemperature
13 |
14 | var id: Self { self }
15 |
16 | var title: String {
17 | switch self {
18 | case .beforeTemperature:
19 | return NSLocalizedString("Before Temperature", comment: "Weather condition before temperature")
20 | case .afterTemperature:
21 | return NSLocalizedString("After Temperature", comment: "Weather condition after temperature")
22 | }
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/DatWeatherDoe/UI/Configure/Options/WeatherSource.swift:
--------------------------------------------------------------------------------
1 | //
2 | // WeatherSource.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 5/23/21.
6 | // Copyright © 2021 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | enum WeatherSource: String, CaseIterable {
12 | case location, latLong
13 |
14 | var title: String {
15 | switch self {
16 | case .location:
17 | return NSLocalizedString("Location", comment: "Weather based on location")
18 | case .latLong:
19 | return NSLocalizedString("Lat/Long", comment: "Weather based on Lat/Long")
20 | }
21 | }
22 |
23 | var placeholder: String {
24 | switch self {
25 | case .location:
26 | return ""
27 | case .latLong:
28 | return "42,42"
29 | }
30 | }
31 |
32 | var textHint: String {
33 | switch self {
34 | case .location:
35 | return ""
36 | case .latLong:
37 | return NSLocalizedString(
38 | "[latitude],[longitude]",
39 | comment: "Placeholder hint for entering Lat/Long"
40 | )
41 | }
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/DatWeatherDoe/UI/Decorator/Condition/WeatherCondition.swift:
--------------------------------------------------------------------------------
1 | //
2 | // WeatherCondition.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 1/29/16.
6 | // Copyright © 2016 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import AppKit
10 | import Foundation
11 |
12 | enum WeatherCondition {
13 | case cloudy, partlyCloudy, partlyCloudyNight
14 | case sunny, clearNight
15 | case snow
16 | case heavyRain, freezingRain, lightRain, partlyCloudyRain
17 | case thunderstorm
18 | case mist, fog
19 |
20 | static func getFallback(isDay: Bool) -> WeatherCondition {
21 | isDay ? .sunny : .clearNight
22 | }
23 |
24 | var symbolName: String {
25 | switch self {
26 | case .cloudy:
27 | "cloud"
28 | case .partlyCloudy:
29 | "cloud.sun"
30 | case .partlyCloudyNight:
31 | "cloud.moon"
32 | case .sunny:
33 | "sun.max"
34 | case .clearNight:
35 | "moon"
36 | case .snow:
37 | "cloud.snow"
38 | case .lightRain, .heavyRain, .freezingRain:
39 | "cloud.rain"
40 | case .partlyCloudyRain:
41 | "cloud.sun.rain"
42 | case .thunderstorm:
43 | "cloud.bolt.rain"
44 | case .mist, .fog:
45 | "cloud.fog"
46 | }
47 | }
48 |
49 | var accessibilityLabel: String {
50 | switch self {
51 | case .cloudy:
52 | "Cloudy"
53 | case .partlyCloudy:
54 | "Partly Cloudy"
55 | case .partlyCloudyNight:
56 | "Partly Cloudy"
57 | case .sunny:
58 | "Sunny"
59 | case .clearNight:
60 | "Clear"
61 | case .snow:
62 | "Snow"
63 | case .lightRain, .heavyRain, .freezingRain:
64 | "Rainy"
65 | case .partlyCloudyRain:
66 | "Partly cloudy with rain"
67 | case .thunderstorm:
68 | "Thunderstorm"
69 | case .mist, .fog:
70 | "Cloudy with Fog"
71 | }
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/DatWeatherDoe/UI/Decorator/Condition/WeatherConditionBuilder.swift:
--------------------------------------------------------------------------------
1 | //
2 | // WeatherConditionBuilder.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 1/11/22.
6 | // Copyright © 2022 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | protocol WeatherConditionBuilderType {
12 | func build() -> WeatherCondition
13 | }
14 |
15 | final class WeatherConditionBuilder: WeatherConditionBuilderType {
16 | private let response: WeatherAPIResponse
17 |
18 | init(response: WeatherAPIResponse) {
19 | self.response = response
20 | }
21 |
22 | func build() -> WeatherCondition {
23 | switch response.weatherConditionCode {
24 | case 1006, 1009:
25 | .cloudy
26 | case 1003:
27 | response.isDay ? .partlyCloudy : .partlyCloudyNight
28 | case 1000:
29 | response.isDay ? .sunny : .clearNight
30 | case 1030:
31 | .mist
32 | case 1135, 1147:
33 | .fog
34 | case 1066,
35 | 1114, 1117,
36 | 1210, 1213, 1216, 1219, 1222, 1225, 1237, 1249, 1252, 1255, 1258, 1261, 1264, 1279, 1282:
37 | .snow
38 | case 1192, 1195, 1243, 1246, 1276:
39 | .heavyRain
40 | case 1069, 1072, 1168, 1171, 1198, 1201, 1204, 1207:
41 | .freezingRain
42 | case 1063, 1150, 1153, 1180, 1183, 1186, 1189, 1240:
43 | response.isDay ? .partlyCloudyRain : .lightRain
44 | case 1087, 1273:
45 | .thunderstorm
46 | default:
47 | WeatherCondition.getFallback(isDay: response.isDay)
48 | }
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/DatWeatherDoe/UI/Decorator/Condition/WeatherConditionTextMapper.swift:
--------------------------------------------------------------------------------
1 | //
2 | // WeatherConditionTextMapper.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 1/11/22.
6 | // Copyright © 2022 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | protocol WeatherConditionTextMapperType {
12 | func map(_ condition: WeatherCondition) -> String
13 | }
14 |
15 | final class WeatherConditionTextMapper: WeatherConditionTextMapperType {
16 | func map(_ condition: WeatherCondition) -> String {
17 | switch condition {
18 | case .cloudy:
19 | NSLocalizedString("Cloudy", comment: "Cloudy weather condition")
20 |
21 | case .partlyCloudy, .partlyCloudyNight:
22 | NSLocalizedString("Partly cloudy", comment: "Partly cloudy weather condition")
23 |
24 | case .sunny:
25 | NSLocalizedString("Sunny", comment: "Sunny weather condition")
26 |
27 | case .clearNight:
28 | NSLocalizedString("Clear", comment: "Clear at night weather condition")
29 |
30 | case .snow:
31 | NSLocalizedString("Snow", comment: "Snow weather condition")
32 |
33 | case .heavyRain:
34 | NSLocalizedString("Heavy rain", comment: "Heavy rain weather condition")
35 |
36 | case .freezingRain:
37 | NSLocalizedString("Freezing rain", comment: "Freezing rain weather condition")
38 |
39 | case .lightRain:
40 | NSLocalizedString("Light rain", comment: "Light rain weather condition")
41 |
42 | case .partlyCloudyRain:
43 | NSLocalizedString(
44 | "Partly cloudy with rain",
45 | comment: "Partly cloudy with rain weather condition"
46 | )
47 |
48 | case .thunderstorm:
49 | NSLocalizedString("Thunderstorm", comment: "Thunderstorm weather condition")
50 |
51 | case .mist:
52 | NSLocalizedString("Mist", comment: "Mist weather condition")
53 |
54 | case .fog:
55 | NSLocalizedString("Fog", comment: "Fog weather condition")
56 | }
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/DatWeatherDoe/UI/Decorator/Text/HumidityTextBuilder.swift:
--------------------------------------------------------------------------------
1 | //
2 | // HumidityTextBuilder.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 1/15/22.
6 | // Copyright © 2022 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import OSLog
11 |
12 | protocol HumidityTextBuilderType {
13 | func build() -> String
14 | }
15 |
16 | final class HumidityTextBuilder: HumidityTextBuilderType {
17 | private let initial: String
18 | private let valueSeparator: String
19 | private let humidity: Int
20 | private let logger: Logger
21 | private let percentString = "\u{0025}"
22 |
23 | private let humidityFormatter: NumberFormatter = {
24 | let formatter = NumberFormatter()
25 | formatter.numberStyle = .none
26 | formatter.maximumFractionDigits = 0
27 | return formatter
28 | }()
29 |
30 | init(
31 | initial: String,
32 | valueSeparator: String,
33 | humidity: Int,
34 | logger: Logger
35 | ) {
36 | self.initial = initial
37 | self.valueSeparator = valueSeparator
38 | self.humidity = humidity
39 | self.logger = logger
40 | }
41 |
42 | func build() -> String {
43 | guard let humidityString = buildHumidity() else {
44 | logger.error("Unable to construct humidity string")
45 |
46 | return initial
47 | }
48 |
49 | return "\(initial) \(valueSeparator) \(humidityString)"
50 | }
51 |
52 | private func buildHumidity() -> String? {
53 | guard let formattedString = buildFormattedString() else { return nil }
54 |
55 | return "\(formattedString)\(percentString)"
56 | }
57 |
58 | private func buildFormattedString() -> String? {
59 | humidityFormatter.string(from: NSNumber(value: humidity))
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/DatWeatherDoe/UI/Decorator/Text/SunriseAndSunsetTextBuilder.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SunriseAndSunsetTextBuilder.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Markus Markus on 2022-07-26.
6 | // Copyright © 2022 Markus Mayer.
7 | //
8 |
9 | import Foundation
10 |
11 | protocol SunriseAndSunsetTextBuilderType {
12 | func build() -> String
13 | }
14 |
15 | final class SunriseAndSunsetTextBuilder: SunriseAndSunsetTextBuilderType {
16 | private let sunset: String
17 | private let sunrise: String
18 |
19 | private let upArrowStr = "⬆"
20 | private let downArrowStr = "⬇"
21 |
22 | init(sunset: String, sunrise: String) {
23 | self.sunset = sunset
24 | self.sunrise = sunrise
25 | }
26 |
27 | func build() -> String {
28 | "\(upArrowStr)\(sunrise) \(downArrowStr)\(sunset)"
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/DatWeatherDoe/UI/Decorator/Text/Temperature/TemperatureForecastTextBuilder.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TemperatureForecastTextBuilder.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 6/25/22.
6 | // Copyright © 2022 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | protocol TemperatureForecastTextBuilderType {
12 | func build() -> String
13 | }
14 |
15 | final class TemperatureForecastTextBuilder: TemperatureForecastTextBuilderType {
16 | private let temperatureData: TemperatureData
17 | private let forecastTemperatureData: ForecastTemperatureData
18 | private let options: TemperatureTextBuilder.Options
19 | private let upArrowStr = "⬆"
20 | private let downArrowStr = "⬇"
21 | private let degreeString = "\u{00B0}"
22 |
23 | init(
24 | temperatureData: TemperatureData,
25 | forecastTemperatureData: ForecastTemperatureData,
26 | options: TemperatureTextBuilder.Options
27 | ) {
28 | self.temperatureData = temperatureData
29 | self.forecastTemperatureData = forecastTemperatureData
30 | self.options = options
31 | }
32 |
33 | func build() -> String {
34 | if options.unit == .all {
35 | buildTemperatureForAllUnits()
36 | } else {
37 | buildTemperatureForUnit(options.unit)
38 | }
39 | }
40 |
41 | private func buildTemperatureForAllUnits() -> String {
42 | let feelsLikeTempFahrenheit = buildFormattedTemperature(
43 | temperatureData.feelsLikeTempFahrenheit, unit: .fahrenheit
44 | )
45 | let feelsLikeTempCelsius = buildFormattedTemperature(
46 | temperatureData.feelsLikeTempCelsius, unit: .celsius
47 | )
48 | let feelsLikeTemperatureCombined = [feelsLikeTempFahrenheit, feelsLikeTempCelsius]
49 | .compactMap { $0 }
50 | .joined(separator: " / ")
51 |
52 | let maxTempFahrenheit = buildFormattedTemperature(
53 | forecastTemperatureData.maxTempF, unit: .fahrenheit
54 | )
55 | let maxTempCelsius = buildFormattedTemperature(
56 | forecastTemperatureData.maxTempC, unit: .celsius
57 | )
58 | let maxTempCombined = [maxTempFahrenheit, maxTempCelsius]
59 | .compactMap { $0 }
60 | .joined(separator: " / ")
61 | let maxTempStr = [upArrowStr, maxTempCombined]
62 | .compactMap { $0 }
63 | .joined()
64 |
65 | let minTempFahrenheit = buildFormattedTemperature(
66 | forecastTemperatureData.minTempF, unit: .fahrenheit
67 | )
68 | let minTempCelsius = buildFormattedTemperature(
69 | forecastTemperatureData.minTempC, unit: .celsius
70 | )
71 | let minTempCombined = [minTempFahrenheit, minTempCelsius]
72 | .compactMap { $0 }
73 | .joined(separator: " / ")
74 | let minTempStr = [downArrowStr, minTempCombined]
75 | .compactMap { $0 }
76 | .joined()
77 |
78 | let maxAndMinTempStr = [maxTempStr, minTempStr]
79 | .compactMap { $0 }
80 | .joined(separator: " ")
81 | return [feelsLikeTemperatureCombined, maxAndMinTempStr]
82 | .compactMap { $0 }
83 | .joined(separator: " - ")
84 | }
85 |
86 | private func buildTemperatureForUnit(_ unit: TemperatureUnit) -> String {
87 | let maxTemp = unit == .fahrenheit ? forecastTemperatureData.maxTempF : forecastTemperatureData.maxTempC
88 | let formattedMaxTemp = buildFormattedTemperature(maxTemp, unit: unit)
89 | let maxTempStr = [upArrowStr, formattedMaxTemp]
90 | .compactMap { $0 }
91 | .joined()
92 |
93 | let minTemp = unit == .fahrenheit ? forecastTemperatureData.minTempF : forecastTemperatureData.minTempC
94 | let formatedMinTemp = buildFormattedTemperature(minTemp, unit: unit)
95 | let minTempStr = [downArrowStr, formatedMinTemp]
96 | .compactMap { $0 }
97 | .joined()
98 |
99 | let maxAndMinTempStr = [maxTempStr, minTempStr]
100 | .compactMap { $0 }
101 | .joined(separator: " ")
102 |
103 | let feelsLikeTemp = unit == .fahrenheit ?
104 | temperatureData.feelsLikeTempFahrenheit :
105 | temperatureData.feelsLikeTempCelsius
106 | let formattedFeelsLikeTemp = buildFormattedTemperature(feelsLikeTemp, unit: unit)
107 | return [formattedFeelsLikeTemp, maxAndMinTempStr]
108 | .compactMap { $0 }
109 | .joined(separator: " - ")
110 | }
111 |
112 | private func buildFormattedTemperature(
113 | _ temperatureForUnit: Double,
114 | unit: TemperatureUnit
115 | ) -> String? {
116 | guard let temperatureString = TemperatureFormatter()
117 | .getFormattedTemperatureString(temperatureForUnit, isRoundingOff: options.isRoundingOff)
118 | else {
119 | return nil
120 | }
121 |
122 | return combineTemperatureWithUnitDegrees(
123 | temperature: temperatureString,
124 | unit: unit.unitString,
125 | isUnitLetterOff: options.isUnitLetterOff,
126 | isUnitSymbolOff: options.isUnitSymbolOff
127 | )
128 | }
129 |
130 | private func combineTemperatureWithUnitDegrees(
131 | temperature: String,
132 | unit: String,
133 | isUnitLetterOff: Bool,
134 | isUnitSymbolOff: Bool
135 | ) -> String {
136 | let unitLetter = isUnitLetterOff ? "" : unit
137 | let unitSymbol = isUnitSymbolOff ? "" : degreeString
138 | return [temperature, unitLetter].joined(separator: unitSymbol)
139 | }
140 | }
141 |
--------------------------------------------------------------------------------
/DatWeatherDoe/UI/Decorator/Text/Temperature/TemperatureFormatter.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TemperatureFormatter.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 1/12/22.
6 | // Copyright © 2022 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | protocol TemperatureFormatterType {
12 | func getFormattedTemperatureString(
13 | _ temperature: Double,
14 | isRoundingOff: Bool
15 | ) -> String?
16 | }
17 |
18 | final class TemperatureFormatter: TemperatureFormatterType {
19 | private let formatter: NumberFormatter = {
20 | let formatter = NumberFormatter()
21 | formatter.numberStyle = .decimal
22 | formatter.maximumFractionDigits = 1
23 | formatter.roundingMode = .halfUp
24 | return formatter
25 | }()
26 |
27 | func getFormattedTemperatureString(
28 | _ temperature: Double,
29 | isRoundingOff: Bool
30 | ) -> String? {
31 | setupTemperatureRounding(isRoundingOff: isRoundingOff)
32 | return formatTemperatureString(temperature)
33 | }
34 |
35 | private func setupTemperatureRounding(isRoundingOff: Bool) {
36 | formatter.maximumFractionDigits = isRoundingOff ? 0 : 1
37 | }
38 |
39 | private func formatTemperatureString(_ temperature: Double) -> String? {
40 | let formattedTemperature = formatter.string(from: NSNumber(value: temperature))
41 | return fixRoundingIssues(formattedTemperature)
42 | }
43 |
44 | private func fixRoundingIssues(_ temperature: String?) -> String? {
45 | if temperature == "-0" {
46 | return "0"
47 | }
48 | return temperature
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/DatWeatherDoe/UI/Decorator/Text/Temperature/TemperatureTextBuilder.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TemperatureTextBuilder.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 1/15/22.
6 | // Copyright © 2022 Inder Dhir. All rights reserved.
7 | //
8 |
9 | protocol TemperatureTextBuilderType {
10 | func build() -> String?
11 | }
12 |
13 | final class TemperatureTextBuilder: TemperatureTextBuilderType {
14 | struct Options {
15 | let unit: TemperatureUnit
16 | let isRoundingOff: Bool
17 | let isUnitLetterOff: Bool
18 | let isUnitSymbolOff: Bool
19 | }
20 |
21 | private let response: WeatherAPIResponse
22 | private let options: Options
23 | private let temperatureCreator: TemperatureWithDegreesCreatorType
24 | private let degreeString = "\u{00B0}"
25 |
26 | init(
27 | response: WeatherAPIResponse,
28 | options: Options,
29 | temperatureCreator: TemperatureWithDegreesCreatorType
30 | ) {
31 | self.response = response
32 | self.options = options
33 | self.temperatureCreator = temperatureCreator
34 | }
35 |
36 | func build() -> String? {
37 | if options.unit == .all {
38 | buildTemperatureTextForAllUnits()
39 | } else {
40 | buildTemperatureText(for: options.unit)
41 | }
42 | }
43 |
44 | private func buildTemperatureTextForAllUnits() -> String? {
45 | let temperatureWithDegrees = temperatureCreator.getTemperatureWithDegrees(
46 | temperatureInMultipleUnits:
47 | .init(
48 | fahrenheit: response.temperatureData.tempFahrenheit,
49 | celsius: response.temperatureData.tempCelsius
50 | ),
51 | isRoundingOff: options.isRoundingOff,
52 | isUnitLetterOff: options.isUnitLetterOff,
53 | isUnitSymbolOff: options.isUnitSymbolOff
54 | )
55 | return temperatureWithDegrees
56 | }
57 |
58 | private func buildTemperatureText(for unit: TemperatureUnit) -> String? {
59 | let temperatureForUnit = unit == .fahrenheit ?
60 | response.temperatureData.tempFahrenheit :
61 | response.temperatureData.tempCelsius
62 | let temperatureWithDegrees = temperatureCreator.getTemperatureWithDegrees(
63 | temperatureForUnit,
64 | unit: unit,
65 | isRoundingOff: options.isRoundingOff,
66 | isUnitLetterOff: options.isUnitLetterOff,
67 | isUnitSymbolOff: options.isUnitSymbolOff
68 | )
69 | return temperatureWithDegrees
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/DatWeatherDoe/UI/Decorator/Text/Temperature/TemperatureWithDegreesCreator.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TemperatureWithDegreesCreator.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 8/9/23.
6 | // Copyright © 2023 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | struct TemperatureInMultipleUnits {
12 | let fahrenheit: Double
13 | let celsius: Double
14 | }
15 |
16 | protocol TemperatureWithDegreesCreatorType {
17 | var degreeString: String { get }
18 |
19 | func getTemperatureWithDegrees(
20 | temperatureInMultipleUnits: TemperatureInMultipleUnits,
21 | isRoundingOff: Bool,
22 | isUnitLetterOff: Bool,
23 | isUnitSymbolOff: Bool
24 | ) -> String?
25 |
26 | func getTemperatureWithDegrees(
27 | _ temperature: Double,
28 | unit: TemperatureUnit,
29 | isRoundingOff: Bool,
30 | isUnitLetterOff: Bool,
31 | isUnitSymbolOff: Bool
32 | ) -> String?
33 | }
34 |
35 | final class TemperatureWithDegreesCreator: TemperatureWithDegreesCreatorType {
36 | let degreeString = "\u{00B0}"
37 |
38 | func getTemperatureWithDegrees(
39 | temperatureInMultipleUnits: TemperatureInMultipleUnits,
40 | isRoundingOff: Bool,
41 | isUnitLetterOff: Bool,
42 | isUnitSymbolOff: Bool
43 | ) -> String? {
44 | guard let fahrenheitString = TemperatureFormatter().getFormattedTemperatureString(
45 | temperatureInMultipleUnits.fahrenheit,
46 | isRoundingOff: isRoundingOff
47 | ) else {
48 | return nil
49 | }
50 | guard let celsiusString = TemperatureFormatter().getFormattedTemperatureString(
51 | temperatureInMultipleUnits.celsius,
52 | isRoundingOff: isRoundingOff
53 | ) else {
54 | return nil
55 | }
56 |
57 | let formattedFahrenheit = combineTemperatureWithUnitDegrees(
58 | temperature: fahrenheitString,
59 | unit: .fahrenheit,
60 | isUnitLetterOff: isUnitLetterOff,
61 | isUnitSymbolOff: isUnitSymbolOff
62 | )
63 | let formattedCelsius = combineTemperatureWithUnitDegrees(
64 | temperature: celsiusString,
65 | unit: .celsius,
66 | isUnitLetterOff: isUnitLetterOff,
67 | isUnitSymbolOff: isUnitSymbolOff
68 | )
69 |
70 | return [formattedFahrenheit, formattedCelsius]
71 | .joined(separator: " / ")
72 | }
73 |
74 | func getTemperatureWithDegrees(
75 | _ temperature: Double,
76 | unit: TemperatureUnit,
77 | isRoundingOff: Bool,
78 | isUnitLetterOff: Bool,
79 | isUnitSymbolOff: Bool
80 | ) -> String? {
81 | guard let temperatureString = TemperatureFormatter().getFormattedTemperatureString(
82 | temperature,
83 | isRoundingOff: isRoundingOff
84 | ) else {
85 | return nil
86 | }
87 |
88 | return combineTemperatureWithUnitDegrees(
89 | temperature: temperatureString,
90 | unit: unit,
91 | isUnitLetterOff: isUnitLetterOff,
92 | isUnitSymbolOff: isUnitSymbolOff
93 | )
94 | }
95 |
96 | private func combineTemperatureWithUnitDegrees(
97 | temperature: String,
98 | unit: TemperatureUnit,
99 | isUnitLetterOff: Bool,
100 | isUnitSymbolOff: Bool
101 | ) -> String {
102 | let unitLetter = isUnitLetterOff ? "" : unit.unitString
103 | let unitSymbol = isUnitSymbolOff ? "" : degreeString
104 | return [temperature, unitLetter].joined(separator: unitSymbol)
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/DatWeatherDoe/UI/Decorator/Text/UVIndexTextBuilder.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UVIndexTextBuilder.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 10/18/24.
6 | // Copyright © 2024 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | final class UVIndexTextBuilder {
12 | private let initial: String
13 | private let separator: String
14 |
15 | init(initial: String, separator: String) {
16 | self.initial = initial
17 | self.separator = separator
18 | }
19 |
20 | func build(from response: WeatherAPIResponse) -> String {
21 | "\(initial) \(separator) \(constructUVString(from: response))"
22 | }
23 |
24 | func constructUVString(from response: WeatherAPIResponse) -> String {
25 | let currentHour = Calendar.current.component(.hour, from: Date())
26 | let currentUVIndex = response.getHourlyUVIndex(hour: currentHour)
27 | return "UV Index: \(currentUVIndex)"
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/DatWeatherDoe/UI/Decorator/Text/WeatherTextBuilder.swift:
--------------------------------------------------------------------------------
1 | //
2 | // WeatherTextBuilder.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 1/15/22.
6 | // Copyright © 2022 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import OSLog
10 |
11 | protocol WeatherTextBuilderType {
12 | func build() -> String
13 | }
14 |
15 | final class WeatherTextBuilder: WeatherTextBuilderType {
16 | struct Options {
17 | let isWeatherConditionAsTextEnabled: Bool
18 | let conditionPosition: WeatherConditionPosition
19 | let valueSeparator: String
20 | let temperatureOptions: TemperatureTextBuilder.Options
21 | let isShowingHumidity: Bool
22 | let isShowingUVIndex: Bool
23 | }
24 |
25 | private let response: WeatherAPIResponse
26 | private let options: Options
27 | private let logger: Logger
28 |
29 | init(
30 | response: WeatherAPIResponse,
31 | options: Options,
32 | logger: Logger
33 | ) {
34 | self.response = response
35 | self.options = options
36 | self.logger = logger
37 | }
38 |
39 | func build() -> String {
40 | let finalString = appendTemperatureAsText() |>
41 | appendUVIndex |>
42 | appendHumidityText |>
43 | buildWeatherConditionAsText
44 | return finalString
45 | }
46 |
47 | private func appendTemperatureAsText() -> String {
48 | TemperatureTextBuilder(
49 | response: response,
50 | options: options.temperatureOptions,
51 | temperatureCreator: TemperatureWithDegreesCreator()
52 | ).build() ?? ""
53 | }
54 |
55 | private func appendUVIndex(initial: String) -> String {
56 | guard options.isShowingUVIndex else { return initial }
57 |
58 | return UVIndexTextBuilder(
59 | initial: initial,
60 | separator: options.valueSeparator
61 | ).build(from: response)
62 | }
63 |
64 | private func appendHumidityText(initial: String) -> String {
65 | guard options.isShowingHumidity else { return initial }
66 |
67 | return HumidityTextBuilder(
68 | initial: initial,
69 | valueSeparator: options.valueSeparator,
70 | humidity: response.humidity,
71 | logger: logger
72 | ).build()
73 | }
74 |
75 | private func buildWeatherConditionAsText(initial: String) -> String {
76 | guard options.isWeatherConditionAsTextEnabled else { return initial }
77 |
78 | let weatherCondition = WeatherConditionBuilder(response: response).build()
79 | let weatherConditionText = WeatherConditionTextMapper().map(weatherCondition)
80 |
81 | let combinedString = options.conditionPosition == .beforeTemperature ?
82 | [weatherConditionText, initial] :
83 | [initial, weatherConditionText.lowercased()]
84 |
85 | return combinedString
86 | .compactMap { $0 }
87 | .joined(separator: ", ")
88 | }
89 | }
90 |
91 | precedencegroup ForwardPipe {
92 | associativity: left
93 | }
94 |
95 | infix operator |>: ForwardPipe
96 |
97 | private func |> (value: T, function: (T) -> U) -> U {
98 | function(value)
99 | }
100 |
--------------------------------------------------------------------------------
/DatWeatherDoe/UI/Decorator/WeatherDataBuilder.swift:
--------------------------------------------------------------------------------
1 | //
2 | // WeatherDataBuilder.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 5/22/21.
6 | // Copyright © 2021 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import OSLog
11 |
12 | protocol WeatherDataBuilderType: AnyObject {
13 | func build() -> WeatherData
14 | }
15 |
16 | final class WeatherDataBuilder: WeatherDataBuilderType {
17 | struct Options {
18 | let unit: MeasurementUnit
19 | let showWeatherIcon: Bool
20 | let textOptions: WeatherTextBuilder.Options
21 | }
22 |
23 | private let response: WeatherAPIResponse
24 | private let options: WeatherDataBuilder.Options
25 | private let logger: Logger
26 |
27 | init(
28 | response: WeatherAPIResponse,
29 | options: WeatherDataBuilder.Options,
30 | logger: Logger
31 | ) {
32 | self.response = response
33 | self.options = options
34 | self.logger = logger
35 | }
36 |
37 | func build() -> WeatherData {
38 | .init(
39 | showWeatherIcon: options.showWeatherIcon,
40 | textualRepresentation: buildTextualRepresentation(),
41 | weatherCondition: buildWeatherCondition(),
42 | response: response
43 | )
44 | }
45 |
46 | private func buildTextualRepresentation() -> String {
47 | WeatherTextBuilder(
48 | response: response,
49 | options: options.textOptions,
50 | logger: logger
51 | ).build()
52 | }
53 |
54 | private func buildWeatherCondition() -> WeatherCondition {
55 | WeatherConditionBuilder(response: response).build()
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/DatWeatherDoe/UI/Forecaster/WeatherForecaster.swift:
--------------------------------------------------------------------------------
1 | //
2 | // WeatherForecaster.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 1/11/22.
6 | // Copyright © 2022 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import Cocoa
10 | import Foundation
11 |
12 | protocol WeatherForecasterType {
13 | func seeForecastForCity()
14 | }
15 |
16 | final class WeatherForecaster: WeatherForecasterType {
17 | private let fullWeatherUrl = URL(string: "https://www.weatherapi.com/weather/")!
18 |
19 | func seeForecastForCity() {
20 | NSWorkspace.shared.open(fullWeatherUrl)
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/DatWeatherDoe/UI/Menu Bar/CustomButton.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CustomButton.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 6/21/24.
6 | // Copyright © 2024 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import SwiftUI
10 |
11 | struct CustomButton: View {
12 | let text: LocalizedStringKey
13 | let textColor: Color
14 | let shortcutKey: KeyEquivalent
15 | let onClick: () -> Void
16 |
17 | init(text: LocalizedStringKey, textColor: Color = Color.primary, shortcutKey: KeyEquivalent, onClick: @escaping () -> Void) {
18 | self.text = text
19 | self.textColor = textColor
20 | self.shortcutKey = shortcutKey
21 | self.onClick = onClick
22 | }
23 |
24 | var body: some View {
25 | Button(action: onClick) {
26 | Text(text)
27 | .foregroundStyle(textColor)
28 | .frame(width: 110, height: 22)
29 | }.keyboardShortcut(KeyboardShortcut(shortcutKey))
30 | }
31 | }
32 |
33 | #Preview {
34 | CustomButton(text: "See Full Weather", shortcutKey: "f") {}
35 | }
36 |
--------------------------------------------------------------------------------
/DatWeatherDoe/UI/Menu Bar/DropdownIcon.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DropdownIcon.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Markus Mayer on 2022-11-21.
6 | // Copyright © 2022 Markus Mayer.
7 | //
8 |
9 | // Icons used by the dropdown menu
10 | enum DropdownIcon {
11 | case location
12 | case thermometer
13 | case sun
14 | case wind
15 | case uvIndexAndAirQualityText
16 |
17 | var symbolName: String {
18 | switch self {
19 | case .location:
20 | "location.north.circle"
21 | case .thermometer:
22 | "thermometer.snowflake.circle"
23 | case .sun:
24 | "sun.horizon.circle"
25 | case .wind:
26 | "wind.circle"
27 | case .uvIndexAndAirQualityText:
28 | "sun.max.circle"
29 | }
30 | }
31 |
32 | var accessibilityLabel: String {
33 | switch self {
34 | case .location:
35 | "Location"
36 | case .thermometer:
37 | "Temperature"
38 | case .sun:
39 | "Sunrise and Sunset"
40 | case .wind:
41 | "Wind data"
42 | case .uvIndexAndAirQualityText:
43 | "UV Index and Air Quality Index"
44 | }
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/DatWeatherDoe/UI/Menu Bar/MenuOptionsView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MenuOptionsView.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 6/21/24.
6 | // Copyright © 2024 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import SwiftUI
10 |
11 | struct MenuOptionData {
12 | let locationText: String
13 | let weatherText: String
14 | let sunriseSunsetText: String
15 | let tempHumidityWindText: String
16 | let uvIndexAndAirQualityText: String
17 | }
18 |
19 | struct MenuOptionsView: View {
20 | let data: MenuOptionData?
21 | let onSeeWeather: () -> Void
22 | let onRefresh: () -> Void
23 |
24 | var body: some View {
25 | VStack(alignment: .leading, spacing: 16) {
26 | VStack(alignment: .leading) {
27 | NonInteractiveMenuOptionView(icon: .location, text: data?.locationText)
28 | NonInteractiveMenuOptionView(icon: .thermometer, text: data?.weatherText)
29 | NonInteractiveMenuOptionView(icon: .sun, text: data?.sunriseSunsetText)
30 | NonInteractiveMenuOptionView(icon: .wind, text: data?.tempHumidityWindText)
31 | NonInteractiveMenuOptionView(icon: .uvIndexAndAirQualityText, text: data?.uvIndexAndAirQualityText)
32 | }
33 |
34 | HStack {
35 | CustomButton(
36 | text: LocalizedStringKey("See Full Weather"),
37 | shortcutKey: "f",
38 | onClick: onSeeWeather
39 | )
40 | .frame(maxWidth: .infinity, alignment: .leading)
41 |
42 | Spacer()
43 | .frame(maxWidth: .infinity)
44 |
45 | CustomButton(text: LocalizedStringKey("Refresh"), shortcutKey: "r", onClick: onRefresh)
46 | .frame(maxWidth: .infinity, alignment: .trailing)
47 | }
48 | .frame(maxWidth: .infinity)
49 | }
50 | .padding()
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/DatWeatherDoe/UI/Menu Bar/MenuView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MenuView.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 6/23/24.
6 | // Copyright © 2024 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import SwiftUI
10 |
11 | struct MenuView: View {
12 | @ObservedObject var viewModel: WeatherViewModel
13 | private var menuOptionData: MenuOptionData?
14 | @State private var configureViewModel: ConfigureViewModel
15 | private var version: String
16 | private var onSeeWeather: () -> Void
17 | private var onRefresh: () -> Void
18 | private var onSave: () -> Void
19 |
20 | init(
21 | viewModel: WeatherViewModel,
22 | onSeeWeather: @escaping () -> Void,
23 | onRefresh: @escaping () -> Void,
24 | onSave: @escaping () -> Void
25 | ) {
26 | self.viewModel = viewModel
27 | self.onSeeWeather = onSeeWeather
28 | self.onRefresh = onRefresh
29 | self.onSave = onSave
30 |
31 | configureViewModel = ConfigureViewModel(configManager: ConfigManager())
32 | version = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "1.0.0"
33 | }
34 |
35 | var body: some View {
36 | VStack {
37 | MenuOptionsView(
38 | data: viewModel.menuOptionData,
39 | onSeeWeather: onSeeWeather,
40 | onRefresh: onRefresh
41 | )
42 |
43 | Divider()
44 |
45 | ConfigureView(
46 | viewModel: configureViewModel,
47 | version: version,
48 | onSave: {
49 | configureViewModel.saveConfig()
50 | onSave()
51 | },
52 | onQuit: {
53 | NSApp.terminate(self)
54 | }
55 | )
56 | }
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/DatWeatherDoe/UI/Menu Bar/NonInteractiveMenuOptionView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NonInteractiveMenuOptionView.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 6/21/24.
6 | // Copyright © 2024 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import SwiftUI
10 |
11 | struct NonInteractiveMenuOptionView: View {
12 | let icon: DropdownIcon
13 | let text: String?
14 |
15 | var body: some View {
16 | HStack(spacing: 6) {
17 | Image(systemName: icon.symbolName)
18 | .renderingMode(.template)
19 | .resizable()
20 | .frame(width: 24, height: 24)
21 | .accessibilityLabel(icon.accessibilityLabel)
22 |
23 | if let text {
24 | Text(text)
25 | .foregroundStyle(Color.secondary)
26 | .frame(maxWidth: .infinity, alignment: .leading)
27 | }
28 | }
29 | }
30 | }
31 |
32 | #Preview {
33 | NonInteractiveMenuOptionView(
34 | icon: .wind,
35 | text: "Test location"
36 | )
37 | .frame(width: 300, height: 100, alignment: .leading)
38 | .padding()
39 | }
40 |
--------------------------------------------------------------------------------
/DatWeatherDoe/UI/Menu Bar/WindSpeedFormatter.swift:
--------------------------------------------------------------------------------
1 | //
2 | // WindSpeedFormatter.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 10/12/23.
6 | // Copyright © 2023 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | struct WindSpeedInMultipleUnits {
12 | let meterPerSec: Double
13 | let milesPerHour: Double
14 | }
15 |
16 | protocol WindSpeedFormatterType {
17 | func getFormattedWindSpeedStringForAllUnits(
18 | windData: WindData,
19 | isRoundingOff: Bool
20 | ) -> String
21 |
22 | func getFormattedWindSpeedString(
23 | unit: MeasurementUnit,
24 | windData: WindData
25 | ) -> String
26 | }
27 |
28 | final class WindSpeedFormatter: WindSpeedFormatterType {
29 | private let degreeString = "\u{00B0}"
30 |
31 | private let formatter: NumberFormatter = {
32 | let formatter = NumberFormatter()
33 | formatter.numberStyle = .decimal
34 | formatter.maximumFractionDigits = 1
35 | formatter.roundingMode = .halfUp
36 | return formatter
37 | }()
38 |
39 | func getFormattedWindSpeedStringForAllUnits(
40 | windData: WindData,
41 | isRoundingOff _: Bool
42 | ) -> String {
43 | let mphSpeed = windData.speedMph
44 | let mpsSpeed = mpsSpeedFrom(mphSpeed: mphSpeed)
45 |
46 | let mphRounded = formatter.string(from: NSNumber(value: mphSpeed)) ?? ""
47 | let windSpeedMph = [mphRounded, "mi/hr"].joined()
48 |
49 | let mpsRounded = formatter.string(from: NSNumber(value: mpsSpeed)) ?? ""
50 | let windSpeedMps = [mpsRounded, "m/s"].joined()
51 |
52 | let windSpeedStr = [windSpeedMph, windSpeedMps].joined(separator: " | ")
53 |
54 | return combinedWindString(windData: windData, windSpeed: windSpeedStr)
55 | }
56 |
57 | func getFormattedWindSpeedString(
58 | unit: MeasurementUnit,
59 | windData: WindData
60 | ) -> String {
61 | let mphSpeed = windData.speedMph
62 | let mpsSpeed = mpsSpeedFrom(mphSpeed: mphSpeed)
63 |
64 | let speed = unit == .imperial ? mphSpeed : mpsSpeed
65 | let speedRounded = formatter.string(from: NSNumber(value: speed)) ?? ""
66 | let windSpeedSuffix = unit == .imperial ? "mi/hr" : "m/s"
67 | let windSpeedStr = [speedRounded, windSpeedSuffix].joined()
68 |
69 | return combinedWindString(windData: windData, windSpeed: windSpeedStr)
70 | }
71 |
72 | private func combinedWindString(
73 | windData: WindData,
74 | windSpeed: String
75 | ) -> String {
76 | let windDegreesStr = [String(windData.degrees), degreeString].joined()
77 | let windDirectionStr = "(\(windData.direction))"
78 | let windAndDegreesStr = [windSpeed, windDegreesStr].joined(separator: " - ")
79 | let windFullStr = [windAndDegreesStr, windDirectionStr].joined(separator: " ")
80 | return windFullStr
81 | }
82 |
83 | private func mpsSpeedFrom(mphSpeed: Double) -> Double {
84 | 0.4469 * mphSpeed
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/DatWeatherDoe/UI/Status Bar/StatusBarView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StatusBarView.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 6/23/24.
6 | // Copyright © 2024 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import SwiftUI
10 |
11 | struct StatusBarView: View {
12 | let weatherResult: Result?
13 |
14 | var body: some View {
15 | if let weatherResult {
16 | switch weatherResult {
17 | case let .success(success):
18 | HStack {
19 | if success.showWeatherIcon {
20 | Image(systemName: success.weatherCondition.symbolName)
21 | .renderingMode(.template)
22 | .accessibilityLabel(success.weatherCondition.accessibilityLabel)
23 | }
24 | if let text = success.textualRepresentation {
25 | Text(text)
26 | }
27 | }
28 | case let .failure(failure):
29 | Text(failure.localizedDescription)
30 | }
31 | }
32 | }
33 | }
34 |
35 | #if DEBUG
36 | #Preview {
37 | StatusBarView(
38 | weatherResult: .success(
39 | .init(
40 | showWeatherIcon: true,
41 | textualRepresentation: "88",
42 | weatherCondition: .cloudy,
43 | response: response
44 | )
45 | )
46 | )
47 | .frame(width: 100, height: 50)
48 | }
49 |
50 | #Preview {
51 | StatusBarView(weatherResult: .failure(WeatherError.latLongIncorrect))
52 | .frame(width: 100, height: 50)
53 | }
54 | #endif
55 |
--------------------------------------------------------------------------------
/DatWeatherDoe/ViewModel/Parser/CityWeatherResultParser.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CityWeatherResultParser.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by preckrasno on 14.02.2023.
6 | // Copyright © 2023 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | final class CityWeatherResultParser: WeatherResultParser {
12 | override func parse() {
13 | switch weatherDataResult {
14 | case let .success(weatherData):
15 | delegate?.didUpdateWeatherData(weatherData)
16 | case let .failure(error):
17 | guard let weatherError = error as? WeatherError else { return }
18 |
19 | let errorString = weatherError == WeatherError.cityIncorrect ?
20 | errorLabels.cityErrorString :
21 | errorLabels.networkErrorString
22 |
23 | delegate?.didFailToUpdateWeatherData(errorString)
24 | }
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/DatWeatherDoe/ViewModel/Parser/ZipCodeWeatherResultParser.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ZipCodeWeatherResultParser.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 1/17/22.
6 | // Copyright © 2022 Inder Dhir. All rights reserved.
7 | //
8 |
9 | final class ZipCodeWeatherResultParser: WeatherResultParser {
10 | override func parse() {
11 | switch weatherDataResult {
12 | case let .success(weatherData):
13 | delegate?.didUpdateWeatherData(weatherData)
14 | case let .failure(error):
15 | guard let weatherError = error as? WeatherError else { return }
16 |
17 | let errorString = weatherError == WeatherError.zipCodeIncorrect ?
18 | errorLabels.zipCodeErrorString :
19 | errorLabels.networkErrorString
20 |
21 | delegate?.didFailToUpdateWeatherData(errorString)
22 | }
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/DatWeatherDoe/ViewModel/Repository/Coordinates/LocationCoordinatesWeatherRepository.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LocationCoordinatesWeatherRepository.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 1/14/22.
6 | // Copyright © 2022 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import CoreLocation
10 | import OSLog
11 |
12 | final class LocationCoordinatesWeatherRepository: WeatherRepositoryType {
13 | private let appId: String
14 | private let latLong: String
15 | private let networkClient: NetworkClientType
16 | private let logger: Logger
17 |
18 | init(
19 | appId: String,
20 | latLong: String,
21 | networkClient: NetworkClientType,
22 | logger: Logger
23 | ) {
24 | self.appId = appId
25 | self.latLong = latLong
26 | self.networkClient = networkClient
27 | self.logger = logger
28 | }
29 |
30 | func getWeather() async throws -> WeatherAPIResponse {
31 | logger.debug("Getting weather via lat/long")
32 |
33 | do {
34 | let location = try getLocationCoordinatesFrom(latLong)
35 | let url = try WeatherURLBuilder(appId: appId, location: location).build()
36 | let data = try await networkClient.performRequest(url: url)
37 | return try WeatherAPIResponseParser().parse(data)
38 | } catch {
39 | logger.error("Getting weather via lat/long failed")
40 |
41 | throw error
42 | }
43 | }
44 |
45 | private func getLocationCoordinatesFrom(_ latLong: String) throws -> CLLocationCoordinate2D {
46 | try LocationValidator(latLong: latLong).validate()
47 |
48 | let latAndlong = try LocationParser().parseCoordinates(latLong)
49 | return latAndlong
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/DatWeatherDoe/ViewModel/Repository/Coordinates/LocationParser.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LocationParser.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 1/10/22.
6 | // Copyright © 2022 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import CoreLocation
10 |
11 | protocol LocationParserType {
12 | func parseCoordinates(_ latLong: String) throws -> CLLocationCoordinate2D
13 | }
14 |
15 | final class LocationParser: LocationParserType {
16 | func parseCoordinates(_ latLong: String) throws -> CLLocationCoordinate2D {
17 | let latLongCombo = latLong.split(separator: ",")
18 | guard latLongCombo.count == 2 else {
19 | throw WeatherError.latLongIncorrect
20 | }
21 |
22 | return try parseLocationDegrees(
23 | possibleLatitude: String(latLongCombo[0]).trim(),
24 | possibleLongitude: String(latLongCombo[1]).trim()
25 | )
26 | }
27 |
28 | private func parseLocationDegrees(
29 | possibleLatitude: String,
30 | possibleLongitude: String
31 | ) throws -> CLLocationCoordinate2D {
32 | let lat = CLLocationDegrees(possibleLatitude.trim())
33 | let long = CLLocationDegrees(possibleLongitude.trim())
34 | guard let lat, let long else {
35 | throw WeatherError.latLongIncorrect
36 | }
37 |
38 | return .init(latitude: lat, longitude: long)
39 | }
40 | }
41 |
42 | private extension String {
43 | func trim() -> String { trimmingCharacters(in: .whitespacesAndNewlines) }
44 | }
45 |
--------------------------------------------------------------------------------
/DatWeatherDoe/ViewModel/Repository/Coordinates/LocationValidator.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LocationValidator.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 1/13/22.
6 | // Copyright © 2022 Inder Dhir. All rights reserved.
7 | //
8 |
9 | final class LocationValidator: WeatherValidatorType {
10 | private let latLong: String
11 |
12 | init(latLong: String) {
13 | self.latLong = latLong
14 | }
15 |
16 | func validate() throws {
17 | let coordinates = latLong.split(separator: ",")
18 | let isValid = coordinates.count == 2
19 | if !isValid {
20 | throw WeatherError.latLongIncorrect
21 | }
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/DatWeatherDoe/ViewModel/Repository/System/SystemLocationFetcher.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SystemLocationFetcher.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 1/9/22.
6 | // Copyright © 2022 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import Combine
10 | import CoreLocation
11 | import Foundation
12 | import OSLog
13 |
14 | protocol SystemLocationFetcherType: AnyObject {
15 | func getLocation() async throws -> CLLocationCoordinate2D
16 | }
17 |
18 | final actor SystemLocationFetcher: NSObject, SystemLocationFetcherType {
19 | private let logger: Logger
20 | private var cachedLocation: CLLocationCoordinate2D?
21 | private var permissionContinuation: CheckedContinuation?
22 | private var locationUpdateContinuation: CheckedContinuation?
23 |
24 | @MainActor
25 | private lazy var locationManager: CLLocationManager = {
26 | let locationManager = CLLocationManager()
27 | locationManager.delegate = self
28 | locationManager.desiredAccuracy = kCLLocationAccuracyKilometer
29 | locationManager.distanceFilter = 1000
30 | return locationManager
31 | }()
32 |
33 | init(logger: Logger) {
34 | self.logger = logger
35 | }
36 |
37 | func getLocation() async throws(WeatherError) -> CLLocationCoordinate2D {
38 | guard CLLocationManager.locationServicesEnabled() else {
39 | logger.error("Location services not enabled")
40 |
41 | throw WeatherError.locationError
42 | }
43 |
44 | switch CLLocationManager().authorizationStatus {
45 | case .notDetermined:
46 | let isAuthorized = await withCheckedContinuation { continuation in
47 | Task(priority: .high) {
48 | logger.debug("Location permission not determined")
49 |
50 | permissionContinuation = continuation
51 |
52 | await MainActor.run {
53 | locationManager.requestWhenInUseAuthorization()
54 | }
55 | }
56 | }
57 |
58 | permissionContinuation = nil
59 |
60 | logger.debug("Location permission changed, isAuthorized?: \(isAuthorized)")
61 |
62 | if isAuthorized {
63 | return try await requestLatestOrCachedLocation()
64 | }
65 |
66 | throw WeatherError.locationError
67 |
68 | case .authorized:
69 | return try await requestLatestOrCachedLocation()
70 |
71 | default:
72 | logger.error("Location permission has NOT been granted")
73 |
74 | throw WeatherError.locationError
75 | }
76 | }
77 |
78 | private func updateCachedLocation(_ location: CLLocationCoordinate2D) {
79 | cachedLocation = location
80 | }
81 |
82 | private func requestLatestOrCachedLocation() async throws(WeatherError) -> CLLocationCoordinate2D {
83 | if let cachedLocation = getCachedLocationIfPresent() {
84 | return cachedLocation
85 | }
86 |
87 | do {
88 | let latestLocation = try await withCheckedThrowingContinuation { continuation in
89 | Task(priority: .high) {
90 | locationUpdateContinuation = continuation
91 |
92 | await MainActor.run {
93 | locationManager.startUpdatingLocation()
94 | }
95 | }
96 | }
97 |
98 | locationUpdateContinuation = nil
99 |
100 | return latestLocation
101 | } catch {
102 | throw WeatherError.locationError
103 | }
104 | }
105 |
106 | private func getCachedLocationIfPresent() -> CLLocationCoordinate2D? {
107 | if let cachedLocation {
108 | logger.debug("Sending cached location")
109 |
110 | return cachedLocation
111 | }
112 |
113 | return nil
114 | }
115 | }
116 |
117 | // MARK: CLLocationManagerDelegate
118 |
119 | extension SystemLocationFetcher: CLLocationManagerDelegate {
120 | nonisolated func locationManagerDidChangeAuthorization(_: CLLocationManager) {
121 | let isAuthorized = CLLocationManager().authorizationStatus == .authorized
122 |
123 | Task(priority: .high) {
124 | await permissionContinuation?.resume(returning: isAuthorized)
125 | }
126 | }
127 |
128 | nonisolated func locationManager(_: CLLocationManager, didFailWithError error: Error) {
129 | Task(priority: .high) {
130 | await locationUpdateContinuation?.resume(throwing: error)
131 | }
132 | }
133 |
134 | nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations _: [CLLocation]) {
135 | let coordinate = manager.location?.coordinate ?? .init(latitude: .zero, longitude: .zero)
136 | Task(priority: .high) {
137 | await locationUpdateContinuation?.resume(returning: coordinate)
138 | await updateCachedLocation(coordinate)
139 | }
140 | }
141 | }
142 |
--------------------------------------------------------------------------------
/DatWeatherDoe/ViewModel/Repository/System/SystemLocationWeatherRepository.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SystemLocationWeatherRepository.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 1/15/22.
6 | // Copyright © 2022 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import CoreLocation
10 | import OSLog
11 |
12 | final class SystemLocationWeatherRepository: WeatherRepositoryType {
13 | private let appId: String
14 | private let location: CLLocationCoordinate2D
15 | private let networkClient: NetworkClientType
16 | private let logger: Logger
17 |
18 | init(
19 | appId: String,
20 | location: CLLocationCoordinate2D,
21 | networkClient: NetworkClientType,
22 | logger: Logger
23 | ) {
24 | self.appId = appId
25 | self.location = location
26 | self.networkClient = networkClient
27 | self.logger = logger
28 | }
29 |
30 | func getWeather() async throws -> WeatherAPIResponse {
31 | logger.debug("Getting weather via location")
32 |
33 | do {
34 | let url = try WeatherURLBuilder(appId: appId, location: location).build()
35 | let data = try await networkClient.performRequest(url: url)
36 | return try WeatherAPIResponseParser().parse(data)
37 | } catch {
38 | logger.error("Getting weather via location failed")
39 |
40 | throw error
41 | }
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/DatWeatherDoe/ViewModel/Repository/WeatherRepositoryFactory.swift:
--------------------------------------------------------------------------------
1 | //
2 | // WeatherRepositoryFactory.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 8/7/23.
6 | // Copyright © 2023 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import CoreLocation
10 | import Foundation
11 | import OSLog
12 |
13 | protocol WeatherRepositoryFactoryType {
14 | func create(location: CLLocationCoordinate2D) -> WeatherRepositoryType
15 | func create(latLong: String) -> WeatherRepositoryType
16 | }
17 |
18 | final class WeatherRepositoryFactory: WeatherRepositoryFactoryType {
19 | struct Options {
20 | let appId: String
21 | let networkClient: NetworkClient
22 | let logger: Logger
23 | }
24 |
25 | private let appId: String
26 | private let networkClient: NetworkClientType
27 | private let logger: Logger
28 |
29 | init(appId: String, networkClient: NetworkClientType, logger: Logger) {
30 | self.appId = appId
31 | self.networkClient = networkClient
32 | self.logger = logger
33 | }
34 |
35 | func create(location: CLLocationCoordinate2D) -> WeatherRepositoryType {
36 | SystemLocationWeatherRepository(
37 | appId: appId,
38 | location: location,
39 | networkClient: networkClient,
40 | logger: logger
41 | )
42 | }
43 |
44 | func create(latLong: String) -> WeatherRepositoryType {
45 | LocationCoordinatesWeatherRepository(
46 | appId: appId,
47 | latLong: latLong,
48 | networkClient: networkClient,
49 | logger: logger
50 | )
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/DatWeatherDoe/ViewModel/Repository/WeatherRepositoryType.swift:
--------------------------------------------------------------------------------
1 | ////
2 | //// WeatherRepositoryType.swift
3 | //// DatWeatherDoe
4 | ////
5 | //// Created by Inder Dhir on 1/30/16.
6 | //// Copyright © 2016 Inder Dhir. All rights reserved.
7 | ////
8 |
9 | protocol WeatherRepositoryType: AnyObject {
10 | func getWeather() async throws -> WeatherAPIResponse
11 | }
12 |
--------------------------------------------------------------------------------
/DatWeatherDoe/ViewModel/Repository/WeatherURLBuilder.swift:
--------------------------------------------------------------------------------
1 | //
2 | // WeatherURLBuilder.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 1/10/22.
6 | // Copyright © 2022 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import CoreLocation
10 | import Foundation
11 |
12 | protocol WeatherURLBuilderType {
13 | func build() throws -> URL
14 | }
15 |
16 | final class WeatherURLBuilder: WeatherURLBuilderType {
17 | private let apiUrlString = "https://api.weatherapi.com/v1/forecast.json"
18 | private let appId: String
19 | private let location: CLLocationCoordinate2D
20 |
21 | init(appId: String, location: CLLocationCoordinate2D) {
22 | self.appId = appId
23 | self.location = location
24 | }
25 |
26 | func build() throws -> URL {
27 | let latLonString = "\(location.latitude),\(location.longitude)"
28 |
29 | let queryItems: [URLQueryItem] = [
30 | URLQueryItem(name: "key", value: appId),
31 | URLQueryItem(name: "aqi", value: String("yes")),
32 | URLQueryItem(name: "q", value: latLonString),
33 | URLQueryItem(name: "dt", value: parsedDateToday)
34 | ]
35 |
36 | var urlComps = URLComponents(string: apiUrlString)
37 | urlComps?.queryItems = queryItems
38 |
39 | guard let finalUrl = urlComps?.url else {
40 | throw WeatherError.unableToConstructUrl
41 | }
42 | return finalUrl
43 | }
44 |
45 | private var parsedDateToday: String {
46 | let dateFormatter = DateFormatter()
47 | dateFormatter.dateFormat = "yyyy-MM-dd"
48 | return dateFormatter.string(from: Date())
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/DatWeatherDoe/ViewModel/Repository/WeatherValidatorType.swift:
--------------------------------------------------------------------------------
1 | //
2 | // WeatherValidatorType.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 1/10/22.
6 | // Copyright © 2022 Inder Dhir. All rights reserved.
7 | //
8 |
9 | protocol WeatherValidatorType: AnyObject {
10 | func validate() throws
11 | }
12 |
--------------------------------------------------------------------------------
/DatWeatherDoe/ViewModel/WeatherDataFormatter.swift:
--------------------------------------------------------------------------------
1 | //
2 | // WeatherDataFormatter.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 6/24/24.
6 | // Copyright © 2024 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | protocol WeatherDataFormatterType {
12 | func getLocation(for data: WeatherData) -> String
13 | func getWeatherText(for data: WeatherData) -> String
14 | func getSunriseSunset(for data: WeatherData) -> String
15 | func getWindSpeedItem(for data: WeatherData) -> String
16 | func getUVIndexAndAirQuality(for data: WeatherData) -> String
17 | }
18 |
19 | final class WeatherDataFormatter: WeatherDataFormatterType {
20 | let configManager: ConfigManagerType
21 |
22 | init(configManager: ConfigManagerType) {
23 | self.configManager = configManager
24 | }
25 |
26 | func getLocation(for data: WeatherData) -> String {
27 | [
28 | data.response.locationName,
29 | WeatherConditionTextMapper().map(data.weatherCondition)
30 | ]
31 | .joined(separator: " - ")
32 | }
33 |
34 | func getWeatherText(for data: WeatherData) -> String {
35 | TemperatureForecastTextBuilder(
36 | temperatureData: data.response.temperatureData,
37 | forecastTemperatureData: data.response.forecastDayData.temp,
38 | options: .init(
39 | unit: configManager.parsedMeasurementUnit.temperatureUnit,
40 | isRoundingOff: configManager.isRoundingOffData,
41 | isUnitLetterOff: configManager.isUnitLetterOff,
42 | isUnitSymbolOff: configManager.isUnitSymbolOff
43 | )
44 | ).build()
45 | }
46 |
47 | func getSunriseSunset(for data: WeatherData) -> String {
48 | SunriseAndSunsetTextBuilder(
49 | sunset: data.response.forecastDayData.astro.sunset,
50 | sunrise: data.response.forecastDayData.astro.sunrise
51 | ).build()
52 | }
53 |
54 | func getWindSpeedItem(for data: WeatherData) -> String {
55 | if configManager.measurementUnit == MeasurementUnit.all.rawValue {
56 | WindSpeedFormatter()
57 | .getFormattedWindSpeedStringForAllUnits(
58 | windData: data.response.windData,
59 | isRoundingOff: configManager.isRoundingOffData
60 | )
61 | } else {
62 | WindSpeedFormatter()
63 | .getFormattedWindSpeedString(
64 | unit: configManager.parsedMeasurementUnit,
65 | windData: data.response.windData
66 | )
67 | }
68 | }
69 |
70 | func getUVIndexAndAirQuality(for data: WeatherData) -> String {
71 | let currentHour = Calendar.current.component(.hour, from: Date())
72 | let currentUVIndex = data.response.getHourlyUVIndex(hour: currentHour)
73 | let uvIndex = "UV Index: \(currentUVIndex)"
74 |
75 | let airQualityIndex = "AQI: \(data.response.airQualityIndex.description)"
76 |
77 | return [uvIndex, airQualityIndex].joined(separator: " | ")
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/DatWeatherDoe/ViewModel/WeatherViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // WeatherViewModel.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 1/9/22.
6 | // Copyright © 2022 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import OSLog
11 |
12 | @MainActor
13 | final class WeatherViewModel: WeatherViewModelType, ObservableObject {
14 | private let locationFetcher: SystemLocationFetcherType
15 | private var weatherFactory: WeatherRepositoryFactoryType
16 | private let configManager: ConfigManagerType
17 | private var dataFormatter: WeatherDataFormatterType!
18 | private let logger: Logger
19 | private var reachability: NetworkReachability!
20 |
21 | private let forecaster = WeatherForecaster()
22 | private var weatherTimerTask: Task?
23 |
24 | @Published var menuOptionData: MenuOptionData?
25 | @Published var weatherResult: Result?
26 |
27 | init(
28 | locationFetcher: SystemLocationFetcher,
29 | weatherFactory: WeatherRepositoryFactoryType,
30 | configManager: ConfigManagerType,
31 | logger: Logger
32 | ) {
33 | self.locationFetcher = locationFetcher
34 | self.configManager = configManager
35 | self.weatherFactory = weatherFactory
36 | self.logger = logger
37 |
38 | setupReachability()
39 | }
40 |
41 | deinit {
42 | Task { [weatherTimerTask] in
43 | weatherTimerTask?.cancel()
44 | }
45 | }
46 |
47 | func setup(with formatter: WeatherDataFormatter) {
48 | dataFormatter = formatter
49 | }
50 |
51 | func getUpdatedWeatherAfterRefresh() {
52 | weatherTimerTask?.cancel()
53 | weatherTimerTask = Task { [weak self] in
54 | guard let self else { return }
55 |
56 | while !Task.isCancelled {
57 | await self.getWeatherWithSelectedSource()
58 | try? await Task.sleep(for: .seconds(configManager.refreshInterval))
59 | }
60 | }
61 | }
62 |
63 | func seeForecastForCurrentCity() {
64 | forecaster.seeForecastForCity()
65 | }
66 |
67 | private func setupReachability() {
68 | reachability = NetworkReachability(
69 | logger: logger,
70 | onBecomingReachable: { [weak self] in
71 | self?.getUpdatedWeatherAfterRefresh()
72 | }
73 | )
74 | }
75 |
76 | private func getWeatherWithSelectedSource() async {
77 | let weatherSource = WeatherSource(rawValue: configManager.weatherSource) ?? .location
78 |
79 | do {
80 | let weatherData = switch weatherSource {
81 | case .location:
82 | try await getWeatherAfterUpdatingLocation()
83 | case .latLong:
84 | try await getWeatherViaLocationCoordinates()
85 | }
86 | updateWeatherData(weatherData)
87 | } catch {
88 | updateWeatherData(error)
89 | }
90 | }
91 |
92 | private func getWeatherAfterUpdatingLocation() async throws -> WeatherData {
93 | let locationFetcher = locationFetcher
94 | let locationTask = Task {
95 | let location = try await locationFetcher.getLocation()
96 | return location
97 | }
98 |
99 | let location = try await locationTask.value
100 | return try await getWeather(
101 | repository: weatherFactory.create(location: location),
102 | unit: configManager.parsedMeasurementUnit
103 | )
104 | }
105 |
106 | private func getWeatherViaLocationCoordinates() async throws -> WeatherData {
107 | let latLong = configManager.weatherSourceText
108 | guard !latLong.isEmpty else {
109 | throw WeatherError.latLongIncorrect
110 | }
111 |
112 | return try await getWeather(
113 | repository: weatherFactory.create(latLong: latLong),
114 | unit: configManager.parsedMeasurementUnit
115 | )
116 | }
117 |
118 | private func buildWeatherDataOptions(for unit: MeasurementUnit) -> WeatherDataBuilder.Options {
119 | .init(
120 | unit: unit,
121 | showWeatherIcon: configManager.isShowingWeatherIcon,
122 | textOptions: buildWeatherTextOptions(for: unit)
123 | )
124 | }
125 |
126 | private func buildWeatherTextOptions(for unit: MeasurementUnit) -> WeatherTextBuilder.Options {
127 | let conditionPosition = WeatherConditionPosition(rawValue: configManager.weatherConditionPosition)
128 | ?? .beforeTemperature
129 | return .init(
130 | isWeatherConditionAsTextEnabled: configManager.isWeatherConditionAsTextEnabled,
131 | conditionPosition: conditionPosition,
132 | valueSeparator: configManager.valueSeparator,
133 | temperatureOptions: .init(
134 | unit: unit.temperatureUnit,
135 | isRoundingOff: configManager.isRoundingOffData,
136 | isUnitLetterOff: configManager.isUnitLetterOff,
137 | isUnitSymbolOff: configManager.isUnitSymbolOff
138 | ),
139 | isShowingHumidity: configManager.isShowingHumidity,
140 | isShowingUVIndex: configManager.isShowingUVIndex
141 | )
142 | }
143 |
144 | private func getWeather(repository: WeatherRepositoryType, unit: MeasurementUnit) async throws -> WeatherData {
145 | let repository = repository
146 |
147 | let responseTask = Task {
148 | let response = try await repository.getWeather()
149 | return response
150 | }
151 |
152 | do {
153 | let response = try await responseTask.value
154 | let weatherData = WeatherDataBuilder(
155 | response: response,
156 | options: buildWeatherDataOptions(for: unit),
157 | logger: logger
158 | ).build()
159 | return weatherData
160 | } catch {
161 | throw WeatherError.networkError
162 | }
163 | }
164 |
165 | private func updateWeatherData(_ data: WeatherData) {
166 | menuOptionData = MenuOptionData(
167 | locationText: dataFormatter.getLocation(for: data),
168 | weatherText: dataFormatter.getWeatherText(for: data),
169 | sunriseSunsetText: dataFormatter.getSunriseSunset(for: data),
170 | tempHumidityWindText: dataFormatter.getWindSpeedItem(for: data),
171 | uvIndexAndAirQualityText: dataFormatter.getUVIndexAndAirQuality(for: data)
172 | )
173 | weatherResult = .success(data)
174 | }
175 |
176 | private func updateWeatherData(_ error: Error) {
177 | menuOptionData = nil
178 | weatherResult = .failure(error)
179 | }
180 | }
181 |
--------------------------------------------------------------------------------
/DatWeatherDoe/ViewModel/WeatherViewModelType.swift:
--------------------------------------------------------------------------------
1 | //
2 | // WeatherViewModelType.swift
3 | // DatWeatherDoe
4 | //
5 | // Created by Inder Dhir on 1/9/22.
6 | // Copyright © 2022 Inder Dhir. All rights reserved.
7 | //
8 |
9 | import Combine
10 |
11 | @MainActor
12 | protocol WeatherViewModelType: AnyObject {
13 | func setup(with formatter: WeatherDataFormatter)
14 | func getUpdatedWeatherAfterRefresh()
15 | func seeForecastForCurrentCity()
16 | }
17 |
--------------------------------------------------------------------------------
/DatWeatherDoeTests/API/Repository/Location/Coordinates/LocationValidatorTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LocationValidatorTests.swift
3 | // DatWeatherDoeTests
4 | //
5 | // Created by Inder Dhir on 1/26/22.
6 | // Copyright © 2022 Inder Dhir. All rights reserved.
7 | //
8 |
9 | @testable import DatWeatherDoe
10 | import XCTest
11 |
12 | class LocationValidatorTests: XCTestCase {
13 | func testLocation_empty() {
14 | XCTAssertThrowsError(try LocationValidator(latLong: "").validate())
15 | }
16 |
17 | func testLocation_correct() {
18 | XCTAssertNoThrow(try LocationValidator(latLong: "12,24").validate())
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/DatWeatherDoeTests/API/Repository/WeatherURLBuilderTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // WeatherURLBuilderTests.swift
3 | // DatWeatherDoeTests
4 | //
5 | // Created by Inder Dhir on 1/25/22.
6 | // Copyright © 2022 Inder Dhir. All rights reserved.
7 | //
8 |
9 | @testable import DatWeatherDoe
10 | import XCTest
11 |
12 | final class WeatherURLBuilderTests: XCTestCase {
13 | func testBuild() {
14 | XCTAssertEqual(
15 | try? WeatherURLBuilder(appId: "123456", location: .init(latitude: 42, longitude: 42)).build().absoluteString,
16 | "https://api.weatherapi.com/v1/forecast.json?key=123456&aqi=no&q=42.0,42.0&dt=\(parsedDateToday)"
17 | )
18 | }
19 |
20 | private var parsedDateToday: String {
21 | let dateFormatter = DateFormatter()
22 | dateFormatter.dateFormat = "yyyy-MM-dd"
23 | return dateFormatter.string(from: Date())
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/DatWeatherDoeTests/Config/ConfigManagerTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ConfigManagerTests.swift
3 | // DefaultsTests
4 | //
5 | // Created by Inder Dhir on 5/23/21.
6 | // Copyright © 2021 Inder Dhir. All rights reserved.
7 | //
8 |
9 | @testable import DatWeatherDoe
10 | import XCTest
11 |
12 | final class ConfigManagerTests: XCTestCase {
13 | var configManager: ConfigManagerType!
14 |
15 | override func setUp() {
16 | super.setUp()
17 | clearUserDefaults()
18 | }
19 |
20 | func testDefaultMeasurementUnit() {
21 | XCTAssertEqual(configManager.measurementUnit, MeasurementUnit.imperial.rawValue)
22 | }
23 |
24 | func testMeasurementUnitSaved() {
25 | XCTAssertEqual(configManager.measurementUnit, MeasurementUnit.imperial.rawValue)
26 | configManager.measurementUnit = MeasurementUnit.metric.rawValue
27 | XCTAssertEqual(configManager.measurementUnit, MeasurementUnit.metric.rawValue)
28 | }
29 |
30 | func testDefaultWeatherSource() {
31 | XCTAssertEqual(configManager.weatherSource, WeatherSource.location.rawValue)
32 | }
33 |
34 | func testDefaultWeatherSourceSaved() {
35 | XCTAssertEqual(configManager.weatherSource, WeatherSource.location.rawValue)
36 | configManager.weatherSource = WeatherSource.latLong.rawValue
37 | XCTAssertEqual(configManager.weatherSource, WeatherSource.latLong.rawValue)
38 | }
39 |
40 | func testDefaultWeatherSourceText() {
41 | XCTAssertEqual(configManager.weatherSourceText, nil)
42 | }
43 |
44 | func testWeatherSourceTextSaved() {
45 | XCTAssertEqual(configManager.weatherSourceText, nil)
46 | configManager.weatherSourceText = "40,40"
47 | XCTAssertEqual(configManager.weatherSourceText, "40,40")
48 | }
49 |
50 | func testDefaultRefreshInterval() {
51 | XCTAssertEqual(configManager.refreshInterval, 900)
52 | }
53 |
54 | func testRefreshIntervalSaved() {
55 | XCTAssertEqual(configManager.refreshInterval, 900)
56 | configManager.refreshInterval = 300
57 | XCTAssertEqual(configManager.refreshInterval, 300)
58 | }
59 |
60 | func testDefaultShowingHumidity() {
61 | XCTAssertEqual(configManager.isShowingHumidity, false)
62 | }
63 |
64 | func testShowingHumiditySaved() {
65 | XCTAssertEqual(configManager.isShowingHumidity, false)
66 | configManager.isShowingHumidity = true
67 | XCTAssertEqual(configManager.isShowingHumidity, true)
68 | }
69 |
70 | func testDefaultRoundingOffData() {
71 | XCTAssertEqual(configManager.isRoundingOffData, false)
72 | }
73 |
74 | func testDefaultUnitLetterOffOffData() {
75 | XCTAssertEqual(configManager.isUnitLetterOff, false)
76 | }
77 |
78 | func testDefaultisUnitSymbolOff() {
79 | XCTAssertEqual(configManager.isUnitSymbolOff, false)
80 | }
81 |
82 | func testRoundingOffDataSaved() {
83 | XCTAssertEqual(configManager.isRoundingOffData, false)
84 | configManager.isRoundingOffData = true
85 | XCTAssertEqual(configManager.isRoundingOffData, true)
86 | }
87 |
88 | func testUnitLetterOffSaved() {
89 | XCTAssertEqual(configManager.isUnitLetterOff, false)
90 | configManager.isUnitLetterOff = true
91 | XCTAssertEqual(configManager.isUnitLetterOff, true)
92 | }
93 |
94 | func testUnitSymbolOffSaved() {
95 | XCTAssertEqual(configManager.isUnitSymbolOff, false)
96 | configManager.isUnitSymbolOff = true
97 | XCTAssertEqual(configManager.isUnitSymbolOff, true)
98 | }
99 |
100 | func testWeatherConditionAsTextDefault() {
101 | XCTAssertEqual(configManager.isWeatherConditionAsTextEnabled, false)
102 | }
103 |
104 | func testWeatherConditionAsTextSaved() {
105 | XCTAssertEqual(configManager.isWeatherConditionAsTextEnabled, false)
106 | configManager.isWeatherConditionAsTextEnabled = true
107 | XCTAssertEqual(configManager.isWeatherConditionAsTextEnabled, true)
108 | }
109 |
110 | private func clearUserDefaults() {
111 | let appDomain = Bundle.main.bundleIdentifier ?? "DatWeatherDoe"
112 | UserDefaults.resetStandardUserDefaults()
113 | UserDefaults.standard.removePersistentDomain(forName: appDomain)
114 | configManager = ConfigManager()
115 | }
116 | }
117 |
--------------------------------------------------------------------------------
/DatWeatherDoeTests/DatWeatherDoe.xctestplan:
--------------------------------------------------------------------------------
1 | {
2 | "configurations" : [
3 | {
4 | "id" : "8E1BA5A7-5E49-4CF1-8D13-2F0DA4BF0476",
5 | "name" : "Main Config",
6 | "options" : {
7 |
8 | }
9 | }
10 | ],
11 | "defaultOptions" : {
12 | "codeCoverage" : false,
13 | "targetForVariableExpansion" : {
14 | "containerPath" : "container:DatWeatherDoe.xcodeproj",
15 | "identifier" : "2023EDA21C4ED09C0087FD67",
16 | "name" : "DatWeatherDoe"
17 | },
18 | "testExecutionOrdering" : "random"
19 | },
20 | "testTargets" : [
21 | {
22 | "parallelizable" : true,
23 | "target" : {
24 | "containerPath" : "container:DatWeatherDoe.xcodeproj",
25 | "identifier" : "61D04A2E1C7B7BCF00CBE6AE",
26 | "name" : "DatWeatherDoeTests"
27 | }
28 | }
29 | ],
30 | "version" : 1
31 | }
32 |
--------------------------------------------------------------------------------
/DatWeatherDoeTests/UI/Configure/Options/RefreshIntervalTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RefreshIntervalTests.swift
3 | // DatWeatherDoeTests
4 | //
5 | // Created by Inder Dhir on 2/12/22.
6 | // Copyright © 2022 Inder Dhir. All rights reserved.
7 | //
8 |
9 | @testable import DatWeatherDoe
10 | import XCTest
11 |
12 | final class RefreshIntervalTests: XCTestCase {
13 | func testRefreshIntervalTimes() {
14 | XCTAssertEqual(RefreshInterval.fiveMinutes.rawValue, 300)
15 | XCTAssertEqual(RefreshInterval.fifteenMinutes.rawValue, 900)
16 | XCTAssertEqual(RefreshInterval.thirtyMinutes.rawValue, 1800)
17 | XCTAssertEqual(RefreshInterval.sixtyMinutes.rawValue, 3600)
18 | }
19 |
20 | func testRefreshintervalStrings() {
21 | XCTAssertEqual(RefreshInterval.fiveMinutes.title, "5 min")
22 | XCTAssertEqual(RefreshInterval.fifteenMinutes.title, "15 min")
23 | XCTAssertEqual(RefreshInterval.thirtyMinutes.title, "30 min")
24 | XCTAssertEqual(RefreshInterval.sixtyMinutes.title, "60 min")
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/DatWeatherDoeTests/UI/Configure/Options/TemperatureUnitTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TemperatureUnitTests.swift
3 | // DatWeatherDoeTests
4 | //
5 | // Created by Inder Dhir on 2/12/22.
6 | // Copyright © 2022 Inder Dhir. All rights reserved.
7 | //
8 |
9 | @testable import DatWeatherDoe
10 | import XCTest
11 |
12 | final class TemperatureUnitTests: XCTestCase {
13 | func testFahrenheit() {
14 | let fahrenheitUnit = TemperatureUnit.fahrenheit
15 |
16 | XCTAssertEqual(fahrenheitUnit.unitString, "F")
17 | XCTAssertEqual(fahrenheitUnit.degreesString, "\u{00B0}F")
18 | }
19 |
20 | func testCelsius() {
21 | let fahrenheitUnit = TemperatureUnit.celsius
22 |
23 | XCTAssertEqual(fahrenheitUnit.unitString, "C")
24 | XCTAssertEqual(fahrenheitUnit.degreesString, "\u{00B0}C")
25 | }
26 |
27 | func testAll() {
28 | let fahrenheitUnit = TemperatureUnit.all
29 |
30 | XCTAssertEqual(fahrenheitUnit.unitString, "All")
31 | XCTAssertEqual(fahrenheitUnit.degreesString, "\u{00B0}All")
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/DatWeatherDoeTests/UI/Configure/Options/WeatherSourceTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // WeatherSourceTests.swift
3 | // DatWeatherDoeTests
4 | //
5 | // Created by Inder Dhir on 2/12/22.
6 | // Copyright © 2022 Inder Dhir. All rights reserved.
7 | //
8 |
9 | @testable import DatWeatherDoe
10 | import XCTest
11 |
12 | final class WeatherSourceTests: XCTestCase {
13 | func testLocationSource() {
14 | let locationSource = WeatherSource.location
15 |
16 | XCTAssertEqual(locationSource.title, "Location")
17 | XCTAssertEqual(locationSource.placeholder, "")
18 | XCTAssertEqual(locationSource.textHint, "")
19 | }
20 |
21 | func testLatLongSource() {
22 | let latLongSource = WeatherSource.latLong
23 |
24 | XCTAssertEqual(latLongSource.title, "Lat/Long")
25 | XCTAssertEqual(latLongSource.placeholder, "42,42")
26 | XCTAssertEqual(latLongSource.textHint, "[latitude],[longitude]")
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License,
2 | Version 2.0 Apache License Version 2.0,
3 | January 2004 http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
10 |
11 | "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
12 |
13 | "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
14 |
15 | "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
16 |
17 | "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
18 |
19 | "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
20 |
21 | "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
22 |
23 | "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
24 |
25 | "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
26 |
27 | "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
28 |
29 | 2. Grant of Copyright License.
30 |
31 | Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
32 |
33 | 3. Grant of Patent License.
34 |
35 | Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
36 |
37 | 4. Redistribution.
38 |
39 | You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
40 |
41 | You must give any other recipients of the Work or Derivative Works a copy of this License; and You must cause any modified files to carry prominent notices stating that You changed the files; and You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
42 |
43 | 5. Submission of Contributions.
44 |
45 | Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
46 |
47 | 6. Trademarks.
48 |
49 | This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
50 |
51 | 7. Disclaimer of Warranty.
52 |
53 | Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
54 |
55 | 8. Limitation of Liability.
56 |
57 | In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
58 |
59 | 9. Accepting Warranty or Additional Liability.
60 |
61 | While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
62 |
63 | END OF TERMS AND CONDITIONS
64 |
65 | APPENDIX: How to apply the Apache License to your work
66 |
67 | To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
68 |
69 | Copyright 2016 Inder Dhir
70 |
71 | Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at
72 |
73 | http://www.apache.org/licenses/LICENSE-2.0
74 |
75 | Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
76 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # [
](image.png) DatWeatherDoe
2 |
3 | > **Note**
4 | OpenWeatherMap API 2.5 support is ending in June 2024. The app uses WeatherAPI going forward with location support.
5 |
6 | - Fetch weather using:
7 | - Location services
8 | - Latitude / Longitude
9 | - Configurable polling interval
10 | - Dark mode support
11 | - Supports MacOS 13.0+
12 |
13 | ## Screenshots
14 |
15 | \
16 | 
17 |
18 | ## Installation
19 |
20 | ### Homebrew Cask
21 |
22 | `brew install --cask datweatherdoe`
23 |
24 | ### Manual
25 |
26 |
27 |
28 | ## Using Location Services
29 |
30 | If using location, please make sure that the app has permission to access location services on macOS.
31 |
32 | `System Preferences > Security & Privacy > Privacy > Location Services`
33 |
34 | 
35 | 
36 |
37 | ## Developer Setup
38 |
39 | - Get your personal API key for WeatherAPI [here](https://www.weatherapi.com)
40 | - Add the following in "Config.xcconfig":
41 |
42 | ```env
43 | WEATHER_API_KEY=YOUR_KEY
44 | ```
45 |
46 | ## Donate
47 |
48 | Buy me a coffee to support the development of this project.
49 |
50 | [](https://ko-fi.com/Y8Y211O253)
51 |
52 | ## Contributing
53 |
54 | Please see CONTRIBUTING.md
55 |
--------------------------------------------------------------------------------
/location_services_1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/inderdhir/DatWeatherDoe/6a46616a3021066fc5b31ecab30f48fdeff8a88f/location_services_1.png
--------------------------------------------------------------------------------
/location_services_2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/inderdhir/DatWeatherDoe/6a46616a3021066fc5b31ecab30f48fdeff8a88f/location_services_2.png
--------------------------------------------------------------------------------
/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/inderdhir/DatWeatherDoe/6a46616a3021066fc5b31ecab30f48fdeff8a88f/logo.png
--------------------------------------------------------------------------------
/screenshot_1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/inderdhir/DatWeatherDoe/6a46616a3021066fc5b31ecab30f48fdeff8a88f/screenshot_1.png
--------------------------------------------------------------------------------
/screenshot_2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/inderdhir/DatWeatherDoe/6a46616a3021066fc5b31ecab30f48fdeff8a88f/screenshot_2.png
--------------------------------------------------------------------------------