├── .gitignore ├── ReadMe.md ├── city_json └── city_filter.py ├── img └── capture.gif ├── sui_sample.xcodeproj ├── project.pbxproj └── xcshareddata │ └── xcschemes │ └── sui_sample.xcscheme └── sui_sample ├── Actions ├── Fetch5DaysForecast.swift ├── FetchCurrentLocation.swift ├── FilterCities.swift └── ReadCitiesFromFile.swift ├── Assets.xcassets ├── AppIcon.appiconset │ └── Contents.json ├── Colors │ ├── Contents.json │ ├── tabBgColor.colorset │ │ └── Contents.json │ └── tabFrameColor.colorset │ │ └── Contents.json ├── Contents.json └── blank.imageset │ ├── Contents.json │ └── blank.png ├── Base.lproj └── LaunchScreen.storyboard ├── ColorTheme.swift ├── CommonView ├── MapView.swift └── TabButton.swift ├── Config-sample.plist ├── Entity ├── City.swift └── Weather.swift ├── Info.plist ├── Preview Content └── Preview Assets.xcassets │ └── Contents.json ├── RootView.swift ├── Stores └── PageStore.swift ├── Utils ├── Async+Ext.swift ├── ResourceFunctions.swift └── SwiftUI+Ext.swift ├── View ├── CityListView.swift ├── CitySelectionView.swift ├── ForecastView.swift └── LocationSelectionView.swift ├── WeatherApp.swift └── asset ├── 5day_sample.json └── city_list.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## 6 | city_json/*.json 7 | Config.plist 8 | .DS_Store 9 | 10 | ## User settings 11 | xcuserdata/ 12 | 13 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 14 | *.xcscmblueprint 15 | *.xccheckout 16 | 17 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 18 | build/ 19 | DerivedData/ 20 | *.moved-aside 21 | *.pbxuser 22 | !default.pbxuser 23 | *.mode1v3 24 | !default.mode1v3 25 | *.mode2v3 26 | !default.mode2v3 27 | *.perspectivev3 28 | !default.perspectivev3 29 | 30 | ## Xcode Patch 31 | *.xcodeproj/* 32 | !*.xcodeproj/project.pbxproj 33 | !*.xcodeproj/xcshareddata/ 34 | !*.xcworkspace/contents.xcworkspacedata 35 | /*.gcno 36 | 37 | *.entitlements 38 | -------------------------------------------------------------------------------- /ReadMe.md: -------------------------------------------------------------------------------- 1 | # SwiftUI + Async Weather 2 | 3 | Showing Weather forecast using SwiftUI + Async. 4 | 5 | Select a area, and show daily forecasts, using `WeatherKit` 6 | 7 |

8 | 9 |

