├── Assets └── Screenshot.png ├── Dogs ├── Assets.xcassets │ ├── Contents.json │ ├── AccentColor.colorset │ │ └── Contents.json │ └── AppIcon.appiconset │ │ └── Contents.json └── DogsApp.swift ├── WatchDogs Watch App ├── Assets.xcassets │ ├── Contents.json │ ├── AccentColor.colorset │ │ └── Contents.json │ └── AppIcon.appiconset │ │ └── Contents.json └── WatchDogsApp.swift ├── Packages └── Core │ ├── .gitignore │ ├── Sources │ ├── PreviewAssets │ │ ├── Assets.xcassets │ │ │ └── Contents.json │ │ ├── _PreviewAssets.xcassets │ │ │ ├── Contents.json │ │ │ └── kurosuke01.imageset │ │ │ │ ├── kurosuke01.jpg │ │ │ │ └── Contents.json │ │ ├── Entity │ │ │ ├── BreedListItem.swift │ │ │ └── BreedList.swift │ │ └── PreviewAsset.swift │ ├── WatchServices │ │ └── WatchServices.swift │ ├── DogAPI │ │ ├── DogAPI.swift │ │ ├── DogAPIResponse.swift │ │ ├── Request │ │ │ ├── BreedListRequest.swift │ │ │ ├── RandomImageRequest.swift │ │ │ ├── BreedImageRequest.swift │ │ │ └── SubBreedImageRequest.swift │ │ ├── DogAPIClient.swift │ │ └── DogAPIRequest.swift │ ├── Core │ │ ├── Entity │ │ │ ├── Breed.swift │ │ │ ├── SubBreed.swift │ │ │ ├── BreedList.swift │ │ │ ├── DogImage.swift │ │ │ ├── FavoritesItem.swift │ │ │ ├── SettingsKey.swift │ │ │ ├── DogImageResource.swift │ │ │ ├── ErrorMessage.swift │ │ │ ├── DisplayableError.swift │ │ │ ├── LoadingState.swift │ │ │ ├── BreedListItem.swift │ │ │ └── ConcreteBreed.swift │ │ ├── Dependency │ │ │ ├── DependencyContainer.swift │ │ │ ├── Dependency.swift │ │ │ ├── ViewInjectable.swift │ │ │ └── MockDependencyContainer.swift │ │ ├── State │ │ │ └── FavoriteState.swift │ │ ├── Extension │ │ │ └── UserDefaults+.swift │ │ └── Utility │ │ │ └── EmptyAction.swift │ ├── RealmStorage │ │ └── Object │ │ │ ├── BreedListObject.swift │ │ │ ├── FavoritesItemObject.swift │ │ │ └── BreedListItemObject.swift │ ├── WatchUI │ │ ├── Dependency │ │ │ ├── State │ │ │ │ └── WatchState.swift │ │ │ ├── Actions │ │ │ │ ├── WatchActions+DogImage.swift │ │ │ │ └── WatchActions.swift │ │ │ ├── WatchDependency.swift │ │ │ ├── WatchContainer.swift │ │ │ └── MockWatchContainer.swift │ │ ├── Flow │ │ │ └── MainFlow.swift │ │ └── Screen │ │ │ └── DogImageScreen.swift │ ├── AppUI │ │ ├── Dependency │ │ │ ├── Actions │ │ │ │ ├── AppActions+BreedList.swift │ │ │ │ ├── AppActions+DogImage.swift │ │ │ │ ├── AppActions+Favorites.swift │ │ │ │ └── AppActions.swift │ │ │ ├── State │ │ │ │ ├── AppState+ErrorAlert.swift │ │ │ │ └── AppState.swift │ │ │ ├── AppDependency.swift │ │ │ ├── AppContainer.swift │ │ │ └── MockAppContainer.swift │ │ ├── Screen │ │ │ ├── Welcome │ │ │ │ └── WelcomeScreen.swift │ │ │ ├── Favorites │ │ │ │ ├── FavoritesEmptyView.swift │ │ │ │ └── FavoritesScreen.swift │ │ │ ├── BreedList │ │ │ │ ├── BreedListRow.swift │ │ │ │ └── BreedListScreen.swift │ │ │ ├── Settings │ │ │ │ └── SettingsScreen.swift │ │ │ └── DogImage │ │ │ │ └── DogImageScreen.swift │ │ ├── Flow │ │ │ ├── RandomImageFlow.swift │ │ │ ├── FavoritesFlow.swift │ │ │ ├── SettingsFlow.swift │ │ │ ├── BreedListFlow.swift │ │ │ └── MainFlow.swift │ │ └── ServiceView │ │ │ └── ErrorAlertServiceView.swift │ ├── CommonUI │ │ ├── Utility │ │ │ ├── WithProperty.swift │ │ │ └── OnFirstAppearance.swift │ │ └── View │ │ │ ├── TaskFailedView.swift │ │ │ └── DogImageView.swift │ ├── AppServices │ │ ├── Factory │ │ │ ├── RealmFavoritesScreenFactory.swift │ │ │ └── RealmBreedListScreenFactory.swift │ │ └── Service │ │ │ ├── BreedsListService.swift │ │ │ └── FavoritesService.swift │ ├── LiveWatch │ │ └── LiveWatchContainer.swift │ ├── CommonServices │ │ └── DogImageService.swift │ └── LiveApp │ │ └── LiveAppContainer.swift │ ├── Package@swift-5.swift │ └── Package.swift ├── Dogs.xcodeproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist ├── xcshareddata │ └── xcschemes │ │ ├── Dogs.xcscheme │ │ └── WatchDogs Watch App.xcscheme └── project.pbxproj ├── Dogs.xcworkspace ├── xcshareddata │ ├── IDEWorkspaceChecks.plist │ └── swiftpm │ │ └── Package.resolved └── contents.xcworkspacedata ├── LICENSE ├── README.md └── .gitignore /Assets/Screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/auramagi/swiftui-first-sample/HEAD/Assets/Screenshot.png -------------------------------------------------------------------------------- /Dogs/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /WatchDogs Watch App/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Packages/Core/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm 7 | .netrc 8 | PreviewAssets.xcassets -------------------------------------------------------------------------------- /Packages/Core/Sources/PreviewAssets/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Packages/Core/Sources/PreviewAssets/_PreviewAssets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Packages/Core/Sources/WatchServices/WatchServices.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WatchServices.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024-02-12. 6 | // 7 | 8 | import Foundation 9 | -------------------------------------------------------------------------------- /Packages/Core/Sources/DogAPI/DogAPI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DogAPI.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024-02-12. 6 | // 7 | 8 | import Foundation 9 | 10 | public enum DogAPI { } 11 | -------------------------------------------------------------------------------- /Dogs.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Packages/Core/Sources/Core/Entity/Breed.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Breed.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024-02-12. 6 | // 7 | 8 | import Foundation 9 | 10 | public typealias Breed = String 11 | -------------------------------------------------------------------------------- /Dogs/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 | -------------------------------------------------------------------------------- /Packages/Core/Sources/Core/Entity/SubBreed.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SubBreed.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024-02-12. 6 | // 7 | 8 | import Foundation 9 | 10 | public typealias SubBreed = String 11 | 12 | -------------------------------------------------------------------------------- /Packages/Core/Sources/Core/Entity/BreedList.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BreedList.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024-02-12. 6 | // 7 | 8 | import Foundation 9 | 10 | public typealias BreedList = [Breed: [SubBreed]] 11 | -------------------------------------------------------------------------------- /Packages/Core/Sources/PreviewAssets/_PreviewAssets.xcassets/kurosuke01.imageset/kurosuke01.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/auramagi/swiftui-first-sample/HEAD/Packages/Core/Sources/PreviewAssets/_PreviewAssets.xcassets/kurosuke01.imageset/kurosuke01.jpg -------------------------------------------------------------------------------- /WatchDogs Watch App/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 | -------------------------------------------------------------------------------- /Dogs/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Packages/Core/Sources/Core/Dependency/DependencyContainer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DependencyContainer.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024-02-12. 6 | // 7 | 8 | import Foundation 9 | 10 | @MainActor public protocol DependencyContainer: AnyObject, ViewInjectable { } 11 | -------------------------------------------------------------------------------- /Packages/Core/Sources/Core/Entity/DogImage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DogImage.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024-02-12. 6 | // 7 | 8 | import Foundation 9 | 10 | public enum DogImage: Sendable, Hashable { 11 | case random 12 | 13 | case breed(ConcreteBreed) 14 | } 15 | -------------------------------------------------------------------------------- /WatchDogs Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "watchos", 6 | "size" : "1024x1024" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Dogs.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Dogs.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Packages/Core/Sources/DogAPI/DogAPIResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DogAPIResponse.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024-02-12. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct DogAPIResponse: Sendable, Decodable { 11 | public let message: Message 12 | 13 | public let status: String 14 | } 15 | -------------------------------------------------------------------------------- /Packages/Core/Sources/PreviewAssets/Entity/BreedListItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BreedListItem.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024/02/22. 6 | // 7 | 8 | import Core 9 | import Foundation 10 | 11 | extension [BreedListItem] { 12 | public static func mock(_ list: BreedList = .mock) -> Self { 13 | list.map() 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Packages/Core/Sources/PreviewAssets/_PreviewAssets.xcassets/kurosuke01.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "kurosuke01.jpg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | }, 12 | "properties" : { 13 | "template-rendering-intent" : "original" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Packages/Core/Sources/RealmStorage/Object/BreedListObject.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BreedListObject.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024-02-14. 6 | // 7 | 8 | import Core 9 | import Foundation 10 | import RealmSwift 11 | 12 | public final class BreedListObject: Object, ObjectKeyIdentifiable { 13 | @Persisted public var breeds: List 14 | } 15 | -------------------------------------------------------------------------------- /Packages/Core/Sources/Core/State/FavoriteState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FavoriteState.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024/02/15. 6 | // 7 | 8 | import Foundation 9 | 10 | public final class FavoriteState: ObservableObject { 11 | @Published public var canFavorite = false 12 | 13 | @Published public var isFavorited = false 14 | 15 | public init() { } 16 | } 17 | -------------------------------------------------------------------------------- /Packages/Core/Sources/WatchUI/Dependency/State/WatchState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WatchState.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024-02-13. 6 | // 7 | 8 | import Core 9 | import SwiftUI 10 | 11 | public struct WatchState: ViewInjectable { 12 | public init() { } 13 | 14 | public func inject(content: Content) -> some View { 15 | content 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Packages/Core/Sources/PreviewAssets/PreviewAsset.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PreviewAssets.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024-02-14. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public enum PreviewAsset { } 11 | 12 | extension PreviewAsset { 13 | public enum Image { 14 | public static var kurosuke01: SwiftUI.Image { .init("kurosuke01", bundle: .module) } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Packages/Core/Sources/AppUI/Dependency/Actions/AppActions+BreedList.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppActions+BreedList.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024-02-12. 6 | // 7 | 8 | import Core 9 | import Foundation 10 | 11 | extension AppActions { 12 | @MainActor public struct BreedList { 13 | public var refresh: () async -> Void = emptyAction() 14 | 15 | nonisolated init() { } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Packages/Core/Sources/AppUI/Dependency/State/AppState+ErrorAlert.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppState+ErrorAlert.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024/02/15. 6 | // 7 | 8 | import Core 9 | import Foundation 10 | 11 | extension AppState { 12 | @MainActor public final class ErrorAlert: ObservableObject { 13 | @Published public var alert: DisplayableError? 14 | 15 | public init() { } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Packages/Core/Sources/Core/Extension/UserDefaults+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserDefaults+.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024/02/14. 6 | // 7 | 8 | import Foundation 9 | 10 | extension UserDefaults { 11 | public static func mock(name: String = UUID().uuidString) -> UserDefaults { 12 | let defaults = UserDefaults(suiteName: name)! 13 | defaults.removePersistentDomain(forName: name) 14 | return defaults 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Packages/Core/Sources/Core/Entity/FavoritesItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FavoritesItem.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024/02/15. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct FavoritesItem: Sendable, Identifiable { 11 | public let id: UUID 12 | 13 | public let resource: DogImageResource 14 | 15 | public init(id: UUID, resource: DogImageResource) { 16 | self.id = id 17 | self.resource = resource 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Packages/Core/Sources/AppUI/Dependency/Actions/AppActions+DogImage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppActions+DogImage.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024-02-12. 6 | // 7 | 8 | import Core 9 | import Foundation 10 | 11 | extension AppActions { 12 | @MainActor public struct DogImage { 13 | public var getImage: (Core.DogImage) async throws -> DogImageResource = emptyAction(throwing: .message("Unimplemented")) 14 | 15 | nonisolated init() { } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Packages/Core/Sources/AppUI/Dependency/AppDependency.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDependency.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024-02-12. 6 | // 7 | 8 | import Core 9 | import SwiftUI 10 | 11 | public struct AppDependency: Dependency { 12 | public var state: AppState 13 | 14 | public var actions: AppActions 15 | 16 | public init(state: AppState, actions: AppActions) { 17 | self.state = state 18 | self.actions = actions 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Packages/Core/Sources/AppUI/Dependency/State/AppState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppState.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024-02-12. 6 | // 7 | 8 | import Core 9 | import SwiftUI 10 | 11 | @MainActor public struct AppState: ViewInjectable { 12 | public var errorAlert = ErrorAlert() 13 | 14 | public init() { } 15 | 16 | public func inject(content: Content) -> some View { 17 | content 18 | .environmentObject(errorAlert) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Packages/Core/Sources/WatchUI/Dependency/Actions/WatchActions+DogImage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WatchActions+DogImage.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024-02-14. 6 | // 7 | 8 | import Core 9 | import Foundation 10 | 11 | extension WatchActions { 12 | @MainActor public struct DogImage { 13 | public var getImage: (Core.DogImage) async throws -> DogImageResource = emptyAction(throwing: .message("Unimplemented")) 14 | 15 | nonisolated init() { } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Packages/Core/Sources/Core/Entity/SettingsKey.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsKey.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024/02/14. 6 | // 7 | 8 | import Foundation 9 | 10 | public enum SettingsKey { } 11 | 12 | extension SettingsKey { 13 | public enum Welcome { 14 | public static let didShow = "Welcome/didShow" 15 | } 16 | } 17 | 18 | extension SettingsKey { 19 | public enum Favorites { 20 | public static let prefersFill = "Favorites/prefersFill" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Packages/Core/Sources/WatchUI/Dependency/WatchDependency.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WatchDependency.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024-02-13. 6 | // 7 | 8 | import Core 9 | import SwiftUI 10 | 11 | public struct WatchDependency: Dependency { 12 | public var state: WatchState 13 | 14 | public var actions: WatchActions 15 | 16 | public init(state: WatchState, actions: WatchActions) { 17 | self.state = state 18 | self.actions = actions 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Packages/Core/Sources/Core/Entity/DogImageResource.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DogImageResource.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024-02-14. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public enum DogImageResource: Sendable { 11 | case local(Image) 12 | 13 | case remote(URL) 14 | 15 | case placeholder 16 | 17 | public var remoteURL: URL? { 18 | if case let .remote(url) = self { 19 | url 20 | } else { 21 | nil 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Dogs.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 9 | 10 | 12 | 13 | 14 | 16 | 17 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /Packages/Core/Sources/DogAPI/Request/BreedListRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BreedListRequest.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024-02-12. 6 | // 7 | 8 | import Core 9 | import Foundation 10 | 11 | extension DogAPI { 12 | public enum BreedListRequest { 13 | public struct Get: DogAPIRequest { 14 | public typealias Message = BreedList 15 | 16 | public let path = "breeds/list/all" 17 | 18 | public let method = "GET" 19 | 20 | public init() { } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Packages/Core/Sources/WatchUI/Dependency/WatchContainer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WatchContainer.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024-02-13. 6 | // 7 | 8 | import Core 9 | import SwiftUI 10 | 11 | @MainActor public protocol WatchContainer: DependencyContainer { 12 | var watch: WatchDependency { get } 13 | } 14 | 15 | extension WatchContainer { 16 | public func inject(content: Content) -> some View { 17 | content 18 | .dependency(watch) 19 | } 20 | 21 | public var watch: some View { 22 | MainFlow(container: self) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Packages/Core/Sources/CommonUI/Utility/WithProperty.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WithProperty.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024/02/20. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public struct WithProperty: View { 11 | let property: Property 12 | 13 | var content: (Property) -> Content 14 | 15 | public init(_ property: Property, @ViewBuilder content: @escaping (Property) -> Content) { 16 | self.property = property 17 | self.content = content 18 | } 19 | 20 | public var body: some View { 21 | content(property) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Packages/Core/Sources/CommonUI/View/TaskFailedView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TaskFailedView.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024-02-12. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public struct TaskFailedView: View { 11 | public init() { } 12 | 13 | public var body: some View { 14 | Image(systemName: "xmark") 15 | .symbolRenderingMode(.multicolor) 16 | .imageScale(.large) 17 | .padding() 18 | } 19 | } 20 | 21 | struct TaskFailedView_Previews: PreviewProvider { 22 | static var previews: some View { 23 | TaskFailedView() 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Packages/Core/Sources/Core/Entity/ErrorMessage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ErrorMessage.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024-02-12. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct ErrorMessage: LocalizedError { 11 | public let message: String 12 | 13 | public init(message: String) { 14 | self.message = message 15 | } 16 | 17 | public var errorDescription: String? { 18 | message 19 | } 20 | } 21 | 22 | public extension Error where Self == ErrorMessage { 23 | static func message(_ message: String) -> Self { 24 | .init(message: message) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Dogs/DogsApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DogsApp.swift 3 | // Dogs 4 | // 5 | // Created by Mikhail Apurin on 2024-02-12. 6 | // 7 | 8 | import Core 9 | import LiveApp 10 | import SwiftUI 11 | 12 | @main 13 | @MainActor 14 | struct DogsApp: App { 15 | static var configuration: LiveAppContainer.Configuration { 16 | .init( 17 | apiBaseURL: .init(string: "https://dog.ceo/api")! 18 | ) 19 | } 20 | 21 | @State var container = LiveAppContainer(configuration: configuration) 22 | 23 | var body: some Scene { 24 | WindowGroup { 25 | container.app 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Packages/Core/Sources/Core/Dependency/Dependency.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Dependency.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024-02-12. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @MainActor public protocol Dependency: ViewInjectable { 11 | associatedtype State: ViewInjectable 12 | 13 | associatedtype Actions: ViewInjectable 14 | 15 | var state: State { get } 16 | 17 | var actions: Actions { get } 18 | } 19 | 20 | extension Dependency { 21 | public func inject(content: Content) -> some View { 22 | content 23 | .dependency(state) 24 | .dependency(actions) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Packages/Core/Sources/Core/Entity/DisplayableError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DisplayableError.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024/02/15. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct DisplayableError: Identifiable, LocalizedError { 11 | public let id: UUID = .init() 12 | 13 | public let underlying: (any Error)? 14 | 15 | public let message: String 16 | 17 | public init(underlying: (any Error)? = nil, message: String) { 18 | self.underlying = underlying 19 | self.message = message 20 | } 21 | 22 | public var errorDescription: String? { 23 | message 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Packages/Core/Sources/PreviewAssets/Entity/BreedList.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BreedList.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024/02/22. 6 | // 7 | 8 | import Core 9 | import Foundation 10 | 11 | extension BreedList { 12 | public static var mock: Self { 13 | #if DEBUG 14 | [ 15 | "affenpinscher": [], 16 | "african": [], 17 | "airedale": [], 18 | "akita": [], 19 | "appenzeller": [], 20 | "australian": [ 21 | "shepherd" 22 | ], 23 | ] 24 | #else 25 | [] 26 | #endif 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Packages/Core/Sources/AppUI/Dependency/Actions/AppActions+Favorites.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppActions+Favorites.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024/02/15. 6 | // 7 | 8 | import Core 9 | import Foundation 10 | 11 | extension AppActions { 12 | @MainActor public struct Favorites { 13 | public var connect: (FavoriteState, DogImageResource) -> Void = emptyAction() 14 | 15 | public var favorite: (DogImageResource) -> Void = emptyAction() 16 | 17 | public var reset: () -> Void = emptyAction() 18 | 19 | public var unfavorite: (DogImageResource) -> Void = emptyAction() 20 | 21 | nonisolated init() { } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /WatchDogs Watch App/WatchDogsApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WatchDogsApp.swift 3 | // WatchDogs Watch App 4 | // 5 | // Created by Mikhail Apurin on 2024-02-12. 6 | // 7 | 8 | import LiveWatch 9 | import SwiftUI 10 | import WatchUI 11 | 12 | @main 13 | @MainActor 14 | struct WatchDogsApp: App { 15 | static var configuration: LiveWatchContainer.Configuration { 16 | .init( 17 | apiBaseURL: .init(string: "https://dog.ceo/api")! 18 | ) 19 | } 20 | 21 | @State var container = LiveWatchContainer(configuration: configuration) 22 | 23 | var body: some Scene { 24 | WindowGroup { 25 | container.watch 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Packages/Core/Sources/CommonUI/Utility/OnFirstAppearance.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OnFirstAppearance.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024-02-12. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct OnFirstAppearance: ViewModifier { 11 | @State private var didAppear = false 12 | 13 | let action: () -> Void 14 | 15 | func body(content: Content) -> some View { 16 | content 17 | .onAppear { 18 | guard !didAppear else { return } 19 | didAppear = true 20 | action() 21 | } 22 | } 23 | } 24 | 25 | extension View { 26 | public func onFirstAppear(perform action: @escaping () -> Void) -> some View { 27 | modifier(OnFirstAppearance(action: action)) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Dogs.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "6c4eb46019eab6eb762676d8ba1c396d7e4d566e1162dfcc037a05c7fd333fd7", 3 | "pins" : [ 4 | { 5 | "identity" : "realm-core", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/realm/realm-core.git", 8 | "state" : { 9 | "revision" : "058ecce712b4be8b2a2384ed893bf83d56a49fc0", 10 | "version" : "14.10.1" 11 | } 12 | }, 13 | { 14 | "identity" : "realm-swift", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/realm/realm-swift", 17 | "state" : { 18 | "revision" : "fb903a9c901cb7863953fdcb5182b05ca2275b27", 19 | "version" : "10.52.0" 20 | } 21 | } 22 | ], 23 | "version" : 3 24 | } 25 | -------------------------------------------------------------------------------- /Packages/Core/Sources/WatchUI/Dependency/Actions/WatchActions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WatchActions.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024-02-13. 6 | // 7 | 8 | import Core 9 | import SwiftUI 10 | 11 | @MainActor public struct WatchActions: ViewInjectable { 12 | struct Key: EnvironmentKey { 13 | static let defaultValue = WatchActions() 14 | } 15 | 16 | public var dogImage = DogImage() 17 | 18 | nonisolated public init() { } 19 | 20 | public func inject(content: Content) -> some View { 21 | content 22 | .environment(\.watchActions, self) 23 | } 24 | } 25 | 26 | extension EnvironmentValues { 27 | public var watchActions: WatchActions { 28 | get { self[WatchActions.Key.self] } 29 | set { self[WatchActions.Key.self] = newValue } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Packages/Core/Sources/AppUI/Screen/Welcome/WelcomeScreen.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WelcomeScreen.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024/02/14. 6 | // 7 | 8 | import Core 9 | import SwiftUI 10 | 11 | struct WelcomeScreen: View { 12 | struct Flow { 13 | var dismiss: () -> Void = emptyAction() 14 | } 15 | 16 | let flow: Flow 17 | 18 | var body: some View { 19 | VStack(spacing: 64) { 20 | Text("Welcome") 21 | .font(.largeTitle.bold()) 22 | 23 | Text("Thank you for installing this app!") 24 | 25 | Button("Show me dogs") { 26 | flow.dismiss() 27 | } 28 | .buttonStyle(.borderedProminent) 29 | } 30 | } 31 | } 32 | 33 | #Preview { 34 | WelcomeScreen(flow: .init()) 35 | .mockContainer(.app) 36 | } 37 | -------------------------------------------------------------------------------- /Packages/Core/Sources/Core/Dependency/ViewInjectable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewInjectable.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024-02-12. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public protocol ViewInjectable { 11 | typealias Content = ViewDependencyModifier.Content 12 | 13 | associatedtype InjectedBody: View 14 | 15 | @MainActor func inject(content: Content) -> InjectedBody 16 | } 17 | 18 | public struct ViewDependencyModifier: ViewModifier { 19 | let dependency: D 20 | 21 | public func body(content: Content) -> some View { 22 | dependency.inject(content: content) 23 | } 24 | } 25 | 26 | extension View { 27 | public func dependency(_ dependency: some ViewInjectable) -> some View { 28 | modifier(ViewDependencyModifier(dependency: dependency)) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Packages/Core/Sources/AppUI/Dependency/AppContainer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppContainer.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024-02-12. 6 | // 7 | 8 | import Core 9 | import SwiftUI 10 | 11 | @MainActor public protocol AppContainer: DependencyContainer { 12 | var app: AppDependency { get } 13 | 14 | associatedtype BreedListScreenFactory: AppUI.BreedListScreenFactory 15 | func makeBreedListScreenFactory() -> BreedListScreenFactory 16 | 17 | associatedtype FavoritesScreenFactory: AppUI.FavoritesScreenFactory 18 | func makeFavoritesScreenFactory() -> FavoritesScreenFactory 19 | } 20 | 21 | extension AppContainer { 22 | public func inject(content: Content) -> some View { 23 | content 24 | .dependency(app) 25 | } 26 | 27 | public var app: some View { 28 | MainFlow(container: self) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Packages/Core/Sources/AppUI/Flow/RandomImageFlow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RandomImageFlow.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024-02-12. 6 | // 7 | 8 | import Core 9 | import SwiftUI 10 | 11 | struct RandomImageFlow: View { 12 | let container: Container 13 | 14 | var body: some View { 15 | Content(flow: self) 16 | .dependency(container) 17 | } 18 | } 19 | 20 | extension RandomImageFlow { 21 | private struct Content: View { 22 | let flow: RandomImageFlow 23 | 24 | var body: some View { 25 | NavigationStack { 26 | DogImageScreen(image: .random) 27 | .navigationTitle("Random Dog") 28 | } 29 | } 30 | } 31 | } 32 | 33 | #Preview { 34 | WithMockContainer(.app) { container in 35 | RandomImageFlow(container: container) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Packages/Core/Sources/WatchUI/Flow/MainFlow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainFlow.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024-02-14. 6 | // 7 | 8 | import Core 9 | import PreviewAssets 10 | import SwiftUI 11 | 12 | struct MainFlow: View { 13 | let container: Container 14 | 15 | var body: some View { 16 | _Content(flow: self) 17 | .dependency(container) 18 | } 19 | } 20 | 21 | private struct _Content: View { 22 | let flow: MainFlow 23 | 24 | public var body: some View { 25 | DogImageScreen(image: .random) 26 | } 27 | } 28 | 29 | #Preview { 30 | WithMockContainer(.watch( 31 | configuration: .init( 32 | defaultImage: .local(PreviewAsset.Image.kurosuke01) 33 | ) 34 | )) { container in 35 | MainFlow(container: container) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Packages/Core/Sources/AppUI/Dependency/Actions/AppActions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppActions.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024-02-12. 6 | // 7 | 8 | import Core 9 | import SwiftUI 10 | 11 | @MainActor public struct AppActions: ViewInjectable { 12 | struct Key: EnvironmentKey { 13 | static let defaultValue = AppActions() 14 | } 15 | 16 | public var breedList = BreedList() 17 | 18 | public var dogImage = DogImage() 19 | 20 | public var favorites = Favorites() 21 | 22 | nonisolated public init() { } 23 | 24 | public func inject(content: Content) -> some View { 25 | content 26 | .environment(\.appActions, self) 27 | } 28 | } 29 | 30 | extension EnvironmentValues { 31 | public var appActions: AppActions { 32 | get { self[AppActions.Key.self] } 33 | set { self[AppActions.Key.self] = newValue } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Packages/Core/Sources/AppUI/Flow/FavoritesFlow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FavoritesFlow.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024/02/14. 6 | // 7 | 8 | import Core 9 | import SwiftUI 10 | 11 | struct FavoritesFlow: View { 12 | let container: Container 13 | 14 | var body: some View { 15 | Content(flow: self) 16 | .dependency(container) 17 | } 18 | } 19 | 20 | extension FavoritesFlow { 21 | private struct Content: View { 22 | let flow: FavoritesFlow 23 | 24 | var body: some View { 25 | NavigationStack { 26 | FavoritesScreen(factory: flow.container.makeFavoritesScreenFactory()) 27 | .navigationTitle("Favorites") 28 | } 29 | } 30 | } 31 | } 32 | 33 | #Preview { 34 | WithMockContainer(.app) { container in 35 | FavoritesFlow(container: container) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Packages/Core/Sources/AppUI/Screen/Favorites/FavoritesEmptyView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FavoritesEmptyView.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024/02/15. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public struct FavoritesEmptyView: View { 11 | public init() { } 12 | 13 | public var body: some View { 14 | if #available(iOS 17.0, *) { 15 | ContentUnavailableView("No Favorites", systemImage: "star") 16 | .symbolVariant(.slash.fill) 17 | } else { 18 | VStack(spacing: 16) { 19 | Image(systemName: "star") 20 | .imageScale(.large) 21 | .foregroundStyle(.secondary) 22 | .symbolVariant(.slash.fill) 23 | .font(.largeTitle) 24 | 25 | Text("No Favorites") 26 | .font(.title2.bold()) 27 | } 28 | } 29 | } 30 | } 31 | 32 | #Preview { 33 | FavoritesEmptyView() 34 | } 35 | -------------------------------------------------------------------------------- /Packages/Core/Sources/DogAPI/DogAPIClient.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DogAPIClient.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024-02-12. 6 | // 7 | 8 | import Core 9 | import Foundation 10 | 11 | public final class DogAPIClient: Sendable { 12 | public struct Configuration: Sendable { 13 | let baseURL: URL 14 | 15 | public init(baseURL: URL) { 16 | self.baseURL = baseURL 17 | } 18 | } 19 | 20 | let session: URLSession 21 | 22 | let configuration: Configuration 23 | 24 | public init(session: URLSession, configuration: Configuration) { 25 | self.session = session 26 | self.configuration = configuration 27 | } 28 | 29 | public func execute(_ request: Request) async throws -> Request.Response { 30 | let (data, _) = try await session.data(for: request.makeURLRequest(configuration: configuration)) 31 | return try request.decode(data: data) 32 | } 33 | } 34 | 35 | -------------------------------------------------------------------------------- /Packages/Core/Sources/RealmStorage/Object/FavoritesItemObject.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FavoritesItemObject.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024/02/15. 6 | // 7 | 8 | import Core 9 | import Foundation 10 | import RealmSwift 11 | 12 | public final class FavoritesItemObject: Object, Identifiable { 13 | @Persisted(primaryKey: true) public var id: UUID 14 | 15 | @Persisted public var urlString: String 16 | } 17 | 18 | extension FavoritesItem { 19 | public init(object: FavoritesItemObject) { 20 | self.init( 21 | id: object.id, 22 | resource: URL(string: object.urlString).map { .remote($0) } ?? .placeholder 23 | ) 24 | } 25 | } 26 | 27 | extension FavoritesItemObject { 28 | public convenience init?(entity: FavoritesItem) { 29 | guard case let .remote(url) = entity.resource else { return nil } 30 | self.init() 31 | id = entity.id 32 | urlString = url.absoluteString 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Packages/Core/Sources/Core/Entity/LoadingState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoadingOperation.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024-02-12. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct LoadingState: Identifiable { 11 | public enum State { 12 | case inProgress 13 | 14 | case completed(Value) 15 | 16 | case error(any Error) 17 | } 18 | 19 | public let id = UUID() 20 | 21 | public let input: Input 22 | 23 | public var state = State.inProgress 24 | 25 | public init(input: Input) { 26 | self.input = input 27 | } 28 | 29 | public var didFinish: Bool { 30 | switch state { 31 | case .inProgress: false 32 | case .completed, .error: true 33 | } 34 | } 35 | 36 | public var value: Value? { 37 | if case let .completed(value) = state { 38 | value 39 | } else { 40 | nil 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Packages/Core/Sources/AppUI/ServiceView/ErrorAlertServiceView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ErrorAlertServiceView.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024/02/15. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ErrorAlertServiceView: ViewModifier { 11 | @EnvironmentObject private var errorAlertState: AppState.ErrorAlert 12 | 13 | func body(content: Content) -> some View { 14 | content 15 | .alert("Error", isPresented: isPresented, presenting: errorAlertState.alert) { error in 16 | // Only the OK button 17 | } message: { error in 18 | Text(error.message) 19 | } 20 | } 21 | 22 | var isPresented: Binding { 23 | .init( 24 | get: { errorAlertState.alert != nil }, 25 | set: { _ in errorAlertState.alert = nil } 26 | ) 27 | } 28 | } 29 | 30 | extension View { 31 | func withErrorAlertService() -> some View { 32 | modifier(ErrorAlertServiceView()) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Packages/Core/Sources/DogAPI/DogAPIRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DogAPIRequest.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024-02-12. 6 | // 7 | 8 | import Foundation 9 | 10 | public protocol DogAPIRequest: Sendable { 11 | associatedtype Message: Decodable & Sendable 12 | 13 | typealias Response = DogAPIResponse 14 | 15 | var path: String { get } 16 | 17 | var method: String { get } 18 | 19 | func makeURLRequest(configuration: DogAPIClient.Configuration) -> URLRequest 20 | 21 | func decode(data: Data) throws -> Response 22 | } 23 | 24 | extension DogAPIRequest { 25 | public func makeURLRequest(configuration: DogAPIClient.Configuration) -> URLRequest { 26 | let url = configuration.baseURL.appending(path: path) 27 | var request = URLRequest(url: url) 28 | request.httpMethod = method 29 | return request 30 | } 31 | 32 | public func decode(data: Data) throws -> Response { 33 | try JSONDecoder().decode(Response.self, from: data) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Packages/Core/Sources/AppServices/Factory/RealmFavoritesScreenFactory.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RealmFavoritesScreenFactory.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024/02/15. 6 | // 7 | 8 | import AppUI 9 | import Core 10 | import RealmStorage 11 | import RealmSwift 12 | import SwiftUI 13 | 14 | public struct RealmFavoritesScreenFactory: FavoritesScreenFactory { 15 | public let realmConfiguration: Realm.Configuration 16 | 17 | public init(realmConfiguration: Realm.Configuration) { 18 | self.realmConfiguration = realmConfiguration 19 | } 20 | 21 | public func makeScreenData() -> some FavoritesScreenData { 22 | RealmFavoritesScreenData() 23 | } 24 | 25 | public func inject(content: Content) -> some View { 26 | content 27 | .environment(\.realmConfiguration, realmConfiguration) 28 | } 29 | } 30 | 31 | public struct RealmFavoritesScreenData: FavoritesScreenData { 32 | @ObservedResults(FavoritesItemObject.self) public var items 33 | 34 | public func item(_ item: Item) -> FavoritesItem { 35 | .init(object: item) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Mike Apurin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Packages/Core/Sources/DogAPI/Request/RandomImageRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RandomImageRequest.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024-02-12. 6 | // 7 | 8 | import Core 9 | import Foundation 10 | 11 | extension DogAPI { 12 | public enum RandomImageRequest { 13 | public enum Single { 14 | public struct Get: DogAPIRequest { 15 | public typealias Message = URL 16 | 17 | public let path = "breeds/image/random" 18 | 19 | public let method = "GET" 20 | 21 | public init() { } 22 | } 23 | } 24 | 25 | public enum Multiple { 26 | public struct Get: DogAPIRequest { 27 | public typealias Message = [URL] 28 | 29 | public var path: String { "breeds/image/random/\(count)" } 30 | 31 | public let method = "GET" 32 | 33 | public let count: Int 34 | 35 | public init(count: Int) { 36 | self.count = count 37 | } 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Packages/Core/Sources/AppUI/Flow/SettingsFlow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsFlow.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024/02/14. 6 | // 7 | 8 | import Core 9 | import SwiftUI 10 | 11 | struct SettingsFlow: View { 12 | let container: Container 13 | 14 | var body: some View { 15 | Content(flow: self) 16 | .dependency(container) 17 | } 18 | } 19 | 20 | extension SettingsFlow { 21 | private struct Content: View { 22 | let flow: SettingsFlow 23 | 24 | @State private var isShowingWelcome = false 25 | 26 | var body: some View { 27 | NavigationStack { 28 | SettingsScreen(flow: .init( 29 | showWelcome: { isShowingWelcome = true } 30 | )) 31 | .navigationTitle("Settings") 32 | } 33 | .sheet(isPresented: $isShowingWelcome) { 34 | WelcomeScreen(flow: .init( 35 | dismiss: { isShowingWelcome = false } 36 | )) 37 | } 38 | } 39 | } 40 | } 41 | 42 | #Preview { 43 | WithMockContainer(.app) { container in 44 | SettingsFlow(container: container) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Packages/Core/Sources/AppServices/Factory/RealmBreedListScreenFactory.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RealmBreedListScreenFactory.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024/02/14. 6 | // 7 | 8 | import AppUI 9 | import Core 10 | import RealmStorage 11 | import RealmSwift 12 | import SwiftUI 13 | 14 | public struct RealmBreedListScreenFactory: BreedListScreenFactory { 15 | public let realmConfiguration: Realm.Configuration 16 | 17 | public init(realmConfiguration: Realm.Configuration) { 18 | self.realmConfiguration = realmConfiguration 19 | } 20 | 21 | public func makeScreenData() -> some BreedListScreenData { 22 | RealmBreedListScreenData() 23 | } 24 | 25 | public func inject(content: Content) -> some View { 26 | content 27 | .environment(\.realmConfiguration, realmConfiguration) 28 | } 29 | } 30 | 31 | public struct RealmBreedListScreenData: BreedListScreenData { 32 | @ObservedResults(BreedListObject.self) public var objects 33 | 34 | public var breeds: RealmSwift.List? { 35 | objects.first?.breeds 36 | } 37 | 38 | public func breed(_ breed: BreedListItemObject) -> BreedListItem { 39 | .init(object: breed) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Packages/Core/Sources/CommonUI/View/DogImageView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DogImageView.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024-02-14. 6 | // 7 | 8 | import Core 9 | import SwiftUI 10 | 11 | public struct DogImageView: View { 12 | let resource: DogImageResource 13 | 14 | public init(resource: DogImageResource) { 15 | self.resource = resource 16 | } 17 | 18 | public var body: some View { 19 | switch resource { 20 | case let .local(image): 21 | image 22 | .resizable() 23 | .scaledToFit() 24 | 25 | case let .remote(url): 26 | AsyncImage(url: url) { phase in 27 | switch phase { 28 | case .empty: 29 | ProgressView() 30 | 31 | case let .success(image): 32 | image 33 | .resizable() 34 | .scaledToFit() 35 | 36 | case .failure: 37 | TaskFailedView() 38 | 39 | @unknown default: 40 | ProgressView() 41 | } 42 | } 43 | 44 | case .placeholder: 45 | Color.clear 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Packages/Core/Sources/LiveWatch/LiveWatchContainer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LiveWatchContainer.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024-02-12. 6 | // 7 | 8 | import DogAPI 9 | import Core 10 | import CommonServices 11 | import SwiftUI 12 | import WatchServices 13 | import WatchUI 14 | 15 | public final class LiveWatchContainer: WatchContainer { 16 | public struct Configuration { 17 | let apiBaseURL: URL 18 | 19 | public init( 20 | apiBaseURL: URL 21 | ) { 22 | self.apiBaseURL = apiBaseURL 23 | } 24 | } 25 | 26 | public let configuration: Configuration 27 | 28 | public var watch: WatchDependency 29 | 30 | let api: DogAPIClient 31 | 32 | let dogImageService: DogImageService 33 | 34 | public init(configuration: Configuration) { 35 | self.configuration = configuration 36 | self.watch = .init( 37 | state: .init(), 38 | actions: .init() 39 | ) 40 | self.api = .init( 41 | session: .shared, 42 | configuration: .init(baseURL: configuration.apiBaseURL) 43 | ) 44 | self.dogImageService = .init(api: api) 45 | 46 | watch.actions.dogImage.getImage = dogImageService.getImage(_:) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Packages/Core/Sources/AppUI/Flow/BreedListFlow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BreedListFlow.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024-02-12. 6 | // 7 | 8 | import Core 9 | import SwiftUI 10 | 11 | struct BreedListFlow: View { 12 | let container: Container 13 | 14 | var body: some View { 15 | Content(flow: self) 16 | .dependency(container) 17 | } 18 | } 19 | 20 | extension BreedListFlow { 21 | private struct Content: View { 22 | let flow: BreedListFlow 23 | 24 | var body: some View { 25 | NavigationStack { 26 | BreedListScreen(factory: flow.container.makeBreedListScreenFactory()) 27 | .navigationTitle("Breeds") 28 | .navigationDestination(for: BreedListScreenDestination.self) { destination in 29 | switch destination { 30 | case let .breedImage(breed): 31 | DogImageScreen(image: .breed(breed)) 32 | .navigationTitle(breed.formatted(.breedName)) 33 | } 34 | } 35 | } 36 | } 37 | } 38 | } 39 | 40 | #Preview { 41 | WithMockContainer(.app) { container in 42 | BreedListFlow(container: container) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Packages/Core/Sources/CommonServices/DogImageService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DogImageService.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024-02-12. 6 | // 7 | 8 | import Core 9 | import DogAPI 10 | import Foundation 11 | 12 | @MainActor public final class DogImageService { 13 | private let api: DogAPIClient 14 | 15 | public init(api: DogAPIClient) { 16 | self.api = api 17 | } 18 | 19 | public func getImage(_ image: DogImage) async throws -> DogImageResource { 20 | switch image { 21 | case .random: 22 | try await .remote( 23 | api.execute(DogAPI.RandomImageRequest.Single.Get()).message 24 | ) 25 | 26 | case let .breed(breed): 27 | try await getDogBreedImage(breed: breed) 28 | } 29 | } 30 | 31 | public func getDogBreedImage(breed: ConcreteBreed) async throws -> DogImageResource { 32 | if let subBreed = breed.subBreed { 33 | try await .remote( 34 | api.execute(DogAPI.SubBreedImageRequest.Random.Single.Get(breed: breed.breed, subBreed: subBreed)).message 35 | ) 36 | } else { 37 | try await .remote( 38 | api.execute(DogAPI.BreedImageRequest.Random.Single.Get(breed: breed.breed)).message 39 | ) 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Packages/Core/Sources/Core/Entity/BreedListItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BreedListItem.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024-02-12. 6 | // 7 | 8 | import Foundation 9 | 10 | public enum BreedListItem: Hashable { 11 | case group(breed: ConcreteBreed, subBreeds: [ConcreteBreed]) 12 | 13 | case concrete(ConcreteBreed) 14 | 15 | public var concreteBreed: ConcreteBreed { 16 | switch self { 17 | case let .group(breed, _), let .concrete(breed): 18 | return breed 19 | } 20 | } 21 | 22 | public var breed: Breed { 23 | concreteBreed.breed 24 | } 25 | } 26 | 27 | extension BreedList { 28 | public func map() -> [BreedListItem] { 29 | reduce(into: []) { partialResult, item in 30 | let breed = ConcreteBreed(breed: item.key, subBreed: nil) 31 | if item.value.isEmpty { 32 | partialResult.append(.concrete(breed)) 33 | } else { 34 | let subBreeds = item.value 35 | .sorted { $0.localizedStandardCompare($1) == .orderedAscending } 36 | .map { ConcreteBreed(breed: breed.breed, subBreed: $0) } 37 | partialResult.append(.group(breed: breed, subBreeds: subBreeds)) 38 | } 39 | } 40 | .sorted { $0.breed.localizedStandardCompare($1.breed) == .orderedAscending } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SwiftUI-First Architecture Demo 2 | 3 | This repository showcases the architecture I use to structure my apps. Check out the article for more detail: 4 | 5 | - [SwiftUI-First Architecture](https://apurin.me/articles/swiftui-first/) 6 | 7 |

