├── 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 |
--------------------------------------------------------------------------------