├── .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 | [](https://github.com/renaudjenny/MixTeam/actions/workflows/xcodetest.yml)
4 | [](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 | 
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 |
--------------------------------------------------------------------------------