├── .github
└── workflows
│ └── codeformat-swift.yml
├── .gitignore
├── LICENSE
├── README.md
├── androidApp
├── build.gradle.kts
├── proguard-rules.pro
└── src
│ └── main
│ ├── AndroidManifest.xml
│ ├── java
│ └── com
│ │ └── magic
│ │ └── app
│ │ └── android
│ │ ├── MagicApp.kt
│ │ └── presentation
│ │ ├── MagicActivity.kt
│ │ ├── MagicScreen.kt
│ │ └── MagicViewModel.kt
│ └── res
│ ├── drawable
│ ├── ic_launcher_background.xml
│ └── ic_launcher_foreground.xml
│ └── mipmap-anydpi-v26
│ ├── ic_launcher.xml
│ └── ic_launcher_round.xml
├── build-logic
├── conventions
│ ├── build.gradle.kts
│ └── src
│ │ └── main
│ │ └── kotlin
│ │ ├── AndroidAppConventionPlugin.kt
│ │ ├── CMPLibraryConventionPlugin.kt
│ │ ├── KMPAndroidLibraryConventionPlugin.kt
│ │ └── extensions
│ │ ├── AndroidConfigurations.kt
│ │ ├── ComposeOptions.kt
│ │ └── KotlinCompileOptions.kt
└── settings.gradle.kts
├── build.gradle.kts
├── core-database
├── build.gradle.kts
└── src
│ ├── androidMain
│ └── kotlin
│ │ └── com
│ │ └── magic
│ │ └── core
│ │ └── database
│ │ └── DI.android.kt
│ ├── androidUnitTest
│ └── kotlin
│ │ └── DITest.android.kt
│ ├── commonMain
│ ├── kotlin
│ │ └── com
│ │ │ └── magic
│ │ │ └── core
│ │ │ └── database
│ │ │ ├── DI.kt
│ │ │ └── MagicDao.kt
│ └── sqldelight
│ │ └── com
│ │ └── magic
│ │ └── core
│ │ └── database
│ │ └── MagicDatabase.sq
│ ├── commonTest
│ └── kotlin
│ │ ├── DITest.kt
│ │ └── MagicDaoTest.kt
│ ├── iosMain
│ └── kotlin
│ │ └── com
│ │ └── magic
│ │ └── core
│ │ └── database
│ │ └── DI.ios.kt
│ └── iosTest
│ └── kotlin
│ └── DITest.ios.kt
├── core-di
├── build.gradle.kts
└── src
│ └── commonMain
│ └── kotlin
│ └── com
│ └── magic
│ └── core
│ └── di
│ └── DependencyInjection.kt
├── core-network
├── build.gradle.kts
└── src
│ ├── androidMain
│ └── kotlin
│ │ └── com
│ │ └── magic
│ │ └── core
│ │ └── network
│ │ └── DI.android.kt
│ ├── commonMain
│ └── kotlin
│ │ └── com
│ │ └── magic
│ │ └── core
│ │ └── network
│ │ ├── DI.kt
│ │ ├── api
│ │ ├── ApiClient.kt
│ │ ├── core
│ │ │ ├── ApiCall.kt
│ │ │ ├── ApiCallBehaviour.kt
│ │ │ └── ApiResult.kt
│ │ ├── errors
│ │ │ └── ApiError.kt
│ │ └── requests
│ │ │ └── BaseRequest.kt
│ │ └── token
│ │ └── TokenProvider.kt
│ ├── commonTest
│ └── kotlin
│ │ └── ApiClientTest.kt
│ └── iosMain
│ └── kotlin
│ └── com
│ └── magic
│ └── core
│ └── network
│ └── DI.ios.kt
├── data-managers
├── build.gradle.kts
└── src
│ ├── commonMain
│ └── kotlin
│ │ └── com
│ │ └── magic
│ │ └── data
│ │ └── managers
│ │ ├── CardRequest.kt
│ │ ├── CardsManager.kt
│ │ └── DI.kt
│ └── commonTest
│ └── kotlin
│ └── CardManagerTest.kt
├── data-models
├── build.gradle.kts
└── src
│ └── commonMain
│ └── kotlin
│ └── com
│ └── magic
│ └── data
│ └── models
│ ├── exceptions
│ └── RateLimitException.kt
│ ├── local
│ ├── Card.kt
│ ├── CardSet.kt
│ └── Result.kt
│ └── remote
│ ├── CardListResponse.kt
│ ├── CardSetListResponse.kt
│ └── CardSetResponse.kt
├── gradle.properties
├── gradle
├── libs.versions.toml
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── iosApp
├── App
│ ├── AppDependencies.swift
│ ├── AppFactories.swift
│ ├── Assets.xcassets
│ │ ├── AppIcon.appiconset
│ │ │ ├── 1024.png
│ │ │ ├── 114.png
│ │ │ ├── 120.png
│ │ │ ├── 180.png
│ │ │ ├── 29.png
│ │ │ ├── 40.png
│ │ │ ├── 57.png
│ │ │ ├── 58.png
│ │ │ ├── 60.png
│ │ │ ├── 80.png
│ │ │ ├── 87.png
│ │ │ └── Contents.json
│ │ ├── Contents.json
│ │ ├── card_back.imageset
│ │ │ ├── Contents.json
│ │ │ ├── card_back 1.png
│ │ │ ├── card_back 2.png
│ │ │ └── card_back.png
│ │ ├── default.imageset
│ │ │ ├── 40.png
│ │ │ └── Contents.json
│ │ ├── edition_4_symbol.imageset
│ │ │ ├── Contents.json
│ │ │ └── edition_4_symbol.svg
│ │ ├── edition_5_symbol.imageset
│ │ │ ├── Contents.json
│ │ │ └── edition_5_symbol.svg
│ │ ├── edition_mirage_symbol.imageset
│ │ │ ├── Contents.json
│ │ │ └── edition_mirage_symbol.svg
│ │ └── edition_tempest_symbol.imageset
│ │ │ ├── Contents.json
│ │ │ └── edition_tempest_symbol.svg
│ ├── Info.plist
│ ├── MagicApp.swift
│ └── Preview Content
│ │ └── Preview Assets.xcassets
│ │ └── Contents.json
├── Magic.xcodeproj
│ ├── project.pbxproj
│ ├── project.xcworkspace
│ │ ├── contents.xcworkspacedata
│ │ └── xcshareddata
│ │ │ ├── IDEWorkspaceChecks.plist
│ │ │ └── swiftpm
│ │ │ └── Package.resolved
│ └── xcshareddata
│ │ └── xcschemes
│ │ └── App.xcscheme
└── Packages
│ ├── Core
│ ├── .gitignore
│ ├── Package.swift
│ └── Sources
│ │ ├── DI
│ │ └── DIContainer.swift
│ │ └── FactoryProtocols
│ │ └── Factory.swift
│ ├── Data
│ ├── .gitignore
│ ├── Package.swift
│ └── Sources
│ │ ├── CardData
│ │ ├── CardsManager.swift
│ │ └── CardsManagerMock.swift
│ │ └── DataExtensions
│ │ └── DataBridge.swift
│ ├── Domain
│ ├── .gitignore
│ ├── Package.swift
│ └── Sources
│ │ ├── CardDomain
│ │ └── CardDomain.swift
│ │ └── DomainProtocols
│ │ └── DomainBridge.swift
│ └── Presentation
│ ├── .gitignore
│ ├── Package.swift
│ └── Sources
│ ├── CardDeckPresentation
│ ├── CardDeckScreen.swift
│ ├── Core
│ │ ├── CardViewModifier.swift
│ │ ├── CircularButtonModifier.swift
│ │ └── Protocols
│ │ │ ├── CardProtocol.swift
│ │ │ ├── CardViewModelProtocol.swift
│ │ │ └── CardViewProtocol.swift
│ └── Views
│ │ ├── CardDeckView.swift
│ │ ├── Color
│ │ ├── ColorCard.swift
│ │ ├── ColorCardView.swift
│ │ └── ColorDeckViewModel.swift
│ │ └── Magic
│ │ ├── MagicCard.swift
│ │ ├── MagicCardView.swift
│ │ └── MagicDeckViewModel.swift
│ ├── CardListPresentation
│ ├── CardListItem.swift
│ ├── CardListView.swift
│ └── CardListViewModel.swift
│ └── CardUIModels
│ ├── CardSetItem.swift
│ └── ModelExtensions.swift
├── media
├── banner.png
├── deck-combine.gif
└── icon.png
└── settings.gradle.kts
/.github/workflows/codeformat-swift.yml:
--------------------------------------------------------------------------------
1 | name: SwiftFormat (code)
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 |
9 | jobs:
10 | Lint:
11 | runs-on: macos-latest
12 | steps:
13 | - uses: actions/checkout@v3
14 |
15 | - name: Running code format
16 | run: |
17 | swiftformat ./iosApp --swiftversion 6.0.3 --reporter github-actions-log
18 | git diff --exit-code || echo "CHANGES_DETECTED=true" >> $GITHUB_ENV
19 |
20 | - name: Check if there are changes
21 | if: env.CHANGES_DETECTED == 'true'
22 | run: echo "SwiftFormat made changes. Proceeding with PR."
23 |
24 | - name: Creating new branch to commit changes
25 | if: env.CHANGES_DETECTED == 'true'
26 | run: |
27 | git config --global user.name 'github-actions[bot]'
28 | git config --global user.email 'github-actions[bot]@users.noreply.github.com'
29 | BRANCH_NAME="auto-formatting-${{ github.event.pull_request.head.ref }}"
30 | git checkout -b $BRANCH_NAME
31 | git add .
32 | git commit -m "Apply SwiftFormat"
33 | git push origin $BRANCH_NAME
34 |
35 | - name: Creating Pull Request
36 | if: env.CHANGES_DETECTED == 'true'
37 | env:
38 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
39 | run: |
40 | gh pr create \
41 | --title "Apply SwiftFormat" \
42 | --body "This PR applies SwiftFormat automatically." \
43 | --base ${{ github.event.pull_request.head.ref }} \
44 | --head $(git branch --show-current)
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | # Magic
4 |
5 | [](https://androidweekly.net/issues/issue-647)
6 |
7 | KMP sample project acting as a playground to illustrate what's discussed in this article:
8 |
9 |
10 |
11 |
12 | ## Details
13 |
14 | ### Shared
15 |
16 | The modules `data-models`, `core-database`, and `core-network` are independent, while `data-managers` depends on all three.
17 | Each module is responsible for providing its own Dependency Injection (DI) logic, but the `core-di` module manages the root DI system, utilised by all platforms.
18 | This is why the `core-di` module depends on all other modules and will also contain the Objective-C framework settings.
19 |
20 | These five modules constitute the Shared Data Layer.
21 |
22 | ### Platforms
23 |
24 | #### Android
25 |
26 | The `MagicApp` serves as the entry point and includes the setup for DI. There's no Domain Layer and `Shared Data Layer` is the Data Layer itself. Finally, the `presentation` contains all the logic related to the UI Layer.
27 |
28 | To run it `./gradlew :androidApp:installDebug`
29 |
30 | #### iOS
31 |
32 | The `App` package serves as the entry point and includes the setup for DI.
33 | The `Core` defines the DI protocols and container. The `Data` acts as a bridge between the App's Data Layer and the Shared Data Layer, where the implementation logic resides.
34 | The `Domain` contains the bridge protocols and data structures, and also the app protocols. Finally, the `Presentation` contains all the logic related to the UI Layer.
35 |
36 | To run it open `iosApp/Magic.xcodeproj` in Xcode and run standard configuration or use [KMP plugin](https://plugins.jetbrains.com/plugin/14936-kotlin-multiplatform) for Android Studio and choose `iosApp` in `run configurations`.
37 |
38 |
39 |
40 |
41 |
42 | ## LICENSE
43 |
44 | Copyright (c) 2024-present GuilhE
45 |
46 | Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy
47 | of the License at
48 |
49 |
50 |
51 | Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
52 | WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under
53 | the License.
54 |
--------------------------------------------------------------------------------
/androidApp/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("buildlogic.plugins.application")
3 | }
4 |
5 | android {
6 | namespace = "com.magic.app.android"
7 | defaultConfig {
8 | applicationId = "com.magic.app.android"
9 | versionCode = 1
10 | versionName = "1.0"
11 |
12 | resValue("string", "app_name_label", "Magic")
13 | }
14 |
15 | buildTypes {
16 | getByName("release") {
17 | isMinifyEnabled = true
18 | isShrinkResources = true
19 | proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro")
20 | }
21 |
22 | getByName("debug") {
23 | isMinifyEnabled = false
24 | }
25 | }
26 | }
27 |
28 | dependencies {
29 | implementation(projects.coreDi)
30 | implementation(projects.dataModels)
31 | implementation(projects.dataManagers)
32 | implementation(libs.androidx.lifecycle.runtime)
33 | implementation(libs.android.material)
34 | implementation(libs.kmp.koin.android)
35 | implementation(libs.kmp.koin.androidx.compose)
36 | }
--------------------------------------------------------------------------------
/androidApp/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | -dontwarn org.slf4j.impl.StaticLoggerBinder
2 | ########################################################
3 | # Kotlin coroutines
4 |
5 | # With R8 full mode generic signatures are stripped for classes that are not
6 | # kept. Suspend functions are wrapped in continuations where the type argument
7 | # is used.
8 | -keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation
9 |
10 | # ServiceLoader support
11 | -keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
12 | -keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
13 | -keepnames class kotlinx.coroutines.android.AndroidExceptionPreHandler {}
14 | -keepnames class kotlinx.coroutines.android.AndroidDispatcherFactory {}
15 |
16 | # Most of volatile fields are updated with AFU and should not be mangled
17 | -keepclassmembernames class kotlinx.** {
18 | volatile ;
19 | }
--------------------------------------------------------------------------------
/androidApp/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
13 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/androidApp/src/main/java/com/magic/app/android/MagicApp.kt:
--------------------------------------------------------------------------------
1 | package com.magic.app.android
2 |
3 | import android.app.Application
4 | import com.magic.app.android.presentation.MagicViewModel
5 | import com.magic.core.di.DependencyInjection
6 | import org.koin.android.ext.koin.androidContext
7 | import org.koin.android.ext.koin.androidLogger
8 | import org.koin.core.module.dsl.viewModel
9 | import org.koin.dsl.module
10 |
11 | class MagicApp : Application() {
12 |
13 | override fun onCreate() {
14 | super.onCreate()
15 | DependencyInjection.initKoin {
16 | modules(
17 | module {
18 | viewModel { MagicViewModel() }
19 | }
20 | )
21 | androidLogger()
22 | androidContext(this@MagicApp)
23 | }
24 | }
25 | }
--------------------------------------------------------------------------------
/androidApp/src/main/java/com/magic/app/android/presentation/MagicActivity.kt:
--------------------------------------------------------------------------------
1 | package com.magic.app.android.presentation
2 |
3 | import android.os.Bundle
4 | import androidx.activity.ComponentActivity
5 | import androidx.activity.compose.setContent
6 | import androidx.compose.foundation.layout.fillMaxSize
7 | import androidx.compose.material3.MaterialTheme
8 | import androidx.compose.material3.Surface
9 | import androidx.compose.ui.Modifier
10 | import org.koin.androidx.compose.KoinAndroidContext
11 |
12 | class MagicActivity : ComponentActivity() {
13 |
14 | override fun onCreate(savedInstanceState: Bundle?) {
15 | super.onCreate(savedInstanceState)
16 |
17 | setContent {
18 | KoinAndroidContext {
19 | MaterialTheme {
20 | Surface(
21 | modifier = Modifier.fillMaxSize(),
22 | color = MaterialTheme.colorScheme.background
23 | ) {
24 | MagicScreen()
25 | }
26 | }
27 | }
28 | }
29 | }
30 | }
--------------------------------------------------------------------------------
/androidApp/src/main/java/com/magic/app/android/presentation/MagicScreen.kt:
--------------------------------------------------------------------------------
1 | @file:OptIn(ExperimentalMaterial3Api::class)
2 |
3 | package com.magic.app.android.presentation
4 |
5 | import androidx.compose.animation.AnimatedVisibility
6 | import androidx.compose.foundation.layout.Arrangement
7 | import androidx.compose.foundation.layout.Box
8 | import androidx.compose.foundation.layout.Column
9 | import androidx.compose.foundation.layout.PaddingValues
10 | import androidx.compose.foundation.layout.Row
11 | import androidx.compose.foundation.layout.Spacer
12 | import androidx.compose.foundation.layout.fillMaxSize
13 | import androidx.compose.foundation.layout.fillMaxWidth
14 | import androidx.compose.foundation.layout.height
15 | import androidx.compose.foundation.layout.padding
16 | import androidx.compose.foundation.layout.size
17 | import androidx.compose.foundation.lazy.LazyColumn
18 | import androidx.compose.foundation.lazy.items
19 | import androidx.compose.material3.Button
20 | import androidx.compose.material3.CircularProgressIndicator
21 | import androidx.compose.material3.DropdownMenuItem
22 | import androidx.compose.material3.ExperimentalMaterial3Api
23 | import androidx.compose.material3.ExposedDropdownMenuBox
24 | import androidx.compose.material3.HorizontalDivider
25 | import androidx.compose.material3.MenuAnchorType
26 | import androidx.compose.material3.OutlinedTextField
27 | import androidx.compose.material3.Text
28 | import androidx.compose.runtime.Composable
29 | import androidx.compose.runtime.getValue
30 | import androidx.compose.runtime.mutableStateOf
31 | import androidx.compose.runtime.remember
32 | import androidx.compose.runtime.setValue
33 | import androidx.compose.ui.Alignment
34 | import androidx.compose.ui.Modifier
35 | import androidx.compose.ui.tooling.preview.Preview
36 | import androidx.compose.ui.unit.dp
37 | import androidx.lifecycle.compose.collectAsStateWithLifecycle
38 | import com.magic.data.models.local.Card
39 | import com.magic.data.models.local.CardSet
40 | import org.koin.androidx.compose.koinViewModel
41 |
42 | @Composable
43 | fun MagicScreen(viewModel: MagicViewModel = koinViewModel()) {
44 | with(viewModel.state.collectAsStateWithLifecycle().value) {
45 | MagicScreenContent(
46 | set = set,
47 | sets = availableSets,
48 | setCount = setCount,
49 | cards = cards,
50 | cardsTotalCount = cardsTotalCount,
51 | isLoading = isLoading,
52 | onSetSelected = { viewModel.changeSet(it) },
53 | onGetCards = { viewModel.getCardsFromCurrentSet() },
54 | onDelete = { viewModel.deleteCardsFromCurrentSet() }
55 | )
56 | }
57 | }
58 |
59 | @Composable
60 | private fun MagicScreenContent(
61 | set: CardSet,
62 | sets: List,
63 | setCount: Int,
64 | cards: List,
65 | cardsTotalCount: Int,
66 | isLoading: Boolean,
67 | onSetSelected: (CardSet) -> Unit,
68 | onGetCards: () -> Unit,
69 | onDelete: () -> Unit,
70 | ) {
71 | Column(Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) {
72 | Column(
73 | modifier = Modifier.padding(top = 20.dp, start = 20.dp, end = 20.dp),
74 | horizontalAlignment = Alignment.CenterHorizontally
75 | ) {
76 | Dropdown(
77 | enabled = !isLoading,
78 | selectedOption = set,
79 | options = sets,
80 | onOptionSelected = { onSetSelected(it) }
81 | )
82 | Row(
83 | modifier = Modifier.padding(20.dp),
84 | horizontalArrangement = Arrangement.spacedBy(20.dp)
85 | ) {
86 | Button(
87 | enabled = set.code.isNotEmpty(),
88 | onClick = { onGetCards() }
89 | ) {
90 | Text("Get cards")
91 | }
92 | Button(
93 | enabled = set.code.isNotEmpty(),
94 | onClick = { onDelete() }
95 | ) {
96 | Text("Delete set")
97 | }
98 | }
99 | }
100 | Column(
101 | modifier = Modifier
102 | .fillMaxWidth()
103 | .height(80.dp),
104 | horizontalAlignment = Alignment.CenterHorizontally
105 | ) {
106 | Text("Number of cards: $cardsTotalCount")
107 | Text("Number of sets: $setCount")
108 | Spacer(modifier = Modifier.size(5.dp))
109 | AnimatedVisibility(visible = isLoading, modifier = Modifier.size(20.dp)) {
110 | CircularProgressIndicator()
111 | }
112 | }
113 | HorizontalDivider(Modifier.padding(horizontal = 10.dp))
114 | Box(
115 | modifier = Modifier
116 | .fillMaxSize()
117 | .padding(top = if (set.code.isEmpty() || cards.isEmpty()) 50.dp else 0.dp),
118 | contentAlignment = Alignment.TopCenter,
119 | ) {
120 | LazyColumn(
121 | modifier = Modifier.fillMaxSize(),
122 | contentPadding = PaddingValues(10.dp)
123 | ) {
124 | items(items = cards, key = { it.id }) { card ->
125 | Text(
126 | modifier = Modifier.animateItem(),
127 | text = card.name
128 | )
129 | }
130 | }
131 | if (set.code.isEmpty()) {
132 | Text("Choose a card set...")
133 | } else {
134 | if (cards.isEmpty()) {
135 | Text("No cards...")
136 | }
137 | }
138 | }
139 | }
140 | }
141 |
142 | @Composable
143 | private fun Dropdown(
144 | enabled: Boolean,
145 | selectedOption: CardSet,
146 | options: List,
147 | onOptionSelected: (CardSet) -> Unit
148 | ) {
149 | var expanded by remember { mutableStateOf(false) }
150 | ExposedDropdownMenuBox(
151 | expanded = enabled && expanded,
152 | onExpandedChange = { expanded = !expanded }
153 | ) {
154 | OutlinedTextField(
155 | enabled = enabled,
156 | value = selectedOption.name,
157 | onValueChange = {},
158 | label = { Text("Card Set") },
159 | readOnly = true,
160 | modifier = Modifier
161 | .fillMaxWidth()
162 | .menuAnchor(MenuAnchorType.PrimaryNotEditable)
163 | )
164 |
165 | ExposedDropdownMenu(
166 | expanded = enabled && expanded,
167 | onDismissRequest = { expanded = false }
168 | ) {
169 | options.forEach { option ->
170 | DropdownMenuItem(
171 | text = { Text("${option.name}, ${option.releaseDate}") },
172 | onClick = {
173 | onOptionSelected(option)
174 | expanded = false
175 | },
176 | modifier = Modifier.padding(8.dp)
177 | )
178 | }
179 | }
180 | }
181 | }
182 |
183 | @Composable
184 | @Preview
185 | private fun MagicScreenPreview() {
186 | MagicScreenContent(
187 | set = CardSet("SET01", "SET01"),
188 | sets = listOf(CardSet("SET01", "SET01"),CardSet("SET02", "SET02")),
189 | setCount = 50,
190 | cards = emptyList(),
191 | cardsTotalCount = 123,
192 | isLoading = true,
193 | onSetSelected = { },
194 | onGetCards = { },
195 | onDelete = { }
196 | )
197 | }
--------------------------------------------------------------------------------
/androidApp/src/main/java/com/magic/app/android/presentation/MagicViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.magic.app.android.presentation
2 |
3 | import androidx.lifecycle.ViewModel
4 | import androidx.lifecycle.viewModelScope
5 | import com.magic.data.managers.CardsManager
6 | import com.magic.data.models.local.Card
7 | import com.magic.data.models.local.CardSet
8 | import kotlinx.coroutines.flow.MutableStateFlow
9 | import kotlinx.coroutines.flow.SharingStarted
10 | import kotlinx.coroutines.flow.combine
11 | import kotlinx.coroutines.flow.distinctUntilChanged
12 | import kotlinx.coroutines.flow.filterNot
13 | import kotlinx.coroutines.flow.flatMapLatest
14 | import kotlinx.coroutines.flow.onSubscription
15 | import kotlinx.coroutines.flow.stateIn
16 | import kotlinx.coroutines.flow.update
17 | import kotlinx.coroutines.launch
18 | import org.koin.core.component.KoinComponent
19 | import org.koin.core.component.inject
20 |
21 | data class MagicScreenState(
22 | val set: CardSet = CardSet(),
23 | val cards: List = emptyList(),
24 | val availableSets: List = emptyList(),
25 | val setCount: Int = 0,
26 | val cardsTotalCount: Int = 0,
27 | val isLoading: Boolean = false
28 | )
29 |
30 | class MagicViewModel : ViewModel(), KoinComponent {
31 | //https://en.wikipedia.org/wiki/List_of_Magic:_The_Gathering_sets
32 | private val cardSetsCodes = listOf("4ED", "5ED", "TMP", "MIR")
33 | private val manager: CardsManager by inject()
34 |
35 | private val observeCurrentSet = MutableStateFlow(CardSet())
36 | private val _state = MutableStateFlow(MagicScreenState())
37 | val state = _state
38 | .onSubscription { safeCall(call = { manager.getSets(cardSetsCodes) }) }
39 | .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), MagicScreenState(isLoading = true))
40 |
41 | init {
42 | viewModelScope.launch {
43 | observeCurrentSet
44 | .flatMapLatest { set ->
45 | combine(
46 | manager.observeSets,
47 | manager.observeCardsFromSet(set.code),
48 | manager.observeCardCount,
49 | manager.observeSetCount
50 | ) { sets, cards, cardsCount, setCount ->
51 | MagicScreenState(set, cards, sets, setCount.toInt(), cardsCount.toInt())
52 | }
53 | }
54 | .collect { new ->
55 | _state.update {
56 | it.copy(
57 | set = new.set,
58 | availableSets = new.availableSets,
59 | cards = new.cards,
60 | cardsTotalCount = new.cardsTotalCount,
61 | setCount = new.setCount
62 | )
63 | }
64 | }
65 | }
66 | }
67 |
68 | fun changeSet(set: CardSet) {
69 | observeCurrentSet.update { set }
70 | }
71 |
72 | fun getCardsFromCurrentSet() {
73 | viewModelScope.launch {
74 | safeCall(
75 | onCallStateChange = { running -> _state.update { it.copy(isLoading = running) } },
76 | call = { manager.getSet(observeCurrentSet.value.code) }
77 | )
78 | }
79 | }
80 |
81 | fun deleteCardsFromCurrentSet() {
82 | manager.removeSet(observeCurrentSet.value.code)
83 | }
84 | }
85 |
86 | private suspend fun safeCall(onCallStateChange: (suspend (running: Boolean) -> Unit)? = null, call: suspend () -> T): T? {
87 | try {
88 | onCallStateChange?.invoke(true)
89 | return call()
90 | } catch (e: Throwable) {
91 | println(e) //to simplify
92 | } finally {
93 | onCallStateChange?.invoke(false)
94 | }
95 | return null
96 | }
--------------------------------------------------------------------------------
/androidApp/src/main/res/drawable/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
10 |
--------------------------------------------------------------------------------
/androidApp/src/main/res/drawable/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
6 |
10 |
14 |
18 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/build-logic/conventions/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | `kotlin-dsl`
3 | }
4 |
5 | repositories {
6 | google()
7 | mavenCentral()
8 | }
9 |
10 | java {
11 | toolchain.languageVersion.set(JavaLanguageVersion.of(JavaVersion.VERSION_17.toString()))
12 | }
13 |
14 | tasks.withType {
15 | kotlinOptions {
16 | jvmTarget = JavaVersion.VERSION_17.toString()
17 | }
18 | }
19 |
20 | dependencies {
21 | implementation(libs.gradle.android.tools)
22 | implementation(libs.gradle.kotlin)
23 | }
24 |
25 | group = "buildlogic.plugins"
26 |
27 | gradlePlugin {
28 | plugins {
29 | register("AndroidAppConventionPlugin") {
30 | id = "${project.group}.application"
31 | implementationClass = "AndroidAppConventionPlugin"
32 | }
33 | register("KMPAndroidLibraryConventionPlugin") {
34 | id = "${project.group}.kmp.library.android"
35 | implementationClass = "KMPAndroidLibraryConventionPlugin"
36 | }
37 | register("CMPLibraryConventionPlugin") {
38 | id = "${project.group}.kmp.compose"
39 | implementationClass = "CMPLibraryConventionPlugin"
40 | }
41 | }
42 | }
--------------------------------------------------------------------------------
/build-logic/conventions/src/main/kotlin/AndroidAppConventionPlugin.kt:
--------------------------------------------------------------------------------
1 | import com.android.build.gradle.internal.dsl.BaseAppModuleExtension
2 | import extensions.addComposeDependencies
3 | import extensions.addKotlinCompileOptions
4 | import extensions.buildComposeMetricsParameters
5 | import org.gradle.api.JavaVersion
6 | import org.gradle.api.Plugin
7 | import org.gradle.api.Project
8 | import org.gradle.api.artifacts.VersionCatalog
9 | import org.gradle.api.artifacts.VersionCatalogsExtension
10 | import org.gradle.kotlin.dsl.configure
11 | import org.gradle.kotlin.dsl.getByType
12 |
13 | class AndroidAppConventionPlugin : Plugin {
14 |
15 | override fun apply(target: Project) {
16 | with(target) {
17 | with(pluginManager) {
18 | apply("com.android.application")
19 | apply("org.jetbrains.kotlin.android")
20 | apply("org.jetbrains.kotlin.plugin.compose")
21 | }
22 |
23 | val versionCatalog = target.extensions.getByType().named("libs")
24 | extensions.configure {
25 | addKotlinAndroidConfigurations(versionCatalog)
26 | }
27 | addKotlinCompileOptions(buildComposeMetricsParameters())
28 | addComposeDependencies(versionCatalog)
29 | }
30 | }
31 |
32 | private fun BaseAppModuleExtension.addKotlinAndroidConfigurations(libs: VersionCatalog) {
33 | apply {
34 | compileSdk = libs.findVersion("androidCompileSdk").get().toString().toInt()
35 | defaultConfig {
36 | targetSdk = libs.findVersion("androidTargetSdk").get().toString().toInt()
37 | minSdk = libs.findVersion("androidMinSdk").get().toString().toInt()
38 |
39 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
40 | testInstrumentationRunnerArguments.putAll(
41 | mapOf(
42 | "disableAnalytics" to "true",
43 | "clearPackageData" to "true"
44 | )
45 | )
46 | }
47 |
48 | buildFeatures {
49 | compose = true
50 | }
51 |
52 | compileOptions {
53 | sourceCompatibility = JavaVersion.VERSION_17
54 | targetCompatibility = JavaVersion.VERSION_17
55 | }
56 |
57 | lint {
58 | disable.add("Instantiatable")
59 | abortOnError = false
60 | }
61 |
62 | @Suppress("UnstableApiUsage")
63 | testOptions {
64 | unitTests.apply {
65 | isReturnDefaultValues = true
66 | isIncludeAndroidResources = true
67 | }
68 | }
69 |
70 | packaging {
71 | // Optimize APK size - remove excess files in the manifest and APK
72 | resources {
73 | excludes.addAll(
74 | listOf(
75 | "**/kotlin/**",
76 | "**/*.kotlin_module",
77 | // "**/*.version",
78 | "**/*.txt",
79 | // "**/*.xml", //if not commented it will delete all shared-ui resources
80 | "**/*.properties",
81 | "/META-INF/{AL2.0,LGPL2.1}"
82 | )
83 | )
84 | }
85 | }
86 | }
87 | }
88 | }
--------------------------------------------------------------------------------
/build-logic/conventions/src/main/kotlin/CMPLibraryConventionPlugin.kt:
--------------------------------------------------------------------------------
1 | import org.gradle.api.Plugin
2 | import org.gradle.api.Project
3 |
4 | class CMPLibraryConventionPlugin : Plugin {
5 | override fun apply(target: Project) {
6 | with(target) {
7 | pluginManager.apply("org.jetbrains.kotlin.multiplatform")
8 | pluginManager.apply("org.jetbrains.kotlin.plugin.compose")
9 | pluginManager.apply("org.jetbrains.compose")
10 | }
11 | }
12 | }
--------------------------------------------------------------------------------
/build-logic/conventions/src/main/kotlin/KMPAndroidLibraryConventionPlugin.kt:
--------------------------------------------------------------------------------
1 | import com.android.build.gradle.LibraryExtension
2 | import extensions.addKotlinAndroidConfigurations
3 | import org.gradle.api.Plugin
4 | import org.gradle.api.Project
5 | import org.gradle.api.artifacts.VersionCatalogsExtension
6 | import org.gradle.kotlin.dsl.configure
7 | import org.gradle.kotlin.dsl.getByType
8 | import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
9 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget
10 | import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
11 |
12 | @OptIn(ExperimentalKotlinGradlePluginApi::class)
13 | class KMPAndroidLibraryConventionPlugin : Plugin {
14 | override fun apply(target: Project) {
15 | with(target) {
16 | pluginManager.apply("org.jetbrains.kotlin.multiplatform")
17 | pluginManager.apply("com.android.library")
18 | extensions.configure {
19 | addKotlinAndroidConfigurations(extensions.getByType().named("libs")).also {
20 | sourceSets.getByName("main").manifest.srcFile("src/androidMain/AndroidManifest.xml")
21 | }
22 | }
23 | extensions.configure {
24 | androidTarget {
25 | compilerOptions {
26 | jvmTarget.set(JvmTarget.JVM_17)
27 | }
28 | }
29 | sourceSets.all {
30 | languageSettings.apply {
31 | optIn("kotlinx.coroutines.ExperimentalCoroutinesApi")
32 | optIn("kotlinx.cinterop.ExperimentalForeignApi")
33 | optIn("kotlin.experimental.ExperimentalObjCName")
34 | optIn("kotlin.RequiresOptIn")
35 | }
36 | }
37 | }
38 | }
39 | }
40 | }
--------------------------------------------------------------------------------
/build-logic/conventions/src/main/kotlin/extensions/AndroidConfigurations.kt:
--------------------------------------------------------------------------------
1 | package extensions
2 |
3 | import com.android.build.gradle.LibraryExtension
4 | import org.gradle.api.JavaVersion
5 | import org.gradle.api.artifacts.VersionCatalog
6 |
7 | internal fun LibraryExtension.addKotlinAndroidConfigurations(libs: VersionCatalog) {
8 | apply {
9 | compileSdk = libs.findVersion("androidCompileSdk").get().toString().toInt()
10 | defaultConfig {
11 | minSdk = libs.findVersion("androidMinSdk").get().toString().toInt()
12 | }
13 |
14 | compileOptions {
15 | sourceCompatibility = JavaVersion.VERSION_17
16 | targetCompatibility = JavaVersion.VERSION_17
17 | }
18 |
19 | lint {
20 | disable.add("Instantiatable")
21 | abortOnError = false
22 | }
23 | }
24 | }
--------------------------------------------------------------------------------
/build-logic/conventions/src/main/kotlin/extensions/ComposeOptions.kt:
--------------------------------------------------------------------------------
1 | package extensions
2 |
3 | import org.gradle.api.Project
4 | import org.gradle.api.artifacts.VersionCatalog
5 | import org.gradle.kotlin.dsl.dependencies
6 | import java.io.File
7 |
8 | internal fun Project.addComposeDependencies(libs: VersionCatalog) {
9 | dependencies {
10 | add("implementation", libs.findBundle("androidx.compose").get())
11 | }
12 | }
13 |
14 | internal fun Project.buildComposeMetricsParameters(): List {
15 | val metricParameters = mutableListOf()
16 | val enableMetricsProvider = project.providers.gradleProperty("enableComposeCompilerMetrics")
17 | val enableMetrics = (enableMetricsProvider.orNull == "true")
18 | if (enableMetrics) {
19 | val metricsFolder = File(project.layout.buildDirectory.get().asFile, "compose-metrics")
20 | metricParameters.add("-P")
21 | metricParameters.add(
22 | "plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=" + metricsFolder.absolutePath
23 | )
24 | }
25 |
26 | val enableReportsProvider = project.providers.gradleProperty("enableComposeCompilerReports")
27 | val enableReports = (enableReportsProvider.orNull == "true")
28 | if (enableReports) {
29 | val reportsFolder = File(project.layout.buildDirectory.get().asFile, "compose-reports")
30 | metricParameters.add("-P")
31 | metricParameters.add(
32 | "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=" + reportsFolder.absolutePath
33 | )
34 | }
35 | return metricParameters.toList()
36 | }
37 |
--------------------------------------------------------------------------------
/build-logic/conventions/src/main/kotlin/extensions/KotlinCompileOptions.kt:
--------------------------------------------------------------------------------
1 | package extensions
2 |
3 | import org.gradle.api.Project
4 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget
5 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
6 |
7 | internal fun Project.addKotlinCompileOptions(options: List = emptyList()) {
8 | tasks.withType(KotlinCompile::class.java).configureEach {
9 | compilerOptions {
10 | jvmTarget.set(JvmTarget.JVM_17)
11 | freeCompilerArgs.addAll(
12 | options + listOf(
13 | "-opt-in=kotlin.RequiresOptIn",
14 | "-opt-in=kotlin.Experimental",
15 | "-opt-in=kotlinx.coroutines.FlowPreview",
16 | "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
17 | "-opt-in=kotlinx.serialization.ExperimentalSerializationApi",
18 | )
19 | )
20 | }
21 | }
22 | }
--------------------------------------------------------------------------------
/build-logic/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | @file:Suppress("UnstableApiUsage")
2 |
3 | dependencyResolutionManagement {
4 | repositories {
5 | google()
6 | mavenCentral()
7 | }
8 | versionCatalogs {
9 | create("libs") {
10 | from(files("../gradle/libs.versions.toml"))
11 | }
12 | }
13 | }
14 |
15 | include(":conventions")
--------------------------------------------------------------------------------
/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.android.application) apply false
3 | alias(libs.plugins.android.library) apply false
4 | alias(libs.plugins.kotlinx.compose) apply false
5 | alias(libs.plugins.kotlinx.compose.compiler) apply false
6 | alias(libs.plugins.sqldelight) apply false
7 | }
--------------------------------------------------------------------------------
/core-database/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("buildlogic.plugins.kmp.library.android")
3 | alias(libs.plugins.sqldelight)
4 | }
5 |
6 | android {
7 | namespace = "com.magic.core.database"
8 | }
9 |
10 | kotlin {
11 | androidTarget()
12 | iosArm64()
13 | iosSimulatorArm64()
14 |
15 | sourceSets {
16 | commonMain.dependencies {
17 | implementation(libs.kotlinx.coroutines.core)
18 | implementation(libs.kmp.sqldelight.coroutines.extensions)
19 | implementation(libs.kmp.koin.core)
20 | }
21 | commonTest.dependencies {
22 | implementation(libs.test.kotlin)
23 | implementation(libs.test.kmp.koin)
24 | implementation(libs.test.kmp.turbine)
25 | implementation(libs.test.kotlinx.coroutines)
26 | }
27 | androidMain.dependencies {
28 | implementation(libs.kmp.sqldelight.android.driver)
29 | implementation(libs.kmp.sqldelight.driver)
30 | }
31 | iosMain.dependencies { implementation(libs.kmp.sqldelight.ios.driver) }
32 | }
33 | }
34 |
35 | sqldelight {
36 | databases {
37 | create("MagicDatabase") {
38 | packageName.set("com.magic.core.database")
39 | }
40 | }
41 | }
--------------------------------------------------------------------------------
/core-database/src/androidMain/kotlin/com/magic/core/database/DI.android.kt:
--------------------------------------------------------------------------------
1 | @file:Suppress("ACTUAL_ANNOTATIONS_NOT_MATCH_EXPECT")
2 |
3 | package com.magic.core.database
4 |
5 | import androidx.sqlite.db.SupportSQLiteDatabase
6 | import app.cash.sqldelight.driver.android.AndroidSqliteDriver
7 | import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
8 | import org.koin.core.module.Module
9 | import org.koin.dsl.module
10 | import java.util.Properties
11 |
12 | actual fun databaseDiModule(): Module = module {
13 | single {
14 | MagicDao(
15 | AndroidSqliteDriver(
16 | schema = MagicDatabase.Schema,
17 | context = get(),
18 | name = DATABASE_NAME,
19 | callback = object : AndroidSqliteDriver.Callback(MagicDatabase.Schema) {
20 | override fun onOpen(db: SupportSQLiteDatabase) {
21 | db.setForeignKeyConstraintsEnabled(true)
22 | }
23 | })
24 | )
25 | }
26 | }
27 |
28 | actual fun databaseDiTestModule(): Module = module {
29 | single {
30 | MagicDao(
31 | JdbcSqliteDriver(
32 | url = JdbcSqliteDriver.IN_MEMORY,
33 | schema = MagicDatabase.Schema,
34 | properties = Properties().apply { put("foreign_keys", "true") })
35 | )
36 | }
37 | }
--------------------------------------------------------------------------------
/core-database/src/androidUnitTest/kotlin/DITest.android.kt:
--------------------------------------------------------------------------------
1 | import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
2 | import com.magic.core.database.MagicDao
3 | import com.magic.core.database.MagicDatabase
4 | import org.koin.core.module.Module
5 | import org.koin.dsl.module
6 | import java.util.Properties
7 |
8 | actual fun databaseDiTestModule(): Module = module {
9 | single {
10 | MagicDao(
11 | JdbcSqliteDriver(
12 | url = JdbcSqliteDriver.IN_MEMORY,
13 | schema = MagicDatabase.Schema,
14 | properties = Properties().apply { put("foreign_keys", "true") })
15 | )
16 | }
17 | }
--------------------------------------------------------------------------------
/core-database/src/commonMain/kotlin/com/magic/core/database/DI.kt:
--------------------------------------------------------------------------------
1 | package com.magic.core.database
2 |
3 | import org.koin.core.module.Module
4 | import kotlin.experimental.ExperimentalObjCRefinement
5 | import kotlin.native.HiddenFromObjC
6 |
7 | @OptIn(ExperimentalObjCRefinement::class)
8 | @HiddenFromObjC
9 | expect fun databaseDiModule(): Module
10 |
11 | @OptIn(ExperimentalObjCRefinement::class)
12 | @HiddenFromObjC
13 | expect fun databaseDiTestModule(): Module
14 |
15 | internal const val DATABASE_NAME = "magic_database.db"
--------------------------------------------------------------------------------
/core-database/src/commonMain/kotlin/com/magic/core/database/MagicDao.kt:
--------------------------------------------------------------------------------
1 | package com.magic.core.database
2 |
3 | import app.cash.sqldelight.coroutines.asFlow
4 | import app.cash.sqldelight.coroutines.mapToList
5 | import app.cash.sqldelight.coroutines.mapToOne
6 | import app.cash.sqldelight.db.SqlDriver
7 | import kotlinx.coroutines.Dispatchers
8 | import kotlinx.coroutines.IO
9 | import kotlinx.coroutines.flow.Flow
10 | import kotlin.experimental.ExperimentalObjCRefinement
11 | import kotlin.native.HiddenFromObjC
12 |
13 | @OptIn(ExperimentalObjCRefinement::class)
14 | @HiddenFromObjC
15 | class MagicDao(driver: SqlDriver) {
16 | private val database = MagicDatabase(driver)
17 | private val queries = database.magicDatabaseQueries
18 |
19 | fun insertSet(code: String, name: String, releaseDate: String) {
20 | queries.insertCardSet(code, name, releaseDate)
21 | }
22 |
23 | fun insertCard(id: String, setCode: String, name: String, text: String, imageUrl: String, artist: String) {
24 | queries.insertCard(id, setCode, name, text, imageUrl, artist)
25 | }
26 |
27 | fun setExist(code: String): Boolean {
28 | return queries.getSet(code).executeAsOneOrNull() != null
29 | }
30 |
31 | fun deleteAllSets() {
32 | queries.deleteAllSets()
33 | }
34 |
35 | fun deleteCardSet(setCode: String) {
36 | queries.deleteCardSetAndCards(setCode)
37 | }
38 |
39 | fun cardsStream(): Flow> {
40 | return queries
41 | .getAllCards()
42 | .asFlow()
43 | .mapToList(Dispatchers.IO)
44 | }
45 |
46 | fun cards(): List {
47 | return queries
48 | .getAllCards()
49 | .executeAsList()
50 | }
51 |
52 | fun cardCountStream(): Flow {
53 | return queries
54 | .getCardsCount()
55 | .asFlow()
56 | .mapToOne(Dispatchers.IO)
57 | }
58 |
59 | fun cardCount(): Long {
60 | return queries
61 | .getCardsCount()
62 | .executeAsOne()
63 | }
64 |
65 | fun setsStream(): Flow> {
66 | return queries
67 | .getAllCardSets()
68 | .asFlow()
69 | .mapToList(Dispatchers.IO)
70 | }
71 |
72 | fun sets(): List {
73 | return queries
74 | .getAllCardSets()
75 | .executeAsList()
76 | }
77 |
78 | fun setCountStream(): Flow {
79 | return queries
80 | .getSetsCount()
81 | .asFlow()
82 | .mapToOne(Dispatchers.IO)
83 | }
84 |
85 | fun setCount(): Long {
86 | return queries
87 | .getSetsCount()
88 | .executeAsOne()
89 | }
90 |
91 | fun cardsFromSetStream(setCode: String): Flow> {
92 | return queries
93 | .getCardsBySetCode(setCode)
94 | .asFlow()
95 | .mapToList(Dispatchers.IO)
96 | }
97 |
98 | fun cardsFromSet(setCode: String): List {
99 | return queries
100 | .getCardsBySetCode(setCode)
101 | .executeAsList()
102 | }
103 | }
--------------------------------------------------------------------------------
/core-database/src/commonMain/sqldelight/com/magic/core/database/MagicDatabase.sq:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS CardSet (
2 | code TEXT NOT NULL PRIMARY KEY,
3 | name TEXT NOT NULL,
4 | releaseDate Text NOT NULL
5 | );
6 |
7 | CREATE TABLE IF NOT EXISTS Card (
8 | id TEXT NOT NULL PRIMARY KEY,
9 | setCode TEXT NOT NULL,
10 | name TEXT NOT NULL ,
11 | text TEXT NOT NULL,
12 | imageUrl TEXT,
13 | artist TEXT NOT NULL,
14 | FOREIGN KEY(setCode) REFERENCES CardSet(code) ON DELETE CASCADE
15 | );
16 |
17 | getCardsCount:
18 | SELECT COUNT(id) FROM Card;
19 |
20 | getSetsCount:
21 | SELECT COUNT(code) FROM CardSet;
22 |
23 | getAllCards:
24 | SELECT DISTINCT * FROM Card WHERE imageUrl!= '' ORDER BY name;
25 |
26 | getAllCardSets:
27 | SELECT * FROM CardSet ORDER BY releaseDate;
28 |
29 | getSet:
30 | SELECT code FROM CardSet WHERE code = ? LIMIT 1;
31 |
32 | getCardsBySetCode:
33 | SELECT * FROM Card WHERE setCode = ? ORDER BY name;
34 |
35 | insertCard:
36 | INSERT OR REPLACE INTO Card(id, setCode, name, text, imageUrl, artist)
37 | VALUES (?, ?, ?, ?, ?, ?);
38 |
39 | insertCardSet:
40 | INSERT OR REPLACE INTO CardSet(code, name, releaseDate)
41 | VALUES (?, ?, ?);
42 |
43 | deleteAllSets:
44 | DELETE FROM CardSet;
45 |
46 | deleteCardSetAndCards:
47 | DELETE FROM CardSet WHERE code = ?;
48 | DELETE FROM Card WHERE setCode = ?;
--------------------------------------------------------------------------------
/core-database/src/commonTest/kotlin/DITest.kt:
--------------------------------------------------------------------------------
1 | import org.koin.core.module.Module
2 | import kotlin.experimental.ExperimentalObjCRefinement
3 | import kotlin.native.HiddenFromObjC
4 |
5 | @OptIn(ExperimentalObjCRefinement::class)
6 | @HiddenFromObjC
7 | expect fun databaseDiTestModule(): Module
--------------------------------------------------------------------------------
/core-database/src/commonTest/kotlin/MagicDaoTest.kt:
--------------------------------------------------------------------------------
1 | import app.cash.turbine.test
2 | import com.magic.core.database.MagicDao
3 | import kotlinx.coroutines.test.runTest
4 | import org.koin.core.context.startKoin
5 | import org.koin.core.context.stopKoin
6 | import org.koin.test.KoinTest
7 | import org.koin.test.get
8 | import kotlin.test.AfterTest
9 | import kotlin.test.BeforeTest
10 | import kotlin.test.Test
11 | import kotlin.test.assertEquals
12 | import kotlin.test.assertFalse
13 | import kotlin.test.assertTrue
14 |
15 | class MagicDaoTest : KoinTest {
16 |
17 | private lateinit var dao: MagicDao
18 |
19 | @BeforeTest
20 | fun setUp() {
21 | startKoin {
22 | modules(
23 | databaseDiTestModule()
24 | )
25 | }
26 | dao = get()
27 | dao.deleteAllSets()
28 | }
29 |
30 | @AfterTest
31 | fun finish() {
32 | stopKoin()
33 | }
34 |
35 | @Test
36 | fun `When inserting a Set it should be retrievable with correct data`() = runTest {
37 | val code = "SET001"
38 | val name = "Test Set"
39 | val releaseDate = "2024-01-01"
40 | dao.insertSet(code, name, releaseDate)
41 |
42 | val sets = dao.sets()
43 | assertEquals(1, sets.size)
44 |
45 | val set = sets[0]
46 | assertEquals(code, set.code)
47 | assertEquals(name, set.name)
48 | assertEquals(releaseDate, set.releaseDate)
49 | }
50 |
51 | @Test
52 | fun `Searching for a Set by code should return true if exists`() = runTest {
53 | val code = "SET001"
54 | val name = "Test Set"
55 | val releaseDate = "2024-01-01"
56 |
57 | dao.insertSet(code, name, releaseDate)
58 | assertTrue { dao.setExist(code) }
59 |
60 | dao.deleteCardSet(code)
61 | assertFalse { dao.setExist(code) }
62 | }
63 |
64 | @Test
65 | fun `Deleting a Set should remove all associated cards`() = runTest {
66 | dao.insertSet("SET001", "Test Set", "2024-01-01")
67 | dao.insertCard("1", "SET001", "Card 1", "Text 1", "url1", "Artist 1")
68 | dao.insertCard("2", "SET001", "Card 2", "Text 2", "url2", "Artist 2")
69 | assertEquals(2, dao.cards().size)
70 |
71 | dao.deleteCardSet("SET001")
72 | assertEquals(0, dao.cards().size)
73 | assertEquals(0, dao.sets().size)
74 | }
75 |
76 | @Test
77 | fun `Deleting all Sets should empty the database`() = runTest {
78 | dao.insertSet("SET001", "Test Set", "2024-01-01")
79 | dao.insertCard("1", "SET001", "Card 1", "Text 1", "url1", "Artist 1")
80 | assertEquals(1, dao.cards().size)
81 | assertEquals(1, dao.sets().size)
82 |
83 | dao.deleteAllSets()
84 | assertEquals(0, dao.cardCount())
85 | assertEquals(0, dao.setCount())
86 | }
87 |
88 | @Test
89 | fun `Set count should update after insertion or deletion`() = runTest {
90 | assertEquals(0, dao.setCount())
91 | dao.insertSet("SET001", "Test Set", "2024-01-01")
92 | dao.insertSet("SET002", "Test Set", "2024-01-01")
93 | assertEquals(2, dao.setCount())
94 | }
95 |
96 | @Test
97 | fun `Set count stream should reflect real-time changes accurately`() = runTest {
98 | dao.setCountStream().test {
99 | assertEquals(0, awaitItem())
100 |
101 | dao.insertSet("SET001", "Set 1", "2024-01-01")
102 | assertEquals(1, awaitItem())
103 |
104 | dao.insertSet("SET002", "Set 2", "2024-02-01")
105 | assertEquals(2, awaitItem())
106 |
107 | cancelAndIgnoreRemainingEvents()
108 | }
109 | }
110 |
111 | @Test
112 | fun `Card count should update after insertion or deletion`() = runTest {
113 | assertEquals(0, dao.cardCount())
114 | dao.insertSet("SET001", "Test Set", "2024-01-01")
115 | dao.insertCard("1", "SET001", "Card 1", "Text 1", "url1", "Artist 1")
116 | assertEquals(1, dao.cardCount())
117 | }
118 |
119 | @Test
120 | fun `Card count stream should reflect real-time changes accurately`() = runTest {
121 | dao.insertSet("SET001", "Test Set", "2024-01-01")
122 | dao.cardCountStream().test {
123 | assertEquals(0, awaitItem())
124 |
125 | dao.insertCard("1", "SET001", "Card 1", "Text 1", "url1", "Artist 1")
126 | assertEquals(1, awaitItem())
127 |
128 | dao.insertCard("2", "SET001", "Card 2", "Text 2", "url2", "Artist 2")
129 | assertEquals(2, awaitItem())
130 |
131 | cancelAndIgnoreRemainingEvents()
132 | }
133 | }
134 |
135 | @Test
136 | fun `Sets should return correct list of sets`() = runTest {
137 | dao.insertSet("SET001", "Set 1", "2024-01-01")
138 | dao.insertSet("SET002", "Set 2", "2023-01-01")
139 |
140 | val sets = dao.sets()
141 | assertEquals(2, sets.size)
142 | }
143 |
144 | @Test
145 | fun `Sets stream should reflect real-time changes accurately`() = runTest {
146 | dao.setsStream().test {
147 | assertEquals(0, awaitItem().size)
148 |
149 | dao.insertSet("SET001", "Set 1", "2024-01-01")
150 | with(awaitItem()) {
151 | assertEquals(1, this.size)
152 | assertEquals("SET001", this[0].code)
153 | }
154 |
155 | dao.insertSet("SET002", "Set 2", "2024-02-01")
156 | with(awaitItem()) {
157 | assertEquals(2, this.size)
158 | assertEquals("SET002", this[1].code)
159 | }
160 |
161 | cancelAndIgnoreRemainingEvents()
162 | }
163 | }
164 |
165 | @Test
166 | fun `Cards from Set should return correct list of cards`() = runTest {
167 | dao.insertSet("SET001", "Set 1", "2024-01-01")
168 | dao.insertCard("1", "SET001", "Card 1", "Text 1", "url1", "Artist 1")
169 | dao.insertCard("2", "SET001", "Card 2", "Text 2", "url2", "Artist 2")
170 |
171 | val cards = dao.cardsFromSet("SET001")
172 | assertEquals(2, cards.size)
173 | assertEquals("1", cards[0].id)
174 | assertEquals("2", cards[1].id)
175 | }
176 |
177 | @Test
178 | fun `Cards from Set stream should reflect real-time changes accurately`() = runTest {
179 | dao.insertSet("SET001", "Set 1", "2024-01-01")
180 | dao.cardsFromSetStream("SET001").test {
181 | assertEquals(0, awaitItem().size)
182 |
183 | dao.insertCard("1", "SET001", "Card 1", "Text 1", "url1", "Artist 1")
184 | with(awaitItem()) {
185 | assertEquals(1, this.size)
186 | assertEquals("1", this[0].id)
187 | }
188 |
189 | dao.insertCard("2", "SET001", "Card 2", "Text 2", "url2", "Artist 2")
190 | with(awaitItem()) {
191 | assertEquals(2, this.size)
192 | assertEquals("2", this[1].id)
193 | }
194 |
195 | cancelAndIgnoreRemainingEvents()
196 | }
197 | }
198 | }
199 |
--------------------------------------------------------------------------------
/core-database/src/iosMain/kotlin/com/magic/core/database/DI.ios.kt:
--------------------------------------------------------------------------------
1 | package com.magic.core.database
2 |
3 | import app.cash.sqldelight.driver.native.NativeSqliteDriver
4 | import app.cash.sqldelight.driver.native.wrapConnection
5 | import co.touchlab.sqliter.DatabaseConfiguration
6 | import org.koin.core.module.Module
7 | import org.koin.dsl.module
8 | import kotlin.experimental.ExperimentalObjCRefinement
9 |
10 | @OptIn(ExperimentalObjCRefinement::class)
11 | @HiddenFromObjC
12 | actual fun databaseDiModule(): Module = module {
13 | single {
14 | MagicDao(
15 | NativeSqliteDriver(
16 | schema = MagicDatabase.Schema,
17 | name = DATABASE_NAME,
18 | onConfiguration = { config: DatabaseConfiguration ->
19 | config.copy(
20 | extendedConfig = DatabaseConfiguration.Extended(foreignKeyConstraints = true)
21 | )
22 | })
23 | )
24 | }
25 | }
26 |
27 | @OptIn(ExperimentalObjCRefinement::class)
28 | @HiddenFromObjC
29 | actual fun databaseDiTestModule(): Module = module {
30 | single {
31 | val schema = MagicDatabase.Schema
32 | MagicDao(
33 | NativeSqliteDriver(
34 | DatabaseConfiguration(
35 | name = null,
36 | inMemory = true,
37 | version = if (schema.version > Int.MAX_VALUE) error("Schema version is larger than Int.MAX_VALUE: ${schema.version}.") else schema.version.toInt(),
38 | create = { connection -> wrapConnection(connection) { schema.create(it) } },
39 | upgrade = { connection, oldVersion, newVersion ->
40 | wrapConnection(connection) { schema.migrate(it, oldVersion.toLong(), newVersion.toLong()) }
41 | },
42 | extendedConfig = DatabaseConfiguration.Extended(foreignKeyConstraints = true)
43 | )
44 | )
45 | )
46 | }
47 | }
--------------------------------------------------------------------------------
/core-database/src/iosTest/kotlin/DITest.ios.kt:
--------------------------------------------------------------------------------
1 | import app.cash.sqldelight.driver.native.NativeSqliteDriver
2 | import app.cash.sqldelight.driver.native.wrapConnection
3 | import co.touchlab.sqliter.DatabaseConfiguration
4 | import com.magic.core.database.MagicDao
5 | import com.magic.core.database.MagicDatabase
6 | import org.koin.core.module.Module
7 | import org.koin.dsl.module
8 | import kotlin.experimental.ExperimentalObjCRefinement
9 |
10 | @OptIn(ExperimentalObjCRefinement::class)
11 | @HiddenFromObjC
12 | actual fun databaseDiTestModule(): Module = module {
13 | single {
14 | val schema = MagicDatabase.Schema
15 | MagicDao(
16 | NativeSqliteDriver(
17 | DatabaseConfiguration(
18 | name = null,
19 | inMemory = true,
20 | version = if (schema.version > Int.MAX_VALUE) error("Schema version is larger than Int.MAX_VALUE: ${schema.version}.") else schema.version.toInt(),
21 | create = { connection -> wrapConnection(connection) { schema.create(it) } },
22 | upgrade = { connection, oldVersion, newVersion ->
23 | wrapConnection(connection) { schema.migrate(it, oldVersion.toLong(), newVersion.toLong()) }
24 | },
25 | extendedConfig = DatabaseConfiguration.Extended(foreignKeyConstraints = true)
26 | )
27 | )
28 | )
29 | }
30 | }
--------------------------------------------------------------------------------
/core-di/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
2 |
3 | plugins {
4 | id("buildlogic.plugins.kmp.library.android")
5 | alias(libs.plugins.sqldelight) //to include sqlite3 in XCFramework
6 | }
7 |
8 | android {
9 | namespace = "com.magic.core.di"
10 | }
11 |
12 | kotlin {
13 | androidTarget()
14 | listOf(iosArm64(), iosSimulatorArm64()).forEach { iosTarget ->
15 | iosTarget.binaries.framework {
16 | baseName = "MagicDataLayer"
17 | export(projects.dataManagers)
18 | export(projects.dataModels)
19 | }
20 | @OptIn(ExperimentalKotlinGradlePluginApi::class)
21 | compilerOptions {
22 | freeCompilerArgs.add("-Xexport-kdoc")
23 | }
24 | }
25 |
26 | sourceSets {
27 | commonMain.dependencies {
28 | implementation(projects.coreNetwork)
29 | implementation(projects.coreDatabase)
30 | api(projects.dataManagers)
31 | api(projects.dataModels)
32 | implementation(libs.kmp.koin.core)
33 | }
34 | }
35 | }
--------------------------------------------------------------------------------
/core-di/src/commonMain/kotlin/com/magic/core/di/DependencyInjection.kt:
--------------------------------------------------------------------------------
1 | @file:OptIn(ExperimentalObjCRefinement::class)
2 |
3 | package com.magic.core.di
4 |
5 | import com.magic.core.database.databaseDiModule
6 | import com.magic.core.network.networkDiModule
7 | import com.magic.data.managers.managersDiModule
8 | import org.koin.core.context.startKoin
9 | import org.koin.dsl.KoinAppDeclaration
10 | import kotlin.experimental.ExperimentalObjCRefinement
11 | import kotlin.native.HiddenFromObjC
12 |
13 | object DependencyInjection {
14 | /**
15 | * DI engine initialization.
16 | * This function must be called by the iOS app inside the respective App struct.
17 | */
18 | @Suppress("unused")
19 | fun initKoin(enableNetworkLogs: Boolean) = initKoin(enableNetworkLogs = enableNetworkLogs, appDeclaration = {})
20 |
21 | /**
22 | * DI engine initialization.
23 | * This function must be called by the Android app inside the respective Application class.
24 | */
25 | @HiddenFromObjC
26 | fun initKoin(enableNetworkLogs: Boolean = false, appDeclaration: KoinAppDeclaration) {
27 | startKoin {
28 | appDeclaration()
29 | modules(
30 | networkDiModule("https://api.magicthegathering.io/v1/", enableNetworkLogs),
31 | databaseDiModule(),
32 | managersDiModule()
33 | )
34 | }
35 | }
36 | }
--------------------------------------------------------------------------------
/core-network/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("buildlogic.plugins.kmp.library.android")
3 | alias(libs.plugins.kotlinx.serialization)
4 | }
5 |
6 | android {
7 | namespace = "com.magic.core.network"
8 | }
9 |
10 | kotlin {
11 | androidTarget()
12 | iosArm64()
13 | iosSimulatorArm64()
14 |
15 | sourceSets {
16 | commonMain.dependencies {
17 | implementation(libs.bundles.ktor)
18 | implementation(libs.kotlinx.coroutines.core)
19 | implementation(libs.kotlinx.serialization)
20 | implementation(libs.kmp.koin.core)
21 | implementation(libs.kmp.kermit)
22 | }
23 | commonTest.dependencies {
24 | implementation(libs.test.kotlin)
25 | implementation(libs.test.kotlinx.coroutines)
26 | implementation(libs.ktor.client.mock)
27 | implementation(libs.kotlinx.serialization)
28 | }
29 | androidMain.dependencies { implementation(libs.ktor.client.okhttp) }
30 | iosMain.dependencies { implementation(libs.ktor.client.darwin) }
31 | }
32 | }
--------------------------------------------------------------------------------
/core-network/src/androidMain/kotlin/com/magic/core/network/DI.android.kt:
--------------------------------------------------------------------------------
1 | @file:Suppress("ACTUAL_ANNOTATIONS_NOT_MATCH_EXPECT")
2 |
3 | package com.magic.core.network
4 |
5 | import com.magic.core.network.api.ApiClient
6 | import io.ktor.client.engine.okhttp.OkHttp
7 | import org.koin.core.module.Module
8 | import org.koin.dsl.module
9 |
10 | actual fun networkDiModule(baseUrl: String, enableNetworkLogs: Boolean): Module = module {
11 | single {
12 | ApiClient(
13 | client = createHttpClient(
14 | engine = OkHttp.create(),
15 | networkLogger = if (enableNetworkLogs) co.touchlab.kermit.Logger else null,
16 | ),
17 | baseUrl = baseUrl
18 | )
19 | }
20 | }
--------------------------------------------------------------------------------
/core-network/src/commonMain/kotlin/com/magic/core/network/DI.kt:
--------------------------------------------------------------------------------
1 | package com.magic.core.network
2 |
3 | import com.magic.core.network.token.TokenProvider
4 | import io.ktor.client.HttpClient
5 | import io.ktor.client.engine.HttpClientEngine
6 | import io.ktor.client.plugins.HttpTimeout
7 | import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
8 | import io.ktor.client.plugins.defaultRequest
9 | import io.ktor.client.plugins.logging.LogLevel
10 | import io.ktor.client.plugins.logging.Logger
11 | import io.ktor.client.plugins.logging.Logging
12 | import io.ktor.client.request.header
13 | import io.ktor.http.ContentType
14 | import io.ktor.http.HttpHeaders
15 | import io.ktor.http.contentType
16 | import io.ktor.serialization.kotlinx.json.json
17 | import kotlinx.serialization.json.Json
18 | import kotlinx.serialization.modules.SerializersModule
19 | import org.koin.core.module.Module
20 | import kotlin.experimental.ExperimentalObjCRefinement
21 | import kotlin.native.HiddenFromObjC
22 |
23 | @OptIn(ExperimentalObjCRefinement::class)
24 | @HiddenFromObjC
25 | expect fun networkDiModule(baseUrl: String, enableNetworkLogs: Boolean): Module
26 |
27 | internal fun createHttpClient(
28 | engine: HttpClientEngine,
29 | networkLogger: co.touchlab.kermit.Logger? = null,
30 | tokenProvider: TokenProvider? = null,
31 | customModule: SerializersModule? = null
32 | ): HttpClient {
33 | return HttpClient(engine) {
34 | if (networkLogger != null) {
35 | install(Logging) {
36 | logger = object : Logger {
37 | override fun log(message: String) {
38 | networkLogger.withTag("HTTP Client").v { message }
39 | }
40 | }
41 | level = LogLevel.ALL
42 | }
43 | }
44 | install(ContentNegotiation) {
45 | json(Json {
46 | isLenient = true
47 | ignoreUnknownKeys = true
48 | explicitNulls = false
49 | prettyPrint = true
50 | if (customModule != null) {
51 | serializersModule = customModule
52 | }
53 | })
54 | }
55 | install(HttpTimeout) {
56 | requestTimeoutMillis = 60000
57 | connectTimeoutMillis = 60000
58 | socketTimeoutMillis = 60000
59 | }
60 | defaultRequest {
61 | contentType(ContentType.Application.Json)
62 | tokenProvider?.let { header(HttpHeaders.Authorization, "Bearer ${it.getAccessToken()}") }
63 | }
64 | }
65 | }
--------------------------------------------------------------------------------
/core-network/src/commonMain/kotlin/com/magic/core/network/api/ApiClient.kt:
--------------------------------------------------------------------------------
1 | @file:OptIn(ExperimentalObjCRefinement::class)
2 |
3 | package com.magic.core.network.api
4 |
5 | import com.magic.core.network.api.core.ApiCall
6 | import com.magic.core.network.api.core.ApiCallBehavior
7 | import com.magic.core.network.api.core.ApiResult
8 | import com.magic.core.network.api.errors.ApiError
9 | import com.magic.core.network.api.requests.BaseRequest
10 | import io.ktor.client.HttpClient
11 | import io.ktor.client.call.body
12 | import io.ktor.client.request.request
13 | import io.ktor.client.request.url
14 | import io.ktor.client.statement.HttpResponse
15 | import io.ktor.http.isSuccess
16 | import kotlin.experimental.ExperimentalObjCRefinement
17 | import kotlin.native.HiddenFromObjC
18 |
19 | @HiddenFromObjC
20 | class ApiClient(
21 | val client: HttpClient,
22 | val baseUrl: String
23 | ) : ApiCallBehavior by ApiCall() {
24 | suspend inline fun perform(request: BaseRequest): ApiResult {
25 | return apiCall {
26 | val response: HttpResponse = client.request {
27 | url("${baseUrl}/${request.path}")
28 | method = request.method
29 | request.requestBuilder().apply {
30 | this()
31 | }
32 | }
33 |
34 | if (response.status.isSuccess()) {
35 | response.body()
36 | } else {
37 | try {
38 | throw response.body()
39 | } catch (e: Exception) {
40 | throw ApiError(response.status.value, response.status.description)
41 | }
42 | }
43 | }
44 | }
45 | }
--------------------------------------------------------------------------------
/core-network/src/commonMain/kotlin/com/magic/core/network/api/core/ApiCall.kt:
--------------------------------------------------------------------------------
1 | package com.magic.core.network.api.core
2 |
3 | internal class ApiCall : ApiCallBehavior {
4 | override suspend fun apiCall(call: suspend () -> T): ApiResult {
5 | return kotlin.runCatching {
6 | val response = call.invoke()
7 | ApiResult.Success(response)
8 | }.getOrElse {
9 | ApiResult.Error(it)
10 | }
11 | }
12 | }
--------------------------------------------------------------------------------
/core-network/src/commonMain/kotlin/com/magic/core/network/api/core/ApiCallBehaviour.kt:
--------------------------------------------------------------------------------
1 | package com.magic.core.network.api.core
2 |
3 | @PublishedApi
4 | internal interface ApiCallBehavior {
5 | suspend fun apiCall(call: suspend () -> T): ApiResult
6 | }
--------------------------------------------------------------------------------
/core-network/src/commonMain/kotlin/com/magic/core/network/api/core/ApiResult.kt:
--------------------------------------------------------------------------------
1 | @file:OptIn(ExperimentalObjCRefinement::class)
2 |
3 | package com.magic.core.network.api.core
4 |
5 | import kotlin.experimental.ExperimentalObjCRefinement
6 | import kotlin.native.HiddenFromObjC
7 |
8 | @HiddenFromObjC
9 | sealed interface ApiResult {
10 | data class Success(val data: T) : ApiResult
11 | data class Error(val exception: Throwable) : ApiResult
12 | }
13 |
--------------------------------------------------------------------------------
/core-network/src/commonMain/kotlin/com/magic/core/network/api/errors/ApiError.kt:
--------------------------------------------------------------------------------
1 | @file:OptIn(ExperimentalObjCRefinement::class)
2 |
3 | package com.magic.core.network.api.errors
4 |
5 | import kotlinx.serialization.Serializable
6 | import kotlin.experimental.ExperimentalObjCRefinement
7 | import kotlin.native.HiddenFromObjC
8 |
9 | @Serializable
10 | @HiddenFromObjC
11 | data class ApiError(val status: Int, val error: String) : Throwable(error)
--------------------------------------------------------------------------------
/core-network/src/commonMain/kotlin/com/magic/core/network/api/requests/BaseRequest.kt:
--------------------------------------------------------------------------------
1 | @file:OptIn(ExperimentalObjCRefinement::class)
2 |
3 | package com.magic.core.network.api.requests
4 |
5 | import io.ktor.client.request.HttpRequestBuilder
6 | import io.ktor.http.HttpMethod
7 | import kotlin.experimental.ExperimentalObjCRefinement
8 | import kotlin.native.HiddenFromObjC
9 |
10 | @HiddenFromObjC
11 | interface BaseRequest {
12 | val path: String
13 | val method: HttpMethod
14 | fun requestBuilder(): HttpRequestBuilder.() -> Unit
15 | }
--------------------------------------------------------------------------------
/core-network/src/commonMain/kotlin/com/magic/core/network/token/TokenProvider.kt:
--------------------------------------------------------------------------------
1 | @file:OptIn(ExperimentalObjCRefinement::class)
2 |
3 | package com.magic.core.network.token
4 |
5 | import kotlin.experimental.ExperimentalObjCRefinement
6 | import kotlin.native.HiddenFromObjC
7 |
8 | @HiddenFromObjC
9 | interface TokenProvider {
10 | fun getAccessToken(): String
11 | }
--------------------------------------------------------------------------------
/core-network/src/commonTest/kotlin/ApiClientTest.kt:
--------------------------------------------------------------------------------
1 | import com.magic.core.network.api.ApiClient
2 | import com.magic.core.network.api.core.ApiResult
3 | import com.magic.core.network.api.errors.ApiError
4 | import com.magic.core.network.api.requests.BaseRequest
5 | import io.ktor.client.HttpClient
6 | import io.ktor.client.engine.mock.MockEngine
7 | import io.ktor.client.engine.mock.respond
8 | import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
9 | import io.ktor.client.request.HttpRequestBuilder
10 | import io.ktor.http.ContentType
11 | import io.ktor.http.HttpHeaders
12 | import io.ktor.http.HttpMethod
13 | import io.ktor.http.HttpStatusCode
14 | import io.ktor.http.contentType
15 | import io.ktor.http.headersOf
16 | import io.ktor.serialization.kotlinx.json.json
17 | import io.ktor.utils.io.ByteReadChannel
18 | import kotlinx.coroutines.test.runTest
19 | import kotlinx.serialization.json.Json
20 | import kotlin.test.Test
21 | import kotlin.test.assertEquals
22 | import kotlin.test.assertTrue
23 |
24 | class ApiClientTest {
25 |
26 | @Test
27 | fun `Client perform succeeds`() = runTest {
28 | val mockEngine = MockEngine {
29 | respond(
30 | content = ByteReadChannel("""{"ip":"127.0.0.1"}"""),
31 | status = HttpStatusCode.OK,
32 | headers = headersOf(HttpHeaders.ContentType, "application/json")
33 | )
34 | }
35 | val httpClient = HttpClient(mockEngine) {
36 | install(ContentNegotiation) {
37 | json()
38 | }
39 | }
40 | val apiClient = ApiClient(httpClient, "https://api.example.com")
41 |
42 | val request = object : BaseRequest {
43 | override val path = "test-path"
44 | override val method = HttpMethod.Get
45 | override fun requestBuilder(): HttpRequestBuilder.() -> Unit = {
46 | contentType(ContentType.Application.Json)
47 | }
48 | }
49 |
50 | val result = apiClient.perform>(request)
51 | assertTrue(result is ApiResult.Success)
52 | assertEquals(mapOf("ip" to "127.0.0.1"), result.data)
53 | }
54 |
55 | @Test
56 | fun `Client perform fails with ApiError`() = runTest {
57 | val mockEngine = MockEngine {
58 | respond(
59 | content = ByteReadChannel("""{"status":"123", "error":"Ups!"}"""),
60 | status = HttpStatusCode.InternalServerError,
61 | headers = headersOf(HttpHeaders.ContentType, "application/json")
62 | )
63 | }
64 | val httpClient = HttpClient(mockEngine) {
65 | install(ContentNegotiation) {
66 | json(Json {
67 | ignoreUnknownKeys = true
68 | })
69 | }
70 | }
71 | val apiClient = ApiClient(httpClient, "https://api.example.com")
72 |
73 | val request = object : BaseRequest {
74 | override val path = "test-path"
75 | override val method = HttpMethod.Get
76 | override fun requestBuilder(): HttpRequestBuilder.() -> Unit = {
77 | contentType(ContentType.Application.Json)
78 | }
79 | }
80 |
81 | val result = apiClient.perform>(request)
82 | assertTrue(result is ApiResult.Error)
83 | assertTrue(result.exception is ApiError)
84 | assertEquals((result.exception as ApiError).status, 123)
85 | assertEquals((result.exception as ApiError).error, "Ups!")
86 | }
87 |
88 | @Test
89 | fun `When client perform fails and ApiError deserialization fails the HttpStatusCode is delivered as ApiError `() = runTest {
90 | val mockEngine = MockEngine {
91 | respond(
92 | content = ByteReadChannel(""),
93 | status = HttpStatusCode.Unauthorized,
94 | headers = headersOf(HttpHeaders.ContentType, "application/json")
95 | )
96 | }
97 | val httpClient = HttpClient(mockEngine) {
98 | install(ContentNegotiation) {
99 | json()
100 | }
101 | }
102 | val apiClient = ApiClient(httpClient, "https://api.example.com")
103 |
104 | val request = object : BaseRequest {
105 | override val path = "test-path"
106 | override val method = HttpMethod.Get
107 | override fun requestBuilder(): HttpRequestBuilder.() -> Unit = {
108 | contentType(ContentType.Application.Json)
109 | }
110 | }
111 |
112 | val result = apiClient.perform>(request)
113 | assertTrue(result is ApiResult.Error)
114 | val error = result.exception
115 | assertTrue(error is ApiError)
116 | assertEquals(HttpStatusCode.Unauthorized.value, error.status)
117 | assertEquals(HttpStatusCode.Unauthorized.description, error.message)
118 | }
119 | }
--------------------------------------------------------------------------------
/core-network/src/iosMain/kotlin/com/magic/core/network/DI.ios.kt:
--------------------------------------------------------------------------------
1 | package com.magic.core.network
2 |
3 | import com.magic.core.network.api.ApiClient
4 | import io.ktor.client.engine.darwin.Darwin
5 | import org.koin.core.module.Module
6 | import org.koin.dsl.module
7 | import kotlin.experimental.ExperimentalObjCRefinement
8 |
9 | @OptIn(ExperimentalObjCRefinement::class)
10 | @HiddenFromObjC
11 | actual fun networkDiModule(baseUrl: String, enableNetworkLogs: Boolean): Module = module {
12 | single {
13 | ApiClient(
14 | client = createHttpClient(
15 | engine = Darwin.create(),
16 | networkLogger = if (enableNetworkLogs) co.touchlab.kermit.Logger else null,
17 | ),
18 | baseUrl = baseUrl
19 | )
20 | }
21 | }
--------------------------------------------------------------------------------
/data-managers/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
2 |
3 | plugins {
4 | id("buildlogic.plugins.kmp.library.android")
5 | alias(libs.plugins.google.ksp)
6 | alias(libs.plugins.nativecoroutines)
7 | alias(libs.plugins.sqldelight) //for unit test
8 | }
9 |
10 | android {
11 | namespace = "com.magic.data.managers"
12 | }
13 |
14 | kotlin {
15 | androidTarget()
16 | listOf(iosArm64(), iosSimulatorArm64()).forEach { _ ->
17 | @OptIn(ExperimentalKotlinGradlePluginApi::class)
18 | compilerOptions {
19 | freeCompilerArgs.add("-Xexport-kdoc")
20 | }
21 | }
22 |
23 | sourceSets {
24 | commonMain.dependencies {
25 | implementation(projects.coreNetwork)
26 | implementation(projects.coreDatabase)
27 | implementation(projects.dataModels)
28 | implementation(libs.kotlinx.coroutines.core)
29 | implementation(libs.bundles.ktor)
30 | implementation(libs.kmp.koin.core)
31 | implementation(libs.kmp.kermit)
32 | }
33 | commonTest.dependencies {
34 | implementation(libs.test.kotlin)
35 | implementation(libs.test.kmp.koin)
36 | implementation(libs.test.kmp.turbine)
37 | implementation(libs.test.kotlinx.coroutines)
38 | implementation(libs.ktor.client.mock)
39 | }
40 | androidMain.dependencies { implementation(libs.kmp.koin.android) }
41 | }
42 | }
--------------------------------------------------------------------------------
/data-managers/src/commonMain/kotlin/com/magic/data/managers/CardRequest.kt:
--------------------------------------------------------------------------------
1 | package com.magic.data.managers
2 |
3 | import com.magic.core.network.api.requests.BaseRequest
4 | import io.ktor.client.request.HttpRequestBuilder
5 | import io.ktor.client.request.parameter
6 | import io.ktor.http.ContentType
7 | import io.ktor.http.HttpMethod
8 | import io.ktor.http.contentType
9 |
10 | /**
11 | * https://docs.magicthegathering.io/
12 | */
13 | internal sealed class CardRequests(
14 | override val path: String,
15 | override val method: HttpMethod
16 | ) : BaseRequest {
17 |
18 | data class GetSet(private val code: String) : CardRequests("sets/$code", HttpMethod.Get) {
19 | override fun requestBuilder(): HttpRequestBuilder.() -> Unit = {
20 | contentType(ContentType.Application.Json)
21 | }
22 | }
23 |
24 | data class GetBooster(private val setCode: String) : CardRequests("sets/$setCode/booster", HttpMethod.Get) {
25 | override fun requestBuilder(): HttpRequestBuilder.() -> Unit = {
26 | contentType(ContentType.Application.Json)
27 | }
28 | }
29 |
30 | /**
31 | * The Api is failing to get Boosters, we'll simulate that with this
32 | */
33 | data class GetCardsFromSet(private val setCode: String) : CardRequests("cards", HttpMethod.Get) {
34 | override fun requestBuilder(): HttpRequestBuilder.() -> Unit = {
35 | contentType(ContentType.Application.Json)
36 | parameter("set", setCode)
37 | parameter("page", 1)
38 | parameter("pageSize", 100)
39 | }
40 | }
41 | }
--------------------------------------------------------------------------------
/data-managers/src/commonMain/kotlin/com/magic/data/managers/CardsManager.kt:
--------------------------------------------------------------------------------
1 | package com.magic.data.managers
2 |
3 | import co.touchlab.kermit.Logger
4 | import com.magic.core.database.MagicDao
5 | import com.magic.core.network.api.ApiClient
6 | import com.magic.core.network.api.core.ApiResult
7 | import com.magic.data.models.exceptions.RateLimitException
8 | import com.magic.data.models.local.Card
9 | import com.magic.data.models.local.CardSet
10 | import com.magic.data.models.local.Result
11 | import com.magic.data.models.remote.CardListResponse
12 | import com.magic.data.models.remote.CardSetResponse
13 | import com.rickclephas.kmp.nativecoroutines.NativeCoroutineScope
14 | import com.rickclephas.kmp.nativecoroutines.NativeCoroutines
15 | import com.rickclephas.kmp.nativecoroutines.NativeCoroutinesState
16 | import kotlinx.coroutines.CoroutineScope
17 | import kotlinx.coroutines.MainScope
18 | import kotlinx.coroutines.async
19 | import kotlinx.coroutines.awaitAll
20 | import kotlinx.coroutines.coroutineScope
21 | import kotlinx.coroutines.flow.SharingStarted
22 | import kotlinx.coroutines.flow.StateFlow
23 | import kotlinx.coroutines.flow.map
24 | import kotlinx.coroutines.flow.stateIn
25 | import org.koin.core.component.KoinComponent
26 | import org.koin.core.component.inject
27 |
28 | /**
29 | * Manager class for handling card and card set operations. This class interacts with the remote API and
30 | * local database to fetch, store, and observe card and card set data.
31 | */
32 | class CardsManager : KoinComponent {
33 | @NativeCoroutineScope
34 | internal val coroutineScope: CoroutineScope = MainScope()
35 | private val logger = Logger.withTag("CardsManager")
36 | private val remote: ApiClient by inject()
37 | private val local: MagicDao by inject()
38 |
39 | @Suppress("unused")
40 | @Throws(RateLimitException::class)
41 | fun exportedExceptions() {
42 | }
43 |
44 | /**
45 | * If [CardSet] is not cached, it will be fetched from the API along with its cards and then inserted into the local database.
46 | *
47 | * @param setCode The code of the [CardSet] to fetch and insert.
48 | * @return A [Result] containing a list of [Card] on success or a [Throwable] on error.
49 | */
50 | @NativeCoroutines
51 | suspend fun getSet(setCode: String): Result> {
52 | logger.i { "> Fetching booster cards from set $setCode" }
53 | if (!local.setExist(setCode)) {
54 | val setResult = remote.perform(CardRequests.GetSet(setCode))
55 | if (setResult is ApiResult.Error) {
56 | logger.i { "> Error fetching card set: ${setResult.exception.message}" }
57 | return Result.Error(RateLimitException(setResult.exception.message ?: ""))
58 | }
59 | val set = (setResult as ApiResult.Success).data.set
60 | if (!local.setExist(set.code)) {
61 | local.insertSet(set.code, set.name, set.releaseDate)
62 | }
63 | } else {
64 | logger.i { "> Card set in cache!" }
65 | }
66 |
67 | val localBooster = local.cardsFromSet(setCode)
68 | if (localBooster.isEmpty()) {
69 | val boosterResult = remote.perform(CardRequests.GetCardsFromSet(setCode))
70 | if (boosterResult is ApiResult.Error) {
71 | logger.i { "> Error fetching booster: ${boosterResult.exception.message}" }
72 | return Result.Error(RateLimitException(boosterResult.exception.message ?: ""))
73 | }
74 |
75 | val cards = (boosterResult as ApiResult.Success).data.cards
76 | cards.forEach { card ->
77 | local.insertCard(card.id, card.setCode, card.name, card.text, card.imageUrl, card.artist)
78 | }
79 | return Result.Success(cards)
80 | } else {
81 | logger.i { "> Booster in cache!" }
82 | }
83 | return Result.Success(localBooster.map { it.toCard() })
84 | }
85 |
86 | /**
87 | * Executes [getSet] in parallel for each setCode in [setCodes].
88 | *
89 | * @param setCodes A list of set codes to be fetched.
90 | * @return A [Result] indicating success or failure of the operation. In case of error, a [Throwable] inside [Result.Error].
91 | */
92 | @NativeCoroutines
93 | suspend fun getSets(setCodes: List): Result {
94 | logger.i { "> Starting parallel database population with booster sets" }
95 | return try {
96 | coroutineScope {
97 | val results = setCodes.map { setCode ->
98 | async {
99 | getSet(setCode)
100 | }
101 | }
102 | val resultsList = results.awaitAll()
103 | val errors = resultsList.filterIsInstance()
104 | if (errors.isNotEmpty()) {
105 | val firstError = errors.first()
106 | logger.e { "> Error populating database: ${firstError.exception.message}" }
107 | Result.Error(firstError.exception)
108 | } else {
109 | logger.i { "> Successfully populated database with booster sets" }
110 | Result.Success(Unit)
111 | }
112 | }
113 | } catch (e: Exception) {
114 | logger.e { "> Exception during database population: ${e.message}" }
115 | Result.Error(e)
116 | }
117 | }
118 |
119 | /**
120 | * Observes the count of card set in the local database.
121 | *
122 | * @return A [StateFlow] of [Long] representing the current count of card sets in the database.
123 | */
124 | @NativeCoroutinesState
125 | val observeSetCount: StateFlow = local.setCountStream()
126 | .stateIn(coroutineScope, SharingStarted.Lazily, 0)
127 |
128 | /**
129 | * Observes the count of cards in the local database.
130 | *
131 | * @return A [StateFlow] of [Long] representing the current count of cards in the database.
132 | */
133 | @NativeCoroutinesState
134 | val observeCardCount: StateFlow = local.cardCountStream()
135 | .stateIn(coroutineScope, SharingStarted.Lazily, 0)
136 |
137 | /**
138 | * Observes changes in the list of cards in the local database.
139 | *
140 | * @return A [StateFlow] of a list of [CardSet] representing the current state of card sets in the database.
141 | */
142 | @NativeCoroutinesState
143 | val observeSets: StateFlow> = local.setsStream()
144 | .map { dbSets -> dbSets.map { dbSet -> dbSet.toCardSet() } }
145 | .stateIn(coroutineScope, SharingStarted.Lazily, emptyList())
146 |
147 | /**
148 | * Returns an observable of all changes in the list of cards for a specific set in the local database.
149 | *
150 | * @param code The code of the [CardSet] for which cards should be observed.
151 | * @return A [StateFlow] of a list of [Card] representing the current state of all cards from a [CardSet] in the database.
152 | */
153 | @NativeCoroutines
154 | fun observeCardsFromSet(code: String): StateFlow> {
155 | return local.cardsFromSetStream(code)
156 | .map { dbCards -> dbCards.map { dbCard -> dbCard.toCard() } }
157 | .stateIn(coroutineScope, SharingStarted.Lazily, emptyList())
158 | }
159 |
160 | /**
161 | * Returns an observable of all cards in the local database.
162 | *
163 | * @return A [StateFlow] of a list of [Card] representing the current state of all cards in the database.
164 | */
165 | @NativeCoroutinesState
166 | val observeCards: StateFlow> = local.cardsStream()
167 | .map { dbCards -> dbCards.map { dbCard -> dbCard.toCard() } }
168 | .stateIn(coroutineScope, SharingStarted.Lazily, emptyList())
169 |
170 | /**
171 | * Gets the count of card sets in the local database.
172 | *
173 | * @return A [Long] representing the number of card sets in the database.
174 | */
175 | fun getSetCount(): Long = local.setCount()
176 |
177 | /**
178 | * Gets the count of cards in the local database.
179 | *
180 | * @return A [Long] representing the number of cards in the database.
181 | */
182 | fun getCardCount(): Long = local.cardCount()
183 |
184 | /**
185 | * Retrieves all card sets from the local database.
186 | *
187 | * @return A list of [CardSet] representing all card sets in the database.
188 | */
189 | fun getSets(): List = local.sets().map { it.toCardSet() }
190 |
191 | /**
192 | * Retrieves all cards for a specific set from the local database.
193 | *
194 | * @param setCode The code of the card set for which cards should be retrieved.
195 | * @return A list of [Card] representing all cards in the specified set.
196 | */
197 | fun getCardsFromSet(setCode: String): List = local.cardsFromSet(setCode).map { it.toCard() }
198 |
199 | /**
200 | * Retrieves all cards from the local database.
201 | *
202 | * @return A list of [Card] representing all cards in the database.
203 | */
204 | fun getCards(): List = local.cards().map { it.toCard() }
205 |
206 | /**
207 | * Removes all card sets and their associated cards from the local database.
208 | */
209 | fun removeAllSets() = local.deleteAllSets()
210 |
211 | /**
212 | * Removes card set and its associated cards from the local database.
213 | *
214 | * @param setCode The code of the card set for which cards should be removed.
215 | */
216 | fun removeSet(setCode: String) = local.deleteCardSet(setCode)
217 | }
218 |
219 | private fun com.magic.core.database.CardSet.toCardSet(): CardSet {
220 | return CardSet(
221 | code = code,
222 | name = name,
223 | releaseDate = releaseDate
224 | )
225 | }
226 |
227 | private fun com.magic.core.database.Card.toCard(): Card {
228 | return Card(
229 | id = this.id,
230 | setCode = this.setCode,
231 | name = this.name,
232 | text = this.text,
233 | imageUrl = this.imageUrl ?: "",
234 | artist = this.artist
235 | )
236 | }
237 |
--------------------------------------------------------------------------------
/data-managers/src/commonMain/kotlin/com/magic/data/managers/DI.kt:
--------------------------------------------------------------------------------
1 | package com.magic.data.managers
2 |
3 | import org.koin.core.module.Module
4 | import org.koin.dsl.module
5 | import kotlin.experimental.ExperimentalObjCRefinement
6 | import kotlin.native.HiddenFromObjC
7 |
8 | @OptIn(ExperimentalObjCRefinement::class)
9 | @HiddenFromObjC
10 | fun managersDiModule(): Module = module {
11 | single { CardsManager() }
12 | }
--------------------------------------------------------------------------------
/data-managers/src/commonTest/kotlin/CardManagerTest.kt:
--------------------------------------------------------------------------------
1 | import app.cash.turbine.test
2 | import com.magic.core.database.databaseDiTestModule
3 | import com.magic.core.network.api.ApiClient
4 | import com.magic.data.managers.CardsManager
5 | import com.magic.data.managers.managersDiModule
6 | import com.magic.data.models.exceptions.RateLimitException
7 | import com.magic.data.models.local.Result
8 | import io.ktor.client.HttpClient
9 | import io.ktor.client.engine.mock.MockEngine
10 | import io.ktor.client.engine.mock.respond
11 | import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
12 | import io.ktor.http.HttpHeaders
13 | import io.ktor.http.HttpStatusCode
14 | import io.ktor.http.headersOf
15 | import io.ktor.serialization.kotlinx.json.json
16 | import io.ktor.utils.io.ByteReadChannel
17 | import kotlinx.coroutines.Dispatchers
18 | import kotlinx.coroutines.test.StandardTestDispatcher
19 | import kotlinx.coroutines.test.resetMain
20 | import kotlinx.coroutines.test.runTest
21 | import kotlinx.coroutines.test.setMain
22 | import kotlinx.serialization.json.Json
23 | import org.koin.core.context.startKoin
24 | import org.koin.core.context.stopKoin
25 | import org.koin.dsl.module
26 | import org.koin.test.KoinTest
27 | import org.koin.test.get
28 | import kotlin.test.AfterTest
29 | import kotlin.test.BeforeTest
30 | import kotlin.test.Test
31 | import kotlin.test.assertEquals
32 | import kotlin.test.assertTrue
33 |
34 | class CardsManagerTest : KoinTest {
35 |
36 | private val json: String = """
37 | {
38 | "set": {
39 | "code": "SET001",
40 | "name": "Test Set",
41 | "releaseDate": "2024-01-01"
42 | },
43 | "cards": [
44 | {
45 | "id":"1",
46 | "set": "SET001",
47 | "name": "Card 1",
48 | "text": "Text 1",
49 | "imageUrl": "url1",
50 | "artist": "Artist 1"
51 | },
52 | {
53 | "id":"2",
54 | "set": "SET001",
55 | "name": "Card 2",
56 | "text": "Text 2",
57 | "imageUrl": "url2",
58 | "artist": "Artist 2"
59 | }
60 | ]
61 | }
62 | """
63 |
64 | @BeforeTest
65 | fun setUp() {
66 | startKoin {
67 | modules(
68 | module {
69 | val mockEngine = MockEngine {
70 | respond(
71 | content = ByteReadChannel(json),
72 | status = HttpStatusCode.OK,
73 | headers = headersOf(HttpHeaders.ContentType, "application/json")
74 | )
75 | }
76 | single {
77 | ApiClient(
78 | client = HttpClient(mockEngine) { install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) } },
79 | baseUrl = "https://api.example.com"
80 | )
81 | }
82 | },
83 | databaseDiTestModule(),
84 | managersDiModule(),
85 | )
86 | }
87 | Dispatchers.setMain(StandardTestDispatcher())
88 | }
89 |
90 | @AfterTest
91 | fun finish() {
92 | stopKoin()
93 | Dispatchers.resetMain()
94 | }
95 |
96 | @Test
97 | fun `getSets should return success when all API calls are successful`() = runTest {
98 | val manager = get()
99 | val result = manager.getSets(listOf("SET001", "SET002"))
100 | assertTrue(result is Result.Success)
101 | }
102 |
103 | @Test
104 | fun `getSet should return success with cards when both API calls are successful`() = runTest {
105 | val manager = get()
106 | val result = manager.getSet("SET001")
107 | assertTrue(result is Result.Success)
108 | assertEquals(2, result.data.size)
109 | assertEquals("Card 1", result.data[0].name)
110 | assertEquals("Card 2", result.data[1].name)
111 | }
112 |
113 | @Test
114 | fun `getSet should return error when API call fails`() = runTest {
115 | stopKoin()
116 | startKoin {
117 | modules(
118 | module {
119 | val errorEngine = MockEngine {
120 | respond(
121 | content = ByteReadChannel(""),
122 | status = HttpStatusCode.TooManyRequests,
123 | headers = headersOf(HttpHeaders.ContentType, "application/json")
124 | )
125 | }
126 | single {
127 | ApiClient(
128 | client = HttpClient(errorEngine) { install(ContentNegotiation) { json() } },
129 | baseUrl = "https://api.example.com"
130 | )
131 | }
132 | },
133 | databaseDiTestModule(),
134 | managersDiModule(),
135 | )
136 | }
137 | val manager = get()
138 | val result = manager.getSet("SET001")
139 | assertTrue(result is Result.Error)
140 | assertTrue(result.exception is RateLimitException)
141 | assertTrue { result.exception.message.equals(HttpStatusCode.TooManyRequests.description) }
142 | }
143 |
144 | @Test
145 | fun `observeSetCount should reflect changes in database`() = runTest {
146 | val manager = get()
147 |
148 | manager.observeSetCount.test {
149 | assertTrue(awaitItem().toInt() == 0)
150 |
151 | manager.getSet("SET001")
152 | assertTrue(awaitItem().toInt() == 1)
153 |
154 | manager.removeAllSets()
155 | assertTrue(awaitItem().toInt() == 0)
156 |
157 | cancelAndIgnoreRemainingEvents()
158 | }
159 | }
160 |
161 | @Test
162 | fun `observeCardCount should reflect changes in database`() = runTest {
163 | val manager = get()
164 | manager.observeCardCount.test {
165 | assertTrue(awaitItem().toInt() == 0)
166 |
167 | manager.getSet("SET001")
168 | assertTrue(awaitItem() > 0)
169 |
170 | manager.removeAllSets()
171 | assertTrue(awaitItem().toInt() == 0)
172 |
173 | cancelAndIgnoreRemainingEvents()
174 | }
175 | }
176 |
177 | @Test
178 | fun `observeSets should reflect changes in database`() = runTest {
179 | val manager = get()
180 |
181 | manager.observeSets.test {
182 | assertTrue(awaitItem().isEmpty())
183 |
184 | manager.getSet("SET001")
185 | assertTrue(awaitItem().isNotEmpty())
186 |
187 | manager.removeAllSets()
188 | assertTrue(awaitItem().isEmpty())
189 |
190 | cancelAndIgnoreRemainingEvents()
191 | }
192 | }
193 |
194 | @Test
195 | fun `observeCardsFromSet should reflect changes in database`() = runTest {
196 | val manager = get()
197 |
198 | manager.observeCardsFromSet("SET001").test {
199 | assertTrue(awaitItem().isEmpty())
200 |
201 | manager.getSet("SET001")
202 | assertTrue(awaitItem().isNotEmpty())
203 |
204 | manager.removeSet("SET001")
205 | assertTrue(awaitItem().isEmpty())
206 |
207 | cancelAndIgnoreRemainingEvents()
208 | }
209 | }
210 |
211 | @Test
212 | fun `observeCards should reflect changes in database`() = runTest {
213 | val manager = get()
214 |
215 | manager.observeCards.test {
216 | assertTrue(awaitItem().isEmpty())
217 |
218 | manager.getSet("SET001")
219 | assertTrue(awaitItem().isNotEmpty())
220 |
221 | manager.removeAllSets()
222 | assertTrue(awaitItem().isEmpty())
223 |
224 | cancelAndIgnoreRemainingEvents()
225 | }
226 | }
227 |
228 | @Test
229 | fun `getSetCount should return the correct count of sets`() = runTest {
230 | val manager = get()
231 | assertEquals(0, manager.getSetCount())
232 |
233 | manager.getSet("SET001")
234 | assertEquals(1, manager.getSetCount())
235 |
236 | manager.removeAllSets()
237 | assertEquals(0, manager.getSetCount())
238 | }
239 |
240 | @Test
241 | fun `getCardCount should return the correct count of sets`() = runTest {
242 | val manager = get()
243 | assertEquals(0, manager.getCardCount())
244 |
245 | manager.getSet("SET001")
246 | assertTrue { manager.getCardCount().toInt() > 0 }
247 |
248 | manager.removeAllSets()
249 | assertEquals(0, manager.getCardCount())
250 | }
251 |
252 | @Test
253 | fun `getSets should return the correct list of sets`() = runTest {
254 | val manager = get()
255 | assertTrue(manager.getSets().isEmpty())
256 |
257 | manager.getSet("SET001")
258 | val sets = manager.getSets()
259 | assertEquals(1, sets.size)
260 | assertEquals("SET001", sets[0].code)
261 | assertEquals("Test Set", sets[0].name)
262 |
263 | manager.removeAllSets()
264 | assertTrue(manager.getSets().isEmpty())
265 | }
266 |
267 | @Test
268 | fun `getCardsFromSet should return the correct list of cards for a set`() = runTest {
269 | val manager = get()
270 | assertTrue(manager.getCardsFromSet("SET001").isEmpty())
271 |
272 | manager.getSet("SET001")
273 | val cards = manager.getCardsFromSet("SET001")
274 | assertEquals(2, cards.size)
275 | assertEquals("Card 1", cards[0].name)
276 | assertEquals("Card 2", cards[1].name)
277 |
278 | manager.removeSet("SET001")
279 | assertTrue(manager.getCardsFromSet("SET001").isEmpty())
280 | }
281 |
282 | @Test
283 | fun `getCards should return the correct list of cards`() = runTest {
284 | val manager = get()
285 | assertTrue(manager.getCards().isEmpty())
286 |
287 | manager.getSet("SET001")
288 | val cards = manager.getCards()
289 | assertEquals(2, cards.size)
290 | assertEquals("Card 1", cards[0].name)
291 | assertEquals("Card 2", cards[1].name)
292 |
293 | manager.removeAllSets()
294 | assertTrue(manager.getCards().isEmpty())
295 | }
296 |
297 | @Test
298 | fun `removeAllSets should remove all cards from the database`() = runTest {
299 | val manager = get()
300 | manager.getSet("SET001")
301 | assertTrue(manager.getCardCount() > 0)
302 | manager.removeAllSets()
303 | assertTrue(manager.getCardCount().toInt() == 0)
304 | }
305 |
306 | @Test
307 | fun `removeCardsFromSet should remove all cards for a given set`() = runTest {
308 | val manager = get()
309 | manager.getSet("SET001")
310 | val initialCards = manager.getCardsFromSet("SET001")
311 | assertEquals(2, initialCards.size)
312 |
313 | manager.removeSet("SET001")
314 | val cardsAfterRemoval = manager.getCardsFromSet("SET001")
315 | assertTrue(cardsAfterRemoval.isEmpty())
316 | }
317 | }
--------------------------------------------------------------------------------
/data-models/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("buildlogic.plugins.kmp.library.android")
3 | alias(libs.plugins.kotlinx.serialization)
4 | }
5 |
6 | android {
7 | namespace = "com.magic.data.models"
8 | }
9 |
10 | kotlin {
11 | androidTarget()
12 | iosArm64()
13 | iosSimulatorArm64()
14 |
15 | sourceSets {
16 | commonMain.dependencies { implementation(libs.kotlinx.serialization) }
17 | }
18 | }
--------------------------------------------------------------------------------
/data-models/src/commonMain/kotlin/com/magic/data/models/exceptions/RateLimitException.kt:
--------------------------------------------------------------------------------
1 | package com.magic.data.models.exceptions
2 |
3 | class RateLimitException(message: String) : Throwable(message)
--------------------------------------------------------------------------------
/data-models/src/commonMain/kotlin/com/magic/data/models/local/Card.kt:
--------------------------------------------------------------------------------
1 | package com.magic.data.models.local
2 |
3 | import kotlinx.serialization.SerialName
4 | import kotlinx.serialization.Serializable
5 |
6 | @Serializable
7 | data class Card(
8 | val id: String = "",
9 | @SerialName("set") val setCode: String = "",
10 | val name: String = "",
11 | val text: String = "",
12 | val imageUrl: String = "",
13 | val artist: String = ""
14 | )
--------------------------------------------------------------------------------
/data-models/src/commonMain/kotlin/com/magic/data/models/local/CardSet.kt:
--------------------------------------------------------------------------------
1 | package com.magic.data.models.local
2 |
3 | import kotlinx.serialization.Serializable
4 |
5 | @Serializable
6 | data class CardSet(
7 | val code: String = "",
8 | val name: String = "",
9 | val releaseDate: String = ""
10 | )
--------------------------------------------------------------------------------
/data-models/src/commonMain/kotlin/com/magic/data/models/local/Result.kt:
--------------------------------------------------------------------------------
1 | package com.magic.data.models.local
2 |
3 | sealed class Result {
4 | data class Success(val data: T) : Result()
5 | data class Error(val exception: Throwable) : Result()
6 | }
--------------------------------------------------------------------------------
/data-models/src/commonMain/kotlin/com/magic/data/models/remote/CardListResponse.kt:
--------------------------------------------------------------------------------
1 | package com.magic.data.models.remote
2 |
3 | import com.magic.data.models.local.Card
4 | import kotlinx.serialization.Serializable
5 | import kotlin.experimental.ExperimentalObjCRefinement
6 | import kotlin.native.HiddenFromObjC
7 |
8 | @OptIn(ExperimentalObjCRefinement::class)
9 | @Serializable
10 | @HiddenFromObjC
11 | data class CardListResponse(val cards: List)
12 |
--------------------------------------------------------------------------------
/data-models/src/commonMain/kotlin/com/magic/data/models/remote/CardSetListResponse.kt:
--------------------------------------------------------------------------------
1 | package com.magic.data.models.remote
2 |
3 | import com.magic.data.models.local.CardSet
4 | import kotlinx.serialization.Serializable
5 | import kotlin.experimental.ExperimentalObjCRefinement
6 | import kotlin.native.HiddenFromObjC
7 |
8 | @OptIn(ExperimentalObjCRefinement::class)
9 | @Serializable
10 | @HiddenFromObjC
11 | data class CardSetListResponse(val sets: List)
--------------------------------------------------------------------------------
/data-models/src/commonMain/kotlin/com/magic/data/models/remote/CardSetResponse.kt:
--------------------------------------------------------------------------------
1 | package com.magic.data.models.remote
2 |
3 | import com.magic.data.models.local.CardSet
4 | import kotlinx.serialization.Serializable
5 | import kotlin.experimental.ExperimentalObjCRefinement
6 | import kotlin.native.HiddenFromObjC
7 |
8 | @OptIn(ExperimentalObjCRefinement::class)
9 | @Serializable
10 | @HiddenFromObjC
11 | data class CardSetResponse(val set: CardSet)
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | #Gradle
2 | org.gradle.caching=true
3 | org.gradle.configureondemand=false
4 | org.gradle.daemon=true
5 | org.gradle.jvmargs=-Xmx4096M -Dkotlin.daemon.jvm.options\="-Xmx4096M"
6 | org.gradle.logging.level=info
7 | org.gradle.parallel=true
8 |
9 | #Android
10 | android.useAndroidX=true
11 |
12 | #Kotlin
13 | kotlin.code.style=official
14 | ksp.useKSP2=true
15 |
16 | #KMP
17 | kotlin.mpp.stability.nowarn=true
18 | kotlin.mpp.androidSourceSetLayoutVersion=2
19 | kotlin.mpp.enableCInteropCommonization=true
20 | kotlin.mpp.androidGradlePluginCompatibility.nowarn=true
--------------------------------------------------------------------------------
/gradle/libs.versions.toml:
--------------------------------------------------------------------------------
1 | [versions]
2 |
3 | #======Kotlin
4 | kotlin = "2.1.21"
5 | kotlinxCoroutines = "1.10.1"
6 | kotlinxSerialization = "1.8.1"
7 | kotlinxCompose = "1.8.0"
8 | ktor = "3.1.1"
9 |
10 | #=====Google
11 | ksp = "2.1.21-2.0.1"
12 |
13 | #======Android
14 | androidCompileSdk = "35"
15 | androidTargetSdk = "35"
16 | androidMinSdk = "24"
17 | androidGradlePlugin = "8.7.3"
18 | androidMaterial = "1.12.0"
19 | androidxActivityKtx = "1.10.1"
20 | androidxLifecycle = "2.9.0"
21 | androidxCompose = "1.8.1"
22 | androidxComposeLifecycle = "2.9.0"
23 | androidxComposeMaterial3 = "1.3.2"
24 | androidxComposeUiUtilAndroid = "1.8.1"
25 |
26 | #======Multiplatform
27 | kermit = "2.0.4"
28 | sqldelight = "2.0.2"
29 | koin = "4.0.0"
30 | nativecoroutines = "1.0.0-ALPHA-43"
31 | turbine = "1.1.0"
32 |
33 | [plugins]
34 |
35 | android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }
36 | android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" }
37 | google-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
38 | kotlinx-compose = { id = "org.jetbrains.compose", version.ref = "kotlinxCompose" }
39 | kotlinx-compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
40 | kotlinx-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
41 | nativecoroutines = { id = "com.rickclephas.kmp.nativecoroutines", version.ref = "nativecoroutines" }
42 | sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }
43 |
44 | [libraries]
45 |
46 | #======Gradle
47 | gradle-android-tools = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" }
48 | gradle-kotlin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" }
49 |
50 | #======Jetbrains
51 | test-kotlin = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.ref = "kotlin" }
52 | kotlinx-serialization = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
53 | kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" }
54 | test-kotlinx-coroutines = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
55 | ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" }
56 | ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" }
57 | ktor-client-darwin = { group = "io.ktor", name = "ktor-client-darwin", version.ref = "ktor" }
58 | ktor-client-mock = { group = "io.ktor", name = "ktor-client-mock", version.ref = "ktor" }
59 | ktor-serialization = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" }
60 | ktor-contentNegotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" }
61 | ktor-logging = { group = "io.ktor", name = "ktor-client-logging", version.ref = "ktor" }
62 |
63 | #======Multiplatform
64 | kmp-kermit = { group = "co.touchlab", name = "kermit", version.ref = "kermit" }
65 | kmp-koin-core = { group = "io.insert-koin", name = "koin-core", version.ref = "koin" }
66 | kmp-koin-android = { group = "io.insert-koin", name = "koin-android", version.ref = "koin" }
67 | kmp-koin-androidx-compose = { group = "io.insert-koin", name = "koin-androidx-compose", version.ref = "koin" }
68 | test-kmp-koin = { group = "io.insert-koin", name = "koin-test", version.ref = "koin" }
69 | kmp-sqldelight-driver = { group = "app.cash.sqldelight", name = "sqlite-driver", version.ref = "sqldelight" }
70 | kmp-sqldelight-android-driver = { group = "app.cash.sqldelight", name = "android-driver", version.ref = "sqldelight" }
71 | kmp-sqldelight-ios-driver = { group = "app.cash.sqldelight", name = "native-driver", version.ref = "sqldelight" }
72 | kmp-sqldelight-coroutines-extensions = { group = "app.cash.sqldelight", name = "coroutines-extensions", version.ref = "sqldelight" }
73 | test-kmp-turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" }
74 |
75 | #======Android
76 | android-material = { group = "com.google.android.material", name = "material", version.ref = "androidMaterial" }
77 | androidx-lifecycle-runtime = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "androidxLifecycle" }
78 |
79 | androidx-compose-activity = { group = "androidx.activity", name = "activity-compose", version.ref = "androidxActivityKtx" }
80 | androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation", version.ref = "androidxCompose" }
81 | androidx-compose-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout", version.ref = "androidxCompose" }
82 | androidx-compose-material = { group = "androidx.compose.material", name = "material", version.ref = "androidxCompose" }
83 | androidx-compose-material-iconsExtended = { group = "androidx.compose.material", name = "material-icons-extended", version.ref = "androidxCompose" }
84 | androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "androidxComposeMaterial3" }
85 | androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime", version.ref = "androidxCompose" }
86 | androidx-compose-lifecycle-runtime = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidxComposeLifecycle" }
87 | androidx-compose-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "androidxCompose" }
88 | androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics", version.ref = "androidxCompose" }
89 | androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "androidxCompose" }
90 | androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview", version.ref = "androidxCompose" }
91 | androidx-compose-ui-util = { group = "androidx.compose.ui", name = "ui-util", version.ref = "androidxCompose" }
92 | androidx-compose-ui-util-android = { group = "androidx.compose.ui", name = "ui-util-android", version.ref = "androidxComposeUiUtilAndroid" }
93 |
94 | [bundles]
95 |
96 | ktor = [
97 | "ktor-client-core",
98 | "ktor-serialization",
99 | "ktor-contentNegotiation",
100 | "ktor-logging"
101 | ]
102 |
103 | androidx-compose = [
104 | "androidx-compose-activity",
105 | "androidx-compose-foundation",
106 | "androidx-compose-foundation-layout",
107 | "androidx-compose-material",
108 | # "androidx-compose-material-iconsExtended",
109 | "androidx-compose-material3",
110 | "androidx-compose-runtime",
111 | "androidx-compose-lifecycle-runtime",
112 | "androidx-compose-ui",
113 | "androidx-compose-ui-graphics",
114 | "androidx-compose-ui-tooling",
115 | "androidx-compose-ui-tooling-preview",
116 | "androidx-compose-ui-util",
117 | "androidx-compose-ui-util-android"
118 | ]
119 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GuilhE/Magic/e730b9c9321addc5f156085fd57b9d78145673ee/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Sat Jul 20 17:52:50 WEST 2024
2 | distributionBase=GRADLE_USER_HOME
3 | distributionPath=wrapper/dists
4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
5 | zipStoreBase=GRADLE_USER_HOME
6 | zipStorePath=wrapper/dists
7 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | #
4 | # Copyright 2015 the original author or authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 |
19 | ##############################################################################
20 | ##
21 | ## Gradle start up script for UN*X
22 | ##
23 | ##############################################################################
24 |
25 | # Attempt to set APP_HOME
26 | # Resolve links: $0 may be a link
27 | PRG="$0"
28 | # Need this for relative symlinks.
29 | while [ -h "$PRG" ] ; do
30 | ls=`ls -ld "$PRG"`
31 | link=`expr "$ls" : '.*-> \(.*\)$'`
32 | if expr "$link" : '/.*' > /dev/null; then
33 | PRG="$link"
34 | else
35 | PRG=`dirname "$PRG"`"/$link"
36 | fi
37 | done
38 | SAVED="`pwd`"
39 | cd "`dirname \"$PRG\"`/" >/dev/null
40 | APP_HOME="`pwd -P`"
41 | cd "$SAVED" >/dev/null
42 |
43 | APP_NAME="Gradle"
44 | APP_BASE_NAME=`basename "$0"`
45 |
46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
48 |
49 | # Use the maximum available, or set MAX_FD != -1 to use that value.
50 | MAX_FD="maximum"
51 |
52 | warn () {
53 | echo "$*"
54 | }
55 |
56 | die () {
57 | echo
58 | echo "$*"
59 | echo
60 | exit 1
61 | }
62 |
63 | # OS specific support (must be 'true' or 'false').
64 | cygwin=false
65 | msys=false
66 | darwin=false
67 | nonstop=false
68 | case "`uname`" in
69 | CYGWIN* )
70 | cygwin=true
71 | ;;
72 | Darwin* )
73 | darwin=true
74 | ;;
75 | MINGW* )
76 | msys=true
77 | ;;
78 | NONSTOP* )
79 | nonstop=true
80 | ;;
81 | esac
82 |
83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
84 |
85 |
86 | # Determine the Java command to use to start the JVM.
87 | if [ -n "$JAVA_HOME" ] ; then
88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
89 | # IBM's JDK on AIX uses strange locations for the executables
90 | JAVACMD="$JAVA_HOME/jre/sh/java"
91 | else
92 | JAVACMD="$JAVA_HOME/bin/java"
93 | fi
94 | if [ ! -x "$JAVACMD" ] ; then
95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
96 |
97 | Please set the JAVA_HOME variable in your environment to match the
98 | location of your Java installation."
99 | fi
100 | else
101 | JAVACMD="java"
102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
103 |
104 | Please set the JAVA_HOME variable in your environment to match the
105 | location of your Java installation."
106 | fi
107 |
108 | # Increase the maximum file descriptors if we can.
109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
110 | MAX_FD_LIMIT=`ulimit -H -n`
111 | if [ $? -eq 0 ] ; then
112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
113 | MAX_FD="$MAX_FD_LIMIT"
114 | fi
115 | ulimit -n $MAX_FD
116 | if [ $? -ne 0 ] ; then
117 | warn "Could not set maximum file descriptor limit: $MAX_FD"
118 | fi
119 | else
120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
121 | fi
122 | fi
123 |
124 | # For Darwin, add options to specify how the application appears in the dock
125 | if $darwin; then
126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
127 | fi
128 |
129 | # For Cygwin or MSYS, switch paths to Windows format before running java
130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
133 |
134 | JAVACMD=`cygpath --unix "$JAVACMD"`
135 |
136 | # We build the pattern for arguments to be converted via cygpath
137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
138 | SEP=""
139 | for dir in $ROOTDIRSRAW ; do
140 | ROOTDIRS="$ROOTDIRS$SEP$dir"
141 | SEP="|"
142 | done
143 | OURCYGPATTERN="(^($ROOTDIRS))"
144 | # Add a user-defined pattern to the cygpath arguments
145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
147 | fi
148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
149 | i=0
150 | for arg in "$@" ; do
151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
153 |
154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
156 | else
157 | eval `echo args$i`="\"$arg\""
158 | fi
159 | i=`expr $i + 1`
160 | done
161 | case $i in
162 | 0) set -- ;;
163 | 1) set -- "$args0" ;;
164 | 2) set -- "$args0" "$args1" ;;
165 | 3) set -- "$args0" "$args1" "$args2" ;;
166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
172 | esac
173 | fi
174 |
175 | # Escape application args
176 | save () {
177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
178 | echo " "
179 | }
180 | APP_ARGS=`save "$@"`
181 |
182 | # Collect all arguments for the java command, following the shell quoting and substitution rules
183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
184 |
185 | exec "$JAVACMD" "$@"
186 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%" == "" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%" == "" set DIRNAME=.
29 | set APP_BASE_NAME=%~n0
30 | set APP_HOME=%DIRNAME%
31 |
32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
34 |
35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
37 |
38 | @rem Find java.exe
39 | if defined JAVA_HOME goto findJavaFromJavaHome
40 |
41 | set JAVA_EXE=java.exe
42 | %JAVA_EXE% -version >NUL 2>&1
43 | if "%ERRORLEVEL%" == "0" goto execute
44 |
45 | echo.
46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
47 | echo.
48 | echo Please set the JAVA_HOME variable in your environment to match the
49 | echo location of your Java installation.
50 |
51 | goto fail
52 |
53 | :findJavaFromJavaHome
54 | set JAVA_HOME=%JAVA_HOME:"=%
55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
56 |
57 | if exist "%JAVA_EXE%" goto execute
58 |
59 | echo.
60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
61 | echo.
62 | echo Please set the JAVA_HOME variable in your environment to match the
63 | echo location of your Java installation.
64 |
65 | goto fail
66 |
67 | :execute
68 | @rem Setup the command line
69 |
70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
71 |
72 |
73 | @rem Execute Gradle
74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
75 |
76 | :end
77 | @rem End local scope for the variables with windows NT shell
78 | if "%ERRORLEVEL%"=="0" goto mainEnd
79 |
80 | :fail
81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
82 | rem the _cmd.exe /c_ return code!
83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
84 | exit /b 1
85 |
86 | :mainEnd
87 | if "%OS%"=="Windows_NT" endlocal
88 |
89 | :omega
90 |
--------------------------------------------------------------------------------
/iosApp/App/AppDependencies.swift:
--------------------------------------------------------------------------------
1 | import MagicDataLayer
2 |
3 | @MainActor
4 | class AppDependencies {
5 | public func setupDependencies() {
6 | DependencyInjection().doInitKoin(enableNetworkLogs: false)
7 | CardsManagerFactory.register()
8 | CardListViewModelFactory.register()
9 | CardDeckViewModelFactory.register()
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/iosApp/App/AppFactories.swift:
--------------------------------------------------------------------------------
1 | import CardData
2 | import CardDeckPresentation
3 | import CardDomain
4 | import CardListPresentation
5 | import DI
6 | import FactoryProtocols
7 | import MagicDataLayer
8 |
9 | class CardsManagerFactory: FactoryProtocol {
10 | typealias T = DomainCardsManagerProtocol
11 |
12 | public private(set) static var createName: String = "CardsManager"
13 | public private(set) static var mockName: String = "CardsManagerMock"
14 |
15 | static func register() {
16 | DIContainer.shared.register(DomainCardsManagerProtocol.self, name: createName) { _ in CardsManager() }
17 | DIContainer.shared.register(DomainCardsManagerProtocol.self, name: mockName) { _ in CardsManagerMock() }
18 | }
19 |
20 | public static func create() -> CardsManager {
21 | DIContainer.shared.resolve(DomainCardsManagerProtocol.self, name: createName) as! CardsManager
22 | }
23 |
24 | public static func mock() -> CardsManagerMock {
25 | DIContainer.shared.resolve(DomainCardsManagerProtocol.self, name: mockName) as! CardsManagerMock
26 | }
27 | }
28 |
29 | class CardListViewModelFactory: FactoryProtocol {
30 | typealias T = CardListViewModelProtocol
31 |
32 | public private(set) static var createName: String = "CardListViewModel"
33 | public private(set) static var mockName: String = "CardListViewModelMock"
34 |
35 | static func register() {
36 | DIContainer.shared.register(CardListViewModelProtocol.self, name: createName) { _ in CardListViewModel(manager: CardsManagerFactory.create()) }
37 | DIContainer.shared.register(CardListViewModelProtocol.self, name: mockName) { _ in CardListViewModel(manager: CardsManagerFactory.mock()) }
38 | }
39 |
40 | public static func create() -> CardListViewModel {
41 | DIContainer.shared.resolve(CardListViewModelProtocol.self, name: createName) as! CardListViewModel
42 | }
43 |
44 | public static func mock() -> CardListViewModelMock {
45 | DIContainer.shared.resolve(CardListViewModelProtocol.self, name: mockName) as! CardListViewModelMock
46 | }
47 | }
48 |
49 | class CardDeckViewModelFactory: FactoryProtocol {
50 | typealias T = CardDeckViewModelProtocol
51 | public private(set) static var createName: String = "CardDeckViewModel"
52 | public private(set) static var mockName: String = "CardDeckViewModelMock"
53 |
54 | static func register() {
55 | DIContainer.shared.register((any CardDeckViewModelProtocol).self, name: createName) { _ in MagicDeckViewModel(manager: CardsManagerFactory.create()) }
56 | DIContainer.shared.register((any CardDeckViewModelProtocol).self, name: mockName) { _ in ColorDeckViewModel() }
57 | }
58 |
59 | public static func create() -> MagicDeckViewModel {
60 | DIContainer.shared.resolve((any CardDeckViewModelProtocol).self, name: createName) as! MagicDeckViewModel
61 | }
62 |
63 | public static func mock() -> ColorDeckViewModel {
64 | DIContainer.shared.resolve((any CardDeckViewModelProtocol).self, name: mockName) as! ColorDeckViewModel
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/iosApp/App/Assets.xcassets/AppIcon.appiconset/1024.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GuilhE/Magic/e730b9c9321addc5f156085fd57b9d78145673ee/iosApp/App/Assets.xcassets/AppIcon.appiconset/1024.png
--------------------------------------------------------------------------------
/iosApp/App/Assets.xcassets/AppIcon.appiconset/114.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GuilhE/Magic/e730b9c9321addc5f156085fd57b9d78145673ee/iosApp/App/Assets.xcassets/AppIcon.appiconset/114.png
--------------------------------------------------------------------------------
/iosApp/App/Assets.xcassets/AppIcon.appiconset/120.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GuilhE/Magic/e730b9c9321addc5f156085fd57b9d78145673ee/iosApp/App/Assets.xcassets/AppIcon.appiconset/120.png
--------------------------------------------------------------------------------
/iosApp/App/Assets.xcassets/AppIcon.appiconset/180.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GuilhE/Magic/e730b9c9321addc5f156085fd57b9d78145673ee/iosApp/App/Assets.xcassets/AppIcon.appiconset/180.png
--------------------------------------------------------------------------------
/iosApp/App/Assets.xcassets/AppIcon.appiconset/29.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GuilhE/Magic/e730b9c9321addc5f156085fd57b9d78145673ee/iosApp/App/Assets.xcassets/AppIcon.appiconset/29.png
--------------------------------------------------------------------------------
/iosApp/App/Assets.xcassets/AppIcon.appiconset/40.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GuilhE/Magic/e730b9c9321addc5f156085fd57b9d78145673ee/iosApp/App/Assets.xcassets/AppIcon.appiconset/40.png
--------------------------------------------------------------------------------
/iosApp/App/Assets.xcassets/AppIcon.appiconset/57.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GuilhE/Magic/e730b9c9321addc5f156085fd57b9d78145673ee/iosApp/App/Assets.xcassets/AppIcon.appiconset/57.png
--------------------------------------------------------------------------------
/iosApp/App/Assets.xcassets/AppIcon.appiconset/58.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GuilhE/Magic/e730b9c9321addc5f156085fd57b9d78145673ee/iosApp/App/Assets.xcassets/AppIcon.appiconset/58.png
--------------------------------------------------------------------------------
/iosApp/App/Assets.xcassets/AppIcon.appiconset/60.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GuilhE/Magic/e730b9c9321addc5f156085fd57b9d78145673ee/iosApp/App/Assets.xcassets/AppIcon.appiconset/60.png
--------------------------------------------------------------------------------
/iosApp/App/Assets.xcassets/AppIcon.appiconset/80.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GuilhE/Magic/e730b9c9321addc5f156085fd57b9d78145673ee/iosApp/App/Assets.xcassets/AppIcon.appiconset/80.png
--------------------------------------------------------------------------------
/iosApp/App/Assets.xcassets/AppIcon.appiconset/87.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GuilhE/Magic/e730b9c9321addc5f156085fd57b9d78145673ee/iosApp/App/Assets.xcassets/AppIcon.appiconset/87.png
--------------------------------------------------------------------------------
/iosApp/App/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "40.png",
5 | "idiom" : "iphone",
6 | "scale" : "2x",
7 | "size" : "20x20"
8 | },
9 | {
10 | "filename" : "60.png",
11 | "idiom" : "iphone",
12 | "scale" : "3x",
13 | "size" : "20x20"
14 | },
15 | {
16 | "filename" : "29.png",
17 | "idiom" : "iphone",
18 | "scale" : "1x",
19 | "size" : "29x29"
20 | },
21 | {
22 | "filename" : "58.png",
23 | "idiom" : "iphone",
24 | "scale" : "2x",
25 | "size" : "29x29"
26 | },
27 | {
28 | "filename" : "87.png",
29 | "idiom" : "iphone",
30 | "scale" : "3x",
31 | "size" : "29x29"
32 | },
33 | {
34 | "filename" : "80.png",
35 | "idiom" : "iphone",
36 | "scale" : "2x",
37 | "size" : "40x40"
38 | },
39 | {
40 | "filename" : "120.png",
41 | "idiom" : "iphone",
42 | "scale" : "3x",
43 | "size" : "40x40"
44 | },
45 | {
46 | "filename" : "57.png",
47 | "idiom" : "iphone",
48 | "scale" : "1x",
49 | "size" : "57x57"
50 | },
51 | {
52 | "filename" : "114.png",
53 | "idiom" : "iphone",
54 | "scale" : "2x",
55 | "size" : "57x57"
56 | },
57 | {
58 | "filename" : "120.png",
59 | "idiom" : "iphone",
60 | "scale" : "2x",
61 | "size" : "60x60"
62 | },
63 | {
64 | "filename" : "180.png",
65 | "idiom" : "iphone",
66 | "scale" : "3x",
67 | "size" : "60x60"
68 | },
69 | {
70 | "filename" : "1024.png",
71 | "idiom" : "ios-marketing",
72 | "scale" : "1x",
73 | "size" : "1024x1024"
74 | }
75 | ],
76 | "info" : {
77 | "author" : "xcode",
78 | "version" : 1
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/iosApp/App/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/iosApp/App/Assets.xcassets/card_back.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "card_back.png",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "filename" : "card_back 1.png",
10 | "idiom" : "universal",
11 | "scale" : "2x"
12 | },
13 | {
14 | "filename" : "card_back 2.png",
15 | "idiom" : "universal",
16 | "scale" : "3x"
17 | }
18 | ],
19 | "info" : {
20 | "author" : "xcode",
21 | "version" : 1
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/iosApp/App/Assets.xcassets/card_back.imageset/card_back 1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GuilhE/Magic/e730b9c9321addc5f156085fd57b9d78145673ee/iosApp/App/Assets.xcassets/card_back.imageset/card_back 1.png
--------------------------------------------------------------------------------
/iosApp/App/Assets.xcassets/card_back.imageset/card_back 2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GuilhE/Magic/e730b9c9321addc5f156085fd57b9d78145673ee/iosApp/App/Assets.xcassets/card_back.imageset/card_back 2.png
--------------------------------------------------------------------------------
/iosApp/App/Assets.xcassets/card_back.imageset/card_back.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GuilhE/Magic/e730b9c9321addc5f156085fd57b9d78145673ee/iosApp/App/Assets.xcassets/card_back.imageset/card_back.png
--------------------------------------------------------------------------------
/iosApp/App/Assets.xcassets/default.imageset/40.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GuilhE/Magic/e730b9c9321addc5f156085fd57b9d78145673ee/iosApp/App/Assets.xcassets/default.imageset/40.png
--------------------------------------------------------------------------------
/iosApp/App/Assets.xcassets/default.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "40.png",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/iosApp/App/Assets.xcassets/edition_4_symbol.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "edition_4_symbol.svg",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/iosApp/App/Assets.xcassets/edition_4_symbol.imageset/edition_4_symbol.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/iosApp/App/Assets.xcassets/edition_5_symbol.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "edition_5_symbol.svg",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/iosApp/App/Assets.xcassets/edition_5_symbol.imageset/edition_5_symbol.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/iosApp/App/Assets.xcassets/edition_mirage_symbol.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "edition_mirage_symbol.svg",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/iosApp/App/Assets.xcassets/edition_mirage_symbol.imageset/edition_mirage_symbol.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/iosApp/App/Assets.xcassets/edition_tempest_symbol.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "edition_tempest_symbol.svg",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/iosApp/App/Assets.xcassets/edition_tempest_symbol.imageset/edition_tempest_symbol.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
15 |
17 |
19 |
20 |
22 | image/svg+xml
23 |
25 |
26 |
27 |
28 |
29 |
34 |
39 |
44 |
45 |
--------------------------------------------------------------------------------
/iosApp/App/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE)
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleVersion
20 | 1
21 | LSRequiresIPhoneOS
22 |
23 | UIApplicationSceneManifest
24 |
25 | UIApplicationSupportsMultipleScenes
26 |
27 |
28 | UILaunchScreen
29 |
30 | UIRequiredDeviceCapabilities
31 |
32 | armv7
33 |
34 | UISupportedInterfaceOrientations
35 |
36 | UIInterfaceOrientationPortrait
37 |
38 | UISupportedInterfaceOrientations~ipad
39 |
40 | UIInterfaceOrientationLandscapeLeft
41 | UIInterfaceOrientationLandscapeRight
42 | UIInterfaceOrientationPortrait
43 | UIInterfaceOrientationPortraitUpsideDown
44 |
45 |
46 |
47 |
--------------------------------------------------------------------------------
/iosApp/App/MagicApp.swift:
--------------------------------------------------------------------------------
1 | import CardDeckPresentation
2 | import CardListPresentation
3 | import netfox
4 | import SwiftUI
5 |
6 | @main
7 | struct MagicApp: App {
8 | init() {
9 | #if DEBUG
10 | NFX.sharedInstance().start()
11 | #endif
12 | AppDependencies().setupDependencies()
13 | }
14 |
15 | var body: some Scene {
16 | WindowGroup {
17 | // CardListView(viewModel: CardListViewModelFactory.mock())
18 | // CardListView(viewModel: CardListViewModelFactory.create())
19 | // CardDeckScreen(viewModel: CardDeckViewModelFactory.mock())
20 | CardDeckScreen(viewModel: CardDeckViewModelFactory.create())
21 | }
22 | }
23 | }
24 |
25 | #Preview {
26 | // CardListView(viewModel: CardListViewModelFactory.mock())
27 | // CardListView(viewModel: CardListViewModelFactory.create())
28 | // CardDeckScreen(viewModel: CardDeckViewModelFactory.mock())
29 | CardDeckScreen(viewModel: CardDeckViewModelFactory.create())
30 | }
31 |
--------------------------------------------------------------------------------
/iosApp/App/Preview Content/Preview Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
--------------------------------------------------------------------------------
/iosApp/Magic.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/iosApp/Magic.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/iosApp/Magic.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "originHash" : "1efb19df4c0da2b40903c7e30e578ab5ad76a760162d8d771d76aa54a322407b",
3 | "pins" : [
4 | {
5 | "identity" : "kingfisher",
6 | "kind" : "remoteSourceControl",
7 | "location" : "https://github.com/onevcat/Kingfisher.git",
8 | "state" : {
9 | "revision" : "4c6b067f96953ee19526e49e4189403a2be21fb3",
10 | "version" : "8.3.1"
11 | }
12 | },
13 | {
14 | "identity" : "kmp-nativecoroutines",
15 | "kind" : "remoteSourceControl",
16 | "location" : "https://github.com/rickclephas/KMP-NativeCoroutines.git",
17 | "state" : {
18 | "revision" : "036d82d104290bed60c1f3a6519ad5a6ce8d1d0e",
19 | "version" : "1.0.0-ALPHA-43-spm-async"
20 | }
21 | },
22 | {
23 | "identity" : "netfox",
24 | "kind" : "remoteSourceControl",
25 | "location" : "https://github.com/kasketis/netfox",
26 | "state" : {
27 | "revision" : "557576032736fd3140422baefb68b8f76c55088f",
28 | "version" : "1.21.0"
29 | }
30 | },
31 | {
32 | "identity" : "swinject",
33 | "kind" : "remoteSourceControl",
34 | "location" : "https://github.com/Swinject/Swinject.git",
35 | "state" : {
36 | "revision" : "be9dbcc7b86811bc131539a20c6f9c2d3e56919f",
37 | "version" : "2.9.1"
38 | }
39 | }
40 | ],
41 | "version" : 3
42 | }
43 |
--------------------------------------------------------------------------------
/iosApp/Magic.xcodeproj/xcshareddata/xcschemes/App.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
9 |
10 |
12 |
15 |
16 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
34 |
40 |
41 |
42 |
43 |
44 |
50 |
51 |
61 |
63 |
69 |
70 |
71 |
72 |
78 |
80 |
86 |
87 |
88 |
89 |
91 |
92 |
95 |
96 |
97 |
--------------------------------------------------------------------------------
/iosApp/Packages/Core/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | xcuserdata/
5 | DerivedData/
6 | .swiftpm/configuration/registries.json
7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8 | .netrc
9 |
--------------------------------------------------------------------------------
/iosApp/Packages/Core/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 6.1
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: "Core",
8 | platforms: [.iOS(.v16)],
9 | products: [
10 | .library(
11 | name: "DI",
12 | targets: ["DI"]
13 | ),
14 | .library(
15 | name: "FactoryProtocols",
16 | targets: ["FactoryProtocols"]
17 | ),
18 | ],
19 | dependencies: [
20 | .package(url: "https://github.com/Swinject/Swinject.git", exact: "2.9.1"),
21 | ],
22 | targets: [
23 | .target(
24 | name: "DI",
25 | dependencies: [
26 | .product(name: "Swinject", package: "Swinject"),
27 | ]
28 | ),
29 | .target(
30 | name: "FactoryProtocols",
31 | dependencies: []
32 | ),
33 | ]
34 | )
35 |
--------------------------------------------------------------------------------
/iosApp/Packages/Core/Sources/DI/DIContainer.swift:
--------------------------------------------------------------------------------
1 | import Swinject
2 |
3 | @MainActor
4 | public class DIContainer {
5 | public static let shared = DIContainer()
6 | private let container: Container = .init()
7 |
8 | public func register(_ type: T.Type, name: String? = nil, instance: @escaping (Resolver) -> T) {
9 | container.register(type, name: name, factory: instance)
10 | }
11 |
12 | public func resolve(_ type: T.Type, name: String? = nil) -> T? {
13 | container.resolve(type, name: name)
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/iosApp/Packages/Core/Sources/FactoryProtocols/Factory.swift:
--------------------------------------------------------------------------------
1 | @MainActor
2 | public protocol FactoryProtocol {
3 | associatedtype T
4 | static var createName: String { get }
5 | static var mockName: String { get }
6 | static func register()
7 | static func create() -> T
8 | static func mock() -> T
9 | }
10 |
--------------------------------------------------------------------------------
/iosApp/Packages/Data/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | xcuserdata/
5 | DerivedData/
6 | .swiftpm/configuration/registries.json
7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8 | .netrc
9 |
--------------------------------------------------------------------------------
/iosApp/Packages/Data/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 6.1
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: "Data",
8 | platforms: [.iOS(.v16)],
9 | products: [
10 | .library(
11 | name: "DataExtensions",
12 | targets: ["DataExtensions"]
13 | ),
14 | .library(
15 | name: "CardData",
16 | targets: ["CardData"]
17 | ),
18 | ],
19 | dependencies: [
20 | .package(path: "../Domain"),
21 | .package(url: "https://github.com/rickclephas/KMP-NativeCoroutines.git", exact: "1.0.0-ALPHA-43-spm-async"),
22 | ],
23 | targets: [
24 | .target(
25 | name: "DataExtensions",
26 | dependencies: [
27 | .product(name: "DomainProtocols", package: "Domain"),
28 | ]
29 | ),
30 | .target(
31 | name: "CardData",
32 | dependencies: [
33 | "DataExtensions",
34 | .product(name: "DomainProtocols", package: "Domain"),
35 | .product(name: "CardDomain", package: "Domain"),
36 | .product(name: "KMPNativeCoroutinesAsync", package: "KMP-NativeCoroutines"),
37 | ]
38 | ),
39 | ]
40 | )
41 |
--------------------------------------------------------------------------------
/iosApp/Packages/Data/Sources/CardData/CardsManager.swift:
--------------------------------------------------------------------------------
1 | import CardDomain
2 | import DataExtensions
3 | import DomainProtocols
4 | import KMPNativeCoroutinesAsync
5 | @preconcurrency import MagicDataLayer
6 |
7 | extension CardsManager: @retroactive DomainCardsManagerProtocol {
8 | public func getCardSet(setCode: String) async -> Swift.Result {
9 | do {
10 | let result = try await asyncFunction(for: getSet(setCode: setCode))
11 | if let successResult = result as? ResultSuccess, let cards = successResult.data as? [DomainCard] {
12 | return .success(DomainCardList(cards: cards))
13 | } else if let errorResult = result as? ResultError {
14 | return .failure(DomainException(domainError: errorResult.exception as ErrorException))
15 | } else {
16 | return .failure(DomainException(error: UnexpectedResultError()))
17 | }
18 | } catch {
19 | return .failure(DomainException(error: error))
20 | }
21 | }
22 |
23 | public func getCardSets(setCodes: [String]) async -> Swift.Result {
24 | do {
25 | let result = try await asyncFunction(for: getSets(setCodes: setCodes))
26 | if result is ResultSuccess {
27 | return .success(())
28 | } else if let errorResult = result as? ResultError {
29 | return .failure(DomainException(domainError: errorResult.exception as ErrorException))
30 | } else {
31 | return .failure(DomainException(error: UnexpectedResultError()))
32 | }
33 | } catch {
34 | return .failure(DomainException(error: error))
35 | }
36 | }
37 |
38 | public func getCardSets() -> [DomainCardSet] {
39 | getSets() as [DomainCardSet]
40 | }
41 |
42 | public func observeCardSets() async throws -> AsyncStream<[DomainCardSet]> {
43 | AsyncStream { continuation in
44 | Task {
45 | let stream = asyncSequence(for: observeSetsFlow)
46 | for try await sets in stream {
47 | continuation.yield(sets as [DomainCardSet])
48 | }
49 | continuation.finish()
50 | }
51 | }
52 | }
53 |
54 | public func observeSetCount() async throws -> AsyncStream {
55 | AsyncStream { continuation in
56 | Task {
57 | let stream = asyncSequence(for: observeSetCountFlow)
58 | for try await count in stream {
59 | continuation.yield(count.intValue)
60 | }
61 | continuation.finish()
62 | }
63 | }
64 | }
65 |
66 | public func observeCardCount() async throws -> AsyncStream {
67 | AsyncStream { continuation in
68 | Task {
69 | let stream = asyncSequence(for: observeCardCountFlow)
70 | for try await count in stream {
71 | continuation.yield(count.intValue)
72 | }
73 | continuation.finish()
74 | }
75 | }
76 | }
77 |
78 | public func observeCardsFromSet(setCode: String) async throws -> AsyncStream<[DomainCard]> {
79 | AsyncStream { continuation in
80 | Task {
81 | let stream = asyncSequence(for: observeCardsFromSet(code: setCode))
82 | for try await cards in stream {
83 | continuation.yield(cards as [DomainCard])
84 | }
85 | continuation.finish()
86 | }
87 | }
88 | }
89 |
90 | public func removeCardSet(setCode: String) {
91 | removeSet(setCode: setCode)
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/iosApp/Packages/Data/Sources/CardData/CardsManagerMock.swift:
--------------------------------------------------------------------------------
1 | import CardDomain
2 | import DomainProtocols
3 | import MagicDataLayer
4 |
5 | public class CardsManagerMock: DomainCardsManagerProtocol {
6 | private var mockCardSets: [CardSet: [Card]] = [:]
7 | private var setCountContinuation: AsyncStream.Continuation?
8 | private var cardCountContinuation: AsyncStream.Continuation?
9 | private var cardsContinuations: [String: AsyncStream<[DomainCard]>.Continuation] = [:]
10 |
11 | public init() {
12 | mockCardSets[CardSet(code: "AAA", name: "Set AAA", releaseDate: "2024-01-01")] = createCards("AAA")
13 | mockCardSets[CardSet(code: "BBB", name: "Set BBB", releaseDate: "2024-02-01")] = createCards("BBB")
14 | mockCardSets[CardSet(code: "CCC", name: "Set CCC", releaseDate: "2024-03-01")] = createCards("CCC")
15 | }
16 |
17 | public func getCardSet(setCode: String) async -> Swift.Result {
18 | if var cards = mockCardSets.first(where: { $0.key.code == setCode })?.value {
19 | if cards.isEmpty {
20 | let set = mockCardSets.first(where: { $0.key.code == setCode })!.key
21 | cards = createCards(setCode)
22 | mockCardSets[set] = cards
23 | }
24 | await emitCardsUpdate(for: setCode, cards: cards)
25 | await emitCountsUpdate(delay: 0)
26 | return .success(DomainCardList(cards: cards as [DomainCard]))
27 | } else {
28 | return .failure(DomainException(error: NSError(domain: "Set not found", code: 404, userInfo: nil)))
29 | }
30 | }
31 |
32 | public func getCardSets(setCodes _: [String]) async -> Swift.Result {
33 | .success(())
34 | }
35 |
36 | public func getCardSets() -> [DomainCardSet] {
37 | mockCardSets.map(\.key) as! [DomainCardSet]
38 | }
39 |
40 | public func observeCardSets() async throws -> AsyncStream<[DomainCardSet]> {
41 | AsyncStream { continuation in
42 | continuation.yield(mockCardSets.keys.map { set in set } as [DomainCardSet])
43 | }
44 | }
45 |
46 | public func observeSetCount() async throws -> AsyncStream {
47 | AsyncStream { continuation in
48 | setCountContinuation = continuation
49 | continuation.yield(mockCardSets.count)
50 | }
51 | }
52 |
53 | public func observeCardCount() async throws -> AsyncStream {
54 | AsyncStream { continuation in
55 | cardCountContinuation = continuation
56 | continuation.yield(mockCardSets.flatMap(\.value).count)
57 | }
58 | }
59 |
60 | public func observeCardsFromSet(setCode: String) async throws -> AsyncStream<[DomainCard]> {
61 | AsyncStream { continuation in
62 | cardsContinuations[setCode] = continuation
63 | if let cards = mockCardSets.first(where: { $0.key.code == setCode })?.value {
64 | continuation.yield(cards as [DomainCard])
65 | } else {
66 | continuation.yield([])
67 | }
68 | }
69 | }
70 |
71 | public func removeCardSet(setCode: String) {
72 | if let set = mockCardSets.first(where: { $0.key.code == setCode }) {
73 | mockCardSets[set.key] = []
74 | Task {
75 | await emitCardsUpdate(for: setCode, cards: [])
76 | }
77 | } else {
78 | return
79 | }
80 | Task {
81 | await emitCountsUpdate(delay: 0)
82 | }
83 | }
84 |
85 | private func createCards(_ setCode: String) -> [Card] {
86 | var cards: [Card] = []
87 | for _ in 1 ... 30 {
88 | let card = Card(
89 | id: UUID().uuidString,
90 | setCode: setCode,
91 | name: generateRandomName(length: 8),
92 | text: "",
93 | imageUrl: "",
94 | artist: ""
95 | )
96 | cards.append(card)
97 | }
98 | return cards
99 | }
100 |
101 | private func generateRandomName(length: Int) -> String {
102 | let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
103 | return String((0 ..< length).map { _ in letters.randomElement()! })
104 | }
105 |
106 | private func emitCountsUpdate(delay: UInt64 = 500_000_000) async {
107 | try? await Task.sleep(nanoseconds: delay)
108 | setCountContinuation?.yield(mockCardSets.count)
109 | cardCountContinuation?.yield(mockCardSets.flatMap(\.value).count)
110 |
111 | for (setCode, continuation) in cardsContinuations {
112 | if let cards = mockCardSets.first(where: { $0.key.code == setCode })?.value {
113 | continuation.yield(cards as [DomainCard])
114 | } else {
115 | continuation.yield([])
116 | }
117 | }
118 | }
119 |
120 | private func emitCardsUpdate(delay: UInt64 = 500_000_000, for setCode: String, cards: [DomainCard]) async {
121 | try? await Task.sleep(nanoseconds: delay)
122 | if let continuation = cardsContinuations[setCode] {
123 | continuation.yield(cards as [DomainCard])
124 | }
125 | }
126 | }
127 |
--------------------------------------------------------------------------------
/iosApp/Packages/Data/Sources/DataExtensions/DataBridge.swift:
--------------------------------------------------------------------------------
1 | import DomainProtocols
2 | import MagicDataLayer
3 |
4 | extension KotlinThrowable: @retroactive ErrorException, @unchecked Sendable {}
5 | extension RateLimitException: @retroactive DomainRateLimitException, @unchecked Sendable {}
6 | extension Card: @retroactive DomainCard, @unchecked Sendable {}
7 | extension CardSet: @retroactive DomainCardSet, @unchecked Sendable {}
8 |
--------------------------------------------------------------------------------
/iosApp/Packages/Domain/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | xcuserdata/
5 | DerivedData/
6 | .swiftpm/configuration/registries.json
7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8 | .netrc
9 |
--------------------------------------------------------------------------------
/iosApp/Packages/Domain/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 6.1
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: "Domain",
8 | platforms: [.iOS(.v16)],
9 | products: [
10 | .library(
11 | name: "DomainProtocols",
12 | targets: ["DomainProtocols"]
13 | ),
14 | .library(
15 | name: "CardDomain",
16 | targets: ["CardDomain"]
17 | ),
18 | ],
19 | targets: [
20 | .target(
21 | name: "DomainProtocols",
22 | dependencies: []
23 | ),
24 | .target(
25 | name: "CardDomain",
26 | dependencies: ["DomainProtocols"]
27 | ),
28 | ]
29 | )
30 |
--------------------------------------------------------------------------------
/iosApp/Packages/Domain/Sources/CardDomain/CardDomain.swift:
--------------------------------------------------------------------------------
1 | import DomainProtocols
2 |
3 | @MainActor
4 | public protocol DomainCardsManagerProtocol {
5 | func getCardSet(setCode: String) async -> Result
6 | func getCardSets(setCodes: [String]) async -> Result
7 | func getCardSets() -> [DomainCardSet]
8 | func observeCardSets() async throws -> AsyncStream<[DomainCardSet]>
9 | func observeSetCount() async throws -> AsyncStream
10 | func observeCardCount() async throws -> AsyncStream
11 | func observeCardsFromSet(setCode: String) async throws -> AsyncStream<[DomainCard]>
12 | func removeCardSet(setCode: String)
13 | }
14 |
--------------------------------------------------------------------------------
/iosApp/Packages/Domain/Sources/DomainProtocols/DomainBridge.swift:
--------------------------------------------------------------------------------
1 | public protocol ErrorException: Sendable {}
2 | public protocol DomainRateLimitException: ErrorException {}
3 |
4 | public struct UnexpectedResultError: Error, Sendable {
5 | public init() {}
6 | var localizedDescription: String {
7 | "Received an unexpected result type."
8 | }
9 | }
10 |
11 | public protocol DomainCardSet: Sendable {
12 | var code: String { get }
13 | var name: String { get }
14 | var releaseDate: String { get }
15 | }
16 |
17 | public protocol DomainCard: Sendable {
18 | var id: String { get }
19 | var setCode: String { get }
20 | var name: String { get }
21 | var text: String { get }
22 | var imageUrl: String { get }
23 | var artist: String { get }
24 | }
25 |
26 | public final class DomainCardList: Sendable {
27 | let cards: [DomainCard]
28 |
29 | public init(cards: [DomainCard]) {
30 | self.cards = cards
31 | }
32 | }
33 |
34 | public final class DomainException: Error, Sendable {
35 | public let error: Error?
36 | public let domainError: ErrorException?
37 |
38 | public init(domainError: ErrorException?) {
39 | error = nil
40 | self.domainError = domainError
41 | }
42 |
43 | public init(error: Error?) {
44 | self.error = error
45 | domainError = nil
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/iosApp/Packages/Presentation/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | xcuserdata/
5 | DerivedData/
6 | .swiftpm/configuration/registries.json
7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8 | .netrc
9 |
--------------------------------------------------------------------------------
/iosApp/Packages/Presentation/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 6.1
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: "Presentation",
8 | platforms: [.iOS(.v16)],
9 | products: [
10 | .library(
11 | name: "CardListPresentation",
12 | targets: ["CardListPresentation"]
13 | ),
14 | .library(
15 | name: "CardDeckPresentation",
16 | targets: ["CardDeckPresentation"]
17 | ),
18 | .library(
19 | name: "CardUIModels",
20 | targets: ["CardUIModels"]
21 | ),
22 | ],
23 | dependencies: [
24 | .package(path: "../Domain"),
25 | ],
26 | targets: [
27 | .target(
28 | name: "CardListPresentation",
29 | dependencies: [
30 | "CardUIModels",
31 | .product(name: "DomainProtocols", package: "Domain"),
32 | .product(name: "CardDomain", package: "Domain"),
33 | ]
34 | ),
35 | .target(
36 | name: "CardDeckPresentation",
37 | dependencies: [
38 | "CardUIModels",
39 | .product(name: "DomainProtocols", package: "Domain"),
40 | .product(name: "CardDomain", package: "Domain"),
41 | ]
42 | ),
43 | .target(
44 | name: "CardUIModels",
45 | dependencies: [
46 | .product(name: "DomainProtocols", package: "Domain"),
47 | ]
48 | ),
49 | ]
50 | )
51 |
--------------------------------------------------------------------------------
/iosApp/Packages/Presentation/Sources/CardDeckPresentation/CardDeckScreen.swift:
--------------------------------------------------------------------------------
1 | import CardUIModels
2 | import SwiftUI
3 |
4 | public protocol CardDeckScreenProtocol: View {
5 | associatedtype CardView: CardViewProtocol
6 | associatedtype CardViewModel: CardDeckViewModelProtocol
7 | }
8 |
9 | public struct CardDeckScreen: CardDeckScreenProtocol where CardView.CardType == CardViewModel.CardType {
10 | @StateObject private var viewModel: CardViewModel
11 |
12 | @State private var currentSet = ""
13 | @State private var changedSet = false
14 | @State private var availableSets: [CardSetItem] = []
15 | @State private var fullDeck: [CardViewModel.CardType] = []
16 | @State private var handDeck: [CardViewModel.CardType] = []
17 | @State private var showRateLimitAlert = false
18 | @State private var runAddAnimation = false
19 | @State private var runRemoveAnimation = false
20 | @State private var runShuffleAnimation = false
21 | @State private var runDeleteAnimation = false
22 | @State private var showEmpty = true
23 | @State private var btnShuffleRotation: Double = 0
24 |
25 | public init(viewModel: CardViewModel) {
26 | _viewModel = StateObject(wrappedValue: viewModel)
27 | }
28 |
29 | public var body: some View {
30 | NavigationView {
31 | VStack {
32 | SetPickers
33 | .frame(maxWidth: .infinity, maxHeight: 100)
34 | .onChange(of: viewModel.availableSets) { value in
35 | availableSets = value
36 | }
37 | CardDeck
38 | .frame(maxWidth: .infinity, maxHeight: .infinity)
39 | .onChange(of: viewModel.cards) { value in
40 | if !value.isEmpty, !runAddAnimation {
41 | fullDeck = value
42 | refreshCards()
43 | }
44 | }
45 | .onChange(of: viewModel.rateExceeded) { value in
46 | showRateLimitAlert = value
47 | }
48 | }
49 | .alert(isPresented: $showRateLimitAlert) {
50 | Alert(
51 | title: Text("Ups!"),
52 | message: Text("No more cards for today..."),
53 | dismissButton: .default(Text("OK")) {
54 | viewModel.rateExceeded = false
55 | }
56 | )
57 | }
58 | .toolbar {
59 | ToolbarItemGroup(placement: .navigationBarTrailing) {
60 | ActionsToolbar
61 | }
62 | }
63 | }
64 | }
65 |
66 | private var SetPickers: some View {
67 | HStack(spacing: 20) {
68 | ForEach(availableSets, id: \.code) { set in
69 | Button(action: {
70 | if currentSet != set.code {
71 | currentSet = set.code
72 | changedSet = true
73 | runRemoveAnimation = true
74 | }
75 | }) {
76 | Image(set.toImage())
77 | .circularBlueBorder()
78 | .accessibilityHidden(true)
79 | }
80 | .scaleEffect(currentSet == set.code ? 1.2 : 1.0)
81 | .animation(.easeInOut(duration: 0.15), value: currentSet == set.code)
82 | .accessibilityLabel("\(set.toLabel()) \(currentSet == set.code ? ", current deck" : "")")
83 | .accessibilityHint("\(currentSet == set.code ? "" : "Touch to change deck")")
84 | }
85 | if availableSets.isEmpty {
86 | if viewModel.isLoading {
87 | ProgressView().accessibilityLabel("Loading available decks")
88 | } else {
89 | Button(action: {
90 | if !viewModel.isLoading {
91 | Task {
92 | await viewModel.getAvailableSets()
93 | }
94 | }
95 | }) {
96 | Image(systemName: "arrow.down.circle.dotted")
97 | .scaleEffect(2.0)
98 | .tint(.green)
99 | .accessibilityHidden(true)
100 | }
101 | .accessibilityLabel("Get avaiable decks")
102 | .accessibilityHint("Touch to download available decks")
103 | }
104 | }
105 | }
106 | }
107 |
108 | private var CardDeck: some View {
109 | ZStack {
110 | if showEmpty {
111 | Image("card_back")
112 | .resizable()
113 | .scaledToFit()
114 | .frame(width: 240, height: 340)
115 | .grayscale(1)
116 | .opacity(0.1)
117 | .animation(.easeInOut(duration: 0.15), value: showEmpty)
118 | .accessibilityLabel("Empty deck, no cards to show")
119 | }
120 | CardDeckView(
121 | cardSize: CGSize(width: 250, height: 350),
122 | deck: $handDeck,
123 | add: $runAddAnimation,
124 | remove: $runRemoveAnimation,
125 | shuffle: $runShuffleAnimation,
126 | delete: $runDeleteAnimation,
127 | onAdded: {
128 | runRemoveAnimation = false
129 | runAddAnimation = false
130 | },
131 | onRemoved: {
132 | if changedSet {
133 | changedSet = false
134 | viewModel.changeSet(setCode: currentSet)
135 | } else {
136 | if fullDeck.isEmpty {
137 | Task {
138 | await viewModel.getCardsFromCurrentSet()
139 | }
140 | } else {
141 | refreshCards()
142 | }
143 | }
144 | },
145 | onShuffled: {
146 | runShuffleAnimation = false
147 | },
148 | onDeleted: {
149 | viewModel.deleteCardsFromCurrentSet()
150 | withAnimation {
151 | showEmpty = true
152 | }
153 | runDeleteAnimation = false
154 | currentSet = ""
155 | }
156 | )
157 | .accessibilityHidden(showEmpty)
158 | }
159 | }
160 |
161 | private func refreshCards() {
162 | handDeck = Array(fullDeck.shuffled().prefix(5))
163 | runAddAnimation = true
164 | withAnimation {
165 | showEmpty = false
166 | }
167 | }
168 |
169 | private var ActionsToolbar: some View {
170 | HStack {
171 | Button(action: {
172 | if !fullDeck.isEmpty {
173 | runDeleteAnimation = true
174 | }
175 | }) {
176 | Image(systemName: "trash.circle")
177 | .accessibilityHidden(true)
178 | }
179 | .disabled(!enabledDeleteButton())
180 | .accessibilityLabel("Delete current set")
181 | .accessibilityHint("Touch to delete \(label(set: currentSet)) set")
182 |
183 | Button(action: {
184 | if !runShuffleAnimation {
185 | runShuffleAnimation = true
186 | btnShuffleRotation += 360
187 | }
188 | }) {
189 | Image(systemName: "shuffle.circle")
190 | .rotationEffect(Angle(degrees: btnShuffleRotation))
191 | .animation(.easeInOut(duration: 1), value: runShuffleAnimation)
192 | .accessibilityHidden(true)
193 | }
194 | .disabled(!enabledShuffleButton())
195 | .accessibilityLabel("Shuffle current deck")
196 | .accessibilityHint("Touch to shuffle \(label(set: currentSet)) deck")
197 |
198 | Button(action: {
199 | if !viewModel.isLoading, !runAddAnimation {
200 | runRemoveAnimation = true
201 | }
202 | }) {
203 | Image(systemName: "arrow.down.circle.dotted")
204 | .scaleEffect(viewModel.isLoading ? 1.2 : 1.0)
205 | .animation(viewModel.isLoading ? .easeInOut(duration: 1).repeatForever(autoreverses: true) : .default, value: viewModel.isLoading)
206 | .tint(viewModel.isLoading ? .green : .blue)
207 | .accessibilityHidden(true)
208 | }
209 | .disabled(!enabledGetButton())
210 | .accessibilityLabel("Get more set cards")
211 | .accessibilityHint("Touch to get more \(label(set: currentSet)) set cards")
212 | }
213 | }
214 |
215 | private func enabledDeleteButton() -> Bool { !viewModel.isLoading && !showEmpty && !runDeleteAnimation && !runAddAnimation && !runRemoveAnimation && !runShuffleAnimation }
216 | private func enabledShuffleButton() -> Bool { !viewModel.isLoading && !showEmpty && !runDeleteAnimation && !runAddAnimation && !runRemoveAnimation }
217 | private func enabledGetButton() -> Bool { viewModel.isLoading || (!runDeleteAnimation && !runShuffleAnimation && !runAddAnimation && !runRemoveAnimation && !currentSet.isEmpty) }
218 | }
219 |
220 | private extension CardSetItem {
221 | func toImage() -> String {
222 | switch code {
223 | case "4ED":
224 | "edition_4_symbol"
225 | case "5ED":
226 | "edition_5_symbol"
227 | case "MIR":
228 | "edition_mirage_symbol"
229 | case "TMP":
230 | "edition_tempest_symbol"
231 | default:
232 | "default"
233 | }
234 | }
235 |
236 | func toLabel() -> String {
237 | label(set: code)
238 | }
239 | }
240 |
241 | private func label(set: String) -> String {
242 | switch set {
243 | case "4ED":
244 | "4th edition"
245 | case "5ED":
246 | "5th edition"
247 | case "MIR":
248 | "Mirage"
249 | case "TMP":
250 | "Tempest"
251 | default:
252 | ""
253 | }
254 | }
255 |
--------------------------------------------------------------------------------
/iosApp/Packages/Presentation/Sources/CardDeckPresentation/Core/CardViewModifier.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct CardViewModifier: ViewModifier where CardType: CardProtocol {
4 | let card: CardType
5 | let isTouchedCard: Bool
6 | let isActiveCard: Bool
7 | let index: Int
8 | let showBack: Bool
9 | let added: Bool
10 | let removed: Bool
11 | let dragOffset: CGSize
12 | let deckCount: Int
13 |
14 | func body(content: Content) -> some View {
15 | content
16 | .scaleEffect(isTouchedCard ? 1.20 : isActiveCard ? 1 : 0.95)
17 | .zIndex(isActiveCard ? Double(deckCount) : fromBottomToTop() ? Double(deckCount - (deckCount - index - 1)) : Double(deckCount - index))
18 | .animation(.easeInOut(duration: 0.3), value: isActiveCard || isTouchedCard)
19 | .animation(.easeIn(duration: 0.3).delay(Double(index) * 0.1), value: removed || added)
20 | .offset(
21 | x: added ? -UIScreen.main.bounds.width : isActiveCard ? dragOffset.width : showBack ? 0 : CGFloat(fromBottomToTop() ? deckCount - index - 1 : index) * 10,
22 | y: removed ? UIScreen.main.bounds.height : isActiveCard ? dragOffset.height : showBack ? 0 : CGFloat(fromBottomToTop() ? deckCount - index - 1 : index) * 10
23 | )
24 | }
25 |
26 | private func fromBottomToTop() -> Bool { !showBack && (removed || !added) }
27 | }
28 |
--------------------------------------------------------------------------------
/iosApp/Packages/Presentation/Sources/CardDeckPresentation/Core/CircularButtonModifier.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct CircularBlueBorder: ViewModifier {
4 | func body(content: Content) -> some View {
5 | content
6 | .frame(width: 20, height: 20)
7 | .padding(10)
8 | .clipShape(Circle())
9 | .overlay(Circle().stroke(Color.blue, lineWidth: 2))
10 | }
11 | }
12 |
13 | extension Image {
14 | func circularBlueBorder() -> some View {
15 | resizable()
16 | .scaledToFit()
17 | .modifier(CircularBlueBorder())
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/iosApp/Packages/Presentation/Sources/CardDeckPresentation/Core/Protocols/CardProtocol.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | public protocol CardProtocol: Identifiable, Equatable {
4 | var id: UUID { get }
5 | }
6 |
--------------------------------------------------------------------------------
/iosApp/Packages/Presentation/Sources/CardDeckPresentation/Core/Protocols/CardViewModelProtocol.swift:
--------------------------------------------------------------------------------
1 | import CardDomain
2 | import CardUIModels
3 | import SwiftUI
4 |
5 | @MainActor
6 | public protocol CardDeckViewModelProtocol: ObservableObject where CardType: CardProtocol {
7 | associatedtype CardType
8 |
9 | var availableSets: [CardSetItem] { get }
10 | var cards: [CardType] { get }
11 | var isLoading: Bool { get }
12 | var rateExceeded: Bool { get set }
13 | func getAvailableSets() async
14 | func changeSet(setCode: String)
15 | func getCardsFromCurrentSet() async
16 | func deleteCardsFromCurrentSet()
17 | }
18 |
--------------------------------------------------------------------------------
/iosApp/Packages/Presentation/Sources/CardDeckPresentation/Core/Protocols/CardViewProtocol.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | public protocol CardViewProtocol: View where CardType: CardProtocol {
4 | associatedtype CardType
5 | var card: CardType { get }
6 | var showBack: Bool { get }
7 | var size: CGSize { get }
8 |
9 | init(card: CardType, showBack: Bool, size: CGSize)
10 | }
11 |
--------------------------------------------------------------------------------
/iosApp/Packages/Presentation/Sources/CardDeckPresentation/Views/CardDeckView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct CardDeckView: View {
4 | @State private var activeCard: CardView.CardType? = nil
5 | @State private var touchedCard: CardView.CardType? = nil
6 | @State private var showBack = false
7 | @State private var addedToDeck = true
8 | @State private var removedFromDeck = false
9 | @State private var dragOffset: CGSize = .zero
10 |
11 | let cardSize: CGSize
12 | @Binding var deck: [CardView.CardType]
13 | @Binding var add: Bool
14 | @Binding var remove: Bool
15 | @Binding var shuffle: Bool
16 | @Binding var delete: Bool
17 | let onAdded: () -> Void
18 | let onRemoved: () -> Void
19 | let onShuffled: () -> Void
20 | let onDeleted: () -> Void
21 |
22 | var body: some View {
23 | ZStack {
24 | ForEach(Array(fromBottomToTop() ? deck.reversed().enumerated() : deck.enumerated()), id: \.element.id) { index, card in
25 | CardView(card: card, showBack: showBack && index == 0, size: cardSize)
26 | .modifier(CardViewModifier(
27 | card: card,
28 | isTouchedCard: card == touchedCard,
29 | isActiveCard: card == activeCard,
30 | index: index,
31 | showBack: showBack,
32 | added: addedToDeck,
33 | removed: removedFromDeck,
34 | dragOffset: dragOffset,
35 | deckCount: deck.count
36 | ))
37 | .gesture(
38 | DragGesture(minimumDistance: 20)
39 | .onChanged { value in
40 | if !shuffle {
41 | if card == deck.first {
42 | activeCard = card
43 | dragOffset = CGSize(
44 | width: max(min(value.translation.width, cardSize.width), -cardSize.width),
45 | height: max(min(value.translation.height, cardSize.height), -cardSize.height)
46 | )
47 | }
48 | }
49 | }
50 | .onEnded { value in
51 | touchedCard = nil
52 | if card == deck.first {
53 | handleDragEnded(value: value)
54 | }
55 | }
56 | )
57 | .onTapGesture {
58 | if !shuffle {
59 | if touchedCard == nil, fromBottomToTop() ? index == deck.count - 1 : index == 0 {
60 | touchedCard = card
61 | } else {
62 | touchedCard = nil
63 | }
64 | }
65 | }
66 | }
67 | }
68 | .onChange(of: add) { value in
69 | if value {
70 | animateAddToDeck()
71 | }
72 | }
73 | .onChange(of: remove) { value in
74 | if value {
75 | animateRemoveFromDeck()
76 | }
77 | }
78 | .onChange(of: shuffle) { value in
79 | if value {
80 | animateShuffleDeck()
81 | }
82 | }
83 | .onChange(of: delete) { value in
84 | if value {
85 | animateDeleteDeck()
86 | }
87 | }
88 | }
89 |
90 | private func handleDragEnded(value: DragGesture.Value) {
91 | let thresholdX = cardSize.width * 2 / 3
92 | let thresholdY = cardSize.height * 1 / 2
93 | let xMoved = abs(value.translation.width)
94 | let yMoved = abs(value.translation.height)
95 | let moveToBack = xMoved > thresholdX || yMoved > thresholdY
96 |
97 | withAnimation {
98 | if moveToBack {
99 | let topCardIndex = deck.indices.last
100 | let topCardPosition = deck.count > 1 ? CGSize(
101 | width: CGFloat(topCardIndex ?? 0) * 10,
102 | height: CGFloat(topCardIndex ?? 0) * 10
103 | ) : .zero
104 |
105 | let overlapOffset = CGSize(
106 | width: max(0, (cardSize.width / 2) - (value.translation.width - topCardPosition.width)),
107 | height: max(0, (cardSize.height / 2) - (value.translation.height - topCardPosition.height))
108 | )
109 |
110 | dragOffset = CGSize(
111 | width: value.translation.width + overlapOffset.width,
112 | height: value.translation.height + overlapOffset.height
113 | )
114 |
115 | moveCardToBack()
116 | } else {
117 | moveCardToFront()
118 | }
119 | }
120 | activeCard = nil
121 | }
122 |
123 | private func moveCardToFront() {
124 | guard !deck.isEmpty else { return }
125 | let card = deck.removeFirst()
126 | deck.insert(card, at: 0)
127 | }
128 |
129 | private func moveCardToBack() {
130 | guard !deck.isEmpty else { return }
131 | let card = deck.removeFirst()
132 | deck.append(card)
133 | }
134 |
135 | private func animateAddToDeck() {
136 | addedToDeck = false
137 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.13 * Double(deck.count)) {
138 | onAdded()
139 | }
140 | }
141 |
142 | private func animateRemoveFromDeck() {
143 | addedToDeck = true
144 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.13 * Double(deck.count)) {
145 | onRemoved()
146 | }
147 | }
148 |
149 | private func animateShuffleDeck() {
150 | withAnimation {
151 | showBack = true
152 | touchedCard = nil
153 | activeCard = nil
154 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) {
155 | deck.shuffle()
156 | withAnimation {
157 | showBack = false
158 | onShuffled()
159 | }
160 | }
161 | }
162 | }
163 |
164 | private func animateDeleteDeck() {
165 | removedFromDeck = true
166 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.13 * Double(deck.count)) {
167 | addedToDeck = true // to reset addedToDeck animation for fromBottomToTop()
168 | removedFromDeck = false
169 | onDeleted()
170 | }
171 | }
172 |
173 | private func fromBottomToTop() -> Bool { !showBack && (removedFromDeck || !addedToDeck) }
174 | }
175 |
--------------------------------------------------------------------------------
/iosApp/Packages/Presentation/Sources/CardDeckPresentation/Views/Color/ColorCard.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | public struct ColorCard: CardProtocol {
4 | public let id = UUID()
5 | let color: Color
6 | let number: Int
7 |
8 | public init(color: Color, number: Int) {
9 | self.color = color
10 | self.number = number
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/iosApp/Packages/Presentation/Sources/CardDeckPresentation/Views/Color/ColorCardView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | public struct ColorCardView: CardViewProtocol {
4 | public let card: ColorCard
5 | public let showBack: Bool
6 | public let size: CGSize
7 |
8 | public init(card: ColorCard, showBack: Bool, size: CGSize) {
9 | self.card = card
10 | self.showBack = showBack
11 | self.size = size
12 | }
13 |
14 | public var body: some View {
15 | ZStack {
16 | if showBack {
17 | RoundedRectangle(cornerRadius: 10)
18 | .fill(Color.gray)
19 | .animation(.easeInOut(duration: 0.5), value: showBack)
20 | .accessibilityHidden(true)
21 | } else {
22 | RoundedRectangle(cornerRadius: 10)
23 | .fill(card.color)
24 | .overlay(
25 | Text("\(card.number)")
26 | .font(.system(size: 100))
27 | .foregroundColor(.white)
28 | )
29 | .accessibilityHidden(true)
30 | }
31 | }
32 | .frame(width: size.width, height: size.height)
33 | .clipShape(RoundedRectangle(cornerRadius: 10))
34 | .rotation3DEffect(Angle(degrees: showBack ? 180 : 0), axis: (x: 0, y: 1, z: 0))
35 | .shadow(radius: 5)
36 | .accessibilityLabel("\(card.color.description) card number \(card.number)")
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/iosApp/Packages/Presentation/Sources/CardDeckPresentation/Views/Color/ColorDeckViewModel.swift:
--------------------------------------------------------------------------------
1 | import CardUIModels
2 | import Combine
3 | import SwiftUI
4 |
5 | public class ColorDeckViewModel: ObservableObject, CardDeckViewModelProtocol {
6 | public typealias CardType = ColorCard
7 |
8 | private var cancellables = Set()
9 |
10 | @Published public private(set) var availableSets: [CardSetItem] = []
11 | @Published public private(set) var cards: [ColorCard] = []
12 | @Published public private(set) var isLoading: Bool = true
13 | @Published public var rateExceeded: Bool = false
14 |
15 | public init() {
16 | $availableSets
17 | .handleEvents(receiveSubscription: { _ in
18 | Task { await self.getAvailableSets() }
19 | })
20 | .sink { _ in }
21 | .store(in: &cancellables)
22 | }
23 |
24 | public func getAvailableSets() async {
25 | isLoading = true
26 | try? await Task.sleep(nanoseconds: 2_000_000_000)
27 | availableSets = [
28 | CardSetItem(code: "AAA", name: "AAA", releaseDate: ""),
29 | CardSetItem(code: "BBB", name: "BBB", releaseDate: ""),
30 | ]
31 | isLoading = false
32 | }
33 |
34 | public func changeSet(setCode _: String) {
35 | Task { await getCardsFromCurrentSet() }
36 | }
37 |
38 | public func getCardsFromCurrentSet() async {
39 | isLoading = true
40 | try? await Task.sleep(nanoseconds: 1_000_000_000)
41 | cards = [
42 | ColorCard(color: .red, number: 1),
43 | ColorCard(color: .orange, number: 2),
44 | ColorCard(color: .yellow, number: 3),
45 | ColorCard(color: .green, number: 4),
46 | ColorCard(color: .blue, number: 5),
47 | ]
48 | isLoading = false
49 | }
50 |
51 | public func deleteCardsFromCurrentSet() {
52 | cards.removeAll()
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/iosApp/Packages/Presentation/Sources/CardDeckPresentation/Views/Magic/MagicCard.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | public struct MagicCard: CardProtocol {
4 | public let id = UUID()
5 | let name: String
6 | let text: String
7 | let imageUrl: String
8 | let artist: String
9 |
10 | public init(name: String, text: String, imageUrl: String, artist: String) {
11 | self.name = name
12 | self.text = text
13 | self.imageUrl = imageUrl
14 | self.artist = artist
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/iosApp/Packages/Presentation/Sources/CardDeckPresentation/Views/Magic/MagicCardView.swift:
--------------------------------------------------------------------------------
1 | import Kingfisher
2 | import SwiftUI
3 |
4 | public struct MagicCardView: CardViewProtocol {
5 | @State private var showError: Bool = false
6 | public let card: MagicCard
7 | public let showBack: Bool
8 | public let size: CGSize
9 |
10 | public init(card: MagicCard, showBack: Bool, size: CGSize) {
11 | self.card = card
12 | self.showBack = showBack
13 | self.size = size
14 | }
15 |
16 | public var body: some View {
17 | ZStack {
18 | if showBack {
19 | Image("card_back")
20 | .resizable()
21 | .scaledToFit()
22 | .rotation3DEffect(Angle(degrees: 180), axis: (x: 0, y: 1, z: 0))
23 | .animation(.easeInOut(duration: 0.5), value: showBack)
24 | .accessibilityHidden(true)
25 | } else {
26 | if showError {
27 | ZStack {
28 | RoundedRectangle(cornerRadius: 14)
29 | .stroke(Color.black)
30 | .background(Color.white)
31 | Color.gray.opacity(0.2)
32 | Image(systemName: "photo")
33 | .font(.title)
34 | .foregroundStyle(.secondary)
35 | .accessibilityHidden(true)
36 | }
37 | } else {
38 | KFImage(URL(string: card.imageUrl))
39 | .resizable()
40 | .placeholder {
41 | ZStack {
42 | RoundedRectangle(cornerRadius: 14)
43 | .stroke(Color.black)
44 | .background(Color.white)
45 | ProgressView()
46 | }
47 | .frame(width: size.width, height: size.height)
48 | }
49 | .onFailure { _ in showError = true }
50 | .onSuccess { _ in showError = false }
51 | .scaledToFit()
52 | }
53 | }
54 | }
55 | .frame(width: size.width, height: size.height)
56 | .clipShape(RoundedRectangle(cornerRadius: 14))
57 | .rotation3DEffect(Angle(degrees: showBack ? 180 : 0), axis: (x: 0, y: 1, z: 0))
58 | .accessibilityLabel("\(card.name) card")
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/iosApp/Packages/Presentation/Sources/CardDeckPresentation/Views/Magic/MagicDeckViewModel.swift:
--------------------------------------------------------------------------------
1 | import CardDomain
2 | import CardUIModels
3 | import Combine
4 | import DomainProtocols
5 | import SwiftUI
6 |
7 | public class MagicDeckViewModel: ObservableObject, CardDeckViewModelProtocol {
8 | public typealias CardType = MagicCard
9 |
10 | // https://en.wikipedia.org/wiki/List_of_Magic:_The_Gathering_sets
11 | private let sets = ["4ED", "5ED", "TMP", "MIR"]
12 | private let manager: DomainCardsManagerProtocol
13 | private var cancellables = Set()
14 |
15 | @Published private(set) var currentSet: CardSetItem = .init(code: "", name: "", releaseDate: "")
16 | @Published public private(set) var availableSets: [CardSetItem] = []
17 | @Published public private(set) var cards: [MagicCard] = []
18 | @Published public private(set) var isLoading: Bool = false
19 | @Published public var rateExceeded: Bool = false
20 |
21 | public init(manager: DomainCardsManagerProtocol) {
22 | self.manager = manager
23 | observeData()
24 | }
25 |
26 | private func observeData() {
27 | $availableSets
28 | .handleEvents(receiveSubscription: { _ in
29 | Task { await self.getAvailableSets() }
30 | })
31 | .sink { _ in }
32 | .store(in: &cancellables)
33 |
34 | $currentSet
35 | .filter { !$0.code.isEmpty }
36 | .map(\.code)
37 | .sink { [weak self] code in
38 | print("> Current set is \(code)")
39 | Task { await self?.observeCards(setCode: code) }
40 | }
41 | .store(in: &cancellables)
42 | }
43 |
44 | private func observeCards(setCode: String) async {
45 | print("> Observing \(currentSet.name) cards")
46 | do {
47 | let stream = try await manager.observeCardsFromSet(setCode: setCode)
48 | for await cardList in stream {
49 | cards = cardList.map { card in card.toMagicCard() }
50 | }
51 | } catch {
52 | print("> Failed to observe cards with error: \(error)")
53 | }
54 | }
55 |
56 | private func handleError(_ error: DomainException) {
57 | if error.domainError != nil {
58 | print("> MagicDataLayer error occurred: \(String(describing: error.domainError)) ")
59 | if error.domainError is DomainRateLimitException {
60 | rateExceeded = true
61 | }
62 | } else {
63 | print("> An error occurred: \(String(describing: error.error))")
64 | }
65 | }
66 |
67 | private func changeCurrentSet(_ setCode: String) {
68 | guard let value = manager.getCardSets().first(where: { $0.code == setCode })?.toCardSetItem() else {
69 | print("> Set \(currentSet.name) not found!")
70 | return
71 | }
72 | currentSet = value
73 | }
74 |
75 | public func getAvailableSets() async {
76 | isLoading = true
77 | defer { isLoading = false }
78 |
79 | let result = await manager.getCardSets(setCodes: sets)
80 |
81 | switch result {
82 | case .success:
83 | availableSets = manager.getCardSets()
84 | .map { set in set.toCardSetItem() }
85 | .sorted { $0.releaseDate < $1.releaseDate }
86 | print("> \(availableSets.count) sets retrieved!")
87 | case let .failure(error): handleError(error)
88 | }
89 | }
90 |
91 | public func changeSet(setCode: String) {
92 | if manager.getCardSets().first(where: { set in set.code == setCode }) != nil {
93 | changeCurrentSet(setCode)
94 | } else {
95 | Task {
96 | rateExceeded = false
97 | isLoading = true
98 | defer { isLoading = false }
99 |
100 | let result = await manager.getCardSet(setCode: setCode)
101 |
102 | switch result {
103 | case .success:
104 | print("> Set \(setCode) retrieved!")
105 | changeCurrentSet(setCode)
106 | case let .failure(error): handleError(error)
107 | }
108 | }
109 | }
110 | }
111 |
112 | public func getCardsFromCurrentSet() async {
113 | if currentSet.code.isEmpty {
114 | return
115 | }
116 |
117 | rateExceeded = false
118 | isLoading = true
119 | defer { isLoading = false }
120 |
121 | let result = await manager.getCardSet(setCode: currentSet.code)
122 |
123 | switch result {
124 | case .success: print("> Set \(currentSet.name) retrieved!")
125 | case let .failure(error): handleError(error)
126 | }
127 | }
128 |
129 | public func deleteCardsFromCurrentSet() {
130 | manager.removeCardSet(setCode: currentSet.code)
131 | print("> \(currentSet.name) deleted!")
132 | }
133 | }
134 |
135 | private extension DomainCard {
136 | func toMagicCard() -> MagicCard {
137 | MagicCard(
138 | name: name,
139 | text: text,
140 | imageUrl: imageUrl.toSecureURL() ?? imageUrl,
141 | artist: artist
142 | )
143 | }
144 | }
145 |
146 | private extension String {
147 | func toSecureURL() -> String? {
148 | guard var urlComponents = URLComponents(string: self) else {
149 | return nil
150 | }
151 | if urlComponents.scheme == "http" {
152 | urlComponents.scheme = "https"
153 | }
154 | return urlComponents.string
155 | }
156 | }
157 |
--------------------------------------------------------------------------------
/iosApp/Packages/Presentation/Sources/CardListPresentation/CardListItem.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct CardListItem: Identifiable, Equatable {
4 | public let id: UUID = .init()
5 | public let cardId: String
6 | public let setCode: String
7 | public let name: String
8 | public let text: String
9 | public let imageUrl: String
10 | public let artist: String
11 |
12 | init(
13 | cardId: String,
14 | setCode: String,
15 | name: String,
16 | text: String,
17 | imageUrl: String,
18 | artist: String
19 | ) {
20 | self.cardId = cardId
21 | self.setCode = setCode
22 | self.name = name
23 | self.text = text
24 | self.imageUrl = imageUrl
25 | self.artist = artist
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/iosApp/Packages/Presentation/Sources/CardListPresentation/CardListView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | public struct CardListView: View {
4 | @StateObject private var viewModel: CardListViewModel
5 | @State private var showRateLimitAlert: Bool = false
6 |
7 | public init(viewModel: CardListViewModelProtocol) {
8 | _viewModel = StateObject(wrappedValue: viewModel as! CardListViewModel)
9 | }
10 |
11 | public var body: some View {
12 | ZStack {
13 | Color(.systemBackground).edgesIgnoringSafeArea(.all)
14 | VStack {
15 | setPicker
16 | actionButtons
17 | dataCounters
18 |
19 | ZStack {
20 | if viewModel.isLoading {
21 | ProgressView().transition(.opacity)
22 | }
23 | }
24 | .frame(maxWidth: .infinity, maxHeight: 25)
25 | .animation(.easeInOut, value: viewModel.isLoading)
26 |
27 | Divider().padding(.horizontal, 10)
28 |
29 | ZStack {
30 | if !viewModel.cards.isEmpty {
31 | cardList.transition(.opacity)
32 | } else {
33 | emptyViews.transition(.opacity)
34 | }
35 | }
36 | .animation(.easeInOut, value: viewModel.cards)
37 | }
38 | .frame(maxWidth: .infinity, maxHeight: .infinity)
39 | .alert(isPresented: $showRateLimitAlert) {
40 | Alert(
41 | title: Text("Ups!"),
42 | message: Text("No more cards for today..."),
43 | dismissButton: .default(Text("OK")) {}
44 | )
45 | }
46 | }
47 | .onChange(of: viewModel.rateExceeded) { value in
48 | showRateLimitAlert = value
49 | }
50 | }
51 |
52 | private var setPicker: some View {
53 | Picker("Card Set", selection: $viewModel.currentSet) {
54 | if viewModel.currentSet.code.isEmpty {
55 | Text("Card Set").tag(nil as String?)
56 | }
57 | ForEach(viewModel.availableSets, id: \.self) { set in
58 | Text("\(set.name), \(set.releaseDate)").tag(set.id)
59 | }
60 | }
61 | .pickerStyle(MenuPickerStyle())
62 | }
63 |
64 | private var actionButtons: some View {
65 | HStack(spacing: 20) {
66 | Button(action: {
67 | Task {
68 | await viewModel.getCardsFromCurrentSet()
69 | }
70 | }) {
71 | Text("Get cards")
72 | }
73 | .disabled(viewModel.isLoading || viewModel.currentSet.code.isEmpty)
74 | Button(action: { viewModel.deleteCardSet() }) {
75 | Text("Delete cards")
76 | }
77 | .disabled(viewModel.isLoading || viewModel.currentSet.code.isEmpty || viewModel.cards.isEmpty)
78 | }
79 | .padding(20)
80 | }
81 |
82 | private var dataCounters: some View {
83 | VStack {
84 | Text("Number of cards: \(viewModel.cardsTotalCount)")
85 | Text("Number of sets: \(viewModel.setCount)")
86 | Spacer().frame(height: 5)
87 | }
88 | .frame(maxWidth: .infinity, maxHeight: 60)
89 | }
90 |
91 | private var cardList: some View {
92 | let items = viewModel.cards
93 | return ScrollView {
94 | LazyVStack {
95 | ForEach(items) { card in
96 | Text(card.name)
97 | }
98 | }
99 | }
100 | .frame(maxWidth: .infinity, maxHeight: .infinity)
101 | }
102 |
103 | private var emptyViews: some View {
104 | ZStack {
105 | if viewModel.currentSet.code.isEmpty {
106 | Text("Select Card Set")
107 | } else {
108 | if viewModel.cards.isEmpty {
109 | Text("No cards...")
110 | }
111 | }
112 | }
113 | .frame(maxWidth: .infinity, maxHeight: .infinity)
114 | }
115 | }
116 |
--------------------------------------------------------------------------------
/iosApp/Packages/Presentation/Sources/CardListPresentation/CardListViewModel.swift:
--------------------------------------------------------------------------------
1 | import CardDomain
2 | import CardUIModels
3 | import Combine
4 | import DomainProtocols
5 | import SwiftUI
6 |
7 | @MainActor
8 | public protocol CardListViewModelProtocol {}
9 |
10 | public class CardListViewModel: ObservableObject, CardListViewModelProtocol {
11 | // https://en.wikipedia.org/wiki/List_of_Magic:_The_Gathering_sets
12 | private let sets = ["4ED", "5ED", "TMP", "MIR"]
13 | private let manager: DomainCardsManagerProtocol
14 | private var cancellables = Set()
15 |
16 | @Published var currentSet: CardSetItem = .init(code: "", name: "", releaseDate: "")
17 | @Published private(set) var availableSets: [CardSetItem] = []
18 | @Published private(set) var setCount: Int = 0
19 | @Published private(set) var cardsTotalCount: Int = 0
20 | @Published private(set) var cards: [CardListItem] = []
21 | @Published private(set) var isLoading: Bool = false
22 | @Published private(set) var rateExceeded: Bool = false
23 |
24 | public init(manager: DomainCardsManagerProtocol) {
25 | self.manager = manager
26 | observeData()
27 | }
28 |
29 | private func observeData() {
30 | $availableSets
31 | .handleEvents(receiveSubscription: { _ in
32 | Task {
33 | await withTaskGroup(of: Void.self) { group in
34 | group.addTask { await self.getAvailableSets() }
35 | group.addTask { await self.observeSetCount() }
36 | group.addTask { await self.observeCardsCount() }
37 | }
38 | }
39 | })
40 | .sink { _ in }
41 | .store(in: &cancellables)
42 |
43 | $currentSet
44 | .filter { !$0.code.isEmpty }
45 | .map(\.code)
46 | .sink { [weak self] code in
47 | Task { await self?.observeCards(setCode: code) }
48 | }
49 | .store(in: &cancellables)
50 | }
51 |
52 | private func handleError(_ error: DomainException) {
53 | if let exception = error as? DomainRateLimitException {
54 | print("> MagicDataLayer error occurred: \(String(describing: exception)) ")
55 | rateExceeded = true
56 | } else {
57 | print("> An error occurred: \(String(describing: error.error))")
58 | }
59 | }
60 |
61 | private func observeSetCount() async {
62 | do {
63 | let stream = try await manager.observeSetCount()
64 | for await count in stream {
65 | setCount = count
66 | }
67 | } catch {
68 | print("> Failed to observe set count with error: \(error)")
69 | }
70 | }
71 |
72 | private func observeCardsCount() async {
73 | do {
74 | let stream = try await manager.observeCardCount()
75 | for await count in stream {
76 | cardsTotalCount = count
77 | }
78 | } catch {
79 | print("> Failed to observe card count with error: \(error)")
80 | }
81 | }
82 |
83 | private func observeCards(setCode: String) async {
84 | do {
85 | let stream = try await manager.observeCardsFromSet(setCode: setCode)
86 | for await cardList in stream {
87 | cards = cardList.map { card in card.toCardListItem() }
88 | }
89 | } catch {
90 | print("> Failed to observe cards with error: \(error)")
91 | }
92 | }
93 |
94 | private func getAvailableSets() async {
95 | isLoading = true
96 | defer { isLoading = false }
97 |
98 | let result = await manager.getCardSets(setCodes: sets)
99 |
100 | switch result {
101 | case .success:
102 | print("> Sets retrieved!")
103 | availableSets = manager.getCardSets()
104 | .map { set in set.toCardSetItem() }
105 | .sorted { $0.releaseDate < $1.releaseDate }
106 | case let .failure(error): handleError(error)
107 | }
108 | }
109 |
110 | func getCardsFromCurrentSet() async {
111 | rateExceeded = false
112 | isLoading = true
113 | defer { isLoading = false }
114 |
115 | let result = await manager.getCardSet(setCode: currentSet.code)
116 |
117 | switch result {
118 | case .success: print("> Set retrieved!")
119 | case let .failure(error): handleError(error)
120 | }
121 | }
122 |
123 | func deleteCardSet() {
124 | manager.removeCardSet(setCode: currentSet.code)
125 | }
126 | }
127 |
128 | private extension DomainCard {
129 | func toCardListItem() -> CardListItem {
130 | CardListItem(
131 | cardId: id,
132 | setCode: setCode,
133 | name: name,
134 | text: text,
135 | imageUrl: imageUrl,
136 | artist: artist
137 | )
138 | }
139 | }
140 |
--------------------------------------------------------------------------------
/iosApp/Packages/Presentation/Sources/CardUIModels/CardSetItem.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | public struct CardSetItem: Identifiable, Equatable, Hashable {
4 | public let id: UUID = .init()
5 | public let code: String
6 | public let name: String
7 | public let releaseDate: String
8 |
9 | public init(
10 | code: String,
11 | name: String,
12 | releaseDate: String
13 | ) {
14 | self.code = code
15 | self.name = name
16 | self.releaseDate = releaseDate
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/iosApp/Packages/Presentation/Sources/CardUIModels/ModelExtensions.swift:
--------------------------------------------------------------------------------
1 | import DomainProtocols
2 |
3 | public extension DomainCardSet {
4 | func toCardSetItem() -> CardSetItem {
5 | CardSetItem(
6 | code: code,
7 | name: name,
8 | releaseDate: releaseDate
9 | )
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/media/banner.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GuilhE/Magic/e730b9c9321addc5f156085fd57b9d78145673ee/media/banner.png
--------------------------------------------------------------------------------
/media/deck-combine.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GuilhE/Magic/e730b9c9321addc5f156085fd57b9d78145673ee/media/deck-combine.gif
--------------------------------------------------------------------------------
/media/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GuilhE/Magic/e730b9c9321addc5f156085fd57b9d78145673ee/media/icon.png
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | @file:Suppress("UnstableApiUsage")
2 |
3 | pluginManagement {
4 | includeBuild("build-logic")
5 | repositories {
6 | mavenLocal()
7 | mavenCentral()
8 | gradlePluginPortal()
9 | google()
10 | maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
11 | }
12 | }
13 |
14 | dependencyResolutionManagement {
15 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
16 | repositories {
17 | mavenLocal()
18 | mavenCentral()
19 | google()
20 | maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
21 | }
22 | }
23 |
24 | enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
25 |
26 | rootProject.name = "Magic"
27 | include(":androidApp")
28 | include(":core-di")
29 | include(":core-network")
30 | include(":core-database")
31 | include(":data-managers")
32 | include(":data-models")
33 |
--------------------------------------------------------------------------------