├── .github └── workflows │ ├── test.yml │ └── xcodetest.yml ├── .gitignore ├── .swiftlint.yml ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── contents.xcworkspacedata ├── LICENSE ├── MixTeam ├── .swiftlint.yml ├── MixTeam.xcodeproj │ ├── project.pbxproj │ └── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ └── Package.resolved ├── MixTeam │ ├── Assets.xcassets │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ ├── Contents.json │ │ │ └── mix-team-logo-1024.png │ │ ├── Contents.json │ │ └── Logo.imageset │ │ │ ├── Contents.json │ │ │ ├── Mix-Team-New-Line-400x400.png │ │ │ └── Mix-Team-New-Line-600x600.png │ ├── Info.plist │ └── MixTeamApp.swift ├── MixTeamUITests │ ├── Info.plist │ └── MixTeamUITests.swift └── Package.swift ├── Package.resolved ├── Package.swift ├── README.md ├── Sources ├── AppFeature │ ├── App+Preview.swift │ ├── App.swift │ └── AppView.swift ├── ArchivesFeature │ ├── ArchiveRow.swift │ └── Archives.swift ├── Assets │ ├── Illustrations.xcassets │ │ ├── Contents.json │ │ ├── amelie.imageset │ │ │ ├── Contents.json │ │ │ ├── amelie@2x.png │ │ │ └── amelie@3x.png │ │ ├── bunny.imageset │ │ │ ├── Contents.json │ │ │ ├── bunny@2x.png │ │ │ └── bunny@3x.png │ │ ├── butterfly.imageset │ │ │ ├── Contents.json │ │ │ ├── butterfly@2x.png │ │ │ └── butterfly@3x.png │ │ ├── clown.imageset │ │ │ ├── Contents.json │ │ │ ├── clown@2x.png │ │ │ └── clown@3x.png │ │ ├── dandy.imageset │ │ │ ├── Contents.json │ │ │ ├── dandy@2x.png │ │ │ └── dandy@3x.png │ │ ├── elephant.imageset │ │ │ ├── Contents.json │ │ │ ├── elephant@2x.png │ │ │ └── elephant@3x.png │ │ ├── heroin.imageset │ │ │ ├── Contents.json │ │ │ ├── heroin@2x.png │ │ │ └── heroin@3x.png │ │ ├── hippo.imageset │ │ │ ├── Contents.json │ │ │ ├── hippo@2x.png │ │ │ └── hippo@3x.png │ │ ├── jack.imageset │ │ │ ├── Contents.json │ │ │ ├── jack@2x.png │ │ │ └── jack@3x.png │ │ ├── king.imageset │ │ │ ├── Contents.json │ │ │ ├── king@2x.png │ │ │ └── king@3x.png │ │ ├── koala.imageset │ │ │ ├── Contents.json │ │ │ ├── koala@2x.png │ │ │ └── koala@3x.png │ │ ├── lara.imageset │ │ │ ├── Contents.json │ │ │ ├── lara@2x.png │ │ │ └── lara@3x.png │ │ ├── lion.imageset │ │ │ ├── Contents.json │ │ │ ├── lion@2x.png │ │ │ └── lion@3x.png │ │ ├── lolita.imageset │ │ │ ├── Contents.json │ │ │ ├── lolita@2x.png │ │ │ └── lolita@3x.png │ │ ├── mentor.imageset │ │ │ ├── Contents.json │ │ │ ├── mentor@2x.png │ │ │ └── mentor@3x.png │ │ ├── nymph.imageset │ │ │ ├── Contents.json │ │ │ ├── nymph@2x.png │ │ │ └── nymph@3x.png │ │ ├── octopus.imageset │ │ │ ├── Contents.json │ │ │ ├── octopus@2x.png │ │ │ └── octopus@3x.png │ │ ├── otter.imageset │ │ │ ├── Contents.json │ │ │ ├── otter@2x.png │ │ │ └── otter@3x.png │ │ ├── panda.imageset │ │ │ ├── Contents.json │ │ │ ├── panda@2x.png │ │ │ └── panda@3x.png │ │ ├── penguin.imageset │ │ │ ├── Contents.json │ │ │ ├── penguin@2x.png │ │ │ └── penguin@3x.png │ │ ├── pierrot.imageset │ │ │ ├── Contents.json │ │ │ ├── pierrot@2x.png │ │ │ └── pierrot@3x.png │ │ ├── pirate.imageset │ │ │ ├── Contents.json │ │ │ ├── pirate@2x.png │ │ │ └── pirate@3x.png │ │ ├── robot.imageset │ │ │ ├── Contents.json │ │ │ ├── robot@2x.png │ │ │ └── robot@3x.png │ │ ├── santa.imageset │ │ │ ├── Contents.json │ │ │ ├── santa@2x.png │ │ │ └── santa@3x.png │ │ ├── starfish.imageset │ │ │ ├── Contents.json │ │ │ ├── starfish@2x.png │ │ │ └── starfish@3x.png │ │ ├── vampire.imageset │ │ │ ├── Contents.json │ │ │ ├── vampire@2x.png │ │ │ └── vampire@3x.png │ │ ├── warrior.imageset │ │ │ ├── Contents.json │ │ │ ├── warrior@2x.png │ │ │ └── warrior@3x.png │ │ └── whale.imageset │ │ │ ├── Contents.json │ │ │ ├── whale@2x.png │ │ │ └── whale@3x.png │ ├── MTColor.swift │ └── MTImage.swift ├── CompositionFeature │ ├── Composition.swift │ ├── CompositionLoader.swift │ ├── CompositionView.swift │ ├── ConfirmationDialog+NotEnoughTeams.swift │ ├── Standing.swift │ └── StandingView.swift ├── ImagePicker │ └── IllustrationPicker.swift ├── LoaderCore │ ├── ErrorCard.swift │ └── LoadingCard.swift ├── Models │ ├── PersistedPlayer.swift │ ├── PersistedScores.swift │ └── PersistedTeam.swift ├── PersistenceCore │ ├── MigrationV2V3+Sample.swift │ ├── MigrationV2toV3.swift │ ├── MigrationV3_0toV3_1+Sample.swift │ ├── MigrationV3_0toV3_1.swift │ ├── PersistenceError.swift │ ├── PlayerPersistence.swift │ ├── ScoresPersistence.swift │ └── TeamPersistence.swift ├── PlayersFeature │ ├── EditPlayerView.swift │ ├── Player.swift │ ├── PlayerRow.swift │ ├── PlayerState+Persistence.swift │ ├── RandomPlayer.swift │ └── ShufflePlayers.swift ├── ScoresFeature │ ├── Round │ │ ├── Binding+IntAsString.swift │ │ ├── Round+Persistence.swift │ │ ├── Round.swift │ │ └── RoundRow.swift │ ├── Score │ │ ├── Score+Persistence.swift │ │ ├── Score.swift │ │ └── ScoreRow.swift │ ├── Scoreboard │ │ ├── Scoreboard.swift │ │ ├── ScoreboardView.swift │ │ └── TotalScoresView.swift │ └── Scores │ │ ├── Scores+Persistence.swift │ │ ├── Scores.swift │ │ └── ScoresView.swift ├── SettingsFeature │ └── Settings.swift ├── StyleCore │ ├── DashedStyles.swift │ └── Splash.swift └── TeamsFeature │ ├── EditTeamView.swift │ ├── RandomTeam.swift │ ├── Team+Persistence.swift │ ├── Team.swift │ └── TeamRow.swift ├── Tests ├── AppFeatureTests │ └── AppTests.swift ├── ArchivesFeatureTests │ ├── ArchiveRowTests.swift │ └── ArchivesTests.swift ├── CompositionFeatureTests │ ├── CompositionLoaderTests.swift │ ├── CompositionTests.swift │ └── StandingTests.swift ├── ImagePickerTests │ └── IllustrationPickerTests.swift ├── PlayersFeatureTests │ └── PlayersTests.swift ├── ScoresFeatureTests │ ├── Round │ │ └── RoundTests.swift │ ├── Score │ │ └── ScoreTests.swift │ └── Scores │ │ └── ScoresTests.swift ├── SettingsFeatureTests │ └── SettingsTests.swift └── TeamsFeatureTests │ └── TeamsTests.swift ├── docs └── assets │ └── iPhoneScreenshots.jpeg └── privacy.md /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Swift Test 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | runs-on: macOS-12 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | 16 | - name: Build 17 | run: swift build 18 | 19 | - name: Run test 20 | run: swift test 21 | -------------------------------------------------------------------------------- /.github/workflows/xcodetest.yml: -------------------------------------------------------------------------------- 1 | name: Xcode Unit Test 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | runs-on: macOS-12 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | 16 | - name: Select Xcode 17 | run: sudo xcode-select -switch /Applications/Xcode_14.2.app 18 | 19 | - name: Xcode version 20 | run: /usr/bin/xcodebuild -version 21 | 22 | - name: List available devices 23 | run: xcrun simctl list 24 | 25 | - name: Xcode test on specific device 26 | working-directory: ./MixTeam 27 | run: xcodebuild clean test -scheme MixTeam -destination 'platform=iOS Simulator,name=iPhone SE (2nd generation)' 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | xcuserdata/ 2 | .DS_Store 3 | .build 4 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | trailing_comma: 2 | mandatory_comma: true 3 | disabled_rules: 4 | - identifier_name 5 | - multiple_closures_with_trailing_closure 6 | opt_in_rules: 7 | - force_unwrapping 8 | - implicitly_unwrapped_optional 9 | - implicit_return 10 | analyzer_rules: 11 | - explicit_self 12 | - unused_import 13 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Renaud Jenny 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 | -------------------------------------------------------------------------------- /MixTeam/.swiftlint.yml: -------------------------------------------------------------------------------- 1 | trailing_comma: 2 | mandatory_comma: true 3 | disabled_rules: 4 | - identifier_name 5 | - multiple_closures_with_trailing_closure 6 | opt_in_rules: 7 | - force_unwrapping 8 | - implicitly_unwrapped_optional 9 | - implicit_return 10 | analyzer_rules: 11 | - explicit_self 12 | - unused_import 13 | -------------------------------------------------------------------------------- /MixTeam/MixTeam.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /MixTeam/MixTeam.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /MixTeam/MixTeam.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "combine-schedulers", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/pointfreeco/combine-schedulers", 7 | "state" : { 8 | "revision" : "9dc9cbe4bc45c65164fa653a563d8d8db61b09bb", 9 | "version" : "1.0.0" 10 | } 11 | }, 12 | { 13 | "identity" : "renaudjennyaboutview", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/renaudjenny/RenaudJennyAboutView", 16 | "state" : { 17 | "branch" : "main", 18 | "revision" : "88b458bdb1cfed265a025f7d257d4fad4d5a5b24" 19 | } 20 | }, 21 | { 22 | "identity" : "swift-case-paths", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/pointfreeco/swift-case-paths", 25 | "state" : { 26 | "revision" : "b871e5ed11a23e52c2896a92ce2c829982ff8619", 27 | "version" : "1.4.2" 28 | } 29 | }, 30 | { 31 | "identity" : "swift-clocks", 32 | "kind" : "remoteSourceControl", 33 | "location" : "https://github.com/pointfreeco/swift-clocks", 34 | "state" : { 35 | "revision" : "a8421d68068d8f45fbceb418fbf22c5dad4afd33", 36 | "version" : "1.0.2" 37 | } 38 | }, 39 | { 40 | "identity" : "swift-collections", 41 | "kind" : "remoteSourceControl", 42 | "location" : "https://github.com/apple/swift-collections", 43 | "state" : { 44 | "revision" : "ee97538f5b81ae89698fd95938896dec5217b148", 45 | "version" : "1.1.1" 46 | } 47 | }, 48 | { 49 | "identity" : "swift-composable-architecture", 50 | "kind" : "remoteSourceControl", 51 | "location" : "https://github.com/pointfreeco/swift-composable-architecture", 52 | "state" : { 53 | "revision" : "1f952d8c69ace5e53bb69a218e6ed00e03a4695c", 54 | "version" : "1.11.2" 55 | } 56 | }, 57 | { 58 | "identity" : "swift-concurrency-extras", 59 | "kind" : "remoteSourceControl", 60 | "location" : "https://github.com/pointfreeco/swift-concurrency-extras", 61 | "state" : { 62 | "revision" : "bb5059bde9022d69ac516803f4f227d8ac967f71", 63 | "version" : "1.1.0" 64 | } 65 | }, 66 | { 67 | "identity" : "swift-custom-dump", 68 | "kind" : "remoteSourceControl", 69 | "location" : "https://github.com/pointfreeco/swift-custom-dump", 70 | "state" : { 71 | "revision" : "f01efb26f3a192a0e88dcdb7c3c391ec2fc25d9c", 72 | "version" : "1.3.0" 73 | } 74 | }, 75 | { 76 | "identity" : "swift-dependencies", 77 | "kind" : "remoteSourceControl", 78 | "location" : "https://github.com/pointfreeco/swift-dependencies", 79 | "state" : { 80 | "revision" : "00bc30ca03f98881329fab7f1bebef8eba472596", 81 | "version" : "1.3.1" 82 | } 83 | }, 84 | { 85 | "identity" : "swift-identified-collections", 86 | "kind" : "remoteSourceControl", 87 | "location" : "https://github.com/pointfreeco/swift-identified-collections", 88 | "state" : { 89 | "revision" : "2f5ab6e091dd032b63dacbda052405756010dc3b", 90 | "version" : "1.1.0" 91 | } 92 | }, 93 | { 94 | "identity" : "swift-perception", 95 | "kind" : "remoteSourceControl", 96 | "location" : "https://github.com/pointfreeco/swift-perception", 97 | "state" : { 98 | "revision" : "d3ab98dc2887d1cc3bed676f6fa354da4cb22b3c", 99 | "version" : "1.2.4" 100 | } 101 | }, 102 | { 103 | "identity" : "swift-syntax", 104 | "kind" : "remoteSourceControl", 105 | "location" : "https://github.com/apple/swift-syntax", 106 | "state" : { 107 | "revision" : "303e5c5c36d6a558407d364878df131c3546fad8", 108 | "version" : "510.0.2" 109 | } 110 | }, 111 | { 112 | "identity" : "swiftui-navigation", 113 | "kind" : "remoteSourceControl", 114 | "location" : "https://github.com/pointfreeco/swiftui-navigation", 115 | "state" : { 116 | "revision" : "b7c9a79f6f6b1fefb87d3e5a83a9c2fe7cdc9720", 117 | "version" : "1.5.0" 118 | } 119 | }, 120 | { 121 | "identity" : "xctest-dynamic-overlay", 122 | "kind" : "remoteSourceControl", 123 | "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", 124 | "state" : { 125 | "revision" : "6f30bdba373bbd7fbfe241dddd732651f2fbd1e2", 126 | "version" : "1.1.2" 127 | } 128 | } 129 | ], 130 | "version" : 2 131 | } 132 | -------------------------------------------------------------------------------- /MixTeam/MixTeam/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "131", 9 | "green" : "111", 10 | "red" : "110" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "221", 27 | "green" : "220", 28 | "red" : "218" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /MixTeam/MixTeam/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "mix-team-logo-1024.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | } 9 | ], 10 | "info" : { 11 | "author" : "xcode", 12 | "version" : 1 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /MixTeam/MixTeam/Assets.xcassets/AppIcon.appiconset/mix-team-logo-1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/MixTeam/MixTeam/Assets.xcassets/AppIcon.appiconset/mix-team-logo-1024.png -------------------------------------------------------------------------------- /MixTeam/MixTeam/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /MixTeam/MixTeam/Assets.xcassets/Logo.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "Mix-Team-New-Line-400x400.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "filename" : "Mix-Team-New-Line-600x600.png", 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /MixTeam/MixTeam/Assets.xcassets/Logo.imageset/Mix-Team-New-Line-400x400.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/MixTeam/MixTeam/Assets.xcassets/Logo.imageset/Mix-Team-New-Line-400x400.png -------------------------------------------------------------------------------- /MixTeam/MixTeam/Assets.xcassets/Logo.imageset/Mix-Team-New-Line-600x600.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/MixTeam/MixTeam/Assets.xcassets/Logo.imageset/Mix-Team-New-Line-600x600.png -------------------------------------------------------------------------------- /MixTeam/MixTeam/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | APPL 17 | CFBundleShortVersionString 18 | $(MARKETING_VERSION) 19 | CFBundleVersion 20 | 1 21 | LSMinimumSystemVersion 22 | 13 23 | LSRequiresIPhoneOS 24 | 25 | UIApplicationSceneManifest 26 | 27 | UIApplicationSupportsMultipleScenes 28 | 29 | 30 | UILaunchScreen 31 | 32 | UILaunchScreen 33 | 34 | 35 | UIRequiredDeviceCapabilities 36 | 37 | armv7 38 | 39 | UIStatusBarTintParameters 40 | 41 | UINavigationBar 42 | 43 | Style 44 | UIBarStyleDefault 45 | Translucent 46 | 47 | 48 | 49 | UISupportedInterfaceOrientations 50 | 51 | UIInterfaceOrientationPortrait 52 | UIInterfaceOrientationLandscapeLeft 53 | UIInterfaceOrientationLandscapeRight 54 | 55 | UISupportedInterfaceOrientations~ipad 56 | 57 | UIInterfaceOrientationPortrait 58 | UIInterfaceOrientationPortraitUpsideDown 59 | UIInterfaceOrientationLandscapeLeft 60 | UIInterfaceOrientationLandscapeRight 61 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /MixTeam/MixTeam/MixTeamApp.swift: -------------------------------------------------------------------------------- 1 | import AppFeature 2 | import PersistenceCore 3 | import SwiftUI 4 | 5 | @main 6 | struct MixTeamApp: SwiftUI.App { 7 | 8 | #if DEBUG 9 | // init() { 10 | // do { 11 | // addV2PersistedData() 12 | // try addV3_0toV3_1PersistedData() 13 | // } catch { 14 | // print(error) 15 | // } 16 | // } 17 | #endif 18 | 19 | var body: some Scene { 20 | WindowGroup { 21 | AppView(store: .live) 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /MixTeam/MixTeamUITests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /MixTeam/MixTeamUITests/MixTeamUITests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | class MixTeamUITests: XCTestCase { 4 | override func setUp() { 5 | super.setUp() 6 | continueAfterFailure = false 7 | XCUIApplication().launch() 8 | } 9 | 10 | func testAddTeam() { 11 | let app = XCUIApplication() 12 | 13 | app.swipeUp() 14 | app.swipeUp() 15 | 16 | app.buttons["Add a new Team"].tap() 17 | 18 | app.buttons["Lilac Elephant"].tap() 19 | 20 | app.buttons["koala"].tap() 21 | 22 | app.buttons["strawberry"].tap() 23 | 24 | let yourTeamNameTextField = app.textFields["Edit"] 25 | yourTeamNameTextField.tap() 26 | 27 | for _ in 0..<"Lilac Elephant".count { 28 | yourTeamNameTextField.typeText(XCUIKeyboardKey.delete.rawValue) 29 | } 30 | 31 | yourTeamNameTextField.typeText("Strawberry Koala\n") 32 | app.buttons["MixTeam"].tap() 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /MixTeam/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.7 2 | // Leave blank. This is only here so that Xcode doesn't display it. 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "app", 7 | products: [], 8 | targets: [] 9 | ) 10 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "combine-schedulers", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/pointfreeco/combine-schedulers", 7 | "state" : { 8 | "revision" : "882ac01eb7ef9e36d4467eb4b1151e74fcef85ab", 9 | "version" : "0.9.1" 10 | } 11 | }, 12 | { 13 | "identity" : "renaudjennyaboutview", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/renaudjenny/RenaudJennyAboutView", 16 | "state" : { 17 | "branch" : "main", 18 | "revision" : "88b458bdb1cfed265a025f7d257d4fad4d5a5b24" 19 | } 20 | }, 21 | { 22 | "identity" : "swift-case-paths", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/pointfreeco/swift-case-paths", 25 | "state" : { 26 | "revision" : "870133b7b2387df136ad301ec67b2e864b51dda1", 27 | "version" : "0.14.0" 28 | } 29 | }, 30 | { 31 | "identity" : "swift-clocks", 32 | "kind" : "remoteSourceControl", 33 | "location" : "https://github.com/pointfreeco/swift-clocks", 34 | "state" : { 35 | "revision" : "20b25ca0dd88ebfb9111ec937814ddc5a8880172", 36 | "version" : "0.2.0" 37 | } 38 | }, 39 | { 40 | "identity" : "swift-collections", 41 | "kind" : "remoteSourceControl", 42 | "location" : "https://github.com/apple/swift-collections", 43 | "state" : { 44 | "revision" : "937e904258d22af6e447a0b72c0bc67583ef64a2", 45 | "version" : "1.0.4" 46 | } 47 | }, 48 | { 49 | "identity" : "swift-composable-architecture", 50 | "kind" : "remoteSourceControl", 51 | "location" : "https://github.com/pointfreeco/swift-composable-architecture", 52 | "state" : { 53 | "revision" : "3e8eee1efe99d06e99426d421733b858b332186b", 54 | "version" : "0.52.0" 55 | } 56 | }, 57 | { 58 | "identity" : "swift-custom-dump", 59 | "kind" : "remoteSourceControl", 60 | "location" : "https://github.com/pointfreeco/swift-custom-dump", 61 | "state" : { 62 | "revision" : "de8ba65649e7ee317b9daf27dd5eebf34bd4be57", 63 | "version" : "0.9.1" 64 | } 65 | }, 66 | { 67 | "identity" : "swift-dependencies", 68 | "kind" : "remoteSourceControl", 69 | "location" : "https://github.com/pointfreeco/swift-dependencies", 70 | "state" : { 71 | "revision" : "6bb1034e8a1bfbf46dfb766b6c09b7b17e1cba10", 72 | "version" : "0.2.0" 73 | } 74 | }, 75 | { 76 | "identity" : "swift-identified-collections", 77 | "kind" : "remoteSourceControl", 78 | "location" : "https://github.com/pointfreeco/swift-identified-collections", 79 | "state" : { 80 | "revision" : "ad3932d28c2e0a009a0167089619526709ef6497", 81 | "version" : "0.7.0" 82 | } 83 | }, 84 | { 85 | "identity" : "swiftui-navigation", 86 | "kind" : "remoteSourceControl", 87 | "location" : "https://github.com/pointfreeco/swiftui-navigation", 88 | "state" : { 89 | "revision" : "0a0e1b321d70ee6a464ecfe6b0136d9eff77ebfc", 90 | "version" : "0.7.0" 91 | } 92 | }, 93 | { 94 | "identity" : "xctest-dynamic-overlay", 95 | "kind" : "remoteSourceControl", 96 | "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", 97 | "state" : { 98 | "revision" : "62041e6016a30f56952f5d7d3f12a3fd7029e1cd", 99 | "version" : "0.8.3" 100 | } 101 | } 102 | ], 103 | "version" : 2 104 | } 105 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.10 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "MixTeam", 8 | platforms: [.iOS(.v17), .macOS(.v14)], 9 | products: [ 10 | .library(name: "AppFeature", targets: ["AppFeature"]), 11 | .library(name: "ArchivesFeature", targets: ["ArchivesFeature"]), 12 | .library(name: "Assets", targets: ["Assets"]), 13 | .library(name: "CompositionFeature", targets: ["CompositionFeature"]), 14 | .library(name: "ImagePicker", targets: ["ImagePicker"]), 15 | .library(name: "LoaderCore", targets: ["LoaderCore"]), 16 | .library(name: "Models", targets: ["Models"]), 17 | .library(name: "PersistenceCore", targets: ["PersistenceCore"]), 18 | .library(name: "PlayersFeature", targets: ["PlayersFeature"]), 19 | .library(name: "ScoresFeature", targets: ["ScoresFeature"]), 20 | .library(name: "SettingsFeature", targets: ["SettingsFeature"]), 21 | .library(name: "StyleCore", targets: ["StyleCore"]), 22 | .library(name: "TeamsFeature", targets: ["TeamsFeature"]), 23 | ], 24 | dependencies: [ 25 | .package(url: "https://github.com/pointfreeco/swift-composable-architecture", from: "1.11.2"), 26 | .package(url: "https://github.com/renaudjenny/RenaudJennyAboutView", branch: "main"), 27 | ], 28 | targets: [ 29 | .target( 30 | name: "AppFeature", 31 | dependencies: [ 32 | .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), 33 | "CompositionFeature", 34 | "PersistenceCore", 35 | "ScoresFeature", 36 | "SettingsFeature", 37 | ] 38 | ), 39 | .testTarget(name: "AppFeatureTests", dependencies: ["AppFeature"]), 40 | .target( 41 | name: "ArchivesFeature", 42 | dependencies: [ 43 | .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), 44 | "LoaderCore", 45 | "TeamsFeature", 46 | ] 47 | ), 48 | .testTarget(name: "ArchivesFeatureTests", dependencies: ["ArchivesFeature"]), 49 | .target(name: "Assets", dependencies: [], resources: [.process("Illustrations.xcassets")]), 50 | .target( 51 | name: "CompositionFeature", 52 | dependencies: [ 53 | .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), 54 | "LoaderCore", 55 | "Models", 56 | "PersistenceCore", 57 | "PlayersFeature", 58 | "StyleCore", 59 | "TeamsFeature", 60 | ] 61 | ), 62 | .testTarget(name: "CompositionFeatureTests", dependencies: ["CompositionFeature"]), 63 | .target( 64 | name: "ImagePicker", 65 | dependencies: [ 66 | "Assets", 67 | .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), 68 | ] 69 | ), 70 | .testTarget(name: "ImagePickerTests", dependencies: ["ImagePicker"]), 71 | .target( 72 | name: "LoaderCore", 73 | dependencies: [ 74 | "Assets", 75 | .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), 76 | "StyleCore", 77 | ] 78 | ), 79 | .target(name: "Models", dependencies: [ 80 | .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), 81 | "Assets", 82 | ]), 83 | .target( 84 | name: "PersistenceCore", 85 | dependencies: [ 86 | .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), 87 | "Models", 88 | ] 89 | ), 90 | .target( 91 | name: "PlayersFeature", 92 | dependencies: [ 93 | "Assets", 94 | .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), 95 | "ImagePicker", 96 | "Models", 97 | "PersistenceCore", 98 | "StyleCore", 99 | ] 100 | ), 101 | .testTarget(name: "PlayersFeatureTests", dependencies: ["PlayersFeature"]), 102 | .target( 103 | name: "ScoresFeature", 104 | dependencies: [ 105 | .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), 106 | "PersistenceCore", 107 | "TeamsFeature", 108 | ] 109 | ), 110 | .testTarget(name: "ScoresFeatureTests", dependencies: ["ScoresFeature"]), 111 | .target( 112 | name: "SettingsFeature", 113 | dependencies: [ 114 | "ArchivesFeature", 115 | .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), 116 | "PersistenceCore", 117 | .product(name: "RenaudJennyAboutView", package: "RenaudJennyAboutView"), 118 | ] 119 | ), 120 | .testTarget(name: "SettingsFeatureTests", dependencies: ["SettingsFeature"]), 121 | .target(name: "StyleCore", dependencies: ["Assets"]), 122 | .target( 123 | name: "TeamsFeature", 124 | dependencies: [ 125 | "Assets", 126 | .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), 127 | "ImagePicker", 128 | "PlayersFeature", 129 | "PersistenceCore", 130 | ] 131 | ), 132 | .testTarget(name: "TeamsFeatureTests", dependencies: ["TeamsFeature"]), 133 | ] 134 | ) 135 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MixTeam 2 | 3 | [![Xcode Unit Test](https://github.com/renaudjenny/MixTeam/actions/workflows/xcodetest.yml/badge.svg)](https://github.com/renaudjenny/MixTeam/actions/workflows/xcodetest.yml) 4 | [![Swift Test](https://github.com/renaudjenny/MixTeam/actions/workflows/test.yml/badge.svg)](https://github.com/renaudjenny/MixTeam/actions/workflows/test.yml) 5 | 6 | >MixTeam is an old project started in late 2017 and maintained just for my personal usage until mid 2018. 7 | >I want now to migrate this project to modern Swift 5 and SwiftUI, and also publish it. 8 | 9 | 📲 App Store: https://apps.apple.com/fr/app/mixteam/id1526493495 10 | 11 | MixTeam will help you organise your boardgames or team sport match. 12 | 13 | * 🎳 Add Teams with different mascots and colours (Strawberry Koala 🍓🐨, Bluejeans Panda 👖🐼, etc.) 14 | * 🤾‍♀️ Add Players, you can even choose their avatar among some cute illustrations 15 | * 🎲 Tap on the 🔀 **Mix Team** button, and Players will randomly put into teams 16 | * 🗒️ Needs to keep track of scores? There is a built in scores board 17 | * 📈 See the score progress between each rounds 18 | * ⚽️ Simply as that, now just enjoy your game! 19 | 20 | ## Screenshots 21 | 22 | ![Screenshots of the application from an iPhone](docs/assets/iPhoneScreenshots.jpeg) 23 | 24 | ## Icons and illustrations 25 | 26 | All artistic work has been made by [Mathilde Seyller](https://instagram.com/myobriel). Go follow her! 27 | -------------------------------------------------------------------------------- /Sources/AppFeature/App+Preview.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import PersistenceCore 3 | 4 | public extension Store where State == App.State, Action == App.Action { 5 | static var live: Store { 6 | Store(initialState: App.State()) { App() } 7 | } 8 | 9 | #if DEBUG 10 | static var preview: Store { 11 | Store(initialState: .example) { 12 | App().dependency(\.legacyPlayerPersistence, .preview) 13 | } 14 | } 15 | 16 | static var withError: Store { 17 | Store(initialState: App.State()) { 18 | App() 19 | .dependency(\.legacyPlayerPersistence, .preview) 20 | .dependency(\.legacyTeamPersistence.load, { 21 | try await Task.sleep(nanoseconds: 500_000_000) 22 | throw PersistenceError.notFound 23 | }) 24 | } 25 | } 26 | #endif 27 | } 28 | -------------------------------------------------------------------------------- /Sources/AppFeature/App.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import CompositionFeature 3 | import PersistenceCore 4 | import ScoresFeature 5 | import SettingsFeature 6 | import SwiftUI 7 | 8 | public typealias Settings = SettingsFeature.Settings 9 | 10 | @Reducer 11 | public struct App { 12 | @ObservableState 13 | public struct State: Equatable { 14 | public var compositionLoader: CompositionLoader.State = .loadingCard 15 | public var scoreboard: Scoreboard.State = .loadingCard 16 | public var settings = Settings.State() 17 | 18 | public var selectedTab: Tab = .compositionLoader 19 | } 20 | 21 | public enum Tab: Equatable { 22 | case compositionLoader 23 | case scoreboard 24 | case settings 25 | } 26 | 27 | public enum Action: Equatable { 28 | case task 29 | case tabSelected(Tab) 30 | case compositionLoader(CompositionLoader.Action) 31 | case scoreboard(Scoreboard.Action) 32 | case settings(Settings.Action) 33 | } 34 | 35 | public init() {} 36 | 37 | @Dependency(\.migration) var migration 38 | 39 | public var body: some Reducer { 40 | Scope(state: \.compositionLoader, action: \.compositionLoader) { 41 | CompositionLoader() 42 | } 43 | Scope(state: \.scoreboard, action: \.scoreboard) { 44 | Scoreboard() 45 | } 46 | Scope(state: \.settings, action: \.settings) { 47 | Settings() 48 | } 49 | Reduce { state, action in 50 | switch action { 51 | case .task: 52 | return .run { _ in 53 | try await migration.v2toV3() 54 | try await migration.v3_0toV3_1() 55 | } 56 | case let .tabSelected(tab): 57 | state.selectedTab = tab 58 | return .none 59 | case .compositionLoader: 60 | return .none 61 | case .scoreboard: 62 | return .none 63 | case .settings: 64 | return .none 65 | } 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Sources/AppFeature/AppView.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import CompositionFeature 3 | import ScoresFeature 4 | import SettingsFeature 5 | import SwiftUI 6 | 7 | public struct AppView: View { 8 | let store: StoreOf 9 | @Environment(\.colorScheme) private var colorScheme 10 | private let buttonSize = CGSize(width: 60, height: 60) 11 | 12 | public init(store: StoreOf) { 13 | self.store = store 14 | } 15 | 16 | public var body: some View { 17 | WithViewStore(store, observe: \.selectedTab) { viewStore in 18 | TabView(selection: viewStore.binding(send: App.Action.tabSelected)) { 19 | CompositionLoaderView( 20 | store: store.scope(state: \.compositionLoader, action: \.compositionLoader) 21 | ) 22 | .tag(App.Tab.compositionLoader) 23 | ScoreboardView( 24 | store: store.scope(state: \.scoreboard, action: \.scoreboard) 25 | ) 26 | .tag(App.Tab.scoreboard) 27 | SettingsView( 28 | store: store.scope(state: \.settings, action: \.settings) 29 | ) 30 | .tag(App.Tab.settings) 31 | } 32 | .task { viewStore.send(.task) } 33 | #if os(iOS) 34 | .navigationViewStyle(.stack) 35 | #endif 36 | } 37 | } 38 | } 39 | 40 | #if DEBUG 41 | struct AppView_Previews: PreviewProvider { 42 | static var previews: some View { 43 | AppView(store: .preview) 44 | .previewDisplayName("Happy path") 45 | AppView(store: .withError) 46 | .previewDisplayName("With Error") 47 | } 48 | } 49 | 50 | public extension App.State { 51 | static var example: Self { 52 | Self(compositionLoader: .loaded(Composition.State( 53 | teams: .example, 54 | standing: .example 55 | ))) 56 | } 57 | } 58 | #endif 59 | -------------------------------------------------------------------------------- /Sources/ArchivesFeature/ArchiveRow.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import SwiftUI 3 | import TeamsFeature 4 | 5 | @Reducer 6 | public struct ArchiveRow { 7 | @ObservableState 8 | public struct State: Equatable, Identifiable { 9 | public var team: Team.State 10 | @Presents public var deleteConfirmationDialog: ConfirmationDialogState? 11 | public var id: Team.State.ID { team.id } 12 | 13 | public init(team: Team.State) { 14 | self.team = team 15 | } 16 | } 17 | 18 | public enum Action: Equatable { 19 | case unarchive 20 | case remove 21 | case confirmRemove(PresentationAction) 22 | case cancelRemove(PresentationAction) 23 | } 24 | 25 | @Dependency(\.legacyTeamPersistence) var legacyTeamPersistence 26 | 27 | public init() {} 28 | 29 | public var body: some Reducer { 30 | Reduce { state, action in 31 | switch action { 32 | case .unarchive: 33 | state.team.isArchived = false 34 | return .run { [team = state.team] _ in try await legacyTeamPersistence.updateOrAppend(team.persisted) } 35 | case .remove: 36 | state.deleteConfirmationDialog = .removeTeam 37 | return .none 38 | case .confirmRemove: 39 | state.deleteConfirmationDialog = nil 40 | return .run { [team = state.team] _ in try await legacyTeamPersistence.remove(team.persisted) } 41 | case .cancelRemove: 42 | state.deleteConfirmationDialog = nil 43 | return .none 44 | } 45 | } 46 | } 47 | } 48 | 49 | extension ConfirmationDialogState where Action == ArchiveRow.Action { 50 | static var removeTeam: Self { 51 | ConfirmationDialogState(titleVisibility: .visible) { 52 | TextState("Are you sure to delete this team?") 53 | } actions: { 54 | ButtonState.cancel(TextState("Cancel")) 55 | ButtonState.destructive(TextState("Remove")) 56 | } message: { 57 | TextState("Rounds in scoreboard with this team will be automatically removed") 58 | } 59 | } 60 | } 61 | 62 | public struct ArchiveRowView: View { 63 | @Bindable var store: StoreOf 64 | 65 | public init(store: StoreOf) { 66 | self.store = store 67 | } 68 | 69 | public var body: some View { 70 | HStack { 71 | Text(store.team.name) 72 | Spacer() 73 | Menu("Edit") { 74 | Button { store.send(.unarchive) } label: { 75 | Label("Unarchive", systemImage: "tray.and.arrow.up") 76 | } 77 | Button(role: .destructive) { store.send(.remove) } label: { 78 | Label("Delete...", systemImage: "trash") 79 | } 80 | } 81 | } 82 | .confirmationDialog(store: store.scope( 83 | state: \.$deleteConfirmationDialog, 84 | action: \.cancelRemove 85 | )) 86 | } 87 | } 88 | 89 | #if DEBUG 90 | struct ArchiveRowView_Previews: PreviewProvider { 91 | static var previews: some View { 92 | List { 93 | ArchiveRowView(store: Store(initialState: ArchiveRow.State(team: .previewArchived)) { ArchiveRow() }) 94 | } 95 | } 96 | } 97 | 98 | public extension Team.State { 99 | static var previewArchived: Self { 100 | var team: Self = .preview 101 | team.isArchived = true 102 | return team 103 | } 104 | } 105 | #endif 106 | -------------------------------------------------------------------------------- /Sources/ArchivesFeature/Archives.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import LoaderCore 3 | import Models 4 | import PersistenceCore 5 | import SwiftUI 6 | import TeamsFeature 7 | 8 | @Reducer 9 | public struct Archives { 10 | @ObservableState 11 | public enum State: Equatable { 12 | case loadingCard 13 | case loaded(rows: ArchiveRows.State) 14 | case errorCard(ErrorCard.State) 15 | } 16 | 17 | public enum Action: Equatable { 18 | case update(TaskResult>) 19 | case loadingCard(LoadingCard.Action) 20 | case archiveRow(ArchiveRows.Action) 21 | case errorCard(ErrorCard.Action) 22 | } 23 | 24 | @Dependency(\.legacyTeamPersistence) var legacyTeamPersistence 25 | 26 | public init() {} 27 | 28 | public var body: some Reducer { 29 | Reduce { state, action in 30 | switch action { 31 | case let .update(result): 32 | switch result { 33 | case let .success(result): 34 | state = .loaded(rows: ArchiveRows.State(rows: IdentifiedArrayOf( 35 | uniqueElements: result.filter(\.isArchived).map(ArchiveRow.State.init) 36 | ))) 37 | return .none 38 | case let .failure(error): 39 | state = .errorCard(ErrorCard.State(description: error.localizedDescription)) 40 | return .none 41 | } 42 | case .loadingCard: 43 | return load(state: &state).concatenate(with: .run { send in 44 | for try await teams in legacyTeamPersistence.publisher() { 45 | await send(.update(TaskResult { try await teams.states })) 46 | } 47 | } catch: { error, send in 48 | await send(.update(TaskResult { throw error })) 49 | }) 50 | case .archiveRow: 51 | return .none 52 | case .errorCard(.reload): 53 | return load(state: &state) 54 | } 55 | } 56 | .ifCaseLet(\.loadingCard, action: \.loadingCard) { 57 | LoadingCard() 58 | } 59 | .ifCaseLet(\.loaded, action: \.archiveRow) { 60 | ArchiveRows() 61 | } 62 | .ifCaseLet(\.errorCard, action: \.errorCard) { 63 | ErrorCard() 64 | } 65 | } 66 | 67 | private func load(state: inout State) -> Effect { 68 | state = .loadingCard 69 | return .run { send in await send(.update(TaskResult { try await legacyTeamPersistence.load().states })) } 70 | } 71 | } 72 | 73 | public struct ArchivesView: View { 74 | let store: StoreOf 75 | 76 | public init(store: StoreOf) { 77 | self.store = store 78 | } 79 | 80 | public var body: some View { 81 | switch store.state { 82 | case .loadingCard: 83 | if let store = store.scope(state: \.loadingCard, action: \.loadingCard) { 84 | LoadingCardView(store: store) 85 | } 86 | case .loaded: 87 | if let store = store.scope(state: \.loaded, action: \.archiveRow) { 88 | if store.rows.isEmpty { 89 | Text("No archived teams") 90 | } else { 91 | List { 92 | Section("Teams") { 93 | ForEachStore(store.scope(state: \.rows, action: \.rows), content: ArchiveRowView.init) 94 | } 95 | } 96 | } 97 | } 98 | case .errorCard: 99 | if let store = store.scope(state: \.errorCard, action: \.errorCard) { 100 | ErrorCardView(store: store) 101 | } 102 | } 103 | } 104 | } 105 | 106 | @Reducer 107 | public struct ArchiveRows { 108 | @ObservableState 109 | public struct State: Equatable { 110 | var rows: IdentifiedArrayOf 111 | } 112 | 113 | public enum Action: Equatable { 114 | case rows(IdentifiedActionOf) 115 | } 116 | 117 | public var body: some Reducer { 118 | EmptyReducer().forEach(\.rows, action: \.rows) { 119 | ArchiveRow() 120 | } 121 | } 122 | } 123 | 124 | #Preview { 125 | ArchivesView(store: Store(initialState: Archives.State.loaded(rows: ArchiveRows.State(rows: []))) { Archives() }) 126 | } 127 | 128 | #Preview("Archives With Teams and Players") { 129 | ArchivesView(store: Store(initialState: Archives.State.loaded( 130 | rows: ArchiveRows.State(rows: IdentifiedArrayOf(uniqueElements: IdentifiedArrayOf.example.map { 131 | var team = $0 132 | team.isArchived = true 133 | return ArchiveRow.State(team: team) 134 | })) 135 | )) { Archives() }) 136 | } 137 | 138 | #Preview("Archives With Error") { 139 | ArchivesView(store: Store(initialState: .loadingCard) { 140 | Archives() 141 | .dependency(\.legacyTeamPersistence.load, { 142 | try await Task.sleep(nanoseconds: 1_000_000_000 * 2) 143 | throw PersistenceError.notFound 144 | }) 145 | // TODO: Fix the missing important if necessary (with Shared API, it shouldn't be) 146 | // .dependency(\.teamPersistence.publisher, { 147 | // Fail(error: PersistenceError.notFound).eraseToAnyPublisher().values 148 | // }) 149 | }) 150 | } 151 | -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/amelie.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "amelie@2x.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "filename" : "amelie@3x.png", 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | }, 22 | "properties" : { 23 | "template-rendering-intent" : "template" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/amelie.imageset/amelie@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/amelie.imageset/amelie@2x.png -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/amelie.imageset/amelie@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/amelie.imageset/amelie@3x.png -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/bunny.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "bunny@2x.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "filename" : "bunny@3x.png", 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | }, 22 | "properties" : { 23 | "template-rendering-intent" : "template" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/bunny.imageset/bunny@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/bunny.imageset/bunny@2x.png -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/bunny.imageset/bunny@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/bunny.imageset/bunny@3x.png -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/butterfly.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "butterfly@2x.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "filename" : "butterfly@3x.png", 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | }, 22 | "properties" : { 23 | "template-rendering-intent" : "template" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/butterfly.imageset/butterfly@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/butterfly.imageset/butterfly@2x.png -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/butterfly.imageset/butterfly@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/butterfly.imageset/butterfly@3x.png -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/clown.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "clown@2x.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "filename" : "clown@3x.png", 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | }, 22 | "properties" : { 23 | "template-rendering-intent" : "template" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/clown.imageset/clown@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/clown.imageset/clown@2x.png -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/clown.imageset/clown@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/clown.imageset/clown@3x.png -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/dandy.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "dandy@2x.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "filename" : "dandy@3x.png", 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | }, 22 | "properties" : { 23 | "template-rendering-intent" : "template" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/dandy.imageset/dandy@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/dandy.imageset/dandy@2x.png -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/dandy.imageset/dandy@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/dandy.imageset/dandy@3x.png -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/elephant.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "elephant@2x.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "filename" : "elephant@3x.png", 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | }, 22 | "properties" : { 23 | "template-rendering-intent" : "template" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/elephant.imageset/elephant@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/elephant.imageset/elephant@2x.png -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/elephant.imageset/elephant@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/elephant.imageset/elephant@3x.png -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/heroin.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "heroin@2x.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "filename" : "heroin@3x.png", 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | }, 22 | "properties" : { 23 | "template-rendering-intent" : "template" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/heroin.imageset/heroin@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/heroin.imageset/heroin@2x.png -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/heroin.imageset/heroin@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/heroin.imageset/heroin@3x.png -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/hippo.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "hippo@2x.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "filename" : "hippo@3x.png", 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | }, 22 | "properties" : { 23 | "template-rendering-intent" : "template" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/hippo.imageset/hippo@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/hippo.imageset/hippo@2x.png -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/hippo.imageset/hippo@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/hippo.imageset/hippo@3x.png -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/jack.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "jack@2x.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "filename" : "jack@3x.png", 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | }, 22 | "properties" : { 23 | "template-rendering-intent" : "template" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/jack.imageset/jack@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/jack.imageset/jack@2x.png -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/jack.imageset/jack@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/jack.imageset/jack@3x.png -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/king.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "king@2x.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "filename" : "king@3x.png", 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | }, 22 | "properties" : { 23 | "template-rendering-intent" : "template" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/king.imageset/king@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/king.imageset/king@2x.png -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/king.imageset/king@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/king.imageset/king@3x.png -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/koala.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "koala@2x.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "filename" : "koala@3x.png", 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | }, 22 | "properties" : { 23 | "template-rendering-intent" : "template" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/koala.imageset/koala@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/koala.imageset/koala@2x.png -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/koala.imageset/koala@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/koala.imageset/koala@3x.png -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/lara.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "lara@2x.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "filename" : "lara@3x.png", 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | }, 22 | "properties" : { 23 | "template-rendering-intent" : "template" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/lara.imageset/lara@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/lara.imageset/lara@2x.png -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/lara.imageset/lara@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/lara.imageset/lara@3x.png -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/lion.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "lion@2x.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "filename" : "lion@3x.png", 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | }, 22 | "properties" : { 23 | "template-rendering-intent" : "template" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/lion.imageset/lion@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/lion.imageset/lion@2x.png -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/lion.imageset/lion@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/lion.imageset/lion@3x.png -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/lolita.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "lolita@2x.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "filename" : "lolita@3x.png", 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | }, 22 | "properties" : { 23 | "template-rendering-intent" : "template" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/lolita.imageset/lolita@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/lolita.imageset/lolita@2x.png -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/lolita.imageset/lolita@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/lolita.imageset/lolita@3x.png -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/mentor.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "mentor@2x.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "filename" : "mentor@3x.png", 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | }, 22 | "properties" : { 23 | "template-rendering-intent" : "template" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/mentor.imageset/mentor@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/mentor.imageset/mentor@2x.png -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/mentor.imageset/mentor@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/mentor.imageset/mentor@3x.png -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/nymph.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "nymph@2x.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "filename" : "nymph@3x.png", 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | }, 22 | "properties" : { 23 | "template-rendering-intent" : "template" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/nymph.imageset/nymph@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/nymph.imageset/nymph@2x.png -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/nymph.imageset/nymph@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/nymph.imageset/nymph@3x.png -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/octopus.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "octopus@2x.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "filename" : "octopus@3x.png", 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | }, 22 | "properties" : { 23 | "template-rendering-intent" : "template" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/octopus.imageset/octopus@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/octopus.imageset/octopus@2x.png -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/octopus.imageset/octopus@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/octopus.imageset/octopus@3x.png -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/otter.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "otter@2x.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "filename" : "otter@3x.png", 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | }, 22 | "properties" : { 23 | "template-rendering-intent" : "template" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/otter.imageset/otter@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/otter.imageset/otter@2x.png -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/otter.imageset/otter@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/otter.imageset/otter@3x.png -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/panda.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "panda@2x.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "filename" : "panda@3x.png", 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | }, 22 | "properties" : { 23 | "template-rendering-intent" : "template" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/panda.imageset/panda@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/panda.imageset/panda@2x.png -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/panda.imageset/panda@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/panda.imageset/panda@3x.png -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/penguin.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "penguin@2x.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "filename" : "penguin@3x.png", 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | }, 22 | "properties" : { 23 | "template-rendering-intent" : "template" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/penguin.imageset/penguin@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/penguin.imageset/penguin@2x.png -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/penguin.imageset/penguin@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/penguin.imageset/penguin@3x.png -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/pierrot.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "pierrot@2x.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "filename" : "pierrot@3x.png", 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | }, 22 | "properties" : { 23 | "template-rendering-intent" : "template" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/pierrot.imageset/pierrot@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/pierrot.imageset/pierrot@2x.png -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/pierrot.imageset/pierrot@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/pierrot.imageset/pierrot@3x.png -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/pirate.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "pirate@2x.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "filename" : "pirate@3x.png", 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | }, 22 | "properties" : { 23 | "template-rendering-intent" : "template" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/pirate.imageset/pirate@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/pirate.imageset/pirate@2x.png -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/pirate.imageset/pirate@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/pirate.imageset/pirate@3x.png -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/robot.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "robot@2x.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "filename" : "robot@3x.png", 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | }, 22 | "properties" : { 23 | "template-rendering-intent" : "template" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/robot.imageset/robot@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/robot.imageset/robot@2x.png -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/robot.imageset/robot@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/robot.imageset/robot@3x.png -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/santa.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "santa@2x.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "filename" : "santa@3x.png", 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | }, 22 | "properties" : { 23 | "template-rendering-intent" : "template" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/santa.imageset/santa@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/santa.imageset/santa@2x.png -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/santa.imageset/santa@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/santa.imageset/santa@3x.png -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/starfish.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "starfish@2x.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "filename" : "starfish@3x.png", 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | }, 22 | "properties" : { 23 | "template-rendering-intent" : "template" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/starfish.imageset/starfish@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/starfish.imageset/starfish@2x.png -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/starfish.imageset/starfish@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/starfish.imageset/starfish@3x.png -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/vampire.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "vampire@2x.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "filename" : "vampire@3x.png", 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | }, 22 | "properties" : { 23 | "template-rendering-intent" : "template" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/vampire.imageset/vampire@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/vampire.imageset/vampire@2x.png -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/vampire.imageset/vampire@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/vampire.imageset/vampire@3x.png -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/warrior.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "warrior@2x.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "filename" : "warrior@3x.png", 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | }, 22 | "properties" : { 23 | "template-rendering-intent" : "template" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/warrior.imageset/warrior@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/warrior.imageset/warrior@2x.png -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/warrior.imageset/warrior@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/warrior.imageset/warrior@3x.png -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/whale.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "whale@2x.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "filename" : "whale@3x.png", 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | }, 22 | "properties" : { 23 | "template-rendering-intent" : "template" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/whale.imageset/whale@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/whale.imageset/whale@2x.png -------------------------------------------------------------------------------- /Sources/Assets/Illustrations.xcassets/whale.imageset/whale@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/Sources/Assets/Illustrations.xcassets/whale.imageset/whale@3x.png -------------------------------------------------------------------------------- /Sources/Assets/MTColor.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | private struct ColorDuo { 4 | let foreground: Color 5 | let background: Color 6 | } 7 | 8 | public enum MTColor: String, Codable, CaseIterable, Identifiable { 9 | case leather 10 | case strawberry 11 | case lilac 12 | case bluejeans 13 | case conifer 14 | case duck 15 | case peach 16 | case aluminium 17 | 18 | private var duo: ColorDuo { 19 | switch self { 20 | case .leather: return ColorDuo( 21 | foreground: Color(hue: 36/360, saturation: 73/100, lightness: 29/100), 22 | background: Color(hue: 48/360, saturation: 71/100, lightness: 75/100) 23 | ) 24 | case .strawberry: return ColorDuo( 25 | foreground: Color(hue: 335/360, saturation: 62/100, lightness: 42/100), 26 | background: Color(hue: 333/360, saturation: 43/100, lightness: 88/100) 27 | ) 28 | case .lilac: return ColorDuo( 29 | foreground: Color(hue: 274/360, saturation: 100/100, lightness: 28/100), 30 | background: Color(hue: 275/360, saturation: 47/100, lightness: 80/100) 31 | ) 32 | case .bluejeans: return ColorDuo( 33 | foreground: Color(hue: 216/360, saturation: 100/100, lightness: 36/100), 34 | background: Color(hue: 213/360, saturation: 53/100, lightness: 81/100) 35 | ) 36 | case .conifer: return ColorDuo( 37 | foreground: Color(hue: 151/360, saturation: 100/100, lightness: 23/100), 38 | background: Color(hue: 143/360, saturation: 34/100, lightness: 75/100) 39 | ) 40 | case .duck: return ColorDuo( 41 | foreground: Color(hue: 191/360, saturation: 100/100, lightness: 19/100), 42 | background: Color(hue: 190/360, saturation: 54/100, lightness: 74/100) 43 | ) 44 | case .peach: return ColorDuo( 45 | foreground: Color(hue: 15/360, saturation: 88/100, lightness: 45/100), 46 | background: Color(hue: 12/360, saturation: 98/100, lightness: 83/100) 47 | ) 48 | case .aluminium: return ColorDuo( 49 | foreground: Color(hue: 237/360, saturation: 9/100, lightness: 47/100), 50 | background: Color(hue: 200/360, saturation: 4/100, lightness: 86/100) 51 | ) 52 | } 53 | } 54 | 55 | public func foregroundColor(scheme: ColorScheme) -> Color { 56 | scheme == .dark ? self.duo.background : self.duo.foreground 57 | } 58 | public func backgroundColor(scheme: ColorScheme) -> Color { 59 | scheme == .dark ? self.duo.foreground : self.duo.background 60 | } 61 | 62 | public var id: Int { hashValue } 63 | } 64 | 65 | private extension Color { 66 | init(hue: Double, saturation: Double, lightness: Double, opacity: Double = 1) { 67 | let brightness = lightness + saturation * min(lightness, 1 - lightness) 68 | let saturation = brightness <= 0 ? 0 : 2 * (1 - lightness / brightness) 69 | self.init(hue: hue, saturation: saturation, brightness: brightness, opacity: opacity) 70 | } 71 | } 72 | 73 | private struct BackgroundAndForeground: ViewModifier { 74 | let color: MTColor 75 | @Environment(\.colorScheme) private var colorScheme 76 | 77 | func body(content: Content) -> some View { 78 | content 79 | .foregroundColor(color.foregroundColor(scheme: colorScheme)) 80 | .background(color.backgroundColor(scheme: colorScheme), ignoresSafeAreaEdges: .all) 81 | .listRowBackground(color.backgroundColor(scheme: colorScheme)) 82 | } 83 | } 84 | 85 | public extension View { 86 | func backgroundAndForeground(color: MTColor) -> some View { 87 | modifier(BackgroundAndForeground(color: color)) 88 | } 89 | } 90 | 91 | #if DEBUG 92 | struct Color_Previews: PreviewProvider { 93 | static var previews: some View { 94 | let colors: [[MTColor]] = MTColor.allCases .enumerated().reduce( 95 | into: [[], []] 96 | ) { result, next in 97 | if next.offset.isMultiple(of: 2) { 98 | result[0].append(next.element) 99 | } else { 100 | result[1].append(next.element) 101 | } 102 | } 103 | ScrollView { 104 | HStack { 105 | ForEach(colors, id: \.hashValue) { colorColumn in 106 | VStack { 107 | ForEach(colorColumn, id: \.hashValue) { color in 108 | VStack { 109 | Text("Lorem Ipsum") 110 | .font(.title) 111 | Text("Smaller text") 112 | Text(color.rawValue.capitalized) 113 | .bold() 114 | 115 | Button { } label: { 116 | Label("Add a new Team", systemImage: "plus") 117 | .frame(maxWidth: .infinity, minHeight: 30) 118 | } 119 | // .buttonStyle(DashedButtonStyle(color: color)) 120 | .padding([.bottom, .horizontal]) 121 | } 122 | .frame(width: 180, height: 200) 123 | .backgroundAndForeground(color: color) 124 | } 125 | } 126 | } 127 | } 128 | } 129 | } 130 | } 131 | #endif 132 | -------------------------------------------------------------------------------- /Sources/Assets/MTImage.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public enum MTImage: String, Identifiable, Codable { 4 | case elephant = "elephant" 5 | case koala = "koala" 6 | case panda = "panda" 7 | case octopus = "octopus" 8 | case lion = "lion" 9 | case hippo = "hippo" 10 | case starfish = "starfish" 11 | case whale = "whale" 12 | case otter = "otter" 13 | case penguin = "penguin" 14 | case butterfly = "butterfly" 15 | case bunny = "bunny" 16 | 17 | case amelie = "amelie" 18 | case lara = "lara" 19 | case jack = "jack" 20 | case santa = "santa" 21 | case clown = "clown" 22 | case pirate = "pirate" 23 | case lolita = "lolita" 24 | case dandy = "dandy" 25 | case heroin = "heroin" 26 | case mentor = "mentor" 27 | case pierrot = "pierrot" 28 | case nymph = "nymph" 29 | case vampire = "vampire" 30 | case robot = "robot" 31 | case warrior = "warrior" 32 | case king = "king" 33 | 34 | case unknown = "" 35 | 36 | public var id: Int { hashValue } 37 | } 38 | 39 | public extension MTImage { 40 | static var players: [Self] { 41 | [ 42 | .amelie, .santa, .jack, .lara, .clown, .pirate, .lolita, .dandy, .heroin, .mentor, .pierrot, .nymph, 43 | .vampire, .robot, .warrior, .king, 44 | ] 45 | } 46 | static var teams: [Self] { 47 | [.elephant, .koala, .panda, .octopus, .lion, .hippo, .starfish, .whale, .otter, .penguin, .butterfly, .bunny] 48 | } 49 | } 50 | 51 | public extension Image { 52 | init(mtImage: MTImage) { 53 | switch mtImage { 54 | case .unknown: self = Image(systemName: "questionmark") 55 | default: self = Image(mtImage.rawValue, bundle: .module) 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Sources/CompositionFeature/Composition.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import Foundation 3 | import Models 4 | import PersistenceCore 5 | import TeamsFeature 6 | 7 | @Reducer 8 | public struct Composition { 9 | @ObservableState 10 | public struct State: Equatable { 11 | public var teams: IdentifiedArrayOf = [] 12 | public var standing = Standing.State() 13 | @Presents public var notEnoughTeamsConfirmationDialog: ConfirmationDialogState? 14 | 15 | public init( 16 | teams: IdentifiedArrayOf = [], 17 | standing: Standing.State = Standing.State(), 18 | notEnoughTeamsConfirmationDialog: ConfirmationDialogState? = nil 19 | ) { 20 | self.teams = teams 21 | self.standing = standing 22 | self.notEnoughTeamsConfirmationDialog = notEnoughTeamsConfirmationDialog 23 | } 24 | } 25 | 26 | public enum Action: Equatable { 27 | case addTeam 28 | case mixTeam 29 | case dismissNotEnoughTeamsAlert(PresentationAction) 30 | case standing(Standing.Action) 31 | case team(id: Team.State.ID, action: Team.Action) 32 | case archiveTeams(IndexSet) 33 | } 34 | 35 | @Dependency(\.legacyTeamPersistence) var legacyTeamPersistance 36 | @Dependency(\.shufflePlayers) var shufflePlayers 37 | @Dependency(\.randomTeam) var randomTeam 38 | @Dependency(\.uuid) var uuid 39 | 40 | public init() {} 41 | 42 | public var body: some Reducer { 43 | Scope(state: \.standing, action: \.standing) { 44 | Standing() 45 | } 46 | Reduce { state, action in 47 | switch action { 48 | case .addTeam: 49 | let team = randomTeam() 50 | state.teams.append(team) 51 | return .run { _ in try await legacyTeamPersistance.updateOrAppend(team.persisted) } 52 | case .mixTeam: 53 | guard state.teams.count > 1 else { 54 | state.notEnoughTeamsConfirmationDialog = .notEnoughTeams 55 | return .none 56 | } 57 | let players = state.standing.players + state.teams.flatMap(\.players) 58 | guard players.count > 0 else { return .none } 59 | 60 | state.teams = IdentifiedArrayOf(uniqueElements: state.teams.map { 61 | var team = $0 62 | team.players = [] 63 | return team 64 | }) 65 | 66 | state.teams = IdentifiedArrayOf( 67 | uniqueElements: shufflePlayers(players: players.elements).reduce(state.teams) { teams, player in 68 | var teams = teams 69 | var player = player 70 | guard let lessPlayerTeam = teams 71 | .sorted(by: { $0.players.count < $1.players.count }) 72 | .first 73 | else { return teams } 74 | guard var team = teams[id: lessPlayerTeam.id] else { return teams } 75 | player.color = team.color 76 | team.players.updateOrAppend(player) 77 | teams.updateOrAppend(team) 78 | return teams 79 | } 80 | ) 81 | state.standing.players = [] 82 | return .run { [state] _ in try await legacyTeamPersistance.updateValues(state.teams.persisted) } 83 | case .dismissNotEnoughTeamsAlert: 84 | state.notEnoughTeamsConfirmationDialog = nil 85 | return .none 86 | case .standing: 87 | return .none 88 | case .team: 89 | return .none 90 | case let .archiveTeams(indexSet): 91 | let archivedTeams: IdentifiedArrayOf = IdentifiedArrayOf( 92 | uniqueElements: indexSet.map { state.teams[$0] } 93 | ) 94 | state.teams.remove(atOffsets: indexSet) 95 | return .run { _ in 96 | let archivedTeams = IdentifiedArrayOf(uniqueElements: archivedTeams.map { 97 | var team = $0 98 | team.players = [] 99 | team.isArchived = true 100 | return team 101 | }) 102 | try await legacyTeamPersistance.updateValues(archivedTeams.persisted) 103 | } 104 | } 105 | } 106 | .forEach(\.teams, action: /Action.team) { 107 | Team() 108 | } 109 | } 110 | } 111 | 112 | extension IdentifiedArrayOf { 113 | var persisted: IdentifiedArrayOf { 114 | IdentifiedArrayOf(uniqueElements: map(\.persisted)) 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /Sources/CompositionFeature/CompositionLoader.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import LoaderCore 3 | import SwiftUI 4 | 5 | @Reducer 6 | public struct CompositionLoader { 7 | @ObservableState 8 | public enum State: Equatable { 9 | case loadingCard 10 | case loaded(Composition.State) 11 | case errorCard(ErrorCard.State) 12 | } 13 | 14 | public enum Action: Equatable { 15 | case update(TaskResult) 16 | case loadingCard(LoadingCard.Action) 17 | case composition(Composition.Action) 18 | case errorCard(ErrorCard.Action) 19 | } 20 | 21 | @Dependency(\.legacyTeamPersistence) var legacyTeamPersistence 22 | @Dependency(\.legacyPlayerPersistence) var legacyPlayerPersistence 23 | 24 | public init() {} 25 | 26 | public var body: some Reducer { 27 | Reduce { state, action in 28 | switch action { 29 | case let .update(result): 30 | switch result { 31 | case let .success(result): 32 | state = .loaded(result) 33 | return .none 34 | case let .failure(error): 35 | state = .errorCard(ErrorCard.State(description: error.localizedDescription)) 36 | return .none 37 | } 38 | case .loadingCard: 39 | return load(state: &state).concatenate(with: .merge( 40 | .run { send in 41 | for try await _ in legacyTeamPersistence.publisher() { 42 | await send(.update(await loadTaskResult)) 43 | } 44 | } catch: { error, send in 45 | await send(.update(.failure(error))) 46 | }, 47 | .run { send in 48 | for try await _ in legacyPlayerPersistence.publisher() { 49 | await send(.update(await loadTaskResult)) 50 | } 51 | } catch: { error, send in 52 | await send(.update(.failure(error))) 53 | } 54 | )) 55 | case .composition: 56 | return .none 57 | case .errorCard(.reload): 58 | return load(state: &state) 59 | } 60 | } 61 | .ifCaseLet(\.loadingCard, action: \.loadingCard) { 62 | LoadingCard() 63 | } 64 | .ifCaseLet(\.loaded, action: \.composition) { 65 | Composition() 66 | } 67 | .ifCaseLet(\.errorCard, action: \.errorCard) { 68 | ErrorCard() 69 | } 70 | } 71 | 72 | private func load(state: inout State) -> Effect { 73 | state = .loadingCard 74 | return .run { send in await send(.update(loadTaskResult)) } 75 | } 76 | 77 | private var loadTaskResult: TaskResult { 78 | get async { 79 | await TaskResult { 80 | let teams = try await legacyTeamPersistence.load().filter { !$0.isArchived }.states 81 | let playersInTeams = teams.flatMap(\.players) 82 | let standingPlayers = try await legacyPlayerPersistence.load() 83 | .filter { !playersInTeams.map(\.id).contains($0.id) } 84 | .map(\.state) 85 | 86 | return Composition.State( 87 | teams: teams, 88 | standing: Standing.State(players: IdentifiedArrayOf(uniqueElements: standingPlayers)) 89 | ) 90 | } 91 | } 92 | } 93 | } 94 | 95 | public struct CompositionLoaderView: View { 96 | let store: StoreOf 97 | 98 | public init(store: StoreOf) { 99 | self.store = store 100 | } 101 | 102 | public var body: some View { 103 | NavigationView { 104 | Group { 105 | switch store.state { 106 | case .loadingCard: 107 | if let store = store.scope(state: \.loadingCard, action: \.loadingCard) { 108 | LoadingCardView(store: store) 109 | } 110 | case .loaded: 111 | if let store = store.scope(state: \.loaded, action: \.composition) { 112 | CompositionView(store: store) 113 | } 114 | case .errorCard: 115 | if let store = store.scope(state: \.errorCard, action: \.errorCard) { 116 | ErrorCardView(store: store) 117 | } 118 | } 119 | } 120 | .listStyle(.plain) 121 | .navigationTitle("Composition") 122 | } 123 | .tabItem { 124 | Label("Composition", systemImage: "person.2.crop.square.stack") 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /Sources/CompositionFeature/CompositionView.swift: -------------------------------------------------------------------------------- 1 | import Assets 2 | import ComposableArchitecture 3 | import PlayersFeature 4 | import SwiftUI 5 | import StyleCore 6 | import TeamsFeature 7 | 8 | public struct CompositionView: View { 9 | let store: StoreOf 10 | 11 | public init(store: StoreOf) { 12 | self.store = store 13 | } 14 | 15 | public var body: some View { 16 | List { 17 | Group { 18 | StandingView(store: store.scope(state: \.standing, action: \.standing)) 19 | mixTeamButton 20 | ForEachStore( 21 | store.scope(state: \.teams, action: \.team), 22 | content: TeamRow.init 23 | ) 24 | .onDelete { store.send(.archiveTeams($0), animation: .default) } 25 | addTeamButton 26 | } 27 | .listRowBackground(Color.clear) 28 | #if os(iOS) 29 | .listRowSeparator(.hidden) 30 | #endif 31 | } 32 | .backgroundAndForeground(color: .aluminium) 33 | .confirmationDialog(store: store.scope( 34 | state: \.$notEnoughTeamsConfirmationDialog, 35 | action: \.dismissNotEnoughTeamsAlert 36 | )) 37 | } 38 | 39 | private var mixTeamButton: some View { 40 | Button { store.send(.mixTeam, animation: .easeInOut) } label: { 41 | Label("Mix Team", systemImage: "shuffle") 42 | } 43 | .buttonStyle(.dashed(color: .aluminium)) 44 | .frame(maxWidth: .infinity) 45 | } 46 | 47 | private var addTeamButton: some View { 48 | Button { store.send(.addTeam, animation: .easeInOut) } label: { 49 | Label("Add a new Team", systemImage: "plus") 50 | } 51 | .buttonStyle(DashedButtonStyle(color: .aluminium)) 52 | .frame(maxWidth: .infinity) 53 | } 54 | } 55 | 56 | #Preview { 57 | NavigationView { 58 | CompositionView( 59 | store: Store(initialState: Composition.State( 60 | teams: .example, 61 | standing: .example 62 | )) { Composition() } 63 | ) 64 | .navigationTitle("Composition") 65 | .listStyle(.plain) 66 | } 67 | } 68 | 69 | public extension Standing.State { 70 | static var example: Self { 71 | let players = IdentifiedArrayOf(uniqueElements: IdentifiedArrayOf.example.prefix(2)) 72 | return Self(players: players) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Sources/CompositionFeature/ConfirmationDialog+NotEnoughTeams.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | 3 | public extension ConfirmationDialogState where Action == Composition.Action { 4 | static var notEnoughTeams: Self { 5 | ConfirmationDialogState(titleVisibility: .visible) { 6 | TextState("Couldn't Mix Team") 7 | } actions: { 8 | ButtonState(role: .cancel) { 9 | TextState("OK") 10 | } 11 | ButtonState(action: .send(.addTeam, animation: .default)) { 12 | TextState("Add a new Team") 13 | } 14 | } message: { 15 | TextState("It needs at least 2 teams. Go create some teams :)") 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/CompositionFeature/Standing.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import PlayersFeature 3 | 4 | @Reducer 5 | public struct Standing { 6 | @ObservableState 7 | public struct State: Equatable { 8 | public var players: IdentifiedArrayOf = [] 9 | 10 | public init(players: IdentifiedArrayOf = []) { 11 | self.players = players 12 | } 13 | } 14 | 15 | public enum Action: Equatable { 16 | case createPlayer 17 | case deletePlayer(id: Player.State.ID) 18 | case player(IdentifiedActionOf) 19 | } 20 | 21 | @Dependency(\.uuid) var uuid 22 | @Dependency(\.legacyPlayerPersistence) var legacyPlayerPersistence 23 | @Dependency(\.randomPlayer) var randomPlayer 24 | 25 | public init() {} 26 | 27 | public var body: some Reducer { 28 | Reduce { state, action in 29 | switch action { 30 | case .createPlayer: 31 | let player = randomPlayer() 32 | state.players.append(player) 33 | return .run { _ in try await legacyPlayerPersistence.updateOrAppend(player.persisted) } 34 | case let .deletePlayer(id): 35 | state.players.remove(id: id) 36 | return .run { _ in try await legacyPlayerPersistence.remove(id) } 37 | case .player: 38 | return .none 39 | } 40 | } 41 | .forEach(\.players, action: \.player) { 42 | Player() 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/CompositionFeature/StandingView.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import PlayersFeature 3 | import SwiftUI 4 | import TeamsFeature 5 | 6 | struct StandingView: View { 7 | let store: StoreOf 8 | 9 | var body: some View { 10 | Section { 11 | header 12 | ForEachStore(store.scope(state: \.players, action: \.player)) { playerStore in 13 | PlayerRow(store: playerStore) 14 | .swipeActions(allowsFullSwipe: true) { 15 | Button(role: .destructive) { 16 | store.send(.deletePlayer(id: playerStore.id), animation: .default) 17 | } label: { 18 | Image(systemName: "trash") 19 | } 20 | .buttonStyle(.plain) 21 | } 22 | } 23 | } 24 | } 25 | 26 | private var header: some View { 27 | VStack { 28 | Text("Players standing for a team") 29 | .font(.title3) 30 | .fontWeight(.semibold) 31 | .frame(maxWidth: .infinity, alignment: .leading) 32 | Button { store.send(.createPlayer, animation: .easeInOut) } label: { 33 | Label { Text("Add Player") } icon: { 34 | HStack { 35 | Image(systemName: "person.3") 36 | Image(systemName: "plus") 37 | } 38 | .font(.title3) 39 | } 40 | .labelStyle(.iconOnly) 41 | } 42 | .buttonStyle(.dashed(color: .aluminium)) 43 | } 44 | .frame(maxWidth: .infinity) 45 | .backgroundAndForeground(color: .aluminium) 46 | } 47 | } 48 | 49 | #Preview { 50 | NavigationView { 51 | List { 52 | StandingView(store: Store(initialState: Standing.State( 53 | players: (IdentifiedArrayOf.example.first?.players[0]).map { [$0] } ?? [] 54 | )) { Standing() }) 55 | } 56 | .listStyle(.plain) 57 | #if os(iOS) 58 | .listRowSeparator(.hidden) 59 | #endif 60 | .navigationTitle("Composition") 61 | .backgroundAndForeground(color: .aluminium) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Sources/ImagePicker/IllustrationPicker.swift: -------------------------------------------------------------------------------- 1 | import Assets 2 | import ComposableArchitecture 3 | import SwiftUI 4 | 5 | @Reducer 6 | public struct IllustrationPicker { 7 | 8 | @ObservableState 9 | public struct State: Equatable { 10 | public let images: IdentifiedArrayOf 11 | public let color: MTColor 12 | public var selectedImage: MTImage? 13 | 14 | public init(images: IdentifiedArrayOf, color: MTColor, selectedImage: MTImage?) { 15 | self.images = images 16 | self.color = color 17 | self.selectedImage = selectedImage 18 | } 19 | } 20 | 21 | public enum Action: Equatable { 22 | case imageTapped(MTImage) 23 | } 24 | 25 | public init() {} 26 | 27 | public var body: some Reducer { 28 | Reduce { state, action in 29 | switch action { 30 | case let .imageTapped(image): 31 | state.selectedImage = image 32 | return .none 33 | } 34 | } 35 | } 36 | } 37 | 38 | public struct IllustrationPickerView: View { 39 | let store: StoreOf 40 | 41 | let columns = [GridItem(.adaptive(minimum: 90, maximum: 100))] 42 | 43 | public init(store: StoreOf) { 44 | self.store = store 45 | } 46 | 47 | public var body: some View { 48 | WithViewStore(store, observe: { $0 }) { viewStore in 49 | LazyVGrid(columns: columns) { 50 | ForEach(viewStore.images, id: \.id) { image in 51 | Button { viewStore.send(.imageTapped(image)) } label: { 52 | Cell(image: image, color: viewStore.color, isSelected: image == viewStore.selectedImage) 53 | } 54 | } 55 | } 56 | .padding() 57 | } 58 | } 59 | } 60 | 61 | private struct Cell: View { 62 | let image: MTImage 63 | let color: MTColor 64 | let isSelected: Bool 65 | @Environment(\.colorScheme) private var colorScheme 66 | 67 | var body: some View { 68 | Image(mtImage: image) 69 | .resizable() 70 | .scaleEffect(isSelected ? 105/100 : 100/100) 71 | .frame(width: 48, height: 48) 72 | .padding() 73 | .background { 74 | if isSelected { 75 | ZStack { 76 | color.backgroundColor(scheme: colorScheme) 77 | .clipShape(RoundedRectangle(cornerRadius: 12)) 78 | .shadow(color: Color(white: 20/100, opacity: 20/100), radius: 2, x: 1, y: 1) 79 | RoundedRectangle(cornerRadius: 12) 80 | .fill(color.foregroundColor(scheme: colorScheme)) 81 | .opacity(20/100) 82 | } 83 | } 84 | } 85 | } 86 | } 87 | 88 | #Preview { 89 | IllustrationPickerView(store: Store(initialState: IllustrationPicker.State( 90 | images: IdentifiedArrayOf(uniqueElements: MTImage.players), 91 | color: .strawberry, 92 | selectedImage: nil 93 | )) { IllustrationPicker() }) 94 | .backgroundAndForeground(color: .strawberry) 95 | } 96 | -------------------------------------------------------------------------------- /Sources/LoaderCore/ErrorCard.swift: -------------------------------------------------------------------------------- 1 | import Assets 2 | import ComposableArchitecture 3 | import SwiftUI 4 | import StyleCore 5 | 6 | @Reducer 7 | public struct ErrorCard { 8 | 9 | @ObservableState 10 | public struct State: Equatable { 11 | var description = "" 12 | 13 | public init(description: String = "") { 14 | self.description = description 15 | } 16 | } 17 | 18 | public enum Action: Equatable { 19 | case reload 20 | } 21 | 22 | public init() {} 23 | 24 | public var body: some Reducer { 25 | EmptyReducer() 26 | } 27 | } 28 | 29 | public struct ErrorCardView: View { 30 | let store: StoreOf 31 | 32 | public init(store: StoreOf) { 33 | self.store = store 34 | } 35 | 36 | public var body: some View { 37 | VStack { 38 | Text(store.description) 39 | Button { store.send(.reload, animation: .default) } label: { 40 | Text("Retry") 41 | } 42 | .buttonStyle(.dashed(color: .strawberry)) 43 | } 44 | .frame(maxWidth: .infinity, maxHeight: .infinity) 45 | .backgroundAndForeground(color: .strawberry) 46 | } 47 | } 48 | 49 | #Preview { 50 | ErrorCardView(store: Store(initialState: ErrorCard.State(description: "Preview Error")) { 51 | ErrorCard() 52 | }) 53 | } 54 | -------------------------------------------------------------------------------- /Sources/LoaderCore/LoadingCard.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import SwiftUI 3 | 4 | @Reducer 5 | public struct LoadingCard { 6 | public typealias State = Void 7 | 8 | public enum Action: Equatable { 9 | case task 10 | } 11 | 12 | public init() {} 13 | 14 | public var body: some Reducer { 15 | EmptyReducer() 16 | } 17 | } 18 | 19 | public struct LoadingCardView: View { 20 | let store: StoreOf 21 | 22 | public init(store: StoreOf) { 23 | self.store = store 24 | } 25 | 26 | public var body: some View { 27 | ProgressView("Loading content from saved data") 28 | .frame(maxWidth: .infinity, maxHeight: .infinity) 29 | .task { store.send(.task) } 30 | } 31 | } 32 | 33 | #Preview { 34 | LoadingCardView(store: Store(initialState: ()) { LoadingCard() }) 35 | } 36 | -------------------------------------------------------------------------------- /Sources/Models/PersistedPlayer.swift: -------------------------------------------------------------------------------- 1 | import Assets 2 | import Foundation 3 | 4 | public struct PersistedPlayer: Codable, Identifiable, Equatable { 5 | public let id: UUID 6 | public let name: String 7 | public let image: MTImage 8 | 9 | public init(id: UUID, name: String, image: MTImage) { 10 | self.id = id 11 | self.name = name 12 | self.image = image 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/Models/PersistedScores.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import Foundation 3 | 4 | public struct PersistedScores: Codable { 5 | public var rounds: IdentifiedArrayOf 6 | 7 | public init(rounds: IdentifiedArrayOf) { 8 | self.rounds = rounds 9 | } 10 | } 11 | 12 | public struct PersistedRound: Codable, Identifiable { 13 | public let id: UUID 14 | public let name: String 15 | public var scores: IdentifiedArrayOf 16 | 17 | public init(id: UUID, name: String, scores: IdentifiedArrayOf) { 18 | self.id = id 19 | self.name = name 20 | self.scores = scores 21 | } 22 | } 23 | 24 | public struct PersistedScore: Codable, Identifiable { 25 | public let id: UUID 26 | public let teamID: PersistedTeam.ID 27 | public let points: Int 28 | 29 | public init(id: UUID, teamID: PersistedTeam.ID, points: Int) { 30 | self.id = id 31 | self.teamID = teamID 32 | self.points = points 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/Models/PersistedTeam.swift: -------------------------------------------------------------------------------- 1 | import Assets 2 | import Foundation 3 | 4 | public struct PersistedTeam: Codable, Identifiable { 5 | public var id: UUID 6 | public var name: String 7 | public var color: MTColor 8 | public var image: MTImage 9 | public var playerIDs: [PersistedPlayer.ID] 10 | public var isArchived: Bool 11 | 12 | public init( 13 | id: UUID, 14 | name: String, 15 | color: MTColor, 16 | image: MTImage, 17 | playerIDs: [PersistedPlayer.ID], 18 | isArchived: Bool 19 | ) { 20 | self.id = id 21 | self.name = name 22 | self.color = color 23 | self.image = image 24 | self.playerIDs = playerIDs 25 | self.isArchived = isArchived 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/PersistenceCore/MigrationV3_0toV3_1.swift: -------------------------------------------------------------------------------- 1 | import Dependencies 2 | import Foundation 3 | import IdentifiedCollections 4 | import Models 5 | 6 | // swiftlint:disable:next type_name 7 | struct MigrationV3_0toV3_1 { 8 | private let team: IdentifiedArrayOf 9 | private let player: IdentifiedArrayOf 10 | private let scores: PersistedScores 11 | 12 | private let legacyTeamFileName = "MixTeamTeamV3_0_0" 13 | private let legacyPlayerFileName = "MixTeamPlayerV3_0_0" 14 | private let legacyAppFileName = "MixTeamAppV3_0_0" 15 | 16 | @Dependency(\.legacyTeamPersistence) var legacyTeamPersistence 17 | @Dependency(\.legacyPlayerPersistence) var legacyPlayerPersistence 18 | @Dependency(\.legacyScoresPersistence) var legacyScoresPersistence 19 | 20 | init?() { 21 | guard let url = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first 22 | else { return nil } 23 | 24 | guard let appData = try? Data(contentsOf: url.appendingPathComponent(legacyAppFileName, conformingTo: .json)), 25 | let playerData = try? Data( 26 | contentsOf: url.appendingPathComponent(legacyPlayerFileName, conformingTo: .json) 27 | ), 28 | let teamData = try? Data(contentsOf: url.appendingPathComponent(legacyTeamFileName, conformingTo: .json)), 29 | let app = try? JSONDecoder().decode(AppDataState.self, from: appData), 30 | let player = try? JSONDecoder().decode(IdentifiedArrayOf.self, from: playerData), 31 | let team = try? JSONDecoder().decode(IdentifiedArrayOf.self, from: teamData) 32 | else { return nil } 33 | 34 | self.team = team 35 | self.player = player 36 | self.scores = app.scores 37 | } 38 | 39 | func migrate() async throws { 40 | try await legacyTeamPersistence.save(team) 41 | try await legacyPlayerPersistence.save(player) 42 | try await legacyScoresPersistence.save(scores) 43 | 44 | guard let url = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first 45 | else { throw PersistenceError.cannotGetDocumentDirectoryWithUserDomainMask } 46 | 47 | try FileManager.default.removeItem(at: url.appendingPathComponent(legacyAppFileName, conformingTo: .json)) 48 | try FileManager.default.removeItem(at: url.appendingPathComponent(legacyPlayerFileName, conformingTo: .json)) 49 | try FileManager.default.removeItem(at: url.appendingPathComponent(legacyTeamFileName, conformingTo: .json)) 50 | } 51 | } 52 | 53 | private extension MigrationV3_0toV3_1 { 54 | struct AppDataState: Decodable { 55 | var scores: PersistedScores 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/PersistenceCore/PersistenceError.swift: -------------------------------------------------------------------------------- 1 | public enum PersistenceError: Error, Equatable { 2 | case cannotGetDocumentDirectoryWithUserDomainMask 3 | case notFound 4 | } 5 | -------------------------------------------------------------------------------- /Sources/PersistenceCore/PlayerPersistence.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | import Dependencies 4 | import IdentifiedCollections 5 | import Models 6 | import XCTestDynamicOverlay 7 | 8 | private final class Persistence { 9 | private let playerFileName = "MixTeamPlayerV3_1_0" 10 | 11 | let subject = PassthroughSubject, Error>() 12 | var value: IdentifiedArrayOf { 13 | didSet { Task { try await persist(value) } } 14 | } 15 | 16 | init() throws { 17 | guard 18 | let url = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first, 19 | let data = try? Data(contentsOf: url.appendingPathComponent(playerFileName, conformingTo: .json)) 20 | else { 21 | value = .example 22 | subject.send(value) 23 | return 24 | } 25 | 26 | let decodedValue = try JSONDecoder().decode(IdentifiedArrayOf.self, from: data) 27 | value = decodedValue 28 | subject.send(value) 29 | } 30 | 31 | func save(_ players: IdentifiedArrayOf) async throws { 32 | value = players 33 | subject.send(value) 34 | } 35 | 36 | func persist(_ players: IdentifiedArrayOf) async throws { 37 | let data = try JSONEncoder().encode(players) 38 | guard let url = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first 39 | else { throw PersistenceError.cannotGetDocumentDirectoryWithUserDomainMask } 40 | try data.write(to: url.appendingPathComponent(playerFileName, conformingTo: .json)) 41 | } 42 | 43 | func updateOrAppend(player: PersistedPlayer) async throws { 44 | value.updateOrAppend(player) 45 | subject.send(value) 46 | } 47 | func remove(id: PersistedPlayer.ID) async throws { 48 | value.remove(id: id) 49 | subject.send(value) 50 | } 51 | } 52 | 53 | public struct LegacyPlayerPersistence { 54 | public var publisher: () -> AsyncThrowingPublisher, Error>> 55 | public var load: () async throws -> IdentifiedArrayOf 56 | public var save: (IdentifiedArrayOf) async throws -> Void 57 | public var updateOrAppend: (PersistedPlayer) async throws -> Void 58 | public var remove: (PersistedPlayer.ID) async throws -> Void 59 | } 60 | 61 | public extension LegacyPlayerPersistence { 62 | static let live = { 63 | do { 64 | let persistence = try Persistence() 65 | return Self( 66 | publisher: { persistence.subject.eraseToAnyPublisher().values }, 67 | load: { persistence.value }, 68 | save: { try await persistence.save($0) }, 69 | updateOrAppend: { try await persistence.updateOrAppend(player: $0) }, 70 | remove: { try await persistence.remove(id: $0) } 71 | ) 72 | } catch { 73 | return Self( 74 | publisher: { Fail(error: error).eraseToAnyPublisher().values }, 75 | load: { throw error }, 76 | save: { _ in throw error }, 77 | updateOrAppend: { _ in throw error }, 78 | remove: { _ in throw error } 79 | ) 80 | } 81 | }() 82 | static let test = Self( 83 | publisher: unimplemented("PlayerPersistence.publisher"), 84 | load: unimplemented("PlayerPersistence.load"), 85 | save: unimplemented("PlayerPersistence.save"), 86 | updateOrAppend: unimplemented("PlayerPersistence.updateOrAppend"), 87 | remove: unimplemented("PlayerPersistence.remove") 88 | ) 89 | static let preview = Self( 90 | publisher: { Result.Publisher(.example).eraseToAnyPublisher().values }, 91 | load: { .example }, 92 | save: { _ in print("PlayerPersistence.save called") }, 93 | updateOrAppend: { _ in print("PlayerPersistence.updateOrAppend called") }, 94 | remove: { _ in print("PlayerPersistence.remove called") } 95 | ) 96 | } 97 | 98 | public extension IdentifiedArrayOf { 99 | static var example: Self { 100 | guard let ameliaID = UUID(uuidString: "F336E7F8-78AC-439B-8E32-202DE58CFAC2"), 101 | let joseID = UUID(uuidString: "C0F0266B-FFF1-47B0-8A2C-CC90BC36CF15"), 102 | let jackID = UUID(uuidString: "34BC8929-C2F6-42D5-8131-8F048CE649A6") 103 | else { fatalError("Cannot generate UUID from a defined UUID String") } 104 | 105 | return [ 106 | PersistedPlayer(id: ameliaID, name: "Amelia", image: .amelie), 107 | PersistedPlayer(id: joseID, name: "José", image: .santa), 108 | PersistedPlayer(id: jackID, name: "Jack", image: .jack), 109 | ] 110 | } 111 | } 112 | 113 | private enum PlayerPersistenceDependencyKey: DependencyKey { 114 | static let liveValue = LegacyPlayerPersistence.live 115 | static let testValue = LegacyPlayerPersistence.test 116 | static let previewValue = LegacyPlayerPersistence.test 117 | } 118 | 119 | public extension DependencyValues { 120 | var legacyPlayerPersistence: LegacyPlayerPersistence { 121 | get { self[PlayerPersistenceDependencyKey.self] } 122 | set { self[PlayerPersistenceDependencyKey.self] = newValue } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /Sources/PersistenceCore/ScoresPersistence.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import Dependencies 3 | import Foundation 4 | import IdentifiedCollections 5 | import Models 6 | import XCTestDynamicOverlay 7 | 8 | private final class Persistence { 9 | private let scoresFileName = "MixTeamScoresV3_1_0" 10 | 11 | var value: PersistedScores { 12 | didSet { Task { try await persist(value) } } 13 | } 14 | 15 | init() throws { 16 | guard 17 | let url = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first, 18 | let data = try? Data(contentsOf: url.appendingPathComponent(scoresFileName, conformingTo: .json)) 19 | else { 20 | value = .example 21 | return 22 | } 23 | 24 | let decodedValue = try JSONDecoder().decode(PersistedScores.self, from: data) 25 | value = decodedValue 26 | } 27 | 28 | func save(_ scores: PersistedScores) async throws { 29 | value = scores 30 | } 31 | 32 | private func persist(_ scores: PersistedScores) async throws { 33 | let data = try JSONEncoder().encode(scores) 34 | guard let url = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first 35 | else { throw PersistenceError.cannotGetDocumentDirectoryWithUserDomainMask } 36 | try data.write(to: url.appendingPathComponent(scoresFileName, conformingTo: .json)) 37 | } 38 | 39 | func update(round: PersistedRound) async throws { 40 | value.rounds.updateOrAppend(round) 41 | } 42 | 43 | func update(score: PersistedScore) async throws { 44 | guard var round = value.rounds.first(where: { $0.scores.contains(score) }) else { return } 45 | round.scores.updateOrAppend(score) 46 | try await update(round: round) 47 | } 48 | } 49 | 50 | public struct LegacyScoresPersistence { 51 | public var load: () async throws -> PersistedScores 52 | public var save: (PersistedScores) async throws -> Void 53 | public var updateRound: (PersistedRound) async throws -> Void 54 | public var updateScore: (PersistedScore) async throws -> Void 55 | } 56 | 57 | extension LegacyScoresPersistence { 58 | static let live = { 59 | do { 60 | let persistence = try Persistence() 61 | return Self( 62 | load: { persistence.value }, 63 | save: { try await persistence.save($0) }, 64 | updateRound: { try await persistence.update(round: $0) }, 65 | updateScore: { try await persistence.update(score: $0) } 66 | ) 67 | } catch { 68 | return Self( 69 | load: { throw error }, 70 | save: { _ in throw error }, 71 | updateRound: { _ in throw error }, 72 | updateScore: { _ in throw error } 73 | ) 74 | } 75 | }() 76 | static let test = Self( 77 | load: unimplemented("ScoresPersistence.load"), 78 | save: unimplemented("ScoresPersistence.save"), 79 | updateRound: unimplemented("ScoresPersistence.updateRound"), 80 | updateScore: unimplemented("ScoresPersistence.updateScpre") 81 | ) 82 | static let preview = Self( 83 | load: { .example }, 84 | save: { _ in print("ScoresPersistence.save called") }, 85 | updateRound: { _ in print("ScoresPersistence.updateRound called") }, 86 | updateScore: unimplemented("ScoresPersistence.updateScore called") 87 | ) 88 | } 89 | 90 | extension PersistedScores { 91 | static var example: Self { 92 | Self(rounds: []) 93 | } 94 | } 95 | 96 | private enum ScoresPersistenceDependencyKey: DependencyKey { 97 | static let liveValue = LegacyScoresPersistence.live 98 | static let testValue = LegacyScoresPersistence.test 99 | static let previewValue = LegacyScoresPersistence.preview 100 | } 101 | 102 | public extension DependencyValues { 103 | var legacyScoresPersistence: LegacyScoresPersistence { 104 | get { self[ScoresPersistenceDependencyKey.self] } 105 | set { self[ScoresPersistenceDependencyKey.self] = newValue } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /Sources/PersistenceCore/TeamPersistence.swift: -------------------------------------------------------------------------------- 1 | import Assets 2 | import Combine 3 | import Dependencies 4 | import Foundation 5 | import IdentifiedCollections 6 | import Models 7 | import XCTestDynamicOverlay 8 | 9 | private final class Persistence { 10 | private let teamFileName = "MixTeamTeamV3_1_0" 11 | 12 | let subject = PassthroughSubject, Error>() 13 | var value: IdentifiedArrayOf { 14 | didSet { Task { try await persist(value) } } 15 | } 16 | 17 | init() throws { 18 | guard 19 | let url = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first, 20 | let data = try? Data(contentsOf: url.appendingPathComponent(teamFileName, conformingTo: .json)) 21 | else { 22 | value = .example 23 | subject.send(value) 24 | return 25 | } 26 | 27 | let decodedValue = try JSONDecoder().decode(IdentifiedArrayOf.self, from: data) 28 | value = decodedValue 29 | subject.send(value) 30 | } 31 | 32 | func save(_ teams: IdentifiedArrayOf) async throws { 33 | value = teams 34 | subject.send(value) 35 | } 36 | 37 | private func persist(_ teams: IdentifiedArrayOf) async throws { 38 | let data = try JSONEncoder().encode(teams) 39 | guard let url = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first 40 | else { throw PersistenceError.cannotGetDocumentDirectoryWithUserDomainMask } 41 | try data.write(to: url.appendingPathComponent(teamFileName, conformingTo: .json)) 42 | } 43 | 44 | func updateOrAppend(team: PersistedTeam) async throws { 45 | value.updateOrAppend(team) 46 | subject.send(value) 47 | } 48 | func update(values: IdentifiedArrayOf) async throws { 49 | for value in values { 50 | self.value.updateOrAppend(value) 51 | } 52 | subject.send(value) 53 | } 54 | func remove(team: PersistedTeam) async throws { 55 | value.remove(team) 56 | subject.send(value) 57 | } 58 | } 59 | 60 | public struct LegacyTeamPersistence { 61 | public var publisher: () -> AsyncThrowingPublisher, Error>> 62 | public var load: () async throws -> IdentifiedArrayOf 63 | public var save: (IdentifiedArrayOf) async throws -> Void 64 | public var updateOrAppend: (PersistedTeam) async throws -> Void 65 | public var updateValues: (IdentifiedArrayOf) async throws -> Void 66 | public var remove: (PersistedTeam) async throws -> Void 67 | } 68 | 69 | extension LegacyTeamPersistence { 70 | static let live = { 71 | do { 72 | let persistence = try Persistence() 73 | return Self( 74 | publisher: { persistence.subject.eraseToAnyPublisher().values }, 75 | load: { persistence.value }, 76 | save: { try await persistence.save($0) }, 77 | updateOrAppend: { try await persistence.updateOrAppend(team: $0) }, 78 | updateValues: { try await persistence.update(values: $0) }, 79 | remove: { try await persistence.remove(team: $0) } 80 | ) 81 | } catch { 82 | return Self( 83 | publisher: { Fail(error: error).eraseToAnyPublisher().values }, 84 | load: { throw error }, 85 | save: { _ in throw error }, 86 | updateOrAppend: { _ in throw error }, 87 | updateValues: { _ in throw error }, 88 | remove: { _ in throw error } 89 | ) 90 | } 91 | }() 92 | static let test = Self( 93 | publisher: unimplemented("TeamPersistence.publisher"), 94 | load: unimplemented("TeamPersistence.load"), 95 | save: unimplemented("TeamPersistence.save"), 96 | updateOrAppend: unimplemented("TeamPersistence.updateOrAppend"), 97 | updateValues: unimplemented("TeamPersistence.updateValues"), 98 | remove: unimplemented("TeamPersistence.remove") 99 | ) 100 | static let preview = Self( 101 | publisher: { Result.Publisher(.example).eraseToAnyPublisher().values }, 102 | load: { .example }, 103 | save: { _ in print("TeamPersistence.save called") }, 104 | updateOrAppend: { _ in print("TeamPersistence.updateOrAppend called") }, 105 | updateValues: { _ in print("TeamPersistence.updateValues called") }, 106 | remove: { _ in print("TeamPersistence.remove called") } 107 | ) 108 | } 109 | 110 | public extension IdentifiedArrayOf { 111 | static var example: Self { 112 | guard let koalaTeamId = UUID(uuidString: "00E9D827-9FAD-4686-83F2-FAD24D2531A2"), 113 | let purpleElephantId = UUID(uuidString: "98DBAF6C-685D-461F-9F81-E5E1E003B9AA"), 114 | let blueLionId = UUID(uuidString: "6634515C-19C9-47DF-8B2B-036736F9AEA9") 115 | else { fatalError("Cannot generate UUID from a defined UUID String") } 116 | 117 | let playersExample: IdentifiedArrayOf = .example 118 | let players = IdentifiedArrayOf(uniqueElements: playersExample.suffix(1)) 119 | 120 | return [ 121 | PersistedTeam( 122 | id: koalaTeamId, 123 | name: "Strawberry Koala", 124 | color: .strawberry, 125 | image: .koala, 126 | playerIDs: players.map(\.id), 127 | isArchived: false 128 | ), 129 | PersistedTeam( 130 | id: purpleElephantId, 131 | name: "Lilac Elephant", 132 | color: .lilac, 133 | image: .elephant, 134 | playerIDs: [], 135 | isArchived: false 136 | ), 137 | PersistedTeam( 138 | id: blueLionId, 139 | name: "Bluejeans Lion", 140 | color: .bluejeans, 141 | image: .lion, 142 | playerIDs: [], 143 | isArchived: false 144 | ), 145 | ] 146 | } 147 | } 148 | 149 | private enum TeamPersistenceDependencyKey: DependencyKey { 150 | static let liveValue = LegacyTeamPersistence.live 151 | static let testValue = LegacyTeamPersistence.test 152 | static let previewValue = LegacyTeamPersistence.preview 153 | } 154 | 155 | public extension DependencyValues { 156 | var legacyTeamPersistence: LegacyTeamPersistence { 157 | get { self[TeamPersistenceDependencyKey.self] } 158 | set { self[TeamPersistenceDependencyKey.self] = newValue } 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /Sources/PlayersFeature/EditPlayerView.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import ImagePicker 3 | import StyleCore 4 | import SwiftUI 5 | 6 | struct EditPlayerView: View { 7 | @Bindable var store: StoreOf 8 | @Environment(\.colorScheme) var colorScheme 9 | 10 | var body: some View { 11 | ScrollView { 12 | TextField("Edit", text: $store.name.sending(\.nameChanged)) 13 | .font(.title) 14 | .dashedCardStyle(color: store.color) 15 | .padding() 16 | IllustrationPickerView( 17 | store: store.scope(state: \.illustrationPicker, action: \.illustrationPicker) 18 | ) 19 | } 20 | .backgroundAndForeground(color: store.color) 21 | .navigationTitle("Editing \(store.name)") 22 | } 23 | } 24 | 25 | #Preview { 26 | NavigationView { 27 | EditPlayerView(store: Store(initialState: .preview, reducer: { Player() })) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/PlayersFeature/Player.swift: -------------------------------------------------------------------------------- 1 | import Assets 2 | import ComposableArchitecture 3 | import Foundation 4 | import ImagePicker 5 | import Models 6 | import PersistenceCore 7 | 8 | @Reducer 9 | public struct Player { 10 | @ObservableState 11 | public struct State: Equatable, Identifiable { 12 | public let id: UUID 13 | // TODO: name & image should be persisted 14 | public var name = "" 15 | public var image: MTImage = .unknown 16 | public var color: MTColor = .aluminium 17 | 18 | public init(id: UUID, name: String = "", image: MTImage = .unknown, color: MTColor = .aluminium) { 19 | self.id = id 20 | self.name = name 21 | self.image = image 22 | self.color = color 23 | } 24 | 25 | var illustrationPicker: IllustrationPicker.State { 26 | IllustrationPicker.State( 27 | images: IdentifiedArrayOf(uniqueElements: MTImage.players), 28 | color: color, 29 | selectedImage: image 30 | ) 31 | } 32 | } 33 | 34 | public enum Action: Equatable { 35 | case nameChanged(String) 36 | case illustrationPicker(IllustrationPicker.Action) 37 | } 38 | 39 | @Dependency(\.legacyPlayerPersistence) var legacyPlayerPersistence 40 | 41 | public init() {} 42 | 43 | public var body: some Reducer { 44 | Reduce { state, action in 45 | switch action { 46 | case let .nameChanged(name): 47 | state.name = name 48 | return .run { [state] _ in try await legacyPlayerPersistence.updateOrAppend(state.persisted) } 49 | case let .illustrationPicker(.imageTapped(image)): 50 | state.image = image 51 | return .run { [state] _ in try await legacyPlayerPersistence.updateOrAppend(state.persisted) } 52 | } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Sources/PlayersFeature/PlayerRow.swift: -------------------------------------------------------------------------------- 1 | import Assets 2 | import ComposableArchitecture 3 | import SwiftUI 4 | 5 | public struct PlayerRow: View { 6 | let store: StoreOf 7 | 8 | public init(store: StoreOf) { 9 | self.store = store 10 | } 11 | 12 | public var body: some View { 13 | NavigationLink(destination: EditPlayerView(store: store)) { 14 | HStack { 15 | Image(mtImage: store.image) 16 | .resizable() 17 | .frame(width: 48, height: 48) 18 | Text(store.name) 19 | .fontWeight(.medium) 20 | } 21 | } 22 | .backgroundAndForeground(color: store.color) 23 | .padding(.leading, 24) 24 | } 25 | } 26 | 27 | #Preview { 28 | List { 29 | ForEach(0..<2) { 30 | PlayerRow(store: Store(initialState: .preview(isStanding: $0 != 1)) { Player() }) 31 | } 32 | } 33 | .listStyle(.plain) 34 | #if os(iOS) 35 | .listRowSeparator(.hidden) 36 | #endif 37 | } 38 | 39 | #if DEBUG 40 | public extension Player.State { 41 | static func preview(isStanding: Bool = false) -> Self { 42 | Player.State( 43 | id: UUIDGenerator.incrementing(), 44 | name: "Test Player", 45 | image: MTImage.amelie, 46 | color: isStanding ? .aluminium : .strawberry 47 | ) 48 | } 49 | static var preview: Self { 50 | .preview() 51 | } 52 | } 53 | #endif 54 | -------------------------------------------------------------------------------- /Sources/PlayersFeature/PlayerState+Persistence.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import Foundation 3 | import Models 4 | 5 | public extension Player.State { 6 | var persisted: PersistedPlayer { 7 | PersistedPlayer(id: id, name: name, image: image) 8 | } 9 | } 10 | 11 | public extension PersistedPlayer { 12 | var state: Player.State { 13 | Player.State(id: id, name: name, image: image) 14 | } 15 | } 16 | 17 | public extension IdentifiedArrayOf { 18 | static var example: Self { 19 | return Self(uniqueElements: IdentifiedArrayOf.example.map { 20 | Player.State(id: $0.id, name: $0.name, image: $0.image) 21 | }) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/PlayersFeature/RandomPlayer.swift: -------------------------------------------------------------------------------- 1 | import Assets 2 | import Dependencies 3 | import Foundation 4 | import XCTestDynamicOverlay 5 | 6 | struct RandomPlayerDepedencyKey: DependencyKey { 7 | static let liveValue: RandomPlayer = .live 8 | static let testValue: RandomPlayer = .test 9 | static let previewValue: RandomPlayer = .amelie 10 | } 11 | public extension DependencyValues { 12 | var randomPlayer: RandomPlayer { 13 | get { self[RandomPlayerDepedencyKey.self] } 14 | set { self[RandomPlayerDepedencyKey.self] = newValue } 15 | } 16 | } 17 | 18 | public struct RandomPlayer { 19 | let random: () -> Player.State 20 | 21 | public init(random: @escaping () -> Player.State) { 22 | self.random = random 23 | } 24 | 25 | static let live = Self { 26 | @Dependency(\.uuid) var uuid 27 | 28 | let name = ["Mathilde", "Renaud", "John", "Alice", "Bob", "CJ"].randomElement() ?? "" 29 | let image = MTImage.players.randomElement() ?? .unknown 30 | return Player.State(id: uuid(), name: name, image: image, color: .aluminium) 31 | } 32 | static let test = Self { 33 | XCTFail(#"Unimplemented: @Dependency(\.shufflePlayers)"#) 34 | return live.random() 35 | } 36 | static let amelie = Self { 37 | @Dependency(\.uuid) var uuid 38 | 39 | let image: MTImage = .amelie 40 | let name = "Amelie" 41 | return Player.State(id: uuid(), name: name, image: image, color: .aluminium) 42 | } 43 | 44 | public func callAsFunction() -> Player.State { 45 | random() 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Sources/PlayersFeature/ShufflePlayers.swift: -------------------------------------------------------------------------------- 1 | import Dependencies 2 | import XCTestDynamicOverlay 3 | 4 | struct ShufflePlayersDepedencyKey: DependencyKey { 5 | static let liveValue: ShufflePlayers = .live 6 | static let testValue: ShufflePlayers = .test 7 | static let previewValue: ShufflePlayers = .alphabeticallySorted 8 | } 9 | 10 | public extension DependencyValues { 11 | var shufflePlayers: ShufflePlayers { 12 | get { self[ShufflePlayersDepedencyKey.self] } 13 | set { self[ShufflePlayersDepedencyKey.self] = newValue } 14 | } 15 | } 16 | 17 | public struct ShufflePlayers { 18 | private let shuffle: ([Player.State]) -> [Player.State] 19 | 20 | static let live = Self { $0.shuffled() } 21 | static let test = Self { 22 | XCTFail(#"Unimplemented: @Dependency(\.shufflePlayers)"#) 23 | return $0.shuffled() 24 | } 25 | public static let alphabeticallySorted = Self { $0.sorted(by: { $0.name > $1.name }) } 26 | 27 | public func callAsFunction(players: [Player.State]) -> [Player.State] { 28 | shuffle(players) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/ScoresFeature/Round/Binding+IntAsString.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension Binding where Value == Int { 4 | var string: Binding { 5 | Binding( 6 | get: { String(wrappedValue) }, 7 | set: { 8 | guard $0 != "" else { return } 9 | wrappedValue = Int($0) ?? wrappedValue 10 | } 11 | ) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/ScoresFeature/Round/Round+Persistence.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import Models 3 | 4 | extension Round.State { 5 | var persisted: PersistedRound { 6 | PersistedRound(id: id, name: name, scores: IdentifiedArrayOf(uniqueElements: scores.map(\.persisted))) 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Sources/ScoresFeature/Round/Round.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import Foundation 3 | import Models 4 | import PersistenceCore 5 | import SwiftUI 6 | 7 | @Reducer 8 | public struct Round { 9 | @ObservableState 10 | public struct State: Identifiable, Equatable, Hashable { 11 | public let id: UUID 12 | public var name: String 13 | public var scores: IdentifiedArrayOf = [] 14 | 15 | public init(id: UUID, name: String, scores: IdentifiedArrayOf = []) { 16 | self.id = id 17 | self.name = name 18 | self.scores = scores 19 | } 20 | } 21 | 22 | public enum Action: BindableAction, Equatable { 23 | case binding(BindingAction) 24 | case scores(IdentifiedActionOf) 25 | } 26 | 27 | @Dependency(\.legacyScoresPersistence.updateRound) var legacyUpdateRound 28 | 29 | public init() {} 30 | 31 | public var body: some Reducer { 32 | BindingReducer() 33 | Reduce { state, action in 34 | switch action { 35 | case .binding: 36 | return .run { [state] _ in try await legacyUpdateRound(state.persisted) } 37 | case let .scores(.element(id: id, action: .remove)): 38 | state.scores.remove(id: id) 39 | return .none 40 | case .scores: 41 | return .none 42 | } 43 | } 44 | .forEach(\.scores, action: \.scores) { 45 | Score() 46 | } 47 | } 48 | } 49 | 50 | struct RoundView: View { 51 | @Bindable var store: StoreOf 52 | @FocusState var focusedField: Score.State? 53 | @FocusState var focusedHeader: Round.State? 54 | 55 | var body: some View { 56 | Section( 57 | header: TextField("Round name", text: $store.name) 58 | .focused($focusedHeader, equals: store.state) 59 | ) { 60 | ForEachStore(store.scope(state: \.scores, action: \.scores)) { store in 61 | ScoreRow(store: store, focusedField: _focusedField) 62 | } 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Sources/ScoresFeature/Round/RoundRow.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import SwiftUI 3 | 4 | struct RoundRow: View { 5 | let store: StoreOf 6 | 7 | var body: some View { 8 | VStack(alignment: .leading, spacing: 0) { 9 | ForEachStore(store.scope(state: \.scores, action: \.scores)) { store in 10 | ScoreRow(store: store) 11 | } 12 | } 13 | .listRowInsets(EdgeInsets()) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/ScoresFeature/Score/Score+Persistence.swift: -------------------------------------------------------------------------------- 1 | import Models 2 | 3 | extension Score.State { 4 | var persisted: PersistedScore { 5 | PersistedScore(id: id, teamID: team.id, points: points) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Sources/ScoresFeature/Score/Score.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import Foundation 3 | import Models 4 | import PersistenceCore 5 | import TeamsFeature 6 | 7 | @Reducer 8 | public struct Score { 9 | @ObservableState 10 | public struct State: Equatable, Identifiable { 11 | public let id: UUID 12 | public var team: Team.State 13 | public var points: Int = 0 14 | public var accumulatedPoints = 0 15 | 16 | public init(id: UUID, team: Team.State, points: Int = 0, accumulatedPoints: Int = 0) { 17 | self.id = id 18 | self.team = team 19 | self.points = points 20 | self.accumulatedPoints = accumulatedPoints 21 | } 22 | } 23 | 24 | public enum Action: BindableAction, Equatable { 25 | case binding(BindingAction) 26 | case remove 27 | } 28 | 29 | @Dependency(\.legacyScoresPersistence) var legacyScorePersistence 30 | 31 | public init() {} 32 | 33 | public var body: some Reducer { 34 | BindingReducer() 35 | Reduce { state, action in 36 | if case .binding = action { 37 | return .run { [state] _ in try await legacyScorePersistence.updateScore(state.persisted) 38 | } 39 | } 40 | return .none 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Sources/ScoresFeature/Score/ScoreRow.swift: -------------------------------------------------------------------------------- 1 | import Assets 2 | import ComposableArchitecture 3 | import Models 4 | import PersistenceCore 5 | import SwiftUI 6 | import TeamsFeature 7 | 8 | public struct ScoreRow: View { 9 | @Bindable var store: StoreOf 10 | @FocusState var focusedField: Score.State? 11 | 12 | public init(store: StoreOf, focusedField: FocusState = FocusState()) { 13 | self.store = store 14 | self._focusedField = focusedField 15 | } 16 | 17 | public var body: some View { 18 | HStack { 19 | Image(mtImage: store.team.image) 20 | .resizable() 21 | .frame(maxWidth: 24, maxHeight: 24) 22 | Text(store.team.name) 23 | .lineLimit(1) 24 | .frame(maxWidth: 120, alignment: .leading) 25 | 26 | TextField("", text: $store.points.string, prompt: Text("123")) 27 | .frame(maxWidth: 70) 28 | .focused($focusedField, equals: store.state) 29 | #if os(iOS) 30 | .keyboardType(.numberPad) 31 | #endif 32 | 33 | Spacer() 34 | 35 | Text("\(store.accumulatedPoints)") 36 | .bold() 37 | .frame(maxWidth: 50, alignment: .trailing) 38 | } 39 | .swipeActions { 40 | Button(role: .destructive) { store.send(.remove, animation: .default) } label: { 41 | Label("Delete", systemImage: "trash") 42 | } 43 | } 44 | #if os(iOS) 45 | .listRowSeparator(.hidden) 46 | #endif 47 | .backgroundAndForeground(color: store.team.color) 48 | .textFieldStyle(.roundedBorder) 49 | } 50 | 51 | private func content(team: Team.State?) -> some View { 52 | HStack { 53 | Image(mtImage: team?.image ?? .unknown) 54 | .resizable() 55 | .frame(maxWidth: 24, maxHeight: 24) 56 | Text(team?.name ?? "Placeholder team name") 57 | .lineLimit(1) 58 | .frame(maxWidth: 120, alignment: .leading) 59 | 60 | TextField("", text: $store.points.string, prompt: Text("123")) 61 | .frame(maxWidth: 70) 62 | .focused($focusedField, equals: store.state) 63 | #if os(iOS) 64 | .keyboardType(.numberPad) 65 | #endif 66 | 67 | Spacer() 68 | 69 | Text("\(store.accumulatedPoints)") 70 | .bold() 71 | .frame(maxWidth: 50, alignment: .trailing) 72 | } 73 | } 74 | } 75 | 76 | // TODO: Fix preview 77 | 78 | //#if DEBUG 79 | //struct ScoreRow_Previews: PreviewProvider { 80 | // static var previews: some View { 81 | // List { 82 | // ScoreRow(store: Store(initialState: .preview, reducer: Score())) 83 | // ScoreRow(store: Store(initialState: .secondPreview, reducer: Score())) 84 | // ScoreRow(store: Store( 85 | // initialState: .loadingPreview, 86 | // reducer: Score() 87 | // .dependency(\.teamPersistence.load, TeamPersistence.previewWithDelay) 88 | // )) 89 | // ScoreRow(store: Store( 90 | // initialState: .loadingPreview, 91 | // reducer: Score() 92 | // .dependency(\.teamPersistence.load, TeamPersistence.previewWithError) 93 | // )) 94 | // } 95 | // } 96 | //} 97 | // 98 | //extension Score.State { 99 | // static var preview: Self { 100 | // guard let id = UUID(uuidString: "8A74A892-2C3F-4BB4-A8B3-19C5B1E0AD84") else { 101 | // fatalError("Cannot generate UUID from a defined UUID String") 102 | // } 103 | // return Score.State(id: id, team: .preview, points: 15, accumulatedPoints: 35) 104 | // } 105 | // static var secondPreview: Self { 106 | // guard let id = UUID(uuidString: "7C3E9E9F-31CE-462B-9894-08C699B13AD0") else { 107 | // fatalError("Cannot generate UUID from a defined UUID String") 108 | // } 109 | // return Score.State(id: id, team: .preview, points: 25, accumulatedPoints: 35) 110 | // } 111 | // static var loadingPreview: Self { 112 | // guard let id = UUID(uuidString: "19DD415A-8769-473D-9F5C-308861274655") else { 113 | // fatalError("Cannot generate UUID from a defined UUID String") 114 | // } 115 | // return Score.State(id: id, team: .preview, points: 1, accumulatedPoints: 10) 116 | // } 117 | //} 118 | // 119 | //private extension TeamPersistence { 120 | // static let previewWithDelay: () async throws -> IdentifiedArrayOf = { 121 | // try await Task.sleep(nanoseconds: 1_000_000_000 * 2) 122 | // let team = Team.State.preview 123 | // return [PersistedTeam( 124 | // id: team.id, 125 | // name: team.name, 126 | // color: team.color, 127 | // image: team.image, 128 | // playerIDs: team.players.map(\.id), 129 | // isArchived: team.isArchived 130 | // )] 131 | // } 132 | // static let previewWithError: () async throws -> IdentifiedArrayOf = { 133 | // try await Task.sleep(nanoseconds: 1_000_000_000 * 2) 134 | // struct PreviewError: Error {} 135 | // throw PreviewError() 136 | // } 137 | //} 138 | //#endif 139 | -------------------------------------------------------------------------------- /Sources/ScoresFeature/Scoreboard/Scoreboard.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import LoaderCore 3 | import Models 4 | import PersistenceCore 5 | 6 | @Reducer 7 | public struct Scoreboard { 8 | @ObservableState 9 | public enum State: Equatable { 10 | case loadingCard 11 | case loaded(Scores.State) 12 | case errorCard(ErrorCard.State) 13 | } 14 | 15 | public enum Action: Equatable { 16 | case update(TaskResult) 17 | case loadingCard(LoadingCard.Action) 18 | case scores(Scores.Action) 19 | case errorCard(ErrorCard.Action) 20 | } 21 | 22 | @Dependency(\.legacyScoresPersistence) var legacyScoresPersistence 23 | @Dependency(\.legacyTeamPersistence) var legacyTeamPersistence 24 | 25 | public init() {} 26 | 27 | public var body: some Reducer { 28 | Reduce { state, action in 29 | switch action { 30 | case let .update(result): 31 | switch result { 32 | case let .success(result): 33 | state = .loaded(result) 34 | return .none 35 | case let .failure(error): 36 | state = .errorCard(ErrorCard.State(description: error.localizedDescription)) 37 | return .none 38 | } 39 | case .loadingCard: 40 | return load(state: &state).concatenate(with: .run { send in 41 | for try await _ in legacyTeamPersistence.publisher() { 42 | await send(.update(TaskResult { try await legacyScoresPersistence.load().state })) 43 | } 44 | }) 45 | case .scores: 46 | return .none 47 | case .errorCard(.reload): 48 | return load(state: &state) 49 | } 50 | } 51 | .ifCaseLet(\.loadingCard, action: \.loadingCard) { 52 | LoadingCard() 53 | } 54 | .ifCaseLet(\.loaded, action: \.scores) { 55 | Scores() 56 | } 57 | .ifCaseLet(\.errorCard, action: \.errorCard) { 58 | ErrorCard() 59 | } 60 | } 61 | 62 | private func load(state: inout State) -> Effect { 63 | state = .loadingCard 64 | return .run { send in await send(.update(TaskResult { try await legacyScoresPersistence.load().state })) } 65 | } 66 | } 67 | 68 | public extension PersistedScores { 69 | var state: Scores.State { 70 | get async throws { 71 | @Dependency(\.legacyTeamPersistence) var legacyTeamPersistence 72 | let teams = try await legacyTeamPersistence.load().states 73 | return Scores.State( 74 | teams: try await legacyTeamPersistence.load().states, 75 | rounds: IdentifiedArrayOf(uniqueElements: rounds.map { Round.State( 76 | id: $0.id, 77 | name: $0.name, 78 | scores: IdentifiedArrayOf(uniqueElements: $0.scores.compactMap { 79 | guard let team = teams[id: $0.teamID] else { return nil } 80 | return Score.State( 81 | id: $0.id, 82 | team: team, 83 | points: $0.points, 84 | accumulatedPoints: 0 85 | ) 86 | }) 87 | )}) 88 | ) 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /Sources/ScoresFeature/Scoreboard/ScoreboardView.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import LoaderCore 3 | import Models 4 | import PersistenceCore 5 | import SwiftUI 6 | 7 | public struct ScoreboardView: View { 8 | let store: StoreOf 9 | 10 | public init(store: StoreOf) { 11 | self.store = store 12 | } 13 | 14 | public var body: some View { 15 | Group { 16 | switch store.state { 17 | case .loadingCard: 18 | if let store = store.scope(state: \.loadingCard, action: \.loadingCard) { 19 | LoadingCardView(store: store) 20 | } 21 | case .loaded: 22 | if let store = store.scope(state: \.loaded, action: \.scores) { 23 | ScoresView(store: store) 24 | } 25 | case .errorCard: 26 | if let store = store.scope(state: \.errorCard, action: \.errorCard) { 27 | ErrorCardView(store: store) 28 | } 29 | } 30 | } 31 | .tabItem { 32 | Label("Scoreboard", systemImage: "list.bullet.clipboard") 33 | } 34 | } 35 | } 36 | 37 | // TODO: Fix preview 38 | 39 | //#if DEBUG 40 | //struct ScorebordView_Previews: PreviewProvider { 41 | // static var previews: some View { 42 | // ScoreboardView(store: .preview) 43 | // ScoreboardView(store: .previewWithLongLoading) 44 | // .previewDisplayName("Scoreboard View With Long Loading") 45 | // ScoreboardView(store: .previewWithError) 46 | // .previewDisplayName("Scoreboard View With Error") 47 | // } 48 | //} 49 | // 50 | //extension Store where State == Scoreboard.State, Action == Scoreboard.Action { 51 | // static var preview: Self { 52 | // Self(initialState: .loadingCard, reducer: Scoreboard()) 53 | // } 54 | // static var previewWithLongLoading: Self { 55 | // Self( 56 | // initialState: .loadingCard, 57 | // reducer: Scoreboard() 58 | // .dependency(\.scoresPersistence.load, { 59 | // try await Task.sleep(nanoseconds: 1_000_000_000 * 5) 60 | // return .previewWithScores(count: 5) 61 | // }) 62 | // ) 63 | // } 64 | // static var previewWithError: Self { 65 | // Self( 66 | // initialState: .loadingCard, 67 | // reducer: Scoreboard() 68 | // .dependency(\.scoresPersistence.load, { 69 | // try await Task.sleep(nanoseconds: 1_000_000_000 * 2) 70 | // throw FakeError() 71 | // }) 72 | // ) 73 | // } 74 | // 75 | // struct FakeError: Error {} 76 | //} 77 | // 78 | //extension PersistedScores { 79 | // static func previewWithScores(count: Int) -> Self { 80 | // ScoresFeature.Scores.State.previewWithScores(count: count).persisted 81 | // } 82 | //} 83 | //#endif 84 | -------------------------------------------------------------------------------- /Sources/ScoresFeature/Scoreboard/TotalScoresView.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import SwiftUI 3 | import TeamsFeature 4 | 5 | struct TotalScoresView: View { 6 | let store: StoreOf 7 | 8 | var body: some View { 9 | Section(header: Text("Total")) { 10 | ForEach(store.teams) { team in 11 | HStack { 12 | Image(mtImage: team.image) 13 | .resizable() 14 | .frame(maxWidth: 24, maxHeight: 24) 15 | Text("\(team.name)") 16 | Spacer() 17 | Text(store.state.total(for: team)) 18 | } 19 | .font(.body.bold()) 20 | .backgroundAndForeground(color: team.color) 21 | #if os(iOS) 22 | .listRowSeparator(.hidden) 23 | #endif 24 | } 25 | } 26 | } 27 | } 28 | 29 | private extension Scores.State { 30 | func total(for team: Team.State) -> String { 31 | String( 32 | rounds 33 | .flatMap(\.scores) 34 | .filter { $0.team == team } 35 | .map(\.points) 36 | .reduce(0, +) 37 | ) 38 | } 39 | } 40 | 41 | #if DEBUG 42 | struct TotalScoresView_Previews: PreviewProvider { 43 | static var previews: some View { 44 | List { 45 | TotalScoresView(store: .preview) 46 | } 47 | } 48 | } 49 | #endif 50 | -------------------------------------------------------------------------------- /Sources/ScoresFeature/Scores/Scores+Persistence.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import Models 3 | 4 | extension Scores.State { 5 | var persisted: PersistedScores { 6 | PersistedScores(rounds: IdentifiedArrayOf(uniqueElements: rounds.map(\.persisted))) 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Sources/ScoresFeature/Scores/Scores.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import Models 3 | import PersistenceCore 4 | import SwiftUI 5 | import TeamsFeature 6 | 7 | @Reducer 8 | public struct Scores { 9 | @ObservableState 10 | public struct State: Equatable { 11 | public var teams: IdentifiedArrayOf = [] 12 | public var rounds: IdentifiedArrayOf = [] 13 | public var focusedField: Score.State? 14 | } 15 | 16 | public enum Action: BindableAction, Equatable { 17 | case task 18 | case addRound 19 | case updateAccumulatedPoints(IdentifiedArrayOf) 20 | case rounds(IdentifiedActionOf) 21 | case minusScore(score: Score.State?) 22 | case binding(BindingAction) 23 | } 24 | 25 | @Dependency(\.uuid) var uuid 26 | @Dependency(\.legacyScoresPersistence) var legacyScoresPersistence 27 | @Dependency(\.legacyTeamPersistence) var legacyTeamPersistence 28 | private enum CancelID { case recalculateTask } 29 | 30 | public init() {} 31 | 32 | public var body: some Reducer { 33 | BindingReducer() 34 | Reduce { state, action in 35 | switch action { 36 | case .task: 37 | return recalculateAccumulatedPoints(state: &state) 38 | case .addRound: 39 | let roundCount = state.rounds.count 40 | let scores = IdentifiedArrayOf(uniqueElements: state.teams.filter { !$0.isArchived }.map { team in 41 | Score.State( 42 | id: uuid(), 43 | team: team, 44 | points: 0, 45 | accumulatedPoints: state.rounds.accumulatedPoints(for: team, roundCount: roundCount) 46 | ) 47 | }) 48 | state.rounds.append(Round.State(id: uuid(), name: "Round \(roundCount + 1)", scores: scores)) 49 | return .run { [state] _ in 50 | try await legacyScoresPersistence.save(state.persisted) 51 | } 52 | case let .updateAccumulatedPoints(rounds): 53 | state.rounds = rounds 54 | return .none 55 | case let .rounds(.element(id, action: .scores(.element(_, action: .remove)))): 56 | if state.rounds[id: id]?.scores.isEmpty == true { 57 | state.rounds.remove(id: id) 58 | } 59 | return .merge( 60 | .run { [state] _ in try await legacyScoresPersistence.save(state.persisted) }, 61 | recalculateAccumulatedPoints(state: &state) 62 | ) 63 | case .rounds(.element(_, action: .scores(.element(_, action: .binding)))): 64 | return recalculateAccumulatedPoints(state: &state) 65 | case .rounds: 66 | return .none 67 | case let .minusScore(score): 68 | guard let score, 69 | let roundID = state.rounds.first(where: { $0.scores.contains(score) })?.id 70 | else { return .none } 71 | 72 | state.rounds[id: roundID]?.scores[id: score.id]?.points = -score.points 73 | return .merge( 74 | .run { [state] _ in try await legacyScoresPersistence.save(state.persisted) }, 75 | recalculateAccumulatedPoints(state: &state) 76 | ) 77 | case .binding: 78 | return .none 79 | } 80 | } 81 | .forEach(\.rounds, action: \.rounds) { 82 | Round() 83 | } 84 | } 85 | 86 | private func recalculateAccumulatedPoints(state: inout State) -> Effect { 87 | .cancel(id: CancelID.recalculateTask).concatenate(with: .run { [rounds = state.rounds] send in 88 | var rounds = rounds 89 | for (index, round) in rounds.enumerated() { 90 | for team in rounds[id: round.id]?.scores.map(\.team) ?? [] { 91 | let accumulatedPoints = rounds.accumulatedPoints(for: team, roundCount: index + 1) 92 | guard let scoreID = rounds[id: round.id]?.scores.first(where: { $0.team == team })?.id 93 | else { continue } 94 | rounds[id: round.id]?.scores[id: scoreID]?.accumulatedPoints = accumulatedPoints 95 | } 96 | } 97 | await send(.updateAccumulatedPoints(rounds)) 98 | }) 99 | .cancellable(id: CancelID.recalculateTask) 100 | } 101 | } 102 | 103 | private extension IdentifiedArrayOf { 104 | func accumulatedPoints(for team: Team.State, roundCount: Int) -> Int { 105 | guard roundCount > 0, roundCount <= count else { return 0 } 106 | return self[...(roundCount - 1)].flatMap(\.scores).filter { $0.team == team }.map(\.points).reduce(0, +) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /Sources/ScoresFeature/Scores/ScoresView.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import SwiftUI 3 | import TeamsFeature 4 | 5 | struct ScoresView: View { 6 | @Bindable var store: StoreOf 7 | @FocusState private var focusedField: Score.State? 8 | @FocusState private var focusedHeader: Round.State? 9 | 10 | var body: some View { 11 | NavigationView { 12 | ZStack { 13 | if store.rounds.count > 0 { 14 | list 15 | .bind($store.focusedField, to: $focusedField) 16 | .toolbar { 17 | ToolbarItemGroup(placement: .keyboard) { 18 | if focusedHeader == nil { 19 | Button { store.send(.minusScore(score: focusedField)) } label: { 20 | Label("Positive/Negative", systemImage: "plus.forwardslash.minus") 21 | } 22 | 23 | Button { 24 | focusedField = nil 25 | focusedHeader = nil 26 | } label: { 27 | Label("Done", systemImage: "checkmark") 28 | } 29 | } 30 | } 31 | } 32 | } else { 33 | VStack { 34 | Text("Add your first round by tapping on the plus button") 35 | Button { store.send(.addRound) } label: { 36 | Label("Add a new round", systemImage: "plus") 37 | .labelStyle(.iconOnly) 38 | } 39 | .padding() 40 | } 41 | .padding() 42 | } 43 | } 44 | .navigationTitle(Text("Scoreboard")) 45 | .toolbar { 46 | #if os(iOS) 47 | ToolbarItem(placement: .navigationBarTrailing) { 48 | Button { store.send(.addRound, animation: .default) } label: { 49 | Label("Add a new round", systemImage: "plus") 50 | } 51 | } 52 | #else 53 | ToolbarItem() { 54 | Button { store.send(.addRound, animation: .default) } label: { 55 | Label("Add a new round", systemImage: "plus") 56 | } 57 | } 58 | #endif 59 | } 60 | } 61 | .backgroundAndForeground(color: .aluminium) 62 | .task { store.send(.task) } 63 | } 64 | 65 | private var list: some View { 66 | List { 67 | ForEachStore(store.scope(state: \.rounds, action: \.rounds)) { store in 68 | RoundView(store: store, focusedField: _focusedField, focusedHeader: _focusedHeader) 69 | } 70 | TotalScoresView(store: store) 71 | } 72 | } 73 | } 74 | 75 | extension Score.State: Hashable { 76 | public func hash(into hasher: inout Hasher) { 77 | hasher.combine(id) 78 | } 79 | } 80 | 81 | #if DEBUG 82 | struct ScoresView_Previews: PreviewProvider { 83 | static var previews: some View { 84 | ScoresView(store: .preview) 85 | ScoresView(store: .previewWithScores) 86 | ScoresView(store: .previewWithManyScores) 87 | } 88 | } 89 | 90 | extension Store where State == Scores.State, Action == Scores.Action { 91 | static var preview: Self { 92 | Self(initialState: .preview) { Scores() } 93 | } 94 | static var previewWithScores: Self { 95 | Self(initialState: .previewWithScores(count: 5)) { Scores() } 96 | } 97 | static var previewWithManyScores: Self { 98 | Self(initialState: .previewWithScores(count: 300)) { Scores() } 99 | } 100 | } 101 | 102 | public extension Scores.State { 103 | static var preview: Self { 104 | Self(teams: .example) 105 | } 106 | static func previewWithScores(count: Int) -> Self { 107 | let teams: IdentifiedArrayOf = .example 108 | let uuid = UUIDGenerator.incrementing 109 | return Scores.State(teams: teams, rounds: IdentifiedArrayOf(uniqueElements: (1...count).map { i in 110 | Round.State( 111 | id: uuid(), 112 | name: "Round \(i)", 113 | scores: IdentifiedArrayOf(uniqueElements: teams.map { 114 | Score.State(id: uuid(), team: $0, points: 10 * i, accumulatedPoints: 10 * i + 10 * (i - 1)) 115 | })) 116 | })) 117 | } 118 | } 119 | #endif 120 | -------------------------------------------------------------------------------- /Sources/SettingsFeature/Settings.swift: -------------------------------------------------------------------------------- 1 | import ArchivesFeature 2 | import ComposableArchitecture 3 | import RenaudJennyAboutView 4 | import SwiftUI 5 | 6 | @Reducer 7 | public struct Settings { 8 | public struct State: Equatable { 9 | var archives: Archives.State = .loadingCard 10 | 11 | public init(archives: Archives.State = .loadingCard) { 12 | self.archives = archives 13 | } 14 | } 15 | 16 | public enum Action: Equatable { 17 | case archives(Archives.Action) 18 | } 19 | 20 | public init() {} 21 | 22 | public var body: some Reducer { 23 | Scope(state: \.archives, action: \.archives) { 24 | Archives() 25 | } 26 | } 27 | } 28 | 29 | public struct SettingsView: View { 30 | let store: StoreOf 31 | @Environment(\.colorScheme) private var colorScheme 32 | 33 | public init(store: StoreOf) { 34 | self.store = store 35 | } 36 | 37 | public var body: some View { 38 | NavigationView { 39 | List { 40 | NavigationLink { aboutView } label: { Text("About") } 41 | NavigationLink { 42 | ArchivesView(store: store.scope(state: \.archives, action: \.archives)) 43 | } label: { 44 | Text("Archives") 45 | } 46 | } 47 | .navigationTitle("Settings") 48 | } 49 | .tabItem { 50 | Label("Settings", systemImage: "gear") 51 | } 52 | } 53 | 54 | private var aboutView: some View { 55 | RenaudJennyAboutView.AboutView(appId: "id1526493495") { 56 | #if os(iOS) 57 | Image(uiImage: #imageLiteral(resourceName: "Logo")) 58 | .cornerRadius(16) 59 | .padding() 60 | .padding(.top) 61 | .shadow(radius: 5) 62 | #else 63 | Image(nsImage: #imageLiteral(resourceName: "Logo")) 64 | .cornerRadius(16) 65 | .padding() 66 | .padding(.top) 67 | .shadow(radius: 5) 68 | #endif 69 | } 70 | } 71 | } 72 | 73 | #Preview { 74 | SettingsView(store: Store(initialState: Settings.State()) { 75 | Settings() 76 | }) 77 | } 78 | -------------------------------------------------------------------------------- /Sources/StyleCore/DashedStyles.swift: -------------------------------------------------------------------------------- 1 | import Assets 2 | import SwiftUI 3 | 4 | enum StandardMetrics { 5 | static let cornerRadius = 12.0 6 | } 7 | 8 | // MARK: - Dashed Button Style 9 | 10 | public struct DashedButtonStyle: ButtonStyle { 11 | let color: MTColor 12 | @Environment(\.colorScheme) private var colorScheme 13 | 14 | public init(color: MTColor) { 15 | self.color = color 16 | } 17 | 18 | public func makeBody(configuration: Configuration) -> some View { 19 | HStack { 20 | configuration.label 21 | .foregroundColor(color.foregroundColor(scheme: colorScheme)) 22 | } 23 | .padding() 24 | .background( 25 | RoundedRectangle(cornerRadius: StandardMetrics.cornerRadius) 26 | .fill(color.backgroundColor(scheme: colorScheme)) 27 | .overlay( 28 | RoundedRectangle(cornerRadius: StandardMetrics.cornerRadius - 2) 29 | .stroke(style: strokeStyle(isPressed: configuration.isPressed)) 30 | .padding(3) 31 | ) 32 | .foregroundColor(color.foregroundColor(scheme: colorScheme)) 33 | .modifier(MTShadow(isApplied: !configuration.isPressed)) 34 | ) 35 | } 36 | 37 | func strokeStyle(isPressed: Bool) -> StrokeStyle { 38 | if isPressed { 39 | return StrokeStyle( 40 | lineWidth: 1, 41 | dash: [5, 2], 42 | dashPhase: 2 43 | ) 44 | } 45 | return StrokeStyle( 46 | lineWidth: 1, 47 | dash: [5, 2], 48 | dashPhase: 3 49 | ) 50 | } 51 | } 52 | 53 | public extension ButtonStyle where Self == DashedButtonStyle { 54 | static func dashed(color: MTColor) -> Self { 55 | DashedButtonStyle(color: color) 56 | } 57 | } 58 | 59 | #if DEBUG 60 | struct MixTeamButtonStyle_Previews: PreviewProvider { 61 | static var previews: some View { 62 | VStack { 63 | Button(action: action) { 64 | Text("Common Button Style (Aluminium)") 65 | } 66 | .buttonStyle(DashedButtonStyle(color: .aluminium)) 67 | 68 | Button(action: action) { 69 | Text("Common Button Style (Duck)") 70 | } 71 | .buttonStyle(DashedButtonStyle(color: .duck)) 72 | 73 | Button(action: action) { 74 | Text("Small") 75 | } 76 | .buttonStyle(DashedButtonStyle(color: .peach)) 77 | 78 | Button(action: action) { 79 | Image(systemName: "moon") 80 | Text("With an icon") 81 | } 82 | .buttonStyle(DashedButtonStyle(color: .strawberry)) 83 | 84 | Button(action: action) { 85 | Image(systemName: "cube.box") 86 | .resizable() 87 | .frame(width: 60, height: 60) 88 | } 89 | .buttonStyle(DashedButtonStyle(color: .leather)) 90 | } 91 | .frame(maxWidth: .infinity, maxHeight: .infinity) 92 | .backgroundAndForeground(color: .aluminium) 93 | } 94 | 95 | private static let action = { } 96 | } 97 | #endif 98 | 99 | // MARK: - Dashed Card Style 100 | 101 | private struct DashedCardStyle: ViewModifier { 102 | let color: MTColor 103 | let isShadowApplied: Bool 104 | @Environment(\.colorScheme) private var colorScheme 105 | 106 | func body(content: Content) -> some View { 107 | content 108 | .padding(12) 109 | .background(color.backgroundColor(scheme: colorScheme)) 110 | .overlay(overlay) 111 | .clipShape(RoundedRectangle(cornerRadius: StandardMetrics.cornerRadius)) 112 | .modifier(MTShadow(isApplied: isShadowApplied)) 113 | } 114 | 115 | private var overlay: some View { 116 | RoundedRectangle(cornerRadius: StandardMetrics.cornerRadius - 2) 117 | .stroke(style: .init(lineWidth: 1, dash: [5, 2], dashPhase: 3)) 118 | .padding(3) 119 | } 120 | } 121 | 122 | public extension View { 123 | func dashedCardStyle(color: MTColor, isShadowApplied: Bool = true) -> some View { 124 | modifier(DashedCardStyle(color: color, isShadowApplied: isShadowApplied)) 125 | } 126 | } 127 | 128 | #if DEBUG 129 | struct DashedCardStyle_Previews: PreviewProvider { 130 | static var previews: some View { 131 | VStack { 132 | TextField("Test", text: .constant("Test")) 133 | .dashedCardStyle(color: .aluminium) 134 | .frame(width: 300, height: 300) 135 | 136 | Rectangle() 137 | .fill(MTColor.aluminium.backgroundColor(scheme: .light)) 138 | .dashedCardStyle(color: .aluminium) 139 | .frame(width: 300, height: 300) 140 | } 141 | } 142 | } 143 | #endif 144 | 145 | // MARK: MixTeam Shadow 146 | 147 | private struct MTShadow: ViewModifier { 148 | var isApplied: Bool 149 | private let shadowColor = Color(.sRGBLinear, white: 0, opacity: 0.25) 150 | 151 | func body(content: Content) -> some View { 152 | content 153 | .background( 154 | Color.white.clipShape(RoundedRectangle(cornerRadius: 20)).shadow( 155 | color: shadowColor, 156 | radius: radius, x: x, y: y 157 | ) 158 | ) 159 | } 160 | 161 | private var radius: CGFloat { isApplied ? 3 : 0 } 162 | private var x: CGFloat { isApplied ? -2 : 0 } 163 | private var y: CGFloat { isApplied ? 2 : 0 } 164 | } 165 | -------------------------------------------------------------------------------- /Sources/TeamsFeature/EditTeamView.swift: -------------------------------------------------------------------------------- 1 | import Assets 2 | import ComposableArchitecture 3 | import ImagePicker 4 | import StyleCore 5 | import SwiftUI 6 | 7 | public struct EditTeamView: View { 8 | @Bindable var store: StoreOf 9 | #if os(iOS) 10 | @Environment(\.verticalSizeClass) private var verticalSizeClass 11 | #endif 12 | 13 | public init(store: StoreOf) { 14 | self.store = store 15 | } 16 | 17 | #if os(iOS) 18 | public var body: some View { 19 | ScrollView { 20 | teamNameField 21 | if verticalSizeClass == .compact { 22 | GeometryReader { geometry in 23 | HStack { 24 | IllustrationPickerView( 25 | store: store.scope(state: \.illustrationPicker, action: \.illustrationPicker) 26 | ) 27 | .frame(width: geometry.size.width * 3/4) 28 | colorPicker 29 | .frame(width: geometry.size.width * 1/4) 30 | } 31 | } 32 | } else { 33 | VStack(spacing: 16) { 34 | colorPicker 35 | VStack(spacing: 0) { 36 | Text("Choose a mascot") 37 | IllustrationPickerView( 38 | store: store.scope(state: \.illustrationPicker, action: \.illustrationPicker) 39 | ) 40 | } 41 | } 42 | } 43 | } 44 | .backgroundAndForeground(color: store.color) 45 | .animation(.easeInOut, value: store.color) 46 | .navigationTitle("Editing \(store.name)") 47 | } 48 | #else 49 | public var body: some View { 50 | ScrollView { 51 | teamNameField 52 | VStack(spacing: 16) { 53 | colorPicker 54 | VStack(spacing: 0) { 55 | Text("Choose a mascot") 56 | IllustrationPickerView( 57 | store: store.scope(state: \.illustrationPicker, action: \.illustrationPicker) 58 | ) 59 | } 60 | } 61 | } 62 | .backgroundAndForeground(color: store.color) 63 | .animation(.easeInOut, value: store.color) 64 | } 65 | #endif 66 | 67 | private var teamNameField: some View { 68 | TextField("Edit", text: $store.name) 69 | .font(.title2.weight(.black)) 70 | .multilineTextAlignment(.center) 71 | .dashedCardStyle(color: store.color) 72 | .padding() 73 | } 74 | 75 | private var colorPicker: some View { 76 | VStack { 77 | Text("Choose a colour") 78 | #if os(iOS) 79 | if verticalSizeClass == .compact { 80 | HStack { 81 | VStack(spacing: 20) { 82 | color(.peach) 83 | color(.strawberry) 84 | color(.lilac) 85 | } 86 | VStack(spacing: 20) { 87 | color(.leather) 88 | color(.conifer) 89 | color(.duck) 90 | color(.bluejeans) 91 | } 92 | } 93 | } else { 94 | VStack { 95 | HStack(spacing: 20) { 96 | color(.leather) 97 | color(.conifer) 98 | color(.duck) 99 | color(.bluejeans) 100 | } 101 | HStack(spacing: 20) { 102 | color(.peach) 103 | color(.strawberry) 104 | color(.lilac) 105 | } 106 | } 107 | } 108 | #else 109 | VStack { 110 | HStack(spacing: 20) { 111 | color(.leather) 112 | color(.conifer) 113 | color(.duck) 114 | color(.bluejeans) 115 | } 116 | HStack(spacing: 20) { 117 | color(.peach) 118 | color(.strawberry) 119 | color(.lilac) 120 | } 121 | } 122 | #endif 123 | } 124 | .padding() 125 | } 126 | 127 | private func color(_ color: MTColor) -> some View { 128 | Button { store.send(.set(\.color, color)) } label: { 129 | Color.clear 130 | .frame(width: 48, height: 48) 131 | .overlay(Splash(animatableData: store.color == color ? 1 : 0).stroke(lineWidth: 2.5)) 132 | .backgroundAndForeground(color: color) 133 | .clipShape(Splash(animatableData: store.color == color ? 1 : 0)) 134 | } 135 | .accessibility(label: Text("\(color.rawValue)")) 136 | } 137 | } 138 | 139 | #if DEBUG 140 | public extension Team.State { 141 | static var preview: Self { 142 | guard let id = UUID(uuidString: "EF9D6B84-B19A-4177-B5F7-6E2478FAAA18") else { 143 | fatalError("Cannot generate UUID from a defined UUID String") 144 | } 145 | return Team.State( 146 | id: id, 147 | name: "Team test", 148 | color: .strawberry, 149 | image: .koala 150 | ) 151 | } 152 | } 153 | 154 | #Preview { 155 | NavigationView { 156 | EditTeamView(store: Store(initialState: .preview) { Team() }) 157 | } 158 | } 159 | #endif 160 | -------------------------------------------------------------------------------- /Sources/TeamsFeature/RandomTeam.swift: -------------------------------------------------------------------------------- 1 | import Assets 2 | import Dependencies 3 | import Foundation 4 | import XCTestDynamicOverlay 5 | 6 | struct RandomTeamDepedencyKey: DependencyKey { 7 | static let liveValue: RandomTeam = .live 8 | static let testValue: RandomTeam = .test 9 | static let previewValue: RandomTeam = .strawberryBunny 10 | } 11 | public extension DependencyValues { 12 | var randomTeam: RandomTeam { 13 | get { self[RandomTeamDepedencyKey.self] } 14 | set { self[RandomTeamDepedencyKey.self] = newValue } 15 | } 16 | } 17 | 18 | public struct RandomTeam { 19 | private let random: () -> Team.State 20 | 21 | static let live = Self { 22 | @Dependency(\.uuid) var uuid 23 | 24 | let image = MTImage.teams.randomElement() ?? .koala 25 | let color = MTColor.allCases.filter({ $0 != .aluminium }).randomElement() ?? .aluminium 26 | let name = "\(color.rawValue) \(image.rawValue)".localizedCapitalized 27 | return Team.State(id: uuid(), name: name, color: color, image: image) 28 | } 29 | static let test = Self { 30 | XCTFail(#"Unimplemented: @Dependency(\.shufflePlayers)"#) 31 | return live.random() 32 | } 33 | public static let strawberryBunny = Self { 34 | @Dependency(\.uuid) var uuid 35 | 36 | let image: MTImage = .bunny 37 | let color: MTColor = .strawberry 38 | let name = "\(color.rawValue) \(image.rawValue)".localizedCapitalized 39 | return Team.State(id: uuid(), name: name, color: color, image: image) 40 | } 41 | 42 | public func callAsFunction() -> Team.State { 43 | random() 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/TeamsFeature/Team+Persistence.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import Foundation 3 | import Models 4 | import PlayersFeature 5 | 6 | public extension Team.State { 7 | var persisted: PersistedTeam { 8 | PersistedTeam( 9 | id: id, 10 | name: name, 11 | color: color, 12 | image: image, 13 | playerIDs: players.map(\.id), 14 | isArchived: isArchived 15 | ) 16 | } 17 | } 18 | 19 | public extension PersistedTeam { 20 | var state: Team.State { 21 | get async throws { 22 | @Dependency(\.legacyPlayerPersistence) var legacyPlayerPersistence 23 | 24 | let players = try await legacyPlayerPersistence.load() 25 | let teamPlayers = IdentifiedArrayOf(uniqueElements: playerIDs.compactMap { 26 | var player = players[id: $0]?.state 27 | player?.color = color 28 | return player 29 | }) 30 | return Team.State( 31 | id: id, 32 | name: name, 33 | color: color, 34 | image: image, 35 | players: teamPlayers, 36 | isArchived: isArchived 37 | ) 38 | } 39 | } 40 | } 41 | 42 | public extension IdentifiedArrayOf { 43 | var states: IdentifiedArrayOf { 44 | get async throws { 45 | var states: [Team.State] = [] 46 | for team in self { 47 | states.append(try await team.state) 48 | } 49 | return IdentifiedArrayOf(uniqueElements: states) 50 | } 51 | } 52 | } 53 | 54 | public extension IdentifiedArrayOf { 55 | static var example: Self { 56 | let players = IdentifiedArrayOf.example 57 | var teams: Self = [] 58 | 59 | for team in IdentifiedArrayOf.example { 60 | var playerStates: IdentifiedArrayOf = [] 61 | for playerID in team.playerIDs { 62 | if var playerState = players[id: playerID] { 63 | playerState.color = team.color 64 | playerStates.updateOrAppend(playerState) 65 | } 66 | } 67 | teams.updateOrAppend(Team.State( 68 | id: team.id, 69 | name: team.name, 70 | color: team.color, 71 | image: team.image, 72 | players: playerStates, 73 | isArchived: team.isArchived 74 | )) 75 | } 76 | return teams 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Sources/TeamsFeature/Team.swift: -------------------------------------------------------------------------------- 1 | import Assets 2 | import ComposableArchitecture 3 | import Foundation 4 | import ImagePicker 5 | import Models 6 | import PersistenceCore 7 | import PlayersFeature 8 | 9 | @Reducer 10 | public struct Team { 11 | @ObservableState 12 | public struct State: Equatable, Identifiable { 13 | public let id: UUID 14 | // TODO: should use Shared API 15 | public var name: String = "" 16 | public var color: MTColor = .aluminium 17 | public var image: MTImage = .unknown 18 | public var players: IdentifiedArrayOf = [] 19 | public var isArchived = false 20 | var illustrationPicker: IllustrationPicker.State { 21 | IllustrationPicker.State( 22 | images: IdentifiedArrayOf(uniqueElements: MTImage.teams), 23 | color: color, 24 | selectedImage: image 25 | ) 26 | } 27 | 28 | public init( 29 | id: UUID, 30 | name: String, 31 | color: MTColor, 32 | image: MTImage, 33 | players: IdentifiedArrayOf = [], 34 | isArchived: Bool = false 35 | ) { 36 | self.id = id 37 | self.name = name 38 | self.color = color 39 | self.image = image 40 | self.players = players 41 | self.isArchived = isArchived 42 | } 43 | } 44 | 45 | public enum Action: BindableAction, Equatable { 46 | case binding(BindingAction) 47 | case moveBackPlayer(id: Player.State.ID) 48 | case player(IdentifiedActionOf) 49 | case illustrationPicker(IllustrationPicker.Action) 50 | } 51 | 52 | @Dependency(\.legacyTeamPersistence) var legacyTeamPersistence 53 | 54 | public init() {} 55 | 56 | public var body: some Reducer { 57 | BindingReducer() 58 | Reduce { state, action in 59 | switch action { 60 | case .binding: 61 | return .run { [state] _ in try await legacyTeamPersistence.updateOrAppend(state.persisted) } 62 | case let .moveBackPlayer(id): 63 | state.players.remove(id: id) 64 | return .run { [state] _ in try await legacyTeamPersistence.updateOrAppend(state.persisted) } 65 | case .player: 66 | return .none 67 | case let .illustrationPicker(.imageTapped(image)): 68 | state.image = image 69 | return .run { [state] _ in try await legacyTeamPersistence.updateOrAppend(state.persisted) } 70 | } 71 | } 72 | .forEach(\.players, action: \.player) { 73 | Player() 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Sources/TeamsFeature/TeamRow.swift: -------------------------------------------------------------------------------- 1 | import Assets 2 | import ComposableArchitecture 3 | import PlayersFeature 4 | import SwiftUI 5 | 6 | public struct TeamRow: View { 7 | let store: StoreOf 8 | 9 | public init(store: StoreOf) { 10 | self.store = store 11 | } 12 | 13 | public var body: some View { 14 | Section { 15 | header 16 | ForEachStore(store.scope(state: \.players, action: \.player)) { playerStore in 17 | PlayerRow(store: playerStore) 18 | .swipeActions(allowsFullSwipe: true) { 19 | Button { 20 | store.send(.moveBackPlayer(id: playerStore.id), animation: .default) 21 | } label: { 22 | Image(systemName: "gobackward") 23 | } 24 | .buttonStyle(.plain) 25 | } 26 | } 27 | } 28 | } 29 | 30 | private var header: some View { 31 | NavigationLink(destination: EditTeamView(store: store)) { 32 | HStack { 33 | Image(mtImage: store.image) 34 | .resizable() 35 | .scaledToFit() 36 | .frame(width: 48, height: 48) 37 | Text(store.name) 38 | .font(.title2) 39 | .fontWeight(.black) 40 | .multilineTextAlignment(.leading) 41 | .frame(maxWidth: .infinity, alignment: .leading) 42 | .padding(.leading, 16) 43 | } 44 | } 45 | .dashedCardStyle(color: store.color) 46 | .backgroundAndForeground(color: store.color) 47 | } 48 | } 49 | 50 | // TODO: fix preview 51 | 52 | //#if DEBUG 53 | //struct TeamRow_Previews: PreviewProvider { 54 | // static var previews: some View { 55 | // NavigationView { 56 | // List { 57 | // TeamRow(store: .preview) 58 | // } 59 | // .listStyle(.plain) 60 | // .padding() 61 | // } 62 | // .previewDisplayName("Team Row Without Players") 63 | // 64 | // NavigationView { 65 | // List { 66 | // TeamRow(store: .previewWithPlayers) 67 | // #if os(iOS) 68 | // .listRowSeparator(.hidden) 69 | // #endif 70 | // } 71 | // .listStyle(.plain) 72 | // .padding() 73 | // } 74 | // .previewDisplayName("Team Row With Players") 75 | // } 76 | //} 77 | // 78 | //extension Store where State == Team.State, Action == Team.Action { 79 | // static var preview: Self { 80 | // Self(initialState: .preview, reducer: Team()) 81 | // } 82 | // static var previewWithPlayers: Self { 83 | // Self(initialState: .previewWithPlayers, reducer: Team()) 84 | // } 85 | //} 86 | // 87 | //public extension Team.State { 88 | // static var preview: Self { 89 | // guard let id = UUID(uuidString: "EF9D6B84-B19A-4177-B5F7-6E2478FAAA18") else { 90 | // fatalError("Cannot generate UUID from a defined UUID String") 91 | // } 92 | // return Team.State( 93 | // id: id, 94 | // name: "Team test", 95 | // color: .strawberry, 96 | // image: .koala 97 | // ) 98 | // } 99 | // 100 | // static var previewWithPlayers: Self { 101 | // guard let id = UUID(uuidString: "EF9D6B84-B19A-4177-B5F7-6E2478FAAA18") else { 102 | // fatalError("Cannot generate UUID from a defined UUID String") 103 | // } 104 | // return Self( 105 | // id: id, 106 | // name: "Team test", 107 | // color: .bluejeans, 108 | // image: .octopus, 109 | // players: [ 110 | // Player.State(id: UUID(), name: "Player 1", image: .amelie, color: .bluejeans), 111 | // Player.State(id: UUID(), name: "Player 2", image: .santa, color: .bluejeans), 112 | // ] 113 | // ) 114 | // } 115 | //} 116 | //#endif 117 | -------------------------------------------------------------------------------- /Tests/AppFeatureTests/AppTests.swift: -------------------------------------------------------------------------------- 1 | import AppFeature 2 | import Combine 3 | import ComposableArchitecture 4 | import XCTest 5 | 6 | @MainActor 7 | class AppTests: XCTestCase { 8 | func testSelectTab() async throws { 9 | let store = TestStore(initialState: App.State.example, reducer: App()) 10 | 11 | await store.send(.tabSelected(.scoreboard)) { 12 | $0.selectedTab = .scoreboard 13 | } 14 | 15 | await store.send(.tabSelected(.settings)) { 16 | $0.selectedTab = .settings 17 | } 18 | 19 | await store.send(.tabSelected(.compositionLoader)) { 20 | $0.selectedTab = .compositionLoader 21 | } 22 | } 23 | 24 | func testTask() async throws { 25 | let store = TestStore(initialState: .example, reducer: App()) 26 | 27 | let migrationV2toV3Expectation = self.expectation(description: "Migration V2 to V3 being called") 28 | store.dependencies.migration.v2toV3 = { migrationV2toV3Expectation.fulfill() } 29 | 30 | let migrationV3_0toV3_1Expectation = self.expectation(description: "Migration V3.0 to V3.1 being called") 31 | store.dependencies.migration.v3_0toV3_1 = { migrationV3_0toV3_1Expectation.fulfill() } 32 | 33 | await store.send(.task) 34 | wait(for: [migrationV2toV3Expectation, migrationV3_0toV3_1Expectation], timeout: 0.1) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Tests/ArchivesFeatureTests/ArchiveRowTests.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import ArchivesFeature 3 | import XCTest 4 | 5 | @MainActor 6 | final class ArchiveRowTests: XCTestCase { 7 | func testUnarchive() async throws { 8 | let store = TestStore(initialState: ArchiveRow.State(team: .previewArchived), reducer: ArchiveRow()) 9 | 10 | let updateTeamExpectation = expectation(description: "Update team is called") 11 | store.dependencies.teamPersistence.updateOrAppend = { _ in updateTeamExpectation.fulfill() } 12 | 13 | await store.send(.unarchive) { 14 | $0.team.isArchived = false 15 | } 16 | wait(for: [updateTeamExpectation], timeout: 0.1) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Tests/ArchivesFeatureTests/ArchivesTests.swift: -------------------------------------------------------------------------------- 1 | import ArchivesFeature 2 | import ComposableArchitecture 3 | import LoaderCore 4 | import Models 5 | import TeamsFeature 6 | import XCTest 7 | 8 | @MainActor 9 | final class ArchivesTest: XCTestCase { 10 | func testUpdate() async { 11 | let store = TestStore(initialState: .loadingCard, reducer: Archives()) { dependencies in 12 | dependencies.teamPersistence.load = { self.persistedExampleOfArchived } 13 | dependencies.teamPersistence.publisher = { 14 | Result.Publisher(.success(self.persistedExampleOfArchived)).eraseToAnyPublisher().values 15 | } 16 | dependencies.playerPersistence.load = { .example } 17 | } 18 | 19 | await store.send(.loadingCard(.task)) 20 | let rows = IdentifiedArrayOf(uniqueElements: self.exampleOfArchived.map { ArchiveRow.State(team: $0) }) 21 | await store.receive(.update(.success(self.exampleOfArchived))) { 22 | $0 = .loaded(rows: rows) 23 | } 24 | await store.receive(.update(.success(self.exampleOfArchived))) 25 | } 26 | 27 | func testReloadOnError() async { 28 | let store = TestStore(initialState: .errorCard(ErrorCard.State(description: "Test error")), reducer: Archives()) { dependencies in 29 | dependencies.teamPersistence.load = { self.persistedExampleOfArchived } 30 | dependencies.playerPersistence.load = { .example } 31 | } 32 | 33 | await store.send(.errorCard(.reload)) { 34 | $0 = .loadingCard 35 | } 36 | let rows = IdentifiedArrayOf(uniqueElements: self.exampleOfArchived.map { ArchiveRow.State(team: $0) }) 37 | await store.receive(.update(.success(self.exampleOfArchived))) { 38 | $0 = .loaded(rows: rows) 39 | } 40 | } 41 | 42 | private var persistedExampleOfArchived: IdentifiedArrayOf { 43 | IdentifiedArrayOf(uniqueElements: IdentifiedArrayOf.example.map { 44 | var team = $0 45 | team.isArchived = true 46 | return team 47 | }) 48 | } 49 | 50 | private var exampleOfArchived: IdentifiedArrayOf { 51 | IdentifiedArrayOf(uniqueElements: IdentifiedArrayOf.example.map { 52 | var team = $0 53 | team.isArchived = true 54 | return team 55 | }) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Tests/CompositionFeatureTests/CompositionLoaderTests.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import ComposableArchitecture 3 | import CompositionFeature 4 | import LoaderCore 5 | import XCTest 6 | 7 | @MainActor 8 | final class CompositionLoaderTests: XCTestCase { 9 | func testUpdate() async { 10 | let store = TestStore(initialState: .loadingCard, reducer: CompositionLoader()) { dependencies in 11 | dependencies.teamPersistence.load = { .example } 12 | dependencies.teamPersistence.publisher = { 13 | Result.Publisher(.success(.example)).eraseToAnyPublisher().values 14 | } 15 | dependencies.playerPersistence.load = { .example } 16 | dependencies.playerPersistence.publisher = { 17 | Result.Publisher(.success(.example)).eraseToAnyPublisher().values 18 | } 19 | } 20 | 21 | await store.send(.loadingCard(.task)) 22 | let expectedState: Composition.State = Composition.State(teams: .example, standing: .example) 23 | await store.receive(.update(.success(expectedState))) { 24 | $0 = .loaded(expectedState) 25 | } 26 | await store.receive(.update(.success(expectedState))) 27 | await store.receive(.update(.success(expectedState))) 28 | } 29 | 30 | func testReloadOnError() async { 31 | let store = TestStore(initialState: .errorCard(ErrorCard.State(description: "Test error")), reducer: CompositionLoader()) { dependencies in 32 | dependencies.teamPersistence.load = { .example } 33 | dependencies.playerPersistence.load = { .example } 34 | } 35 | 36 | await store.send(.errorCard(.reload)) { 37 | $0 = .loadingCard 38 | } 39 | let expectedState: Composition.State = Composition.State(teams: .example, standing: .example) 40 | await store.receive(.update(.success(expectedState))) { 41 | $0 = .loaded(expectedState) 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Tests/CompositionFeatureTests/CompositionTests.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import CompositionFeature 3 | import SwiftUI 4 | import TeamsFeature 5 | import XCTest 6 | 7 | @MainActor 8 | class CompositionTests: XCTestCase { 9 | func testMixTeam() async throws { 10 | let store = TestStore(initialState: .example, reducer: Composition()) 11 | 12 | let allPlayers = store.state.teams.flatMap(\.players) + store.state.standing.players 13 | guard 14 | var amelia = allPlayers.first(where: { $0.name == "Amelia" }), 15 | var jack = allPlayers.first(where: { $0.name == "Jack" }), 16 | var jose = allPlayers.first(where: { $0.name == "José" }) 17 | else { fatalError("Cannot instanciate named players") } 18 | 19 | jose.color = store.state.teams[0].color 20 | jack.color = store.state.teams[1].color 21 | amelia.color = store.state.teams[2].color 22 | 23 | store.dependencies.shufflePlayers = .alphabeticallySorted 24 | store.dependencies.teamPersistence.save = { _ in } 25 | store.dependencies.teamPersistence.updateValues = { _ in } 26 | await store.send(.mixTeam) { 27 | $0.notEnoughTeamsConfirmationDialog = nil 28 | $0.standing.players = [] 29 | $0.teams[id: $0.teams[0].id]?.players = [jose] 30 | $0.teams[id: $0.teams[1].id]?.players = [jack] 31 | $0.teams[id: $0.teams[2].id]?.players = [amelia] 32 | } 33 | 34 | await store.finish(timeout: 1) 35 | } 36 | 37 | func testMixTeamAndConfirmationIsPresented() async throws { 38 | let store = TestStore(initialState: Composition.State(), reducer: Composition()) 39 | 40 | await store.send(.mixTeam) { 41 | $0.notEnoughTeamsConfirmationDialog = .notEnoughTeams 42 | } 43 | 44 | await store.send(.dismissNotEnoughTeamsAlert) { 45 | $0.notEnoughTeamsConfirmationDialog = nil 46 | } 47 | } 48 | 49 | func testAddTeam() async throws { 50 | let store = TestStore(initialState: Composition.State(), reducer: Composition()) 51 | 52 | store.dependencies.uuid = .incrementing 53 | store.dependencies.randomTeam = .strawberryBunny 54 | let addTeamToPersistenceExpectation = expectation(description: "Persist team after adding it") 55 | store.dependencies.teamPersistence.updateOrAppend = { _ in addTeamToPersistenceExpectation.fulfill() } 56 | 57 | guard let id = UUID(uuidString: "00000000-0000-0000-0000-000000000000") else { return XCTFail("UUID missing") } 58 | 59 | await store.send(.addTeam) { 60 | $0.teams = [Team.State(id: id, name: "Strawberry Bunny", color: .strawberry, image: .bunny)] 61 | } 62 | wait(for: [addTeamToPersistenceExpectation], timeout: 0.1) 63 | } 64 | 65 | func testArchiveTeam() async throws { 66 | let store = TestStore(initialState: .example, reducer: Composition()) 67 | 68 | let teams = store.state.teams 69 | let indexSet = IndexSet(integer: 0) 70 | 71 | let updateTeamsExpectation = expectation(description: "Update teams") 72 | store.dependencies.teamPersistence.updateValues = { archivedTeams in 73 | XCTAssertEqual(archivedTeams.elements.map(\.id), indexSet.map { teams[$0] }.map(\.id)) 74 | XCTAssert(archivedTeams.allSatisfy(\.isArchived), "All archived teams should be in archived status") 75 | updateTeamsExpectation.fulfill() 76 | } 77 | 78 | await store.send(.archiveTeams(indexSet)) { 79 | $0.teams.remove(atOffsets: indexSet) 80 | } 81 | 82 | wait(for: [updateTeamsExpectation], timeout: 0.1) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /Tests/CompositionFeatureTests/StandingTests.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import CompositionFeature 3 | import PlayersFeature 4 | import XCTest 5 | 6 | @MainActor 7 | final class StandingTests: XCTestCase { 8 | func testCreatePlayer() async { 9 | let updatePlayerExpectation = expectation(description: "Update player") 10 | let expectationUUIDGenerator: UUIDGenerator = .incrementing 11 | let newPlayer = Player.State(id: expectationUUIDGenerator(), name: "Test", image: .clown, color: .aluminium) 12 | let store = TestStore(initialState: Standing.State(), reducer: Standing()) { dependencies in 13 | dependencies.uuid = .incrementing 14 | dependencies.playerPersistence.updateOrAppend = { _ in updatePlayerExpectation.fulfill() } 15 | dependencies.randomPlayer = RandomPlayer { newPlayer } 16 | } 17 | 18 | await store.send(.createPlayer) { 19 | $0.players = [newPlayer] 20 | } 21 | wait(for: [updatePlayerExpectation], timeout: 0.1) 22 | } 23 | 24 | func testDeletePlayer() async throws { 25 | let removePlayerExpectation = expectation(description: "Remove Player") 26 | let store = TestStore(initialState: .example, reducer: Standing()) { dependencies in 27 | dependencies.playerPersistence.remove = { _ in removePlayerExpectation.fulfill() } 28 | } 29 | let playerToRemove = try XCTUnwrap(store.state.players.first) 30 | 31 | await store.send(.deletePlayer(id: playerToRemove.id)) { 32 | $0.players.remove(id: playerToRemove.id) 33 | } 34 | wait(for: [removePlayerExpectation], timeout: 0.1) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Tests/ImagePickerTests/IllustrationPickerTests.swift: -------------------------------------------------------------------------------- 1 | import Assets 2 | import ComposableArchitecture 3 | import ImagePicker 4 | import XCTest 5 | 6 | @MainActor 7 | final class IllustrationPickerTests: XCTestCase { 8 | func testImageTapped() async { 9 | let store = TestStore(initialState: .preview, reducer: IllustrationPicker()) 10 | let image: MTImage = .heroin 11 | await store.send(.imageTapped(image)) { 12 | $0.selectedImage = image 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Tests/PlayersFeatureTests/PlayersTests.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import PlayersFeature 3 | import XCTest 4 | 5 | @MainActor 6 | final class PlayersTests: XCTestCase { 7 | func testUpdateName() async throws { 8 | let store = TestStore(initialState: Player.State.preview, reducer: Player()) 9 | 10 | let updateExpectation = expectation(description: "Update player persistence") 11 | store.dependencies.playerPersistence.updateOrAppend = { _ in updateExpectation.fulfill() } 12 | 13 | await store.send(.set(\.$name, "Test")) { 14 | $0.name = "Test" 15 | } 16 | wait(for: [updateExpectation], timeout: 0.1) 17 | } 18 | 19 | func testUpdateImage() async throws { 20 | let store = TestStore(initialState: Player.State.preview, reducer: Player()) 21 | 22 | let updateExpectation = expectation(description: "Update player persistence") 23 | store.dependencies.playerPersistence.updateOrAppend = { _ in updateExpectation.fulfill() } 24 | 25 | await store.send(.illustrationPicker(.imageTapped(.nymph))) { 26 | $0.image = .nymph 27 | } 28 | wait(for: [updateExpectation], timeout: 0.1) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Tests/ScoresFeatureTests/Round/RoundTests.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import ScoresFeature 3 | import XCTest 4 | 5 | @MainActor 6 | final class RoundTests: XCTestCase { 7 | func testUpdateName() async { 8 | let updateRoundExpectation = expectation(description: "Update round") 9 | let uuid = UUIDGenerator.incrementing 10 | let state = Round.State(id: uuid(), name: "Test") 11 | let store = TestStore(initialState: state, reducer: Round()) { dependencies in 12 | dependencies.scoresPersistence.updateRound = { _ in updateRoundExpectation.fulfill() } 13 | } 14 | 15 | await store.send(.set(\.$name, "Test modified")) { 16 | $0.name = "Test modified" 17 | } 18 | wait(for: [updateRoundExpectation], timeout: 0.1) 19 | } 20 | 21 | func testRemoveScore() async throws { 22 | let uuid = UUIDGenerator.incrementing 23 | let scores: IdentifiedArrayOf = [ 24 | Score.State(id: uuid(), team: .preview), 25 | Score.State(id: uuid(), team: .preview), 26 | Score.State(id: uuid(), team: .preview), 27 | ] 28 | let state = Round.State(id: uuid(), name: "Test", scores: scores) 29 | let store = TestStore(initialState: state, reducer: Round()) 30 | 31 | let firstScore = try XCTUnwrap(scores.first) 32 | 33 | await store.send(.score(id: firstScore.id, action: .remove)) { 34 | $0.scores.remove(id: firstScore.id) 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Tests/ScoresFeatureTests/Score/ScoreTests.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import ScoresFeature 3 | import XCTest 4 | 5 | @MainActor 6 | final class ScoreTests: XCTestCase { 7 | func testSetPoints() async { 8 | let updateScoreExpectation = expectation(description: "Update score") 9 | let uuid: UUIDGenerator = .incrementing 10 | let state = Score.State(id: uuid(), team: .preview) 11 | let store = TestStore(initialState: state, reducer: Score()) { dependencies in 12 | dependencies.scoresPersistence.updateScore = { _ in updateScoreExpectation.fulfill() } 13 | } 14 | 15 | await store.send(.set(\.$points, 123)) { 16 | $0.points = 123 17 | } 18 | wait(for: [updateScoreExpectation], timeout: 0.1) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Tests/ScoresFeatureTests/Scores/ScoresTests.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import ScoresFeature 3 | import XCTest 4 | 5 | @MainActor 6 | final class ScoresTest: XCTestCase { 7 | func testAddRound() async throws { 8 | let saveScoresExpectation = expectation(description: "Save Scores") 9 | let store = TestStore(initialState: .previewWithScores(count: 3), reducer: Scores()) { 10 | $0.uuid = .incrementing 11 | $0.scoresPersistence.save = { _ in saveScoresExpectation.fulfill() } 12 | } 13 | let uuid = UUIDGenerator.incrementing 14 | 15 | let scores = IdentifiedArrayOf(uniqueElements: store.state.teams.filter { !$0.isArchived }.map { team in 16 | Score.State( 17 | id: uuid(), 18 | team: team, 19 | points: 0, 20 | accumulatedPoints: store.state.rounds 21 | .flatMap(\.scores) 22 | .filter { $0.team == team } 23 | .map(\.points) 24 | .reduce(0, +) 25 | ) 26 | }) 27 | 28 | let newRound = Round.State(id: uuid(), name: "Round 4", scores: scores) 29 | await store.send(.addRound) { 30 | $0.rounds.append(newRound) 31 | } 32 | wait(for: [saveScoresExpectation], timeout: 0.1) 33 | } 34 | 35 | func testUpdateAccumulatedPoints() async throws { 36 | let store = TestStore(initialState: .previewWithScores(count: 3), reducer: Scores()) 37 | let rounds = IdentifiedArrayOf(uniqueElements: store.state.rounds.map { round in 38 | var round = round 39 | round.scores = IdentifiedArrayOf(uniqueElements: round.scores.map { score in 40 | var score = score 41 | score.accumulatedPoints += 10 42 | return score 43 | }) 44 | return round 45 | }) 46 | 47 | await store.send(.updateAccumulatedPoints(rounds)) { 48 | $0.rounds = rounds 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Tests/SettingsFeatureTests/SettingsTests.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import SettingsFeature 3 | import XCTest 4 | 5 | @MainActor 6 | final class SettingsTests: XCTestCase { 7 | // There is no logic yet in Settings, it's just an intermediate step to Archive or About View 8 | func testNothing() async throws { 9 | let store = TestStore(initialState: Settings.State(), reducer: Settings()) 10 | 11 | _ = store 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Tests/TeamsFeatureTests/TeamsTests.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import TeamsFeature 3 | import XCTest 4 | 5 | @MainActor 6 | final class TeamsTests: XCTestCase { 7 | 8 | func testUpdateColor() async throws { 9 | let store = TestStore(initialState: Team.State.previewWithPlayers, reducer: Team()) 10 | 11 | let updateExpectation = expectation(description: "Update team persistence") 12 | store.dependencies.teamPersistence.updateOrAppend = { _ in updateExpectation.fulfill() } 13 | 14 | await store.send(.set(\.$color, .strawberry)) { 15 | $0.color = .strawberry 16 | $0.players = IdentifiedArrayOf(uniqueElements: $0.players.map { 17 | var player = $0 18 | player.color = .strawberry 19 | return player 20 | }) 21 | } 22 | wait(for: [updateExpectation], timeout: 0.1) 23 | } 24 | 25 | func testUpdateName() async throws { 26 | let store = TestStore(initialState: Team.State.previewWithPlayers, reducer: Team()) 27 | 28 | let updateExpectation = expectation(description: "Update team persistence") 29 | store.dependencies.teamPersistence.updateOrAppend = { _ in updateExpectation.fulfill() } 30 | 31 | await store.send(.set(\.$name, "Test")) { 32 | $0.name = "Test" 33 | } 34 | wait(for: [updateExpectation], timeout: 0.1) 35 | } 36 | 37 | func testUpdateImage() async throws { 38 | let store = TestStore(initialState: Team.State.previewWithPlayers, reducer: Team()) 39 | 40 | let updateExpectation = expectation(description: "Update team persistence") 41 | store.dependencies.teamPersistence.updateOrAppend = { _ in updateExpectation.fulfill() } 42 | 43 | await store.send(.illustrationPicker(.imageTapped(.clown))) { 44 | $0.image = .clown 45 | } 46 | wait(for: [updateExpectation], timeout: 0.1) 47 | } 48 | 49 | func testMoveBackPlayer() async throws { 50 | let store = TestStore(initialState: Team.State.previewWithPlayers, reducer: Team()) 51 | 52 | let updateExpectation = expectation(description: "Update team persistence") 53 | store.dependencies.teamPersistence.updateOrAppend = { _ in updateExpectation.fulfill() } 54 | 55 | let firstPlayerID = store.state.players.first?.id ?? UUID() 56 | 57 | await store.send(.moveBackPlayer(id: firstPlayerID)) { 58 | $0.players.remove(id: firstPlayerID) 59 | } 60 | wait(for: [updateExpectation], timeout: 0.1) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /docs/assets/iPhoneScreenshots.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renaudjenny/MixTeam/e54cf1ba6c1cad34d6877ba7de16df36da3bafae/docs/assets/iPhoneScreenshots.jpeg -------------------------------------------------------------------------------- /privacy.md: -------------------------------------------------------------------------------- 1 | # Privacy Policy 2 | 3 | This policy applies to all information collected or submitted on Renaud Jenny apps for iPhone, iPad and any other devices and platforms. 4 | 5 | ## Information we collect 6 | 7 | I don't collect any information or data with Tell Time UK. This app can even fully work without any internet connection. 8 | 9 | ## Technical basics 10 | 11 | I don't use Apple Notification service yet. So there is no informations saved anywhere. 12 | 13 | ## iCloud 14 | 15 | I don't store your data in Apple’s iCloud service. 16 | 17 | ## Ads and analytics 18 | 19 | My apps are free from ads. 20 | 21 | My apps doesn't use any analytics to track you. 22 | 23 | No personal data is used. 24 | 25 | ## Information usage 26 | 27 | I don't collect any information usage. 28 | 29 | ## Security 30 | 31 | My app doesn't use any internet connection to work. So there is no security regards about this. 32 | 33 | ## Your Consent 34 | 35 | By using my apps, you consent to my privacy policy. 36 | 37 | ## Contacting Me 38 | 39 | If you have questions regarding this privacy policy, you may email renaud.jenny@gmail.com. 40 | 41 | ## Changes to this policy 42 | 43 | If I decide to change our privacy policy, I will post those changes on this page. Summary of changes so far: 44 | 45 | August 5, 2020: First published. 46 | --------------------------------------------------------------------------------