├── .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 | [![Android Weekly](https://androidweekly.net/issues/issue-647/badge)](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 | --------------------------------------------------------------------------------