diff --git a/CoffeeKit.xcodeproj/project.pbxproj b/CoffeeKit.xcodeproj/project.pbxproj index 8d7f3c3..f29caee 100644 --- a/CoffeeKit.xcodeproj/project.pbxproj +++ b/CoffeeKit.xcodeproj/project.pbxproj @@ -22,6 +22,9 @@ 158467B1235A99AC00D0DAF6 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 158467B0235A99AC00D0DAF6 /* Assets.xcassets */; }; 158467B4235A99AC00D0DAF6 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 158467B3235A99AC00D0DAF6 /* Preview Assets.xcassets */; }; 158467C2235A99AC00D0DAF6 /* CoffeeKitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 158467C1235A99AC00D0DAF6 /* CoffeeKitTests.swift */; }; + 2C429B7523E7EB4B0013394B /* ImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C429B7423E7EB4B0013394B /* ImageView.swift */; }; + 2C429B7723E7EBE60013394B /* ImageLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C429B7623E7EBE60013394B /* ImageLoader.swift */; }; + 2C6BFBD523ADA26E0025DC5D /* CoffeeStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C6BFBD423ADA26E0025DC5D /* CoffeeStepView.swift */; }; 2CD8356E2388B63A003B9F6F /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 2CD835702388B63A003B9F6F /* Localizable.strings */; }; /* End PBXBuildFile section */ @@ -56,6 +59,9 @@ 158467BD235A99AC00D0DAF6 /* CoffeeKitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CoffeeKitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 158467C1235A99AC00D0DAF6 /* CoffeeKitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoffeeKitTests.swift; sourceTree = ""; }; 158467C3235A99AC00D0DAF6 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 2C429B7423E7EB4B0013394B /* ImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageView.swift; sourceTree = ""; }; + 2C429B7623E7EBE60013394B /* ImageLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageLoader.swift; sourceTree = ""; }; + 2C6BFBD423ADA26E0025DC5D /* CoffeeStepView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CoffeeStepView.swift; sourceTree = ""; }; 2CD8356F2388B63A003B9F6F /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 2CD835712388B641003B9F6F /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Localizable.strings; sourceTree = ""; }; /* End PBXFileReference section */ @@ -81,8 +87,10 @@ 035B074E237F8F0F004287DB /* Views */ = { isa = PBXGroup; children = ( + 2C6BFBD423ADA26E0025DC5D /* CoffeeStepView.swift */, 158467AE235A99A800D0DAF6 /* ContentView.swift */, 035B0755237F904F004287DB /* CoffeeCell.swift */, + 2C429B7423E7EB4B0013394B /* ImageView.swift */, ); path = Views; sourceTree = ""; @@ -121,6 +129,7 @@ isa = PBXGroup; children = ( 035B075D237F9640004287DB /* LocalDataService.swift */, + 2C429B7623E7EBE60013394B /* ImageLoader.swift */, ); path = Services; sourceTree = ""; @@ -302,9 +311,12 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 2C429B7723E7EBE60013394B /* ImageLoader.swift in Sources */, 158467AB235A99A800D0DAF6 /* AppDelegate.swift in Sources */, 158467AD235A99A800D0DAF6 /* SceneDelegate.swift in Sources */, + 2C6BFBD523ADA26E0025DC5D /* CoffeeStepView.swift in Sources */, 0318D9CA239B46F100643730 /* Ingredient.swift in Sources */, + 2C429B7523E7EB4B0013394B /* ImageView.swift in Sources */, 0318D9CE239B4BC500643730 /* PreparationStep.swift in Sources */, 0318D9C8239B431000643730 /* Grind.swift in Sources */, 0318D9CC239B4A3900643730 /* Equipment.swift in Sources */, diff --git a/CoffeeKit/Delegates/SceneDelegate.swift b/CoffeeKit/Delegates/SceneDelegate.swift index 8dc58bf..175d4a4 100644 --- a/CoffeeKit/Delegates/SceneDelegate.swift +++ b/CoffeeKit/Delegates/SceneDelegate.swift @@ -15,6 +15,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { // Create the SwiftUI view that provides the window contents. + // read data about known coffees from somewhere - for now, built-in service which reads app-bundle json, later update for remote server data guard let coffees = LocalDataService.data() else { return } diff --git a/CoffeeKit/Models/Coffee.swift b/CoffeeKit/Models/Coffee.swift index 4265048..aa1bf72 100644 --- a/CoffeeKit/Models/Coffee.swift +++ b/CoffeeKit/Models/Coffee.swift @@ -20,3 +20,59 @@ extension Coffee: Hashable { hasher.combine(id) } } + +#if DEBUG +// this is a subset of an entry from the data.json + +let testCoffees = LocalDataService.data() + +// lets try for two which should be in the data.json, with default fallback so not optional in previews +let testCoffeeStovetop: Coffee = testCoffees?.first(where: {$0.name == "Stovetop Espresso"}) ?? Coffee(id: "99", + name: "Espresso", + grind: .fine, + ingredients: [], + equipment: Equipment(filter: nil, + machine: "Espresso machine", + tamper: true), + steps: [PreparationStep(order: 9, + imageUrlString: nil, + instruction: "some words for machine expresso", + duration: 0.0, + resourceUrlString: nil)] +) + +// and a guaranteed one as created here, independent of data.json +let testCoffeeDebug = Coffee(id: "99", + name: "Espresso", + grind: .fine, + ingredients: [Ingredient(name: "beans", + quantity: 30.0, + metric: "grams"), + Ingredient(name: "water", + quantity: 60.0, + metric: "millilitres")], + equipment: Equipment(filter: nil, + machine: "Espresso pot", + tamper: true), + steps: [PreparationStep(order: 0, + imageUrlString: "https://placekitten.com/200/200", + instruction: "Remove the funnel from the base, fill the base with water to just below the safety valve.\n\nThe valve is a small fitting on the side of the lower part of the pot, usually attached by a hexagonal nut)", + duration: 120.0, + resourceUrlString: "https://www.youtube.com/watch?v=A8MFlzT07SU"), + PreparationStep(order: 1, + imageUrlString: nil, + instruction: "Place the funnel into the base, and slightly overfill with coffee grounds", + duration: 0.0, + resourceUrlString: ""), + PreparationStep(order: 2, + imageUrlString: "https://loremflickr.com/200/200/dog", + instruction: "Tamp down the coffee, wipe off any excess grounds, before screwing the top firmly onto the bottom", + duration: 0.0, + resourceUrlString: nil), + PreparationStep(order: 3, + imageUrlString: "https://loremflickr.com/g/200/200/paris", + instruction: "Put on gentle heat, ensuring the handle is NOT in rising high heat", + duration: 0.0, + resourceUrlString: nil)]) + +#endif diff --git a/CoffeeKit/Models/PreparationStep.swift b/CoffeeKit/Models/PreparationStep.swift index f2fad44..5bce2b9 100644 --- a/CoffeeKit/Models/PreparationStep.swift +++ b/CoffeeKit/Models/PreparationStep.swift @@ -2,10 +2,16 @@ import Foundation +/// data about one step in making coffee struct PreparationStep: Codable { + /// steps are presented sorted by order i.e. they are sequential, not a set let order: Int + /// if present has URL for some image (thumbnail etc) of what this step looks like let imageUrlString: String? + /// string description (how will we handle localization of this?) for this step. let instruction: String + /// time for this step in seconds. Does this really need a non-integer type ? let duration: Double + /// if present, URL links to more info such as a more detail webpage or a video.. probably in a WKWebview inside a UIViewRepresentable let resourceUrlString: String? } diff --git a/CoffeeKit/Resources/data.json b/CoffeeKit/Resources/data.json index 56f02b5..afa861e 100644 --- a/CoffeeKit/Resources/data.json +++ b/CoffeeKit/Resources/data.json @@ -1,192 +1,201 @@ { - "coffees": [ - { - "id": "0", - "name": "Machine Espresso", - "grind": "fine", - "ingredients": [ - { - "name": "beans", - "quantity": 30.0, - "metric": "grams" - }, - { - "name": "water", - "quantity": 40.0, - "metric": "grams" - }, - { - "name": "sugar", - "quantity": 50.0, - "metric": "grams" - } - ], - "equipment": { - "filter": null, - "machine": "Espresso machine", - "tamper": true, - }, - "steps": [ - { - "order": 0, - "imageUrlString": "https://placekitten.com/200/200", - "instruction": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam dui libero, viverra non est euismod, porta suscipit neque. Morbi non elementum ipsum. Donec quis lectus pharetra, pretium dui eu, porttitor turpis. Etiam sed erat orci. Praesent blandit metus vel porttitor venenatis. Donec pharetra consectetur sodales. In eget metus justo. Praesent eu vehicula dui. Nulla sed cursus odio. Suspendisse pulvinar tellus vel consectetur dictum. Morbi scelerisque faucibus orci in varius. Cras eu orci eu arcu mattis sagittis. Aliquam sit amet ipsum eros. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Suspendisse et elit et sapien ultricies lobortis.", - "duration": 120.0, - "resourceUrlString": "https://www.youtube.com/watch?v=A8MFlzT07SU", - }, - { - "order": 1, - "imageUrlString": "https://placekitten.com/200/200", - "instruction": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam dui libero, viverra non est euismod, porta suscipit neque. Morbi non elementum ipsum. Donec quis lectus pharetra, pretium dui eu, porttitor turpis. Etiam sed erat orci. Praesent blandit metus vel porttitor venenatis. Donec pharetra consectetur sodales. In eget metus justo. Praesent eu vehicula dui. Nulla sed cursus odio. Suspendisse pulvinar tellus vel consectetur dictum. Morbi scelerisque faucibus orci in varius. Cras eu orci eu arcu mattis sagittis. Aliquam sit amet ipsum eros. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Suspendisse et elit et sapien ultricies lobortis.", - "duration": 120.0, - "resourceUrlString": "https://www.youtube.com/watch?v=A8MFlzT07SU" - } + "coffees": [ + { + "id": "0", + "name": "Machine Espresso", + "grind": "fine", + "ingredients": [ + { + "name": "beans", + "quantity": 30.0, + "metric": "grams" + }, + { + "name": "water", + "quantity": 40.0, + "metric": "grams" + }, + { + "name": "sugar", + "quantity": 50.0, + "metric": "grams" + } + ], + "equipment": { + "filter": null, + "machine": "Espresso machine", + "tamper": true, + }, + "steps": [ + { + "order": 0, + "imageUrlString": "https://placekitten.com/200/200", + "instruction": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam dui libero, viverra non est euismod, porta suscipit neque. Morbi non elementum ipsum. Donec quis lectus pharetra, pretium dui eu, porttitor turpis. Etiam sed erat orci. Praesent blandit metus vel porttitor venenatis. Donec pharetra consectetur sodales. In eget metus justo. Praesent eu vehicula dui. Nulla sed cursus odio. Suspendisse pulvinar tellus vel consectetur dictum. Morbi scelerisque faucibus orci in varius. Cras eu orci eu arcu mattis sagittis. Aliquam sit amet ipsum eros. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Suspendisse et elit et sapien ultricies lobortis.", + "duration": 120.0, + "resourceUrlString": "https://www.youtube.com/watch?v=A8MFlzT07SU", + }, + { + "order": 1, + "imageUrlString": "https://placekitten.com/200/200", + "instruction": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam dui libero, viverra non est euismod, porta suscipit neque. Morbi non elementum ipsum. Donec quis lectus pharetra, pretium dui eu, porttitor turpis. Etiam sed erat orci. Praesent blandit metus vel porttitor venenatis. Donec pharetra consectetur sodales. In eget metus justo. Praesent eu vehicula dui. Nulla sed cursus odio. Suspendisse pulvinar tellus vel consectetur dictum. Morbi scelerisque faucibus orci in varius. Cras eu orci eu arcu mattis sagittis. Aliquam sit amet ipsum eros. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Suspendisse et elit et sapien ultricies lobortis.", + "duration": 120.0, + "resourceUrlString": "https://www.youtube.com/watch?v=A8MFlzT07SU" + } - ] - }, - { - "id": "1", - "name": "Moka Espresso", - "grind": "fine", - "ingredients": [ - { - "name": "beans", - "quantity": 60.0, - "metric": "grams" - }, - { - "name": "water", - "quantity": 70.0, - "metric": "grams" - }, - { - "name": "sugar", - "quantity": 80.0, - "metric": "grams" - } - ], - "equipment": { - "filter": null, - "machine": "type", - "tamper": true, - }, - "steps": [ - { - "order": 0, - "imageUrlString": "", - "instruction": "", - "duration": 120.0, - "resourceUrlString": "" - } - ] - }, - { - "id": "2", - "name": "Plunger", - "grind": "coarse", - "ingredients": [ - { - "name": "beans", - "quantity": 90.0, - "metric": "grams" - }, - { - "name": "water", - "quantity": 100.0, - "metric": "grams" - }, - { - "name": "sugar", - "quantity": 110.0, - "metric": "grams" - } - ], - "equipment": { - "filter": null, - "machine": "Plunger", - "tamper": false, - }, - "steps": [ - { - "order": 0, - "imageUrlString": "", - "instruction": "", - "duration": 120.0, - "resourceUrlString": "" - } - ] - }, - { - "id": "3", - "name": "Pour Over", - "grind": "medium", - "ingredients": [ - { - "name": "beans", - "quantity": 120.0, - "metric": "grams" - }, - { - "name": "water", - "quantity": 130.0, - "metric": "grams" - }, - { - "name": "sugar", - "quantity": 140.0, - "metric": "grams" - } - ], - "equipment": { - "filter": "Cone filter (paper or reusable)", - "machine": "Hario V60", - "tamper": false, - }, - "steps": [ - { - "order": 0, - "imageUrlString": null, - "instruction": "", - "duration": 120.0, - "resourceUrlString": null - } - ] - }, - { - "id": "4", - "name": "Turkish Coffee", - "grind": "extraFine", - "ingredients": [ - { - "name": "beans", - "quantity": 150.0, - "metric": "grams" - }, - { - "name": "water", - "quantity": 160.0, - "metric": "grams" - }, - { - "name": "sugar", - "quantity": 170.0, - "metric": "grams" - } - ], - "equipment": { - "filter": null, - "machine": "Turkish coffee maker?", - "tamper": false, - }, - "steps": [ - { - "order": 0, - "imageUrlString": "", - "instruction": "", - "duration": 120.0, - "resourceUrlString": "" - } - ] - } - ] + ] + }, + { + "id": "1", + "name": "Stovetop Espresso", + "grind": "fine", + "ingredients": [ + { + "name": "beans", + "quantity": 30.0, + "metric": "grams" + }, + { + "name": "water", + "quantity": 60.0, + "metric": "grams" + }, + ], + "equipment": { + "filter": null, + "machine": "Espresso pot", + "tamper": true, + }, + "steps": [ + { + "order": 0, + "imageUrlString": "https://placekitten.com/200/200", + "instruction": "Remove the funnel from the base, fill the base with tepid water to just below the safety valve.\n\nThe valve is a small fitting on the side of the lower part of the pot, usually attached by a hexagonal nut)", + "duration": 0.0, + "resourceUrlString": "https://www.youtube.com/watch?v=A8MFlzT07SU" + }, + { + "order": 1, + "imageUrlString": null, + "instruction": "Place the funnel into the base, and slightly overfill with coffee grounds", + "duration": 0.0, + "resourceUrlString": null + }, + { + "order": 2, + "imageUrlString": "https://loremflickr.com/200/200/dog" + "instruction": "Tamp down the coffee, wipe off any excess grounds, before screwing the top firmly onto the bottom", + "duration": 0.0, + "resourceUrlString": null + } + ] + }, + { + "id": "2", + "name": "Plunger", + "grind": "coarse", + "ingredients": [ + { + "name": "beans", + "quantity": 90.0, + "metric": "grams" + }, + { + "name": "water", + "quantity": 200.0, + "metric": "grams" + }, + { + "name": "sugar", + "quantity": 110.0, + "metric": "grams" + } + ], + "equipment": { + "filter": null, + "machine": "Plunger", + "tamper": false, + }, + "steps": [ + { + "order": 0, + "imageUrlString": "", + "instruction": "", + "duration": 120.0, + "resourceUrlString": "" + } + ] + }, + { + "id": "3", + "name": "Pour Over", + "grind": "medium", + "ingredients": [ + { + "name": "beans", + "quantity": 120.0, + "metric": "grams" + }, + { + "name": "water", + "quantity": 130.0, + "metric": "grams" + }, + { + "name": "sugar", + "quantity": 140.0, + "metric": "grams" + } + ], + "equipment": { + "filter": "Cone filter (paper or reusable)", + "machine": "Hario V60", + "tamper": false, + }, + "steps": [ + { + "order": 0, + "imageUrlString": null, + "instruction": "", + "duration": 120.0, + "resourceUrlString": null + } + ] + }, + { + "id": "4", + "name": "Turkish Coffee", + "grind": "extraFine", + "ingredients": [ + { + "name": "beans", + "quantity": 150.0, + "metric": "grams" + }, + { + "name": "water", + "quantity": 160.0, + "metric": "grams" + }, + { + "name": "sugar", + "quantity": 170.0, + "metric": "grams" + } + ], + "equipment": { + "filter": null, + "machine": "Turkish coffee maker?", + "tamper": false, + }, + "steps": [ + { + "order": 0, + "imageUrlString": "", + "instruction": "", + "duration": 120.0, + "resourceUrlString": "" + } + ] + } + ] } diff --git a/CoffeeKit/Services/ImageLoader.swift b/CoffeeKit/Services/ImageLoader.swift new file mode 100644 index 0000000..8375eb1 --- /dev/null +++ b/CoffeeKit/Services/ImageLoader.swift @@ -0,0 +1,20 @@ +// Copyright © 2020 SwiftPeerLabSydney. All rights reserved. +// adapted from http://www.gfrigerio.com/remote-images-in-swiftui/ + +import Foundation + +class ImageLoader: ObservableObject { + @Published var data: Data? + + init(urlString: String) { + guard let url = URL(string: urlString) else { return } + // NC 20200307 this needs error handling etc + let task = URLSession.shared.dataTask(with: url) { data, response, error in + guard let data = data else { return } + DispatchQueue.main.async { + self.data = data + } + } + task.resume() + } +} diff --git a/CoffeeKit/Views/CoffeeStepView.swift b/CoffeeKit/Views/CoffeeStepView.swift new file mode 100644 index 0000000..6c83c4a --- /dev/null +++ b/CoffeeKit/Views/CoffeeStepView.swift @@ -0,0 +1,60 @@ +// Copyright © 2019 SwiftPeerLabSydney. All rights reserved. + +import SwiftUI + +struct CoffeeStepView: View { + // which coffee we are showing a step of + var coffee: Coffee + /// which PreparationStep is this view showing - some manager of the multi-step process should be changing this (next/previous buttons?) + /// should this be an observable, being changed externally and view refreshes (rather than creating new view for different step ?) + var stepIndex: Int + + var body: some View { + // we want some indent from screen edges + HStack { + Spacer() + + VStack { + Text(NSLocalizedString(coffee.name, comment: "must have key:value to match data.json")) + Text("Step \(stepIndex+1)") + + Spacer() + + // optional image view IF a URL is specified. Note, can't do this with if inside View builder + coffee.steps[stepIndex].imageUrlString.map({ + ImageView(withURL: $0) + }) + + Text(coffee.steps[stepIndex].instruction) + + // optional UX element, IF there is a resourceUrlString + coffee.steps[stepIndex].resourceUrlString.map({_ in + VStack { + Spacer() + // probably should be a NavigationLink, tapping goes to View probably wrapping a WKWebView + Text("More info >") + .foregroundColor(.blue) + } + }) + + // optional helper timer IF there is a non-zero duration + } + Spacer() + } + } +} + +#if DEBUG +struct CoffeeStepView_Previews: PreviewProvider { + // test coffee from model Coffee.swift + static var previews: some View { + CoffeeStepView(coffee: testCoffeeDebug, + stepIndex: 1) + .previewDevice(PreviewDevice(rawValue: "iPhone 8")) + .previewDisplayName("iPhone 8") +// .environment(\.sizeCategory, .extraExtraExtraLarge) + // set the local language, can be simple ISO 639-1 2-char codes like "ko" == korean, or more-specialised language ID like "en-AU" for English in Australia + .environment(\.locale, .init(identifier: "ko")) + } +} +#endif diff --git a/CoffeeKit/Views/ContentView.swift b/CoffeeKit/Views/ContentView.swift index 19e8aef..f40d285 100644 --- a/CoffeeKit/Views/ContentView.swift +++ b/CoffeeKit/Views/ContentView.swift @@ -15,7 +15,9 @@ struct ContentView: View { TabView { List { ForEach(coffees, id: \.self ) { coffee in +// NavigationLink(destination: CoffeeStepView) { CoffeeCell(coffee: coffee) +// } } } .tabItem { @@ -30,3 +32,14 @@ struct ContentView: View { } } } + +#if DEBUG +struct CoffeeMainView_Previews: PreviewProvider { + // test coffee from model Coffee.swift + static var previews: some View { + ContentView(coffees: []) + // set the local language, ISO 639-1 codes. "ko" == korean + .environment(\.locale, .init(identifier: "ko")) + } +} +#endif diff --git a/CoffeeKit/Views/ImageView.swift b/CoffeeKit/Views/ImageView.swift new file mode 100644 index 0000000..c0230a5 --- /dev/null +++ b/CoffeeKit/Views/ImageView.swift @@ -0,0 +1,45 @@ +// Copyright © 2020 SwiftPeerLabSydney. All rights reserved. +// adapted from http://www.gfrigerio.com/remote-images-in-swiftui/ + +import Foundation +import SwiftUI +import UIKit + +struct ImageView: View { + @ObservedObject var imageLoader: ImageLoader + @State var image: UIImage = UIImage() + let W, H: CGFloat + let CM: ContentMode + + // the frame width/height + + init(withURL url: String, + width: CGFloat = 200, + height: CGFloat = 200, + contentMode: ContentMode = .fit) { + imageLoader = ImageLoader(urlString: url) + W = width + H = height + CM = contentMode + } + + var body: some View { + VStack { + Image(uiImage: imageLoader.data != nil ? UIImage(data: imageLoader.data!)! : UIImage()) + .resizable() + .aspectRatio(contentMode: CM) + .frame(width: W, height: H) + } + } +} + +#if DEBUG +struct ImageView_Previews: PreviewProvider { + // test image from model Coffee.swift + static var previews: some View { + ImageView(withURL: "https://placekitten.com/200/200") + .previewDevice(PreviewDevice(rawValue: "iPhone 8")) + .previewDisplayName("iPhone 8") + .border(Color.red, width: 1.0)} +} +#endif