8 | 9 | The demo project uses [Dog API](https://dog.ceo/dog-api/) to fetch images of dogs. 10 | 11 | ## Preview assets 12 | 13 | The `PreviewAssets` target is intended to provide shared resources that are available for debug builds and previews but excluded in release builds. Ideally, this should be a proper feature of SwiftPM, but in its absence, this is achieved by manually copying folders. 14 | 15 | As a safe default, resources in `_PreviewAssets.xcassets` (with an underscore) are excluded from builds. To include them, duplicate the whole folder to `PreviewAssets.xcassets` (without an underscore). 16 | ```sh 17 | rm -rf Packages/Core/Sources/PreviewAssets/PreviewAssets.xcassets && cp -R Packages/Core/Sources/PreviewAssets/_PreviewAssets.xcassets Packages/Core/Sources/PreviewAssets/PreviewAssets.xcassets 18 | ``` 19 | 20 | `PreviewAssets.xcassets` (without an underscore) is not tracked by git, so any changes to it are local-only and temporary. To once again exclude development resources, delete this folder. 21 | ```sh 22 | rm -rf Packages/Core/Sources/PreviewAssets/PreviewAssets.xcassets 23 | ``` 24 | -------------------------------------------------------------------------------- /Packages/Core/Sources/Core/Dependency/MockDependencyContainer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockContainer.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024-02-12. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @MainActor public protocol MockDependencyContainer: DependencyContainer { } 11 | 12 | public struct WithMockContainer: View { 13 | private class Storage: ObservableObject { 14 | let container: Container 15 | 16 | init(container: Container) { 17 | self.container = container 18 | } 19 | } 20 | 21 | @StateObject private var storage: Storage 22 | 23 | private var content: (Container) -> Content 24 | 25 | public init( 26 | _ container: @autoclosure @escaping () -> Container, 27 | @ViewBuilder content: @escaping (_ container: Container) -> Content 28 | ) { 29 | self._storage = .init(wrappedValue: .init(container: container())) 30 | self.content = content 31 | } 32 | 33 | public var body: some View { 34 | content(storage.container) 35 | .dependency(storage.container) 36 | } 37 | } 38 | 39 | extension View { 40 | @MainActor public func mockContainer( 41 | _ container: @autoclosure @escaping () -> Container 42 | ) -> some View { 43 | WithMockContainer(container()) { _ in 44 | self 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Packages/Core/Sources/AppUI/Screen/BreedList/BreedListRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BreedListRow.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024-02-12. 6 | // 7 | 8 | import Core 9 | import SwiftUI 10 | 11 | struct BreedListRow: View { 12 | let item: BreedListItem 13 | 14 | @State private var isExpanded = true 15 | 16 | var body: some View { 17 | switch item { 18 | case let .group(breed, subBreeds): 19 | DisclosureGroup(isExpanded: $isExpanded) { 20 | ForEach(subBreeds, id: \.self) { subBreed in 21 | NavigationLink(value: BreedListScreenDestination.breedImage(breed: subBreed)) { 22 | Text(subBreed, format: .breedName) 23 | } 24 | } 25 | } label: { 26 | NavigationLink(value: BreedListScreenDestination.breedImage(breed: breed)) { 27 | Text(breed, format: .breedName) 28 | } 29 | } 30 | 31 | case let .concrete(breed): 32 | NavigationLink(value: BreedListScreenDestination.breedImage(breed: breed)) { 33 | Text(breed, format: .breedName) 34 | } 35 | } 36 | } 37 | } 38 | 39 | #Preview { 40 | List { 41 | BreedListRow(item: .concrete(.init(breed: "Shiba", subBreed: nil))) 42 | 43 | BreedListRow(item: .group(breed: .init(breed: "Poodle", subBreed: nil), subBreeds: [ 44 | .init(breed: "Poodle", subBreed: "Toy") 45 | ])) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Packages/Core/Sources/Core/Entity/ConcreteBreed.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ConcreteBreed.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024-02-12. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct ConcreteBreed: Sendable, Hashable { 11 | public let breed: Breed 12 | 13 | public let subBreed: SubBreed? 14 | 15 | public init(breed: Breed, subBreed: SubBreed?) { 16 | self.breed = breed 17 | self.subBreed = subBreed 18 | } 19 | } 20 | 21 | extension ConcreteBreed { 22 | public struct BreedNameFormatStyle: FormatStyle { 23 | let locale: Locale? 24 | 25 | public init(locale: Locale? = nil) { 26 | self.locale = locale 27 | } 28 | 29 | public func locale(_ locale: Locale) -> ConcreteBreed.BreedNameFormatStyle { 30 | .init(locale: locale) 31 | } 32 | 33 | public func format(_ value: ConcreteBreed) -> String { 34 | if let subBreed = value.subBreed { 35 | return "\(subBreed) \(value.breed)".capitalized(with: locale) 36 | } else { 37 | return value.breed.capitalized(with: locale) 38 | } 39 | } 40 | } 41 | } 42 | 43 | extension ConcreteBreed { 44 | public func formatted(_ format: F) -> F.FormatOutput where F.FormatInput == Self { 45 | format.format(self) 46 | } 47 | } 48 | 49 | extension FormatStyle where Self == ConcreteBreed.BreedNameFormatStyle { 50 | public static var breedName: Self { 51 | .init(locale: nil) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Packages/Core/Sources/WatchUI/Dependency/MockWatchContainer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockWatchContainer.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024-02-13. 6 | // 7 | 8 | import Core 9 | import SwiftUI 10 | 11 | public final class MockWatchContainer: MockDependencyContainer, WatchContainer { 12 | public struct Configuration { 13 | var defaultImage: DogImageResource? 14 | 15 | public init( 16 | defaultImage: DogImageResource? = nil 17 | ) { 18 | self.defaultImage = defaultImage 19 | } 20 | } 21 | 22 | public let configuration: Configuration 23 | 24 | public var watch: WatchDependency 25 | 26 | public init(configuration: Configuration) { 27 | self.configuration = configuration 28 | self.watch = .init( 29 | state: .init(), 30 | actions: .init() 31 | ) 32 | if let resource = configuration.defaultImage { 33 | watch.actions.dogImage.getImage = { _ in 34 | try? await Task.sleep(for: .seconds(1)) 35 | return resource 36 | } 37 | } 38 | } 39 | } 40 | 41 | extension MockDependencyContainer where Self == MockWatchContainer { 42 | public static func watch( 43 | configuration: MockWatchContainer.Configuration = .init(), 44 | configure: (Self) -> Void = { _ in } 45 | ) -> Self { 46 | let container = Self(configuration: configuration) 47 | configure(container) 48 | return container 49 | } 50 | 51 | public static var watch: Self { 52 | .watch(configuration: .init()) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Packages/Core/Sources/AppUI/Screen/Settings/SettingsScreen.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsScreen.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024/02/14. 6 | // 7 | 8 | import Core 9 | import SwiftUI 10 | 11 | struct SettingsScreen: View { 12 | struct Flow { 13 | var showWelcome: () -> Void = emptyAction() 14 | } 15 | 16 | let flow: Flow 17 | 18 | @State private var isResetFavoritesDialogPresented = false 19 | 20 | @Environment(\.appActions) private var actions 21 | 22 | var body: some View { 23 | List { 24 | Section("Favorites") { 25 | Button("Reset favorites", role: .destructive) { 26 | isResetFavoritesDialogPresented = true 27 | } 28 | .confirmationDialog("Reset favorites?", isPresented: $isResetFavoritesDialogPresented) { 29 | Button("Reset favorites", role: .destructive) { 30 | actions.favorites.reset() 31 | } 32 | } 33 | } 34 | 35 | Section("SwiftUI MV Sample (Dogs)") { 36 | LabeledContent("Version", value: version) 37 | 38 | Button("Welcome screen") { 39 | flow.showWelcome() 40 | } 41 | } 42 | } 43 | .listStyle(.insetGrouped) 44 | } 45 | 46 | var version: String { 47 | let bundle = Bundle.main 48 | let version = bundle.infoDictionary?["CFBundleVersion"] ?? "0" 49 | let shortVersion = bundle.infoDictionary?["CFBundleShortVersionString"] ?? "0" 50 | return "\(shortVersion) (\(version))" 51 | } 52 | } 53 | 54 | #Preview { 55 | SettingsScreen(flow: .init()) 56 | .mockContainer(.app) 57 | } 58 | -------------------------------------------------------------------------------- /Packages/Core/Sources/AppServices/Service/BreedsListService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BreedsListService.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024-02-12. 6 | // 7 | 8 | import AppUI 9 | import Core 10 | import DogAPI 11 | import RealmStorage 12 | import RealmSwift 13 | import Foundation 14 | 15 | @MainActor public final class BreedsListService: Sendable { 16 | private let api: DogAPIClient 17 | 18 | private var lastRefresh = Date.distantPast 19 | 20 | private let realmConfiguration: Realm.Configuration 21 | 22 | private let errorAlert: AppState.ErrorAlert 23 | 24 | public init(api: DogAPIClient, realmConfiguration: Realm.Configuration, errorAlert: AppState.ErrorAlert) { 25 | self.api = api 26 | self.realmConfiguration = realmConfiguration 27 | self.errorAlert = errorAlert 28 | } 29 | 30 | public func refresh() async { 31 | guard Date.now.timeIntervalSince(lastRefresh) > 60 else { return } // Throttle reloads for 60s 32 | do { 33 | let breeds = try await api.execute(DogAPI.BreedListRequest.Get()).message 34 | try save(breeds: breeds.map()) 35 | lastRefresh = .now 36 | } catch is CancellationError { 37 | // nothing 38 | } catch { 39 | errorAlert.alert = .init(underlying: error, message: "Could not refresh breeds list") 40 | } 41 | } 42 | 43 | func save(breeds: [BreedListItem]) throws { 44 | let realm = try Realm(configuration: realmConfiguration) 45 | try realm.write { 46 | let list = realm.objects(BreedListObject.self).first ?? realm.create(BreedListObject.self) 47 | realm.delete(realm.objects(BreedListItemObject.self)) 48 | list.breeds.append( 49 | objectsIn: breeds.map { BreedListItemObject(entity: $0) } 50 | ) 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Packages/Core/Sources/RealmStorage/Object/BreedListItemObject.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BreedListItemObject.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024-02-14. 6 | // 7 | 8 | import Core 9 | import Foundation 10 | import RealmSwift 11 | 12 | public final class BreedListItemObject: Object, ObjectKeyIdentifiable { 13 | @Persisted(primaryKey: true) public var id: String 14 | 15 | @Persisted public var breed: String 16 | 17 | @Persisted public var subBreed: String? 18 | 19 | @Persisted public var subBreeds: List 20 | } 21 | 22 | extension BreedListItemObject { 23 | static func primaryKey(entity: ConcreteBreed) -> String { 24 | "\(entity.breed);\(entity.subBreed ?? "")" 25 | } 26 | } 27 | 28 | extension BreedListItem { 29 | public init(object: BreedListItemObject) { 30 | if object.subBreeds.isEmpty { 31 | self = .concrete(object.concreteBreed) 32 | } else { 33 | self = .group(breed: object.concreteBreed, subBreeds: object.subBreeds.map(\.concreteBreed)) 34 | } 35 | } 36 | } 37 | 38 | extension BreedListItemObject { 39 | public convenience init(entity: BreedListItem) { 40 | self.init(entity: entity.concreteBreed) 41 | 42 | switch entity { 43 | case let .group(_, subBreeds): 44 | self.subBreeds = .init() 45 | self.subBreeds.append(objectsIn: subBreeds.map { .init(entity: $0) }) 46 | 47 | case .concrete: 48 | self.subBreeds = .init() 49 | } 50 | } 51 | 52 | public convenience init(entity: ConcreteBreed) { 53 | self.init() 54 | self.id = BreedListItemObject.primaryKey(entity: entity) 55 | self.breed = entity.breed 56 | self.subBreed = entity.subBreed 57 | } 58 | 59 | public var concreteBreed: ConcreteBreed { 60 | .init(breed: breed, subBreed: subBreed) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Packages/Core/Sources/DogAPI/Request/BreedImageRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BreedImageRequest.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024-02-12. 6 | // 7 | 8 | import Core 9 | import Foundation 10 | 11 | extension DogAPI { 12 | public enum BreedImageRequest { 13 | public enum All { 14 | public struct Get: DogAPIRequest { 15 | public typealias Message = [URL] 16 | 17 | public var path: String { "breed/\(breed)/images" } 18 | 19 | public let method = "GET" 20 | 21 | public let breed: Breed 22 | 23 | public init(breed: Breed) { 24 | self.breed = breed 25 | } 26 | } 27 | } 28 | 29 | public enum Random { 30 | public enum Single { 31 | public struct Get: DogAPIRequest { 32 | public typealias Message = URL 33 | 34 | public var path: String { "breed/\(breed)/images/random" } 35 | 36 | public let method = "GET" 37 | 38 | public let breed: Breed 39 | 40 | public init(breed: Breed) { 41 | self.breed = breed 42 | } 43 | } 44 | } 45 | 46 | public enum Multiple { 47 | public struct Get: DogAPIRequest { 48 | public typealias Message = [URL] 49 | 50 | public var path: String { "breed/\(breed)/images/random/\(count)" } 51 | 52 | public let method = "GET" 53 | 54 | public let breed: Breed 55 | 56 | public let count: Int 57 | 58 | public init(breed: Breed, count: Int) { 59 | self.breed = breed 60 | self.count = count 61 | } 62 | } 63 | } 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Packages/Core/Sources/Core/Utility/EmptyAction.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EmptyAction.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024-02-12. 6 | // 7 | 8 | import OSLog 9 | import Foundation 10 | 11 | private let log = Logger(subsystem: "me.apurin.actions", category: "Empty Action") 12 | 13 | /// Empty action to be used as a default when initializing closures. When called outputs a reminder to set a proper closure. 14 | public func emptyAction(file: StaticString = #fileID, line: UInt = #line) -> (repeat each Input) -> Void { 15 | { (_: repeat each Input) -> Void in 16 | log.info("Executing empty action (defined in \(file):\(line))") 17 | } 18 | } 19 | 20 | /// Empty action to be used as a default when initializing closures. When called outputs a reminder to set a proper closure and returns `nil`. 21 | public func emptyAction(file: StaticString = #fileID, line: UInt = #line) -> (repeat each Input) -> Output? { 22 | { (_: repeat each Input) -> Output? in 23 | log.info("Executing empty action (defined in \(file):\(line))") 24 | return nil 25 | } 26 | } 27 | 28 | /// Empty action to be used as a default when initializing closures. When called outputs a reminder to set a proper value and returns the provided value. 29 | public func emptyAction(returning defaultValue: @autoclosure @escaping () -> Output, file: StaticString = #fileID, line: UInt = #line) -> (repeat each Input) -> Output { 30 | { (_: repeat each Input) -> Output in 31 | log.info("Executing empty action (defined in \(file):\(line))") 32 | return defaultValue() 33 | } 34 | } 35 | 36 | /// Empty action to be used as a default when initializing closures. When called outputs a reminder to set a proper closure and throws the provided error. 37 | public func emptyAction(throwing error: @autoclosure @escaping () -> any Error, file: StaticString = #fileID, line: UInt = #line) -> (repeat each Input) throws -> Output { 38 | { (_: repeat each Input) throws -> Output in 39 | log.info("Executing empty action (defined in \(file):\(line))") 40 | throw error() 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Packages/Core/Sources/WatchUI/Screen/DogImageScreen.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DogImageScreen.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024-02-14. 6 | // 7 | 8 | import Core 9 | import CommonUI 10 | import PreviewAssets 11 | import SwiftUI 12 | 13 | struct DogImageScreen: View { 14 | let image: DogImage 15 | 16 | @State private var state: LoadingState? 17 | 18 | @Environment(\.watchActions) private var actions 19 | 20 | var body: some View { 21 | Group { 22 | switch state?.state { 23 | case .none, .inProgress: 24 | ProgressView() 25 | 26 | case let .completed(url): 27 | DogImageView(resource: url) 28 | .scaledToFill() 29 | .ignoresSafeArea() 30 | .frame(maxWidth: .infinity, maxHeight: .infinity) 31 | 32 | case let .error(error): 33 | Text(error.localizedDescription) 34 | } 35 | } 36 | .onTapGesture { state = .init(input: image) } 37 | .accessibilityAction(named: "Reload") { state = .init(input: image) } 38 | .task(id: state?.id) { 39 | if let state { 40 | guard !state.didFinish else { return } // Don't reload on each appearance 41 | do { 42 | let url = try await actions.dogImage.getImage(state.input) 43 | self.state?.state = .completed(url) 44 | } catch is CancellationError { 45 | 46 | } catch { 47 | self.state?.state = .error(error) 48 | } 49 | } else { 50 | state = .init(input: image) 51 | } 52 | } 53 | } 54 | } 55 | 56 | #Preview { 57 | DogImageScreen(image: .random) 58 | .mockContainer(.watch( 59 | configuration: .init( 60 | defaultImage: .local(PreviewAsset.Image.kurosuke01) 61 | // defaultImage: .remote(URL(string: "https://images.dog.ceo/breeds/shiba/shiba-3i.jpg")!) 62 | ) 63 | )) 64 | } 65 | 66 | #Preview("Error") { 67 | DogImageScreen(image: .random) 68 | .mockContainer(.watch { 69 | $0.watch.actions.dogImage.getImage = { _ in throw .message("This is an error") } 70 | }) 71 | } 72 | -------------------------------------------------------------------------------- /Packages/Core/Sources/LiveApp/LiveAppContainer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LiveAppContainer.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024-02-12. 6 | // 7 | 8 | import AppServices 9 | import AppUI 10 | import CommonServices 11 | import DogAPI 12 | import RealmSwift 13 | import SwiftUI 14 | 15 | public final class LiveAppContainer: AppContainer { 16 | public struct Configuration { 17 | let apiBaseURL: URL 18 | 19 | let realm = Realm.Configuration.defaultConfiguration 20 | 21 | public init( 22 | apiBaseURL: URL 23 | ) { 24 | self.apiBaseURL = apiBaseURL 25 | } 26 | } 27 | 28 | public let configuration: Configuration 29 | 30 | public var app: AppDependency 31 | 32 | let api: DogAPIClient 33 | 34 | let breedListService: BreedsListService 35 | 36 | let dogImageService: DogImageService 37 | 38 | let favoritesService: FavoritesService 39 | 40 | public init(configuration: Configuration) { 41 | self.configuration = configuration 42 | self.app = .init( 43 | state: .init(), 44 | actions: .init() 45 | ) 46 | self.api = .init( 47 | session: .shared, 48 | configuration: .init(baseURL: configuration.apiBaseURL) 49 | ) 50 | self.breedListService = .init(api: api, realmConfiguration: configuration.realm, errorAlert: app.state.errorAlert) 51 | self.dogImageService = .init(api: api) 52 | self.favoritesService = .init(realmConfiguration: configuration.realm, errorState: app.state.errorAlert) 53 | 54 | app.actions.breedList.refresh = breedListService.refresh 55 | app.actions.dogImage.getImage = dogImageService.getImage(_:) 56 | app.actions.favorites.connect = favoritesService.connect(state:resource:) 57 | app.actions.favorites.favorite = favoritesService.favorite(resource:) 58 | app.actions.favorites.reset = favoritesService.reset 59 | app.actions.favorites.unfavorite = favoritesService.unfavorite(resource:) 60 | } 61 | 62 | public func makeBreedListScreenFactory() -> some BreedListScreenFactory { 63 | RealmBreedListScreenFactory(realmConfiguration: configuration.realm) 64 | } 65 | 66 | public func makeFavoritesScreenFactory() -> some FavoritesScreenFactory { 67 | RealmFavoritesScreenFactory(realmConfiguration: configuration.realm) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Packages/Core/Sources/DogAPI/Request/SubBreedImageRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SubBreedImageRequest.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024-02-12. 6 | // 7 | 8 | import Core 9 | import Foundation 10 | 11 | extension DogAPI { 12 | public enum SubBreedImageRequest { 13 | public enum All { 14 | public struct Get: DogAPIRequest { 15 | public typealias Message = [URL] 16 | 17 | public var path: String { "breed/\(breed)/\(subBreed)/images" } 18 | 19 | public let method = "GET" 20 | 21 | public let breed: Breed 22 | 23 | public let subBreed: SubBreed 24 | 25 | public init(breed: Breed, subBreed: SubBreed) { 26 | self.breed = breed 27 | self.subBreed = subBreed 28 | } 29 | } 30 | } 31 | 32 | public enum Random { 33 | public enum Single { 34 | public struct Get: DogAPIRequest { 35 | public typealias Message = URL 36 | 37 | public var path: String { "breed/\(breed)/\(subBreed)/images/random" } 38 | 39 | public let method = "GET" 40 | 41 | public let breed: Breed 42 | 43 | public let subBreed: SubBreed 44 | 45 | public init(breed: Breed, subBreed: SubBreed) { 46 | self.breed = breed 47 | self.subBreed = subBreed 48 | } 49 | } 50 | } 51 | 52 | public enum Multiple { 53 | public struct Get: DogAPIRequest { 54 | public typealias Message = [URL] 55 | 56 | public var path: String { "breed/\(breed)/\(subBreed)/images/random/\(count)" } 57 | 58 | public let method = "GET" 59 | 60 | public let breed: Breed 61 | 62 | public let subBreed: SubBreed 63 | 64 | public let count: Int 65 | 66 | public init(breed: Breed, subBreed: SubBreed, count: Int) { 67 | self.breed = breed 68 | self.subBreed = subBreed 69 | self.count = count 70 | } 71 | } 72 | } 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Packages/Core/Sources/AppUI/Flow/MainFlow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainFlow.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024-02-12. 6 | // 7 | 8 | import Core 9 | import SwiftUI 10 | 11 | struct MainFlow: View { 12 | let container: Container 13 | 14 | var body: some View { 15 | Content(flow: self) 16 | .dependency(container) 17 | } 18 | } 19 | 20 | extension MainFlow { 21 | private struct Content: View { 22 | let flow: MainFlow 23 | 24 | @AppStorage(SettingsKey.Welcome.didShow) private var didShowWelcome = false 25 | 26 | var body: some View { 27 | ZStack { 28 | if didShowWelcome { 29 | TabView { 30 | RandomImageFlow(container: flow.container) 31 | .tabItem { 32 | Label("Random", systemImage: "photo") 33 | } 34 | 35 | BreedListFlow(container: flow.container) 36 | .tabItem { 37 | Label("Breeds", systemImage: "dog") 38 | } 39 | 40 | FavoritesFlow(container: flow.container) 41 | .tabItem { 42 | Label("Favorites", systemImage: "star") 43 | } 44 | 45 | SettingsFlow(container: flow.container) 46 | .tabItem { 47 | Label("Settings", systemImage: "gear") 48 | } 49 | } 50 | } else { 51 | WelcomeScreen(flow: .init( 52 | dismiss: { didShowWelcome = true } 53 | )) 54 | } 55 | } 56 | .animation(.default, value: didShowWelcome) 57 | .withErrorAlertService() 58 | } 59 | } 60 | } 61 | 62 | #Preview { 63 | WithMockContainer(.app) { container in 64 | MainFlow(container: container) 65 | } 66 | } 67 | 68 | #Preview("Welcome") { 69 | WithMockContainer(.app(configuration: .init(didShowWelcome: false))) { container in 70 | MainFlow(container: container) 71 | } 72 | } 73 | 74 | #Preview("Error") { 75 | WithMockContainer(.app { 76 | $0.app.state.errorAlert.alert = .init(message: "This is an error") 77 | }) { container in 78 | MainFlow(container: container) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | # *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | # .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # Code Injection 86 | # 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ 91 | -------------------------------------------------------------------------------- /Packages/Core/Sources/AppUI/Screen/BreedList/BreedListScreen.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BreedListScreen.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024-02-12. 6 | // 7 | 8 | import Core 9 | import CommonUI 10 | import SwiftUI 11 | 12 | struct BreedListScreen: View { 13 | let factory: Factory 14 | 15 | @Environment(\.appActions) private var actions 16 | 17 | var body: some View { 18 | WithProperty(factory.makeScreenData()) { screenData in 19 | List { 20 | if let breeds = screenData.breeds { 21 | ForEach(breeds) { breed in 22 | BreedListRow(item: screenData.breed(breed)) 23 | } 24 | } 25 | } 26 | .task { 27 | await actions.breedList.refresh() 28 | } 29 | .refreshable { 30 | await actions.breedList.refresh() 31 | } 32 | } 33 | .dependency(factory) 34 | } 35 | } 36 | 37 | // MARK: Factory 38 | 39 | @MainActor public protocol BreedListScreenFactory: ViewInjectable { 40 | associatedtype ScreenData: BreedListScreenData 41 | func makeScreenData() -> ScreenData 42 | } 43 | 44 | @MainActor public protocol BreedListScreenData: DynamicProperty { 45 | typealias Breed = Breeds.Element 46 | 47 | associatedtype Breeds: RandomAccessCollection where Breed: Identifiable 48 | 49 | var breeds: Breeds? { get } 50 | 51 | func breed(_ breed: Breed) -> BreedListItem 52 | } 53 | 54 | struct MockBreedListScreenFactory: BreedListScreenFactory { 55 | let breeds: [BreedListItem]? 56 | 57 | func makeScreenData() -> some BreedListScreenData { 58 | MockBreedListScreenData(breeds: breeds) 59 | } 60 | 61 | func inject(content: Content) -> some View { 62 | content 63 | } 64 | } 65 | 66 | struct MockBreedListScreenData: BreedListScreenData { 67 | struct Breed: Identifiable { 68 | var id: BreedListItem { breed } 69 | 70 | let breed: BreedListItem 71 | } 72 | 73 | init(breeds: [BreedListItem]?) { 74 | self.breeds = breeds.map { $0.map { .init(breed: $0) } } 75 | } 76 | 77 | var breeds: [Breed]? 78 | 79 | func breed(_ breed: Breed) -> BreedListItem { 80 | breed.breed 81 | } 82 | } 83 | 84 | // MARK: Components 85 | 86 | enum BreedListScreenDestination: Hashable { 87 | case breedImage(breed: ConcreteBreed) 88 | } 89 | 90 | // MARK: Preview 91 | 92 | #Preview { 93 | WithMockContainer(.app) { container in 94 | BreedListScreen(factory: container.makeBreedListScreenFactory()) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /Packages/Core/Sources/AppUI/Dependency/MockAppContainer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockAppContainer.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024-02-12. 6 | // 7 | 8 | import Core 9 | import PreviewAssets 10 | import SwiftUI 11 | 12 | public final class MockAppContainer: MockDependencyContainer, AppContainer { 13 | 14 | public struct Configuration { 15 | var breeds: BreedList 16 | 17 | var defaultImage: DogImageResource? 18 | 19 | var didShowWelcome: Bool 20 | 21 | var favorites: [FavoritesItem] 22 | 23 | public init( 24 | breeds: BreedList = .mock, 25 | defaultImage: DogImageResource? = nil, 26 | didShowWelcome: Bool = true, 27 | favorites: [FavoritesItem] = [] 28 | ) { 29 | self.breeds = breeds 30 | self.defaultImage = defaultImage 31 | self.didShowWelcome = didShowWelcome 32 | self.favorites = favorites 33 | } 34 | } 35 | 36 | public let configuration: Configuration 37 | 38 | public var app: AppDependency 39 | 40 | let defaults: UserDefaults 41 | 42 | public init(configuration: Configuration) { 43 | self.configuration = configuration 44 | self.app = .init( 45 | state: .init(), 46 | actions: .init() 47 | ) 48 | self.defaults = .mock() 49 | 50 | if let resource = configuration.defaultImage { 51 | app.actions.dogImage.getImage = { _ in 52 | try? await Task.sleep(for: .seconds(1)) 53 | return resource 54 | } 55 | } 56 | 57 | defaults.set(configuration.didShowWelcome, forKey: SettingsKey.Welcome.didShow) 58 | } 59 | 60 | public func inject(content: Content) -> some View { 61 | content 62 | .dependency(app) 63 | .defaultAppStorage(defaults) 64 | } 65 | 66 | public func makeBreedListScreenFactory() -> some BreedListScreenFactory { 67 | MockBreedListScreenFactory(breeds: configuration.breeds.map()) 68 | } 69 | 70 | public func makeFavoritesScreenFactory() -> some FavoritesScreenFactory { 71 | MockFavoritesScreenFactory(items: configuration.favorites) 72 | } 73 | } 74 | 75 | extension MockDependencyContainer where Self == MockAppContainer { 76 | public static func app( 77 | configuration: MockAppContainer.Configuration = .init(), 78 | configure: (Self) -> Void = { _ in } 79 | ) -> Self { 80 | let container = Self(configuration: configuration) 81 | configure(container) 82 | return container 83 | } 84 | 85 | public static var app: Self { 86 | .app(configuration: .init()) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /Packages/Core/Package@swift-5.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.10 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "Core", 7 | platforms: [.iOS(.v16), .macOS(.v13), .watchOS(.v9)], 8 | products: [ 9 | .library(name: "Core", targets: ["Core"]), 10 | .library(name: "CommonUI", targets: ["CommonUI"]), 11 | .library(name: "AppUI", targets: ["AppUI"]), 12 | .library(name: "WatchUI", targets: ["WatchUI"]), 13 | .library(name: "DogAPI", targets: ["DogAPI"]), 14 | .library(name: "RealmStorage", targets: ["RealmStorage"]), 15 | .library(name: "CommonServices", targets: ["CommonServices"]), 16 | .library(name: "AppServices", targets: ["AppServices"]), 17 | .library(name: "WatchServices", targets: ["WatchServices"]), 18 | .library(name: "LiveApp", targets: ["LiveApp"]), 19 | .library(name: "LiveWatch", targets: ["LiveWatch"]), 20 | ], 21 | dependencies: [ 22 | .package(url: "https://github.com/realm/realm-swift", from: "10.47.0"), 23 | ], 24 | targets: [ 25 | // MARK: Core 26 | 27 | .target( 28 | name: "Core" 29 | ), 30 | 31 | // MARK: UI 32 | 33 | .target( 34 | name: "PreviewAssets", 35 | dependencies: ["Core"], 36 | exclude: ["_PreviewAssets.xcassets"] 37 | ), 38 | .target( 39 | name: "CommonUI", 40 | dependencies: ["Core", "PreviewAssets"] 41 | ), 42 | .target( 43 | name: "AppUI", 44 | dependencies: ["Core", "PreviewAssets", "CommonUI"] 45 | ), 46 | .target( 47 | name: "WatchUI", 48 | dependencies: ["Core", "PreviewAssets", "CommonUI"] 49 | ), 50 | 51 | // MARK: Infrastructure 52 | 53 | .target( 54 | name: "DogAPI", 55 | dependencies: ["Core"] 56 | ), 57 | .target( 58 | name: "RealmStorage", 59 | dependencies: [ 60 | "Core", 61 | .product(name: "RealmSwift", package: "realm-swift"), 62 | ] 63 | ), 64 | 65 | // MARK: Services 66 | 67 | .target( 68 | name: "CommonServices", 69 | dependencies: ["Core", "DogAPI"] 70 | ), 71 | .target( 72 | name: "AppServices", 73 | dependencies: ["Core", "CommonServices", "AppUI", "DogAPI", "RealmStorage"] 74 | ), 75 | .target( 76 | name: "WatchServices", 77 | dependencies: ["Core", "CommonServices", "WatchUI"] 78 | ), 79 | 80 | // MARK: Live containers 81 | 82 | .target( 83 | name: "LiveApp", 84 | dependencies: ["Core", "AppUI", "CommonServices", "AppServices", "DogAPI", "RealmStorage"] 85 | ), 86 | .target( 87 | name: "LiveWatch", 88 | dependencies: ["Core", "WatchUI", "CommonServices", "WatchServices", "DogAPI"] 89 | ), 90 | ] 91 | ) 92 | -------------------------------------------------------------------------------- /Packages/Core/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "Core", 7 | platforms: [.iOS(.v16), .macOS(.v13), .watchOS(.v9)], 8 | products: [ 9 | .library(name: "Core", targets: ["Core"]), 10 | .library(name: "CommonUI", targets: ["CommonUI"]), 11 | .library(name: "AppUI", targets: ["AppUI"]), 12 | .library(name: "WatchUI", targets: ["WatchUI"]), 13 | .library(name: "DogAPI", targets: ["DogAPI"]), 14 | .library(name: "RealmStorage", targets: ["RealmStorage"]), 15 | .library(name: "CommonServices", targets: ["CommonServices"]), 16 | .library(name: "AppServices", targets: ["AppServices"]), 17 | .library(name: "WatchServices", targets: ["WatchServices"]), 18 | .library(name: "LiveApp", targets: ["LiveApp"]), 19 | .library(name: "LiveWatch", targets: ["LiveWatch"]), 20 | ], 21 | dependencies: [ 22 | .package(url: "https://github.com/realm/realm-swift", from: "10.47.0"), 23 | ], 24 | targets: [ 25 | // MARK: Core 26 | 27 | .target( 28 | name: "Core" 29 | ), 30 | 31 | // MARK: UI 32 | 33 | .target( 34 | name: "PreviewAssets", 35 | dependencies: ["Core"], 36 | exclude: ["_PreviewAssets.xcassets"] 37 | ), 38 | .target( 39 | name: "CommonUI", 40 | dependencies: ["Core", "PreviewAssets"] 41 | ), 42 | .target( 43 | name: "AppUI", 44 | dependencies: ["Core", "PreviewAssets", "CommonUI"] 45 | ), 46 | .target( 47 | name: "WatchUI", 48 | dependencies: ["Core", "PreviewAssets", "CommonUI"] 49 | ), 50 | 51 | // MARK: Infrastructure 52 | 53 | .target( 54 | name: "DogAPI", 55 | dependencies: ["Core"] 56 | ), 57 | .target( 58 | name: "RealmStorage", 59 | dependencies: [ 60 | "Core", 61 | .product(name: "RealmSwift", package: "realm-swift"), 62 | ] 63 | ), 64 | 65 | // MARK: Services 66 | 67 | .target( 68 | name: "CommonServices", 69 | dependencies: ["Core", "DogAPI"] 70 | ), 71 | .target( 72 | name: "AppServices", 73 | dependencies: ["Core", "CommonServices", "AppUI", "DogAPI", "RealmStorage"] 74 | ), 75 | .target( 76 | name: "WatchServices", 77 | dependencies: ["Core", "CommonServices", "WatchUI"] 78 | ), 79 | 80 | // MARK: Live containers 81 | 82 | .target( 83 | name: "LiveApp", 84 | dependencies: ["Core", "AppUI", "CommonServices", "AppServices", "DogAPI", "RealmStorage"] 85 | ), 86 | .target( 87 | name: "LiveWatch", 88 | dependencies: ["Core", "WatchUI", "CommonServices", "WatchServices", "DogAPI"] 89 | ), 90 | ], 91 | swiftLanguageVersions: [.v6] 92 | ) 93 | -------------------------------------------------------------------------------- /Packages/Core/Sources/AppServices/Service/FavoritesService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FavoritesService.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024/02/15. 6 | // 7 | 8 | import AppUI 9 | import Combine 10 | import Core 11 | import Foundation 12 | import RealmStorage 13 | import RealmSwift 14 | 15 | @MainActor public final class FavoritesService: Sendable { 16 | let realmConfiguration: Realm.Configuration 17 | 18 | let errorAlert: AppState.ErrorAlert 19 | 20 | public init(realmConfiguration: Realm.Configuration, errorState: AppState.ErrorAlert) { 21 | self.realmConfiguration = realmConfiguration 22 | self.errorAlert = errorState 23 | } 24 | 25 | public func connect(state: FavoriteState, resource: DogImageResource) { 26 | do { 27 | guard case let .remote(url) = resource else { 28 | state.canFavorite = false 29 | return 30 | } 31 | state.canFavorite = true 32 | let urlString = url.absoluteString 33 | let realm = try Realm(configuration: realmConfiguration) 34 | realm.objects(FavoritesItemObject.self) 35 | .where { $0.urlString == urlString } 36 | .collectionPublisher 37 | .map { !$0.isEmpty } 38 | .replaceError(with: false) 39 | .assign(to: &state.$isFavorited) 40 | } catch { 41 | print(error) 42 | } 43 | } 44 | 45 | public func favorite(resource: DogImageResource) { 46 | do { 47 | guard let object = FavoritesItemObject(entity: .init(id: .init(), resource: resource)) else { 48 | throw .message("This image can't be favorited.") 49 | } 50 | let realm = try Realm(configuration: realmConfiguration) 51 | try realm.write { 52 | realm.add(object) 53 | } 54 | } catch { 55 | errorAlert.alert = .init(underlying: error, message: "Could not favorite") 56 | } 57 | } 58 | 59 | public func reset() -> Void { 60 | do { 61 | let realm = try Realm(configuration: realmConfiguration) 62 | try realm.write { 63 | realm.delete(realm.objects(FavoritesItemObject.self)) 64 | } 65 | } catch { 66 | errorAlert.alert = .init(underlying: error, message: "Could not reset") 67 | } 68 | } 69 | 70 | public func unfavorite(resource: DogImageResource) { 71 | do { 72 | guard case let .remote(url) = resource else { 73 | throw .message("This image can't be favorited.") 74 | } 75 | let urlString = url.absoluteString 76 | let realm = try Realm(configuration: realmConfiguration) 77 | try realm.write { 78 | let objects = realm.objects(FavoritesItemObject.self).where { $0.urlString == urlString } 79 | realm.delete(objects) 80 | } 81 | } catch { 82 | errorAlert.alert = .init(underlying: error, message: "Could not unfavorite") 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Dogs.xcodeproj/xcshareddata/xcschemes/Dogs.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 42 | 44 | 50 | 51 | 52 | 53 | 59 | 61 | 67 | 68 | 69 | 70 | 72 | 73 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /Packages/Core/Sources/AppUI/Screen/DogImage/DogImageScreen.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DogImageScreen.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024-02-12. 6 | // 7 | 8 | import CommonUI 9 | import Core 10 | import PreviewAssets 11 | import SwiftUI 12 | 13 | struct DogImageScreen: View { 14 | let image: DogImage 15 | 16 | @State private var state: LoadingState? 17 | 18 | @Environment(\.appActions) private var actions 19 | 20 | var body: some View { 21 | VStack { 22 | Group { 23 | switch state?.state { 24 | case let .completed(resource): 25 | DogImageView(resource: resource) 26 | 27 | case .error: 28 | TaskFailedView() 29 | 30 | case .inProgress, .none: 31 | ProgressView() 32 | } 33 | } 34 | .frame(maxWidth: .infinity, maxHeight: .infinity) 35 | 36 | HStack(spacing: 8) { 37 | Button { 38 | state = .init(input: image) 39 | } label: { 40 | Label("Reload", systemImage: "arrow.triangle.2.circlepath") 41 | } 42 | .buttonStyle(.borderedProminent) 43 | .disabled(state?.didFinish == false) 44 | 45 | DogImageFavoriteButton(resource: state?.value) 46 | .id(state?.value?.remoteURL) 47 | } 48 | .padding() 49 | } 50 | .task(id: state?.id) { 51 | if let state { 52 | guard !state.didFinish else { return } // Don't reload on each appearance 53 | do { 54 | let url = try await actions.dogImage.getImage(state.input) 55 | self.state?.state = .completed(url) 56 | } catch is CancellationError { 57 | 58 | } catch { 59 | self.state?.state = .error(error) 60 | } 61 | } else { 62 | state = .init(input: image) 63 | } 64 | } 65 | .onChange(of: image) { _ in 66 | state = .init(input: image) 67 | } 68 | } 69 | } 70 | 71 | struct DogImageFavoriteButton: View { 72 | let resource: DogImageResource? 73 | 74 | @StateObject private var state = FavoriteState() 75 | 76 | @Environment(\.appActions) private var actions 77 | 78 | var body: some View { 79 | Button { 80 | guard let resource else { return } 81 | if state.isFavorited { 82 | actions.favorites.unfavorite(resource) 83 | } else { 84 | actions.favorites.favorite(resource) 85 | } 86 | } label: { 87 | Label(state.isFavorited ? "Unfavorite" : "Favorite", systemImage: "star") 88 | .labelStyle(.iconOnly) 89 | .symbolVariant(state.isFavorited ? .fill : .none) 90 | } 91 | .buttonStyle(.borderedProminent) 92 | .disabled(!state.canFavorite) 93 | .padding() 94 | .onFirstAppear { 95 | guard let resource else { return } 96 | actions.favorites.connect(state, resource) 97 | } 98 | } 99 | } 100 | 101 | #Preview { 102 | DogImageScreen(image: .random) 103 | .mockContainer(.app( 104 | configuration: .init( 105 | defaultImage: .local(PreviewAsset.Image.kurosuke01) 106 | // defaultImage: .remote(URL(string: "https://images.dog.ceo/breeds/shiba/shiba-3i.jpg")!) 107 | ) 108 | )) 109 | } 110 | 111 | #Preview("Error") { 112 | DogImageScreen(image: .random) 113 | .mockContainer(.app) 114 | } 115 | -------------------------------------------------------------------------------- /Dogs.xcodeproj/xcshareddata/xcschemes/WatchDogs Watch App.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 38 | 39 | 45 | 46 | 56 | 58 | 64 | 65 | 66 | 67 | 73 | 75 | 81 | 82 | 83 | 84 | 86 | 87 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /Packages/Core/Sources/AppUI/Screen/Favorites/FavoritesScreen.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FavoritesScreen.swift 3 | // 4 | // 5 | // Created by Mikhail Apurin on 2024/02/15. 6 | // 7 | 8 | import CommonUI 9 | import Core 10 | import PreviewAssets 11 | import SwiftUI 12 | 13 | struct FavoritesScreen: View { 14 | let factory: Factory 15 | 16 | @AppStorage(SettingsKey.Favorites.prefersFill) private var prefersFill = false 17 | 18 | var body: some View { 19 | WithProperty(factory.makeScreenData()) { screenData in 20 | if screenData.items.isEmpty { 21 | FavoritesEmptyView() 22 | } else { 23 | ScrollView { 24 | LazyVGrid( 25 | columns: [GridItem(.adaptive(minimum: 100, maximum: 200), spacing: 8)], 26 | alignment: .leading, 27 | spacing: 8 28 | ) { 29 | ForEach(screenData.items) { item in 30 | FavoritesGridItem(item: screenData.item(item)) 31 | } 32 | } 33 | .padding() 34 | } 35 | .toolbar { 36 | ToolbarItem(placement: .topBarTrailing) { 37 | Button { 38 | prefersFill.toggle() 39 | } label: { 40 | Label( 41 | prefersFill ? "Fit" : "Fill", 42 | systemImage: prefersFill ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right" 43 | ) 44 | } 45 | } 46 | } 47 | } 48 | } 49 | .dependency(factory) 50 | } 51 | } 52 | 53 | // MARK: Factory 54 | 55 | @MainActor public protocol FavoritesScreenFactory: ViewInjectable { 56 | associatedtype ScreenData: FavoritesScreenData 57 | func makeScreenData() -> ScreenData 58 | } 59 | 60 | @MainActor public protocol FavoritesScreenData: DynamicProperty { 61 | typealias Item = Items.Element 62 | 63 | associatedtype Items: RandomAccessCollection where Item: Identifiable 64 | 65 | var items: Items { get } 66 | 67 | func item(_ item: Item) -> FavoritesItem 68 | } 69 | 70 | struct MockFavoritesScreenFactory: FavoritesScreenFactory { 71 | let items: [FavoritesItem] 72 | 73 | func makeScreenData() -> some FavoritesScreenData { 74 | MockFavoritesScreenData(items: items) 75 | } 76 | 77 | func inject(content: Content) -> some View { 78 | content 79 | } 80 | } 81 | 82 | struct MockFavoritesScreenData: FavoritesScreenData { 83 | let items: [FavoritesItem] 84 | 85 | func item(_ item: Item) -> FavoritesItem { 86 | item 87 | } 88 | } 89 | 90 | // MARK: Components 91 | 92 | struct FavoritesGridItem: View { 93 | let item: FavoritesItem 94 | 95 | @AppStorage(SettingsKey.Favorites.prefersFill) private var prefersFill = false 96 | 97 | var body: some View { 98 | DogImageView(resource: item.resource) 99 | .aspectRatio(contentMode: prefersFill ? .fill : .fit) 100 | .frame(minWidth: prefersFill ? 0 : nil, maxWidth: prefersFill ? .infinity : nil, minHeight: prefersFill ? 0 : nil, maxHeight: prefersFill ? .infinity : nil) 101 | .clipShape(.rect(cornerRadius: 4)) 102 | .frame(minWidth: prefersFill ? nil : 0, maxWidth: prefersFill ? nil : .infinity, minHeight: prefersFill ? nil : 0, maxHeight: prefersFill ? nil : .infinity) 103 | .aspectRatio(1, contentMode: .fill) 104 | .animation(.default, value: prefersFill) 105 | } 106 | } 107 | 108 | // MARK: Preview 109 | 110 | @MainActor private let previewFavorites = [ 111 | FavoritesItem(id: .init(), resource: .local(PreviewAsset.Image.kurosuke01)), 112 | FavoritesItem(id: .init(), resource: .local(PreviewAsset.Image.kurosuke01)), 113 | FavoritesItem(id: .init(), resource: .local(PreviewAsset.Image.kurosuke01)), 114 | FavoritesItem(id: .init(), resource: .local(PreviewAsset.Image.kurosuke01)), 115 | FavoritesItem(id: .init(), resource: .local(PreviewAsset.Image.kurosuke01)), 116 | FavoritesItem(id: .init(), resource: .local(PreviewAsset.Image.kurosuke01)), 117 | FavoritesItem(id: .init(), resource: .local(PreviewAsset.Image.kurosuke01)), 118 | FavoritesItem(id: .init(), resource: .local(PreviewAsset.Image.kurosuke01)), 119 | FavoritesItem(id: .init(), resource: .local(PreviewAsset.Image.kurosuke01)), 120 | FavoritesItem(id: .init(), resource: .local(PreviewAsset.Image.kurosuke01)), 121 | FavoritesItem(id: .init(), resource: .local(PreviewAsset.Image.kurosuke01)), 122 | ] 123 | 124 | #Preview { 125 | NavigationStack { 126 | WithMockContainer(.app(configuration: .init(favorites: previewFavorites))) { container in 127 | FavoritesScreen(factory: container.makeFavoritesScreenFactory()) 128 | } 129 | .navigationTitle("Favorites") 130 | } 131 | } 132 | 133 | #Preview("Empty") { 134 | NavigationStack { 135 | WithMockContainer(.app) { container in 136 | FavoritesScreen(factory: container.makeFavoritesScreenFactory()) 137 | } 138 | .navigationTitle("Favorites") 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /Dogs.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 56; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | A34ECB122B79E66000C10200 /* LiveApp in Frameworks */ = {isa = PBXBuildFile; productRef = A34ECB112B79E66000C10200 /* LiveApp */; }; 11 | A39D33352B7A55F000A1E608 /* WatchDogsApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A39D33342B7A55F000A1E608 /* WatchDogsApp.swift */; }; 12 | A39D33392B7A55F100A1E608 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A39D33382B7A55F100A1E608 /* Assets.xcassets */; }; 13 | A39D333F2B7A55F100A1E608 /* WatchDogs Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = A39D33322B7A55F000A1E608 /* WatchDogs Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 14 | A39D33612B7AF1A600A1E608 /* LiveWatch in Frameworks */ = {isa = PBXBuildFile; productRef = A39D33602B7AF1A600A1E608 /* LiveWatch */; }; 15 | A39D33652B7AF1A600A1E608 /* WatchUI in Frameworks */ = {isa = PBXBuildFile; productRef = A39D33642B7AF1A600A1E608 /* WatchUI */; }; 16 | A3E330DE2B79DB9E0034C25E /* DogsApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3E330DD2B79DB9E0034C25E /* DogsApp.swift */; }; 17 | A3E330E22B79DBA00034C25E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A3E330E12B79DBA00034C25E /* Assets.xcassets */; }; 18 | /* End PBXBuildFile section */ 19 | 20 | /* Begin PBXContainerItemProxy section */ 21 | A39D333D2B7A55F100A1E608 /* PBXContainerItemProxy */ = { 22 | isa = PBXContainerItemProxy; 23 | containerPortal = A3E330D22B79DB9E0034C25E /* Project object */; 24 | proxyType = 1; 25 | remoteGlobalIDString = A39D33312B7A55F000A1E608; 26 | remoteInfo = "WatchDogs Watch App"; 27 | }; 28 | /* End PBXContainerItemProxy section */ 29 | 30 | /* Begin PBXCopyFilesBuildPhase section */ 31 | A39D33432B7A55F100A1E608 /* Embed Watch Content */ = { 32 | isa = PBXCopyFilesBuildPhase; 33 | buildActionMask = 2147483647; 34 | dstPath = "$(CONTENTS_FOLDER_PATH)/Watch"; 35 | dstSubfolderSpec = 16; 36 | files = ( 37 | A39D333F2B7A55F100A1E608 /* WatchDogs Watch App.app in Embed Watch Content */, 38 | ); 39 | name = "Embed Watch Content"; 40 | runOnlyForDeploymentPostprocessing = 0; 41 | }; 42 | /* End PBXCopyFilesBuildPhase section */ 43 | 44 | /* Begin PBXFileReference section */ 45 | A39D33322B7A55F000A1E608 /* WatchDogs Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "WatchDogs Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 46 | A39D33342B7A55F000A1E608 /* WatchDogsApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchDogsApp.swift; sourceTree = ""; }; 47 | A39D33382B7A55F100A1E608 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 48 | A3E330DA2B79DB9E0034C25E /* Dogs.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Dogs.app; sourceTree = BUILT_PRODUCTS_DIR; }; 49 | A3E330DD2B79DB9E0034C25E /* DogsApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DogsApp.swift; sourceTree = ""; }; 50 | A3E330E12B79DBA00034C25E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 51 | /* End PBXFileReference section */ 52 | 53 | /* Begin PBXFrameworksBuildPhase section */ 54 | A39D332F2B7A55F000A1E608 /* Frameworks */ = { 55 | isa = PBXFrameworksBuildPhase; 56 | buildActionMask = 2147483647; 57 | files = ( 58 | A39D33652B7AF1A600A1E608 /* WatchUI in Frameworks */, 59 | A39D33612B7AF1A600A1E608 /* LiveWatch in Frameworks */, 60 | ); 61 | runOnlyForDeploymentPostprocessing = 0; 62 | }; 63 | A3E330D72B79DB9E0034C25E /* Frameworks */ = { 64 | isa = PBXFrameworksBuildPhase; 65 | buildActionMask = 2147483647; 66 | files = ( 67 | A34ECB122B79E66000C10200 /* LiveApp in Frameworks */, 68 | ); 69 | runOnlyForDeploymentPostprocessing = 0; 70 | }; 71 | /* End PBXFrameworksBuildPhase section */ 72 | 73 | /* Begin PBXGroup section */ 74 | A34ECB102B79E66000C10200 /* Frameworks */ = { 75 | isa = PBXGroup; 76 | children = ( 77 | ); 78 | name = Frameworks; 79 | sourceTree = ""; 80 | }; 81 | A39D33332B7A55F000A1E608 /* WatchDogs Watch App */ = { 82 | isa = PBXGroup; 83 | children = ( 84 | A39D33342B7A55F000A1E608 /* WatchDogsApp.swift */, 85 | A39D33382B7A55F100A1E608 /* Assets.xcassets */, 86 | ); 87 | path = "WatchDogs Watch App"; 88 | sourceTree = ""; 89 | }; 90 | A3E330D12B79DB9E0034C25E = { 91 | isa = PBXGroup; 92 | children = ( 93 | A3E330DC2B79DB9E0034C25E /* Dogs */, 94 | A39D33332B7A55F000A1E608 /* WatchDogs Watch App */, 95 | A3E330DB2B79DB9E0034C25E /* Products */, 96 | A34ECB102B79E66000C10200 /* Frameworks */, 97 | ); 98 | sourceTree = ""; 99 | }; 100 | A3E330DB2B79DB9E0034C25E /* Products */ = { 101 | isa = PBXGroup; 102 | children = ( 103 | A3E330DA2B79DB9E0034C25E /* Dogs.app */, 104 | A39D33322B7A55F000A1E608 /* WatchDogs Watch App.app */, 105 | ); 106 | name = Products; 107 | sourceTree = ""; 108 | }; 109 | A3E330DC2B79DB9E0034C25E /* Dogs */ = { 110 | isa = PBXGroup; 111 | children = ( 112 | A3E330DD2B79DB9E0034C25E /* DogsApp.swift */, 113 | A3E330E12B79DBA00034C25E /* Assets.xcassets */, 114 | ); 115 | path = Dogs; 116 | sourceTree = ""; 117 | }; 118 | /* End PBXGroup section */ 119 | 120 | /* Begin PBXNativeTarget section */ 121 | A39D33312B7A55F000A1E608 /* WatchDogs Watch App */ = { 122 | isa = PBXNativeTarget; 123 | buildConfigurationList = A39D33402B7A55F100A1E608 /* Build configuration list for PBXNativeTarget "WatchDogs Watch App" */; 124 | buildPhases = ( 125 | A39D332E2B7A55F000A1E608 /* Sources */, 126 | A39D332F2B7A55F000A1E608 /* Frameworks */, 127 | A39D33302B7A55F000A1E608 /* Resources */, 128 | ); 129 | buildRules = ( 130 | ); 131 | dependencies = ( 132 | ); 133 | name = "WatchDogs Watch App"; 134 | packageProductDependencies = ( 135 | A39D33602B7AF1A600A1E608 /* LiveWatch */, 136 | A39D33642B7AF1A600A1E608 /* WatchUI */, 137 | ); 138 | productName = "WatchDogs Watch App"; 139 | productReference = A39D33322B7A55F000A1E608 /* WatchDogs Watch App.app */; 140 | productType = "com.apple.product-type.application"; 141 | }; 142 | A3E330D92B79DB9E0034C25E /* Dogs */ = { 143 | isa = PBXNativeTarget; 144 | buildConfigurationList = A3E330E82B79DBA00034C25E /* Build configuration list for PBXNativeTarget "Dogs" */; 145 | buildPhases = ( 146 | A3E330D62B79DB9E0034C25E /* Sources */, 147 | A3E330D72B79DB9E0034C25E /* Frameworks */, 148 | A3E330D82B79DB9E0034C25E /* Resources */, 149 | A39D33432B7A55F100A1E608 /* Embed Watch Content */, 150 | ); 151 | buildRules = ( 152 | ); 153 | dependencies = ( 154 | A39D333E2B7A55F100A1E608 /* PBXTargetDependency */, 155 | ); 156 | name = Dogs; 157 | packageProductDependencies = ( 158 | A34ECB112B79E66000C10200 /* LiveApp */, 159 | ); 160 | productName = Dogs; 161 | productReference = A3E330DA2B79DB9E0034C25E /* Dogs.app */; 162 | productType = "com.apple.product-type.application"; 163 | }; 164 | /* End PBXNativeTarget section */ 165 | 166 | /* Begin PBXProject section */ 167 | A3E330D22B79DB9E0034C25E /* Project object */ = { 168 | isa = PBXProject; 169 | attributes = { 170 | BuildIndependentTargetsInParallel = 1; 171 | LastSwiftUpdateCheck = 1520; 172 | LastUpgradeCheck = 1600; 173 | TargetAttributes = { 174 | A39D33312B7A55F000A1E608 = { 175 | CreatedOnToolsVersion = 15.2; 176 | }; 177 | A3E330D92B79DB9E0034C25E = { 178 | CreatedOnToolsVersion = 15.2; 179 | }; 180 | }; 181 | }; 182 | buildConfigurationList = A3E330D52B79DB9E0034C25E /* Build configuration list for PBXProject "Dogs" */; 183 | compatibilityVersion = "Xcode 14.0"; 184 | developmentRegion = en; 185 | hasScannedForEncodings = 0; 186 | knownRegions = ( 187 | en, 188 | Base, 189 | ); 190 | mainGroup = A3E330D12B79DB9E0034C25E; 191 | productRefGroup = A3E330DB2B79DB9E0034C25E /* Products */; 192 | projectDirPath = ""; 193 | projectRoot = ""; 194 | targets = ( 195 | A3E330D92B79DB9E0034C25E /* Dogs */, 196 | A39D33312B7A55F000A1E608 /* WatchDogs Watch App */, 197 | ); 198 | }; 199 | /* End PBXProject section */ 200 | 201 | /* Begin PBXResourcesBuildPhase section */ 202 | A39D33302B7A55F000A1E608 /* Resources */ = { 203 | isa = PBXResourcesBuildPhase; 204 | buildActionMask = 2147483647; 205 | files = ( 206 | A39D33392B7A55F100A1E608 /* Assets.xcassets in Resources */, 207 | ); 208 | runOnlyForDeploymentPostprocessing = 0; 209 | }; 210 | A3E330D82B79DB9E0034C25E /* Resources */ = { 211 | isa = PBXResourcesBuildPhase; 212 | buildActionMask = 2147483647; 213 | files = ( 214 | A3E330E22B79DBA00034C25E /* Assets.xcassets in Resources */, 215 | ); 216 | runOnlyForDeploymentPostprocessing = 0; 217 | }; 218 | /* End PBXResourcesBuildPhase section */ 219 | 220 | /* Begin PBXSourcesBuildPhase section */ 221 | A39D332E2B7A55F000A1E608 /* Sources */ = { 222 | isa = PBXSourcesBuildPhase; 223 | buildActionMask = 2147483647; 224 | files = ( 225 | A39D33352B7A55F000A1E608 /* WatchDogsApp.swift in Sources */, 226 | ); 227 | runOnlyForDeploymentPostprocessing = 0; 228 | }; 229 | A3E330D62B79DB9E0034C25E /* Sources */ = { 230 | isa = PBXSourcesBuildPhase; 231 | buildActionMask = 2147483647; 232 | files = ( 233 | A3E330DE2B79DB9E0034C25E /* DogsApp.swift in Sources */, 234 | ); 235 | runOnlyForDeploymentPostprocessing = 0; 236 | }; 237 | /* End PBXSourcesBuildPhase section */ 238 | 239 | /* Begin PBXTargetDependency section */ 240 | A39D333E2B7A55F100A1E608 /* PBXTargetDependency */ = { 241 | isa = PBXTargetDependency; 242 | target = A39D33312B7A55F000A1E608 /* WatchDogs Watch App */; 243 | targetProxy = A39D333D2B7A55F100A1E608 /* PBXContainerItemProxy */; 244 | }; 245 | /* End PBXTargetDependency section */ 246 | 247 | /* Begin XCBuildConfiguration section */ 248 | A39D33412B7A55F100A1E608 /* Debug */ = { 249 | isa = XCBuildConfiguration; 250 | buildSettings = { 251 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 252 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 253 | CODE_SIGN_STYLE = Automatic; 254 | CURRENT_PROJECT_VERSION = 1; 255 | DEVELOPMENT_ASSET_PATHS = ""; 256 | DEVELOPMENT_TEAM = FK4VRNJVL7; 257 | ENABLE_PREVIEWS = YES; 258 | GENERATE_INFOPLIST_FILE = YES; 259 | INFOPLIST_KEY_CFBundleDisplayName = WatchDogs; 260 | INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; 261 | INFOPLIST_KEY_WKCompanionAppBundleIdentifier = me.apurin.Dogs; 262 | LD_RUNPATH_SEARCH_PATHS = ( 263 | "$(inherited)", 264 | "@executable_path/Frameworks", 265 | ); 266 | MARKETING_VERSION = 1.0; 267 | PRODUCT_BUNDLE_IDENTIFIER = me.apurin.Dogs.watchkitapp; 268 | PRODUCT_NAME = "$(TARGET_NAME)"; 269 | SDKROOT = watchos; 270 | SKIP_INSTALL = YES; 271 | SWIFT_EMIT_LOC_STRINGS = YES; 272 | TARGETED_DEVICE_FAMILY = 4; 273 | WATCHOS_DEPLOYMENT_TARGET = 10.0; 274 | }; 275 | name = Debug; 276 | }; 277 | A39D33422B7A55F100A1E608 /* Release */ = { 278 | isa = XCBuildConfiguration; 279 | buildSettings = { 280 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 281 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 282 | CODE_SIGN_STYLE = Automatic; 283 | CURRENT_PROJECT_VERSION = 1; 284 | DEVELOPMENT_ASSET_PATHS = ""; 285 | DEVELOPMENT_TEAM = FK4VRNJVL7; 286 | ENABLE_PREVIEWS = YES; 287 | GENERATE_INFOPLIST_FILE = YES; 288 | INFOPLIST_KEY_CFBundleDisplayName = WatchDogs; 289 | INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; 290 | INFOPLIST_KEY_WKCompanionAppBundleIdentifier = me.apurin.Dogs; 291 | LD_RUNPATH_SEARCH_PATHS = ( 292 | "$(inherited)", 293 | "@executable_path/Frameworks", 294 | ); 295 | MARKETING_VERSION = 1.0; 296 | PRODUCT_BUNDLE_IDENTIFIER = me.apurin.Dogs.watchkitapp; 297 | PRODUCT_NAME = "$(TARGET_NAME)"; 298 | SDKROOT = watchos; 299 | SKIP_INSTALL = YES; 300 | SWIFT_EMIT_LOC_STRINGS = YES; 301 | TARGETED_DEVICE_FAMILY = 4; 302 | WATCHOS_DEPLOYMENT_TARGET = 10.0; 303 | }; 304 | name = Release; 305 | }; 306 | A3E330E62B79DBA00034C25E /* Debug */ = { 307 | isa = XCBuildConfiguration; 308 | buildSettings = { 309 | ALWAYS_SEARCH_USER_PATHS = NO; 310 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 311 | CLANG_ANALYZER_NONNULL = YES; 312 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 313 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 314 | CLANG_ENABLE_MODULES = YES; 315 | CLANG_ENABLE_OBJC_ARC = YES; 316 | CLANG_ENABLE_OBJC_WEAK = YES; 317 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 318 | CLANG_WARN_BOOL_CONVERSION = YES; 319 | CLANG_WARN_COMMA = YES; 320 | CLANG_WARN_CONSTANT_CONVERSION = YES; 321 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 322 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 323 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 324 | CLANG_WARN_EMPTY_BODY = YES; 325 | CLANG_WARN_ENUM_CONVERSION = YES; 326 | CLANG_WARN_INFINITE_RECURSION = YES; 327 | CLANG_WARN_INT_CONVERSION = YES; 328 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 329 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 330 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 331 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 332 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 333 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 334 | CLANG_WARN_STRICT_PROTOTYPES = YES; 335 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 336 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 337 | CLANG_WARN_UNREACHABLE_CODE = YES; 338 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 339 | COPY_PHASE_STRIP = NO; 340 | DEBUG_INFORMATION_FORMAT = dwarf; 341 | ENABLE_STRICT_OBJC_MSGSEND = YES; 342 | ENABLE_TESTABILITY = YES; 343 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 344 | GCC_C_LANGUAGE_STANDARD = gnu17; 345 | GCC_DYNAMIC_NO_PIC = NO; 346 | GCC_NO_COMMON_BLOCKS = YES; 347 | GCC_OPTIMIZATION_LEVEL = 0; 348 | GCC_PREPROCESSOR_DEFINITIONS = ( 349 | "DEBUG=1", 350 | "$(inherited)", 351 | ); 352 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 353 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 354 | GCC_WARN_UNDECLARED_SELECTOR = YES; 355 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 356 | GCC_WARN_UNUSED_FUNCTION = YES; 357 | GCC_WARN_UNUSED_VARIABLE = YES; 358 | IPHONEOS_DEPLOYMENT_TARGET = 16.0; 359 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 360 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 361 | MTL_FAST_MATH = YES; 362 | ONLY_ACTIVE_ARCH = YES; 363 | SDKROOT = iphoneos; 364 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; 365 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 366 | SWIFT_VERSION = 5.0; 367 | }; 368 | name = Debug; 369 | }; 370 | A3E330E72B79DBA00034C25E /* Release */ = { 371 | isa = XCBuildConfiguration; 372 | buildSettings = { 373 | ALWAYS_SEARCH_USER_PATHS = NO; 374 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 375 | CLANG_ANALYZER_NONNULL = YES; 376 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 377 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 378 | CLANG_ENABLE_MODULES = YES; 379 | CLANG_ENABLE_OBJC_ARC = YES; 380 | CLANG_ENABLE_OBJC_WEAK = YES; 381 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 382 | CLANG_WARN_BOOL_CONVERSION = YES; 383 | CLANG_WARN_COMMA = YES; 384 | CLANG_WARN_CONSTANT_CONVERSION = YES; 385 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 386 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 387 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 388 | CLANG_WARN_EMPTY_BODY = YES; 389 | CLANG_WARN_ENUM_CONVERSION = YES; 390 | CLANG_WARN_INFINITE_RECURSION = YES; 391 | CLANG_WARN_INT_CONVERSION = YES; 392 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 393 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 394 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 395 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 396 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 397 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 398 | CLANG_WARN_STRICT_PROTOTYPES = YES; 399 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 400 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 401 | CLANG_WARN_UNREACHABLE_CODE = YES; 402 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 403 | COPY_PHASE_STRIP = NO; 404 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 405 | ENABLE_NS_ASSERTIONS = NO; 406 | ENABLE_STRICT_OBJC_MSGSEND = YES; 407 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 408 | GCC_C_LANGUAGE_STANDARD = gnu17; 409 | GCC_NO_COMMON_BLOCKS = YES; 410 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 411 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 412 | GCC_WARN_UNDECLARED_SELECTOR = YES; 413 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 414 | GCC_WARN_UNUSED_FUNCTION = YES; 415 | GCC_WARN_UNUSED_VARIABLE = YES; 416 | IPHONEOS_DEPLOYMENT_TARGET = 16.0; 417 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 418 | MTL_ENABLE_DEBUG_INFO = NO; 419 | MTL_FAST_MATH = YES; 420 | SDKROOT = iphoneos; 421 | SWIFT_COMPILATION_MODE = wholemodule; 422 | SWIFT_VERSION = 5.0; 423 | VALIDATE_PRODUCT = YES; 424 | }; 425 | name = Release; 426 | }; 427 | A3E330E92B79DBA00034C25E /* Debug */ = { 428 | isa = XCBuildConfiguration; 429 | buildSettings = { 430 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 431 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 432 | CODE_SIGN_STYLE = Automatic; 433 | CURRENT_PROJECT_VERSION = 1; 434 | DEVELOPMENT_ASSET_PATHS = ""; 435 | DEVELOPMENT_TEAM = FK4VRNJVL7; 436 | ENABLE_PREVIEWS = YES; 437 | GENERATE_INFOPLIST_FILE = YES; 438 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 439 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 440 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 441 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 442 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 443 | LD_RUNPATH_SEARCH_PATHS = ( 444 | "$(inherited)", 445 | "@executable_path/Frameworks", 446 | ); 447 | MARKETING_VERSION = 1.0; 448 | PRODUCT_BUNDLE_IDENTIFIER = me.apurin.Dogs; 449 | PRODUCT_NAME = "$(TARGET_NAME)"; 450 | SWIFT_EMIT_LOC_STRINGS = YES; 451 | TARGETED_DEVICE_FAMILY = "1,2"; 452 | }; 453 | name = Debug; 454 | }; 455 | A3E330EA2B79DBA00034C25E /* Release */ = { 456 | isa = XCBuildConfiguration; 457 | buildSettings = { 458 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 459 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 460 | CODE_SIGN_STYLE = Automatic; 461 | CURRENT_PROJECT_VERSION = 1; 462 | DEVELOPMENT_ASSET_PATHS = ""; 463 | DEVELOPMENT_TEAM = FK4VRNJVL7; 464 | ENABLE_PREVIEWS = YES; 465 | GENERATE_INFOPLIST_FILE = YES; 466 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 467 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 468 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 469 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 470 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 471 | LD_RUNPATH_SEARCH_PATHS = ( 472 | "$(inherited)", 473 | "@executable_path/Frameworks", 474 | ); 475 | MARKETING_VERSION = 1.0; 476 | PRODUCT_BUNDLE_IDENTIFIER = me.apurin.Dogs; 477 | PRODUCT_NAME = "$(TARGET_NAME)"; 478 | SWIFT_EMIT_LOC_STRINGS = YES; 479 | TARGETED_DEVICE_FAMILY = "1,2"; 480 | }; 481 | name = Release; 482 | }; 483 | /* End XCBuildConfiguration section */ 484 | 485 | /* Begin XCConfigurationList section */ 486 | A39D33402B7A55F100A1E608 /* Build configuration list for PBXNativeTarget "WatchDogs Watch App" */ = { 487 | isa = XCConfigurationList; 488 | buildConfigurations = ( 489 | A39D33412B7A55F100A1E608 /* Debug */, 490 | A39D33422B7A55F100A1E608 /* Release */, 491 | ); 492 | defaultConfigurationIsVisible = 0; 493 | defaultConfigurationName = Release; 494 | }; 495 | A3E330D52B79DB9E0034C25E /* Build configuration list for PBXProject "Dogs" */ = { 496 | isa = XCConfigurationList; 497 | buildConfigurations = ( 498 | A3E330E62B79DBA00034C25E /* Debug */, 499 | A3E330E72B79DBA00034C25E /* Release */, 500 | ); 501 | defaultConfigurationIsVisible = 0; 502 | defaultConfigurationName = Release; 503 | }; 504 | A3E330E82B79DBA00034C25E /* Build configuration list for PBXNativeTarget "Dogs" */ = { 505 | isa = XCConfigurationList; 506 | buildConfigurations = ( 507 | A3E330E92B79DBA00034C25E /* Debug */, 508 | A3E330EA2B79DBA00034C25E /* Release */, 509 | ); 510 | defaultConfigurationIsVisible = 0; 511 | defaultConfigurationName = Release; 512 | }; 513 | /* End XCConfigurationList section */ 514 | 515 | /* Begin XCSwiftPackageProductDependency section */ 516 | A34ECB112B79E66000C10200 /* LiveApp */ = { 517 | isa = XCSwiftPackageProductDependency; 518 | productName = LiveApp; 519 | }; 520 | A39D33602B7AF1A600A1E608 /* LiveWatch */ = { 521 | isa = XCSwiftPackageProductDependency; 522 | productName = LiveWatch; 523 | }; 524 | A39D33642B7AF1A600A1E608 /* WatchUI */ = { 525 | isa = XCSwiftPackageProductDependency; 526 | productName = WatchUI; 527 | }; 528 | /* End XCSwiftPackageProductDependency section */ 529 | }; 530 | rootObject = A3E330D22B79DB9E0034C25E /* Project object */; 531 | } 532 | --------------------------------------------------------------------------------