10 | 11 | ## Highlight of this app 12 | 13 | * SwiftUI.framework 14 | * Swift6 Support 15 | * Using `LocationButton`, which are new APIs in iOS 15. 16 | * MKMapView with CoreLocation 17 | * GPS with async/await 18 | * detect tap and add pin at tap position 19 | * Adapt dark mode with asset-catalog-colors 20 | * Use `WeatherKit` 21 | 22 | ## how to run 23 | 24 | * Install Xcode16.0 or later 25 | * Build and Run 26 | 27 | By default, this app will access local-json file. 28 | If you want to use network-api, please do as following 29 | 30 | * sign in [OpenWeather](https://openweathermap.org/) and create Api Key 31 | * rename `Config-sample.plist` to `Config.plist` 32 | * Write your Api Key in `Constants.plist` 33 | 34 | > city_list.json from Openweather is too big (20MB!). 35 | > So I shrinked it. Please check city_json/ 36 | 37 | 38 | ### WeatherKit 39 | 40 | To run devices, you need to set up your project as follows. 41 | 42 | https://developer.apple.com/documentation/weatherkit/fetching_weather_forecasts_with_weatherkit 43 | 44 | ## file structure 45 | 46 | + / 47 | +- RootView.swift : view at app-launching 48 | +- Theme.swift : Color list 49 | +- asset/ : cities and sample json 50 | +- Actions/ : side-effects 51 | +- CommonView/ : common views 52 | +- Utils/ 53 | +- View/ 54 | 55 | ## app structure 56 | 57 | * Simplify structure, no view-models 58 | * `Actions/` has complex side-effect operations with `@Environment` 59 | 60 | ## thanks 61 | 62 | * [OpenWeather](https://openweathermap.org/) 63 | * Many repositories using SwiftUI and authors. 64 | 65 | -------------------------------------------------------------------------------- /city_json/city_filter.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | # open-weather's city list file is too large. it containts about 210,000 cities. 4 | # So this program reduces data. about 1 tenth 5 | # How To use 6 | # 1. download city.list.min.json.gz from 7 | # http://bulk.openweathermap.org/sample/ 8 | # 2. put it into this folder and extract it 9 | # 3. run code: python3 city_filter.py 10 | # 4. city_list.json will be created 11 | # 5. copy it to sui_sample/asset 12 | 13 | def reduce_cities(): 14 | with open("city.list.min.json", "rb") as json_file: 15 | cities = json.load(json_file) 16 | 17 | reduced = list() 18 | for (i, k) in enumerate(cities): 19 | if i % 10 == 0: 20 | reduced.append(k) 21 | 22 | # if you want to filter by country, use following code 23 | reduced = [city for city in cities if city["country"] == "JP"] 24 | 25 | print("number of cities:" + str(len(reduced))) 26 | with open("city_list.json", "w") as jp_file: 27 | json.dump(reduced, jp_file) 28 | 29 | 30 | if __name__ == "__main__": 31 | reduce_cities() 32 | -------------------------------------------------------------------------------- /img/capture.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naodroid/swiftui_weather/28df70549e8808c85631f02262d48aa2f4dba1a6/img/capture.gif -------------------------------------------------------------------------------- /sui_sample.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 50; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 353B93D82DC9DA1400EED29A /* PageStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 353B93D72DC9DA1200EED29A /* PageStore.swift */; }; 11 | 353B93E12DC9E08F00EED29A /* CityListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 353B93E02DC9E08C00EED29A /* CityListView.swift */; }; 12 | 353B93E32DC9E52300EED29A /* LocationSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 353B93E22DC9E51F00EED29A /* LocationSelectionView.swift */; }; 13 | 353B93E52DC9E7D100EED29A /* FetchCurrentLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 353B93E42DC9E7CE00EED29A /* FetchCurrentLocation.swift */; }; 14 | 353B93E72DC9E8D800EED29A /* ReadCitiesFromFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 353B93E62DC9E8D000EED29A /* ReadCitiesFromFile.swift */; }; 15 | 353B93E92DC9EDC000EED29A /* FilterCities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 353B93E82DC9EDBC00EED29A /* FilterCities.swift */; }; 16 | 353B93EF2DC9EF1400EED29A /* Fetch5DaysForecast.swift in Sources */ = {isa = PBXBuildFile; fileRef = 353B93EE2DC9EF1000EED29A /* Fetch5DaysForecast.swift */; }; 17 | 356AFCD122A617D900D472F0 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 356AFCD022A617D900D472F0 /* Assets.xcassets */; }; 18 | 356AFCD422A617D900D472F0 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 356AFCD322A617D900D472F0 /* Preview Assets.xcassets */; }; 19 | 356AFCD722A617D900D472F0 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 356AFCD522A617D900D472F0 /* LaunchScreen.storyboard */; }; 20 | 356AFCE322A6230800D472F0 /* Weather.swift in Sources */ = {isa = PBXBuildFile; fileRef = 356AFCE222A6230800D472F0 /* Weather.swift */; }; 21 | 357EFDFA22A9172100432774 /* TabButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 357EFDF922A9172100432774 /* TabButton.swift */; }; 22 | 358804DC22A770CD00DAD63E /* SwiftUI+Ext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 358804DB22A770CD00DAD63E /* SwiftUI+Ext.swift */; }; 23 | 358D999222A9D1CE00CB68C9 /* 5day_sample.json in Resources */ = {isa = PBXBuildFile; fileRef = 358D999122A9D1CE00CB68C9 /* 5day_sample.json */; }; 24 | 358D999622A9D31600CB68C9 /* ResourceFunctions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 358D999522A9D31600CB68C9 /* ResourceFunctions.swift */; }; 25 | 359D9E5426AA5BE800FE82AE /* WeatherApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 359D9E5326AA5BE800FE82AE /* WeatherApp.swift */; }; 26 | 35A6311522A9D51B00940C06 /* ForecastView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35A6311422A9D51B00940C06 /* ForecastView.swift */; }; 27 | 35B1506C22A6571400BC52BB /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 35B1506B22A6571300BC52BB /* SwiftUI.framework */; }; 28 | 35B440A522A8F09B0012D6C9 /* CoreLocation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 35B440A422A8F09B0012D6C9 /* CoreLocation.framework */; }; 29 | 35DC39FB22A937AE00694DC0 /* MapKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 35DC39FA22A937AE00694DC0 /* MapKit.framework */; }; 30 | 35DC39FD22A937B800694DC0 /* MapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35DC39FC22A937B800694DC0 /* MapView.swift */; }; 31 | 35DC39FF22A9409B00694DC0 /* city_list.json in Resources */ = {isa = PBXBuildFile; fileRef = 35DC39FE22A9409B00694DC0 /* city_list.json */; }; 32 | 35DC3A0122A9C64500694DC0 /* ColorTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35DC3A0022A9C64500694DC0 /* ColorTheme.swift */; }; 33 | 35EB645322A9012200061C40 /* City.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35EB645222A9012200061C40 /* City.swift */; }; 34 | 35EB645B22A911F900061C40 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35EB645A22A911F900061C40 /* RootView.swift */; }; 35 | 35EB645D22A9120D00061C40 /* CitySelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35EB645C22A9120D00061C40 /* CitySelectionView.swift */; }; 36 | C63E790C26A2589A00EC5CB9 /* CoreLocationUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C63E790B26A2589A00EC5CB9 /* CoreLocationUI.framework */; }; 37 | C65D9D7826A25233000C94C9 /* Async+Ext.swift in Sources */ = {isa = PBXBuildFile; fileRef = C65D9D7726A25233000C94C9 /* Async+Ext.swift */; }; 38 | /* End PBXBuildFile section */ 39 | 40 | /* Begin PBXFileReference section */ 41 | 353B93D72DC9DA1200EED29A /* PageStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageStore.swift; sourceTree = ""; }; 42 | 353B93E02DC9E08C00EED29A /* CityListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CityListView.swift; sourceTree = ""; }; 43 | 353B93E22DC9E51F00EED29A /* LocationSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationSelectionView.swift; sourceTree = ""; }; 44 | 353B93E42DC9E7CE00EED29A /* FetchCurrentLocation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchCurrentLocation.swift; sourceTree = ""; }; 45 | 353B93E62DC9E8D000EED29A /* ReadCitiesFromFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadCitiesFromFile.swift; sourceTree = ""; }; 46 | 353B93E82DC9EDBC00EED29A /* FilterCities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterCities.swift; sourceTree = ""; }; 47 | 353B93EE2DC9EF1000EED29A /* Fetch5DaysForecast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Fetch5DaysForecast.swift; sourceTree = ""; }; 48 | 3555D902295F9F1A0041AF38 /* sui_sample.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = sui_sample.entitlements; sourceTree = ""; }; 49 | 356AFCC722A617D700D472F0 /* sui_sample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = sui_sample.app; sourceTree = BUILT_PRODUCTS_DIR; }; 50 | 356AFCD022A617D900D472F0 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 51 | 356AFCD322A617D900D472F0 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 52 | 356AFCD622A617D900D472F0 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 53 | 356AFCD822A617D900D472F0 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 54 | 356AFCE222A6230800D472F0 /* Weather.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Weather.swift; sourceTree = ""; }; 55 | 357EFDF922A9172100432774 /* TabButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabButton.swift; sourceTree = ""; }; 56 | 358804DB22A770CD00DAD63E /* SwiftUI+Ext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SwiftUI+Ext.swift"; sourceTree = ""; }; 57 | 358D999122A9D1CE00CB68C9 /* 5day_sample.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = 5day_sample.json; sourceTree = ""; }; 58 | 358D999522A9D31600CB68C9 /* ResourceFunctions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourceFunctions.swift; sourceTree = ""; }; 59 | 359D9E5326AA5BE800FE82AE /* WeatherApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeatherApp.swift; sourceTree = ""; }; 60 | 35A6311422A9D51B00940C06 /* ForecastView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForecastView.swift; sourceTree = ""; }; 61 | 35B1506B22A6571300BC52BB /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; 62 | 35B440A422A8F09B0012D6C9 /* CoreLocation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreLocation.framework; path = System/Library/Frameworks/CoreLocation.framework; sourceTree = SDKROOT; }; 63 | 35DC39FA22A937AE00694DC0 /* MapKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MapKit.framework; path = System/Library/Frameworks/MapKit.framework; sourceTree = SDKROOT; }; 64 | 35DC39FC22A937B800694DC0 /* MapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapView.swift; sourceTree = ""; }; 65 | 35DC39FE22A9409B00694DC0 /* city_list.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = city_list.json; sourceTree = ""; }; 66 | 35DC3A0022A9C64500694DC0 /* ColorTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorTheme.swift; sourceTree = ""; }; 67 | 35EB645222A9012200061C40 /* City.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = City.swift; sourceTree = ""; }; 68 | 35EB645A22A911F900061C40 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; 69 | 35EB645C22A9120D00061C40 /* CitySelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CitySelectionView.swift; sourceTree = ""; }; 70 | C63E790B26A2589A00EC5CB9 /* CoreLocationUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreLocationUI.framework; path = System/Library/Frameworks/CoreLocationUI.framework; sourceTree = SDKROOT; }; 71 | C65D9D7726A25233000C94C9 /* Async+Ext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Async+Ext.swift"; sourceTree = ""; }; 72 | /* End PBXFileReference section */ 73 | 74 | /* Begin PBXFrameworksBuildPhase section */ 75 | 356AFCC422A617D700D472F0 /* Frameworks */ = { 76 | isa = PBXFrameworksBuildPhase; 77 | buildActionMask = 2147483647; 78 | files = ( 79 | C63E790C26A2589A00EC5CB9 /* CoreLocationUI.framework in Frameworks */, 80 | 35B440A522A8F09B0012D6C9 /* CoreLocation.framework in Frameworks */, 81 | 35DC39FB22A937AE00694DC0 /* MapKit.framework in Frameworks */, 82 | 35B1506C22A6571400BC52BB /* SwiftUI.framework in Frameworks */, 83 | ); 84 | runOnlyForDeploymentPostprocessing = 0; 85 | }; 86 | /* End PBXFrameworksBuildPhase section */ 87 | 88 | /* Begin PBXGroup section */ 89 | 353B93D62DC9DA0B00EED29A /* Stores */ = { 90 | isa = PBXGroup; 91 | children = ( 92 | 353B93D72DC9DA1200EED29A /* PageStore.swift */, 93 | ); 94 | path = Stores; 95 | sourceTree = ""; 96 | }; 97 | 353B93D92DC9DBAD00EED29A /* Actions */ = { 98 | isa = PBXGroup; 99 | children = ( 100 | 353B93EE2DC9EF1000EED29A /* Fetch5DaysForecast.swift */, 101 | 353B93E82DC9EDBC00EED29A /* FilterCities.swift */, 102 | 353B93E62DC9E8D000EED29A /* ReadCitiesFromFile.swift */, 103 | 353B93E42DC9E7CE00EED29A /* FetchCurrentLocation.swift */, 104 | ); 105 | path = Actions; 106 | sourceTree = ""; 107 | }; 108 | 356AFCBE22A617D700D472F0 = { 109 | isa = PBXGroup; 110 | children = ( 111 | 356AFCC922A617D700D472F0 /* sui_sample */, 112 | 356AFCC822A617D700D472F0 /* Products */, 113 | 35B1506A22A6571300BC52BB /* Frameworks */, 114 | ); 115 | sourceTree = ""; 116 | }; 117 | 356AFCC822A617D700D472F0 /* Products */ = { 118 | isa = PBXGroup; 119 | children = ( 120 | 356AFCC722A617D700D472F0 /* sui_sample.app */, 121 | ); 122 | name = Products; 123 | sourceTree = ""; 124 | }; 125 | 356AFCC922A617D700D472F0 /* sui_sample */ = { 126 | isa = PBXGroup; 127 | children = ( 128 | 3555D902295F9F1A0041AF38 /* sui_sample.entitlements */, 129 | 35B440AA22A8F6AC0012D6C9 /* asset */, 130 | 353B93D92DC9DBAD00EED29A /* Actions */, 131 | 358804DE22A772A100DAD63E /* CommonView */, 132 | 356AFCDE22A61C3C00D472F0 /* Entity */, 133 | 353B93D62DC9DA0B00EED29A /* Stores */, 134 | 356AFCE122A61DF800D472F0 /* View */, 135 | 35B1507722A67CE800BC52BB /* Utils */, 136 | 35EB645A22A911F900061C40 /* RootView.swift */, 137 | 359D9E5326AA5BE800FE82AE /* WeatherApp.swift */, 138 | 35DC3A0022A9C64500694DC0 /* ColorTheme.swift */, 139 | 356AFCD022A617D900D472F0 /* Assets.xcassets */, 140 | 356AFCD522A617D900D472F0 /* LaunchScreen.storyboard */, 141 | 356AFCD822A617D900D472F0 /* Info.plist */, 142 | 356AFCD222A617D900D472F0 /* Preview Content */, 143 | ); 144 | path = sui_sample; 145 | sourceTree = ""; 146 | }; 147 | 356AFCD222A617D900D472F0 /* Preview Content */ = { 148 | isa = PBXGroup; 149 | children = ( 150 | 356AFCD322A617D900D472F0 /* Preview Assets.xcassets */, 151 | ); 152 | path = "Preview Content"; 153 | sourceTree = ""; 154 | }; 155 | 356AFCDE22A61C3C00D472F0 /* Entity */ = { 156 | isa = PBXGroup; 157 | children = ( 158 | 35EB645222A9012200061C40 /* City.swift */, 159 | 356AFCE222A6230800D472F0 /* Weather.swift */, 160 | ); 161 | path = Entity; 162 | sourceTree = ""; 163 | }; 164 | 356AFCE122A61DF800D472F0 /* View */ = { 165 | isa = PBXGroup; 166 | children = ( 167 | 35EB645C22A9120D00061C40 /* CitySelectionView.swift */, 168 | 353B93E02DC9E08C00EED29A /* CityListView.swift */, 169 | 353B93E22DC9E51F00EED29A /* LocationSelectionView.swift */, 170 | 35A6311422A9D51B00940C06 /* ForecastView.swift */, 171 | ); 172 | path = View; 173 | sourceTree = ""; 174 | }; 175 | 358804DE22A772A100DAD63E /* CommonView */ = { 176 | isa = PBXGroup; 177 | children = ( 178 | 357EFDF922A9172100432774 /* TabButton.swift */, 179 | 35DC39FC22A937B800694DC0 /* MapView.swift */, 180 | ); 181 | path = CommonView; 182 | sourceTree = ""; 183 | }; 184 | 35B1506A22A6571300BC52BB /* Frameworks */ = { 185 | isa = PBXGroup; 186 | children = ( 187 | C63E790B26A2589A00EC5CB9 /* CoreLocationUI.framework */, 188 | 35DC39FA22A937AE00694DC0 /* MapKit.framework */, 189 | 35B440A422A8F09B0012D6C9 /* CoreLocation.framework */, 190 | 35B1506B22A6571300BC52BB /* SwiftUI.framework */, 191 | ); 192 | name = Frameworks; 193 | sourceTree = ""; 194 | }; 195 | 35B1507722A67CE800BC52BB /* Utils */ = { 196 | isa = PBXGroup; 197 | children = ( 198 | C65D9D7726A25233000C94C9 /* Async+Ext.swift */, 199 | 358804DB22A770CD00DAD63E /* SwiftUI+Ext.swift */, 200 | 358D999522A9D31600CB68C9 /* ResourceFunctions.swift */, 201 | ); 202 | path = Utils; 203 | sourceTree = ""; 204 | }; 205 | 35B440AA22A8F6AC0012D6C9 /* asset */ = { 206 | isa = PBXGroup; 207 | children = ( 208 | 358D999122A9D1CE00CB68C9 /* 5day_sample.json */, 209 | 35DC39FE22A9409B00694DC0 /* city_list.json */, 210 | ); 211 | path = asset; 212 | sourceTree = ""; 213 | }; 214 | /* End PBXGroup section */ 215 | 216 | /* Begin PBXNativeTarget section */ 217 | 356AFCC622A617D700D472F0 /* sui_sample */ = { 218 | isa = PBXNativeTarget; 219 | buildConfigurationList = 356AFCDB22A617D900D472F0 /* Build configuration list for PBXNativeTarget "sui_sample" */; 220 | buildPhases = ( 221 | 356AFCC322A617D700D472F0 /* Sources */, 222 | 356AFCC422A617D700D472F0 /* Frameworks */, 223 | 356AFCC522A617D700D472F0 /* Resources */, 224 | ); 225 | buildRules = ( 226 | ); 227 | dependencies = ( 228 | ); 229 | name = sui_sample; 230 | productName = sui_sample; 231 | productReference = 356AFCC722A617D700D472F0 /* sui_sample.app */; 232 | productType = "com.apple.product-type.application"; 233 | }; 234 | /* End PBXNativeTarget section */ 235 | 236 | /* Begin PBXProject section */ 237 | 356AFCBF22A617D700D472F0 /* Project object */ = { 238 | isa = PBXProject; 239 | attributes = { 240 | LastSwiftUpdateCheck = 1100; 241 | LastUpgradeCheck = 1300; 242 | ORGANIZATIONNAME = naodroid; 243 | TargetAttributes = { 244 | 356AFCC622A617D700D472F0 = { 245 | CreatedOnToolsVersion = 11.0; 246 | }; 247 | }; 248 | }; 249 | buildConfigurationList = 356AFCC222A617D700D472F0 /* Build configuration list for PBXProject "sui_sample" */; 250 | compatibilityVersion = "Xcode 9.3"; 251 | developmentRegion = en; 252 | hasScannedForEncodings = 0; 253 | knownRegions = ( 254 | en, 255 | Base, 256 | ); 257 | mainGroup = 356AFCBE22A617D700D472F0; 258 | productRefGroup = 356AFCC822A617D700D472F0 /* Products */; 259 | projectDirPath = ""; 260 | projectRoot = ""; 261 | targets = ( 262 | 356AFCC622A617D700D472F0 /* sui_sample */, 263 | ); 264 | }; 265 | /* End PBXProject section */ 266 | 267 | /* Begin PBXResourcesBuildPhase section */ 268 | 356AFCC522A617D700D472F0 /* Resources */ = { 269 | isa = PBXResourcesBuildPhase; 270 | buildActionMask = 2147483647; 271 | files = ( 272 | 356AFCD722A617D900D472F0 /* LaunchScreen.storyboard in Resources */, 273 | 356AFCD422A617D900D472F0 /* Preview Assets.xcassets in Resources */, 274 | 35DC39FF22A9409B00694DC0 /* city_list.json in Resources */, 275 | 356AFCD122A617D900D472F0 /* Assets.xcassets in Resources */, 276 | 358D999222A9D1CE00CB68C9 /* 5day_sample.json in Resources */, 277 | ); 278 | runOnlyForDeploymentPostprocessing = 0; 279 | }; 280 | /* End PBXResourcesBuildPhase section */ 281 | 282 | /* Begin PBXSourcesBuildPhase section */ 283 | 356AFCC322A617D700D472F0 /* Sources */ = { 284 | isa = PBXSourcesBuildPhase; 285 | buildActionMask = 2147483647; 286 | files = ( 287 | 35EB645D22A9120D00061C40 /* CitySelectionView.swift in Sources */, 288 | 35DC39FD22A937B800694DC0 /* MapView.swift in Sources */, 289 | 353B93E32DC9E52300EED29A /* LocationSelectionView.swift in Sources */, 290 | 359D9E5426AA5BE800FE82AE /* WeatherApp.swift in Sources */, 291 | 35A6311522A9D51B00940C06 /* ForecastView.swift in Sources */, 292 | C65D9D7826A25233000C94C9 /* Async+Ext.swift in Sources */, 293 | 35DC3A0122A9C64500694DC0 /* ColorTheme.swift in Sources */, 294 | 35EB645B22A911F900061C40 /* RootView.swift in Sources */, 295 | 353B93E52DC9E7D100EED29A /* FetchCurrentLocation.swift in Sources */, 296 | 35EB645322A9012200061C40 /* City.swift in Sources */, 297 | 353B93D82DC9DA1400EED29A /* PageStore.swift in Sources */, 298 | 358D999622A9D31600CB68C9 /* ResourceFunctions.swift in Sources */, 299 | 358804DC22A770CD00DAD63E /* SwiftUI+Ext.swift in Sources */, 300 | 356AFCE322A6230800D472F0 /* Weather.swift in Sources */, 301 | 353B93E12DC9E08F00EED29A /* CityListView.swift in Sources */, 302 | 353B93EF2DC9EF1400EED29A /* Fetch5DaysForecast.swift in Sources */, 303 | 353B93E92DC9EDC000EED29A /* FilterCities.swift in Sources */, 304 | 353B93E72DC9E8D800EED29A /* ReadCitiesFromFile.swift in Sources */, 305 | 357EFDFA22A9172100432774 /* TabButton.swift in Sources */, 306 | ); 307 | runOnlyForDeploymentPostprocessing = 0; 308 | }; 309 | /* End PBXSourcesBuildPhase section */ 310 | 311 | /* Begin PBXVariantGroup section */ 312 | 356AFCD522A617D900D472F0 /* LaunchScreen.storyboard */ = { 313 | isa = PBXVariantGroup; 314 | children = ( 315 | 356AFCD622A617D900D472F0 /* Base */, 316 | ); 317 | name = LaunchScreen.storyboard; 318 | sourceTree = ""; 319 | }; 320 | /* End PBXVariantGroup section */ 321 | 322 | /* Begin XCBuildConfiguration section */ 323 | 356AFCD922A617D900D472F0 /* Debug */ = { 324 | isa = XCBuildConfiguration; 325 | buildSettings = { 326 | ALWAYS_SEARCH_USER_PATHS = NO; 327 | CLANG_ANALYZER_NONNULL = YES; 328 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 329 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 330 | CLANG_CXX_LIBRARY = "libc++"; 331 | CLANG_ENABLE_MODULES = YES; 332 | CLANG_ENABLE_OBJC_ARC = YES; 333 | CLANG_ENABLE_OBJC_WEAK = YES; 334 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 335 | CLANG_WARN_BOOL_CONVERSION = YES; 336 | CLANG_WARN_COMMA = YES; 337 | CLANG_WARN_CONSTANT_CONVERSION = YES; 338 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 339 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 340 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 341 | CLANG_WARN_EMPTY_BODY = YES; 342 | CLANG_WARN_ENUM_CONVERSION = YES; 343 | CLANG_WARN_INFINITE_RECURSION = YES; 344 | CLANG_WARN_INT_CONVERSION = YES; 345 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 346 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 347 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 348 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 349 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 350 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 351 | CLANG_WARN_STRICT_PROTOTYPES = YES; 352 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 353 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 354 | CLANG_WARN_UNREACHABLE_CODE = YES; 355 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 356 | COPY_PHASE_STRIP = NO; 357 | DEBUG_INFORMATION_FORMAT = dwarf; 358 | ENABLE_STRICT_OBJC_MSGSEND = YES; 359 | ENABLE_TESTABILITY = YES; 360 | GCC_C_LANGUAGE_STANDARD = gnu11; 361 | GCC_DYNAMIC_NO_PIC = NO; 362 | GCC_NO_COMMON_BLOCKS = YES; 363 | GCC_OPTIMIZATION_LEVEL = 0; 364 | GCC_PREPROCESSOR_DEFINITIONS = ( 365 | "DEBUG=1", 366 | "$(inherited)", 367 | ); 368 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 369 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 370 | GCC_WARN_UNDECLARED_SELECTOR = YES; 371 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 372 | GCC_WARN_UNUSED_FUNCTION = YES; 373 | GCC_WARN_UNUSED_VARIABLE = YES; 374 | IPHONEOS_DEPLOYMENT_TARGET = 17.6; 375 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 376 | MTL_FAST_MATH = YES; 377 | ONLY_ACTIVE_ARCH = YES; 378 | SDKROOT = iphoneos; 379 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 380 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 381 | SWIFT_VERSION = 6.0; 382 | }; 383 | name = Debug; 384 | }; 385 | 356AFCDA22A617D900D472F0 /* Release */ = { 386 | isa = XCBuildConfiguration; 387 | buildSettings = { 388 | ALWAYS_SEARCH_USER_PATHS = NO; 389 | CLANG_ANALYZER_NONNULL = YES; 390 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 391 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 392 | CLANG_CXX_LIBRARY = "libc++"; 393 | CLANG_ENABLE_MODULES = YES; 394 | CLANG_ENABLE_OBJC_ARC = YES; 395 | CLANG_ENABLE_OBJC_WEAK = YES; 396 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 397 | CLANG_WARN_BOOL_CONVERSION = YES; 398 | CLANG_WARN_COMMA = YES; 399 | CLANG_WARN_CONSTANT_CONVERSION = YES; 400 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 401 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 402 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 403 | CLANG_WARN_EMPTY_BODY = YES; 404 | CLANG_WARN_ENUM_CONVERSION = YES; 405 | CLANG_WARN_INFINITE_RECURSION = YES; 406 | CLANG_WARN_INT_CONVERSION = YES; 407 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 408 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 409 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 410 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 411 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 412 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 413 | CLANG_WARN_STRICT_PROTOTYPES = YES; 414 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 415 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 416 | CLANG_WARN_UNREACHABLE_CODE = YES; 417 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 418 | COPY_PHASE_STRIP = NO; 419 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 420 | ENABLE_NS_ASSERTIONS = NO; 421 | ENABLE_STRICT_OBJC_MSGSEND = YES; 422 | GCC_C_LANGUAGE_STANDARD = gnu11; 423 | GCC_NO_COMMON_BLOCKS = YES; 424 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 425 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 426 | GCC_WARN_UNDECLARED_SELECTOR = YES; 427 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 428 | GCC_WARN_UNUSED_FUNCTION = YES; 429 | GCC_WARN_UNUSED_VARIABLE = YES; 430 | IPHONEOS_DEPLOYMENT_TARGET = 17.6; 431 | MTL_ENABLE_DEBUG_INFO = NO; 432 | MTL_FAST_MATH = YES; 433 | SDKROOT = iphoneos; 434 | SWIFT_COMPILATION_MODE = wholemodule; 435 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 436 | SWIFT_VERSION = 6.0; 437 | VALIDATE_PRODUCT = YES; 438 | }; 439 | name = Release; 440 | }; 441 | 356AFCDC22A617D900D472F0 /* Debug */ = { 442 | isa = XCBuildConfiguration; 443 | buildSettings = { 444 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 445 | CODE_SIGN_ENTITLEMENTS = sui_sample/sui_sample.entitlements; 446 | CODE_SIGN_IDENTITY = "Apple Development"; 447 | CODE_SIGN_STYLE = Automatic; 448 | DEVELOPMENT_ASSET_PATHS = "sui_sample/Preview\\ Content"; 449 | DEVELOPMENT_TEAM = 5JHB3Z4BRX; 450 | ENABLE_PREVIEWS = YES; 451 | INFOPLIST_FILE = sui_sample/Info.plist; 452 | LD_RUNPATH_SEARCH_PATHS = ( 453 | "$(inherited)", 454 | "@executable_path/Frameworks", 455 | ); 456 | PRODUCT_BUNDLE_IDENTIFIER = "com.github.naodroid.sui-sample"; 457 | PRODUCT_NAME = "$(TARGET_NAME)"; 458 | PROVISIONING_PROFILE_SPECIFIER = ""; 459 | TARGETED_DEVICE_FAMILY = "1,2"; 460 | }; 461 | name = Debug; 462 | }; 463 | 356AFCDD22A617D900D472F0 /* Release */ = { 464 | isa = XCBuildConfiguration; 465 | buildSettings = { 466 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 467 | CODE_SIGN_ENTITLEMENTS = sui_sample/sui_sample.entitlements; 468 | CODE_SIGN_IDENTITY = "Apple Development"; 469 | CODE_SIGN_STYLE = Automatic; 470 | DEVELOPMENT_ASSET_PATHS = "sui_sample/Preview\\ Content"; 471 | DEVELOPMENT_TEAM = 5JHB3Z4BRX; 472 | ENABLE_PREVIEWS = YES; 473 | INFOPLIST_FILE = sui_sample/Info.plist; 474 | LD_RUNPATH_SEARCH_PATHS = ( 475 | "$(inherited)", 476 | "@executable_path/Frameworks", 477 | ); 478 | PRODUCT_BUNDLE_IDENTIFIER = "com.github.naodroid.sui-sample"; 479 | PRODUCT_NAME = "$(TARGET_NAME)"; 480 | PROVISIONING_PROFILE_SPECIFIER = ""; 481 | TARGETED_DEVICE_FAMILY = "1,2"; 482 | }; 483 | name = Release; 484 | }; 485 | /* End XCBuildConfiguration section */ 486 | 487 | /* Begin XCConfigurationList section */ 488 | 356AFCC222A617D700D472F0 /* Build configuration list for PBXProject "sui_sample" */ = { 489 | isa = XCConfigurationList; 490 | buildConfigurations = ( 491 | 356AFCD922A617D900D472F0 /* Debug */, 492 | 356AFCDA22A617D900D472F0 /* Release */, 493 | ); 494 | defaultConfigurationIsVisible = 0; 495 | defaultConfigurationName = Release; 496 | }; 497 | 356AFCDB22A617D900D472F0 /* Build configuration list for PBXNativeTarget "sui_sample" */ = { 498 | isa = XCConfigurationList; 499 | buildConfigurations = ( 500 | 356AFCDC22A617D900D472F0 /* Debug */, 501 | 356AFCDD22A617D900D472F0 /* Release */, 502 | ); 503 | defaultConfigurationIsVisible = 0; 504 | defaultConfigurationName = Release; 505 | }; 506 | /* End XCConfigurationList section */ 507 | }; 508 | rootObject = 356AFCBF22A617D700D472F0 /* Project object */; 509 | } 510 | -------------------------------------------------------------------------------- /sui_sample.xcodeproj/xcshareddata/xcschemes/sui_sample.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /sui_sample/Actions/Fetch5DaysForecast.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FetchWeatherForecast.swift 3 | // sui_sample 4 | // 5 | // Created by nao on 2025/05/06. 6 | // Copyright © 2025 naodroid. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import CoreLocation 11 | import WeatherKit 12 | 13 | extension EnvironmentValues { 14 | @Entry var fetch5DaysForecast = Fetch5DaysForecast() 15 | } 16 | 17 | struct Fetch5DaysForecast { 18 | func callAsFunction(city: City) async throws -> [DayForecast] { 19 | try await self(lat: city.coord.lat, lon: city.coord.lon) 20 | } 21 | func callAsFunction(lat: Double, lon: Double) async throws -> [DayForecast] { 22 | let service = WeatherService() 23 | let loc = CLLocation(latitude: lat, longitude: lon) 24 | let cal = Calendar.current 25 | let startDate = Date() 26 | let endDate = cal.date(byAdding: .day, value: 5, to: startDate)! 27 | // fetch daily 28 | let query1 = WeatherQuery.daily(startDate: startDate, endDate: endDate) 29 | let daily = try await service.weather(for: loc, including: query1) 30 | // fetch hourly 31 | let query2 = WeatherQuery.hourly(startDate: startDate, endDate: endDate) 32 | let hourly = try await service.weather(for: loc, including: query2) 33 | 34 | // combine daily with hourly 35 | var results = daily.map { 36 | DayForecast(forecast: $0) 37 | } 38 | for h1 in hourly { 39 | let h2 = HourForecast(forecast: h1) 40 | let idx = results.firstIndex { 41 | $0.yyyymmdd == h2.yyyymmdd 42 | } 43 | if let idx { 44 | results[idx].hourly.append(h2) 45 | } 46 | } 47 | return results 48 | } 49 | } 50 | 51 | -------------------------------------------------------------------------------- /sui_sample/Actions/FetchCurrentLocation.swift: -------------------------------------------------------------------------------- 1 | @preconcurrency import CoreLocation 2 | // 3 | // LocationActions.swift 4 | // sui_sample 5 | // 6 | // Created by nao on 2025/05/06. 7 | // Copyright © 2025 naodroid. All rights reserved. 8 | // 9 | import Foundation 10 | import SwiftUI 11 | 12 | /// define errors 13 | enum LocationError: Error { 14 | case serviceNotAvailable 15 | case permissionDenied 16 | case canceled 17 | } 18 | typealias LocationContinuation = CheckedContinuation 19 | 20 | extension EnvironmentValues { 21 | @Entry var fetchCurrentLocation = FetchCurrentLocation() 22 | } 23 | /// fetch gps location 24 | @MainActor 25 | final class FetchCurrentLocation { 26 | private var handler: LocationHandler? 27 | 28 | nonisolated init() { 29 | } 30 | func callAsFunction() async throws -> CLLocation { 31 | let handler = self.handler ?? LocationHandler() 32 | self.handler = handler 33 | return try await handler.fetchCurrentLocation() 34 | } 35 | } 36 | 37 | /// Event Handling class, 38 | /// CLLocation requires NSObject 39 | @MainActor 40 | private class LocationHandler: NSObject, CLLocationManagerDelegate { 41 | //other 42 | private var locationManager: CLLocationManager? 43 | private var continuation: LocationContinuation? 44 | // 45 | func fetchCurrentLocation() async throws -> CLLocation { 46 | let mn = self.locationManager ?? CLLocationManager() 47 | self.locationManager = mn 48 | mn.delegate = self 49 | if let c = self.continuation { 50 | c.resume(throwing: LocationError.canceled) 51 | self.continuation = nil 52 | } 53 | return try await withCheckedThrowingContinuation { 54 | (continuation: LocationContinuation) in 55 | self.continuation = continuation 56 | mn.requestLocation() 57 | } 58 | } 59 | 60 | func cancel() { 61 | self.locationManager?.stopUpdatingLocation() 62 | self.locationManager = nil 63 | if let c = self.continuation { 64 | c.resume(throwing: LocationError.canceled) 65 | self.continuation = nil 66 | } 67 | } 68 | 69 | // MARK: Delegate 70 | nonisolated func locationManager( 71 | _ manager: CLLocationManager, 72 | didChangeAuthorization status: CLAuthorizationStatus 73 | ) { 74 | Task { 75 | await didChangeAuthorization(status) 76 | } 77 | } 78 | private func didChangeAuthorization(_ status: CLAuthorizationStatus) { 79 | switch status { 80 | case .notDetermined: 81 | //If using a LocationButton, this code will not run. 82 | self.locationManager?.requestWhenInUseAuthorization() 83 | case .restricted, .denied: 84 | self.continuation?.resume(throwing: LocationError.permissionDenied) 85 | self.continuation = nil 86 | case .authorizedAlways, .authorizedWhenInUse: 87 | break 88 | @unknown default: 89 | break 90 | } 91 | } 92 | nonisolated func locationManager( 93 | _ manager: CLLocationManager, didUpdateLocations locations: [CLLocation] 94 | ) { 95 | Task { 96 | await didUpdateLocations(locations) 97 | } 98 | } 99 | private func didUpdateLocations(_ locations: [CLLocation]) { 100 | guard let location = locations.first else { 101 | return 102 | } 103 | self.continuation?.resume(returning: location) 104 | self.continuation = nil 105 | } 106 | nonisolated func locationManager( 107 | _ manager: CLLocationManager, didFailWithError error: Error 108 | ) { 109 | Task { 110 | await didFailWithError(error) 111 | } 112 | } 113 | private func didFailWithError(_ error: Error) { 114 | self.continuation?.resume(throwing: error) 115 | self.continuation = nil 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /sui_sample/Actions/FilterCities.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FilterCities.swift 3 | // sui_sample 4 | // 5 | // Created by nao on 2025/05/06. 6 | // Copyright © 2025 naodroid. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | extension EnvironmentValues { 12 | @Entry var filterCities = FilterCities() 13 | } 14 | 15 | struct FilterCities { 16 | func callAsFunction( 17 | keyword: String, 18 | allCities: [City] 19 | ) async -> [City] { 20 | let k = keyword.lowercased().trimmingCharacters( 21 | in: .whitespacesAndNewlines) 22 | let result = allCities.filter { (c) -> Bool in 23 | return keyword.isEmpty || c.name.lowercased().contains(k) 24 | } 25 | let limited = 26 | (result.count >= 100) 27 | ? Array(result[..<100]) 28 | : result 29 | return limited 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /sui_sample/Actions/ReadCitiesFromFile.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ReadCitiesFromFile.swift 3 | // sui_sample 4 | // 5 | // Created by nao on 2025/05/06. 6 | // Copyright © 2025 naodroid. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | /// define errors 12 | enum CityListError: Error { 13 | case resourceNotFount 14 | case parseError 15 | } 16 | /// ext 17 | extension EnvironmentValues { 18 | @Entry var readCitiesFromFile = ReadCitiesFromFile() 19 | } 20 | 21 | /// read city names from resource the json file 22 | struct ReadCitiesFromFile { 23 | /// read all cities from json 24 | func callAsFunction() async throws -> [City] { 25 | guard let path = Bundle.main.path(forResource: "city_list.json", ofType: nil), 26 | let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { 27 | print("Json read error. Please put city_list.json to asset folder.") 28 | throw CityListError.resourceNotFount 29 | } 30 | do { 31 | let cities = try JSONDecoder().decode([City].self, from: data) 32 | return cities 33 | } catch { 34 | throw CityListError.parseError 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /sui_sample/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "size" : "20x20", 6 | "scale" : "2x" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "size" : "20x20", 11 | "scale" : "3x" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "size" : "29x29", 16 | "scale" : "2x" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "size" : "29x29", 21 | "scale" : "3x" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "size" : "40x40", 26 | "scale" : "2x" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "size" : "40x40", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "size" : "60x60", 36 | "scale" : "2x" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "size" : "60x60", 41 | "scale" : "3x" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "size" : "20x20", 46 | "scale" : "1x" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "size" : "20x20", 51 | "scale" : "2x" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "size" : "29x29", 56 | "scale" : "1x" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "size" : "29x29", 61 | "scale" : "2x" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "size" : "40x40", 66 | "scale" : "1x" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "size" : "40x40", 71 | "scale" : "2x" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "size" : "76x76", 76 | "scale" : "1x" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "size" : "76x76", 81 | "scale" : "2x" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "size" : "83.5x83.5", 86 | "scale" : "2x" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "size" : "1024x1024", 91 | "scale" : "1x" 92 | } 93 | ], 94 | "info" : { 95 | "version" : 1, 96 | "author" : "xcode" 97 | } 98 | } -------------------------------------------------------------------------------- /sui_sample/Assets.xcassets/Colors/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /sui_sample/Assets.xcassets/Colors/tabBgColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | }, 6 | "colors" : [ 7 | { 8 | "idiom" : "universal", 9 | "color" : { 10 | "color-space" : "srgb", 11 | "components" : { 12 | "red" : "0.678", 13 | "alpha" : "1.000", 14 | "blue" : "1.000", 15 | "green" : "0.933" 16 | } 17 | } 18 | }, 19 | { 20 | "idiom" : "universal", 21 | "appearances" : [ 22 | { 23 | "appearance" : "luminosity", 24 | "value" : "dark" 25 | } 26 | ], 27 | "color" : { 28 | "color-space" : "srgb", 29 | "components" : { 30 | "red" : "0.239", 31 | "alpha" : "1.000", 32 | "blue" : "0.544", 33 | "green" : "0.500" 34 | } 35 | } 36 | } 37 | ] 38 | } -------------------------------------------------------------------------------- /sui_sample/Assets.xcassets/Colors/tabFrameColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | }, 6 | "colors" : [ 7 | { 8 | "idiom" : "universal", 9 | "color" : { 10 | "color-space" : "srgb", 11 | "components" : { 12 | "red" : "0.374", 13 | "alpha" : "1.000", 14 | "blue" : "1.000", 15 | "green" : "0.843" 16 | } 17 | } 18 | }, 19 | { 20 | "idiom" : "universal", 21 | "appearances" : [ 22 | { 23 | "appearance" : "luminosity", 24 | "value" : "dark" 25 | } 26 | ], 27 | "color" : { 28 | "color-space" : "srgb", 29 | "components" : { 30 | "red" : "0.082", 31 | "alpha" : "1.000", 32 | "blue" : "0.411", 33 | "green" : "0.411" 34 | } 35 | } 36 | } 37 | ] 38 | } -------------------------------------------------------------------------------- /sui_sample/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /sui_sample/Assets.xcassets/blank.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "blank.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "version" : 1, 19 | "author" : "xcode" 20 | } 21 | } -------------------------------------------------------------------------------- /sui_sample/Assets.xcassets/blank.imageset/blank.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naodroid/swiftui_weather/28df70549e8808c85631f02262d48aa2f4dba1a6/sui_sample/Assets.xcassets/blank.imageset/blank.png -------------------------------------------------------------------------------- /sui_sample/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /sui_sample/ColorTheme.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColorTheme.swift 3 | // sui_sample 4 | // 5 | // Created by naodroid on 2019/06/07. 6 | // Copyright © 2019 naodroid. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import SwiftUI 11 | import Combine 12 | 13 | // TO dispatch dark-mode-echanging, create ownVC and dispatch event through Observer 14 | // I haven't found other methods to do same thing. 15 | class TraitObservingViewController: UIHostingController where T: View { 16 | var theme: ColorTheme? 17 | 18 | override func viewDidLoad() { 19 | super.viewDidLoad() 20 | theme?.currentTrait = self.traitCollection 21 | } 22 | 23 | override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { 24 | self.theme?.currentTrait = self.traitCollection 25 | } 26 | } 27 | 28 | extension EnvironmentValues { 29 | @Entry var colorTheme = ColorTheme() 30 | } 31 | 32 | 33 | 34 | @MainActor 35 | @Observable 36 | final class ColorTheme { 37 | let didChange = PassthroughSubject() 38 | fileprivate var currentTrait: UITraitCollection? { 39 | didSet { 40 | self.didChange.send(self) 41 | } 42 | } 43 | 44 | nonisolated init() { 45 | } 46 | 47 | private func resolve(_ color: UIColor) -> Color { 48 | guard let t = self.currentTrait else { 49 | return color.toSwiftUI() 50 | } 51 | return color.resolvedColor(with: t).toSwiftUI() 52 | } 53 | 54 | 55 | /// this color works when you switch dark-mode. 56 | /// but, the color will be reset to light-color after chaning tab 57 | /// .colorScheme(.dark) also won't work. so I created resolve function 58 | var tabBgColor: Color { 59 | self.resolve(UIColor(named: "tabBgColor")!) 60 | } 61 | var tabFrameColor: Color { 62 | self.resolve(UIColor(named: "tabFrameColor")!) 63 | } 64 | var foregronud: Color { 65 | self.resolve(UIColor.label) 66 | } 67 | var foregronudLight: Color { 68 | self.resolve(UIColor.secondaryLabel) 69 | } 70 | var foregronudReversed: Color { 71 | self.resolve(UIColor.tertiaryLabel) 72 | } 73 | var background: Color { 74 | self.resolve(UIColor.systemBackground) 75 | } 76 | } 77 | //convert to SwiftUI-Color 78 | extension UIColor { 79 | func toSwiftUI() -> Color { 80 | var r = CGFloat(1.0) 81 | var g = CGFloat(1.0) 82 | var b = CGFloat(1.0) 83 | var a = CGFloat(1.0) 84 | self.getRed(&r, green: &g, blue: &b, alpha: &a) 85 | 86 | //TODO: set correct ColorSpace 87 | return Color(Color.RGBColorSpace.sRGB, 88 | red: Double(r), 89 | green: Double(g), 90 | blue: Double(b), 91 | opacity: Double(a)) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /sui_sample/CommonView/MapView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MapView.swift 3 | // sui_sample 4 | // 5 | // Created by naodroid on 2019/06/06. 6 | // Copyright © 2019 naodroid. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import MapKit 11 | 12 | @MainActor 13 | struct MapView: UIViewRepresentable { 14 | 15 | let loc: CLLocation? 16 | let pin: CLLocationCoordinate2D? 17 | let onTap: ((CLLocationCoordinate2D) -> Void)? 18 | 19 | func makeCoordinator() -> Coordinator { 20 | Coordinator(self) 21 | } 22 | 23 | func makeUIView(context: Context) -> MKMapView { 24 | let map = MKMapView(frame: .zero) 25 | context.coordinator.addGesture(map: map) 26 | map.isZoomEnabled = true 27 | return map 28 | } 29 | 30 | func updateUIView(_ view: MKMapView, context: Context) { 31 | 32 | let coordinator = context.coordinator 33 | if let loc = self.loc, loc != coordinator.lastCenterLocation { 34 | let coordinate = loc.coordinate 35 | let span = MKCoordinateSpan(latitudeDelta: 0.5, longitudeDelta: 0.5) 36 | let region = MKCoordinateRegion(center: coordinate, span: span) 37 | view.setRegion(region, animated: true) 38 | } 39 | coordinator.lastCenterLocation = self.loc 40 | // 41 | if let loc = self.pin, loc != coordinator.lastPinLocation { 42 | for a in view.annotations { 43 | view.removeAnnotation(a) 44 | } 45 | 46 | let annotation = MKPointAnnotation() 47 | annotation.coordinate = loc 48 | view.addAnnotation(annotation) 49 | } 50 | coordinator.lastPinLocation = self.pin 51 | } 52 | @MainActor 53 | class Coordinator: NSObject { 54 | var parent: MapView 55 | var lastCenterLocation: CLLocation? = nil 56 | var lastPinLocation: CLLocationCoordinate2D? = nil 57 | 58 | init(_ parent: MapView) { 59 | self.parent = parent 60 | } 61 | func addGesture(map: MKMapView) { 62 | let gesture = UITapGestureRecognizer(target: self, action: #selector(didTapMap(_:))) 63 | map.addGestureRecognizer(gesture) 64 | } 65 | @objc func didTapMap(_ sender: UITapGestureRecognizer) { 66 | guard let map = sender.view as? MKMapView else { 67 | return 68 | } 69 | let point = sender.location(in: map) 70 | let pos = map.convert(point, toCoordinateFrom: map) 71 | self.parent.onTap?(pos) 72 | } 73 | } 74 | } 75 | extension CLLocationCoordinate2D: @retroactive Equatable { 76 | public static func ==(lhs: CLLocationCoordinate2D, rhs: CLLocationCoordinate2D) -> Bool { 77 | lhs.latitude == rhs.latitude 78 | && lhs.longitude == rhs.longitude 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /sui_sample/CommonView/TabButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TabButton.swift 3 | // sui_sample 4 | // 5 | // Created by naodroid on 2019/06/06. 6 | // Copyright © 2019 naodroid. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | 12 | struct TabButton: View { 13 | // dependencies 14 | @Environment(\.colorTheme) var colorTheme 15 | // params 16 | let text: String 17 | let selected: Bool 18 | let action: () -> Void 19 | 20 | private var fillColorTop: Color { 21 | selected ? colorTheme.tabFrameColor : colorTheme.background 22 | } 23 | private var fillColorBottom: Color { 24 | selected ? colorTheme.tabBgColor : colorTheme.background 25 | } 26 | private var textColor: Color { 27 | selected ? colorTheme.foregronud : colorTheme.foregronudLight 28 | } 29 | 30 | var body: some View { 31 | Button(action: action) { 32 | Text(text) 33 | .foregroundColor(colorTheme.foregronud) 34 | .background { 35 | RoundedBg( 36 | strokeColor: colorTheme.tabFrameColor, 37 | fillColorTop: fillColorTop, 38 | fillColorBottom: fillColorBottom 39 | ) 40 | .colorScheme(.light) 41 | } 42 | .frame(height: 36.0) 43 | } 44 | } 45 | } 46 | 47 | struct RoundedBg: View { 48 | // params 49 | let strokeColor: Color 50 | let fillColorTop: Color 51 | let fillColorBottom: Color 52 | private let cornerRadius: CGFloat = 8.0 53 | 54 | var body: some View { 55 | GeometryReader { geometory in 56 | ZStack { 57 | self.createPath(for: geometory).fill( 58 | LinearGradient( 59 | gradient: .init(colors: [ 60 | self.fillColorTop, self.fillColorBottom, 61 | ]), 62 | startPoint: .init(x: 0.5, y: 0), 63 | endPoint: .init(x: 0.5, y: 1.0) 64 | )) 65 | self.createPath(for: geometory).stroke(self.strokeColor) 66 | } 67 | } 68 | } 69 | private func createPath(for geometory: GeometryProxy) -> Path { 70 | Path { path in 71 | path.move( 72 | to: CGPoint( 73 | x: 0, 74 | y: geometory.size.height 75 | )) 76 | path.addLine( 77 | to: CGPoint( 78 | x: 0, 79 | y: self.cornerRadius 80 | )) 81 | path.addQuadCurve( 82 | to: CGPoint( 83 | x: self.cornerRadius, 84 | y: 0 85 | ), 86 | control: CGPoint( 87 | x: 0, 88 | y: 0 89 | ) 90 | ) 91 | path.addLine( 92 | to: CGPoint( 93 | x: geometory.size.width - self.cornerRadius, 94 | y: 0 95 | )) 96 | path.addQuadCurve( 97 | to: CGPoint( 98 | x: geometory.size.width, 99 | y: self.cornerRadius 100 | ), 101 | control: CGPoint( 102 | x: geometory.size.width, 103 | y: 0 104 | ) 105 | ) 106 | path.addLine( 107 | to: CGPoint( 108 | x: geometory.size.width, 109 | y: geometory.size.height 110 | )) 111 | } 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /sui_sample/Config-sample.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | OpenWeatherApiKey 6 | WriteYourKey 7 | 8 | 9 | -------------------------------------------------------------------------------- /sui_sample/Entity/City.swift: -------------------------------------------------------------------------------- 1 | // 2 | // City.swift 3 | // sui_sample 4 | // 5 | // Created by naodroid on 2019/06/06. 6 | // Copyright © 2019 naodroid. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | 12 | /// open weather city data 13 | /// sample json 14 | /// [{"country": "UA", "id": 707860, "coord": {"lat": 44.549999, "lon": 34.283333}, "name": "Hurzuf"}] 15 | 16 | 17 | struct City: Codable, Equatable, Identifiable, Hashable { 18 | let id: Int64 19 | let name: String 20 | let country: String 21 | let coord: Coord 22 | } 23 | struct Coord: Codable, Equatable, Hashable { 24 | let lat: Double 25 | let lon: Double 26 | } 27 | -------------------------------------------------------------------------------- /sui_sample/Entity/Weather.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Weather.swift 3 | // sui_sample 4 | // 5 | // Created by nadroid on 2019/06/04. 6 | // Copyright © 2019 naodroid. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | @preconcurrency import WeatherKit 11 | 12 | /// wrap weatherkit's struct to confirm identifiable & Sendable 13 | struct DayForecast: Identifiable, Sendable { 14 | var id: String { yyyymmdd } 15 | let yyyymmdd: String //YYYYMMDD 16 | let forecast: DayWeather 17 | var hourly: [HourForecast] = [] 18 | 19 | init(forecast: DayWeather) { 20 | self.yyyymmdd = forecast.date.yyyymmdd 21 | self.forecast = forecast 22 | } 23 | } 24 | 25 | struct HourForecast: Identifiable, Sendable { 26 | let id: String 27 | let yyyymmdd: String 28 | let hhmm: String 29 | let forecast: HourWeather 30 | init(forecast: HourWeather) { 31 | self.id = "\(forecast.date)" 32 | self.yyyymmdd = forecast.date.yyyymmdd 33 | self.hhmm = forecast.date.hhmm 34 | self.forecast = forecast 35 | } 36 | } 37 | 38 | private extension Date { 39 | var yyyymmdd: String { 40 | let formatter = DateFormatter() 41 | formatter.dateFormat = "YYYYMMDD" 42 | return formatter.string(from: self) 43 | } 44 | var hhmm: String { 45 | let formatter = DateFormatter() 46 | formatter.dateFormat = "HH:mm" 47 | return formatter.string(from: self) 48 | } 49 | } 50 | 51 | 52 | -------------------------------------------------------------------------------- /sui_sample/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | NSAppTransportSecurity 24 | 25 | NSAllowsArbitraryLoads 26 | 27 | 28 | NSLocationWhenInUseUsageDescription 29 | Get your location for weather forecast 30 | UIApplicationSceneManifest 31 | 32 | UIApplicationSupportsMultipleScenes 33 | 34 | 35 | UILaunchStoryboardName 36 | LaunchScreen 37 | UIRequiredDeviceCapabilities 38 | 39 | armv7 40 | 41 | UISupportedInterfaceOrientations 42 | 43 | UIInterfaceOrientationPortrait 44 | UIInterfaceOrientationLandscapeLeft 45 | UIInterfaceOrientationLandscapeRight 46 | 47 | UISupportedInterfaceOrientations~ipad 48 | 49 | UIInterfaceOrientationPortrait 50 | UIInterfaceOrientationPortraitUpsideDown 51 | UIInterfaceOrientationLandscapeLeft 52 | UIInterfaceOrientationLandscapeRight 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /sui_sample/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /sui_sample/RootView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RootView.swift 3 | // sui_sample 4 | // 5 | // Created by naodroid on 2019/06/06. 6 | // Copyright © 2019 naodroid. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | 12 | struct RootView: View { 13 | // stores 14 | @State var pageStore = PageStore() 15 | // actions 16 | @State var fetchCurrentLocation = FetchCurrentLocation() 17 | @State var readCitiesFromFile = ReadCitiesFromFile() 18 | @State var filterCities = FilterCities() 19 | @State var fetch5DaysForecast = Fetch5DaysForecast() 20 | 21 | var body: some View { 22 | NavigationStack(path: $pageStore.pages) { 23 | CitySelectionView() 24 | .navigationDestination(for: Page.self) { page in 25 | switch page { 26 | case .cityList: CitySelectionView() 27 | case .forecast(let searchType): 28 | ForecastView(searchType: searchType) 29 | } 30 | } 31 | } 32 | .environment(pageStore) 33 | .environment(\.fetchCurrentLocation, fetchCurrentLocation) 34 | .environment(\.readCitiesFromFile, readCitiesFromFile) 35 | .environment(\.filterCities, filterCities) 36 | .environment(\.fetch5DaysForecast, fetch5DaysForecast) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /sui_sample/Stores/PageStore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PageStore.swift 3 | // sui_sample 4 | // 5 | // Created by nao on 2025/05/06. 6 | // Copyright © 2025 naodroid. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | enum Page: Sendable, Hashable { 12 | case cityList 13 | case forecast(searchType: WeatherSearchType) 14 | } 15 | 16 | @MainActor 17 | @Observable 18 | final class PageStore { 19 | var pages: [Page] = [] 20 | 21 | func push(_ page: Page) { 22 | self.pages.append(page) 23 | } 24 | func pop() { 25 | self.pages.removeLast() 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /sui_sample/Utils/Async+Ext.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Async+Ext.swift 3 | // sui_sample 4 | // 5 | // Created by 坂本尚嗣 on 2021/07/17. 6 | // Copyright © 2021 naodroid. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension Task { 12 | /// Currently Task.sleep(_:) causes crashes, so created own method to avoid this issue. 13 | /// https://bugs.swift.org/browse/SR-14375 14 | static func sleep(forSeconds seconds: TimeInterval) async { 15 | await AsyncSleeper().sleep(forSeconds: seconds) 16 | } 17 | } 18 | 19 | 20 | private struct AsyncSleeper { 21 | func sleep(forSeconds seconds: TimeInterval) async { 22 | typealias SleepContinuation = CheckedContinuation 23 | do { 24 | try await withCheckedThrowingContinuation { (continuation: SleepContinuation) in 25 | DispatchQueue.global().asyncAfter(deadline: .now() + seconds) { 26 | continuation.resume(with: .success(())) 27 | } 28 | } 29 | } catch { 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /sui_sample/Utils/ResourceFunctions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ResourceFunctions.swift 3 | // sui_sample 4 | // 5 | // Created by naodroid on 2019/06/07. 6 | // Copyright © 2019 naodroid. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | 12 | enum ResoruceError : Error { 13 | case invalidURL 14 | case jsonParseError(Error) 15 | case connectionError(Error) 16 | case unknownError 17 | } 18 | 19 | /// Mainily for debug usage, 20 | /// use local resource instead of network access 21 | /// to avoid API limit. 22 | func resourceRequest(fileName: String) async throws -> Data { 23 | guard let path = Bundle.main.path(forResource: fileName, ofType: nil) else { 24 | throw ResoruceError.invalidURL 25 | } 26 | await Task.sleep(forSeconds: 1.0) 27 | let url = URL(fileURLWithPath: path) 28 | guard let data = try? Data(contentsOf: url) else { 29 | throw ResoruceError.invalidURL 30 | } 31 | return data 32 | } 33 | 34 | func resourceRequestJson(fileName: String) async throws -> T { 35 | let data = try await resourceRequest(fileName: fileName) 36 | let decoder = JSONDecoder() 37 | decoder.keyDecodingStrategy = .convertFromSnakeCase 38 | return try! JSONDecoder().decode(T.self, from: data) 39 | } 40 | -------------------------------------------------------------------------------- /sui_sample/Utils/SwiftUI+Ext.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftUI+Ext.swift 3 | // sui_sample 4 | // 5 | // Created by nadroid on 2019/06/05. 6 | // Copyright © 2019 naodroid. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | 12 | extension View { 13 | func maximumWidth() -> some View { 14 | self.frame(minWidth: 0, idealWidth: 12000, maxWidth: 12000) 15 | } 16 | 17 | func maximumHeight() -> some View { 18 | self.frame(minHeight: 0, idealHeight: 12000, maxHeight: 12000) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /sui_sample/View/CityListView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CityListView.swift 3 | // sui_sample 4 | // 5 | // Created by nao on 2025/05/06. 6 | // Copyright © 2025 naodroid. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct CityListView: View { 12 | // dependescies 13 | @Environment(\.colorTheme) var colorTheme 14 | @Environment(\.readCitiesFromFile) var readCitiesFromFile 15 | @Environment(\.filterCities) var filterCities 16 | 17 | // states 18 | /// cities not filtered 19 | @State private var allCities: [City] = [] 20 | /// filtered city 21 | @State private var cities: [City] = [] 22 | /// search text 23 | @State private var text = "" 24 | /// store tasks for cancelling 25 | @State private var lastSearchTask: Task? 26 | 27 | /// body 28 | var body: some View { 29 | _body 30 | .onAppear { 31 | setup() 32 | } 33 | .onChange(of: text) { _, _ in 34 | filter() 35 | } 36 | } 37 | } 38 | // MARK: views 39 | private extension CityListView { 40 | @ViewBuilder 41 | private var _body: some View { 42 | VStack(spacing: 0) { 43 | textField 44 | List( 45 | cities, 46 | rowContent: { city in 47 | CityRow(city: city) 48 | } 49 | ) 50 | } 51 | } 52 | @ViewBuilder 53 | private var textField: some View { 54 | TextField( 55 | text: $text, 56 | label: { 57 | Text("City Name") 58 | } 59 | ) 60 | .padding(.horizontal, 8) 61 | .background(colorTheme.background) 62 | .cornerRadius(4.0) 63 | .padding(.vertical, 8) 64 | .padding(.horizontal, 16) 65 | .background(colorTheme.tabBgColor) 66 | } 67 | } 68 | // MARK: actions 69 | private extension CityListView { 70 | func setup() { 71 | Task { 72 | do { 73 | allCities = try await readCitiesFromFile() 74 | filter() 75 | } catch { 76 | print(error) 77 | } 78 | } 79 | } 80 | func filter() { 81 | lastSearchTask?.cancel() 82 | lastSearchTask = Task { 83 | do { 84 | // throttle 85 | try await Task.sleep(nanoseconds: 300 * 1000 * 1000) 86 | self.cities = await filterCities( 87 | keyword: text, 88 | allCities: allCities 89 | ) 90 | } catch { 91 | print(error) 92 | } 93 | } 94 | } 95 | } 96 | 97 | // MARK: List item 98 | private struct CityRow: View { 99 | // dependencies 100 | @Environment(PageStore.self) var pageStore 101 | // params 102 | let city: City 103 | // body 104 | var body: some View { 105 | HStack { 106 | Text(city.name + "(" + city.country + ")") 107 | .lineLimit(2) 108 | .layoutPriority(1) 109 | Spacer() 110 | .layoutPriority(0) 111 | } 112 | .contentShape(Rectangle()) 113 | .onTapGesture { 114 | let searchType: WeatherSearchType = .city(city) 115 | pageStore.push( 116 | .forecast(searchType: searchType) 117 | ) 118 | } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /sui_sample/View/CitySelectionView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CitySelectView.swift 3 | // sui_sample 4 | // 5 | // Created by naodroid on 2019/06/06. 6 | // Copyright © 2019 naodroid. All rights reserved. 7 | // 8 | 9 | import CoreLocation 10 | import CoreLocationUI 11 | import Foundation 12 | import SwiftUI 13 | 14 | 15 | struct CitySelectionView: View { 16 | // dependencies 17 | @Environment(\.colorTheme) var colorTheme 18 | // states 19 | @State var selectedTab: Int = 0 20 | @State var cities: [City] = [] 21 | @State var locating = false 22 | @State var location: CLLocation? = nil 23 | @State var pin: CLLocationCoordinate2D? = nil 24 | @State var text: String = "" 25 | 26 | private var lat: Double { pin?.latitude ?? 0 } 27 | private var lon: Double { pin?.longitude ?? 0 } 28 | 29 | //------------------------ 30 | var body: some View { 31 | TabView { 32 | CityListView() 33 | .tag(0) 34 | .toolbarBackground(.visible, for: .tabBar) 35 | .tabItem { 36 | Label("List", systemImage: "list.bullet") 37 | } 38 | LocationSelectionView() 39 | .tag(1) 40 | .toolbarBackground(.visible, for: .tabBar) 41 | .tabItem { 42 | Label("Map", systemImage: "mappin.and.ellipse") 43 | } 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /sui_sample/View/ForecastView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Weather5DayView.swift 3 | // sui_sample 4 | // 5 | // Created by naodroid on 2019/06/07. 6 | // Copyright © 2019 naodroid. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | import WeatherKit 12 | 13 | enum WeatherSearchType: Hashable, Sendable { 14 | case city(City) 15 | case location(lat: Double, lon: Double) 16 | } 17 | 18 | struct ForecastView: View { 19 | // dependescies 20 | @Environment(\.colorTheme) var colorTheme 21 | @Environment(\.fetch5DaysForecast) var fetch5DaysForecast 22 | // params 23 | let searchType: WeatherSearchType 24 | // states 25 | @State private var forecasts: [DayForecast] = [] 26 | private var hasForecast: Bool { !forecasts.isEmpty } 27 | 28 | var navigationTitle: String { 29 | switch searchType { 30 | case .city(let city): return city.name 31 | case .location(let lat, let lon): 32 | return String.init(format: "%.3f,%.3f", lat, lon) 33 | } 34 | } 35 | 36 | var body: some View { 37 | _body 38 | .navigationBarTitle( 39 | Text(self.navigationTitle), 40 | displayMode: .large 41 | ) 42 | .onAppear { 43 | fetch() 44 | } 45 | } 46 | } 47 | // MARK: views 48 | extension ForecastView { 49 | @ViewBuilder 50 | fileprivate var _body: some View { 51 | if forecasts.isEmpty { 52 | Text("Loading") 53 | } else { 54 | List( 55 | forecasts, 56 | rowContent: { (item) in 57 | ForecastRow(item: item) 58 | }) 59 | } 60 | } 61 | } 62 | // MARK: actions 63 | extension ForecastView { 64 | private func fetch() { 65 | Task { 66 | do { 67 | switch searchType { 68 | case .city(let city): 69 | forecasts = try await fetch5DaysForecast(city: city) 70 | case .location(let lat, let lon): 71 | forecasts = try await fetch5DaysForecast(lat: lat, lon: lon) 72 | 73 | } 74 | } catch { 75 | print(error) 76 | } 77 | } 78 | } 79 | } 80 | // MARK: list item 81 | private struct ForecastRow: View { 82 | @Environment(\.colorTheme) var colorTheme 83 | // params 84 | let item: DayForecast 85 | 86 | var list: [HourForecast] { 87 | return item.hourly 88 | } 89 | 90 | var body: some View { 91 | VStack(alignment: .leading, spacing: 4) { 92 | Text(item.forecast.date.formatted()) 93 | .padding(.horizontal, 8) 94 | ScrollView( 95 | Axis.Set.horizontal, 96 | showsIndicators: false 97 | ) { 98 | HStack { 99 | ForEach(list, id: \.id) { (item) in 100 | VStack { 101 | Image(systemName: item.forecast.symbolName) 102 | Text(item.hhmm) 103 | .font(.footnote) 104 | } 105 | .frame(width: CGFloat(60)) 106 | } 107 | } 108 | } 109 | } 110 | .padding(.all, 4) 111 | .frame(height: 120) 112 | .background(colorTheme.background) 113 | .shadow(radius: 3) 114 | .padding(.all, 4) 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /sui_sample/View/LocationSelectionView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocationSelectionView.swift 3 | // sui_sample 4 | // 5 | // Created by nao on 2025/05/06. 6 | // Copyright © 2025 naodroid. All rights reserved. 7 | // 8 | 9 | @preconcurrency import CoreLocation 10 | import CoreLocationUI 11 | import Foundation 12 | import SwiftUI 13 | 14 | struct LocationSelectionView: View { 15 | // dependencies 16 | @Environment(PageStore.self) var pageStore 17 | @Environment(\.colorTheme) var colorTheme 18 | @Environment(\.fetchCurrentLocation) var fetchCurrentLocation 19 | // states 20 | @State private var location: CLLocation? 21 | @State private var pin: CLLocationCoordinate2D? = nil 22 | @State private var locating = false 23 | // body 24 | var body: some View { 25 | _body 26 | } 27 | } 28 | 29 | // MARK: views 30 | extension LocationSelectionView { 31 | @ViewBuilder 32 | private var _body: some View { 33 | ZStack { 34 | MapView( 35 | loc: location, 36 | pin: pin, 37 | onTap: { (pos) in 38 | pin = pos 39 | } 40 | ) 41 | VStack { 42 | Text("Tap to select place") 43 | Spacer().layoutPriority(1) 44 | locationButton 45 | nextButton 46 | Spacer().frame(height: 20) 47 | } 48 | progress 49 | } 50 | } 51 | @ViewBuilder 52 | private var locationButton: some View { 53 | HStack { 54 | Spacer() 55 | LocationButton(.currentLocation) { 56 | fetchLocation() 57 | }.labelStyle(.titleOnly) 58 | .foregroundColor(Color.white) 59 | .symbolVariant(.fill) 60 | .cornerRadius(8) 61 | .disabled(locating) 62 | Spacer().frame(width: 8) 63 | } 64 | } 65 | @ViewBuilder 66 | private var nextButton: some View { 67 | Button { 68 | if let location { 69 | let searchType = WeatherSearchType.location( 70 | lat: location.coordinate.latitude, 71 | lon: location.coordinate.longitude 72 | ) 73 | pageStore.push( 74 | .forecast(searchType: searchType) 75 | ) 76 | } 77 | } label: { 78 | Text("Use Center") 79 | .foregroundColor(Color.white) 80 | .padding(.horizontal, 16) 81 | .padding(.vertical, 8) 82 | .background(Color.blue) 83 | .clipShape(RoundedRectangle(cornerRadius: 8)) 84 | .disabled(pin == nil) 85 | } 86 | } 87 | 88 | @ViewBuilder 89 | private var progress: some View { 90 | if locating { 91 | ProgressView("Locating...") 92 | .progressViewStyle( 93 | CircularProgressViewStyle(tint: Color.white) 94 | ) 95 | .foregroundColor(Color.white) 96 | .frame( 97 | width: 120, 98 | height: 120, 99 | alignment: .center 100 | ) 101 | .background(Color.black.opacity(0.5)) 102 | .cornerRadius(16) 103 | } 104 | } 105 | } 106 | // MARK: actions 107 | extension LocationSelectionView { 108 | private func fetchLocation() { 109 | Task { 110 | locating = true 111 | defer { 112 | locating = false 113 | } 114 | do { 115 | location = try await fetchCurrentLocation() 116 | } catch { 117 | } 118 | } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /sui_sample/WeatherApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WeatherApp.swift 3 | // WeatherApp 4 | // 5 | // Created by nao on 2021/07/23. 6 | // Copyright © 2021 naodroid. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | 12 | @main 13 | struct WeatherApp: App { 14 | var body: some Scene { 15 | let theme = ColorTheme() 16 | let contentView = RootView() 17 | .environment(\.colorTheme, theme) 18 | let root = TraitObservingViewController(rootView: contentView) 19 | root.theme = theme 20 | return WindowGroup { 21 | contentView 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /sui_sample/asset/5day_sample.json: -------------------------------------------------------------------------------- 1 | { 2 | "cod": "200", 3 | "message": 0.0032, 4 | "cnt": 36, 5 | "list": [ 6 | { 7 | "dt": 1487246400, 8 | "main": { 9 | "temp": 286.67, 10 | "temp_min": 281.556, 11 | "temp_max": 286.67, 12 | "pressure": 972.73, 13 | "sea_level": 1046.46, 14 | "grnd_level": 972.73, 15 | "humidity": 75, 16 | "temp_kf": 5.11 17 | }, 18 | "weather": [ 19 | { 20 | "id": 800, 21 | "main": "Clear", 22 | "description": "clear sky", 23 | "icon": "01d" 24 | } 25 | ], 26 | "clouds": { 27 | "all": 0 28 | }, 29 | "wind": { 30 | "speed": 1.81, 31 | "deg": 247.501 32 | }, 33 | "sys": { 34 | "pod": "d" 35 | }, 36 | "dt_txt": "2017-02-16 12:00:00" 37 | }, 38 | { 39 | "dt": 1487257200, 40 | "main": { 41 | "temp": 285.66, 42 | "temp_min": 281.821, 43 | "temp_max": 285.66, 44 | "pressure": 970.91, 45 | "sea_level": 1044.32, 46 | "grnd_level": 970.91, 47 | "humidity": 70, 48 | "temp_kf": 3.84 49 | }, 50 | "weather": [ 51 | { 52 | "id": 800, 53 | "main": "Clear", 54 | "description": "clear sky", 55 | "icon": "01d" 56 | } 57 | ], 58 | "clouds": { 59 | "all": 0 60 | }, 61 | "wind": { 62 | "speed": 1.59, 63 | "deg": 290.501 64 | }, 65 | "sys": { 66 | "pod": "d" 67 | }, 68 | "dt_txt": "2017-02-16 15:00:00" 69 | }, 70 | { 71 | "dt": 1487268000, 72 | "main": { 73 | "temp": 277.05, 74 | "temp_min": 274.498, 75 | "temp_max": 277.05, 76 | "pressure": 970.44, 77 | "sea_level": 1044.7, 78 | "grnd_level": 970.44, 79 | "humidity": 90, 80 | "temp_kf": 2.56 81 | }, 82 | "weather": [ 83 | { 84 | "id": 800, 85 | "main": "Clear", 86 | "description": "clear sky", 87 | "icon": "01n" 88 | } 89 | ], 90 | "clouds": { 91 | "all": 0 92 | }, 93 | "wind": { 94 | "speed": 1.41, 95 | "deg": 263.5 96 | }, 97 | "sys": { 98 | "pod": "n" 99 | }, 100 | "dt_txt": "2017-02-16 18:00:00" 101 | }, 102 | { 103 | "dt": 1487278800, 104 | "main": { 105 | "temp": 272.78, 106 | "temp_min": 271.503, 107 | "temp_max": 272.78, 108 | "pressure": 969.32, 109 | "sea_level": 1044.14, 110 | "grnd_level": 969.32, 111 | "humidity": 80, 112 | "temp_kf": 1.28 113 | }, 114 | "weather": [ 115 | { 116 | "id": 800, 117 | "main": "Clear", 118 | "description": "clear sky", 119 | "icon": "01n" 120 | } 121 | ], 122 | "clouds": { 123 | "all": 0 124 | }, 125 | "wind": { 126 | "speed": 2.24, 127 | "deg": 205.502 128 | }, 129 | "sys": { 130 | "pod": "n" 131 | }, 132 | "dt_txt": "2017-02-16 21:00:00" 133 | }, 134 | { 135 | "dt": 1487289600, 136 | "main": { 137 | "temp": 273.341, 138 | "temp_min": 273.341, 139 | "temp_max": 273.341, 140 | "pressure": 968.14, 141 | "sea_level": 1042.96, 142 | "grnd_level": 968.14, 143 | "humidity": 85, 144 | "temp_kf": 0 145 | }, 146 | "weather": [ 147 | { 148 | "id": 803, 149 | "main": "Clouds", 150 | "description": "broken clouds", 151 | "icon": "04n" 152 | } 153 | ], 154 | "clouds": { 155 | "all": 76 156 | }, 157 | "wind": { 158 | "speed": 3.59, 159 | "deg": 224.003 160 | }, 161 | "sys": { 162 | "pod": "n" 163 | }, 164 | "dt_txt": "2017-02-17 00:00:00" 165 | }, 166 | { 167 | "dt": 1487300400, 168 | "main": { 169 | "temp": 275.568, 170 | "temp_min": 275.568, 171 | "temp_max": 275.568, 172 | "pressure": 966.6, 173 | "sea_level": 1041.39, 174 | "grnd_level": 966.6, 175 | "humidity": 89, 176 | "temp_kf": 0 177 | }, 178 | "weather": [ 179 | { 180 | "id": 500, 181 | "main": "Rain", 182 | "description": "light rain", 183 | "icon": "10n" 184 | } 185 | ], 186 | "clouds": { 187 | "all": 76 188 | }, 189 | "wind": { 190 | "speed": 3.77, 191 | "deg": 237.002 192 | }, 193 | "rain": { 194 | "3h": 0.32 195 | }, 196 | "sys": { 197 | "pod": "n" 198 | }, 199 | "dt_txt": "2017-02-17 03:00:00" 200 | }, 201 | { 202 | "dt": 1487311200, 203 | "main": { 204 | "temp": 276.478, 205 | "temp_min": 276.478, 206 | "temp_max": 276.478, 207 | "pressure": 966.45, 208 | "sea_level": 1041.21, 209 | "grnd_level": 966.45, 210 | "humidity": 97, 211 | "temp_kf": 0 212 | }, 213 | "weather": [ 214 | { 215 | "id": 501, 216 | "main": "Rain", 217 | "description": "moderate rain", 218 | "icon": "10n" 219 | } 220 | ], 221 | "clouds": { 222 | "all": 92 223 | }, 224 | "wind": { 225 | "speed": 3.81, 226 | "deg": 268.005 227 | }, 228 | "rain": { 229 | "3h": 4.9 230 | }, 231 | "sys": { 232 | "pod": "n" 233 | }, 234 | "dt_txt": "2017-02-17 06:00:00" 235 | }, 236 | { 237 | "dt": 1487322000, 238 | "main": { 239 | "temp": 276.67, 240 | "temp_min": 276.67, 241 | "temp_max": 276.67, 242 | "pressure": 967.41, 243 | "sea_level": 1041.95, 244 | "grnd_level": 967.41, 245 | "humidity": 100, 246 | "temp_kf": 0 247 | }, 248 | "weather": [ 249 | { 250 | "id": 500, 251 | "main": "Rain", 252 | "description": "light rain", 253 | "icon": "10d" 254 | } 255 | ], 256 | "clouds": { 257 | "all": 64 258 | }, 259 | "wind": { 260 | "speed": 2.6, 261 | "deg": 266.504 262 | }, 263 | "rain": { 264 | "3h": 1.37 265 | }, 266 | "sys": { 267 | "pod": "d" 268 | }, 269 | "dt_txt": "2017-02-17 09:00:00" 270 | }, 271 | { 272 | "dt": 1487332800, 273 | "main": { 274 | "temp": 278.253, 275 | "temp_min": 278.253, 276 | "temp_max": 278.253, 277 | "pressure": 966.98, 278 | "sea_level": 1040.89, 279 | "grnd_level": 966.98, 280 | "humidity": 95, 281 | "temp_kf": 0 282 | }, 283 | "weather": [ 284 | { 285 | "id": 500, 286 | "main": "Rain", 287 | "description": "light rain", 288 | "icon": "10d" 289 | } 290 | ], 291 | "clouds": { 292 | "all": 92 293 | }, 294 | "wind": { 295 | "speed": 3.17, 296 | "deg": 261.501 297 | }, 298 | "rain": { 299 | "3h": 0.12 300 | }, 301 | "sys": { 302 | "pod": "d" 303 | }, 304 | "dt_txt": "2017-02-17 12:00:00" 305 | }, 306 | { 307 | "dt": 1487343600, 308 | "main": { 309 | "temp": 276.455, 310 | "temp_min": 276.455, 311 | "temp_max": 276.455, 312 | "pressure": 966.38, 313 | "sea_level": 1040.17, 314 | "grnd_level": 966.38, 315 | "humidity": 99, 316 | "temp_kf": 0 317 | }, 318 | "weather": [ 319 | { 320 | "id": 500, 321 | "main": "Rain", 322 | "description": "light rain", 323 | "icon": "10d" 324 | } 325 | ], 326 | "clouds": { 327 | "all": 92 328 | }, 329 | "wind": { 330 | "speed": 3.21, 331 | "deg": 268.001 332 | }, 333 | "rain": { 334 | "3h": 2.12 335 | }, 336 | "sys": { 337 | "pod": "d" 338 | }, 339 | "dt_txt": "2017-02-17 15:00:00" 340 | }, 341 | { 342 | "dt": 1487354400, 343 | "main": { 344 | "temp": 275.639, 345 | "temp_min": 275.639, 346 | "temp_max": 275.639, 347 | "pressure": 966.39, 348 | "sea_level": 1040.65, 349 | "grnd_level": 966.39, 350 | "humidity": 95, 351 | "temp_kf": 0 352 | }, 353 | "weather": [ 354 | { 355 | "id": 500, 356 | "main": "Rain", 357 | "description": "light rain", 358 | "icon": "10n" 359 | } 360 | ], 361 | "clouds": { 362 | "all": 88 363 | }, 364 | "wind": { 365 | "speed": 3.17, 366 | "deg": 258.001 367 | }, 368 | "rain": { 369 | "3h": 0.7 370 | }, 371 | "snow": { 372 | "3h": 0.0775 373 | }, 374 | "sys": { 375 | "pod": "n" 376 | }, 377 | "dt_txt": "2017-02-17 18:00:00" 378 | }, 379 | { 380 | "dt": 1487365200, 381 | "main": { 382 | "temp": 275.459, 383 | "temp_min": 275.459, 384 | "temp_max": 275.459, 385 | "pressure": 966.3, 386 | "sea_level": 1040.8, 387 | "grnd_level": 966.3, 388 | "humidity": 96, 389 | "temp_kf": 0 390 | }, 391 | "weather": [ 392 | { 393 | "id": 500, 394 | "main": "Rain", 395 | "description": "light rain", 396 | "icon": "10n" 397 | } 398 | ], 399 | "clouds": { 400 | "all": 88 401 | }, 402 | "wind": { 403 | "speed": 3.71, 404 | "deg": 265.503 405 | }, 406 | "rain": { 407 | "3h": 1.16 408 | }, 409 | "snow": { 410 | "3h": 0.075 411 | }, 412 | "sys": { 413 | "pod": "n" 414 | }, 415 | "dt_txt": "2017-02-17 21:00:00" 416 | }, 417 | { 418 | "dt": 1487376000, 419 | "main": { 420 | "temp": 275.035, 421 | "temp_min": 275.035, 422 | "temp_max": 275.035, 423 | "pressure": 966.43, 424 | "sea_level": 1041.02, 425 | "grnd_level": 966.43, 426 | "humidity": 99, 427 | "temp_kf": 0 428 | }, 429 | "weather": [ 430 | { 431 | "id": 500, 432 | "main": "Rain", 433 | "description": "light rain", 434 | "icon": "10n" 435 | } 436 | ], 437 | "clouds": { 438 | "all": 92 439 | }, 440 | "wind": { 441 | "speed": 3.56, 442 | "deg": 273.5 443 | }, 444 | "rain": { 445 | "3h": 1.37 446 | }, 447 | "snow": { 448 | "3h": 0.1525 449 | }, 450 | "sys": { 451 | "pod": "n" 452 | }, 453 | "dt_txt": "2017-02-18 00:00:00" 454 | }, 455 | { 456 | "dt": 1487386800, 457 | "main": { 458 | "temp": 274.965, 459 | "temp_min": 274.965, 460 | "temp_max": 274.965, 461 | "pressure": 966.36, 462 | "sea_level": 1041.17, 463 | "grnd_level": 966.36, 464 | "humidity": 97, 465 | "temp_kf": 0 466 | }, 467 | "weather": [ 468 | { 469 | "id": 500, 470 | "main": "Rain", 471 | "description": "light rain", 472 | "icon": "10n" 473 | } 474 | ], 475 | "clouds": { 476 | "all": 88 477 | }, 478 | "wind": { 479 | "speed": 2.66, 480 | "deg": 285.502 481 | }, 482 | "rain": { 483 | "3h": 0.79 484 | }, 485 | "snow": { 486 | "3h": 0.52 487 | }, 488 | "sys": { 489 | "pod": "n" 490 | }, 491 | "dt_txt": "2017-02-18 03:00:00" 492 | }, 493 | { 494 | "dt": 1487397600, 495 | "main": { 496 | "temp": 274.562, 497 | "temp_min": 274.562, 498 | "temp_max": 274.562, 499 | "pressure": 966.75, 500 | "sea_level": 1041.57, 501 | "grnd_level": 966.75, 502 | "humidity": 98, 503 | "temp_kf": 0 504 | }, 505 | "weather": [ 506 | { 507 | "id": 500, 508 | "main": "Rain", 509 | "description": "light rain", 510 | "icon": "10n" 511 | } 512 | ], 513 | "clouds": { 514 | "all": 88 515 | }, 516 | "wind": { 517 | "speed": 1.46, 518 | "deg": 276.5 519 | }, 520 | "rain": { 521 | "3h": 0.08 522 | }, 523 | "snow": { 524 | "3h": 0.06 525 | }, 526 | "sys": { 527 | "pod": "n" 528 | }, 529 | "dt_txt": "2017-02-18 06:00:00" 530 | }, 531 | { 532 | "dt": 1487408400, 533 | "main": { 534 | "temp": 275.648, 535 | "temp_min": 275.648, 536 | "temp_max": 275.648, 537 | "pressure": 967.21, 538 | "sea_level": 1041.74, 539 | "grnd_level": 967.21, 540 | "humidity": 99, 541 | "temp_kf": 0 542 | }, 543 | "weather": [ 544 | { 545 | "id": 500, 546 | "main": "Rain", 547 | "description": "light rain", 548 | "icon": "10d" 549 | } 550 | ], 551 | "clouds": { 552 | "all": 56 553 | }, 554 | "wind": { 555 | "speed": 1.5, 556 | "deg": 251.008 557 | }, 558 | "rain": { 559 | "3h": 0.02 560 | }, 561 | "snow": { 562 | "3h": 0.03 563 | }, 564 | "sys": { 565 | "pod": "d" 566 | }, 567 | "dt_txt": "2017-02-18 09:00:00" 568 | }, 569 | { 570 | "dt": 1487419200, 571 | "main": { 572 | "temp": 277.927, 573 | "temp_min": 277.927, 574 | "temp_max": 277.927, 575 | "pressure": 966.06, 576 | "sea_level": 1039.98, 577 | "grnd_level": 966.06, 578 | "humidity": 95, 579 | "temp_kf": 0 580 | }, 581 | "weather": [ 582 | { 583 | "id": 800, 584 | "main": "Clear", 585 | "description": "clear sky", 586 | "icon": "02d" 587 | } 588 | ], 589 | "clouds": { 590 | "all": 8 591 | }, 592 | "wind": { 593 | "speed": 0.86, 594 | "deg": 244.004 595 | }, 596 | "rain": {}, 597 | "snow": {}, 598 | "sys": { 599 | "pod": "d" 600 | }, 601 | "dt_txt": "2017-02-18 12:00:00" 602 | }, 603 | { 604 | "dt": 1487430000, 605 | "main": { 606 | "temp": 278.367, 607 | "temp_min": 278.367, 608 | "temp_max": 278.367, 609 | "pressure": 964.57, 610 | "sea_level": 1038.35, 611 | "grnd_level": 964.57, 612 | "humidity": 89, 613 | "temp_kf": 0 614 | }, 615 | "weather": [ 616 | { 617 | "id": 800, 618 | "main": "Clear", 619 | "description": "clear sky", 620 | "icon": "02d" 621 | } 622 | ], 623 | "clouds": { 624 | "all": 8 625 | }, 626 | "wind": { 627 | "speed": 1.62, 628 | "deg": 79.5024 629 | }, 630 | "rain": {}, 631 | "snow": {}, 632 | "sys": { 633 | "pod": "d" 634 | }, 635 | "dt_txt": "2017-02-18 15:00:00" 636 | }, 637 | { 638 | "dt": 1487440800, 639 | "main": { 640 | "temp": 273.797, 641 | "temp_min": 273.797, 642 | "temp_max": 273.797, 643 | "pressure": 964.13, 644 | "sea_level": 1038.48, 645 | "grnd_level": 964.13, 646 | "humidity": 91, 647 | "temp_kf": 0 648 | }, 649 | "weather": [ 650 | { 651 | "id": 800, 652 | "main": "Clear", 653 | "description": "clear sky", 654 | "icon": "01n" 655 | } 656 | ], 657 | "clouds": { 658 | "all": 0 659 | }, 660 | "wind": { 661 | "speed": 2.42, 662 | "deg": 77.0026 663 | }, 664 | "rain": {}, 665 | "snow": {}, 666 | "sys": { 667 | "pod": "n" 668 | }, 669 | "dt_txt": "2017-02-18 18:00:00" 670 | }, 671 | { 672 | "dt": 1487451600, 673 | "main": { 674 | "temp": 271.239, 675 | "temp_min": 271.239, 676 | "temp_max": 271.239, 677 | "pressure": 963.39, 678 | "sea_level": 1038.21, 679 | "grnd_level": 963.39, 680 | "humidity": 93, 681 | "temp_kf": 0 682 | }, 683 | "weather": [ 684 | { 685 | "id": 800, 686 | "main": "Clear", 687 | "description": "clear sky", 688 | "icon": "01n" 689 | } 690 | ], 691 | "clouds": { 692 | "all": 0 693 | }, 694 | "wind": { 695 | "speed": 2.42, 696 | "deg": 95.5017 697 | }, 698 | "rain": {}, 699 | "snow": {}, 700 | "sys": { 701 | "pod": "n" 702 | }, 703 | "dt_txt": "2017-02-18 21:00:00" 704 | }, 705 | { 706 | "dt": 1487462400, 707 | "main": { 708 | "temp": 269.553, 709 | "temp_min": 269.553, 710 | "temp_max": 269.553, 711 | "pressure": 962.39, 712 | "sea_level": 1037.44, 713 | "grnd_level": 962.39, 714 | "humidity": 92, 715 | "temp_kf": 0 716 | }, 717 | "weather": [ 718 | { 719 | "id": 800, 720 | "main": "Clear", 721 | "description": "clear sky", 722 | "icon": "01n" 723 | } 724 | ], 725 | "clouds": { 726 | "all": 0 727 | }, 728 | "wind": { 729 | "speed": 1.96, 730 | "deg": 101.004 731 | }, 732 | "rain": {}, 733 | "snow": {}, 734 | "sys": { 735 | "pod": "n" 736 | }, 737 | "dt_txt": "2017-02-19 00:00:00" 738 | }, 739 | { 740 | "dt": 1487473200, 741 | "main": { 742 | "temp": 268.198, 743 | "temp_min": 268.198, 744 | "temp_max": 268.198, 745 | "pressure": 961.28, 746 | "sea_level": 1036.51, 747 | "grnd_level": 961.28, 748 | "humidity": 84, 749 | "temp_kf": 0 750 | }, 751 | "weather": [ 752 | { 753 | "id": 800, 754 | "main": "Clear", 755 | "description": "clear sky", 756 | "icon": "01n" 757 | } 758 | ], 759 | "clouds": { 760 | "all": 0 761 | }, 762 | "wind": { 763 | "speed": 1.06, 764 | "deg": 121.5 765 | }, 766 | "rain": {}, 767 | "snow": {}, 768 | "sys": { 769 | "pod": "n" 770 | }, 771 | "dt_txt": "2017-02-19 03:00:00" 772 | }, 773 | { 774 | "dt": 1487484000, 775 | "main": { 776 | "temp": 267.295, 777 | "temp_min": 267.295, 778 | "temp_max": 267.295, 779 | "pressure": 961.16, 780 | "sea_level": 1036.45, 781 | "grnd_level": 961.16, 782 | "humidity": 86, 783 | "temp_kf": 0 784 | }, 785 | "weather": [ 786 | { 787 | "id": 800, 788 | "main": "Clear", 789 | "description": "clear sky", 790 | "icon": "01n" 791 | } 792 | ], 793 | "clouds": { 794 | "all": 0 795 | }, 796 | "wind": { 797 | "speed": 1.17, 798 | "deg": 155.005 799 | }, 800 | "rain": {}, 801 | "snow": {}, 802 | "sys": { 803 | "pod": "n" 804 | }, 805 | "dt_txt": "2017-02-19 06:00:00" 806 | }, 807 | { 808 | "dt": 1487494800, 809 | "main": { 810 | "temp": 272.956, 811 | "temp_min": 272.956, 812 | "temp_max": 272.956, 813 | "pressure": 962.03, 814 | "sea_level": 1036.85, 815 | "grnd_level": 962.03, 816 | "humidity": 84, 817 | "temp_kf": 0 818 | }, 819 | "weather": [ 820 | { 821 | "id": 800, 822 | "main": "Clear", 823 | "description": "clear sky", 824 | "icon": "01d" 825 | } 826 | ], 827 | "clouds": { 828 | "all": 0 829 | }, 830 | "wind": { 831 | "speed": 1.66, 832 | "deg": 195.002 833 | }, 834 | "rain": {}, 835 | "snow": {}, 836 | "sys": { 837 | "pod": "d" 838 | }, 839 | "dt_txt": "2017-02-19 09:00:00" 840 | }, 841 | { 842 | "dt": 1487505600, 843 | "main": { 844 | "temp": 277.422, 845 | "temp_min": 277.422, 846 | "temp_max": 277.422, 847 | "pressure": 962.23, 848 | "sea_level": 1036.06, 849 | "grnd_level": 962.23, 850 | "humidity": 89, 851 | "temp_kf": 0 852 | }, 853 | "weather": [ 854 | { 855 | "id": 800, 856 | "main": "Clear", 857 | "description": "clear sky", 858 | "icon": "01d" 859 | } 860 | ], 861 | "clouds": { 862 | "all": 0 863 | }, 864 | "wind": { 865 | "speed": 1.32, 866 | "deg": 357.003 867 | }, 868 | "rain": {}, 869 | "snow": {}, 870 | "sys": { 871 | "pod": "d" 872 | }, 873 | "dt_txt": "2017-02-19 12:00:00" 874 | }, 875 | { 876 | "dt": 1487516400, 877 | "main": { 878 | "temp": 277.984, 879 | "temp_min": 277.984, 880 | "temp_max": 277.984, 881 | "pressure": 962.15, 882 | "sea_level": 1035.86, 883 | "grnd_level": 962.15, 884 | "humidity": 87, 885 | "temp_kf": 0 886 | }, 887 | "weather": [ 888 | { 889 | "id": 800, 890 | "main": "Clear", 891 | "description": "clear sky", 892 | "icon": "01d" 893 | } 894 | ], 895 | "clouds": { 896 | "all": 0 897 | }, 898 | "wind": { 899 | "speed": 1.58, 900 | "deg": 48.5031 901 | }, 902 | "rain": {}, 903 | "snow": {}, 904 | "sys": { 905 | "pod": "d" 906 | }, 907 | "dt_txt": "2017-02-19 15:00:00" 908 | }, 909 | { 910 | "dt": 1487527200, 911 | "main": { 912 | "temp": 272.459, 913 | "temp_min": 272.459, 914 | "temp_max": 272.459, 915 | "pressure": 963.31, 916 | "sea_level": 1037.81, 917 | "grnd_level": 963.31, 918 | "humidity": 90, 919 | "temp_kf": 0 920 | }, 921 | "weather": [ 922 | { 923 | "id": 800, 924 | "main": "Clear", 925 | "description": "clear sky", 926 | "icon": "01n" 927 | } 928 | ], 929 | "clouds": { 930 | "all": 0 931 | }, 932 | "wind": { 933 | "speed": 1.16, 934 | "deg": 75.5042 935 | }, 936 | "rain": {}, 937 | "snow": {}, 938 | "sys": { 939 | "pod": "n" 940 | }, 941 | "dt_txt": "2017-02-19 18:00:00" 942 | }, 943 | { 944 | "dt": 1487538000, 945 | "main": { 946 | "temp": 269.473, 947 | "temp_min": 269.473, 948 | "temp_max": 269.473, 949 | "pressure": 964.65, 950 | "sea_level": 1039.76, 951 | "grnd_level": 964.65, 952 | "humidity": 83, 953 | "temp_kf": 0 954 | }, 955 | "weather": [ 956 | { 957 | "id": 800, 958 | "main": "Clear", 959 | "description": "clear sky", 960 | "icon": "01n" 961 | } 962 | ], 963 | "clouds": { 964 | "all": 0 965 | }, 966 | "wind": { 967 | "speed": 1.12, 968 | "deg": 174.002 969 | }, 970 | "rain": {}, 971 | "snow": {}, 972 | "sys": { 973 | "pod": "n" 974 | }, 975 | "dt_txt": "2017-02-19 21:00:00" 976 | }, 977 | { 978 | "dt": 1487548800, 979 | "main": { 980 | "temp": 268.793, 981 | "temp_min": 268.793, 982 | "temp_max": 268.793, 983 | "pressure": 965.92, 984 | "sea_level": 1041.32, 985 | "grnd_level": 965.92, 986 | "humidity": 80, 987 | "temp_kf": 0 988 | }, 989 | "weather": [ 990 | { 991 | "id": 800, 992 | "main": "Clear", 993 | "description": "clear sky", 994 | "icon": "01n" 995 | } 996 | ], 997 | "clouds": { 998 | "all": 0 999 | }, 1000 | "wind": { 1001 | "speed": 2.11, 1002 | "deg": 207.502 1003 | }, 1004 | "rain": {}, 1005 | "snow": {}, 1006 | "sys": { 1007 | "pod": "n" 1008 | }, 1009 | "dt_txt": "2017-02-20 00:00:00" 1010 | }, 1011 | { 1012 | "dt": 1487559600, 1013 | "main": { 1014 | "temp": 268.106, 1015 | "temp_min": 268.106, 1016 | "temp_max": 268.106, 1017 | "pressure": 966.4, 1018 | "sea_level": 1042.18, 1019 | "grnd_level": 966.4, 1020 | "humidity": 85, 1021 | "temp_kf": 0 1022 | }, 1023 | "weather": [ 1024 | { 1025 | "id": 800, 1026 | "main": "Clear", 1027 | "description": "clear sky", 1028 | "icon": "01n" 1029 | } 1030 | ], 1031 | "clouds": { 1032 | "all": 0 1033 | }, 1034 | "wind": { 1035 | "speed": 1.67, 1036 | "deg": 191.001 1037 | }, 1038 | "rain": {}, 1039 | "snow": {}, 1040 | "sys": { 1041 | "pod": "n" 1042 | }, 1043 | "dt_txt": "2017-02-20 03:00:00" 1044 | }, 1045 | { 1046 | "dt": 1487570400, 1047 | "main": { 1048 | "temp": 267.655, 1049 | "temp_min": 267.655, 1050 | "temp_max": 267.655, 1051 | "pressure": 967.4, 1052 | "sea_level": 1043.43, 1053 | "grnd_level": 967.4, 1054 | "humidity": 84, 1055 | "temp_kf": 0 1056 | }, 1057 | "weather": [ 1058 | { 1059 | "id": 800, 1060 | "main": "Clear", 1061 | "description": "clear sky", 1062 | "icon": "01n" 1063 | } 1064 | ], 1065 | "clouds": { 1066 | "all": 0 1067 | }, 1068 | "wind": { 1069 | "speed": 1.61, 1070 | "deg": 194.001 1071 | }, 1072 | "rain": {}, 1073 | "snow": {}, 1074 | "sys": { 1075 | "pod": "n" 1076 | }, 1077 | "dt_txt": "2017-02-20 06:00:00" 1078 | }, 1079 | { 1080 | "dt": 1487581200, 1081 | "main": { 1082 | "temp": 273.75, 1083 | "temp_min": 273.75, 1084 | "temp_max": 273.75, 1085 | "pressure": 968.84, 1086 | "sea_level": 1044.23, 1087 | "grnd_level": 968.84, 1088 | "humidity": 83, 1089 | "temp_kf": 0 1090 | }, 1091 | "weather": [ 1092 | { 1093 | "id": 800, 1094 | "main": "Clear", 1095 | "description": "clear sky", 1096 | "icon": "01d" 1097 | } 1098 | ], 1099 | "clouds": { 1100 | "all": 0 1101 | }, 1102 | "wind": { 1103 | "speed": 2.49, 1104 | "deg": 208.5 1105 | }, 1106 | "rain": {}, 1107 | "snow": {}, 1108 | "sys": { 1109 | "pod": "d" 1110 | }, 1111 | "dt_txt": "2017-02-20 09:00:00" 1112 | }, 1113 | { 1114 | "dt": 1487592000, 1115 | "main": { 1116 | "temp": 279.302, 1117 | "temp_min": 279.302, 1118 | "temp_max": 279.302, 1119 | "pressure": 968.37, 1120 | "sea_level": 1042.52, 1121 | "grnd_level": 968.37, 1122 | "humidity": 83, 1123 | "temp_kf": 0 1124 | }, 1125 | "weather": [ 1126 | { 1127 | "id": 800, 1128 | "main": "Clear", 1129 | "description": "clear sky", 1130 | "icon": "01d" 1131 | } 1132 | ], 1133 | "clouds": { 1134 | "all": 0 1135 | }, 1136 | "wind": { 1137 | "speed": 2.46, 1138 | "deg": 252.001 1139 | }, 1140 | "rain": {}, 1141 | "snow": {}, 1142 | "sys": { 1143 | "pod": "d" 1144 | }, 1145 | "dt_txt": "2017-02-20 12:00:00" 1146 | }, 1147 | { 1148 | "dt": 1487602800, 1149 | "main": { 1150 | "temp": 279.343, 1151 | "temp_min": 279.343, 1152 | "temp_max": 279.343, 1153 | "pressure": 967.9, 1154 | "sea_level": 1041.64, 1155 | "grnd_level": 967.9, 1156 | "humidity": 81, 1157 | "temp_kf": 0 1158 | }, 1159 | "weather": [ 1160 | { 1161 | "id": 800, 1162 | "main": "Clear", 1163 | "description": "clear sky", 1164 | "icon": "01d" 1165 | } 1166 | ], 1167 | "clouds": { 1168 | "all": 0 1169 | }, 1170 | "wind": { 1171 | "speed": 3.21, 1172 | "deg": 268.001 1173 | }, 1174 | "rain": {}, 1175 | "snow": {}, 1176 | "sys": { 1177 | "pod": "d" 1178 | }, 1179 | "dt_txt": "2017-02-20 15:00:00" 1180 | }, 1181 | { 1182 | "dt": 1487613600, 1183 | "main": { 1184 | "temp": 274.443, 1185 | "temp_min": 274.443, 1186 | "temp_max": 274.443, 1187 | "pressure": 968.19, 1188 | "sea_level": 1042.66, 1189 | "grnd_level": 968.19, 1190 | "humidity": 88, 1191 | "temp_kf": 0 1192 | }, 1193 | "weather": [ 1194 | { 1195 | "id": 801, 1196 | "main": "Clouds", 1197 | "description": "few clouds", 1198 | "icon": "02n" 1199 | } 1200 | ], 1201 | "clouds": { 1202 | "all": 24 1203 | }, 1204 | "wind": { 1205 | "speed": 3.27, 1206 | "deg": 257.501 1207 | }, 1208 | "rain": {}, 1209 | "snow": {}, 1210 | "sys": { 1211 | "pod": "n" 1212 | }, 1213 | "dt_txt": "2017-02-20 18:00:00" 1214 | }, 1215 | { 1216 | "dt": 1487624400, 1217 | "main": { 1218 | "temp": 272.424, 1219 | "temp_min": 272.424, 1220 | "temp_max": 272.424, 1221 | "pressure": 968.38, 1222 | "sea_level": 1043.17, 1223 | "grnd_level": 968.38, 1224 | "humidity": 85, 1225 | "temp_kf": 0 1226 | }, 1227 | "weather": [ 1228 | { 1229 | "id": 801, 1230 | "main": "Clouds", 1231 | "description": "few clouds", 1232 | "icon": "02n" 1233 | } 1234 | ], 1235 | "clouds": { 1236 | "all": 20 1237 | }, 1238 | "wind": { 1239 | "speed": 3.57, 1240 | "deg": 255.503 1241 | }, 1242 | "rain": {}, 1243 | "snow": {}, 1244 | "sys": { 1245 | "pod": "n" 1246 | }, 1247 | "dt_txt": "2017-02-20 21:00:00" 1248 | } 1249 | ], 1250 | "city": { 1251 | "id": 6940463, 1252 | "name": "Altstadt", 1253 | "coord": { 1254 | "lat": 48.137, 1255 | "lon": 11.5752 1256 | }, 1257 | "country": "none" 1258 | } 1259 | } --------------------------------------------------------------------------------