diff --git a/App/App.swift b/App/App.swift index d72e7ca..12a969d 100644 --- a/App/App.swift +++ b/App/App.swift @@ -7,6 +7,7 @@ The single entry point for the Food Truck app on iOS and macOS. import SwiftUI import FoodTruckKit +import Decide /// The app's entry point. /// @@ -18,7 +19,11 @@ struct FoodTruckApp: App { @StateObject private var model = FoodTruckModel() /// The in-app purchase store's state. @StateObject private var accountStore = AccountStore() - + + init() { + ApplicationEnvironment.bootstrap(donuts: model.donuts) + } + /// The app's body function. /// /// This app uses a [`WindowGroup`](https://developer.apple.com/documentation/swiftui/windowgroup) scene, which contains the root view of the app, ``ContentView``. diff --git a/App/Donut/DonutEditor.swift b/App/Donut/DonutEditor.swift index 0fbb309..8c2e594 100644 --- a/App/Donut/DonutEditor.swift +++ b/App/Donut/DonutEditor.swift @@ -7,10 +7,16 @@ The donut editor view. import SwiftUI import FoodTruckKit +import Decide struct DonutEditor: View { - @Binding var donut: Donut - + @Observe(\FoodTruckState.$selectedDonut) var id + @BindKeyed(\FoodTruckState.Data.$donut) var donuts + + var donut: Donut { + donuts[id] + } + var body: some View { ZStack { #if os(macOS) @@ -76,7 +82,7 @@ struct DonutEditor: View { @ViewBuilder var editorContent: some View { Section("Donut") { - TextField("Name", text: $donut.name, prompt: Text("Donut Name")) + TextField("Name", text: donuts[id].name, prompt: Text("Donut Name")) } Section("Flavor Profile") { @@ -108,14 +114,14 @@ struct DonutEditor: View { } Section("Ingredients") { - Picker("Dough", selection: $donut.dough) { + Picker("Dough", selection: donuts[id].dough) { ForEach(Donut.Dough.all) { dough in Text(dough.name) .tag(dough) } } - Picker("Glaze", selection: $donut.glaze) { + Picker("Glaze", selection: donuts[id].glaze) { Section { Text("None") .tag(nil as Donut.Glaze?) @@ -126,7 +132,7 @@ struct DonutEditor: View { } } - Picker("Topping", selection: $donut.topping) { + Picker("Topping", selection: donuts[id].topping) { Section { Text("None") .tag(nil as Donut.Topping?) @@ -165,7 +171,7 @@ struct DonutEditor_Previews: PreviewProvider { @State private var donut = Donut.preview var body: some View { - DonutEditor(donut: $donut) + DonutEditor() } } diff --git a/App/Donut/DonutGallery.swift b/App/Donut/DonutGallery.swift index f08a900..c9758eb 100644 --- a/App/Donut/DonutGallery.swift +++ b/App/Donut/DonutGallery.swift @@ -7,9 +7,14 @@ The donut gallery view. import SwiftUI import FoodTruckKit +import Decide struct DonutGallery: View { @ObservedObject var model: FoodTruckModel + + @Bind(\FoodTruckState.$selectedDonut) var selectedDonut + @Bind(\FoodTruckState.Index.$donut) var donutsIndex + @ObserveKeyed(\FoodTruckState.Data.$donut) var donutsData @State private var layout = BrowserLayout.grid @State private var sort = DonutSortOrder.popularity(.week) @@ -19,8 +24,8 @@ struct DonutGallery: View { @State private var selection = Set() @State private var searchText = "" - var filteredDonuts: [Donut] { - model.donuts(sortedBy: sort).filter { $0.matches(searchText: searchText) } + var filteredDonuts: [Donut.ID] { + donutsIndex.filter { donutsData[$0].matches(searchText: searchText) } } var tableImageSize: Double { @@ -61,10 +66,18 @@ struct DonutGallery: View { .searchable(text: $searchText) .navigationTitle("Donuts") .navigationDestination(for: Donut.ID.self) { donutID in - DonutEditor(donut: model.donutBinding(id: donutID)) + DonutEditor() + .onAppear() { + selectedDonut = donutID + } } .navigationDestination(for: String.self) { _ in - DonutEditor(donut: $model.newDonut) + DonutEditor() + .onAppear() { + let newID = donutsIndex.count + selectedDonut = newID + donutsIndex.append(newID) + } } } @@ -78,13 +91,13 @@ struct DonutGallery: View { var table: some View { Table(filteredDonuts, selection: $selection) { - TableColumn("Name") { donut in - NavigationLink(value: donut.id) { + TableColumn("Name") { donutId in + NavigationLink(value: donutId) { HStack { - DonutView(donut: donut) + DonutView(donut: donutsData[donutId]) .frame(width: tableImageSize, height: tableImageSize) - Text(donut.name) + Text(donutsData[donutId].name) } } } @@ -180,5 +193,6 @@ struct DonutBakery_Previews: PreviewProvider { NavigationStack { Preview() } + .appEnvironment(.preview) } } diff --git a/App/Donut/DonutGalleryGrid.swift b/App/Donut/DonutGalleryGrid.swift index a9bb87d..73d9bc5 100644 --- a/App/Donut/DonutGalleryGrid.swift +++ b/App/Donut/DonutGalleryGrid.swift @@ -7,10 +7,13 @@ The grid view used in the DonutGallery. import SwiftUI import FoodTruckKit +import Decide struct DonutGalleryGrid: View { - var donuts: [Donut] + var donuts: [Donut.ID] var width: Double + + @ObserveKeyed(\FoodTruckState.Data.$donut) var donutsData #if os(iOS) @Environment(\.horizontalSizeClass) private var sizeClass @@ -58,15 +61,15 @@ struct DonutGalleryGrid: View { var body: some View { LazyVGrid(columns: gridItems, spacing: 20) { - ForEach(donuts) { donut in - NavigationLink(value: donut.id) { + ForEach(donuts, id: \.self) { donutId in + NavigationLink(value: donutId) { VStack { - DonutView(donut: donut) + DonutView(donut: donutsData[donutId]) .frame(width: thumbnailSize, height: thumbnailSize) VStack { - let flavor = donut.flavors.mostPotentFlavor - Text(donut.name) + let flavor = donutsData[donutId].flavors.mostPotentFlavor + Text(donutsData[donutId].name) HStack(spacing: 4) { flavor.image Text(flavor.name) @@ -86,12 +89,15 @@ struct DonutGalleryGrid: View { struct DonutGalleryGrid_Previews: PreviewProvider { struct Preview: View { - @State private var donuts = Donut.all - + @State private var donuts = Donut.all.map { $0.id } + var body: some View { GeometryReader { geometryProxy in ScrollView { - DonutGalleryGrid(donuts: donuts, width: geometryProxy.size.width) + DonutGalleryGrid( + donuts: donuts, + width: geometryProxy.size.width + ) } } } @@ -99,5 +105,6 @@ struct DonutGalleryGrid_Previews: PreviewProvider { static var previews: some View { Preview() + .appEnvironment(.preview) } } diff --git a/App/General/ApplicationEnvironment+Preview.swift b/App/General/ApplicationEnvironment+Preview.swift new file mode 100644 index 0000000..3bbce21 --- /dev/null +++ b/App/General/ApplicationEnvironment+Preview.swift @@ -0,0 +1,49 @@ +// +// ApplicationEnvironment+Preview.swift +// +// +// Created by Anton Kolchunov on 21.08.23. +// + +import Foundation +import FoodTruckKit +@testable import Decide + +extension ApplicationEnvironment { + static var preview: ApplicationEnvironment { + bootstrap() + return `default` + } + + static func bootstrap(donuts: [Donut] = Donut.all) { + bootstrap(donuts.map { $0.id }, for: \FoodTruckState.Index.$donut) + for donut in donuts { + bootstrap(donut, for: \FoodTruckState.Data.$donut, at: donut.id) + } + bootstrap(0, for: \FoodTruckState.$selectedDonut) + } + + // TODO: Move to Decide framework + static func bootstrap( + _ newValue: Value, + for keyPath: KeyPath> + ) { + `default`.setValue( + newValue, + keyPath.appending(path: \.wrappedValue) + ) + } + + // TODO: Move to Decide framework + static func bootstrap, Value>( + _ newValue: Value, + for keyPath: KeyPath>, + at identifier: I + ) { + `default`.setValue( + newValue, + keyPath.appending(path: \.wrappedValue), + at: identifier) + } +} + diff --git a/App/General/Int+Identifiable.swift b/App/General/Int+Identifiable.swift new file mode 100644 index 0000000..e40e016 --- /dev/null +++ b/App/General/Int+Identifiable.swift @@ -0,0 +1,13 @@ +// +// Int+Identifiable.swift +// Food Truck +// +// Created by Anton Kolchunov on 21.08.23. +// Copyright © 2023 Apple. All rights reserved. +// + +import Foundation + +extension Int: Identifiable { + public var id: Int { self } +} diff --git a/App/Navigation/DetailColumn.swift b/App/Navigation/DetailColumn.swift index d073ae4..094188a 100644 --- a/App/Navigation/DetailColumn.swift +++ b/App/Navigation/DetailColumn.swift @@ -42,7 +42,7 @@ struct DetailColumn: View { case .donuts: DonutGallery(model: model) case .donutEditor: - DonutEditor(donut: $model.newDonut) + DonutEditor() case .topFive: TopFiveDonutsView(model: model) case .city(let id): diff --git a/App/Truck/FoodTruckState.swift b/App/Truck/FoodTruckState.swift new file mode 100644 index 0000000..a02e41f --- /dev/null +++ b/App/Truck/FoodTruckState.swift @@ -0,0 +1,32 @@ +// +// FoodTruckState.swift +// Food Truck +// +// Created by Anton Kolchunov on 21.08.23. +// Copyright © 2023 Apple. All rights reserved. +// + +import Decide +import FoodTruckKit + +final class FoodTruckState: AtomicState { + @Mutable @Property public var selectedDonut: Donut.ID = -1 + + final class Index: AtomicState { + @Mutable @Property public var donut = [Donut.ID]() + } + + final class Data: KeyedState { + @Mutable @Property public var donut: Donut = Donut.newDonut + } +} + +extension Donut { + static var newDonut = Donut( + id: Donut.all.count, + name: String(localized: "New Donut", comment: "New donut-placeholder name."), + dough: .plain, + glaze: .none, + topping: .none + ) +} diff --git a/Food Truck.xcodeproj/project.pbxproj b/Food Truck.xcodeproj/project.pbxproj index c879589..f969515 100644 --- a/Food Truck.xcodeproj/project.pbxproj +++ b/Food Truck.xcodeproj/project.pbxproj @@ -7,6 +7,13 @@ objects = { /* Begin PBXBuildFile section */ + 220EFD2A2A93AF0E004488DE /* Decide in Frameworks */ = {isa = PBXBuildFile; productRef = 220EFD292A93AF0E004488DE /* Decide */; }; + 2273ABDE2A940FB8005EFE4F /* FoodTruckState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2273ABDD2A940FB8005EFE4F /* FoodTruckState.swift */; }; + 2273ABDF2A940FB8005EFE4F /* FoodTruckState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2273ABDD2A940FB8005EFE4F /* FoodTruckState.swift */; }; + 2273ABE12A941145005EFE4F /* Int+Identifiable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2273ABE02A941145005EFE4F /* Int+Identifiable.swift */; }; + 2273ABE22A941145005EFE4F /* Int+Identifiable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2273ABE02A941145005EFE4F /* Int+Identifiable.swift */; }; + 2273ABE42A941173005EFE4F /* ApplicationEnvironment+Preview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2273ABE32A941173005EFE4F /* ApplicationEnvironment+Preview.swift */; }; + 2273ABE52A941173005EFE4F /* ApplicationEnvironment+Preview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2273ABE32A941173005EFE4F /* ApplicationEnvironment+Preview.swift */; }; 6178CE6E2845B07E00F240E8 /* CardNavigationHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6178CE6D2845B07E00F240E8 /* CardNavigationHeader.swift */; }; 6178CE6F2845B07E00F240E8 /* CardNavigationHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6178CE6D2845B07E00F240E8 /* CardNavigationHeader.swift */; }; 84ACCDC3282D671600756FB7 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 84ACCDC1282D671600756FB7 /* Localizable.strings */; }; @@ -161,6 +168,9 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 2273ABDD2A940FB8005EFE4F /* FoodTruckState.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FoodTruckState.swift; sourceTree = ""; }; + 2273ABE02A941145005EFE4F /* Int+Identifiable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Int+Identifiable.swift"; sourceTree = ""; }; + 2273ABE32A941173005EFE4F /* ApplicationEnvironment+Preview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ApplicationEnvironment+Preview.swift"; sourceTree = ""; }; 6178CE6D2845B07E00F240E8 /* CardNavigationHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardNavigationHeader.swift; sourceTree = ""; }; 7AE906C103F323E6CEF38CA5 /* LICENSE.txt */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text; path = LICENSE.txt; sourceTree = ""; }; 84ACCDC5282D671600756FB7 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = ""; }; @@ -248,6 +258,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 220EFD2A2A93AF0E004488DE /* Decide in Frameworks */, E0C37BDF28232B8B007B925B /* FoodTruckKit in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -399,6 +410,7 @@ E039E3522834AC4800508176 /* SalesHistoryView.swift */, E0C37C0B2823339F007B925B /* TruckView.swift */, E08D705F283C492C00014B89 /* SocialFeedContent.swift */, + 2273ABDD2A940FB8005EFE4F /* FoodTruckState.swift */, ); path = Truck; sourceTree = ""; @@ -408,6 +420,8 @@ children = ( E0C37C14282333AE007B925B /* WidthThresholdReader.swift */, E0C4A534283EBD67007D5B83 /* FlowLayout.swift */, + 2273ABE02A941145005EFE4F /* Int+Identifiable.swift */, + 2273ABE32A941173005EFE4F /* ApplicationEnvironment+Preview.swift */, ); path = General; sourceTree = ""; @@ -505,6 +519,7 @@ name = "Food Truck"; packageProductDependencies = ( E0C37BDE28232B8B007B925B /* FoodTruckKit */, + 220EFD292A93AF0E004488DE /* Decide */, ); productName = "Food Truck"; productReference = E0C37BC12823189A007B925B /* Food Truck.app */; @@ -559,6 +574,9 @@ ar, ); mainGroup = E0C37BB82823189A007B925B; + packageReferences = ( + 220EFD282A93AF0E004488DE /* XCRemoteSwiftPackageReference "Decide" */, + ); productRefGroup = E0C37BC22823189A007B925B /* Products */; projectDirPath = ""; projectRoot = ""; @@ -612,6 +630,7 @@ E0510739283C187300FCE3E6 /* App.swift in Sources */, E051073A283C187300FCE3E6 /* OrderDetailView.swift in Sources */, E051073B283C187300FCE3E6 /* SocialFeedView.swift in Sources */, + 2273ABE22A941145005EFE4F /* Int+Identifiable.swift in Sources */, E051073C283C187300FCE3E6 /* OrderCompleteView.swift in Sources */, E051073F283C187300FCE3E6 /* RecommendedParkingSpotCard.swift in Sources */, E0510740283C187300FCE3E6 /* StoreSupportView.swift in Sources */, @@ -623,8 +642,10 @@ E0510748283C187300FCE3E6 /* CardNavigationHeaderLabelStyle.swift in Sources */, E0510749283C187300FCE3E6 /* TruckDonutsCard.swift in Sources */, E051074A283C187300FCE3E6 /* DonutGallery.swift in Sources */, + 2273ABDF2A940FB8005EFE4F /* FoodTruckState.swift in Sources */, E051074B283C187300FCE3E6 /* SocialFeedPlusSettings.swift in Sources */, E0C4A536283EBD67007D5B83 /* FlowLayout.swift in Sources */, + 2273ABE52A941173005EFE4F /* ApplicationEnvironment+Preview.swift in Sources */, E051074C283C187300FCE3E6 /* DetailColumn.swift in Sources */, E051074D283C187300FCE3E6 /* WidthThresholdReader.swift in Sources */, E051074E283C187300FCE3E6 /* SignUpView.swift in Sources */, @@ -665,6 +686,7 @@ E0C37C102823339F007B925B /* SocialFeedView.swift in Sources */, E0C37C112823339F007B925B /* OrderCompleteView.swift in Sources */, 6178CE6E2845B07E00F240E8 /* CardNavigationHeader.swift in Sources */, + 2273ABDE2A940FB8005EFE4F /* FoodTruckState.swift in Sources */, E0C37BEF282331AA007B925B /* RecommendedParkingSpotCard.swift in Sources */, E0C37C28282333C9007B925B /* StoreSupportView.swift in Sources */, E0C37BFC28233374007B925B /* DonutEditor.swift in Sources */, @@ -675,6 +697,7 @@ E09DC3692833222700040525 /* CardNavigationHeaderLabelStyle.swift in Sources */, E09DC36D2833222700040525 /* TruckDonutsCard.swift in Sources */, E0C37C0028233374007B925B /* DonutGallery.swift in Sources */, + 2273ABE12A941145005EFE4F /* Int+Identifiable.swift in Sources */, E0C37C29282333C9007B925B /* SocialFeedPlusSettings.swift in Sources */, E0C37C1E282333B8007B925B /* DetailColumn.swift in Sources */, E0C4A52B283E8FD5007D5B83 /* TopFiveDonutsView.swift in Sources */, @@ -683,6 +706,7 @@ E08D7060283C492C00014B89 /* SocialFeedContent.swift in Sources */, E0C37BE328233197007B925B /* SignUpView.swift in Sources */, E0C37BED282331AA007B925B /* CityWeatherCard.swift in Sources */, + 2273ABE42A941173005EFE4F /* ApplicationEnvironment+Preview.swift in Sources */, E0C37C26282333C9007B925B /* SubscriptionStoreView.swift in Sources */, E09DC36A2833222700040525 /* TruckSocialFeedCard.swift in Sources */, E09DC36B2833222700040525 /* TruckWeatherCard.swift in Sources */, @@ -1167,7 +1191,23 @@ }; /* End XCConfigurationList section */ +/* Begin XCRemoteSwiftPackageReference section */ + 220EFD282A93AF0E004488DE /* XCRemoteSwiftPackageReference "Decide" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/MaximBazarov/Decide"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.1.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + /* Begin XCSwiftPackageProductDependency section */ + 220EFD292A93AF0E004488DE /* Decide */ = { + isa = XCSwiftPackageProductDependency; + package = 220EFD282A93AF0E004488DE /* XCRemoteSwiftPackageReference "Decide" */; + productName = Decide; + }; E0510733283C187300FCE3E6 /* FoodTruckKit */ = { isa = XCSwiftPackageProductDependency; productName = FoodTruckKit;