├── sunshine ├── Assets.xcassets │ ├── Contents.json │ ├── Colors │ │ ├── Contents.json │ │ ├── Pink Dawn.colorset │ │ │ └── Contents.json │ │ ├── Blue Dusk.colorset │ │ │ └── Contents.json │ │ ├── Blue Mid Day.colorset │ │ │ └── Contents.json │ │ ├── Blue Night.colorset │ │ │ └── Contents.json │ │ ├── Blue Dawn End Gradient.colorset │ │ │ └── Contents.json │ │ ├── Blue Mid Day End Gradient.colorset │ │ │ └── Contents.json │ │ ├── Blue Night End Gradient.colorset │ │ │ └── Contents.json │ │ ├── Peach Dusk End Gradient.colorset │ │ │ └── Contents.json │ │ ├── Blue Dawn Shadow Card.colorset │ │ │ └── Contents.json │ │ ├── Blue Mid Day Shadow Card.colorset │ │ │ └── Contents.json │ │ ├── Blue Night Shadow Card.colorset │ │ │ └── Contents.json │ │ └── Peach Dusk Shadow Card.colorset │ │ │ └── Contents.json │ ├── Moon.imageset │ │ ├── Moon@1x.png │ │ ├── Moon@2x.png │ │ ├── Moon@3x.png │ │ └── Contents.json │ ├── cloud.imageset │ │ ├── cloud@3x.png │ │ └── Contents.json │ ├── drop.imageset │ │ ├── drop@3x.png │ │ └── Contents.json │ ├── clouds.imageset │ │ ├── clouds@3x.png │ │ └── Contents.json │ ├── sun-mid.imageset │ │ ├── sun-mid@1x.png │ │ ├── sun-mid@2x.png │ │ ├── sun-mid@3x.png │ │ └── Contents.json │ ├── sun-dawn.imageset │ │ ├── sun-dawn@1x.png │ │ ├── sun-dawn@2x.png │ │ ├── sun-dawn@3x.png │ │ └── Contents.json │ ├── big-cloud.imageset │ │ ├── Big Cloud@3x.png │ │ └── Contents.json │ ├── heavy-rain.imageset │ │ ├── Heavy Rain@3x.png │ │ └── Contents.json │ ├── light-drop.imageset │ │ ├── light drop@3x.png │ │ └── Contents.json │ ├── light-rain.imageset │ │ ├── Light Rain@3x.png │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ ├── iPhone_App_60_2x.png │ │ ├── iPhone_App_60_3x.png │ │ ├── App_store_1024_1x.png │ │ ├── iPhone_Settings_29_2x.png │ │ ├── iPhone_Settings_29_3x.png │ │ ├── iPhone_Spotlight_40_2x.png │ │ ├── iPhone_Spotlight_40_3x.png │ │ ├── iPhone_Notifications_20_2x.png │ │ ├── iPhone_Notifications_20_3x.png │ │ └── Contents.json │ ├── heavy-rain-drops.imageset │ │ ├── heavy rain drops@3x.png │ │ └── Contents.json │ ├── light-rain-drops.imageset │ │ ├── Light rain drops@3x.png │ │ └── Contents.json │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── humidity.imageset │ │ ├── Contents.json │ │ └── humidity.svg │ ├── menu-dark.imageset │ │ ├── Contents.json │ │ └── menu.svg │ └── menu-light.imageset │ │ ├── Contents.json │ │ └── menu-light.svg ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── sunshine.xcdatamodeld │ ├── .xccurrentversion │ └── sunshine.xcdatamodel │ │ └── contents ├── sunshineApp.swift ├── Info.plist ├── LocationService.swift ├── SearchBar.swift ├── Persistence.swift ├── WeatherAPIClient.swift ├── SunDial.swift ├── Settings.swift ├── Forecast.swift └── ContentView.swift ├── sunshine.xcodeproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist ├── xcuserdata │ └── maxime.xcuserdatad │ │ └── xcschemes │ │ └── xcschememanagement.plist └── project.pbxproj ├── sunshineTests ├── Info.plist └── sunshineTests.swift ├── sunshineUITests ├── Info.plist └── sunshineUITests.swift └── README.md /sunshine/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/Colors/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /sunshine/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/Moon.imageset/Moon@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaximeHeckel/sunshine-weather-app/HEAD/sunshine/Assets.xcassets/Moon.imageset/Moon@1x.png -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/Moon.imageset/Moon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaximeHeckel/sunshine-weather-app/HEAD/sunshine/Assets.xcassets/Moon.imageset/Moon@2x.png -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/Moon.imageset/Moon@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaximeHeckel/sunshine-weather-app/HEAD/sunshine/Assets.xcassets/Moon.imageset/Moon@3x.png -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/cloud.imageset/cloud@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaximeHeckel/sunshine-weather-app/HEAD/sunshine/Assets.xcassets/cloud.imageset/cloud@3x.png -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/drop.imageset/drop@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaximeHeckel/sunshine-weather-app/HEAD/sunshine/Assets.xcassets/drop.imageset/drop@3x.png -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/clouds.imageset/clouds@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaximeHeckel/sunshine-weather-app/HEAD/sunshine/Assets.xcassets/clouds.imageset/clouds@3x.png -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/sun-mid.imageset/sun-mid@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaximeHeckel/sunshine-weather-app/HEAD/sunshine/Assets.xcassets/sun-mid.imageset/sun-mid@1x.png -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/sun-mid.imageset/sun-mid@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaximeHeckel/sunshine-weather-app/HEAD/sunshine/Assets.xcassets/sun-mid.imageset/sun-mid@2x.png -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/sun-mid.imageset/sun-mid@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaximeHeckel/sunshine-weather-app/HEAD/sunshine/Assets.xcassets/sun-mid.imageset/sun-mid@3x.png -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/sun-dawn.imageset/sun-dawn@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaximeHeckel/sunshine-weather-app/HEAD/sunshine/Assets.xcassets/sun-dawn.imageset/sun-dawn@1x.png -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/sun-dawn.imageset/sun-dawn@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaximeHeckel/sunshine-weather-app/HEAD/sunshine/Assets.xcassets/sun-dawn.imageset/sun-dawn@2x.png -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/sun-dawn.imageset/sun-dawn@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaximeHeckel/sunshine-weather-app/HEAD/sunshine/Assets.xcassets/sun-dawn.imageset/sun-dawn@3x.png -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/big-cloud.imageset/Big Cloud@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaximeHeckel/sunshine-weather-app/HEAD/sunshine/Assets.xcassets/big-cloud.imageset/Big Cloud@3x.png -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/heavy-rain.imageset/Heavy Rain@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaximeHeckel/sunshine-weather-app/HEAD/sunshine/Assets.xcassets/heavy-rain.imageset/Heavy Rain@3x.png -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/light-drop.imageset/light drop@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaximeHeckel/sunshine-weather-app/HEAD/sunshine/Assets.xcassets/light-drop.imageset/light drop@3x.png -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/light-rain.imageset/Light Rain@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaximeHeckel/sunshine-weather-app/HEAD/sunshine/Assets.xcassets/light-rain.imageset/Light Rain@3x.png -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/AppIcon.appiconset/iPhone_App_60_2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaximeHeckel/sunshine-weather-app/HEAD/sunshine/Assets.xcassets/AppIcon.appiconset/iPhone_App_60_2x.png -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/AppIcon.appiconset/iPhone_App_60_3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaximeHeckel/sunshine-weather-app/HEAD/sunshine/Assets.xcassets/AppIcon.appiconset/iPhone_App_60_3x.png -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/AppIcon.appiconset/App_store_1024_1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaximeHeckel/sunshine-weather-app/HEAD/sunshine/Assets.xcassets/AppIcon.appiconset/App_store_1024_1x.png -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/AppIcon.appiconset/iPhone_Settings_29_2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaximeHeckel/sunshine-weather-app/HEAD/sunshine/Assets.xcassets/AppIcon.appiconset/iPhone_Settings_29_2x.png -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/AppIcon.appiconset/iPhone_Settings_29_3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaximeHeckel/sunshine-weather-app/HEAD/sunshine/Assets.xcassets/AppIcon.appiconset/iPhone_Settings_29_3x.png -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/AppIcon.appiconset/iPhone_Spotlight_40_2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaximeHeckel/sunshine-weather-app/HEAD/sunshine/Assets.xcassets/AppIcon.appiconset/iPhone_Spotlight_40_2x.png -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/AppIcon.appiconset/iPhone_Spotlight_40_3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaximeHeckel/sunshine-weather-app/HEAD/sunshine/Assets.xcassets/AppIcon.appiconset/iPhone_Spotlight_40_3x.png -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/AppIcon.appiconset/iPhone_Notifications_20_2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaximeHeckel/sunshine-weather-app/HEAD/sunshine/Assets.xcassets/AppIcon.appiconset/iPhone_Notifications_20_2x.png -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/AppIcon.appiconset/iPhone_Notifications_20_3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaximeHeckel/sunshine-weather-app/HEAD/sunshine/Assets.xcassets/AppIcon.appiconset/iPhone_Notifications_20_3x.png -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/heavy-rain-drops.imageset/heavy rain drops@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaximeHeckel/sunshine-weather-app/HEAD/sunshine/Assets.xcassets/heavy-rain-drops.imageset/heavy rain drops@3x.png -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/light-rain-drops.imageset/Light rain drops@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaximeHeckel/sunshine-weather-app/HEAD/sunshine/Assets.xcassets/light-rain-drops.imageset/Light rain drops@3x.png -------------------------------------------------------------------------------- /sunshine.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /sunshine/sunshine.xcdatamodeld/.xccurrentversion: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | _XCCurrentVersionName 6 | sunshine.xcdatamodel 7 | 8 | 9 | -------------------------------------------------------------------------------- /sunshine.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/drop.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "scale" : "2x" 10 | }, 11 | { 12 | "filename" : "drop@3x.png", 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/cloud.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "scale" : "2x" 10 | }, 11 | { 12 | "filename" : "cloud@3x.png", 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/clouds.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "scale" : "2x" 10 | }, 11 | { 12 | "filename" : "clouds@3x.png", 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/humidity.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "humidity.svg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/menu-dark.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "menu.svg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/big-cloud.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "scale" : "2x" 10 | }, 11 | { 12 | "filename" : "Big Cloud@3x.png", 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/menu-light.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "menu-light.svg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/heavy-rain.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "scale" : "2x" 10 | }, 11 | { 12 | "filename" : "Heavy Rain@3x.png", 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/light-drop.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "scale" : "2x" 10 | }, 11 | { 12 | "filename" : "light drop@3x.png", 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/light-rain.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "scale" : "2x" 10 | }, 11 | { 12 | "filename" : "Light Rain@3x.png", 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/heavy-rain-drops.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "scale" : "2x" 10 | }, 11 | { 12 | "filename" : "heavy rain drops@3x.png", 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/light-rain-drops.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "scale" : "2x" 10 | }, 11 | { 12 | "filename" : "Light rain drops@3x.png", 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /sunshine/sunshineApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // sunshineApp.swift 3 | // sunshine 4 | // 5 | // Created by Maxime on 11/26/20. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @main 11 | struct sunshineApp: App { 12 | let persistenceController = PersistenceController.shared 13 | 14 | var body: some Scene { 15 | WindowGroup { 16 | ContentView() 17 | .environment(\.managedObjectContext, persistenceController.container.viewContext) 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/menu-dark.imageset/menu.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/menu-light.imageset/menu-light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/Moon.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Moon@1x.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "Moon@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "Moon@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/sun-mid.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "sun-mid@1x.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "sun-mid@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "sun-mid@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/sun-dawn.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "sun-dawn@1x.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "sun-dawn@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "sun-dawn@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/humidity.imageset/humidity.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /sunshineTests/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 | 22 | 23 | -------------------------------------------------------------------------------- /sunshineUITests/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 | 22 | 23 | -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/Colors/Pink Dawn.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0xC0", 9 | "green" : "0xBF", 10 | "red" : "0xFF" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "light" 20 | } 21 | ], 22 | "idiom" : "universal" 23 | }, 24 | { 25 | "appearances" : [ 26 | { 27 | "appearance" : "luminosity", 28 | "value" : "dark" 29 | } 30 | ], 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/Colors/Blue Dusk.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "1.000", 9 | "green" : "0.831", 10 | "red" : "0.839" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "light" 20 | } 21 | ], 22 | "idiom" : "universal" 23 | }, 24 | { 25 | "appearances" : [ 26 | { 27 | "appearance" : "luminosity", 28 | "value" : "dark" 29 | } 30 | ], 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/Colors/Blue Mid Day.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "1.000", 9 | "green" : "0.820", 10 | "red" : "0.667" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "light" 20 | } 21 | ], 22 | "idiom" : "universal" 23 | }, 24 | { 25 | "appearances" : [ 26 | { 27 | "appearance" : "luminosity", 28 | "value" : "dark" 29 | } 30 | ], 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/Colors/Blue Night.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.306", 9 | "green" : "0.137", 10 | "red" : "0.027" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "light" 20 | } 21 | ], 22 | "idiom" : "universal" 23 | }, 24 | { 25 | "appearances" : [ 26 | { 27 | "appearance" : "luminosity", 28 | "value" : "dark" 29 | } 30 | ], 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /sunshine.xcodeproj/xcuserdata/maxime.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | sunshine.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 0 11 | 12 | 13 | SuppressBuildableAutocreation 14 | 15 | 9C79929F25701B180072BEE2 16 | 17 | primary 18 | 19 | 20 | 9C7992B525701B1D0072BEE2 21 | 22 | primary 23 | 24 | 25 | 9C7992C025701B1D0072BEE2 26 | 27 | primary 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/Colors/Blue Dawn End Gradient.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0xFF", 9 | "green" : "0xE2", 10 | "red" : "0xCE" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "light" 20 | } 21 | ], 22 | "idiom" : "universal" 23 | }, 24 | { 25 | "appearances" : [ 26 | { 27 | "appearance" : "luminosity", 28 | "value" : "dark" 29 | } 30 | ], 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/Colors/Blue Mid Day End Gradient.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "1.000", 9 | "green" : "0.914", 10 | "red" : "0.855" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "light" 20 | } 21 | ], 22 | "idiom" : "universal" 23 | }, 24 | { 25 | "appearances" : [ 26 | { 27 | "appearance" : "luminosity", 28 | "value" : "dark" 29 | } 30 | ], 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/Colors/Blue Night End Gradient.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.651", 9 | "green" : "0.290", 10 | "red" : "0.106" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "light" 20 | } 21 | ], 22 | "idiom" : "universal" 23 | }, 24 | { 25 | "appearances" : [ 26 | { 27 | "appearance" : "luminosity", 28 | "value" : "dark" 29 | } 30 | ], 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/Colors/Peach Dusk End Gradient.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.812", 9 | "green" : "0.875", 10 | "red" : "1.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "light" 20 | } 21 | ], 22 | "idiom" : "universal" 23 | }, 24 | { 25 | "appearances" : [ 26 | { 27 | "appearance" : "luminosity", 28 | "value" : "dark" 29 | } 30 | ], 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sunshine 2 | 3 | This is my first SwiftUI project, as I decided not to release it to the App Store. I made the codebase available here for anyone to take a look at. 4 | It can serve as a good example or template to anyone looking to build a weather app. 5 | 6 | ## Instructions 7 | 8 | You can try out the app on your own device. For that you will need to: 9 | 10 | 1. Register to [OpenWeatherMap](https://openweathermap.org) and get a token for the **One Call API** (very important as the app is based on the spec of this API) 11 | 2. Clone this repository 12 | 3. Add the OpenWeatherMap token to `WeatherAPIClient.swift` line 145 13 | 4. Compile and run the app on a device or the simulator 14 | 15 | ## Notes 16 | 17 | - Sunshine works almost flawlessly from iOS 14 to iOS 14.3. 18 | - This project is stopped, so I won't do any maintenance. I won't reply to most issues if any at all. 19 | - Some weather assets are missing, some others are not animated, this is because I stopped working on this project before being able to tackle those. 20 | 21 | -------------------------------------------------------------------------------- /sunshineTests/sunshineTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // sunshineTests.swift 3 | // sunshineTests 4 | // 5 | // Created by Maxime on 11/26/20. 6 | // 7 | 8 | import XCTest 9 | @testable import sunshine 10 | 11 | class sunshineTests: XCTestCase { 12 | 13 | override func setUpWithError() throws { 14 | // Put setup code here. This method is called before the invocation of each test method in the class. 15 | } 16 | 17 | override func tearDownWithError() throws { 18 | // Put teardown code here. This method is called after the invocation of each test method in the class. 19 | } 20 | 21 | func testExample() throws { 22 | // This is an example of a functional test case. 23 | // Use XCTAssert and related functions to verify your tests produce the correct results. 24 | } 25 | 26 | func testPerformanceExample() throws { 27 | // This is an example of a performance test case. 28 | self.measure { 29 | // Put the code you want to measure the time of here. 30 | } 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/Colors/Blue Dawn Shadow Card.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | }, 6 | { 7 | "appearances" : [ 8 | { 9 | "appearance" : "luminosity", 10 | "value" : "light" 11 | } 12 | ], 13 | "color" : { 14 | "color-space" : "srgb", 15 | "components" : { 16 | "alpha" : "1.000", 17 | "blue" : "1.000", 18 | "green" : "0.961", 19 | "red" : "0.937" 20 | } 21 | }, 22 | "idiom" : "universal" 23 | }, 24 | { 25 | "appearances" : [ 26 | { 27 | "appearance" : "luminosity", 28 | "value" : "dark" 29 | } 30 | ], 31 | "color" : { 32 | "color-space" : "srgb", 33 | "components" : { 34 | "alpha" : "0.650", 35 | "blue" : "1.000", 36 | "green" : "0.961", 37 | "red" : "0.937" 38 | } 39 | }, 40 | "idiom" : "universal" 41 | } 42 | ], 43 | "info" : { 44 | "author" : "xcode", 45 | "version" : 1 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/Colors/Blue Mid Day Shadow Card.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | }, 6 | { 7 | "appearances" : [ 8 | { 9 | "appearance" : "luminosity", 10 | "value" : "light" 11 | } 12 | ], 13 | "color" : { 14 | "color-space" : "srgb", 15 | "components" : { 16 | "alpha" : "1.000", 17 | "blue" : "1.000", 18 | "green" : "0.965", 19 | "red" : "0.937" 20 | } 21 | }, 22 | "idiom" : "universal" 23 | }, 24 | { 25 | "appearances" : [ 26 | { 27 | "appearance" : "luminosity", 28 | "value" : "dark" 29 | } 30 | ], 31 | "color" : { 32 | "color-space" : "srgb", 33 | "components" : { 34 | "alpha" : "0.650", 35 | "blue" : "1.000", 36 | "green" : "0.965", 37 | "red" : "0.937" 38 | } 39 | }, 40 | "idiom" : "universal" 41 | } 42 | ], 43 | "info" : { 44 | "author" : "xcode", 45 | "version" : 1 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/Colors/Blue Night Shadow Card.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | }, 6 | { 7 | "appearances" : [ 8 | { 9 | "appearance" : "luminosity", 10 | "value" : "light" 11 | } 12 | ], 13 | "color" : { 14 | "color-space" : "srgb", 15 | "components" : { 16 | "alpha" : "0.500", 17 | "blue" : "0.780", 18 | "green" : "0.659", 19 | "red" : "0.592" 20 | } 21 | }, 22 | "idiom" : "universal" 23 | }, 24 | { 25 | "appearances" : [ 26 | { 27 | "appearance" : "luminosity", 28 | "value" : "dark" 29 | } 30 | ], 31 | "color" : { 32 | "color-space" : "srgb", 33 | "components" : { 34 | "alpha" : "0.500", 35 | "blue" : "0.780", 36 | "green" : "0.659", 37 | "red" : "0.592" 38 | } 39 | }, 40 | "idiom" : "universal" 41 | } 42 | ], 43 | "info" : { 44 | "author" : "xcode", 45 | "version" : 1 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/Colors/Peach Dusk Shadow Card.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | }, 6 | { 7 | "appearances" : [ 8 | { 9 | "appearance" : "luminosity", 10 | "value" : "light" 11 | } 12 | ], 13 | "color" : { 14 | "color-space" : "srgb", 15 | "components" : { 16 | "alpha" : "1.000", 17 | "blue" : "0.933", 18 | "green" : "0.949", 19 | "red" : "1.000" 20 | } 21 | }, 22 | "idiom" : "universal" 23 | }, 24 | { 25 | "appearances" : [ 26 | { 27 | "appearance" : "luminosity", 28 | "value" : "dark" 29 | } 30 | ], 31 | "color" : { 32 | "color-space" : "srgb", 33 | "components" : { 34 | "alpha" : "0.650", 35 | "blue" : "0.933", 36 | "green" : "0.949", 37 | "red" : "1.000" 38 | } 39 | }, 40 | "idiom" : "universal" 41 | } 42 | ], 43 | "info" : { 44 | "author" : "xcode", 45 | "version" : 1 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /sunshine/sunshine.xcdatamodeld/sunshine.xcdatamodel/contents: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /sunshine/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "iPhone_Notifications_20_2x.png", 5 | "idiom" : "iphone", 6 | "scale" : "2x", 7 | "size" : "20x20" 8 | }, 9 | { 10 | "filename" : "iPhone_Notifications_20_3x.png", 11 | "idiom" : "iphone", 12 | "scale" : "3x", 13 | "size" : "20x20" 14 | }, 15 | { 16 | "filename" : "iPhone_Settings_29_2x.png", 17 | "idiom" : "iphone", 18 | "scale" : "2x", 19 | "size" : "29x29" 20 | }, 21 | { 22 | "filename" : "iPhone_Settings_29_3x.png", 23 | "idiom" : "iphone", 24 | "scale" : "3x", 25 | "size" : "29x29" 26 | }, 27 | { 28 | "filename" : "iPhone_Spotlight_40_2x.png", 29 | "idiom" : "iphone", 30 | "scale" : "2x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "filename" : "iPhone_Spotlight_40_3x.png", 35 | "idiom" : "iphone", 36 | "scale" : "3x", 37 | "size" : "40x40" 38 | }, 39 | { 40 | "filename" : "iPhone_App_60_2x.png", 41 | "idiom" : "iphone", 42 | "scale" : "2x", 43 | "size" : "60x60" 44 | }, 45 | { 46 | "filename" : "iPhone_App_60_3x.png", 47 | "idiom" : "iphone", 48 | "scale" : "3x", 49 | "size" : "60x60" 50 | }, 51 | { 52 | "filename" : "App_store_1024_1x.png", 53 | "idiom" : "ios-marketing", 54 | "scale" : "1x", 55 | "size" : "1024x1024" 56 | } 57 | ], 58 | "info" : { 59 | "author" : "xcode", 60 | "version" : 1 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /sunshineUITests/sunshineUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // sunshineUITests.swift 3 | // sunshineUITests 4 | // 5 | // Created by Maxime on 11/26/20. 6 | // 7 | 8 | import XCTest 9 | 10 | class sunshineUITests: XCTestCase { 11 | 12 | override func setUpWithError() throws { 13 | // Put setup code here. This method is called before the invocation of each test method in the class. 14 | 15 | // In UI tests it is usually best to stop immediately when a failure occurs. 16 | continueAfterFailure = false 17 | 18 | // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. 19 | } 20 | 21 | override func tearDownWithError() throws { 22 | // Put teardown code here. This method is called after the invocation of each test method in the class. 23 | } 24 | 25 | func testExample() throws { 26 | // UI tests must launch the application that they test. 27 | let app = XCUIApplication() 28 | app.launch() 29 | 30 | // Use recording to get started writing UI tests. 31 | // Use XCTAssert and related functions to verify your tests produce the correct results. 32 | } 33 | 34 | func testLaunchPerformance() throws { 35 | if #available(macOS 10.15, iOS 13.0, tvOS 13.0, *) { 36 | // This measures how long it takes to launch your application. 37 | measure(metrics: [XCTApplicationLaunchMetric()]) { 38 | XCUIApplication().launch() 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /sunshine/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleDisplayName 8 | Sunshine 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 19 | CFBundleShortVersionString 20 | 1.0 21 | CFBundleVersion 22 | 1 23 | LSRequiresIPhoneOS 24 | 25 | UIApplicationSceneManifest 26 | 27 | UIApplicationSupportsMultipleScenes 28 | 29 | 30 | UIApplicationSupportsIndirectInputEvents 31 | 32 | UILaunchScreen 33 | 34 | UIRequiredDeviceCapabilities 35 | 36 | armv7 37 | 38 | UISupportedInterfaceOrientations 39 | 40 | UIInterfaceOrientationPortrait 41 | 42 | UISupportedInterfaceOrientations~ipad 43 | 44 | UIInterfaceOrientationPortrait 45 | UIInterfaceOrientationPortraitUpsideDown 46 | UIInterfaceOrientationLandscapeLeft 47 | UIInterfaceOrientationLandscapeRight 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /sunshine/LocationService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocationSearchService.swift 3 | // sunshine 4 | // 5 | // Created by Maxime on 9/24/20. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import MapKit 11 | import Combine 12 | 13 | /** 14 | This location search service is based on https://www.mozzafiller.com/posts/mklocalsearchcompleter-swiftui-combine 15 | */ 16 | 17 | class LocationSearchService: NSObject, ObservableObject, MKLocalSearchCompleterDelegate { 18 | @Published var searchQuery = "" 19 | var completer: MKLocalSearchCompleter 20 | @Published var completions: [MKLocalSearchCompletion] = [] 21 | var cancellable: AnyCancellable? 22 | 23 | override init() { 24 | completer = MKLocalSearchCompleter() 25 | super.init() 26 | cancellable = $searchQuery.assign(to: \.queryFragment, on: self.completer) 27 | completer.delegate = self 28 | completer.resultTypes = .address 29 | } 30 | 31 | func completer(_ completer: MKLocalSearchCompleter, didFailWithError: Error) { 32 | // Set the results to empty in case the search query is empty or in case there's an uknown error 33 | self.completions = [] 34 | } 35 | 36 | func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) { 37 | // Filter the results 38 | self.completions = completer.results.filter { result in 39 | 40 | 41 | if result.title.rangeOfCharacter(from: CharacterSet.decimalDigits) != nil { 42 | return false 43 | } 44 | 45 | if result.subtitle.rangeOfCharacter(from: CharacterSet.decimalDigits) != nil { 46 | return false 47 | } 48 | 49 | return true 50 | } 51 | } 52 | } 53 | 54 | extension MKLocalSearchCompletion: Identifiable {} 55 | -------------------------------------------------------------------------------- /sunshine/SearchBar.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchBar.swift 3 | // sunshine 4 | // 5 | // Created by Maxime on 9/24/20. 6 | // 7 | 8 | import SwiftUI 9 | 10 | 11 | /** 12 | This search bar element is based on https://www.mozzafiller.com/posts/mklocalsearchcompleter-swiftui-combine 13 | */ 14 | 15 | struct SearchBar: UIViewRepresentable { 16 | @Binding var text: String 17 | @Binding var toggled: Bool 18 | 19 | class Coordinator: NSObject, UISearchBarDelegate { 20 | 21 | @Binding var text: String 22 | @Binding var toggled: Bool 23 | 24 | init(text: Binding, toggled: Binding) { 25 | _text = text 26 | _toggled = toggled 27 | } 28 | 29 | func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { 30 | text = searchText 31 | 32 | } 33 | 34 | func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) { 35 | searchBar.setShowsCancelButton(true, animated: true) 36 | toggled = true 37 | } 38 | 39 | func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { 40 | searchBar.resignFirstResponder() 41 | searchBar.setShowsCancelButton(false, animated: true) 42 | toggled = false 43 | } 44 | 45 | } 46 | 47 | func makeCoordinator() -> SearchBar.Coordinator { 48 | return Coordinator(text: $text, toggled: $toggled) 49 | } 50 | 51 | func makeUIView(context: UIViewRepresentableContext) -> UISearchBar { 52 | let searchBar = UISearchBar(frame: .zero) 53 | searchBar.delegate = context.coordinator 54 | searchBar.searchBarStyle = .minimal 55 | searchBar.placeholder = "Enter a city name e.g. New York" 56 | searchBar.setShowsCancelButton(true, animated: true) 57 | searchBar.becomeFirstResponder() 58 | return searchBar 59 | } 60 | 61 | func updateUIView(_ uiView: UISearchBar, context: UIViewRepresentableContext) { 62 | uiView.text = text 63 | } 64 | } 65 | 66 | -------------------------------------------------------------------------------- /sunshine/Persistence.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Persistence.swift 3 | // sunshine 4 | // 5 | // Created by Maxime on 11/26/20. 6 | // 7 | 8 | import CoreData 9 | 10 | struct PersistenceController { 11 | static let shared = PersistenceController() 12 | 13 | static var preview: PersistenceController = { 14 | let result = PersistenceController(inMemory: true) 15 | let viewContext = result.container.viewContext 16 | for _ in 0..<10 { 17 | let newItem = Item(context: viewContext) 18 | newItem.timestamp = Date() 19 | } 20 | do { 21 | try viewContext.save() 22 | } catch { 23 | // Replace this implementation with code to handle the error appropriately. 24 | // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. 25 | let nsError = error as NSError 26 | fatalError("Unresolved error \(nsError), \(nsError.userInfo)") 27 | } 28 | return result 29 | }() 30 | 31 | let container: NSPersistentContainer 32 | 33 | init(inMemory: Bool = false) { 34 | container = NSPersistentContainer(name: "sunshine") 35 | if inMemory { 36 | container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null") 37 | } 38 | container.loadPersistentStores(completionHandler: { (storeDescription, error) in 39 | if let error = error as NSError? { 40 | // Replace this implementation with code to handle the error appropriately. 41 | // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. 42 | 43 | /* 44 | Typical reasons for an error here include: 45 | * The parent directory does not exist, cannot be created, or disallows writing. 46 | * The persistent store is not accessible, due to permissions or data protection when the device is locked. 47 | * The device is out of space. 48 | * The store could not be migrated to the current model version. 49 | Check the error message to determine what the actual problem was. 50 | */ 51 | fatalError("Unresolved error \(error), \(error.userInfo)") 52 | } 53 | }) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /sunshine/WeatherAPIClient.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Store.swift 3 | // sunshine 4 | // 5 | // Created by Maxime on 11/26/20. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | 11 | enum ViewState: String{ 12 | case loading 13 | case error 14 | case idle 15 | } 16 | 17 | enum Temperature: Int{ 18 | case fahrenheit = 0 19 | case celsius = 1 20 | case kelvin = 2 21 | } 22 | 23 | enum Speed: Int{ 24 | case mph = 0 25 | case kmh = 1 26 | case mps = 2 27 | } 28 | 29 | enum MainDaypart: String{ 30 | case dawn 31 | case mid 32 | case dusk 33 | case night 34 | } 35 | 36 | enum MainWeatherType: String, Decodable{ 37 | case Thunderstorm 38 | case Drizzle 39 | case Rain 40 | case Snow 41 | case Clear 42 | case Few_Clouds 43 | case Clouds 44 | case Mist 45 | case Haze 46 | case Fog 47 | case Smoke 48 | } 49 | 50 | struct WeatherObject: Decodable { 51 | let id: Int 52 | let main: MainWeatherType 53 | let description: String 54 | } 55 | 56 | struct CurrentForecast: Decodable { 57 | let dt: Int 58 | let sunrise: Int 59 | let sunset: Int 60 | let temp: Double 61 | let feelsLike: Double 62 | let pressure: Int 63 | let humidity: Int 64 | let dewPoint: Double 65 | let clouds: Int 66 | let visibility: Int 67 | let windSpeed: Double 68 | let windDeg: Int 69 | let weather: [WeatherObject] 70 | } 71 | 72 | struct HourlyForecast: Hashable, Decodable { 73 | 74 | static func == (lhs: HourlyForecast, rhs: HourlyForecast) -> Bool { 75 | return lhs.dt > rhs.dt 76 | } 77 | 78 | func hash(into hasher: inout Hasher) { 79 | hasher.combine(dt) 80 | } 81 | 82 | let dt: Int 83 | let temp: Double 84 | let feelsLike: Double 85 | let pressure: Int 86 | let humidity: Int 87 | let dewPoint: Double 88 | let clouds: Int 89 | let visibility: Int 90 | let windSpeed: Double 91 | let weather: [WeatherObject] 92 | } 93 | 94 | struct DetailTemp: Decodable { 95 | let day: Double 96 | let min: Double 97 | let max: Double 98 | let night: Double 99 | let eve: Double 100 | let morn: Double 101 | } 102 | 103 | struct DetailFeelsLikeTemp: Decodable { 104 | let day: Double 105 | let night: Double 106 | let eve: Double 107 | let morn: Double 108 | } 109 | 110 | struct DailyForecast: Hashable, Decodable { 111 | static func == (lhs: DailyForecast, rhs: DailyForecast) -> Bool { 112 | return lhs.dt > rhs.dt 113 | } 114 | 115 | func hash(into hasher: inout Hasher) { 116 | hasher.combine(dt) 117 | } 118 | 119 | let dt: Int 120 | let sunrise: Int 121 | let sunset: Int 122 | let temp: DetailTemp 123 | let feelsLike: DetailFeelsLikeTemp 124 | let pressure: Int 125 | let humidity: Int 126 | let dewPoint: Double 127 | let windSpeed: Double 128 | let windDeg: Int 129 | let weather: [WeatherObject] 130 | let clouds: Int 131 | } 132 | 133 | struct Response: Decodable { 134 | let lat: Double 135 | let lon: Double 136 | let timezone: String 137 | let timezoneOffset: Int 138 | let current: CurrentForecast 139 | let hourly: [HourlyForecast] 140 | let daily: [DailyForecast] 141 | 142 | } 143 | 144 | class WeatherAPIClient { 145 | private let apiKey = ""// OPEN WEATHER MAP TOKEN GOES HERE 146 | private let decoder = JSONDecoder() 147 | private let session: URLSession 148 | 149 | init(configuration: URLSessionConfiguration) { 150 | self.session = URLSession(configuration: configuration) 151 | } 152 | 153 | convenience init() { 154 | self.init(configuration: .default) 155 | } 156 | 157 | func fetch (lat: Double, lon: Double, handler: @escaping (Result) -> Void) { 158 | let baseURL = URL(string: "https://api.openweathermap.org/data/2.5/onecall?lat=\(lat)&lon=\(lon)&appid=\(apiKey)&exclude=minutely") 159 | // Ensure the URL is unpacked 160 | guard let url = baseURL else { 161 | print("error...") 162 | return 163 | } 164 | 165 | let request = URLRequest(url: url) 166 | 167 | // Fetch data with URL session 168 | session.dataTask(with: request) {(data, response, error) in 169 | // If an error occurs, call the callback with the error 170 | if let error = error { 171 | handler(.failure(error)) 172 | } else { 173 | // If there's no error try to decode the JSON response 174 | // The following is equivalent to a try/catch block in JS 175 | do { 176 | // Convert from snake case to camel case 177 | self.decoder.keyDecodingStrategy = .convertFromSnakeCase 178 | let data = data ?? Data() 179 | let response = try self.decoder.decode(Response.self, from: data) 180 | // Call the callback with the response 181 | handler(.success(response)) 182 | } catch { 183 | // Call the callback with an error if decoding the JSON fails 184 | handler(.failure(error)) 185 | } 186 | } 187 | 188 | }.resume() 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /sunshine/SunDial.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SunDial.swift 3 | // pagetest 4 | // 5 | // Created by Maxime on 9/14/20. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct Arc: Shape { 11 | var startAngle: Angle = .degrees(0) 12 | var endAngle: Angle = .degrees(180) 13 | var clockWise: Bool = true 14 | 15 | func path(in rect: CGRect) -> Path { 16 | var path = Path() 17 | 18 | let center = CGPoint(x: rect.midX, y: rect.maxY) 19 | let radius = CGFloat(130) 20 | 21 | path.addArc(center: center, radius: radius, startAngle: startAngle, endAngle: endAngle, clockwise: clockWise) 22 | 23 | return path 24 | } 25 | } 26 | // 27 | //struct XAxis: Shape { 28 | // func path(in rect: CGRect) -> Path { 29 | // var path = Path() 30 | // 31 | // let center = CGPoint(x: rect.midX, y: rect.maxY) 32 | // 33 | // path.move(to: center) 34 | // path.addLine(to: CGPoint(x: rect.minX, y: center.y)) 35 | // path.addLine(to: CGPoint(x: rect.maxX, y: center.y)) 36 | // 37 | // 38 | // return path 39 | // } 40 | //} 41 | 42 | struct SunDial: View { 43 | var sunrise: Int 44 | var sunset: Int 45 | var timestamp: Int 46 | var timezone: String 47 | 48 | let strokeDash: [CGFloat] = [5,5] 49 | 50 | @State private var targetAngle: Double = 0 51 | 52 | // let timer = Timer.publish(every: 600, on: .main, in: .common).autoconnect() 53 | var date: Date { Date(timeIntervalSince1970: Double(timestamp))} 54 | 55 | 56 | var nextAngle: Double { 57 | updateTargetAngle(date) 58 | } 59 | 60 | var body: some View { 61 | return 62 | ZStack { 63 | GeometryReader { geometry in 64 | VStack(spacing: 15) { 65 | Text("Sun from \(formatDateToHoursMinutes(sunrise)) to \(formatDateToHoursMinutes(sunset))") 66 | .font(.system(size: 14, weight: .semibold)) 67 | .foregroundColor(.black) 68 | .padding(.top, 15) 69 | ZStack(alignment: Alignment(horizontal: .center, vertical: .bottom)) { 70 | Arc() 71 | .fill(LinearGradient( 72 | gradient: Gradient(stops: [ 73 | .init(color: Color(#colorLiteral(red: 1, green: 0.892666757106781, blue: 0.6166666746139526, alpha: 1)), location: 0), 74 | .init(color: Color(#colorLiteral(red: 1, green: 0.6822500228881836, blue: 0.612500011920929, alpha: 0.30000001192092896)), location: 1)]), 75 | startPoint: UnitPoint(x: 0.5, y: 6.905165726926942e-15), 76 | endPoint: UnitPoint(x: 0.49999995240056083, y: 0.9718156807337223))) 77 | // .stroke(Color.yellow, style: StrokeStyle(lineWidth: 2, dash: strokeDash)) 78 | 79 | .frame(height: 130) 80 | .opacity(0.7) 81 | // XAxis() 82 | // .frame(width: 300, height: 5) 83 | HStack(spacing: 245) { 84 | VStack { 85 | Circle() 86 | .fill(Color.yellow) 87 | .frame(width: 10, height: 10) 88 | } 89 | VStack { 90 | Circle() 91 | .fill(Color.yellow) 92 | .frame(width: 10, height: 10) 93 | 94 | } 95 | }.padding(.bottom, 0) 96 | VStack { 97 | if (Int(date.timeIntervalSince1970) >= sunrise && Int(date.timeIntervalSince1970) < sunset) { 98 | Image("sun-mid") 99 | .resizable() 100 | .rotationEffect(.degrees(-self.targetAngle)) 101 | .scaledToFit() 102 | .frame(height: 50) 103 | .position(x: 0, y: 0) 104 | .rotationEffect( .degrees(self.targetAngle)) 105 | .animation(Animation.easeInOut(duration: 3 + 3 * self.targetAngle / 180)) 106 | } 107 | 108 | } 109 | .frame(width: 260, height: 1) 110 | } 111 | } 112 | } 113 | .frame(height: 175) 114 | .clipped() 115 | .padding(.leading,10) 116 | .padding(.trailing,10) 117 | .animation(Animation.easeOut(duration: 0.7).delay(0)) 118 | // On timer update 119 | // .onReceive(timer){time in 120 | // self.targetAngle = updateTargetAngle(time) 121 | // } 122 | // On prop change 123 | .onChange(of: nextAngle) { angle in 124 | self.targetAngle = angle 125 | } 126 | // On mount 127 | .onAppear { 128 | // DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) { 129 | self.targetAngle = updateTargetAngle(date) 130 | // } 131 | } 132 | } 133 | } 134 | 135 | func updateTargetAngle(_ updatedDate: Date) -> Double { 136 | return Double((Int(updatedDate.timeIntervalSince1970) - sunrise) * 180 / (sunset - sunrise)) 137 | } 138 | 139 | func formatDateToHoursMinutes(_ timestamp: Int) -> String { 140 | let date = Date(timeIntervalSince1970: Double(timestamp)) 141 | let formatter = DateFormatter() 142 | formatter.timeStyle = .short 143 | formatter.locale = NSLocale.current 144 | formatter.timeZone = TimeZone(identifier: timezone) 145 | var formattedDate:String {formatter.string(from: date)} 146 | 147 | return formattedDate 148 | } 149 | 150 | func str(hour: Int, minutes: Int) -> Double { 151 | return Double(hour) + Double(minutes) / 60 152 | } 153 | } 154 | 155 | struct SunDial_Previews: PreviewProvider { 156 | static var previews: some View { 157 | SunDial(sunrise: 1600374388, sunset: 1600418714, timestamp: 1600394388, timezone: "Asia/Tokyo") 158 | } 159 | } 160 | 161 | -------------------------------------------------------------------------------- /sunshine/Settings.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Settings.swift 3 | // sunshine 4 | // 5 | // Created by Maxime on 9/20/20. 6 | // 7 | 8 | import SwiftUI 9 | import MapKit 10 | 11 | enum Section: Int{ 12 | case locations = 0 13 | case settings = 1 14 | } 15 | 16 | struct BackgroundClearView: UIViewRepresentable { 17 | func makeUIView(context: Context) -> UIView { 18 | let view = UIView() 19 | DispatchQueue.main.async { 20 | view.superview?.superview?.backgroundColor = .clear 21 | } 22 | return view 23 | } 24 | 25 | func updateUIView(_ uiView: UIView, context: Context) {} 26 | } 27 | 28 | struct SettingsView: View { 29 | @EnvironmentObject var viewModel: ViewModel 30 | 31 | var body: some View { 32 | VStack { 33 | VStack { 34 | HStack { 35 | Text("Temperature") 36 | .font(.system(size: 18, weight: .medium)) 37 | .foregroundColor(.secondary) 38 | Spacer() 39 | } 40 | Picker(selection: $viewModel.temperature, label: Text("Temperature"), content: { 41 | Text("Fahrenheit °F 🇺🇸").tag(Temperature.fahrenheit) 42 | Text("Celsius °C 🌍").tag(Temperature.celsius) 43 | Text("Kelvin °K 🤓").tag(Temperature.kelvin) 44 | }) 45 | .pickerStyle(SegmentedPickerStyle()) 46 | } 47 | .padding(.top, 20) 48 | VStack { 49 | HStack { 50 | Text("Wind Speed") 51 | .font(.system(size: 18, weight: .medium)) 52 | .foregroundColor(.secondary) 53 | Spacer() 54 | } 55 | Picker(selection: $viewModel.speed, label: Text("Speed"), content: { 56 | Text("mph 🇺🇸").tag(Speed.mph) 57 | Text("km/h 🌍").tag(Speed.kmh) 58 | Text("m/s 🤓").tag(Speed.mps) 59 | }) 60 | .pickerStyle(SegmentedPickerStyle()) 61 | } 62 | .padding(.top, 20) 63 | } 64 | .padding([.leading, .trailing], 12) 65 | } 66 | } 67 | 68 | struct SheetPane: View { 69 | @Environment(\.managedObjectContext) private var viewContext 70 | @EnvironmentObject var viewModel: ViewModel 71 | 72 | @ObservedObject var locationSearchService: LocationSearchService 73 | var savedLocations: FetchedResults 74 | 75 | @State private var isSearchToggled = false 76 | @State private var selection = Section.locations 77 | 78 | var currentTitles: [String] { 79 | savedLocations.map { $0.title! } 80 | } 81 | 82 | var body: some View { 83 | ZStack { 84 | VisualEffectView(effect: UIBlurEffect(style: .systemThinMaterial)).ignoresSafeArea(.all) 85 | VStack { 86 | Picker(selection: $selection, label: Text(""), content: { 87 | Text("Locations").tag(Section.locations) 88 | Text("Settings").tag(Section.settings) 89 | 90 | }) 91 | .pickerStyle(SegmentedPickerStyle()) 92 | .padding([.leading, .trailing], 100) 93 | 94 | if(selection == Section.settings) { 95 | GeometryReader { _ in 96 | SettingsView() 97 | } 98 | } 99 | 100 | if(selection == Section.locations) { 101 | GeometryReader { geometry in 102 | // If Search is not toggled: show the list of saved locations 103 | if (!isSearchToggled) { 104 | VStack { 105 | VStack { 106 | HStack { 107 | Text("Your saved locations (\(savedLocations.count)/10)") 108 | .font(.system(size: 18, weight: .medium)) 109 | .foregroundColor(.secondary) 110 | Spacer() 111 | } 112 | .padding([.leading, .trailing], 12) 113 | .padding(.top, 20) 114 | .padding(.bottom, 10) 115 | 116 | List { 117 | ForEach(savedLocations) { item in 118 | Text(item.name ?? "") 119 | } 120 | .onDelete(perform: removeLocation) 121 | .listRowBackground(Color.clear) 122 | } 123 | } 124 | Button(action: { 125 | self.isSearchToggled = true 126 | }) { 127 | Image(systemName: "magnifyingglass") 128 | .font(.system(size: 18, weight: .medium)) 129 | Text("Add locations") 130 | } 131 | .disabled(savedLocations.count >= 10) 132 | .padding(.bottom, 10) 133 | } 134 | // If search is toggled: show the search locations view 135 | } else { 136 | VStack { 137 | SearchBar(text: $locationSearchService.searchQuery, toggled: $isSearchToggled) 138 | // If there are no items in the results of the search query (completion) AND if the search query is not empty, show the empty state 139 | if (locationSearchService.completions.count == 0 && locationSearchService.searchQuery != "" ) { 140 | Spacer() 141 | Text("No results found.") 142 | .font(.system(size: 14, weight: .medium)) 143 | .foregroundColor(.secondary) 144 | Spacer() 145 | // Else show the list 146 | } else { 147 | List { 148 | ForEach(locationSearchService.completions, id: \.self) { completion in 149 | VStack(alignment: .leading) { 150 | HStack { 151 | Text(completion.title) 152 | // If this completion is not yet added in the locations array, add the Add Location button 153 | if (!currentTitles.contains(completion.title)) { 154 | Spacer() 155 | Button(action: { 156 | addLocation(completion) 157 | }) { 158 | Image(systemName: "plus") 159 | } 160 | // Else show a checkmark to tell the user this view has already been added 161 | } else { 162 | Spacer() 163 | Image(systemName: "checkmark") 164 | 165 | } 166 | } 167 | } 168 | .padding([.leading, .trailing], 10) 169 | } 170 | .listRowBackground(Color.clear) 171 | } 172 | } 173 | } 174 | } 175 | } 176 | } 177 | } 178 | .padding([.top], 20) 179 | } 180 | .onAppear { 181 | UITableView.appearance().backgroundColor = UIColor.clear 182 | UITableViewCell.appearance().backgroundColor = UIColor.clear 183 | } 184 | .background(BackgroundClearView()) 185 | } 186 | 187 | func addLocation(_ completion: MKLocalSearchCompletion) { 188 | let searchRequest = MKLocalSearch.Request(completion: completion) 189 | let search = MKLocalSearch(request: searchRequest) 190 | 191 | search.start { response, error in 192 | guard let coordinate = response?.mapItems[0].placemark.coordinate else { 193 | return 194 | } 195 | 196 | guard let name = response?.mapItems[0].name else { 197 | return 198 | } 199 | 200 | let lat = coordinate.latitude 201 | let lon = coordinate.longitude 202 | 203 | let newSavedLocation = Location(context: viewContext) 204 | newSavedLocation.name = name 205 | newSavedLocation.title = completion.title 206 | newSavedLocation.lat = lat 207 | newSavedLocation.lon = lon 208 | newSavedLocation.id = UUID() 209 | newSavedLocation.timestamp = Date() 210 | 211 | do { 212 | try viewContext.save() 213 | } catch { 214 | // Replace this implementation with code to handle the error appropriately. 215 | // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. 216 | let nsError = error as NSError 217 | fatalError("Unresolved error \(nsError), \(nsError.userInfo)") 218 | } 219 | } 220 | } 221 | 222 | func removeLocation(at offsets: IndexSet) { 223 | viewModel.items.remove(atOffsets: offsets) 224 | offsets.map { savedLocations[$0] }.forEach(viewContext.delete) 225 | 226 | do { 227 | try viewContext.save() 228 | } catch { 229 | // Replace this implementation with code to handle the error appropriately. 230 | // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. 231 | let nsError = error as NSError 232 | fatalError("Unresolved error \(nsError), \(nsError.userInfo)") 233 | } 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /sunshine/Forecast.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Forecast.swift 3 | // sunshine 4 | // 5 | // Created by Maxime on 11/25/20. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct WeatherImage: View { 11 | var weather: WeatherObject 12 | var daypart: MainDaypart 13 | 14 | var body: some View { 15 | ZStack { 16 | if(weather.id == 800) { 17 | if (daypart == MainDaypart.night) { 18 | Image("moon") 19 | .resizable() 20 | .scaledToFit() 21 | } 22 | 23 | if (daypart == MainDaypart.mid) { 24 | Image("sun-mid") 25 | .resizable() 26 | .scaledToFit() 27 | } 28 | 29 | if(daypart == MainDaypart.dawn || daypart == MainDaypart.dusk) { 30 | Image("sun-dawn") 31 | .resizable() 32 | .scaledToFit() 33 | } 34 | } 35 | 36 | if (weather.id == 801 || weather.id == 802) { 37 | Image("clouds") 38 | .resizable() 39 | .scaledToFit() 40 | } 41 | 42 | if (weather.id > 802) { 43 | Image("big-cloud") 44 | .resizable() 45 | .scaledToFit() 46 | } 47 | 48 | if (weather.id >= 500 && weather.id < 600) { 49 | if (weather.id <= 501) { 50 | Image("light-rain-drops") 51 | .resizable() 52 | .scaledToFit() 53 | } else { 54 | Image("heavy-rain-drops") 55 | .resizable() 56 | .scaledToFit() 57 | } 58 | } 59 | 60 | if (weather.id >= 300 && weather.id < 400) { 61 | if (weather.id <= 311) { 62 | Image("light-rain-drops") 63 | .resizable() 64 | .scaledToFit() 65 | } else { 66 | Image("heavy-rain-drops") 67 | .resizable() 68 | .scaledToFit() 69 | } 70 | } 71 | } 72 | } 73 | } 74 | 75 | struct HourlyCard: View { 76 | var forecast: HourlyForecast 77 | var timezone: String 78 | var sunrise: Int 79 | var sunset: Int 80 | var nextDaySunrise: Int 81 | var tempUnit: Temperature 82 | 83 | 84 | var currentDayPart: MainDaypart { 85 | return getDayPart(sunrise: Double(sunrise), sunset: Double(sunset), nextDaySunrise: Double(nextDaySunrise), timestamp: Double(forecast.dt)) 86 | } 87 | 88 | var body: some View { 89 | ZStack { 90 | RoundedRectangle(cornerRadius: 15) 91 | .fill(LinearGradient( 92 | gradient: Gradient(colors: gradients[currentDayPart]!), 93 | startPoint: UnitPoint(x: 0.5, y: 0), 94 | endPoint: UnitPoint(x: 0.5, y: 0.6))) 95 | .frame(width: 60, height: 120) 96 | .shadow(color: Color(#colorLiteral(red: 0, green: 0, blue: 0, alpha: 0.25)), radius:5, x:0, y:4) 97 | if (isCloudy(forecast.weather[0].id)) { 98 | if (currentDayPart == MainDaypart.night) { 99 | RoundedRectangle(cornerRadius: 15) 100 | .fill(LinearGradient( 101 | gradient: Gradient(stops: [ 102 | .init(color: Color(#colorLiteral(red: 0.09019608050584793, green: 0.1411764770746231, blue: 0.16862745583057404, alpha: 1)), location: 0), 103 | .init(color: Color(#colorLiteral(red: 0.250980406999588, green: 0.2705882489681244, blue: 0.3294117748737335, alpha: 1)), location: 1)]), 104 | startPoint: UnitPoint(x: 0, y: 0.5), 105 | endPoint: UnitPoint(x: 1.2, y: 0.5))) 106 | .opacity(0.7) 107 | .frame(width: 60, height: 120) 108 | } else { 109 | RoundedRectangle(cornerRadius: 15) 110 | .fill(LinearGradient( 111 | gradient: Gradient(stops: [ 112 | .init(color: Color(#colorLiteral(red: 0.6976562142, green: 0.7035937309, blue: 0.7124999762, alpha: 0.700952492)), location: 0), 113 | .init(color: Color(#colorLiteral(red: 0.8545138835906982, green: 0.8718518614768982, blue: 0.8916666507720947, alpha: 0.20000000298023224)), location: 1)]), 114 | startPoint: UnitPoint(x: 0.5, y: 0), 115 | endPoint: UnitPoint(x: 0.5, y: 0.5))) 116 | .opacity(0.7) 117 | .frame(width: 60, height: 120) 118 | } 119 | } 120 | VStack { 121 | FormattedTemperature(temperature: forecast.temp, unit: tempUnit) 122 | .font(.system(size: 14, weight: .medium)) 123 | .foregroundColor(.white) 124 | .padding(.top, 10) 125 | WeatherImage(weather: forecast.weather[0], daypart: currentDayPart) 126 | .frame(width: 50, height: 50) 127 | ZStack { 128 | VisualEffectView(effect: UIBlurEffect(style: .light)) 129 | Text("\(formatToLocalTime(forecast.dt, timezone))") 130 | .font(.system(size: 11, weight: .medium)) 131 | .foregroundColor(.black) 132 | } 133 | .frame(height: 25, alignment: .bottom) 134 | } 135 | .cornerRadius(15) 136 | .frame(height: 120) 137 | .clipped() 138 | } 139 | .frame(height: 140) 140 | } 141 | 142 | func formatToLocalTime(_ timestamp: Int, _ timezone: String) -> String { 143 | let date = Date(timeIntervalSince1970: Double(timestamp)) 144 | let formatter = DateFormatter() 145 | formatter.timeStyle = .short 146 | formatter.locale = NSLocale.current 147 | formatter.timeZone = TimeZone(identifier: timezone) 148 | var formattedDate:String {formatter.string(from: date)} 149 | 150 | return formattedDate 151 | } 152 | } 153 | 154 | struct NextSixHours: View { 155 | var hourly: [HourlyForecast] 156 | var timezone: String 157 | var sunrise: Int 158 | var sunset: Int 159 | var nextDaySunrise: Int 160 | var tempUnit: Temperature 161 | 162 | var body: some View { 163 | ScrollView(.horizontal, showsIndicators: false) { 164 | HStack(spacing: 0) { 165 | ForEach(hourly.prefix(6), id: \.dt) { forecast in 166 | HourlyCard(forecast: forecast, timezone:timezone, sunrise: sunrise, sunset: sunset, nextDaySunrise: nextDaySunrise, tempUnit: tempUnit) 167 | .padding(.leading, 10) 168 | } 169 | } 170 | } 171 | } 172 | } 173 | 174 | struct DailyCard: View { 175 | var forecast: DailyForecast 176 | var tempUnit: Temperature 177 | 178 | let height: CGFloat = 70 179 | 180 | let formatter: DateFormatter = { 181 | let formatter = DateFormatter() 182 | formatter.dateFormat = "E" 183 | return formatter 184 | }() 185 | 186 | var body: some View { 187 | ZStack { 188 | RoundedRectangle(cornerRadius: 20) 189 | .fill(LinearGradient( 190 | gradient: Gradient(colors: gradients[MainDaypart.mid]!), 191 | startPoint: UnitPoint(x: 0, y: 0.5), 192 | endPoint: UnitPoint(x: 1.2, y: 0.5))) 193 | .frame(height: height) 194 | .shadow(color: Color(#colorLiteral(red: 0, green: 0, blue: 0, alpha: 0.25)), radius:5, x:0, y:4) 195 | if (isCloudy(forecast.weather[0].id)) { 196 | RoundedRectangle(cornerRadius: 20) 197 | .fill(LinearGradient( 198 | gradient: Gradient(stops: [ 199 | .init(color: Color(#colorLiteral(red: 0.6976562142, green: 0.7035937309, blue: 0.7124999762, alpha: 0.700952492)), location: 0), 200 | .init(color: Color(#colorLiteral(red: 0.8545138835906982, green: 0.8718518614768982, blue: 0.8916666507720947, alpha: 0.20000000298023224)), location: 1)]), 201 | startPoint: UnitPoint(x: 0, y: 0.5), 202 | endPoint: UnitPoint(x: 1.2, y: 0.5))) 203 | .opacity(0.7) 204 | .frame(height: height) 205 | } 206 | HStack { 207 | Text(Date(timeIntervalSince1970: Double(forecast.dt)), formatter: formatter) 208 | .font(.system(size: 18, weight: .medium)) 209 | .foregroundColor(.white) 210 | Spacer() 211 | HStack(alignment: .bottom) { 212 | FormattedTemperature(temperature: forecast.temp.max, unit: tempUnit) 213 | .font(.system(size: 18, weight: .medium)) 214 | .foregroundColor(.white) 215 | FormattedTemperature(temperature: forecast.temp.min, unit: tempUnit) 216 | .font(.system(size: 14, weight: .medium)) 217 | .foregroundColor(.white) 218 | } 219 | HStack { 220 | Text("\(forecast.weather[0].main.rawValue)") 221 | .font(.system(size: 14, weight: .medium)) 222 | .foregroundColor(.white) 223 | WeatherImage(weather: forecast.weather[0], daypart: MainDaypart.mid) 224 | .frame(width: 50, height: 50) 225 | }.frame(width: 150, alignment: .trailing) 226 | 227 | } 228 | .padding([.leading, .trailing], 10) 229 | } 230 | } 231 | } 232 | 233 | struct NextSevenDays: View { 234 | var daily: [DailyForecast] 235 | var tempUnit: Temperature 236 | 237 | var body: some View { 238 | VStack { 239 | ForEach(daily.suffix(7), id: \.dt) { forecast in 240 | DailyCard(forecast: forecast, tempUnit: tempUnit) 241 | .padding([.leading, .trailing], 10) 242 | .padding([.bottom], 5) 243 | } 244 | } 245 | } 246 | } 247 | 248 | struct ForecastView: View { 249 | @EnvironmentObject var viewModel: ViewModel 250 | 251 | var body: some View { 252 | VStack { 253 | HStack { 254 | Text("Forecast").font(.system(size: 24, weight: .medium)) 255 | Spacer() 256 | } 257 | .padding([.leading, .trailing], 20) 258 | .padding(.top, 5) 259 | .padding(.bottom, 10) 260 | 261 | if(viewModel.state == ViewState.loading) { 262 | Text("loading") 263 | } 264 | 265 | if(viewModel.state == ViewState.idle && viewModel.focusedItem != nil){ 266 | ScrollView { 267 | VStack { 268 | HStack { 269 | Text("Next 6 Hours").font(.system(size: 18, weight: .medium)) 270 | Spacer() 271 | } 272 | .padding([.leading, .trailing], 20) 273 | NextSixHours(hourly: viewModel.focusedItem!.hourly, timezone: viewModel.focusedItem!.timezone, sunrise: viewModel.focusedItem!.current.sunrise, sunset: viewModel.focusedItem!.current.sunset, nextDaySunrise: viewModel.focusedItem!.daily[1].sunrise, tempUnit: viewModel.temperature) 274 | HStack { 275 | Text("Next 7 Days").font(.system(size: 18, weight: .medium)) 276 | Spacer() 277 | } 278 | .padding([.top, .leading, .trailing], 20) 279 | NextSevenDays(daily: viewModel.focusedItem!.daily, tempUnit: viewModel.temperature) 280 | } 281 | } 282 | .padding(.bottom, 30) 283 | } 284 | } 285 | } 286 | } 287 | 288 | -------------------------------------------------------------------------------- /sunshine.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 50; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 9C7992A425701B180072BEE2 /* sunshineApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C7992A325701B180072BEE2 /* sunshineApp.swift */; }; 11 | 9C7992A625701B180072BEE2 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C7992A525701B180072BEE2 /* ContentView.swift */; }; 12 | 9C7992A825701B1D0072BEE2 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9C7992A725701B1D0072BEE2 /* Assets.xcassets */; }; 13 | 9C7992AB25701B1D0072BEE2 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9C7992AA25701B1D0072BEE2 /* Preview Assets.xcassets */; }; 14 | 9C7992AD25701B1D0072BEE2 /* Persistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C7992AC25701B1D0072BEE2 /* Persistence.swift */; }; 15 | 9C7992B025701B1D0072BEE2 /* sunshine.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 9C7992AE25701B1D0072BEE2 /* sunshine.xcdatamodeld */; }; 16 | 9C7992BB25701B1D0072BEE2 /* sunshineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C7992BA25701B1D0072BEE2 /* sunshineTests.swift */; }; 17 | 9C7992C625701B1D0072BEE2 /* sunshineUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C7992C525701B1D0072BEE2 /* sunshineUITests.swift */; }; 18 | 9C7992D725701C770072BEE2 /* WeatherAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C7992D625701C770072BEE2 /* WeatherAPIClient.swift */; }; 19 | 9C7992DC25701CC10072BEE2 /* SunDial.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C7992DB25701CC10072BEE2 /* SunDial.swift */; }; 20 | 9C7992E125701CDB0072BEE2 /* Settings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C7992E025701CDB0072BEE2 /* Settings.swift */; }; 21 | 9C7992E625701D170072BEE2 /* Forecast.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C7992E525701D170072BEE2 /* Forecast.swift */; }; 22 | 9C7992EB25701D530072BEE2 /* LocationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C7992EA25701D530072BEE2 /* LocationService.swift */; }; 23 | 9C7992F025701D8A0072BEE2 /* SearchBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C7992EF25701D8A0072BEE2 /* SearchBar.swift */; }; 24 | /* End PBXBuildFile section */ 25 | 26 | /* Begin PBXContainerItemProxy section */ 27 | 9C7992B725701B1D0072BEE2 /* PBXContainerItemProxy */ = { 28 | isa = PBXContainerItemProxy; 29 | containerPortal = 9C79929825701B180072BEE2 /* Project object */; 30 | proxyType = 1; 31 | remoteGlobalIDString = 9C79929F25701B180072BEE2; 32 | remoteInfo = sunshine; 33 | }; 34 | 9C7992C225701B1D0072BEE2 /* PBXContainerItemProxy */ = { 35 | isa = PBXContainerItemProxy; 36 | containerPortal = 9C79929825701B180072BEE2 /* Project object */; 37 | proxyType = 1; 38 | remoteGlobalIDString = 9C79929F25701B180072BEE2; 39 | remoteInfo = sunshine; 40 | }; 41 | /* End PBXContainerItemProxy section */ 42 | 43 | /* Begin PBXFileReference section */ 44 | 9C7992A025701B180072BEE2 /* sunshine.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = sunshine.app; sourceTree = BUILT_PRODUCTS_DIR; }; 45 | 9C7992A325701B180072BEE2 /* sunshineApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = sunshineApp.swift; sourceTree = ""; }; 46 | 9C7992A525701B180072BEE2 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 47 | 9C7992A725701B1D0072BEE2 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 48 | 9C7992AA25701B1D0072BEE2 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 49 | 9C7992AC25701B1D0072BEE2 /* Persistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Persistence.swift; sourceTree = ""; }; 50 | 9C7992AF25701B1D0072BEE2 /* sunshine.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = sunshine.xcdatamodel; sourceTree = ""; }; 51 | 9C7992B125701B1D0072BEE2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 52 | 9C7992B625701B1D0072BEE2 /* sunshineTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = sunshineTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 53 | 9C7992BA25701B1D0072BEE2 /* sunshineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = sunshineTests.swift; sourceTree = ""; }; 54 | 9C7992BC25701B1D0072BEE2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 55 | 9C7992C125701B1D0072BEE2 /* sunshineUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = sunshineUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 56 | 9C7992C525701B1D0072BEE2 /* sunshineUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = sunshineUITests.swift; sourceTree = ""; }; 57 | 9C7992C725701B1D0072BEE2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 58 | 9C7992D625701C770072BEE2 /* WeatherAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeatherAPIClient.swift; sourceTree = ""; }; 59 | 9C7992DB25701CC10072BEE2 /* SunDial.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SunDial.swift; sourceTree = ""; }; 60 | 9C7992E025701CDB0072BEE2 /* Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings.swift; sourceTree = ""; }; 61 | 9C7992E525701D170072BEE2 /* Forecast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Forecast.swift; sourceTree = ""; }; 62 | 9C7992EA25701D530072BEE2 /* LocationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationService.swift; sourceTree = ""; }; 63 | 9C7992EF25701D8A0072BEE2 /* SearchBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchBar.swift; sourceTree = ""; }; 64 | /* End PBXFileReference section */ 65 | 66 | /* Begin PBXFrameworksBuildPhase section */ 67 | 9C79929D25701B180072BEE2 /* Frameworks */ = { 68 | isa = PBXFrameworksBuildPhase; 69 | buildActionMask = 2147483647; 70 | files = ( 71 | ); 72 | runOnlyForDeploymentPostprocessing = 0; 73 | }; 74 | 9C7992B325701B1D0072BEE2 /* Frameworks */ = { 75 | isa = PBXFrameworksBuildPhase; 76 | buildActionMask = 2147483647; 77 | files = ( 78 | ); 79 | runOnlyForDeploymentPostprocessing = 0; 80 | }; 81 | 9C7992BE25701B1D0072BEE2 /* Frameworks */ = { 82 | isa = PBXFrameworksBuildPhase; 83 | buildActionMask = 2147483647; 84 | files = ( 85 | ); 86 | runOnlyForDeploymentPostprocessing = 0; 87 | }; 88 | /* End PBXFrameworksBuildPhase section */ 89 | 90 | /* Begin PBXGroup section */ 91 | 9C79929725701B180072BEE2 = { 92 | isa = PBXGroup; 93 | children = ( 94 | 9C7992A225701B180072BEE2 /* sunshine */, 95 | 9C7992B925701B1D0072BEE2 /* sunshineTests */, 96 | 9C7992C425701B1D0072BEE2 /* sunshineUITests */, 97 | 9C7992A125701B180072BEE2 /* Products */, 98 | ); 99 | sourceTree = ""; 100 | }; 101 | 9C7992A125701B180072BEE2 /* Products */ = { 102 | isa = PBXGroup; 103 | children = ( 104 | 9C7992A025701B180072BEE2 /* sunshine.app */, 105 | 9C7992B625701B1D0072BEE2 /* sunshineTests.xctest */, 106 | 9C7992C125701B1D0072BEE2 /* sunshineUITests.xctest */, 107 | ); 108 | name = Products; 109 | sourceTree = ""; 110 | }; 111 | 9C7992A225701B180072BEE2 /* sunshine */ = { 112 | isa = PBXGroup; 113 | children = ( 114 | 9C7992A325701B180072BEE2 /* sunshineApp.swift */, 115 | 9C7992A525701B180072BEE2 /* ContentView.swift */, 116 | 9C7992DB25701CC10072BEE2 /* SunDial.swift */, 117 | 9C7992E025701CDB0072BEE2 /* Settings.swift */, 118 | 9C7992E525701D170072BEE2 /* Forecast.swift */, 119 | 9C7992EF25701D8A0072BEE2 /* SearchBar.swift */, 120 | 9C7992EA25701D530072BEE2 /* LocationService.swift */, 121 | 9C7992A725701B1D0072BEE2 /* Assets.xcassets */, 122 | 9C7992AC25701B1D0072BEE2 /* Persistence.swift */, 123 | 9C7992B125701B1D0072BEE2 /* Info.plist */, 124 | 9C7992AE25701B1D0072BEE2 /* sunshine.xcdatamodeld */, 125 | 9C7992A925701B1D0072BEE2 /* Preview Content */, 126 | 9C7992D625701C770072BEE2 /* WeatherAPIClient.swift */, 127 | ); 128 | path = sunshine; 129 | sourceTree = ""; 130 | }; 131 | 9C7992A925701B1D0072BEE2 /* Preview Content */ = { 132 | isa = PBXGroup; 133 | children = ( 134 | 9C7992AA25701B1D0072BEE2 /* Preview Assets.xcassets */, 135 | ); 136 | path = "Preview Content"; 137 | sourceTree = ""; 138 | }; 139 | 9C7992B925701B1D0072BEE2 /* sunshineTests */ = { 140 | isa = PBXGroup; 141 | children = ( 142 | 9C7992BA25701B1D0072BEE2 /* sunshineTests.swift */, 143 | 9C7992BC25701B1D0072BEE2 /* Info.plist */, 144 | ); 145 | path = sunshineTests; 146 | sourceTree = ""; 147 | }; 148 | 9C7992C425701B1D0072BEE2 /* sunshineUITests */ = { 149 | isa = PBXGroup; 150 | children = ( 151 | 9C7992C525701B1D0072BEE2 /* sunshineUITests.swift */, 152 | 9C7992C725701B1D0072BEE2 /* Info.plist */, 153 | ); 154 | path = sunshineUITests; 155 | sourceTree = ""; 156 | }; 157 | /* End PBXGroup section */ 158 | 159 | /* Begin PBXNativeTarget section */ 160 | 9C79929F25701B180072BEE2 /* sunshine */ = { 161 | isa = PBXNativeTarget; 162 | buildConfigurationList = 9C7992CA25701B1D0072BEE2 /* Build configuration list for PBXNativeTarget "sunshine" */; 163 | buildPhases = ( 164 | 9C79929C25701B180072BEE2 /* Sources */, 165 | 9C79929D25701B180072BEE2 /* Frameworks */, 166 | 9C79929E25701B180072BEE2 /* Resources */, 167 | ); 168 | buildRules = ( 169 | ); 170 | dependencies = ( 171 | ); 172 | name = sunshine; 173 | productName = sunshine; 174 | productReference = 9C7992A025701B180072BEE2 /* sunshine.app */; 175 | productType = "com.apple.product-type.application"; 176 | }; 177 | 9C7992B525701B1D0072BEE2 /* sunshineTests */ = { 178 | isa = PBXNativeTarget; 179 | buildConfigurationList = 9C7992CD25701B1D0072BEE2 /* Build configuration list for PBXNativeTarget "sunshineTests" */; 180 | buildPhases = ( 181 | 9C7992B225701B1D0072BEE2 /* Sources */, 182 | 9C7992B325701B1D0072BEE2 /* Frameworks */, 183 | 9C7992B425701B1D0072BEE2 /* Resources */, 184 | ); 185 | buildRules = ( 186 | ); 187 | dependencies = ( 188 | 9C7992B825701B1D0072BEE2 /* PBXTargetDependency */, 189 | ); 190 | name = sunshineTests; 191 | productName = sunshineTests; 192 | productReference = 9C7992B625701B1D0072BEE2 /* sunshineTests.xctest */; 193 | productType = "com.apple.product-type.bundle.unit-test"; 194 | }; 195 | 9C7992C025701B1D0072BEE2 /* sunshineUITests */ = { 196 | isa = PBXNativeTarget; 197 | buildConfigurationList = 9C7992D025701B1D0072BEE2 /* Build configuration list for PBXNativeTarget "sunshineUITests" */; 198 | buildPhases = ( 199 | 9C7992BD25701B1D0072BEE2 /* Sources */, 200 | 9C7992BE25701B1D0072BEE2 /* Frameworks */, 201 | 9C7992BF25701B1D0072BEE2 /* Resources */, 202 | ); 203 | buildRules = ( 204 | ); 205 | dependencies = ( 206 | 9C7992C325701B1D0072BEE2 /* PBXTargetDependency */, 207 | ); 208 | name = sunshineUITests; 209 | productName = sunshineUITests; 210 | productReference = 9C7992C125701B1D0072BEE2 /* sunshineUITests.xctest */; 211 | productType = "com.apple.product-type.bundle.ui-testing"; 212 | }; 213 | /* End PBXNativeTarget section */ 214 | 215 | /* Begin PBXProject section */ 216 | 9C79929825701B180072BEE2 /* Project object */ = { 217 | isa = PBXProject; 218 | attributes = { 219 | LastSwiftUpdateCheck = 1220; 220 | LastUpgradeCheck = 1220; 221 | TargetAttributes = { 222 | 9C79929F25701B180072BEE2 = { 223 | CreatedOnToolsVersion = 12.2; 224 | }; 225 | 9C7992B525701B1D0072BEE2 = { 226 | CreatedOnToolsVersion = 12.2; 227 | TestTargetID = 9C79929F25701B180072BEE2; 228 | }; 229 | 9C7992C025701B1D0072BEE2 = { 230 | CreatedOnToolsVersion = 12.2; 231 | TestTargetID = 9C79929F25701B180072BEE2; 232 | }; 233 | }; 234 | }; 235 | buildConfigurationList = 9C79929B25701B180072BEE2 /* Build configuration list for PBXProject "sunshine" */; 236 | compatibilityVersion = "Xcode 9.3"; 237 | developmentRegion = en; 238 | hasScannedForEncodings = 0; 239 | knownRegions = ( 240 | en, 241 | Base, 242 | ); 243 | mainGroup = 9C79929725701B180072BEE2; 244 | productRefGroup = 9C7992A125701B180072BEE2 /* Products */; 245 | projectDirPath = ""; 246 | projectRoot = ""; 247 | targets = ( 248 | 9C79929F25701B180072BEE2 /* sunshine */, 249 | 9C7992B525701B1D0072BEE2 /* sunshineTests */, 250 | 9C7992C025701B1D0072BEE2 /* sunshineUITests */, 251 | ); 252 | }; 253 | /* End PBXProject section */ 254 | 255 | /* Begin PBXResourcesBuildPhase section */ 256 | 9C79929E25701B180072BEE2 /* Resources */ = { 257 | isa = PBXResourcesBuildPhase; 258 | buildActionMask = 2147483647; 259 | files = ( 260 | 9C7992AB25701B1D0072BEE2 /* Preview Assets.xcassets in Resources */, 261 | 9C7992A825701B1D0072BEE2 /* Assets.xcassets in Resources */, 262 | ); 263 | runOnlyForDeploymentPostprocessing = 0; 264 | }; 265 | 9C7992B425701B1D0072BEE2 /* Resources */ = { 266 | isa = PBXResourcesBuildPhase; 267 | buildActionMask = 2147483647; 268 | files = ( 269 | ); 270 | runOnlyForDeploymentPostprocessing = 0; 271 | }; 272 | 9C7992BF25701B1D0072BEE2 /* Resources */ = { 273 | isa = PBXResourcesBuildPhase; 274 | buildActionMask = 2147483647; 275 | files = ( 276 | ); 277 | runOnlyForDeploymentPostprocessing = 0; 278 | }; 279 | /* End PBXResourcesBuildPhase section */ 280 | 281 | /* Begin PBXSourcesBuildPhase section */ 282 | 9C79929C25701B180072BEE2 /* Sources */ = { 283 | isa = PBXSourcesBuildPhase; 284 | buildActionMask = 2147483647; 285 | files = ( 286 | 9C7992E625701D170072BEE2 /* Forecast.swift in Sources */, 287 | 9C7992D725701C770072BEE2 /* WeatherAPIClient.swift in Sources */, 288 | 9C7992AD25701B1D0072BEE2 /* Persistence.swift in Sources */, 289 | 9C7992DC25701CC10072BEE2 /* SunDial.swift in Sources */, 290 | 9C7992A625701B180072BEE2 /* ContentView.swift in Sources */, 291 | 9C7992E125701CDB0072BEE2 /* Settings.swift in Sources */, 292 | 9C7992B025701B1D0072BEE2 /* sunshine.xcdatamodeld in Sources */, 293 | 9C7992F025701D8A0072BEE2 /* SearchBar.swift in Sources */, 294 | 9C7992EB25701D530072BEE2 /* LocationService.swift in Sources */, 295 | 9C7992A425701B180072BEE2 /* sunshineApp.swift in Sources */, 296 | ); 297 | runOnlyForDeploymentPostprocessing = 0; 298 | }; 299 | 9C7992B225701B1D0072BEE2 /* Sources */ = { 300 | isa = PBXSourcesBuildPhase; 301 | buildActionMask = 2147483647; 302 | files = ( 303 | 9C7992BB25701B1D0072BEE2 /* sunshineTests.swift in Sources */, 304 | ); 305 | runOnlyForDeploymentPostprocessing = 0; 306 | }; 307 | 9C7992BD25701B1D0072BEE2 /* Sources */ = { 308 | isa = PBXSourcesBuildPhase; 309 | buildActionMask = 2147483647; 310 | files = ( 311 | 9C7992C625701B1D0072BEE2 /* sunshineUITests.swift in Sources */, 312 | ); 313 | runOnlyForDeploymentPostprocessing = 0; 314 | }; 315 | /* End PBXSourcesBuildPhase section */ 316 | 317 | /* Begin PBXTargetDependency section */ 318 | 9C7992B825701B1D0072BEE2 /* PBXTargetDependency */ = { 319 | isa = PBXTargetDependency; 320 | target = 9C79929F25701B180072BEE2 /* sunshine */; 321 | targetProxy = 9C7992B725701B1D0072BEE2 /* PBXContainerItemProxy */; 322 | }; 323 | 9C7992C325701B1D0072BEE2 /* PBXTargetDependency */ = { 324 | isa = PBXTargetDependency; 325 | target = 9C79929F25701B180072BEE2 /* sunshine */; 326 | targetProxy = 9C7992C225701B1D0072BEE2 /* PBXContainerItemProxy */; 327 | }; 328 | /* End PBXTargetDependency section */ 329 | 330 | /* Begin XCBuildConfiguration section */ 331 | 9C7992C825701B1D0072BEE2 /* Debug */ = { 332 | isa = XCBuildConfiguration; 333 | buildSettings = { 334 | ALWAYS_SEARCH_USER_PATHS = NO; 335 | CLANG_ANALYZER_NONNULL = YES; 336 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 337 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 338 | CLANG_CXX_LIBRARY = "libc++"; 339 | CLANG_ENABLE_MODULES = YES; 340 | CLANG_ENABLE_OBJC_ARC = YES; 341 | CLANG_ENABLE_OBJC_WEAK = YES; 342 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 343 | CLANG_WARN_BOOL_CONVERSION = YES; 344 | CLANG_WARN_COMMA = YES; 345 | CLANG_WARN_CONSTANT_CONVERSION = YES; 346 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 347 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 348 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 349 | CLANG_WARN_EMPTY_BODY = YES; 350 | CLANG_WARN_ENUM_CONVERSION = YES; 351 | CLANG_WARN_INFINITE_RECURSION = YES; 352 | CLANG_WARN_INT_CONVERSION = YES; 353 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 354 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 355 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 356 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 357 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 358 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 359 | CLANG_WARN_STRICT_PROTOTYPES = YES; 360 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 361 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 362 | CLANG_WARN_UNREACHABLE_CODE = YES; 363 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 364 | COPY_PHASE_STRIP = NO; 365 | DEBUG_INFORMATION_FORMAT = dwarf; 366 | ENABLE_STRICT_OBJC_MSGSEND = YES; 367 | ENABLE_TESTABILITY = YES; 368 | GCC_C_LANGUAGE_STANDARD = gnu11; 369 | GCC_DYNAMIC_NO_PIC = NO; 370 | GCC_NO_COMMON_BLOCKS = YES; 371 | GCC_OPTIMIZATION_LEVEL = 0; 372 | GCC_PREPROCESSOR_DEFINITIONS = ( 373 | "DEBUG=1", 374 | "$(inherited)", 375 | ); 376 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 377 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 378 | GCC_WARN_UNDECLARED_SELECTOR = YES; 379 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 380 | GCC_WARN_UNUSED_FUNCTION = YES; 381 | GCC_WARN_UNUSED_VARIABLE = YES; 382 | IPHONEOS_DEPLOYMENT_TARGET = 14.2; 383 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 384 | MTL_FAST_MATH = YES; 385 | ONLY_ACTIVE_ARCH = YES; 386 | SDKROOT = iphoneos; 387 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 388 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 389 | }; 390 | name = Debug; 391 | }; 392 | 9C7992C925701B1D0072BEE2 /* Release */ = { 393 | isa = XCBuildConfiguration; 394 | buildSettings = { 395 | ALWAYS_SEARCH_USER_PATHS = NO; 396 | CLANG_ANALYZER_NONNULL = YES; 397 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 398 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 399 | CLANG_CXX_LIBRARY = "libc++"; 400 | CLANG_ENABLE_MODULES = YES; 401 | CLANG_ENABLE_OBJC_ARC = YES; 402 | CLANG_ENABLE_OBJC_WEAK = YES; 403 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 404 | CLANG_WARN_BOOL_CONVERSION = YES; 405 | CLANG_WARN_COMMA = YES; 406 | CLANG_WARN_CONSTANT_CONVERSION = YES; 407 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 408 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 409 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 410 | CLANG_WARN_EMPTY_BODY = YES; 411 | CLANG_WARN_ENUM_CONVERSION = YES; 412 | CLANG_WARN_INFINITE_RECURSION = YES; 413 | CLANG_WARN_INT_CONVERSION = YES; 414 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 415 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 416 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 417 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 418 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 419 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 420 | CLANG_WARN_STRICT_PROTOTYPES = YES; 421 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 422 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 423 | CLANG_WARN_UNREACHABLE_CODE = YES; 424 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 425 | COPY_PHASE_STRIP = NO; 426 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 427 | ENABLE_NS_ASSERTIONS = NO; 428 | ENABLE_STRICT_OBJC_MSGSEND = YES; 429 | GCC_C_LANGUAGE_STANDARD = gnu11; 430 | GCC_NO_COMMON_BLOCKS = YES; 431 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 432 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 433 | GCC_WARN_UNDECLARED_SELECTOR = YES; 434 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 435 | GCC_WARN_UNUSED_FUNCTION = YES; 436 | GCC_WARN_UNUSED_VARIABLE = YES; 437 | IPHONEOS_DEPLOYMENT_TARGET = 14.2; 438 | MTL_ENABLE_DEBUG_INFO = NO; 439 | MTL_FAST_MATH = YES; 440 | SDKROOT = iphoneos; 441 | SWIFT_COMPILATION_MODE = wholemodule; 442 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 443 | VALIDATE_PRODUCT = YES; 444 | }; 445 | name = Release; 446 | }; 447 | 9C7992CB25701B1D0072BEE2 /* Debug */ = { 448 | isa = XCBuildConfiguration; 449 | buildSettings = { 450 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 451 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 452 | CODE_SIGN_STYLE = Automatic; 453 | DEVELOPMENT_ASSET_PATHS = "\"sunshine/Preview Content\""; 454 | DEVELOPMENT_TEAM = SF87CBF59Z; 455 | ENABLE_PREVIEWS = YES; 456 | INFOPLIST_FILE = sunshine/Info.plist; 457 | IPHONEOS_DEPLOYMENT_TARGET = 14.0; 458 | LD_RUNPATH_SEARCH_PATHS = ( 459 | "$(inherited)", 460 | "@executable_path/Frameworks", 461 | ); 462 | PRODUCT_BUNDLE_IDENTIFIER = com.maximeheckel.sunshine; 463 | PRODUCT_NAME = "$(TARGET_NAME)"; 464 | SWIFT_VERSION = 5.0; 465 | TARGETED_DEVICE_FAMILY = 1; 466 | }; 467 | name = Debug; 468 | }; 469 | 9C7992CC25701B1D0072BEE2 /* Release */ = { 470 | isa = XCBuildConfiguration; 471 | buildSettings = { 472 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 473 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 474 | CODE_SIGN_STYLE = Automatic; 475 | DEVELOPMENT_ASSET_PATHS = "\"sunshine/Preview Content\""; 476 | DEVELOPMENT_TEAM = SF87CBF59Z; 477 | ENABLE_PREVIEWS = YES; 478 | INFOPLIST_FILE = sunshine/Info.plist; 479 | IPHONEOS_DEPLOYMENT_TARGET = 14.0; 480 | LD_RUNPATH_SEARCH_PATHS = ( 481 | "$(inherited)", 482 | "@executable_path/Frameworks", 483 | ); 484 | PRODUCT_BUNDLE_IDENTIFIER = com.maximeheckel.sunshine; 485 | PRODUCT_NAME = "$(TARGET_NAME)"; 486 | SWIFT_VERSION = 5.0; 487 | TARGETED_DEVICE_FAMILY = 1; 488 | }; 489 | name = Release; 490 | }; 491 | 9C7992CE25701B1D0072BEE2 /* Debug */ = { 492 | isa = XCBuildConfiguration; 493 | buildSettings = { 494 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 495 | BUNDLE_LOADER = "$(TEST_HOST)"; 496 | CODE_SIGN_STYLE = Automatic; 497 | DEVELOPMENT_TEAM = SF87CBF59Z; 498 | INFOPLIST_FILE = sunshineTests/Info.plist; 499 | IPHONEOS_DEPLOYMENT_TARGET = 14.0; 500 | LD_RUNPATH_SEARCH_PATHS = ( 501 | "$(inherited)", 502 | "@executable_path/Frameworks", 503 | "@loader_path/Frameworks", 504 | ); 505 | PRODUCT_BUNDLE_IDENTIFIER = com.maximeheckel.sunshineTests; 506 | PRODUCT_NAME = "$(TARGET_NAME)"; 507 | SWIFT_VERSION = 5.0; 508 | TARGETED_DEVICE_FAMILY = "1,2"; 509 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/sunshine.app/sunshine"; 510 | }; 511 | name = Debug; 512 | }; 513 | 9C7992CF25701B1D0072BEE2 /* Release */ = { 514 | isa = XCBuildConfiguration; 515 | buildSettings = { 516 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 517 | BUNDLE_LOADER = "$(TEST_HOST)"; 518 | CODE_SIGN_STYLE = Automatic; 519 | DEVELOPMENT_TEAM = SF87CBF59Z; 520 | INFOPLIST_FILE = sunshineTests/Info.plist; 521 | IPHONEOS_DEPLOYMENT_TARGET = 14.0; 522 | LD_RUNPATH_SEARCH_PATHS = ( 523 | "$(inherited)", 524 | "@executable_path/Frameworks", 525 | "@loader_path/Frameworks", 526 | ); 527 | PRODUCT_BUNDLE_IDENTIFIER = com.maximeheckel.sunshineTests; 528 | PRODUCT_NAME = "$(TARGET_NAME)"; 529 | SWIFT_VERSION = 5.0; 530 | TARGETED_DEVICE_FAMILY = "1,2"; 531 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/sunshine.app/sunshine"; 532 | }; 533 | name = Release; 534 | }; 535 | 9C7992D125701B1D0072BEE2 /* Debug */ = { 536 | isa = XCBuildConfiguration; 537 | buildSettings = { 538 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 539 | CODE_SIGN_STYLE = Automatic; 540 | DEVELOPMENT_TEAM = SF87CBF59Z; 541 | INFOPLIST_FILE = sunshineUITests/Info.plist; 542 | LD_RUNPATH_SEARCH_PATHS = ( 543 | "$(inherited)", 544 | "@executable_path/Frameworks", 545 | "@loader_path/Frameworks", 546 | ); 547 | PRODUCT_BUNDLE_IDENTIFIER = com.maximeheckel.sunshineUITests; 548 | PRODUCT_NAME = "$(TARGET_NAME)"; 549 | SWIFT_VERSION = 5.0; 550 | TARGETED_DEVICE_FAMILY = "1,2"; 551 | TEST_TARGET_NAME = sunshine; 552 | }; 553 | name = Debug; 554 | }; 555 | 9C7992D225701B1D0072BEE2 /* Release */ = { 556 | isa = XCBuildConfiguration; 557 | buildSettings = { 558 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 559 | CODE_SIGN_STYLE = Automatic; 560 | DEVELOPMENT_TEAM = SF87CBF59Z; 561 | INFOPLIST_FILE = sunshineUITests/Info.plist; 562 | LD_RUNPATH_SEARCH_PATHS = ( 563 | "$(inherited)", 564 | "@executable_path/Frameworks", 565 | "@loader_path/Frameworks", 566 | ); 567 | PRODUCT_BUNDLE_IDENTIFIER = com.maximeheckel.sunshineUITests; 568 | PRODUCT_NAME = "$(TARGET_NAME)"; 569 | SWIFT_VERSION = 5.0; 570 | TARGETED_DEVICE_FAMILY = "1,2"; 571 | TEST_TARGET_NAME = sunshine; 572 | }; 573 | name = Release; 574 | }; 575 | /* End XCBuildConfiguration section */ 576 | 577 | /* Begin XCConfigurationList section */ 578 | 9C79929B25701B180072BEE2 /* Build configuration list for PBXProject "sunshine" */ = { 579 | isa = XCConfigurationList; 580 | buildConfigurations = ( 581 | 9C7992C825701B1D0072BEE2 /* Debug */, 582 | 9C7992C925701B1D0072BEE2 /* Release */, 583 | ); 584 | defaultConfigurationIsVisible = 0; 585 | defaultConfigurationName = Release; 586 | }; 587 | 9C7992CA25701B1D0072BEE2 /* Build configuration list for PBXNativeTarget "sunshine" */ = { 588 | isa = XCConfigurationList; 589 | buildConfigurations = ( 590 | 9C7992CB25701B1D0072BEE2 /* Debug */, 591 | 9C7992CC25701B1D0072BEE2 /* Release */, 592 | ); 593 | defaultConfigurationIsVisible = 0; 594 | defaultConfigurationName = Release; 595 | }; 596 | 9C7992CD25701B1D0072BEE2 /* Build configuration list for PBXNativeTarget "sunshineTests" */ = { 597 | isa = XCConfigurationList; 598 | buildConfigurations = ( 599 | 9C7992CE25701B1D0072BEE2 /* Debug */, 600 | 9C7992CF25701B1D0072BEE2 /* Release */, 601 | ); 602 | defaultConfigurationIsVisible = 0; 603 | defaultConfigurationName = Release; 604 | }; 605 | 9C7992D025701B1D0072BEE2 /* Build configuration list for PBXNativeTarget "sunshineUITests" */ = { 606 | isa = XCConfigurationList; 607 | buildConfigurations = ( 608 | 9C7992D125701B1D0072BEE2 /* Debug */, 609 | 9C7992D225701B1D0072BEE2 /* Release */, 610 | ); 611 | defaultConfigurationIsVisible = 0; 612 | defaultConfigurationName = Release; 613 | }; 614 | /* End XCConfigurationList section */ 615 | 616 | /* Begin XCVersionGroup section */ 617 | 9C7992AE25701B1D0072BEE2 /* sunshine.xcdatamodeld */ = { 618 | isa = XCVersionGroup; 619 | children = ( 620 | 9C7992AF25701B1D0072BEE2 /* sunshine.xcdatamodel */, 621 | ); 622 | currentVersion = 9C7992AF25701B1D0072BEE2 /* sunshine.xcdatamodel */; 623 | path = sunshine.xcdatamodeld; 624 | sourceTree = ""; 625 | versionGroupType = wrapper.xcdatamodel; 626 | }; 627 | /* End XCVersionGroup section */ 628 | }; 629 | rootObject = 9C79929825701B180072BEE2 /* Project object */; 630 | } 631 | -------------------------------------------------------------------------------- /sunshine/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // sunshine 4 | // 5 | // Created by Maxime on 8/22/20. 6 | // 7 | 8 | import SwiftUI 9 | import CoreData 10 | 11 | let gradients: [MainDaypart: [Color]]! = [ 12 | MainDaypart.dawn: [Color("Pink Dawn"), Color("Blue Dawn End Gradient")], 13 | MainDaypart.mid: [Color("Blue Mid Day"), Color("Blue Mid Day End Gradient")], 14 | MainDaypart.dusk: [Color("Blue Dusk"), Color("Peach Dusk End Gradient")], 15 | MainDaypart.night: [Color("Blue Night"), Color("Blue Night End Gradient")]] 16 | 17 | func getDayPart(sunrise: Double, sunset: Double, nextDaySunrise: Double, timestamp: Double) -> MainDaypart { 18 | let dawnStart = sunrise - 86400 * 0.03 19 | let dawnEnd = sunrise + 86400 * 0.05 20 | let duskStart = sunset - 86400 * 0.03 21 | let duskEnd = sunset + 86400 * 0.05 22 | 23 | let nextDawnStart = nextDaySunrise - 86400 * 0.03 24 | let nextDawnEnd = nextDaySunrise + 86400 * 0.05 25 | 26 | if (timestamp > sunset && timestamp > dawnStart && timestamp < nextDawnStart) { 27 | // Current day after sunset before next sunset (sunrise is day 1 sunrise thus < timestramp) 28 | return MainDaypart.night 29 | } 30 | 31 | if (timestamp < duskEnd && timestamp < dawnStart) { 32 | // Next day, timestamp is both < sunrise and < sunset 33 | return MainDaypart.night 34 | } 35 | 36 | if (timestamp > dawnStart && timestamp < dawnEnd) { 37 | // Current day sunrise 38 | return MainDaypart.dawn 39 | } 40 | 41 | if (timestamp > nextDawnStart && timestamp < nextDawnEnd) { 42 | // Next day sunrise (for hourly forecast) 43 | return MainDaypart.dawn 44 | } 45 | 46 | if (timestamp > duskStart && timestamp < duskEnd) { 47 | return MainDaypart.dusk 48 | } 49 | 50 | return MainDaypart.mid 51 | } 52 | 53 | func kelvinToFarenheit(_ kelvin: Double) -> Double { 54 | return kelvin * 1.8 - 459.67 55 | } 56 | 57 | func kelvinToCelsius(_ celsius: Double) -> Double { 58 | return celsius - 273.15 59 | } 60 | 61 | func metersPerSecToMPH(_ mps: Double) -> Double { 62 | return mps / 0.44704 63 | } 64 | 65 | func metersPerSecToKMH(_ mps: Double) -> Double { 66 | return mps * 18 / 5 67 | } 68 | 69 | struct FormattedTemperature: View { 70 | var temperature: Double 71 | var unit: Temperature 72 | 73 | @ViewBuilder var body: some View { 74 | switch(unit) { 75 | case Temperature.fahrenheit: 76 | Text(String(format: "%.0f°F", kelvinToFarenheit(temperature))) 77 | case Temperature.celsius: 78 | Text(String(format: "%.0f°C", kelvinToCelsius(temperature))) 79 | default: 80 | Text(String(format: "%.0f°K", temperature)) 81 | } 82 | 83 | } 84 | } 85 | 86 | struct FormattedSpeed: View { 87 | var speed: Double 88 | var unit: Speed 89 | 90 | @ViewBuilder var body: some View { 91 | switch(unit) { 92 | case Speed.mph: 93 | Text(String(format: "%.1f mph", metersPerSecToMPH(speed))) 94 | case Speed.kmh: 95 | Text(String(format: "%.1f km/h", metersPerSecToKMH(speed))) 96 | default: 97 | Text(String(format: "%.1f m/s", speed)) 98 | 99 | } 100 | } 101 | } 102 | 103 | let REFRESH_INTERVAL = 1800 104 | 105 | 106 | 107 | struct VisualEffectView: UIViewRepresentable { 108 | var effect: UIVisualEffect? 109 | func makeUIView(context: UIViewRepresentableContext) -> UIVisualEffectView { UIVisualEffectView() } 110 | func updateUIView(_ uiView: UIVisualEffectView, context: UIViewRepresentableContext) { uiView.effect = effect } 111 | } 112 | 113 | struct BottomSheetView: View { 114 | @Binding var isOpen: Bool 115 | @GestureState private var translation: CGFloat = 0 116 | 117 | let maxHeight: CGFloat 118 | let minHeight: CGFloat 119 | let content: Content 120 | 121 | init(isOpen: Binding, maxHeight: CGFloat, @ViewBuilder content: () -> Content) { 122 | self.minHeight = maxHeight * 1/7 123 | self.maxHeight = maxHeight 124 | self.content = content() 125 | self._isOpen = isOpen 126 | } 127 | 128 | private var offset: CGFloat { 129 | isOpen ? 0 : maxHeight - minHeight 130 | } 131 | 132 | private var indicator: some View { 133 | RoundedRectangle(cornerRadius: 50) 134 | .fill(Color.secondary) 135 | .frame( 136 | width: 100, 137 | height: 5 138 | ) 139 | } 140 | 141 | var body: some View { 142 | GeometryReader { geometry in 143 | VStack(spacing: 0) { 144 | self.indicator.padding() 145 | self.content 146 | } 147 | .frame(width: geometry.size.width, height: self.maxHeight, alignment: .top) 148 | .background(Color(.tertiarySystemBackground)) 149 | .cornerRadius(25) 150 | .frame(width: geometry.size.width, height: geometry.size.height, alignment: .bottom) 151 | .offset(y: max(self.offset + self.translation, 0)) 152 | .animation(.interactiveSpring()) 153 | .gesture( 154 | DragGesture().updating(self.$translation) { value, state, _ in 155 | state = value.translation.height 156 | }.onEnded { value in 157 | let snapDistance = self.maxHeight * 0.2 158 | guard abs(value.translation.height) > snapDistance else { 159 | return 160 | } 161 | self.isOpen = value.translation.height < 0 162 | } 163 | ) 164 | } 165 | } 166 | } 167 | 168 | struct MetricsRow: View { 169 | var forecast: Response 170 | var speedUnit: Speed 171 | 172 | @State private var toggleAnimation = false 173 | 174 | var body: some View { 175 | VStack { 176 | HStack(spacing: 20) { 177 | HStack { 178 | Image("humidity") 179 | .resizable() 180 | .scaledToFit() 181 | .frame(width: 14) 182 | Text("\(forecast.current.humidity)%") 183 | .font(.system(size: 12, weight: .medium)) 184 | .foregroundColor(.black) 185 | } 186 | HStack { 187 | Image(systemName: "wind") 188 | .foregroundColor(.black) 189 | FormattedSpeed(speed: forecast.current.windSpeed, unit: speedUnit) 190 | .font(.system(size: 12, weight: .medium)) 191 | .foregroundColor(.black) 192 | } 193 | HStack { 194 | Image(systemName: "location") 195 | .foregroundColor(.black) 196 | .rotationEffect(.degrees(Double(45 - forecast.current.windDeg))) 197 | .animation(.spring()) 198 | Text("\(forecast.current.windDeg)°") 199 | .font(.system(size: 12, weight: .medium)) 200 | .foregroundColor(.black) 201 | } 202 | HStack { 203 | Image(systemName: "cloud") 204 | .foregroundColor(.black) 205 | Text("\(forecast.current.clouds)%") 206 | .font(.system(size: 12, weight: .medium)) 207 | .foregroundColor(.black) 208 | } 209 | } 210 | .padding(.top, 10) 211 | } 212 | .frame(height: 35) 213 | .onAppear { 214 | self.toggleAnimation = true 215 | } 216 | } 217 | } 218 | 219 | struct MainAssetByDaypart: View { 220 | @State private var toggleAnimation = false 221 | var currentDayPart: MainDaypart 222 | 223 | @ViewBuilder var body: some View { 224 | if (currentDayPart == MainDaypart.night) { 225 | Image("moon") 226 | .resizable() 227 | .frame(width: 140.0, height: 140.0) 228 | .opacity(toggleAnimation ? 1: 0) 229 | .animation(Animation.easeInOut(duration: 2.0).delay(0.7)) 230 | .onAppear { 231 | self.toggleAnimation = true 232 | } 233 | } 234 | 235 | if (currentDayPart == MainDaypart.mid) { 236 | Image("sun-mid").resizable() 237 | .frame(width: 200.0, height: 200.0) 238 | .offset(y: toggleAnimation ? 0 : 100) 239 | .animation(Animation.easeInOut(duration: 2.0).delay(0.7)) 240 | .onAppear { 241 | self.toggleAnimation = true 242 | } 243 | } 244 | 245 | if(currentDayPart == MainDaypart.dusk) { 246 | Image("sun-dawn").resizable() 247 | .frame(width: 200.0, height: 200.0) 248 | .opacity(toggleAnimation ? 1 : 0) 249 | .offset(y: toggleAnimation ? 100 : 0) 250 | .animation(Animation.easeInOut(duration: 2.0).delay(0.5).delay(0.7)) 251 | .onAppear { 252 | self.toggleAnimation = true 253 | } 254 | } 255 | 256 | if(currentDayPart == MainDaypart.dawn) { 257 | Image("sun-dawn").resizable() 258 | .frame(width: 200.0, height: 200.0) 259 | .offset(y: toggleAnimation ? 100 : 180) 260 | .animation(Animation.easeInOut(duration: 2.0).delay(0.7)) 261 | .onAppear { 262 | self.toggleAnimation = true 263 | } 264 | } 265 | } 266 | } 267 | 268 | func isCloudy(_ weatherId: Int) -> Bool { 269 | if (weatherId > 802 || weatherId >= 500 && weatherId < 600 || weatherId >= 200 && weatherId < 300 || weatherId >= 600 && weatherId < 700) { 270 | return true 271 | } 272 | 273 | return false 274 | } 275 | 276 | struct MainWeatherImage: View { 277 | @State private var toggleAnimation = false 278 | var weather: WeatherObject 279 | var currentDayPart: MainDaypart 280 | 281 | var isMainAssetHigh: Bool { 282 | return currentDayPart == MainDaypart.mid || currentDayPart == MainDaypart.night 283 | } 284 | 285 | @ViewBuilder var body: some View { 286 | ZStack { 287 | if (weather.id >= 800) { 288 | MainAssetByDaypart(currentDayPart: currentDayPart) 289 | 290 | if (weather.id == 801 || weather.id == 802) { 291 | Image("cloud") 292 | .resizable() 293 | .scaledToFit() 294 | .frame(width: 130, height: 100) 295 | .offset(x: toggleAnimation ? 40 : 300, y: isMainAssetHigh ? 20 : 100) 296 | .animation(Animation.easeInOut(duration: toggleAnimation ? 3.0 : 0).delay(1)) 297 | Image("cloud") 298 | .resizable() 299 | .scaledToFit() 300 | .frame(width: 130, height: 90) 301 | .offset(x: toggleAnimation ? -20 : -300, y: isMainAssetHigh ? -10 : 70) 302 | .animation(Animation.easeInOut(duration: toggleAnimation ? 3.0 : 0).delay(0.7)) 303 | } 304 | 305 | if (weather.id > 802) { 306 | // Broken clouds / overcast 307 | Image("big-cloud") 308 | .resizable() 309 | .scaledToFit() 310 | .frame(width: 130, height: 100) 311 | .offset(x: toggleAnimation ? 0 : 300, y: isMainAssetHigh ? 20: 50) 312 | .animation(Animation.easeInOut(duration: toggleAnimation ? 3.0 : 0)) 313 | } 314 | } 315 | 316 | if (weather.id >= 500 && weather.id < 600) { 317 | if (weather.id <= 501) { 318 | Image("light-rain-drops") 319 | .resizable() 320 | .scaledToFit() 321 | .frame(width: 100, height: 50) 322 | 323 | } else { 324 | Image("heavy-rain-drops") 325 | .resizable() 326 | .scaledToFit() 327 | .frame(width: 100, height: 50) 328 | 329 | } 330 | } 331 | 332 | if (weather.id >= 300 && weather.id < 400) { 333 | if (weather.id <= 311) { 334 | Image("light-rain-drops") 335 | .resizable() 336 | 337 | } else { 338 | Image("heavy-rain-drops") 339 | .resizable() 340 | 341 | } 342 | } 343 | 344 | if (weather.id >= 200 && weather.id < 300) { 345 | // thunderstorm 346 | MainAssetByDaypart(currentDayPart: currentDayPart) 347 | } 348 | 349 | if (weather.id >= 600 && weather.id < 700) { 350 | // snow 351 | MainAssetByDaypart(currentDayPart: currentDayPart) 352 | } 353 | 354 | if (weather.id >= 700 && weather.id < 800) { 355 | // atmospheric 356 | MainAssetByDaypart(currentDayPart: currentDayPart) 357 | } 358 | 359 | else { 360 | // MainAssetByDaypart(currentDayPart: currentDayPart) 361 | } 362 | } 363 | .onAppear { 364 | self.toggleAnimation = true 365 | } 366 | } 367 | } 368 | 369 | struct MainCard: View { 370 | 371 | var forecast: Response 372 | var currentDayPart: MainDaypart 373 | var tempUnit: Temperature 374 | var speedUnit: Speed 375 | 376 | var gradient: [Color] { 377 | return gradients[currentDayPart] ?? [Color.white] 378 | } 379 | 380 | var body: some View { 381 | VStack { 382 | ZStack { 383 | // Main Card with background and little drop shaddow based on time of day (daypart) 384 | RoundedRectangle(cornerRadius: 25) 385 | .fill(LinearGradient( 386 | gradient: Gradient(colors: gradient), 387 | startPoint: UnitPoint(x: 0.5, y: 0), 388 | endPoint: UnitPoint(x: 0.5, y: 0.6))) 389 | .shadow(color: Color(#colorLiteral(red: 0, green: 0, blue: 0, alpha: 0.25)), radius:10, x:0, y:4) 390 | 391 | // Cloudy overlay 392 | if (isCloudy(forecast.current.weather[0].id)) { 393 | if (currentDayPart == MainDaypart.night) { 394 | RoundedRectangle(cornerRadius: 25) 395 | .fill(LinearGradient( 396 | gradient: Gradient(stops: [ 397 | .init(color: Color(#colorLiteral(red: 0.09019608050584793, green: 0.1411764770746231, blue: 0.16862745583057404, alpha: 1)), location: 0), 398 | .init(color: Color(#colorLiteral(red: 0.250980406999588, green: 0.2705882489681244, blue: 0.3294117748737335, alpha: 1)), location: 1)]), 399 | startPoint: UnitPoint(x: 0.5, y: 0), 400 | endPoint: UnitPoint(x: 0.5, y: 0.5))) 401 | .opacity(0.7) 402 | } else { 403 | RoundedRectangle(cornerRadius: 25) 404 | .fill(LinearGradient( 405 | gradient: Gradient(stops: [ 406 | .init(color: Color(#colorLiteral(red: 0.6976562142, green: 0.7035937309, blue: 0.7124999762, alpha: 0.700952492)), location: 0), 407 | .init(color: Color(#colorLiteral(red: 0.8545138835906982, green: 0.8718518614768982, blue: 0.8916666507720947, alpha: 0.20000000298023224)), location: 1)]), 408 | startPoint: UnitPoint(x: 0.5, y: 0), 409 | endPoint: UnitPoint(x: 0.5, y: 0.5))) 410 | .opacity(0.7) 411 | } 412 | } 413 | 414 | // Assets based on weather and time (each asset may have it's own position) 415 | GeometryReader { geometry in 416 | MainWeatherImage(weather: forecast.current.weather[0], currentDayPart: currentDayPart) 417 | .frame(width: geometry.size.width, height: 260) 418 | .clipped() 419 | } 420 | 421 | // Temperature, conditions, feels like 422 | VStack { 423 | HStack { 424 | FormattedTemperature(temperature: forecast.current.temp, unit: tempUnit).font(.system(size: 36, weight: .medium)).foregroundColor(.white) 425 | Spacer() 426 | VStack(alignment: .trailing) { 427 | Text("\(forecast.current.weather[0].description.capitalized)").font(.system(size: 24, weight: .medium)).foregroundColor(.white) 428 | HStack(spacing: 3) { 429 | Text("Feels like") 430 | .font(.system(size: 14, weight: .semibold)).foregroundColor(.white) 431 | FormattedTemperature(temperature: forecast.current.feelsLike, unit: tempUnit) 432 | .font(.system(size: 14, weight: .semibold)).foregroundColor(.white) 433 | } 434 | } 435 | 436 | }.padding(15) 437 | 438 | Spacer() 439 | 440 | ZStack { 441 | VisualEffectView(effect: UIBlurEffect(style: .light)) 442 | .cornerRadius(25) 443 | VStack { 444 | MetricsRow(forecast: forecast, speedUnit: speedUnit) 445 | Rectangle() 446 | .fill(Color.black) 447 | .frame(height: 1) 448 | .opacity(0.05) 449 | if (currentDayPart != MainDaypart.night) { 450 | SunDial(sunrise: forecast.current.sunrise, sunset: forecast.current.sunset, timestamp: forecast.current.dt, timezone: forecast.timezone) 451 | } else { 452 | VStack { 453 | Text("Next sunrise at \(getNextSunriseTime(forecast.current.sunrise))") 454 | .font(.system(size: 14, weight: .medium)) 455 | .foregroundColor(.black) 456 | 457 | } 458 | .frame(height: 70) 459 | .padding(.leading,5) 460 | .padding(.trailing,5) 461 | } 462 | } 463 | 464 | } 465 | .frame(height:currentDayPart == MainDaypart.night ? 125 : 230) 466 | } 467 | } 468 | } 469 | .frame(height: currentDayPart == MainDaypart.night ? 385 : 475) 470 | .padding(.leading,8) 471 | .padding(.trailing,8) 472 | } 473 | 474 | 475 | func getNextSunriseTime(_ sunrise: Int) -> String { 476 | let date = Date(timeIntervalSince1970: Double(sunrise)) 477 | let formatter = DateFormatter() 478 | formatter.timeStyle = .short 479 | formatter.locale = NSLocale.current 480 | formatter.timeZone = TimeZone(identifier: forecast.timezone) 481 | var formattedDate:String {formatter.string(from: date)} 482 | 483 | return formattedDate 484 | } 485 | } 486 | 487 | 488 | 489 | struct MainWeatherScreen: View { 490 | var forecast: Response! 491 | var isSelected: Bool 492 | var name: String 493 | var status: ViewState 494 | var tempUnit: Temperature 495 | var speedUnit: Speed 496 | 497 | var currentDayPart: MainDaypart { 498 | return getDayPart(sunrise: Double(forecast.current.sunrise), sunset: Double(forecast.current.sunset), nextDaySunrise: Double(forecast.daily[1].sunrise), timestamp: Double(forecast.current.dt)) 499 | } 500 | 501 | 502 | var body: some View { 503 | VStack(spacing: 0) { 504 | if (status == ViewState.loading && forecast == nil) { 505 | VStack(alignment: .leading) { 506 | Text(" ").font(.system(size: 16, weight: .medium, design: Font.Design.default)).foregroundColor(Color(#colorLiteral(red: 0.5920000076293945, green: 0.5920000076293945, blue: 0.5920000076293945, alpha: 1))) 507 | HStack { 508 | Text("\(name)").font(.system(size: 24, weight: .medium)) 509 | Spacer() 510 | } 511 | } 512 | .padding([.leading, .trailing], 20) 513 | .frame(height: 105, alignment: .leading) 514 | Spacer() 515 | } 516 | if (status == ViewState.error) { 517 | Text("Error :(") 518 | } 519 | if (status != ViewState.error && forecast != nil) { 520 | VStack(alignment: .leading) { 521 | Text("\(getDate(forecast.current.dt))").font(.system(size: 16, weight: .bold, design: Font.Design.default)).foregroundColor(Color(#colorLiteral(red: 0.5920000076293945, green: 0.5920000076293945, blue: 0.5920000076293945, alpha: 1))) 522 | HStack { 523 | Text("\(name)").font(.system(size: 24, weight: .medium)) 524 | Spacer() 525 | } 526 | 527 | } 528 | .padding([.leading, .trailing], 20) 529 | .frame(height: 105, alignment: .leading) 530 | 531 | // Main Card 532 | VStack { 533 | if (isSelected) { 534 | MainCard(forecast: forecast, currentDayPart: currentDayPart, tempUnit: tempUnit, speedUnit: speedUnit) 535 | if(currentDayPart == MainDaypart.night){ 536 | if (forecast.current.dt > forecast.current.sunset) { 537 | DailyCard(forecast: forecast.daily[1], tempUnit: tempUnit) 538 | .padding([.leading, .trailing], 10) 539 | .padding([.top], 10) 540 | } else { 541 | DailyCard(forecast: forecast.daily[0], tempUnit: tempUnit) 542 | .padding([.leading, .trailing], 10) 543 | .padding([.top], 10) 544 | } 545 | } 546 | Spacer() 547 | } else { 548 | Spacer() 549 | } 550 | } 551 | .transition(.opacity) 552 | .animation(.easeInOut) 553 | } 554 | 555 | } 556 | .transition(.opacity) 557 | .animation(.easeInOut) 558 | } 559 | 560 | func getDate(_ timestamp: Int) -> String { 561 | var date: Date { Date(timeIntervalSince1970: Double(timestamp)) } 562 | let formatter = DateFormatter() 563 | formatter.dateFormat = "E MMM dd" 564 | formatter.timeZone = TimeZone(identifier: forecast.timezone) 565 | var formattedDate:String {formatter.string(from: date)} 566 | 567 | return formattedDate 568 | } 569 | } 570 | 571 | class ViewModel: ObservableObject { 572 | @Published var state: ViewState 573 | @Published var items: [Response] { 574 | didSet { 575 | self.focusedItem = items.indices.contains(selectTabIndex) ? items[selectTabIndex] : nil 576 | } 577 | } 578 | @Published var selectTabIndex: Int { 579 | didSet { 580 | self.focusedItem = items.indices.contains(selectTabIndex) ? items[selectTabIndex] : nil 581 | } 582 | } 583 | @Published var temperature: Temperature { 584 | didSet { 585 | UserDefaults.standard.set(temperature.rawValue, forKey: "temperature") 586 | } 587 | } 588 | @Published var speed: Speed { 589 | didSet { 590 | UserDefaults.standard.set(speed.rawValue, forKey: "speed") 591 | } 592 | } 593 | 594 | @Published var focusedItem: Response? 595 | 596 | 597 | let client = WeatherAPIClient() 598 | 599 | init() { 600 | self.focusedItem = nil 601 | self.selectTabIndex = 0 602 | self.state = ViewState.idle 603 | self.items = [Response]() 604 | self.temperature = (UserDefaults.standard.object(forKey: "temperature") == nil ? Temperature.fahrenheit : Temperature(rawValue: UserDefaults.standard.object(forKey: "temperature") as! Int)) ?? Temperature.fahrenheit 605 | self.speed = (UserDefaults.standard.object(forKey: "speed") == nil ? Speed.mph : Speed(rawValue: UserDefaults.standard.object(forKey: "speed") as! Int)) ?? Speed.mph 606 | } 607 | 608 | 609 | 610 | func refresh(_ index: Int, _ savedLocations: FetchedResults) { 611 | // print("Trying refresh at index \(index)") 612 | if (!items.indices.contains(index) && savedLocations.indices.contains(index)) { 613 | fetch(index, savedLocations) 614 | return 615 | } else if (savedLocations.indices.contains(index)) { 616 | let formatter = DateFormatter() 617 | formatter.dateFormat = "E, dd MMM, HH:mm:ss" 618 | var formattedDate:String {formatter.string(from: Date(timeIntervalSince1970: Double(items[index].current.dt)))} 619 | 620 | let diff = Int(Date().timeIntervalSince1970) - items[index].current.dt 621 | 622 | if (diff >= REFRESH_INTERVAL) { 623 | // print("Refresh") 624 | fetch(index, savedLocations) 625 | } 626 | } 627 | return 628 | } 629 | 630 | private func fetch(_ index: Int, _ savedLocations: FetchedResults) { 631 | if (savedLocations.indices.contains(index)) { 632 | self.state = ViewState.loading 633 | client.fetch(lat: savedLocations[index].lat, lon: savedLocations[index].lon) { result in 634 | DispatchQueue.main.async { 635 | switch result { 636 | case .success(let response): 637 | DispatchQueue.main.async { 638 | if (!self.items.indices.contains(index)) { 639 | self.items.append(response) 640 | } else { 641 | self.items[index] = response 642 | } 643 | self.state = ViewState.idle 644 | 645 | } 646 | 647 | case .failure(let error): 648 | print("Error! \(error)") 649 | self.state = ViewState.error 650 | } 651 | 652 | } 653 | } 654 | } 655 | } 656 | } 657 | 658 | 659 | struct ContentView: View { 660 | @Environment(\.managedObjectContext) private var viewContext 661 | @Environment(\.colorScheme) var colorScheme 662 | @StateObject var viewModel = ViewModel() 663 | @State private var loading = false 664 | @State private var error = false 665 | @State private var isSheetPresented = false 666 | @State private var bottomSheetShown = false 667 | 668 | 669 | @FetchRequest(entity: Location.entity(),sortDescriptors: [NSSortDescriptor(keyPath: \Location.timestamp, ascending: true)], animation: .default) 670 | private var savedLocations: FetchedResults 671 | 672 | let client = WeatherAPIClient() 673 | 674 | let locationSearchService = LocationSearchService() 675 | // let refreshTimer = Timer.publish(every: TimeInterval(3600), on: .main, in: .common).autoconnect() 676 | 677 | 678 | var body: some View { 679 | ZStack { 680 | Color(.secondarySystemBackground) 681 | .edgesIgnoringSafeArea(.all) 682 | VStack { 683 | // Top Bar 684 | HStack { 685 | Button(action: { 686 | self.isSheetPresented.toggle() 687 | }) { 688 | if(colorScheme == .dark) { 689 | Image("menu-light") 690 | } else { 691 | Image("menu-dark") 692 | } 693 | } 694 | Spacer() 695 | } 696 | .padding([.top, .leading], 20) 697 | 698 | // Center and Bottom View 699 | GeometryReader { geometry in 700 | if(savedLocations.count != 0 ){ 701 | ZStack { 702 | TabView(selection: $viewModel.selectTabIndex){ 703 | ForEach(savedLocations.indices) { index in 704 | MainWeatherScreen(forecast: viewModel.focusedItem, isSelected: viewModel.selectTabIndex == index, name: savedLocations[index].name!, status: viewModel.state, tempUnit: viewModel.temperature, speedUnit: viewModel.speed) 705 | .tag(index) 706 | } 707 | } 708 | .onReceive(viewModel.$selectTabIndex, perform: { index in 709 | viewModel.refresh(index, savedLocations) 710 | }) 711 | .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never)) 712 | .id(savedLocations.count) 713 | 714 | BottomSheetView( 715 | isOpen: self.$bottomSheetShown, 716 | maxHeight: geometry.size.height * 0.9 717 | ) { 718 | ForecastView() 719 | } 720 | .edgesIgnoringSafeArea(.bottom) 721 | } 722 | } 723 | } 724 | } 725 | .environmentObject(viewModel) 726 | } 727 | // .onReceive(refreshTimer){ _ in 728 | // self.refresh(store.selectTabIndex) 729 | // } 730 | .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in 731 | if (viewModel.items.indices.contains(viewModel.selectTabIndex)) { 732 | viewModel.refresh(viewModel.selectTabIndex, savedLocations) 733 | } 734 | } 735 | .sheet(isPresented: $isSheetPresented, content: { 736 | SheetPane(locationSearchService: locationSearchService, savedLocations: savedLocations) 737 | .environmentObject(viewModel) 738 | }) 739 | 740 | } 741 | } 742 | --------------------------------------------------------------------------------