From 287e862b3af956ea5b2a85c037f4f6f7f3c7f29c Mon Sep 17 00:00:00 2001 From: bpedryc Date: Wed, 7 Jun 2023 17:30:01 +0200 Subject: [PATCH 01/18] Implement sample navigation for both platforms --- app/build.gradle.kts | 2 + .../touchlab/kampkit/android/MainActivity.kt | 11 +- .../co/touchlab/kampkit/android/MainApp.kt | 10 +- .../kampkit/android/ui/BreedDetailsScreen.kt | 18 ++ .../kampkit/android/ui/BreedsScreen.kt | 16 +- .../kampkit/android/ui/MainNavigation.kt | 37 +++++ gradle/libs.versions.toml | 3 + ios/KaMPKitiOS.xcodeproj/project.pbxproj | 10 +- ios/KaMPKitiOS/AppDelegate.swift | 5 +- ios/KaMPKitiOS/AppNavigationController.swift | 29 ++++ ios/KaMPKitiOS/BreedDetailsScreen.swift | 67 ++++++++ ios/KaMPKitiOS/BreedListScreen.swift | 155 ++++++++++++++++++ .../co/touchlab/kampkit/core/ViewModel.kt | 6 +- .../kotlin/co/touchlab/kampkit/core/Koin.kt | 4 + .../co/touchlab/kampkit/core/ViewModel.kt | 2 +- .../kampkit/data/dog/DogDatabaseHelper.kt | 7 +- .../kampkit/data/dog/DogRepository.kt | 63 +++++++ .../ui/breedDetails/BreedDetailsViewModel.kt | 67 ++++++++ .../kampkit/ui/breeds/BreedsViewModel.kt | 41 ++++- .../touchlab/kampkit/BreedsViewModelTest.kt | 6 +- .../co/touchlab/kampkit/SqlDelightTest.kt | 2 +- .../co/touchlab/kampkit/core/KoinIOS.kt | 7 +- .../co/touchlab/kampkit/core/ViewModel.kt | 2 +- 23 files changed, 543 insertions(+), 27 deletions(-) create mode 100644 app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedDetailsScreen.kt create mode 100644 app/src/main/kotlin/co/touchlab/kampkit/android/ui/MainNavigation.kt create mode 100644 ios/KaMPKitiOS/AppNavigationController.swift create mode 100644 ios/KaMPKitiOS/BreedDetailsScreen.swift create mode 100644 ios/KaMPKitiOS/BreedListScreen.swift create mode 100644 shared/src/commonMain/kotlin/co/touchlab/kampkit/data/dog/DogRepository.kt create mode 100644 shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDetailsViewModel.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3e720db9..41ad7c2a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -54,4 +54,6 @@ dependencies { coreLibraryDesugaring(libs.android.desugaring) implementation(libs.koin.android) testImplementation(libs.junit) + implementation(libs.compose.navigation) + implementation(libs.koin.compose) } diff --git a/app/src/main/kotlin/co/touchlab/kampkit/android/MainActivity.kt b/app/src/main/kotlin/co/touchlab/kampkit/android/MainActivity.kt index ed0f2dff..fcbac8a6 100644 --- a/app/src/main/kotlin/co/touchlab/kampkit/android/MainActivity.kt +++ b/app/src/main/kotlin/co/touchlab/kampkit/android/MainActivity.kt @@ -3,24 +3,17 @@ package co.touchlab.kampkit.android import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import co.touchlab.kampkit.android.ui.BreedsScreen +import co.touchlab.kampkit.android.ui.MainNavigation import co.touchlab.kampkit.android.ui.theme.KaMPKitTheme -import co.touchlab.kampkit.core.injectLogger -import co.touchlab.kampkit.ui.breeds.BreedsViewModel -import co.touchlab.kermit.Logger -import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.component.KoinComponent class MainActivity : ComponentActivity(), KoinComponent { - private val log: Logger by injectLogger("MainActivity") - private val viewModel: BreedsViewModel by viewModel() - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { KaMPKitTheme { - BreedsScreen(viewModel, log) + MainNavigation() } } } diff --git a/app/src/main/kotlin/co/touchlab/kampkit/android/MainApp.kt b/app/src/main/kotlin/co/touchlab/kampkit/android/MainApp.kt index b6c202ca..6b261ada 100644 --- a/app/src/main/kotlin/co/touchlab/kampkit/android/MainApp.kt +++ b/app/src/main/kotlin/co/touchlab/kampkit/android/MainApp.kt @@ -6,6 +6,7 @@ import android.content.SharedPreferences import android.util.Log import co.touchlab.kampkit.core.AppInfo import co.touchlab.kampkit.core.initKoin +import co.touchlab.kampkit.ui.breedDetails.BreedDetailsViewModel import co.touchlab.kampkit.ui.breeds.BreedsViewModel import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.core.parameter.parametersOf @@ -18,7 +19,14 @@ class MainApp : Application() { initKoin( module { single { this@MainApp } - viewModel { BreedsViewModel(get(), get { parametersOf("BreedsViewModel") }) } + viewModel { + BreedsViewModel(get(), get { parametersOf("BreedsViewModel") }) + } + viewModel {params -> + BreedDetailsViewModel( + params.get(), get(), get { parametersOf("BreedDetailsViewModel") } + ) + } single { get().getSharedPreferences("KAMPSTARTER_SETTINGS", MODE_PRIVATE) } diff --git a/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedDetailsScreen.kt b/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedDetailsScreen.kt new file mode 100644 index 00000000..255b7a87 --- /dev/null +++ b/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedDetailsScreen.kt @@ -0,0 +1,18 @@ +package co.touchlab.kampkit.android.ui + +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import co.touchlab.kampkit.ui.breedDetails.BreedDetailsViewModel +import co.touchlab.kermit.Logger + +@Composable +fun BreedDetailsScreen( + viewModel: BreedDetailsViewModel, + log: Logger +) { + val state by viewModel.detailsState.collectAsStateWithLifecycle() + + Text("${state.breed?.name }") +} \ No newline at end of file diff --git a/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedsScreen.kt b/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedsScreen.kt index 1f5d73ea..575b0fa4 100644 --- a/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedsScreen.kt +++ b/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedsScreen.kt @@ -32,6 +32,10 @@ import co.touchlab.kampkit.android.R import co.touchlab.kampkit.domain.breed.Breed import co.touchlab.kampkit.ui.breeds.BreedsViewModel import co.touchlab.kampkit.ui.breeds.BreedsViewState +import co.touchlab.kampkit.db.Breed +import co.touchlab.kampkit.ui.breeds.BreedViewState +import co.touchlab.kampkit.ui.breeds.BreedsViewModel +import co.touchlab.kampkit.ui.breeds.NavigationIntent import co.touchlab.kermit.Logger import com.google.accompanist.swiperefresh.SwipeRefresh import com.google.accompanist.swiperefresh.rememberSwipeRefreshState @@ -39,11 +43,21 @@ import com.google.accompanist.swiperefresh.rememberSwipeRefreshState @Composable fun BreedsScreen( viewModel: BreedsViewModel, + onNavigateToDetails: (breedId: Long) -> Unit, log: Logger ) { val breedsState by viewModel.breedsState.collectAsStateWithLifecycle() - BreedsScreenContent( + dogsState.navigationIntent?.let { navIntent -> + LaunchedEffect(navIntent) { + if (navIntent is NavigationIntent.ToDetails) { + onNavigateToDetails(navIntent.breedId) + viewModel.onNavigationCompleted() + } + } + } + + MainScreenContent( dogsState = breedsState, onRefresh = { viewModel.refreshBreeds() }, onSuccess = { data -> log.v { "View updating with ${data.size} breeds" } }, diff --git a/app/src/main/kotlin/co/touchlab/kampkit/android/ui/MainNavigation.kt b/app/src/main/kotlin/co/touchlab/kampkit/android/ui/MainNavigation.kt new file mode 100644 index 00000000..b5749ace --- /dev/null +++ b/app/src/main/kotlin/co/touchlab/kampkit/android/ui/MainNavigation.kt @@ -0,0 +1,37 @@ +package co.touchlab.kampkit.android.ui + +import androidx.compose.runtime.Composable +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import org.koin.androidx.compose.get +import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf + +@Composable +fun MainNavigation() { + val navController = rememberNavController() + NavHost(navController = navController, startDestination = "breeds") { + composable("breeds") { + BreedsScreen( + viewModel = koinViewModel(), + onNavigateToDetails = { breedId -> + navController.navigate("breedDetails/$breedId") + }, + log = get { parametersOf("BreedsScreen") } + ) + } + composable( + route = "breedDetails/{breedId}", + arguments = listOf(navArgument("breedId") { type = NavType.LongType }) + ) { + val breedId = it.arguments?.getLong("breedId") + BreedDetailsScreen( + viewModel = koinViewModel { parametersOf(breedId) }, + log = get { parametersOf("BreedDetailsScreen") } + ) + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e301cfb7..e6f7d7c9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,6 +19,7 @@ androidx-core = "1.9.0" androidx-test-junit = "1.1.3" androidx-activity-compose = "1.5.1" androidx-lifecycle = "2.6.0" +androidx-navigation-compose = "2.5.3" junit = "4.13.2" @@ -51,6 +52,7 @@ compose-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "co compose-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "compose" } compose-material = { module = "androidx.compose.material:material", version.ref = "compose" } compose-activity = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" } +compose-navigation = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation-compose" } coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } @@ -62,6 +64,7 @@ junit = { module = "junit:junit", version.ref = "junit" } koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" } koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } koin-test = { module = "io.insert-koin:koin-test", version.ref = "koin" } +koin-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koin"} kotlinx-dateTime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } diff --git a/ios/KaMPKitiOS.xcodeproj/project.pbxproj b/ios/KaMPKitiOS.xcodeproj/project.pbxproj index 9d39915c..eef8b4ef 100644 --- a/ios/KaMPKitiOS.xcodeproj/project.pbxproj +++ b/ios/KaMPKitiOS.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + 3C6AEC072A30881B0003F34A /* BreedDetailsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C6AEC062A30881B0003F34A /* BreedDetailsScreen.swift */; }; + 3C6AEC092A30C17A0003F34A /* AppNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C6AEC082A30C17A0003F34A /* AppNavigationController.swift */; }; 3C73D04A2A335103003E6929 /* BreedsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C73D0492A335103003E6929 /* BreedsViewModel.swift */; }; 3CD290EC2A251417004C7AD1 /* KMPNativeCoroutinesCombine in Frameworks */ = {isa = PBXBuildFile; productRef = 3CD290EB2A251417004C7AD1 /* KMPNativeCoroutinesCombine */; }; 3CD290EE2A251417004C7AD1 /* KMPNativeCoroutinesCore in Frameworks */ = {isa = PBXBuildFile; productRef = 3CD290ED2A251417004C7AD1 /* KMPNativeCoroutinesCore */; }; @@ -43,6 +45,8 @@ 2A1ED6A4A2A53F5F75C58E5F /* Pods-KaMPKitiOS.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-KaMPKitiOS.release.xcconfig"; path = "Pods/Target Support Files/Pods-KaMPKitiOS/Pods-KaMPKitiOS.release.xcconfig"; sourceTree = ""; }; 3C73D0492A335103003E6929 /* BreedsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreedsViewModel.swift; sourceTree = ""; }; 46A5B5EE26AF54F7002EFEAA /* BreedsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreedsScreen.swift; sourceTree = ""; }; + 3C6AEC062A30881B0003F34A /* BreedDetailsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreedDetailsScreen.swift; sourceTree = ""; }; + 3C6AEC082A30C17A0003F34A /* AppNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppNavigationController.swift; sourceTree = ""; }; 46A5B60726B04920002EFEAA /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 46B5284C249C5CF400A7725D /* Koin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Koin.swift; sourceTree = ""; }; B859F3FB23133D22AB9DD835 /* Pods_KaMPKitiOS.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_KaMPKitiOS.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -149,6 +153,8 @@ F1465F0923AA94BF0055F7C3 /* Assets.xcassets */, F1465F0B23AA94BF0055F7C3 /* LaunchScreen.storyboard */, F1465F0E23AA94BF0055F7C3 /* Info.plist */, + 3C6AEC062A30881B0003F34A /* BreedDetailsScreen.swift */, + 3C6AEC082A30C17A0003F34A /* AppNavigationController.swift */, ); path = KaMPKitiOS; sourceTree = ""; @@ -376,6 +382,8 @@ 3C73D04A2A335103003E6929 /* BreedsViewModel.swift in Sources */, 46A5B5EF26AF54F7002EFEAA /* BreedsScreen.swift in Sources */, F1465F0123AA94BF0055F7C3 /* AppDelegate.swift in Sources */, + 3C6AEC072A30881B0003F34A /* BreedDetailsScreen.swift in Sources */, + 3C6AEC092A30C17A0003F34A /* AppNavigationController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -465,7 +473,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "Apple Development: brady.aiello@gmail.com (94U525PPDD)"; COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 6A5MWU525T; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; diff --git a/ios/KaMPKitiOS/AppDelegate.swift b/ios/KaMPKitiOS/AppDelegate.swift index 139d7889..0bbace75 100644 --- a/ios/KaMPKitiOS/AppDelegate.swift +++ b/ios/KaMPKitiOS/AppDelegate.swift @@ -22,10 +22,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate { startKoin() - let viewController = UIHostingController(rootView: BreedsScreen()) + let navController = AppNavigationController() + navController.toBreeds() self.window = UIWindow(frame: UIScreen.main.bounds) - self.window?.rootViewController = viewController + self.window?.rootViewController = navController self.window?.makeKeyAndVisible() log.v(message: {"App Started"}) diff --git a/ios/KaMPKitiOS/AppNavigationController.swift b/ios/KaMPKitiOS/AppNavigationController.swift new file mode 100644 index 00000000..05fa256a --- /dev/null +++ b/ios/KaMPKitiOS/AppNavigationController.swift @@ -0,0 +1,29 @@ +// +// AppNavigationController.swift +// KaMPKitiOS +// +// Created by Bartłomiej Pedryc on 07/06/2023. +// Copyright © 2023 Touchlab. All rights reserved. +// + +import SwiftUI +import Foundation + +class AppNavigationController: UINavigationController { + func toBreeds() { + pushViewController( + UIHostingController( + rootView: BreedListScreen(observableModel: ObservableBreedModel(navigationController: self)) + ), + animated: false + ) + } + func toBreedDetails(breedId: Int64) { + pushViewController( + UIHostingController( + rootView: BreedDetailsScreen(observableModel: ObservableBreedDetailsModel(breedId: breedId)) + ), + animated: false + ) + } +} diff --git a/ios/KaMPKitiOS/BreedDetailsScreen.swift b/ios/KaMPKitiOS/BreedDetailsScreen.swift new file mode 100644 index 00000000..c3cb1367 --- /dev/null +++ b/ios/KaMPKitiOS/BreedDetailsScreen.swift @@ -0,0 +1,67 @@ +// +// BreedDetailsScreen.swift +// KaMPKitiOS +// +// Created by Bartłomiej Pedryc on 07/06/2023. +// Copyright © 2023 Touchlab. All rights reserved. +// + +import Combine +import SwiftUI +import shared +import KMPNativeCoroutinesCombine +import Foundation + +class ObservableBreedDetailsModel: ObservableObject { + private var viewModel: BreedDetailsViewModel + + init(breedId: Int64) { + self.viewModel = KotlinDependencies.shared.getBreedDetailsViewModel(breedId: breedId) + } + + @Published + var breed: Breed? + + private var cancellables = [AnyCancellable]() + + func activate() { + createPublisher(for: viewModel.detailsStateFlow) + .sink { _ in } receiveValue: { [weak self] (detailsState: BreedDetailsViewState) in + if let breed = detailsState.breed { self?.breed = breed } + } + .store(in: &cancellables) + } + + func deactivate() { + cancellables.forEach { $0.cancel() } + cancellables.removeAll() + } + + deinit { + viewModel.clear() + } +} + +struct BreedDetailsScreen: View { + @StateObject + var observableModel: ObservableBreedDetailsModel + + var body: some View { + BreedDetailsContent( + breedName: observableModel.breed?.name ?? "" + ) + .onAppear(perform: { + observableModel.activate() + }) + .onDisappear(perform: { + observableModel.deactivate() + }) + } +} + +struct BreedDetailsContent: View { + var breedName: String + var body: some View { + Text(breedName) + } +} diff --git a/ios/KaMPKitiOS/BreedListScreen.swift b/ios/KaMPKitiOS/BreedListScreen.swift new file mode 100644 index 00000000..54b219ee --- /dev/null +++ b/ios/KaMPKitiOS/BreedListScreen.swift @@ -0,0 +1,155 @@ +// +// BreedListView.swift +// KaMPKitiOS +// +// Created by Russell Wolf on 7/26/21. +// Copyright © 2021 Touchlab. All rights reserved. +// + +import Combine +import SwiftUI +import shared +import KMPNativeCoroutinesCombine + +private let log = koin.loggerWithTag(tag: "ViewController") + +class ObservableBreedModel: ObservableObject { + private var navigationController: AppNavigationController + private var viewModel: BreedsViewModel = KotlinDependencies.shared.getBreedsViewModel() + init(navigationController: AppNavigationController) { + self.navigationController = navigationController + } + + @Published + var loading = false + + @Published + var breeds: [Breed]? + + @Published + var error: String? + + private var cancellables = [AnyCancellable]() + + deinit { + viewModel.clear() + } + + func activate() { + createPublisher(for: viewModel.breedStateFlow) + .sink { _ in } receiveValue: { [weak self] (breedState: BreedViewState) in + self?.loading = breedState.isLoading + self?.breeds = breedState.breeds + self?.error = breedState.error + + if let navIntent = breedState.navigationIntent as? NavigationIntent.ToDetails { + self?.navigationController.toBreedDetails(breedId: navIntent.breedId) + self?.viewModel.onNavigationCompleted() + } + + if let breeds = breedState.breeds { + log.d(message: {"View updating with \(breeds.count) breeds"}) + } + if let errorMessage = breedState.error { + log.e(message: {"Displaying error: \(errorMessage)"}) + } + } + .store(in: &cancellables) + } + + func deactivate() { + cancellables.forEach { $0.cancel() } + cancellables.removeAll() + } + + func onBreedFavorite(_ breed: Breed) { + viewModel.updateBreedFavorite(breed: breed) + } + + func refresh() { + viewModel.refreshBreeds() + } +} + +struct BreedListScreen: View { + @StateObject + var observableModel: ObservableBreedModel + + var body: some View { + BreedListContent( + loading: observableModel.loading, + breeds: observableModel.breeds, + error: observableModel.error, + onBreedFavorite: { observableModel.onBreedFavorite($0) }, + refresh: { observableModel.refresh() } + ) + .onAppear(perform: { + observableModel.activate() + }) + .onDisappear(perform: { + observableModel.deactivate() + }) + } +} + +struct BreedListContent: View { + var loading: Bool + var breeds: [Breed]? + var error: String? + var onBreedFavorite: (Breed) -> Void + var refresh: () -> Void + + var body: some View { + ZStack { + VStack { + if let breeds = breeds { + List(breeds, id: \.id) { breed in + BreedRowView(breed: breed) { + onBreedFavorite(breed) + } + } + } + if let error = error { + Text(error) + .foregroundColor(.red) + } + Button("Refresh") { + refresh() + } + } + if loading { Text("Loading...") } + } + } +} + +struct BreedRowView: View { + var breed: Breed + var onTap: () -> Void + + var body: some View { + Button(action: onTap) { + HStack { + Text(breed.name) + .padding(4.0) + Spacer() + Image(systemName: (!breed.favorite) ? "heart" : "heart.fill") + .padding(4.0) + } + } + } +} + +struct BreedListScreen_Previews: PreviewProvider { + static var previews: some View { + BreedListContent( + loading: false, + breeds: [ + Breed(id: 0, name: "appenzeller", favorite: false), + Breed(id: 1, name: "australian", favorite: true) + ], + error: nil, + onBreedFavorite: { _ in }, + refresh: {} + ) + } +} diff --git a/shared/src/androidMain/kotlin/co/touchlab/kampkit/core/ViewModel.kt b/shared/src/androidMain/kotlin/co/touchlab/kampkit/core/ViewModel.kt index a5cb3267..595a7dbf 100644 --- a/shared/src/androidMain/kotlin/co/touchlab/kampkit/core/ViewModel.kt +++ b/shared/src/androidMain/kotlin/co/touchlab/kampkit/core/ViewModel.kt @@ -1,13 +1,13 @@ package co.touchlab.kampkit.core +import androidx.lifecycle.ViewModel import kotlinx.coroutines.CoroutineScope -import androidx.lifecycle.ViewModel as AndroidXViewModel import androidx.lifecycle.viewModelScope as androidXViewModelScope -actual abstract class ViewModel actual constructor() : AndroidXViewModel() { +actual abstract class ViewModel actual constructor() : ViewModel() { actual val viewModelScope: CoroutineScope = androidXViewModelScope actual override fun onCleared() { super.onCleared() } -} +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/core/Koin.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/core/Koin.kt index 5bd590b6..7296ac48 100644 --- a/shared/src/commonMain/kotlin/co/touchlab/kampkit/core/Koin.kt +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/core/Koin.kt @@ -5,6 +5,10 @@ import co.touchlab.kampkit.data.dog.DogApiImpl import co.touchlab.kampkit.data.dog.DogDatabaseHelper import co.touchlab.kampkit.data.dog.NetworkBreedRepository import co.touchlab.kampkit.domain.breed.BreedRepository +import co.touchlab.kampkit.data.dog.DogApi +import co.touchlab.kampkit.data.dog.DogApiImpl +import co.touchlab.kampkit.data.dog.DogDatabaseHelper +import co.touchlab.kampkit.data.dog.DogRepository import co.touchlab.kermit.Logger import co.touchlab.kermit.StaticConfig import co.touchlab.kermit.platformLogWriter diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/core/ViewModel.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/core/ViewModel.kt index 4e9aab7c..d0eb076b 100644 --- a/shared/src/commonMain/kotlin/co/touchlab/kampkit/core/ViewModel.kt +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/core/ViewModel.kt @@ -5,4 +5,4 @@ import kotlinx.coroutines.CoroutineScope expect abstract class ViewModel() { val viewModelScope: CoroutineScope protected open fun onCleared() -} +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/data/dog/DogDatabaseHelper.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/data/dog/DogDatabaseHelper.kt index 730af8ac..ca111eef 100644 --- a/shared/src/commonMain/kotlin/co/touchlab/kampkit/data/dog/DogDatabaseHelper.kt +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/data/dog/DogDatabaseHelper.kt @@ -1,5 +1,7 @@ package co.touchlab.kampkit.data.dog +import co.touchlab.kampkit.core.transactionWithContext +import co.touchlab.kampkit.db.Breed import co.touchlab.kampkit.core.transactionWithContext import co.touchlab.kampkit.db.DbBreed import co.touchlab.kampkit.db.KaMPKitDb @@ -7,6 +9,7 @@ import co.touchlab.kermit.Logger import com.squareup.sqldelight.db.SqlDriver import com.squareup.sqldelight.runtime.coroutines.asFlow import com.squareup.sqldelight.runtime.coroutines.mapToList +import com.squareup.sqldelight.runtime.coroutines.mapToOneOrNull import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow @@ -35,11 +38,11 @@ class DogDatabaseHelper( } } - fun selectById(id: Long): Flow> = + fun selectById(id: Long): Flow = dbRef.tableQueries .selectById(id) .asFlow() - .mapToList(Dispatchers.Default) + .mapToOneOrNull(Dispatchers.Default) .flowOn(backgroundDispatcher) suspend fun deleteAll() { diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/data/dog/DogRepository.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/data/dog/DogRepository.kt new file mode 100644 index 00000000..c51b3bc3 --- /dev/null +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/data/dog/DogRepository.kt @@ -0,0 +1,63 @@ +package co.touchlab.kampkit.data.dog + +import co.touchlab.kampkit.db.Breed +import co.touchlab.kermit.Logger +import co.touchlab.stately.ensureNeverFrozen +import com.russhwolf.settings.Settings +import kotlinx.coroutines.flow.Flow +import kotlinx.datetime.Clock + +class DogRepository( + private val dbHelper: DogDatabaseHelper, + private val settings: Settings, + private val dogApi: DogApi, + log: Logger, + private val clock: Clock +) { + + private val log = log.withTag("BreedModel") + + companion object { + internal const val DB_TIMESTAMP_KEY = "DbTimestampKey" + } + + init { + ensureNeverFrozen() + } + + fun getBreed(id: Long): Flow = dbHelper.selectById(id) + + fun getBreeds(): Flow> = dbHelper.selectAllItems() + + suspend fun refreshBreedsIfStale() { + if (isBreedListStale()) { + refreshBreeds() + } + } + + suspend fun refreshBreeds() { + val breedResult = dogApi.getJsonFromApi() + log.v { "Breed network result: ${breedResult.status}" } + val breedList = breedResult.message.keys.sorted().toList() + log.v { "Fetched ${breedList.size} breeds from network" } + settings.putLong(DB_TIMESTAMP_KEY, clock.now().toEpochMilliseconds()) + + if (breedList.isNotEmpty()) { + dbHelper.insertBreeds(breedList) + } + } + + suspend fun updateBreedFavorite(breed: Breed) { + dbHelper.updateFavorite(breed.id, !breed.favorite) + } + + private fun isBreedListStale(): Boolean { + val lastDownloadTimeMS = settings.getLong(DB_TIMESTAMP_KEY, 0) + val oneHourMS = 60 * 60 * 1000 + val stale = lastDownloadTimeMS + oneHourMS < clock.now().toEpochMilliseconds() + if (!stale) { + log.i { "Breeds not fetched from network. Recently updated" } + } + return stale + } +} diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDetailsViewModel.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDetailsViewModel.kt new file mode 100644 index 00000000..6365145b --- /dev/null +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDetailsViewModel.kt @@ -0,0 +1,67 @@ +package co.touchlab.kampkit.ui.breedDetails + +import co.touchlab.kampkit.core.ViewModel +import co.touchlab.kampkit.data.dog.DogRepository +import co.touchlab.kampkit.db.Breed +import co.touchlab.kermit.Logger +import com.rickclephas.kmp.nativecoroutines.NativeCoroutinesState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class BreedDetailsViewModel( + private val breedId: Long, + private val dogRepository: DogRepository, + log: Logger +) : ViewModel() { + private val log = log.withTag("BreedDetailsViewModel") + + private val mutableDetailsState: MutableStateFlow = + MutableStateFlow(BreedDetailsViewState(isLoading = true)) + + @NativeCoroutinesState + val detailsState: StateFlow = mutableDetailsState + + init { + observeDetails() + } + + private fun observeDetails() { + // Refresh breeds, and emit any exception that was thrown so we can handle it downstream + val refreshFlow = flow { + try { + dogRepository.refreshBreedsIfStale() + emit(null) + } catch (exception: Exception) { + emit(exception) + } + } + + viewModelScope.launch { + combine(refreshFlow, dogRepository.getBreed(breedId)) { throwable, breed -> throwable to breed } + .collect { (error, breed) -> + mutableDetailsState.update { previousState -> + val errorMessage = if (error != null) { + "Unable to download breed details" + } else { + previousState.error + } + previousState.copy( + isLoading = false, + breed = breed, + error = errorMessage.takeIf { breed != null }, + ) + } + } + } + } +} + +data class BreedDetailsViewState( + val breed: Breed? = null, + val error: String? = null, + val isLoading: Boolean = false, +) diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breeds/BreedsViewModel.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breeds/BreedsViewModel.kt index 33897c9b..fc322d99 100644 --- a/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breeds/BreedsViewModel.kt +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breeds/BreedsViewModel.kt @@ -1,10 +1,14 @@ package co.touchlab.kampkit.ui.breeds +import co.touchlab.kampkit.db.Breed +import co.touchlab.kampkit.core.ViewModel +import co.touchlab.kampkit.data.dog.DogRepository import co.touchlab.kampkit.core.ViewModel import co.touchlab.kampkit.domain.breed.BreedRepository import co.touchlab.kermit.Logger import com.rickclephas.kmp.nativecoroutines.NativeCoroutinesState import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine @@ -30,6 +34,16 @@ class BreedsViewModel( observeBreeds() } + override fun onCleared() { + log.v("Clearing BreedViewModel") + } + + fun onNavigationCompleted() { + mutableBreedState.update { + it.copy(navigationIntent = null) + } + } + private fun observeBreeds() { // Refresh breeds, and emit any exception that was thrown so we can handle it downstream val refreshFlow = flow { @@ -50,7 +64,7 @@ class BreedsViewModel( } else { previousState.error } - BreedsViewState( + previousState.copy( isLoading = false, breeds = breeds, error = errorMessage.takeIf { breeds.isEmpty() }, @@ -76,7 +90,9 @@ class BreedsViewModel( fun updateBreedFavorite(breedId: Long): Job { return viewModelScope.launch { - breedRepository.updateBreedFavorite(breedId) + mutableBreedState.update { + it.copy(navigationIntent = NavigationIntent.ToDetails(breed.id)) + } } } @@ -92,3 +108,24 @@ class BreedsViewModel( } } } + +data class BreedViewState( + val breeds: List = emptyList(), + val error: String? = null, + val isLoading: Boolean = false, + val isEmpty: Boolean = false, + val navigationIntent: NavigationIntent? = null +) { + companion object { + // This method lets you use the default constructor values in Swift. When accessing the + // constructor directly, they will not work there and would need to be provided explicitly. + fun default() = BreedViewState() + } +} + +) + +sealed class NavigationIntent { + data class ToDetails(val breedId: Long) : NavigationIntent() +} + diff --git a/shared/src/commonTest/kotlin/co/touchlab/kampkit/BreedsViewModelTest.kt b/shared/src/commonTest/kotlin/co/touchlab/kampkit/BreedsViewModelTest.kt index 36f83861..a4dea13c 100644 --- a/shared/src/commonTest/kotlin/co/touchlab/kampkit/BreedsViewModelTest.kt +++ b/shared/src/commonTest/kotlin/co/touchlab/kampkit/BreedsViewModelTest.kt @@ -95,7 +95,8 @@ class BreedsViewModelTest { settings.putLong(NetworkBreedRepository.DB_TIMESTAMP_KEY, clock.currentInstant.toEpochMilliseconds()) val successResult = ktorApi.successResult() - val resultWithExtraBreed = successResult.copy(message = successResult.message + ("extra" to emptyList())) + val resultWithExtraBreed = + successResult.copy(message = successResult.message + ("extra" to emptyList())) ktorApi.prepareResult(resultWithExtraBreed) dbHelper.insertBreeds(breedNames) @@ -119,7 +120,8 @@ class BreedsViewModelTest { settings.putLong(NetworkBreedRepository.DB_TIMESTAMP_KEY, (clock.currentInstant - 2.hours).toEpochMilliseconds()) val successResult = ktorApi.successResult() - val resultWithExtraBreed = successResult.copy(message = successResult.message + ("extra" to emptyList())) + val resultWithExtraBreed = + successResult.copy(message = successResult.message + ("extra" to emptyList())) ktorApi.prepareResult(resultWithExtraBreed) dbHelper.insertBreeds(breedNames) diff --git a/shared/src/commonTest/kotlin/co/touchlab/kampkit/SqlDelightTest.kt b/shared/src/commonTest/kotlin/co/touchlab/kampkit/SqlDelightTest.kt index 0b72423c..a859f770 100644 --- a/shared/src/commonTest/kotlin/co/touchlab/kampkit/SqlDelightTest.kt +++ b/shared/src/commonTest/kotlin/co/touchlab/kampkit/SqlDelightTest.kt @@ -54,7 +54,7 @@ class SqlDelightTest { val breeds = dbHelper.selectAllItems().first() val firstBreed = breeds.first() dbHelper.updateFavorite(firstBreed.id, true) - val newBreed = dbHelper.selectById(firstBreed.id).first().first() + val newBreed = dbHelper.selectById(firstBreed.id).first() assertNotNull( newBreed, "Could not retrieve Breed by Id" diff --git a/shared/src/iosMain/kotlin/co/touchlab/kampkit/core/KoinIOS.kt b/shared/src/iosMain/kotlin/co/touchlab/kampkit/core/KoinIOS.kt index 74e8c0c0..dfc125ac 100644 --- a/shared/src/iosMain/kotlin/co/touchlab/kampkit/core/KoinIOS.kt +++ b/shared/src/iosMain/kotlin/co/touchlab/kampkit/core/KoinIOS.kt @@ -2,6 +2,8 @@ package co.touchlab.kampkit.core import co.touchlab.kampkit.db.KaMPKitDb import co.touchlab.kampkit.ui.breeds.BreedsViewModel +import co.touchlab.kampkit.ui.breedDetails.BreedDetailsViewModel +import co.touchlab.kampkit.ui.breeds.BreedsViewModel import co.touchlab.kermit.Logger import com.russhwolf.settings.NSUserDefaultsSettings import com.russhwolf.settings.Settings @@ -32,7 +34,9 @@ actual val platformModule = module { single { Darwin.create() } - single { BreedsViewModel(get(), getWith("BreedsViewModel")) } + factory { BreedsViewModel(get(), getWith("BreedsViewModel")) } + + factory { params -> BreedDetailsViewModel(params.get(), get(), getWith("BreedDetailsViewModel")) } } // Access from Swift to create a logger @@ -43,4 +47,5 @@ fun Koin.loggerWithTag(tag: String) = @Suppress("unused") // Called from Swift object KotlinDependencies : KoinComponent { fun getBreedsViewModel() = getKoin().get() + fun getBreedDetailsViewModel(breedId: Long) = getKoin().get { parametersOf(breedId) } } diff --git a/shared/src/iosMain/kotlin/co/touchlab/kampkit/core/ViewModel.kt b/shared/src/iosMain/kotlin/co/touchlab/kampkit/core/ViewModel.kt index ef4ba26e..abfa5c9f 100644 --- a/shared/src/iosMain/kotlin/co/touchlab/kampkit/core/ViewModel.kt +++ b/shared/src/iosMain/kotlin/co/touchlab/kampkit/core/ViewModel.kt @@ -29,4 +29,4 @@ actual abstract class ViewModel { onCleared() viewModelScope.cancel() } -} +} \ No newline at end of file From 62d1a20c39fd1a95ad03db51625bc32100cf377f Mon Sep 17 00:00:00 2001 From: bpedryc Date: Fri, 16 Jun 2023 10:47:01 +0200 Subject: [PATCH 02/18] Improve the structure of BreedDetails code --- .../kampkit/android/ui/BreedDetailsScreen.kt | 14 +++++- .../ui/breedDetails/BreedDetailsViewModel.kt | 46 +++++-------------- .../ui/breedDetails/BreedDetailsViewState.kt | 7 +++ .../ui/breedDetails/BreedDisplayable.kt | 15 ++++++ 4 files changed, 47 insertions(+), 35 deletions(-) create mode 100644 shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDetailsViewState.kt create mode 100644 shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDisplayable.kt diff --git a/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedDetailsScreen.kt b/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedDetailsScreen.kt index 255b7a87..a552b2d4 100644 --- a/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedDetailsScreen.kt +++ b/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedDetailsScreen.kt @@ -1,8 +1,13 @@ package co.touchlab.kampkit.android.ui +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.lifecycle.compose.collectAsStateWithLifecycle import co.touchlab.kampkit.ui.breedDetails.BreedDetailsViewModel import co.touchlab.kermit.Logger @@ -13,6 +18,13 @@ fun BreedDetailsScreen( log: Logger ) { val state by viewModel.detailsState.collectAsStateWithLifecycle() + Box(Modifier.fillMaxSize()) { + state.error?.let { error -> + Text(error, Modifier.align(Alignment.Center), color = Color.Red) + } + if (state.error == null) { + Text(state.breed.name, Modifier.align(Alignment.Center)) + } + } - Text("${state.breed?.name }") } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDetailsViewModel.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDetailsViewModel.kt index 6365145b..ed5f07d4 100644 --- a/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDetailsViewModel.kt +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDetailsViewModel.kt @@ -2,13 +2,10 @@ package co.touchlab.kampkit.ui.breedDetails import co.touchlab.kampkit.core.ViewModel import co.touchlab.kampkit.data.dog.DogRepository -import co.touchlab.kampkit.db.Breed import co.touchlab.kermit.Logger import com.rickclephas.kmp.nativecoroutines.NativeCoroutinesState import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -26,42 +23,23 @@ class BreedDetailsViewModel( val detailsState: StateFlow = mutableDetailsState init { - observeDetails() + loadDetails() } - private fun observeDetails() { - // Refresh breeds, and emit any exception that was thrown so we can handle it downstream - val refreshFlow = flow { - try { - dogRepository.refreshBreedsIfStale() - emit(null) - } catch (exception: Exception) { - emit(exception) - } - } - + private fun loadDetails() { viewModelScope.launch { - combine(refreshFlow, dogRepository.getBreed(breedId)) { throwable, breed -> throwable to breed } - .collect { (error, breed) -> - mutableDetailsState.update { previousState -> - val errorMessage = if (error != null) { - "Unable to download breed details" - } else { - previousState.error - } - previousState.copy( - isLoading = false, - breed = breed, - error = errorMessage.takeIf { breed != null }, - ) - } + dogRepository.getBreed(breedId).collect { breed -> + mutableDetailsState.update { previousState -> + val error = if (breed == null) "Couldn't load the breed details" else null + val newBreed = breed?.toDisplayable() ?: previousState.breed + previousState.copy( + isLoading = false, + breed = newBreed, + error = error + ) } + } } } } -data class BreedDetailsViewState( - val breed: Breed? = null, - val error: String? = null, - val isLoading: Boolean = false, -) diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDetailsViewState.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDetailsViewState.kt new file mode 100644 index 00000000..ec486562 --- /dev/null +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDetailsViewState.kt @@ -0,0 +1,7 @@ +package co.touchlab.kampkit.ui.breedDetails + +data class BreedDetailsViewState( + val breed: BreedDisplayable = BreedDisplayable(), + val error: String? = null, + val isLoading: Boolean = false, +) diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDisplayable.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDisplayable.kt new file mode 100644 index 00000000..58767ae1 --- /dev/null +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDisplayable.kt @@ -0,0 +1,15 @@ +package co.touchlab.kampkit.ui.breedDetails + +import co.touchlab.kampkit.db.Breed + +data class BreedDisplayable( + val id: Long = 0, + val name: String = "", + val favorite: Boolean = false +) + +fun Breed.toDisplayable() = BreedDisplayable( + this.id, + this.name, + this.favorite +) From 007fa2d4e9f3305b64251beb04c8f74737b3c64e Mon Sep 17 00:00:00 2001 From: bpedryc Date: Fri, 16 Jun 2023 16:17:56 +0200 Subject: [PATCH 03/18] Cleanup the code --- .../touchlab/kampkit/android/MainActivity.kt | 4 +- .../kampkit/android/ui/BreedsScreen.kt | 14 +++---- ...ainNavigation.kt => MainNavCoordinator.kt} | 24 +++++++---- ios/KaMPKitiOS.xcodeproj/project.pbxproj | 9 +++- ios/KaMPKitiOS/AppDelegate.swift | 5 ++- ios/KaMPKitiOS/AppNavigationController.swift | 29 ------------- .../BreedDetailsNavController.swift | 9 ++++ ios/KaMPKitiOS/BreedDetailsScreen.swift | 16 +++---- .../Breeds/BreedsNavController.swift | 13 ++++++ ...eedListScreen.swift => BreedsScreen.swift} | 40 ++++++++++-------- ios/KaMPKitiOS/MainNavCoordinator.swift | 42 +++++++++++++++++++ .../ui/breedDetails/BreedDetailsViewModel.kt | 2 + .../ui/breedDetails/BreedDetailsViewState.kt | 6 ++- .../kampkit/ui/breeds/BreedsNavRequest.kt | 5 +++ .../kampkit/ui/breeds/BreedsViewModel.kt | 17 +++----- 15 files changed, 148 insertions(+), 87 deletions(-) rename app/src/main/kotlin/co/touchlab/kampkit/android/ui/{MainNavigation.kt => MainNavCoordinator.kt} (59%) delete mode 100644 ios/KaMPKitiOS/AppNavigationController.swift create mode 100644 ios/KaMPKitiOS/BreedDetails/BreedDetailsNavController.swift create mode 100644 ios/KaMPKitiOS/Breeds/BreedsNavController.swift rename ios/KaMPKitiOS/{BreedListScreen.swift => BreedsScreen.swift} (76%) create mode 100644 ios/KaMPKitiOS/MainNavCoordinator.swift create mode 100644 shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breeds/BreedsNavRequest.kt diff --git a/app/src/main/kotlin/co/touchlab/kampkit/android/MainActivity.kt b/app/src/main/kotlin/co/touchlab/kampkit/android/MainActivity.kt index fcbac8a6..46e13ed9 100644 --- a/app/src/main/kotlin/co/touchlab/kampkit/android/MainActivity.kt +++ b/app/src/main/kotlin/co/touchlab/kampkit/android/MainActivity.kt @@ -3,7 +3,7 @@ package co.touchlab.kampkit.android import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import co.touchlab.kampkit.android.ui.MainNavigation +import co.touchlab.kampkit.android.ui.MainNavCoordinator import co.touchlab.kampkit.android.ui.theme.KaMPKitTheme import org.koin.core.component.KoinComponent @@ -13,7 +13,7 @@ class MainActivity : ComponentActivity(), KoinComponent { super.onCreate(savedInstanceState) setContent { KaMPKitTheme { - MainNavigation() + MainNavCoordinator() } } } diff --git a/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedsScreen.kt b/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedsScreen.kt index 575b0fa4..87f9c5d0 100644 --- a/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedsScreen.kt +++ b/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedsScreen.kt @@ -35,7 +35,7 @@ import co.touchlab.kampkit.ui.breeds.BreedsViewState import co.touchlab.kampkit.db.Breed import co.touchlab.kampkit.ui.breeds.BreedViewState import co.touchlab.kampkit.ui.breeds.BreedsViewModel -import co.touchlab.kampkit.ui.breeds.NavigationIntent +import co.touchlab.kampkit.ui.breeds.BreedsNavRequest import co.touchlab.kermit.Logger import com.google.accompanist.swiperefresh.SwipeRefresh import com.google.accompanist.swiperefresh.rememberSwipeRefreshState @@ -43,16 +43,16 @@ import com.google.accompanist.swiperefresh.rememberSwipeRefreshState @Composable fun BreedsScreen( viewModel: BreedsViewModel, - onNavigateToDetails: (breedId: Long) -> Unit, + onBreedDetailsNavRequest: (breedId: Long) -> Unit, log: Logger ) { val breedsState by viewModel.breedsState.collectAsStateWithLifecycle() - dogsState.navigationIntent?.let { navIntent -> - LaunchedEffect(navIntent) { - if (navIntent is NavigationIntent.ToDetails) { - onNavigateToDetails(navIntent.breedId) - viewModel.onNavigationCompleted() + dogsState.breedsNavRequest?.let { navRequest -> + LaunchedEffect(navRequest) { + if (navRequest is BreedsNavRequest.ToDetails) { + onBreedDetailsNavRequest(navRequest.breedId) + viewModel.onBreedDetailsNavRequestCompleted() } } } diff --git a/app/src/main/kotlin/co/touchlab/kampkit/android/ui/MainNavigation.kt b/app/src/main/kotlin/co/touchlab/kampkit/android/ui/MainNavCoordinator.kt similarity index 59% rename from app/src/main/kotlin/co/touchlab/kampkit/android/ui/MainNavigation.kt rename to app/src/main/kotlin/co/touchlab/kampkit/android/ui/MainNavCoordinator.kt index b5749ace..4ec264a3 100644 --- a/app/src/main/kotlin/co/touchlab/kampkit/android/ui/MainNavigation.kt +++ b/app/src/main/kotlin/co/touchlab/kampkit/android/ui/MainNavCoordinator.kt @@ -1,6 +1,7 @@ package co.touchlab.kampkit.android.ui import androidx.compose.runtime.Composable + import androidx.navigation.NavController import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable @@ -10,24 +11,29 @@ import org.koin.androidx.compose.get import org.koin.androidx.compose.koinViewModel import org.koin.core.parameter.parametersOf +private const val BREEDS = "breeds" + +private const val BREED_DETAILS = "breedDetails" +private const val BREED_ID_ARG = "breedId" + @Composable -fun MainNavigation() { +fun MainNavCoordinator() { val navController = rememberNavController() NavHost(navController = navController, startDestination = "breeds") { - composable("breeds") { + composable(BREEDS) { BreedsScreen( viewModel = koinViewModel(), - onNavigateToDetails = { breedId -> - navController.navigate("breedDetails/$breedId") + onBreedDetailsNavRequest = { breedId -> + navController.navigateToBreedDetails(breedId) }, log = get { parametersOf("BreedsScreen") } ) } composable( - route = "breedDetails/{breedId}", - arguments = listOf(navArgument("breedId") { type = NavType.LongType }) + route = "$BREED_DETAILS/{$BREED_ID_ARG}", + arguments = listOf(navArgument(BREED_ID_ARG) { type = NavType.LongType }) ) { - val breedId = it.arguments?.getLong("breedId") + val breedId = it.arguments?.getLong(BREED_ID_ARG) BreedDetailsScreen( viewModel = koinViewModel { parametersOf(breedId) }, log = get { parametersOf("BreedDetailsScreen") } @@ -35,3 +41,7 @@ fun MainNavigation() { } } } + +private fun NavController.navigateToBreedDetails(breedId: Long) { + navigate("$BREED_DETAILS/$breedId") +} diff --git a/ios/KaMPKitiOS.xcodeproj/project.pbxproj b/ios/KaMPKitiOS.xcodeproj/project.pbxproj index eef8b4ef..9e67bd88 100644 --- a/ios/KaMPKitiOS.xcodeproj/project.pbxproj +++ b/ios/KaMPKitiOS.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 3C6AEC072A30881B0003F34A /* BreedDetailsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C6AEC062A30881B0003F34A /* BreedDetailsScreen.swift */; }; 3C6AEC092A30C17A0003F34A /* AppNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C6AEC082A30C17A0003F34A /* AppNavigationController.swift */; }; 3C73D04A2A335103003E6929 /* BreedsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C73D0492A335103003E6929 /* BreedsViewModel.swift */; }; + 3C6AEC092A30C17A0003F34A /* MainNavCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C6AEC082A30C17A0003F34A /* MainNavCoordinator.swift */; }; 3CD290EC2A251417004C7AD1 /* KMPNativeCoroutinesCombine in Frameworks */ = {isa = PBXBuildFile; productRef = 3CD290EB2A251417004C7AD1 /* KMPNativeCoroutinesCombine */; }; 3CD290EE2A251417004C7AD1 /* KMPNativeCoroutinesCore in Frameworks */ = {isa = PBXBuildFile; productRef = 3CD290ED2A251417004C7AD1 /* KMPNativeCoroutinesCore */; }; 3DFF917C64A18A83DA010EE1 /* Pods_KaMPKitiOS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B859F3FB23133D22AB9DD835 /* Pods_KaMPKitiOS.framework */; }; @@ -47,6 +48,8 @@ 46A5B5EE26AF54F7002EFEAA /* BreedsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreedsScreen.swift; sourceTree = ""; }; 3C6AEC062A30881B0003F34A /* BreedDetailsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreedDetailsScreen.swift; sourceTree = ""; }; 3C6AEC082A30C17A0003F34A /* AppNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppNavigationController.swift; sourceTree = ""; }; + 3C6AEC082A30C17A0003F34A /* MainNavCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainNavCoordinator.swift; sourceTree = ""; }; + 46A5B5EE26AF54F7002EFEAA /* BreedsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreedsScreen.swift; sourceTree = ""; }; 46A5B60726B04920002EFEAA /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 46B5284C249C5CF400A7725D /* Koin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Koin.swift; sourceTree = ""; }; B859F3FB23133D22AB9DD835 /* Pods_KaMPKitiOS.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_KaMPKitiOS.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -147,6 +150,8 @@ isa = PBXGroup; children = ( 3C73D0482A335061003E6929 /* Breeds */, + 3C6AEC062A30881B0003F34A /* BreedDetailsScreen.swift */, + 46A5B5EE26AF54F7002EFEAA /* BreedsScreen.swift */, F1465F0023AA94BF0055F7C3 /* AppDelegate.swift */, 46A5B60626B04920002EFEAA /* Main.storyboard */, 46B5284C249C5CF400A7725D /* Koin.swift */, @@ -155,6 +160,7 @@ F1465F0E23AA94BF0055F7C3 /* Info.plist */, 3C6AEC062A30881B0003F34A /* BreedDetailsScreen.swift */, 3C6AEC082A30C17A0003F34A /* AppNavigationController.swift */, + 3C6AEC082A30C17A0003F34A /* MainNavCoordinator.swift */, ); path = KaMPKitiOS; sourceTree = ""; @@ -381,9 +387,10 @@ 46B5284D249C5CF400A7725D /* Koin.swift in Sources */, 3C73D04A2A335103003E6929 /* BreedsViewModel.swift in Sources */, 46A5B5EF26AF54F7002EFEAA /* BreedsScreen.swift in Sources */, + 46A5B5EF26AF54F7002EFEAA /* BreedsScreen.swift in Sources */, F1465F0123AA94BF0055F7C3 /* AppDelegate.swift in Sources */, 3C6AEC072A30881B0003F34A /* BreedDetailsScreen.swift in Sources */, - 3C6AEC092A30C17A0003F34A /* AppNavigationController.swift in Sources */, + 3C6AEC092A30C17A0003F34A /* MainNavCoordinator.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/KaMPKitiOS/AppDelegate.swift b/ios/KaMPKitiOS/AppDelegate.swift index 0bbace75..ddcf29af 100644 --- a/ios/KaMPKitiOS/AppDelegate.swift +++ b/ios/KaMPKitiOS/AppDelegate.swift @@ -22,8 +22,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { startKoin() - let navController = AppNavigationController() - navController.toBreeds() + let navController = UINavigationController() + let mainCoordinator = MainNavCoordinator(navController: navController) + mainCoordinator.start() self.window = UIWindow(frame: UIScreen.main.bounds) self.window?.rootViewController = navController diff --git a/ios/KaMPKitiOS/AppNavigationController.swift b/ios/KaMPKitiOS/AppNavigationController.swift deleted file mode 100644 index 05fa256a..00000000 --- a/ios/KaMPKitiOS/AppNavigationController.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// AppNavigationController.swift -// KaMPKitiOS -// -// Created by Bartłomiej Pedryc on 07/06/2023. -// Copyright © 2023 Touchlab. All rights reserved. -// - -import SwiftUI -import Foundation - -class AppNavigationController: UINavigationController { - func toBreeds() { - pushViewController( - UIHostingController( - rootView: BreedListScreen(observableModel: ObservableBreedModel(navigationController: self)) - ), - animated: false - ) - } - func toBreedDetails(breedId: Int64) { - pushViewController( - UIHostingController( - rootView: BreedDetailsScreen(observableModel: ObservableBreedDetailsModel(breedId: breedId)) - ), - animated: false - ) - } -} diff --git a/ios/KaMPKitiOS/BreedDetails/BreedDetailsNavController.swift b/ios/KaMPKitiOS/BreedDetails/BreedDetailsNavController.swift new file mode 100644 index 00000000..013b8d05 --- /dev/null +++ b/ios/KaMPKitiOS/BreedDetails/BreedDetailsNavController.swift @@ -0,0 +1,9 @@ +// +// BreedDetailsNavController.swift +// KaMPKitiOS +// +// Created by Bartłomiej Pedryc on 16/06/2023. +// Copyright © 2023 Touchlab. All rights reserved. +// + +import Foundation diff --git a/ios/KaMPKitiOS/BreedDetailsScreen.swift b/ios/KaMPKitiOS/BreedDetailsScreen.swift index c3cb1367..0b2dc87c 100644 --- a/ios/KaMPKitiOS/BreedDetailsScreen.swift +++ b/ios/KaMPKitiOS/BreedDetailsScreen.swift @@ -12,22 +12,22 @@ import shared import KMPNativeCoroutinesCombine import Foundation -class ObservableBreedDetailsModel: ObservableObject { - private var viewModel: BreedDetailsViewModel +class BreedDetailsViewModel: ObservableObject { + private var viewModel: BreedDetailsViewModelDelegate init(breedId: Int64) { self.viewModel = KotlinDependencies.shared.getBreedDetailsViewModel(breedId: breedId) } @Published - var breed: Breed? + var detailsState: BreedDetailsViewState = BreedDetailsViewState.companion.default() private var cancellables = [AnyCancellable]() func activate() { createPublisher(for: viewModel.detailsStateFlow) .sink { _ in } receiveValue: { [weak self] (detailsState: BreedDetailsViewState) in - if let breed = detailsState.breed { self?.breed = breed } + self?.detailsState = detailsState } .store(in: &cancellables) } @@ -44,17 +44,17 @@ class ObservableBreedDetailsModel: ObservableObject { struct BreedDetailsScreen: View { @StateObject - var observableModel: ObservableBreedDetailsModel + var viewModel: BreedDetailsViewModel var body: some View { BreedDetailsContent( - breedName: observableModel.breed?.name ?? "" + breedName: viewModel.detailsState.breed.name ) .onAppear(perform: { - observableModel.activate() + viewModel.activate() }) .onDisappear(perform: { - observableModel.deactivate() + viewModel.deactivate() }) } } diff --git a/ios/KaMPKitiOS/Breeds/BreedsNavController.swift b/ios/KaMPKitiOS/Breeds/BreedsNavController.swift new file mode 100644 index 00000000..9ac7f21a --- /dev/null +++ b/ios/KaMPKitiOS/Breeds/BreedsNavController.swift @@ -0,0 +1,13 @@ +// +// BreedsNavController.swift +// KaMPKitiOS +// +// Created by Bartłomiej Pedryc on 16/06/2023. +// Copyright © 2023 Touchlab. All rights reserved. +// + +import Foundation + +func buildBreedsNavController(navController: BreedsNavController) -> UIViewController { + let viewModel = Kotlin +} diff --git a/ios/KaMPKitiOS/BreedListScreen.swift b/ios/KaMPKitiOS/BreedsScreen.swift similarity index 76% rename from ios/KaMPKitiOS/BreedListScreen.swift rename to ios/KaMPKitiOS/BreedsScreen.swift index 54b219ee..9917ecea 100644 --- a/ios/KaMPKitiOS/BreedListScreen.swift +++ b/ios/KaMPKitiOS/BreedsScreen.swift @@ -13,11 +13,11 @@ import KMPNativeCoroutinesCombine private let log = koin.loggerWithTag(tag: "ViewController") -class ObservableBreedModel: ObservableObject { - private var navigationController: AppNavigationController - private var viewModel: BreedsViewModel = KotlinDependencies.shared.getBreedsViewModel() - init(navigationController: AppNavigationController) { - self.navigationController = navigationController +class BreedsViewModel: ObservableObject { + private var navCoordinator: BreedsNavCoordinator + private var viewModel: BreedsViewModelDelegate = KotlinDependencies.shared.getBreedsViewModel() + init(navCoordinator: BreedsNavCoordinator) { + self.navCoordinator = navCoordinator } @Published @@ -42,11 +42,8 @@ class ObservableBreedModel: ObservableObject { self?.breeds = breedState.breeds self?.error = breedState.error - if let navIntent = breedState.navigationIntent as? NavigationIntent.ToDetails { - self?.navigationController.toBreedDetails(breedId: navIntent.breedId) - self?.viewModel.onNavigationCompleted() - } - + self?.handleNavRequests(breedsState: breedState) + if let breeds = breedState.breeds { log.d(message: {"View updating with \(breeds.count) breeds"}) } @@ -57,6 +54,13 @@ class ObservableBreedModel: ObservableObject { .store(in: &cancellables) } + private func handleNavRequests(breedsState: BreedViewState) { + if let navRequest = breedsState.breedsNavRequest as? BreedsNavRequest.ToDetails { + self.navCoordinator.onBreedDetailsRequest(breedId: navRequest.breedId) + self.viewModel.onBreedDetailsNavRequestCompleted() + } + } + func deactivate() { cancellables.forEach { $0.cancel() } cancellables.removeAll() @@ -73,21 +77,21 @@ class ObservableBreedModel: ObservableObject { struct BreedListScreen: View { @StateObject - var observableModel: ObservableBreedModel + var viewModel: BreedsViewModel var body: some View { BreedListContent( - loading: observableModel.loading, - breeds: observableModel.breeds, - error: observableModel.error, - onBreedFavorite: { observableModel.onBreedFavorite($0) }, - refresh: { observableModel.refresh() } + loading: viewModel.loading, + breeds: viewModel.breeds, + error: viewModel.error, + onBreedFavorite: { viewModel.onBreedFavorite($0) }, + refresh: { viewModel.refresh() } ) .onAppear(perform: { - observableModel.activate() + viewModel.activate() }) .onDisappear(perform: { - observableModel.deactivate() + viewModel.deactivate() }) } } diff --git a/ios/KaMPKitiOS/MainNavCoordinator.swift b/ios/KaMPKitiOS/MainNavCoordinator.swift new file mode 100644 index 00000000..5f7e0c64 --- /dev/null +++ b/ios/KaMPKitiOS/MainNavCoordinator.swift @@ -0,0 +1,42 @@ +// +// AppNavigationController.swift +// KaMPKitiOS +// +// Created by Bartłomiej Pedryc on 07/06/2023. +// Copyright © 2023 Touchlab. All rights reserved. +// + +import SwiftUI +import Foundation + +class MainNavCoordinator: BreedsNavCoordinator { + private let navController: UINavigationController + + init(navController: UINavigationController) { + self.navController = navController + } + + func start() { + let controller = buildBreedsController(navCoordinator: self) + navController.pushViewController(controller, animated: false) + } + + func onBreedDetailsRequest(breedId: Int64) { + let controller = buildBreedDetailsController(breedId: breedId) + navController.pushViewController(controller, animated: true) + } +} + +private func buildBreedsController(navCoordinator: BreedsNavCoordinator) -> UIHostingController { + let viewModel = BreedsViewModel(navCoordinator: navCoordinator) + return UIHostingController(rootView: BreedListScreen(viewModel: viewModel)) +} + +private func buildBreedDetailsController(breedId: Int64) -> UIHostingController { + let viewModel = BreedDetailsViewModel(breedId: breedId) + return UIHostingController(rootView: BreedDetailsScreen(viewModel: viewModel)) +} + +protocol BreedsNavCoordinator { + func onBreedDetailsRequest(breedId: Int64) +} diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDetailsViewModel.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDetailsViewModel.kt index ed5f07d4..a4ee795e 100644 --- a/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDetailsViewModel.kt +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDetailsViewModel.kt @@ -8,7 +8,9 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlin.native.ObjCName +@ObjCName("BreedDetailsViewModelDelegate") class BreedDetailsViewModel( private val breedId: Long, private val dogRepository: DogRepository, diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDetailsViewState.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDetailsViewState.kt index ec486562..fa7667d5 100644 --- a/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDetailsViewState.kt +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDetailsViewState.kt @@ -4,4 +4,8 @@ data class BreedDetailsViewState( val breed: BreedDisplayable = BreedDisplayable(), val error: String? = null, val isLoading: Boolean = false, -) +) { + companion object { + fun default() = BreedDetailsViewState() + } +} diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breeds/BreedsNavRequest.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breeds/BreedsNavRequest.kt new file mode 100644 index 00000000..8132580c --- /dev/null +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breeds/BreedsNavRequest.kt @@ -0,0 +1,5 @@ +package co.touchlab.kampkit.ui.breeds + +sealed class BreedsNavRequest { + data class ToDetails(val breedId: Long) : BreedsNavRequest() +} diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breeds/BreedsViewModel.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breeds/BreedsViewModel.kt index fc322d99..ad84f5ff 100644 --- a/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breeds/BreedsViewModel.kt +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breeds/BreedsViewModel.kt @@ -8,7 +8,6 @@ import co.touchlab.kampkit.domain.breed.BreedRepository import co.touchlab.kermit.Logger import com.rickclephas.kmp.nativecoroutines.NativeCoroutinesState import kotlinx.coroutines.Job -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine @@ -38,9 +37,9 @@ class BreedsViewModel( log.v("Clearing BreedViewModel") } - fun onNavigationCompleted() { + fun onBreedDetailsNavRequestCompleted() { mutableBreedState.update { - it.copy(navigationIntent = null) + it.copy(breedsNavRequest = null) } } @@ -91,8 +90,9 @@ class BreedsViewModel( fun updateBreedFavorite(breedId: Long): Job { return viewModelScope.launch { mutableBreedState.update { - it.copy(navigationIntent = NavigationIntent.ToDetails(breed.id)) + it.copy(breedsNavRequest = BreedsNavRequest.ToDetails(breed.id)) } + dogRepository.updateBreedFavorite(breed) } } @@ -114,7 +114,7 @@ data class BreedViewState( val error: String? = null, val isLoading: Boolean = false, val isEmpty: Boolean = false, - val navigationIntent: NavigationIntent? = null + val breedsNavRequest: BreedsNavRequest? = null ) { companion object { // This method lets you use the default constructor values in Swift. When accessing the @@ -122,10 +122,3 @@ data class BreedViewState( fun default() = BreedViewState() } } - -) - -sealed class NavigationIntent { - data class ToDetails(val breedId: Long) : NavigationIntent() -} - From 265e8ec43ba82a7ea499866304d776f80d677b9f Mon Sep 17 00:00:00 2001 From: bpedryc Date: Thu, 29 Jun 2023 13:21:29 +0200 Subject: [PATCH 04/18] Fix iOS after latest changes --- ios/KaMPKitiOS/Breeds/BreedsViewModel.swift | 12 ++++ ios/KaMPKitiOS/BreedsScreen.swift | 73 ++------------------- ios/KaMPKitiOS/MainNavCoordinator.swift | 4 +- 3 files changed, 20 insertions(+), 69 deletions(-) diff --git a/ios/KaMPKitiOS/Breeds/BreedsViewModel.swift b/ios/KaMPKitiOS/Breeds/BreedsViewModel.swift index 0d19c64a..2d324939 100644 --- a/ios/KaMPKitiOS/Breeds/BreedsViewModel.swift +++ b/ios/KaMPKitiOS/Breeds/BreedsViewModel.swift @@ -15,8 +15,12 @@ class BreedsViewModel: ObservableObject { @Published var state: BreedsViewState = BreedsViewState.companion.default() + private var navCoordinator: BreedsNavCoordinator private var viewModelDelegate: BreedsViewModelDelegate = KotlinDependencies.shared.getBreedsViewModel() private var cancellables = [AnyCancellable]() + init(navCoordinator: BreedsNavCoordinator) { + self.navCoordinator = navCoordinator + } deinit { viewModelDelegate.clear() @@ -26,10 +30,18 @@ class BreedsViewModel: ObservableObject { createPublisher(for: viewModelDelegate.breedsStateFlow) .sink { _ in } receiveValue: { [weak self] (breedState: BreedsViewState) in self?.state = breedState + self?.handleNavRequests(breedsState: breedState) } .store(in: &cancellables) } + private func handleNavRequests(breedsState: BreedViewState) { + if let navRequest = breedsState.breedsNavRequest as? BreedsNavRequest.ToDetails { + self.navCoordinator.onBreedDetailsRequest(breedId: navRequest.breedId) + self.viewModelDelegate.onBreedDetailsNavRequestCompleted() + } + } + func unsubscribeState() { cancellables.forEach { $0.cancel() } cancellables.removeAll() diff --git a/ios/KaMPKitiOS/BreedsScreen.swift b/ios/KaMPKitiOS/BreedsScreen.swift index 9917ecea..281e2ce4 100644 --- a/ios/KaMPKitiOS/BreedsScreen.swift +++ b/ios/KaMPKitiOS/BreedsScreen.swift @@ -13,85 +13,24 @@ import KMPNativeCoroutinesCombine private let log = koin.loggerWithTag(tag: "ViewController") -class BreedsViewModel: ObservableObject { - private var navCoordinator: BreedsNavCoordinator - private var viewModel: BreedsViewModelDelegate = KotlinDependencies.shared.getBreedsViewModel() - init(navCoordinator: BreedsNavCoordinator) { - self.navCoordinator = navCoordinator - } - - @Published - var loading = false - - @Published - var breeds: [Breed]? - - @Published - var error: String? - - private var cancellables = [AnyCancellable]() - - deinit { - viewModel.clear() - } - - func activate() { - createPublisher(for: viewModel.breedStateFlow) - .sink { _ in } receiveValue: { [weak self] (breedState: BreedViewState) in - self?.loading = breedState.isLoading - self?.breeds = breedState.breeds - self?.error = breedState.error - - self?.handleNavRequests(breedsState: breedState) - - if let breeds = breedState.breeds { - log.d(message: {"View updating with \(breeds.count) breeds"}) - } - if let errorMessage = breedState.error { - log.e(message: {"Displaying error: \(errorMessage)"}) - } - } - .store(in: &cancellables) - } - - private func handleNavRequests(breedsState: BreedViewState) { - if let navRequest = breedsState.breedsNavRequest as? BreedsNavRequest.ToDetails { - self.navCoordinator.onBreedDetailsRequest(breedId: navRequest.breedId) - self.viewModel.onBreedDetailsNavRequestCompleted() - } - } - - func deactivate() { - cancellables.forEach { $0.cancel() } - cancellables.removeAll() - } - - func onBreedFavorite(_ breed: Breed) { - viewModel.updateBreedFavorite(breed: breed) - } - - func refresh() { - viewModel.refreshBreeds() - } -} -struct BreedListScreen: View { +struct BreedsScreen: View { @StateObject var viewModel: BreedsViewModel var body: some View { BreedListContent( - loading: viewModel.loading, - breeds: viewModel.breeds, - error: viewModel.error, + loading: viewModel.state.isLoading, + breeds: viewModel.state.breeds, + error: viewModel.state.error, onBreedFavorite: { viewModel.onBreedFavorite($0) }, refresh: { viewModel.refresh() } ) .onAppear(perform: { - viewModel.activate() + viewModel.subscribeState() }) .onDisappear(perform: { - viewModel.deactivate() + viewModel.unsubscribeState() }) } } diff --git a/ios/KaMPKitiOS/MainNavCoordinator.swift b/ios/KaMPKitiOS/MainNavCoordinator.swift index 5f7e0c64..3f90e810 100644 --- a/ios/KaMPKitiOS/MainNavCoordinator.swift +++ b/ios/KaMPKitiOS/MainNavCoordinator.swift @@ -27,9 +27,9 @@ class MainNavCoordinator: BreedsNavCoordinator { } } -private func buildBreedsController(navCoordinator: BreedsNavCoordinator) -> UIHostingController { +private func buildBreedsController(navCoordinator: BreedsNavCoordinator) -> UIHostingController { let viewModel = BreedsViewModel(navCoordinator: navCoordinator) - return UIHostingController(rootView: BreedListScreen(viewModel: viewModel)) + return UIHostingController(rootView: BreedsScreen(viewModel: viewModel)) } private func buildBreedDetailsController(breedId: Int64) -> UIHostingController { From d14710d5c580e42193c0c7ab05b380c2e5f44ae3 Mon Sep 17 00:00:00 2001 From: bpedryc Date: Mon, 3 Jul 2023 09:39:27 +0200 Subject: [PATCH 05/18] Move the favorite button to details screen --- .../kampkit/android/ui/BreedDetailsScreen.kt | 51 ++++++++++++++++++- .../kampkit/android/ui/BreedsScreen.kt | 49 +++++++----------- .../kampkit/data/dog/DogRepository.kt | 4 +- .../ui/breedDetails/BreedDetailsViewModel.kt | 4 ++ .../kampkit/ui/breeds/BreedsViewModel.kt | 21 ++++---- .../touchlab/kampkit/BreedsViewModelTest.kt | 19 +++---- 6 files changed, 88 insertions(+), 60 deletions(-) diff --git a/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedDetailsScreen.kt b/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedDetailsScreen.kt index a552b2d4..f43f3646 100644 --- a/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedDetailsScreen.kt +++ b/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedDetailsScreen.kt @@ -1,15 +1,28 @@ package co.touchlab.kampkit.android.ui +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.TweenSpec +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.width import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import co.touchlab.kampkit.android.R import co.touchlab.kampkit.ui.breedDetails.BreedDetailsViewModel +import co.touchlab.kampkit.ui.breedDetails.BreedDisplayable import co.touchlab.kermit.Logger @Composable @@ -23,8 +36,42 @@ fun BreedDetailsScreen( Text(error, Modifier.align(Alignment.Center), color = Color.Red) } if (state.error == null) { - Text(state.breed.name, Modifier.align(Alignment.Center)) + Row(Modifier.align(Alignment.Center)) { + Text(state.breed.name) + Spacer(Modifier.width(4.dp)) + FavoriteIcon( + breed = state.breed, + onClick = viewModel::toggleFavorite + ) + } } } -} \ No newline at end of file +} + +@Composable +fun FavoriteIcon( + breed: BreedDisplayable, + onClick: () -> Unit +) { + Crossfade( + targetState = !breed.favorite, + animationSpec = TweenSpec( + durationMillis = 500, + easing = FastOutSlowInEasing + ), + modifier = Modifier.clickable { onClick() } + ) { fav -> + if (fav) { + Image( + painter = painterResource(id = R.drawable.ic_favorite_border_24px), + contentDescription = stringResource(R.string.favorite_breed, breed.name) + ) + } else { + Image( + painter = painterResource(id = R.drawable.ic_favorite_24px), + contentDescription = stringResource(R.string.unfavorite_breed, breed.name) + ) + } + } +} diff --git a/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedsScreen.kt b/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedsScreen.kt index 87f9c5d0..bd868c39 100644 --- a/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedsScreen.kt +++ b/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedsScreen.kt @@ -1,8 +1,5 @@ package co.touchlab.kampkit.android.ui -import androidx.compose.animation.Crossfade -import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.TweenSpec import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -32,10 +29,6 @@ import co.touchlab.kampkit.android.R import co.touchlab.kampkit.domain.breed.Breed import co.touchlab.kampkit.ui.breeds.BreedsViewModel import co.touchlab.kampkit.ui.breeds.BreedsViewState -import co.touchlab.kampkit.db.Breed -import co.touchlab.kampkit.ui.breeds.BreedViewState -import co.touchlab.kampkit.ui.breeds.BreedsViewModel -import co.touchlab.kampkit.ui.breeds.BreedsNavRequest import co.touchlab.kermit.Logger import com.google.accompanist.swiperefresh.SwipeRefresh import com.google.accompanist.swiperefresh.rememberSwipeRefreshState @@ -48,7 +41,7 @@ fun BreedsScreen( ) { val breedsState by viewModel.breedsState.collectAsStateWithLifecycle() - dogsState.breedsNavRequest?.let { navRequest -> + breedsState.breedsNavRequest?.let { navRequest -> LaunchedEffect(navRequest) { if (navRequest is BreedsNavRequest.ToDetails) { onBreedDetailsNavRequest(navRequest.breedId) @@ -57,12 +50,12 @@ fun BreedsScreen( } } - MainScreenContent( + BreedsScreenContent( dogsState = breedsState, onRefresh = { viewModel.refreshBreeds() }, onSuccess = { data -> log.v { "View updating with ${data.size} breeds" } }, onError = { exception -> log.e { "Displaying error: $exception" } }, - onFavorite = { viewModel.updateBreedFavorite(it.id) } + onBreedClick = { viewModel.navigateToDetails(it) }, ) } @@ -72,7 +65,7 @@ fun BreedsScreenContent( onRefresh: () -> Unit = {}, onSuccess: (List) -> Unit = {}, onError: (String) -> Unit = {}, - onFavorite: (Breed) -> Unit = {} + onBreedClick: (breedId: Long) -> Unit = {}, ) { Surface( color = MaterialTheme.colors.background, @@ -133,17 +126,17 @@ fun Error(error: String) { @Composable fun Success( successData: List, - favoriteBreed: (Breed) -> Unit + onBreedClick: (breedId: Long) -> Unit ) { - DogList(breeds = successData, favoriteBreed) + DogList(breeds = successData, onBreedClick) } @Composable -fun DogList(breeds: List, onItemClick: (Breed) -> Unit) { +fun DogList(breeds: List, onItemClick: (breedId: Long) -> Unit) { LazyColumn { items(breeds) { breed -> DogRow(breed) { - onItemClick(it) + onItemClick(breed.id) } Divider() } @@ -164,24 +157,16 @@ fun DogRow(breed: Breed, onClick: (Breed) -> Unit) { @Composable fun FavoriteIcon(breed: Breed) { - Crossfade( - targetState = !breed.favorite, - animationSpec = TweenSpec( - durationMillis = 500, - easing = FastOutSlowInEasing + if (!breed.favorite) { + Image( + painter = painterResource(id = R.drawable.ic_favorite_border_24px), + contentDescription = stringResource(R.string.favorite_breed, breed.name) + ) + } else { + Image( + painter = painterResource(id = R.drawable.ic_favorite_24px), + contentDescription = stringResource(R.string.unfavorite_breed, breed.name) ) - ) { fav -> - if (fav) { - Image( - painter = painterResource(id = R.drawable.ic_favorite_border_24px), - contentDescription = stringResource(R.string.favorite_breed, breed.name) - ) - } else { - Image( - painter = painterResource(id = R.drawable.ic_favorite_24px), - contentDescription = stringResource(R.string.unfavorite_breed, breed.name) - ) - } } } diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/data/dog/DogRepository.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/data/dog/DogRepository.kt index c51b3bc3..8bfa52fe 100644 --- a/shared/src/commonMain/kotlin/co/touchlab/kampkit/data/dog/DogRepository.kt +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/data/dog/DogRepository.kt @@ -5,6 +5,7 @@ import co.touchlab.kermit.Logger import co.touchlab.stately.ensureNeverFrozen import com.russhwolf.settings.Settings import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first import kotlinx.datetime.Clock class DogRepository( @@ -47,7 +48,8 @@ class DogRepository( } } - suspend fun updateBreedFavorite(breed: Breed) { + suspend fun updateBreedFavorite(breedId: Long) { + val breed = dbHelper.selectById(breedId).first() ?: throw Exception("Breed not found") dbHelper.updateFavorite(breed.id, !breed.favorite) } diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDetailsViewModel.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDetailsViewModel.kt index a4ee795e..63981439 100644 --- a/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDetailsViewModel.kt +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDetailsViewModel.kt @@ -43,5 +43,9 @@ class BreedDetailsViewModel( } } } + + fun toggleFavorite() = viewModelScope.launch { + dogRepository.updateBreedFavorite(breedId) + } } diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breeds/BreedsViewModel.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breeds/BreedsViewModel.kt index ad84f5ff..12bb0561 100644 --- a/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breeds/BreedsViewModel.kt +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breeds/BreedsViewModel.kt @@ -1,9 +1,7 @@ package co.touchlab.kampkit.ui.breeds -import co.touchlab.kampkit.db.Breed -import co.touchlab.kampkit.core.ViewModel -import co.touchlab.kampkit.data.dog.DogRepository import co.touchlab.kampkit.core.ViewModel +import co.touchlab.kampkit.domain.breed.Breed import co.touchlab.kampkit.domain.breed.BreedRepository import co.touchlab.kermit.Logger import com.rickclephas.kmp.nativecoroutines.NativeCoroutinesState @@ -37,12 +35,6 @@ class BreedsViewModel( log.v("Clearing BreedViewModel") } - fun onBreedDetailsNavRequestCompleted() { - mutableBreedState.update { - it.copy(breedsNavRequest = null) - } - } - private fun observeBreeds() { // Refresh breeds, and emit any exception that was thrown so we can handle it downstream val refreshFlow = flow { @@ -87,12 +79,17 @@ class BreedsViewModel( } } - fun updateBreedFavorite(breedId: Long): Job { + fun navigateToDetails(breedId: Long): Job { return viewModelScope.launch { mutableBreedState.update { - it.copy(breedsNavRequest = BreedsNavRequest.ToDetails(breed.id)) + it.copy(breedsNavRequest = BreedsNavRequest.ToDetails(breedId)) } - dogRepository.updateBreedFavorite(breed) + } + } + + fun onBreedDetailsNavRequestCompleted() { + mutableBreedState.update { + it.copy(breedsNavRequest = null) } } diff --git a/shared/src/commonTest/kotlin/co/touchlab/kampkit/BreedsViewModelTest.kt b/shared/src/commonTest/kotlin/co/touchlab/kampkit/BreedsViewModelTest.kt index a4dea13c..66444909 100644 --- a/shared/src/commonTest/kotlin/co/touchlab/kampkit/BreedsViewModelTest.kt +++ b/shared/src/commonTest/kotlin/co/touchlab/kampkit/BreedsViewModelTest.kt @@ -9,6 +9,7 @@ import co.touchlab.kampkit.domain.breed.Breed import co.touchlab.kampkit.domain.breed.BreedRepository import co.touchlab.kampkit.mock.ClockMock import co.touchlab.kampkit.mock.DogApiMock +import co.touchlab.kampkit.ui.breeds.BreedsNavRequest import co.touchlab.kampkit.ui.breeds.BreedsViewModel import co.touchlab.kampkit.ui.breeds.BreedsViewState import co.touchlab.kermit.Logger @@ -137,21 +138,13 @@ class BreedsViewModelTest { } @Test - fun `Toggle favorite cached breed`() = runTest { - settings.putLong(NetworkBreedRepository.DB_TIMESTAMP_KEY, clock.currentInstant.toEpochMilliseconds()) - + fun `Navigate to breed details`() = runTest { dbHelper.insertBreeds(breedNames) - dbHelper.updateFavorite(australianLike.id, true) + viewModel.navigateToDetails(1).join() - viewModel.breedsState.test { - assertEquals(breedsViewStateSuccessFavorite, awaitItemPrecededBy(BreedsViewState(isLoading = true))) - expectNoEvents() - - viewModel.updateBreedFavorite(australianLike.id).join() - assertEquals( - breedsViewStateSuccessNoFavorite, - awaitItemPrecededBy(breedsViewStateSuccessFavorite.copy(isLoading = true)) - ) + viewModel.breedState.test { + val state = awaitItem() + assertEquals(BreedsNavRequest.ToDetails(1), state.breedsNavRequest) } } From 9523100cbe624c4502d1be9e8b6f3e0e4ef9515f Mon Sep 17 00:00:00 2001 From: bpedryc Date: Mon, 3 Jul 2023 10:18:03 +0200 Subject: [PATCH 06/18] Fix Ktlint errors --- .../touchlab/kampkit/ui/breedDetails/BreedDetailsViewModel.kt | 1 - .../touchlab/kampkit/ui/breedDetails/BreedDetailsViewState.kt | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDetailsViewModel.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDetailsViewModel.kt index 63981439..7ed0b3c1 100644 --- a/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDetailsViewModel.kt +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDetailsViewModel.kt @@ -48,4 +48,3 @@ class BreedDetailsViewModel( dogRepository.updateBreedFavorite(breedId) } } - diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDetailsViewState.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDetailsViewState.kt index fa7667d5..540094c0 100644 --- a/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDetailsViewState.kt +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDetailsViewState.kt @@ -3,7 +3,7 @@ package co.touchlab.kampkit.ui.breedDetails data class BreedDetailsViewState( val breed: BreedDisplayable = BreedDisplayable(), val error: String? = null, - val isLoading: Boolean = false, + val isLoading: Boolean = false ) { companion object { fun default() = BreedDetailsViewState() From 5700704f28614993d7399836a0c393c11f34c7bb Mon Sep 17 00:00:00 2001 From: bpedryc Date: Mon, 3 Jul 2023 10:43:51 +0200 Subject: [PATCH 07/18] Implement the favorite behavior changes on iOS --- .../kampkit/android/ui/BreedDetailsScreen.kt | 2 +- .../kampkit/android/ui/BreedsScreen.kt | 2 +- ios/KaMPKitiOS/BreedDetailsScreen.swift | 26 ++++++++++++++----- ios/KaMPKitiOS/Breeds/BreedsViewModel.swift | 4 +-- ios/KaMPKitiOS/BreedsScreen.swift | 8 +++--- .../ui/breedDetails/BreedDetailsViewModel.kt | 6 +++-- .../kampkit/ui/breeds/BreedsViewModel.kt | 2 +- .../touchlab/kampkit/BreedsViewModelTest.kt | 2 +- 8 files changed, 34 insertions(+), 18 deletions(-) diff --git a/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedDetailsScreen.kt b/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedDetailsScreen.kt index f43f3646..75b10a19 100644 --- a/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedDetailsScreen.kt +++ b/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedDetailsScreen.kt @@ -41,7 +41,7 @@ fun BreedDetailsScreen( Spacer(Modifier.width(4.dp)) FavoriteIcon( breed = state.breed, - onClick = viewModel::toggleFavorite + onClick = viewModel::onFavoriteClick ) } } diff --git a/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedsScreen.kt b/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedsScreen.kt index bd868c39..dccd8440 100644 --- a/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedsScreen.kt +++ b/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedsScreen.kt @@ -55,7 +55,7 @@ fun BreedsScreen( onRefresh = { viewModel.refreshBreeds() }, onSuccess = { data -> log.v { "View updating with ${data.size} breeds" } }, onError = { exception -> log.e { "Displaying error: $exception" } }, - onBreedClick = { viewModel.navigateToDetails(it) }, + onBreedClick = { viewModel.onBreedClick(it) }, ) } diff --git a/ios/KaMPKitiOS/BreedDetailsScreen.swift b/ios/KaMPKitiOS/BreedDetailsScreen.swift index 0b2dc87c..2fd88cd2 100644 --- a/ios/KaMPKitiOS/BreedDetailsScreen.swift +++ b/ios/KaMPKitiOS/BreedDetailsScreen.swift @@ -13,10 +13,10 @@ import KMPNativeCoroutinesCombine import Foundation class BreedDetailsViewModel: ObservableObject { - private var viewModel: BreedDetailsViewModelDelegate + private var viewModelDelegate: BreedDetailsViewModelDelegate init(breedId: Int64) { - self.viewModel = KotlinDependencies.shared.getBreedDetailsViewModel(breedId: breedId) + self.viewModelDelegate = KotlinDependencies.shared.getBreedDetailsViewModel(breedId: breedId) } @Published @@ -24,8 +24,12 @@ class BreedDetailsViewModel: ObservableObject { private var cancellables = [AnyCancellable]() + func onFavoriteClick() { + viewModelDelegate.onFavoriteClick() + } + func activate() { - createPublisher(for: viewModel.detailsStateFlow) + createPublisher(for: viewModelDelegate.detailsStateFlow) .sink { _ in } receiveValue: { [weak self] (detailsState: BreedDetailsViewState) in self?.detailsState = detailsState } @@ -38,7 +42,7 @@ class BreedDetailsViewModel: ObservableObject { } deinit { - viewModel.clear() + viewModelDelegate.clear() } } @@ -48,7 +52,9 @@ struct BreedDetailsScreen: View { var body: some View { BreedDetailsContent( - breedName: viewModel.detailsState.breed.name + breedName: viewModel.detailsState.breed.name, + isBreedFavorite: viewModel.detailsState.breed.favorite, + onFavoriteClick: { viewModel.onFavoriteClick() } ) .onAppear(perform: { viewModel.activate() @@ -61,7 +67,15 @@ struct BreedDetailsScreen: View { struct BreedDetailsContent: View { var breedName: String + var isBreedFavorite: Bool + var onFavoriteClick: () -> Void var body: some View { - Text(breedName) + HStack { + Text(breedName) + Button(action: onFavoriteClick) { + Image(systemName: (!isBreedFavorite) ? "heart" : "heart.fill") + .padding(4.0) + } + } } } diff --git a/ios/KaMPKitiOS/Breeds/BreedsViewModel.swift b/ios/KaMPKitiOS/Breeds/BreedsViewModel.swift index 2d324939..49a2b7aa 100644 --- a/ios/KaMPKitiOS/Breeds/BreedsViewModel.swift +++ b/ios/KaMPKitiOS/Breeds/BreedsViewModel.swift @@ -47,8 +47,8 @@ class BreedsViewModel: ObservableObject { cancellables.removeAll() } - func onBreedFavorite(_ breedId: Int64) { - viewModelDelegate.updateBreedFavorite(breedId: breedId) + func onBreedClick(_ breedId: Int64) { + viewModelDelegate.onBreedClick(breedId: breedId) } func refresh() { diff --git a/ios/KaMPKitiOS/BreedsScreen.swift b/ios/KaMPKitiOS/BreedsScreen.swift index 281e2ce4..019f9124 100644 --- a/ios/KaMPKitiOS/BreedsScreen.swift +++ b/ios/KaMPKitiOS/BreedsScreen.swift @@ -23,7 +23,7 @@ struct BreedsScreen: View { loading: viewModel.state.isLoading, breeds: viewModel.state.breeds, error: viewModel.state.error, - onBreedFavorite: { viewModel.onBreedFavorite($0) }, + onBreedClick: { viewModel.onBreedClick($0) }, refresh: { viewModel.refresh() } ) .onAppear(perform: { @@ -39,7 +39,7 @@ struct BreedListContent: View { var loading: Bool var breeds: [Breed]? var error: String? - var onBreedFavorite: (Breed) -> Void + var onBreedClick: (Int64) -> Void var refresh: () -> Void var body: some View { @@ -48,7 +48,7 @@ struct BreedListContent: View { if let breeds = breeds { List(breeds, id: \.id) { breed in BreedRowView(breed: breed) { - onBreedFavorite(breed) + onBreedClick(breed.id) } } } @@ -91,7 +91,7 @@ struct BreedListScreen_Previews: PreviewProvider { Breed(id: 1, name: "australian", favorite: true) ], error: nil, - onBreedFavorite: { _ in }, + onBreedClick: { _ in }, refresh: {} ) } diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDetailsViewModel.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDetailsViewModel.kt index 7ed0b3c1..f279354a 100644 --- a/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDetailsViewModel.kt +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDetailsViewModel.kt @@ -44,7 +44,9 @@ class BreedDetailsViewModel( } } - fun toggleFavorite() = viewModelScope.launch { - dogRepository.updateBreedFavorite(breedId) + fun onFavoriteClick() { + viewModelScope.launch { + dogRepository.updateBreedFavorite(breedId) + } } } diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breeds/BreedsViewModel.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breeds/BreedsViewModel.kt index 12bb0561..52ada5f8 100644 --- a/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breeds/BreedsViewModel.kt +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breeds/BreedsViewModel.kt @@ -79,7 +79,7 @@ class BreedsViewModel( } } - fun navigateToDetails(breedId: Long): Job { + fun onBreedClick(breedId: Long): Job { return viewModelScope.launch { mutableBreedState.update { it.copy(breedsNavRequest = BreedsNavRequest.ToDetails(breedId)) diff --git a/shared/src/commonTest/kotlin/co/touchlab/kampkit/BreedsViewModelTest.kt b/shared/src/commonTest/kotlin/co/touchlab/kampkit/BreedsViewModelTest.kt index 66444909..9f44f729 100644 --- a/shared/src/commonTest/kotlin/co/touchlab/kampkit/BreedsViewModelTest.kt +++ b/shared/src/commonTest/kotlin/co/touchlab/kampkit/BreedsViewModelTest.kt @@ -140,7 +140,7 @@ class BreedsViewModelTest { @Test fun `Navigate to breed details`() = runTest { dbHelper.insertBreeds(breedNames) - viewModel.navigateToDetails(1).join() + viewModel.onBreedClick(1).join() viewModel.breedState.test { val state = awaitItem() From 99379a3aaa4bfb708f2951fc5a4fc7e29d5b8572 Mon Sep 17 00:00:00 2001 From: bpedryc Date: Wed, 5 Jul 2023 12:56:39 +0200 Subject: [PATCH 08/18] Fix rebase changes --- .../kampkit/android/ui/BreedsScreen.kt | 3 +- ios/KaMPKitiOS/Breeds/BreedsViewModel.swift | 2 +- .../kotlin/co/touchlab/kampkit/core/Koin.kt | 4 -- .../kampkit/data/dog/DogDatabaseHelper.kt | 2 - .../kampkit/data/dog/DogRepository.kt | 65 ------------------- .../data/dog/NetworkBreedRepository.kt | 19 ++++-- .../kampkit/domain/breed/BreedRepository.kt | 1 + .../ui/breedDetails/BreedDetailsViewModel.kt | 8 +-- .../ui/breedDetails/BreedDisplayable.kt | 2 +- .../kampkit/ui/breeds/BreedsViewModel.kt | 15 ----- .../kampkit/ui/breeds/BreedsViewState.kt | 3 +- .../touchlab/kampkit/BreedsViewModelTest.kt | 2 +- ...yTest.kt => NetworkBreedRepositoryTest.kt} | 2 +- .../co/touchlab/kampkit/core/KoinIOS.kt | 1 - 14 files changed, 26 insertions(+), 103 deletions(-) delete mode 100644 shared/src/commonMain/kotlin/co/touchlab/kampkit/data/dog/DogRepository.kt rename shared/src/commonTest/kotlin/co/touchlab/kampkit/{NetworkDogRepositoryTest.kt => NetworkBreedRepositoryTest.kt} (99%) diff --git a/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedsScreen.kt b/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedsScreen.kt index dccd8440..b0f0fc9c 100644 --- a/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedsScreen.kt +++ b/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedsScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import co.touchlab.kampkit.android.R import co.touchlab.kampkit.domain.breed.Breed +import co.touchlab.kampkit.ui.breeds.BreedsNavRequest import co.touchlab.kampkit.ui.breeds.BreedsViewModel import co.touchlab.kampkit.ui.breeds.BreedsViewState import co.touchlab.kermit.Logger @@ -83,7 +84,7 @@ fun BreedsScreenContent( LaunchedEffect(breeds) { onSuccess(breeds) } - Success(successData = breeds, favoriteBreed = onFavorite) + Success(successData = breeds, onBreedClick = onBreedClick) } } diff --git a/ios/KaMPKitiOS/Breeds/BreedsViewModel.swift b/ios/KaMPKitiOS/Breeds/BreedsViewModel.swift index 49a2b7aa..f7375eb3 100644 --- a/ios/KaMPKitiOS/Breeds/BreedsViewModel.swift +++ b/ios/KaMPKitiOS/Breeds/BreedsViewModel.swift @@ -35,7 +35,7 @@ class BreedsViewModel: ObservableObject { .store(in: &cancellables) } - private func handleNavRequests(breedsState: BreedViewState) { + private func handleNavRequests(breedsState: BreedsViewState) { if let navRequest = breedsState.breedsNavRequest as? BreedsNavRequest.ToDetails { self.navCoordinator.onBreedDetailsRequest(breedId: navRequest.breedId) self.viewModelDelegate.onBreedDetailsNavRequestCompleted() diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/core/Koin.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/core/Koin.kt index 7296ac48..5bd590b6 100644 --- a/shared/src/commonMain/kotlin/co/touchlab/kampkit/core/Koin.kt +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/core/Koin.kt @@ -5,10 +5,6 @@ import co.touchlab.kampkit.data.dog.DogApiImpl import co.touchlab.kampkit.data.dog.DogDatabaseHelper import co.touchlab.kampkit.data.dog.NetworkBreedRepository import co.touchlab.kampkit.domain.breed.BreedRepository -import co.touchlab.kampkit.data.dog.DogApi -import co.touchlab.kampkit.data.dog.DogApiImpl -import co.touchlab.kampkit.data.dog.DogDatabaseHelper -import co.touchlab.kampkit.data.dog.DogRepository import co.touchlab.kermit.Logger import co.touchlab.kermit.StaticConfig import co.touchlab.kermit.platformLogWriter diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/data/dog/DogDatabaseHelper.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/data/dog/DogDatabaseHelper.kt index ca111eef..85dea97d 100644 --- a/shared/src/commonMain/kotlin/co/touchlab/kampkit/data/dog/DogDatabaseHelper.kt +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/data/dog/DogDatabaseHelper.kt @@ -1,7 +1,5 @@ package co.touchlab.kampkit.data.dog -import co.touchlab.kampkit.core.transactionWithContext -import co.touchlab.kampkit.db.Breed import co.touchlab.kampkit.core.transactionWithContext import co.touchlab.kampkit.db.DbBreed import co.touchlab.kampkit.db.KaMPKitDb diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/data/dog/DogRepository.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/data/dog/DogRepository.kt deleted file mode 100644 index 8bfa52fe..00000000 --- a/shared/src/commonMain/kotlin/co/touchlab/kampkit/data/dog/DogRepository.kt +++ /dev/null @@ -1,65 +0,0 @@ -package co.touchlab.kampkit.data.dog - -import co.touchlab.kampkit.db.Breed -import co.touchlab.kermit.Logger -import co.touchlab.stately.ensureNeverFrozen -import com.russhwolf.settings.Settings -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.first -import kotlinx.datetime.Clock - -class DogRepository( - private val dbHelper: DogDatabaseHelper, - private val settings: Settings, - private val dogApi: DogApi, - log: Logger, - private val clock: Clock -) { - - private val log = log.withTag("BreedModel") - - companion object { - internal const val DB_TIMESTAMP_KEY = "DbTimestampKey" - } - - init { - ensureNeverFrozen() - } - - fun getBreed(id: Long): Flow = dbHelper.selectById(id) - - fun getBreeds(): Flow> = dbHelper.selectAllItems() - - suspend fun refreshBreedsIfStale() { - if (isBreedListStale()) { - refreshBreeds() - } - } - - suspend fun refreshBreeds() { - val breedResult = dogApi.getJsonFromApi() - log.v { "Breed network result: ${breedResult.status}" } - val breedList = breedResult.message.keys.sorted().toList() - log.v { "Fetched ${breedList.size} breeds from network" } - settings.putLong(DB_TIMESTAMP_KEY, clock.now().toEpochMilliseconds()) - - if (breedList.isNotEmpty()) { - dbHelper.insertBreeds(breedList) - } - } - - suspend fun updateBreedFavorite(breedId: Long) { - val breed = dbHelper.selectById(breedId).first() ?: throw Exception("Breed not found") - dbHelper.updateFavorite(breed.id, !breed.favorite) - } - - private fun isBreedListStale(): Boolean { - val lastDownloadTimeMS = settings.getLong(DB_TIMESTAMP_KEY, 0) - val oneHourMS = 60 * 60 * 1000 - val stale = lastDownloadTimeMS + oneHourMS < clock.now().toEpochMilliseconds() - if (!stale) { - log.i { "Breeds not fetched from network. Recently updated" } - } - return stale - } -} diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/data/dog/NetworkBreedRepository.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/data/dog/NetworkBreedRepository.kt index df74d05f..e14f45db 100644 --- a/shared/src/commonMain/kotlin/co/touchlab/kampkit/data/dog/NetworkBreedRepository.kt +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/data/dog/NetworkBreedRepository.kt @@ -18,7 +18,7 @@ class NetworkBreedRepository( private val clock: Clock ) : BreedRepository { - private val log = log.withTag("DogRepository") + private val log = log.withTag("NetworkBreedRepository") companion object { internal const val DB_TIMESTAMP_KEY = "DbTimestampKey" @@ -28,10 +28,15 @@ class NetworkBreedRepository( ensureNeverFrozen() } + override fun getBreed(id: Long): Flow { + return dbHelper + .selectById(id) + .map { dbBreed -> dbBreed?.toDomain() } + } override fun getBreeds(): Flow> { - return dbHelper.selectAllItems().map { list -> - list.map { dbBreed -> dbBreed.toDomain() } - } + return dbHelper + .selectAllItems() + .map { list -> list.map { dbBreed -> dbBreed.toDomain() } } } override suspend fun refreshBreedsIfStale() { @@ -53,8 +58,10 @@ class NetworkBreedRepository( } override suspend fun updateBreedFavorite(breedId: Long) { - val foundBreedsWithId = dbHelper.selectById(breedId).first() - foundBreedsWithId.firstOrNull()?.let { breed -> + dbHelper + .selectById(breedId) + .first() + ?.let { breed -> dbHelper.updateFavorite(breed.id, !breed.favorite) } } diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/domain/breed/BreedRepository.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/domain/breed/BreedRepository.kt index b5cbb2e2..626bd672 100644 --- a/shared/src/commonMain/kotlin/co/touchlab/kampkit/domain/breed/BreedRepository.kt +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/domain/breed/BreedRepository.kt @@ -3,6 +3,7 @@ package co.touchlab.kampkit.domain.breed import kotlinx.coroutines.flow.Flow interface BreedRepository { + fun getBreed(id: Long): Flow fun getBreeds(): Flow> suspend fun refreshBreedsIfStale() suspend fun refreshBreeds() diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDetailsViewModel.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDetailsViewModel.kt index f279354a..349e6c35 100644 --- a/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDetailsViewModel.kt +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDetailsViewModel.kt @@ -1,7 +1,7 @@ package co.touchlab.kampkit.ui.breedDetails import co.touchlab.kampkit.core.ViewModel -import co.touchlab.kampkit.data.dog.DogRepository +import co.touchlab.kampkit.domain.breed.BreedRepository import co.touchlab.kermit.Logger import com.rickclephas.kmp.nativecoroutines.NativeCoroutinesState import kotlinx.coroutines.flow.MutableStateFlow @@ -13,7 +13,7 @@ import kotlin.native.ObjCName @ObjCName("BreedDetailsViewModelDelegate") class BreedDetailsViewModel( private val breedId: Long, - private val dogRepository: DogRepository, + private val breedRepository: BreedRepository, log: Logger ) : ViewModel() { private val log = log.withTag("BreedDetailsViewModel") @@ -30,7 +30,7 @@ class BreedDetailsViewModel( private fun loadDetails() { viewModelScope.launch { - dogRepository.getBreed(breedId).collect { breed -> + breedRepository.getBreed(breedId).collect { breed -> mutableDetailsState.update { previousState -> val error = if (breed == null) "Couldn't load the breed details" else null val newBreed = breed?.toDisplayable() ?: previousState.breed @@ -46,7 +46,7 @@ class BreedDetailsViewModel( fun onFavoriteClick() { viewModelScope.launch { - dogRepository.updateBreedFavorite(breedId) + breedRepository.updateBreedFavorite(breedId) } } } diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDisplayable.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDisplayable.kt index 58767ae1..e49b4b9c 100644 --- a/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDisplayable.kt +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDisplayable.kt @@ -1,6 +1,6 @@ package co.touchlab.kampkit.ui.breedDetails -import co.touchlab.kampkit.db.Breed +import co.touchlab.kampkit.domain.breed.Breed data class BreedDisplayable( val id: Long = 0, diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breeds/BreedsViewModel.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breeds/BreedsViewModel.kt index 52ada5f8..af861b5a 100644 --- a/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breeds/BreedsViewModel.kt +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breeds/BreedsViewModel.kt @@ -1,7 +1,6 @@ package co.touchlab.kampkit.ui.breeds import co.touchlab.kampkit.core.ViewModel -import co.touchlab.kampkit.domain.breed.Breed import co.touchlab.kampkit.domain.breed.BreedRepository import co.touchlab.kermit.Logger import com.rickclephas.kmp.nativecoroutines.NativeCoroutinesState @@ -105,17 +104,3 @@ class BreedsViewModel( } } } - -data class BreedViewState( - val breeds: List = emptyList(), - val error: String? = null, - val isLoading: Boolean = false, - val isEmpty: Boolean = false, - val breedsNavRequest: BreedsNavRequest? = null -) { - companion object { - // This method lets you use the default constructor values in Swift. When accessing the - // constructor directly, they will not work there and would need to be provided explicitly. - fun default() = BreedViewState() - } -} diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breeds/BreedsViewState.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breeds/BreedsViewState.kt index 0a908bad..75633c56 100644 --- a/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breeds/BreedsViewState.kt +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breeds/BreedsViewState.kt @@ -6,7 +6,8 @@ data class BreedsViewState( val breeds: List = emptyList(), val error: String? = null, val isLoading: Boolean = false, - val isEmpty: Boolean = false + val isEmpty: Boolean = false, + val breedsNavRequest: BreedsNavRequest? = null ) { companion object { // This method lets you use the default constructor values in Swift. When accessing the diff --git a/shared/src/commonTest/kotlin/co/touchlab/kampkit/BreedsViewModelTest.kt b/shared/src/commonTest/kotlin/co/touchlab/kampkit/BreedsViewModelTest.kt index 9f44f729..8f22573f 100644 --- a/shared/src/commonTest/kotlin/co/touchlab/kampkit/BreedsViewModelTest.kt +++ b/shared/src/commonTest/kotlin/co/touchlab/kampkit/BreedsViewModelTest.kt @@ -142,7 +142,7 @@ class BreedsViewModelTest { dbHelper.insertBreeds(breedNames) viewModel.onBreedClick(1).join() - viewModel.breedState.test { + viewModel.breedsState.test { val state = awaitItem() assertEquals(BreedsNavRequest.ToDetails(1), state.breedsNavRequest) } diff --git a/shared/src/commonTest/kotlin/co/touchlab/kampkit/NetworkDogRepositoryTest.kt b/shared/src/commonTest/kotlin/co/touchlab/kampkit/NetworkBreedRepositoryTest.kt similarity index 99% rename from shared/src/commonTest/kotlin/co/touchlab/kampkit/NetworkDogRepositoryTest.kt rename to shared/src/commonTest/kotlin/co/touchlab/kampkit/NetworkBreedRepositoryTest.kt index e7850239..102564fb 100644 --- a/shared/src/commonTest/kotlin/co/touchlab/kampkit/NetworkDogRepositoryTest.kt +++ b/shared/src/commonTest/kotlin/co/touchlab/kampkit/NetworkBreedRepositoryTest.kt @@ -18,7 +18,7 @@ import kotlin.test.assertEquals import kotlin.test.assertFails import kotlin.time.Duration.Companion.hours -class NetworkDogRepositoryTest { +class NetworkBreedRepositoryTest { private var kermit = Logger(StaticConfig()) private var testDbConnection = testDbConnection() diff --git a/shared/src/iosMain/kotlin/co/touchlab/kampkit/core/KoinIOS.kt b/shared/src/iosMain/kotlin/co/touchlab/kampkit/core/KoinIOS.kt index dfc125ac..faf9243f 100644 --- a/shared/src/iosMain/kotlin/co/touchlab/kampkit/core/KoinIOS.kt +++ b/shared/src/iosMain/kotlin/co/touchlab/kampkit/core/KoinIOS.kt @@ -1,7 +1,6 @@ package co.touchlab.kampkit.core import co.touchlab.kampkit.db.KaMPKitDb -import co.touchlab.kampkit.ui.breeds.BreedsViewModel import co.touchlab.kampkit.ui.breedDetails.BreedDetailsViewModel import co.touchlab.kampkit.ui.breeds.BreedsViewModel import co.touchlab.kermit.Logger From f64bb50c616e840093a4506de2132bed123c89d2 Mon Sep 17 00:00:00 2001 From: bpedryc Date: Wed, 5 Jul 2023 12:59:19 +0200 Subject: [PATCH 09/18] Delete unused BreedDisplayable --- .../kampkit/android/ui/BreedDetailsScreen.kt | 4 ++-- .../co/touchlab/kampkit/domain/breed/Breed.kt | 6 +++--- .../ui/breedDetails/BreedDetailsViewModel.kt | 3 +-- .../ui/breedDetails/BreedDetailsViewState.kt | 4 +++- .../kampkit/ui/breedDetails/BreedDisplayable.kt | 15 --------------- 5 files changed, 9 insertions(+), 23 deletions(-) delete mode 100644 shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDisplayable.kt diff --git a/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedDetailsScreen.kt b/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedDetailsScreen.kt index 75b10a19..5abfd4e4 100644 --- a/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedDetailsScreen.kt +++ b/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedDetailsScreen.kt @@ -21,8 +21,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import co.touchlab.kampkit.android.R +import co.touchlab.kampkit.domain.breed.Breed import co.touchlab.kampkit.ui.breedDetails.BreedDetailsViewModel -import co.touchlab.kampkit.ui.breedDetails.BreedDisplayable import co.touchlab.kermit.Logger @Composable @@ -51,7 +51,7 @@ fun BreedDetailsScreen( @Composable fun FavoriteIcon( - breed: BreedDisplayable, + breed: Breed, onClick: () -> Unit ) { Crossfade( diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/domain/breed/Breed.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/domain/breed/Breed.kt index 27c7787e..2711e940 100644 --- a/shared/src/commonMain/kotlin/co/touchlab/kampkit/domain/breed/Breed.kt +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/domain/breed/Breed.kt @@ -1,7 +1,7 @@ package co.touchlab.kampkit.domain.breed data class Breed( - val id: Long, - val name: String, - val favorite: Boolean + val id: Long = 0, + val name: String = "", + val favorite: Boolean = false ) diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDetailsViewModel.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDetailsViewModel.kt index 349e6c35..31507691 100644 --- a/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDetailsViewModel.kt +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDetailsViewModel.kt @@ -33,10 +33,9 @@ class BreedDetailsViewModel( breedRepository.getBreed(breedId).collect { breed -> mutableDetailsState.update { previousState -> val error = if (breed == null) "Couldn't load the breed details" else null - val newBreed = breed?.toDisplayable() ?: previousState.breed previousState.copy( isLoading = false, - breed = newBreed, + breed = breed ?: previousState.breed, error = error ) } diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDetailsViewState.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDetailsViewState.kt index 540094c0..f188b128 100644 --- a/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDetailsViewState.kt +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDetailsViewState.kt @@ -1,7 +1,9 @@ package co.touchlab.kampkit.ui.breedDetails +import co.touchlab.kampkit.domain.breed.Breed + data class BreedDetailsViewState( - val breed: BreedDisplayable = BreedDisplayable(), + val breed: Breed = Breed(), val error: String? = null, val isLoading: Boolean = false ) { diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDisplayable.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDisplayable.kt deleted file mode 100644 index e49b4b9c..00000000 --- a/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDisplayable.kt +++ /dev/null @@ -1,15 +0,0 @@ -package co.touchlab.kampkit.ui.breedDetails - -import co.touchlab.kampkit.domain.breed.Breed - -data class BreedDisplayable( - val id: Long = 0, - val name: String = "", - val favorite: Boolean = false -) - -fun Breed.toDisplayable() = BreedDisplayable( - this.id, - this.name, - this.favorite -) From 926f40b526acdd79f1c938c9b43c666634db0499 Mon Sep 17 00:00:00 2001 From: bpedryc Date: Wed, 5 Jul 2023 13:44:40 +0200 Subject: [PATCH 10/18] Fix Ktlint issues --- .../kotlin/co/touchlab/kampkit/core/ViewModel.kt | 2 +- .../kotlin/co/touchlab/kampkit/core/ViewModel.kt | 2 +- .../co/touchlab/kampkit/data/dog/NetworkBreedRepository.kt | 7 +++---- .../iosMain/kotlin/co/touchlab/kampkit/core/ViewModel.kt | 2 +- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/shared/src/androidMain/kotlin/co/touchlab/kampkit/core/ViewModel.kt b/shared/src/androidMain/kotlin/co/touchlab/kampkit/core/ViewModel.kt index 595a7dbf..90122412 100644 --- a/shared/src/androidMain/kotlin/co/touchlab/kampkit/core/ViewModel.kt +++ b/shared/src/androidMain/kotlin/co/touchlab/kampkit/core/ViewModel.kt @@ -10,4 +10,4 @@ actual abstract class ViewModel actual constructor() : ViewModel() { actual override fun onCleared() { super.onCleared() } -} \ No newline at end of file +} diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/core/ViewModel.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/core/ViewModel.kt index d0eb076b..4e9aab7c 100644 --- a/shared/src/commonMain/kotlin/co/touchlab/kampkit/core/ViewModel.kt +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/core/ViewModel.kt @@ -5,4 +5,4 @@ import kotlinx.coroutines.CoroutineScope expect abstract class ViewModel() { val viewModelScope: CoroutineScope protected open fun onCleared() -} \ No newline at end of file +} diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/data/dog/NetworkBreedRepository.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/data/dog/NetworkBreedRepository.kt index e14f45db..c875f686 100644 --- a/shared/src/commonMain/kotlin/co/touchlab/kampkit/data/dog/NetworkBreedRepository.kt +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/data/dog/NetworkBreedRepository.kt @@ -60,10 +60,9 @@ class NetworkBreedRepository( override suspend fun updateBreedFavorite(breedId: Long) { dbHelper .selectById(breedId) - .first() - ?.let { breed -> - dbHelper.updateFavorite(breed.id, !breed.favorite) - } + .first()?.let { breed -> + dbHelper.updateFavorite(breed.id, !breed.favorite) + } } private fun isBreedListStale(): Boolean { diff --git a/shared/src/iosMain/kotlin/co/touchlab/kampkit/core/ViewModel.kt b/shared/src/iosMain/kotlin/co/touchlab/kampkit/core/ViewModel.kt index abfa5c9f..ef4ba26e 100644 --- a/shared/src/iosMain/kotlin/co/touchlab/kampkit/core/ViewModel.kt +++ b/shared/src/iosMain/kotlin/co/touchlab/kampkit/core/ViewModel.kt @@ -29,4 +29,4 @@ actual abstract class ViewModel { onCleared() viewModelScope.cancel() } -} \ No newline at end of file +} From 0390b4925ca61a885b87f0562b3e76635c597d6c Mon Sep 17 00:00:00 2001 From: bpedryc Date: Wed, 5 Jul 2023 13:54:02 +0200 Subject: [PATCH 11/18] Improve iOS folder structure --- ios/KaMPKitiOS.xcodeproj/project.pbxproj | 26 ++++++----- .../BreedDetailsNavController.swift | 9 ---- .../BreedDetails/BreedDetailsScreen.swift | 45 +++++++++++++++++++ .../BreedDetailsViewModel.swift} | 41 ++--------------- .../Breeds/BreedsNavController.swift | 13 ------ 5 files changed, 64 insertions(+), 70 deletions(-) delete mode 100644 ios/KaMPKitiOS/BreedDetails/BreedDetailsNavController.swift create mode 100644 ios/KaMPKitiOS/BreedDetails/BreedDetailsScreen.swift rename ios/KaMPKitiOS/{BreedDetailsScreen.swift => BreedDetails/BreedDetailsViewModel.swift} (52%) delete mode 100644 ios/KaMPKitiOS/Breeds/BreedsNavController.swift diff --git a/ios/KaMPKitiOS.xcodeproj/project.pbxproj b/ios/KaMPKitiOS.xcodeproj/project.pbxproj index 9e67bd88..9ec20cfd 100644 --- a/ios/KaMPKitiOS.xcodeproj/project.pbxproj +++ b/ios/KaMPKitiOS.xcodeproj/project.pbxproj @@ -7,10 +7,10 @@ objects = { /* Begin PBXBuildFile section */ + 3C61D1352A55909300D4DF1D /* BreedDetailsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C61D1342A55909300D4DF1D /* BreedDetailsViewModel.swift */; }; 3C6AEC072A30881B0003F34A /* BreedDetailsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C6AEC062A30881B0003F34A /* BreedDetailsScreen.swift */; }; - 3C6AEC092A30C17A0003F34A /* AppNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C6AEC082A30C17A0003F34A /* AppNavigationController.swift */; }; - 3C73D04A2A335103003E6929 /* BreedsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C73D0492A335103003E6929 /* BreedsViewModel.swift */; }; 3C6AEC092A30C17A0003F34A /* MainNavCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C6AEC082A30C17A0003F34A /* MainNavCoordinator.swift */; }; + 3C73D04A2A335103003E6929 /* BreedsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C73D0492A335103003E6929 /* BreedsViewModel.swift */; }; 3CD290EC2A251417004C7AD1 /* KMPNativeCoroutinesCombine in Frameworks */ = {isa = PBXBuildFile; productRef = 3CD290EB2A251417004C7AD1 /* KMPNativeCoroutinesCombine */; }; 3CD290EE2A251417004C7AD1 /* KMPNativeCoroutinesCore in Frameworks */ = {isa = PBXBuildFile; productRef = 3CD290ED2A251417004C7AD1 /* KMPNativeCoroutinesCore */; }; 3DFF917C64A18A83DA010EE1 /* Pods_KaMPKitiOS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B859F3FB23133D22AB9DD835 /* Pods_KaMPKitiOS.framework */; }; @@ -44,12 +44,11 @@ /* Begin PBXFileReference section */ 1DFCC00C8DAA719770A18D1A /* Pods-KaMPKitiOS.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-KaMPKitiOS.release.xcconfig"; path = "Pods/Target Support Files/Pods-KaMPKitiOS/Pods-KaMPKitiOS.release.xcconfig"; sourceTree = ""; }; 2A1ED6A4A2A53F5F75C58E5F /* Pods-KaMPKitiOS.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-KaMPKitiOS.release.xcconfig"; path = "Pods/Target Support Files/Pods-KaMPKitiOS/Pods-KaMPKitiOS.release.xcconfig"; sourceTree = ""; }; - 3C73D0492A335103003E6929 /* BreedsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreedsViewModel.swift; sourceTree = ""; }; - 46A5B5EE26AF54F7002EFEAA /* BreedsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreedsScreen.swift; sourceTree = ""; }; + 3C61D1342A55909300D4DF1D /* BreedDetailsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreedDetailsViewModel.swift; sourceTree = ""; }; 3C6AEC062A30881B0003F34A /* BreedDetailsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreedDetailsScreen.swift; sourceTree = ""; }; - 3C6AEC082A30C17A0003F34A /* AppNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppNavigationController.swift; sourceTree = ""; }; 3C6AEC082A30C17A0003F34A /* MainNavCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainNavCoordinator.swift; sourceTree = ""; }; - 46A5B5EE26AF54F7002EFEAA /* BreedsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreedsScreen.swift; sourceTree = ""; }; + 3C73D0492A335103003E6929 /* BreedsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreedsViewModel.swift; sourceTree = ""; }; + 46A5B5EE26AF54F7002EFEAA /* BreedsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BreedsScreen.swift; path = KaMPKitiOS/BreedsScreen.swift; sourceTree = SOURCE_ROOT; }; 46A5B60726B04920002EFEAA /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 46B5284C249C5CF400A7725D /* Koin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Koin.swift; sourceTree = ""; }; B859F3FB23133D22AB9DD835 /* Pods_KaMPKitiOS.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_KaMPKitiOS.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -96,6 +95,15 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 3C61D1332A55908200D4DF1D /* BreedDetails */ = { + isa = PBXGroup; + children = ( + 3C6AEC062A30881B0003F34A /* BreedDetailsScreen.swift */, + 3C61D1342A55909300D4DF1D /* BreedDetailsViewModel.swift */, + ); + path = BreedDetails; + sourceTree = ""; + }; 3C73D0482A335061003E6929 /* Breeds */ = { isa = PBXGroup; children = ( @@ -149,17 +157,14 @@ F1465EFF23AA94BF0055F7C3 /* KaMPKitiOS */ = { isa = PBXGroup; children = ( + 3C61D1332A55908200D4DF1D /* BreedDetails */, 3C73D0482A335061003E6929 /* Breeds */, - 3C6AEC062A30881B0003F34A /* BreedDetailsScreen.swift */, - 46A5B5EE26AF54F7002EFEAA /* BreedsScreen.swift */, F1465F0023AA94BF0055F7C3 /* AppDelegate.swift */, 46A5B60626B04920002EFEAA /* Main.storyboard */, 46B5284C249C5CF400A7725D /* Koin.swift */, F1465F0923AA94BF0055F7C3 /* Assets.xcassets */, F1465F0B23AA94BF0055F7C3 /* LaunchScreen.storyboard */, F1465F0E23AA94BF0055F7C3 /* Info.plist */, - 3C6AEC062A30881B0003F34A /* BreedDetailsScreen.swift */, - 3C6AEC082A30C17A0003F34A /* AppNavigationController.swift */, 3C6AEC082A30C17A0003F34A /* MainNavCoordinator.swift */, ); path = KaMPKitiOS; @@ -386,6 +391,7 @@ files = ( 46B5284D249C5CF400A7725D /* Koin.swift in Sources */, 3C73D04A2A335103003E6929 /* BreedsViewModel.swift in Sources */, + 3C61D1352A55909300D4DF1D /* BreedDetailsViewModel.swift in Sources */, 46A5B5EF26AF54F7002EFEAA /* BreedsScreen.swift in Sources */, 46A5B5EF26AF54F7002EFEAA /* BreedsScreen.swift in Sources */, F1465F0123AA94BF0055F7C3 /* AppDelegate.swift in Sources */, diff --git a/ios/KaMPKitiOS/BreedDetails/BreedDetailsNavController.swift b/ios/KaMPKitiOS/BreedDetails/BreedDetailsNavController.swift deleted file mode 100644 index 013b8d05..00000000 --- a/ios/KaMPKitiOS/BreedDetails/BreedDetailsNavController.swift +++ /dev/null @@ -1,9 +0,0 @@ -// -// BreedDetailsNavController.swift -// KaMPKitiOS -// -// Created by Bartłomiej Pedryc on 16/06/2023. -// Copyright © 2023 Touchlab. All rights reserved. -// - -import Foundation diff --git a/ios/KaMPKitiOS/BreedDetails/BreedDetailsScreen.swift b/ios/KaMPKitiOS/BreedDetails/BreedDetailsScreen.swift new file mode 100644 index 00000000..b2c5676f --- /dev/null +++ b/ios/KaMPKitiOS/BreedDetails/BreedDetailsScreen.swift @@ -0,0 +1,45 @@ +// +// BreedDetailsScreen.swift +// KaMPKitiOS +// +// Created by Bartłomiej Pedryc on 07/06/2023. +// Copyright © 2023 Touchlab. All rights reserved. +// + +import SwiftUI +import shared +import Foundation + +struct BreedDetailsScreen: View { + @StateObject + var viewModel: BreedDetailsViewModel + + var body: some View { + BreedDetailsContent( + breedName: viewModel.detailsState.breed.name, + isBreedFavorite: viewModel.detailsState.breed.favorite, + onFavoriteClick: { viewModel.onFavoriteClick() } + ) + .onAppear(perform: { + viewModel.activate() + }) + .onDisappear(perform: { + viewModel.deactivate() + }) + } +} + +struct BreedDetailsContent: View { + var breedName: String + var isBreedFavorite: Bool + var onFavoriteClick: () -> Void + var body: some View { + HStack { + Text(breedName) + Button(action: onFavoriteClick) { + Image(systemName: (!isBreedFavorite) ? "heart" : "heart.fill") + .padding(4.0) + } + } + } +} diff --git a/ios/KaMPKitiOS/BreedDetailsScreen.swift b/ios/KaMPKitiOS/BreedDetails/BreedDetailsViewModel.swift similarity index 52% rename from ios/KaMPKitiOS/BreedDetailsScreen.swift rename to ios/KaMPKitiOS/BreedDetails/BreedDetailsViewModel.swift index 2fd88cd2..77eafaaf 100644 --- a/ios/KaMPKitiOS/BreedDetailsScreen.swift +++ b/ios/KaMPKitiOS/BreedDetails/BreedDetailsViewModel.swift @@ -1,16 +1,15 @@ // -// BreedDetailsScreen.swift +// BreedDetailsViewModel.swift // KaMPKitiOS // -// Created by Bartłomiej Pedryc on 07/06/2023. +// Created by Bartłomiej Pedryc on 05/07/2023. // Copyright © 2023 Touchlab. All rights reserved. // import Combine -import SwiftUI +import Foundation import shared import KMPNativeCoroutinesCombine -import Foundation class BreedDetailsViewModel: ObservableObject { private var viewModelDelegate: BreedDetailsViewModelDelegate @@ -45,37 +44,3 @@ class BreedDetailsViewModel: ObservableObject { viewModelDelegate.clear() } } - -struct BreedDetailsScreen: View { - @StateObject - var viewModel: BreedDetailsViewModel - - var body: some View { - BreedDetailsContent( - breedName: viewModel.detailsState.breed.name, - isBreedFavorite: viewModel.detailsState.breed.favorite, - onFavoriteClick: { viewModel.onFavoriteClick() } - ) - .onAppear(perform: { - viewModel.activate() - }) - .onDisappear(perform: { - viewModel.deactivate() - }) - } -} - -struct BreedDetailsContent: View { - var breedName: String - var isBreedFavorite: Bool - var onFavoriteClick: () -> Void - var body: some View { - HStack { - Text(breedName) - Button(action: onFavoriteClick) { - Image(systemName: (!isBreedFavorite) ? "heart" : "heart.fill") - .padding(4.0) - } - } - } -} diff --git a/ios/KaMPKitiOS/Breeds/BreedsNavController.swift b/ios/KaMPKitiOS/Breeds/BreedsNavController.swift deleted file mode 100644 index 9ac7f21a..00000000 --- a/ios/KaMPKitiOS/Breeds/BreedsNavController.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// BreedsNavController.swift -// KaMPKitiOS -// -// Created by Bartłomiej Pedryc on 16/06/2023. -// Copyright © 2023 Touchlab. All rights reserved. -// - -import Foundation - -func buildBreedsNavController(navController: BreedsNavController) -> UIViewController { - let viewModel = Kotlin -} From 1bf16955277e8f78eeded3bc488ec5ad75588eac Mon Sep 17 00:00:00 2001 From: bpedryc Date: Wed, 5 Jul 2023 13:55:33 +0200 Subject: [PATCH 12/18] Remove unused overriden method in BreedsViewModel --- .../kotlin/co/touchlab/kampkit/ui/breeds/BreedsViewModel.kt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breeds/BreedsViewModel.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breeds/BreedsViewModel.kt index af861b5a..e309fb38 100644 --- a/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breeds/BreedsViewModel.kt +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breeds/BreedsViewModel.kt @@ -30,10 +30,6 @@ class BreedsViewModel( observeBreeds() } - override fun onCleared() { - log.v("Clearing BreedViewModel") - } - private fun observeBreeds() { // Refresh breeds, and emit any exception that was thrown so we can handle it downstream val refreshFlow = flow { From 027cd3842c8c46e91fcd93da38bc5b7a97bb3770 Mon Sep 17 00:00:00 2001 From: bpedryc Date: Mon, 10 Jul 2023 12:53:55 +0200 Subject: [PATCH 13/18] Provide BreedDetailsViewModel parameter for iOS koin testing --- shared/src/iosTest/kotlin/co/touchlab/kampkit/KoinTest.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/shared/src/iosTest/kotlin/co/touchlab/kampkit/KoinTest.kt b/shared/src/iosTest/kotlin/co/touchlab/kampkit/KoinTest.kt index 52631c11..c2d502e5 100644 --- a/shared/src/iosTest/kotlin/co/touchlab/kampkit/KoinTest.kt +++ b/shared/src/iosTest/kotlin/co/touchlab/kampkit/KoinTest.kt @@ -1,6 +1,7 @@ package co.touchlab.kampkit import co.touchlab.kampkit.core.initKoinIos +import co.touchlab.kampkit.ui.breedDetails.BreedDetailsViewModel import co.touchlab.kermit.Logger import org.koin.core.context.stopKoin import org.koin.core.parameter.parametersOf @@ -18,6 +19,7 @@ class KoinTest { doOnStartup = { } ).checkModules { withParameters { parametersOf("TestTag") } + withParameters { parametersOf(0L) } } } From 2cdaa90c7beefe8d5e484524ac936ae43ef47b8c Mon Sep 17 00:00:00 2001 From: bpedryc Date: Tue, 11 Jul 2023 10:38:59 +0200 Subject: [PATCH 14/18] Make the BreedDetailsViewModel naming consistent --- .../BreedDetails/BreedDetailsScreen.swift | 8 +++--- .../BreedDetails/BreedDetailsViewModel.swift | 26 +++++++++---------- ios/KaMPKitiOS/Breeds/BreedsViewModel.swift | 4 +-- 3 files changed, 18 insertions(+), 20 deletions(-) diff --git a/ios/KaMPKitiOS/BreedDetails/BreedDetailsScreen.swift b/ios/KaMPKitiOS/BreedDetails/BreedDetailsScreen.swift index b2c5676f..41a54491 100644 --- a/ios/KaMPKitiOS/BreedDetails/BreedDetailsScreen.swift +++ b/ios/KaMPKitiOS/BreedDetails/BreedDetailsScreen.swift @@ -16,15 +16,15 @@ struct BreedDetailsScreen: View { var body: some View { BreedDetailsContent( - breedName: viewModel.detailsState.breed.name, - isBreedFavorite: viewModel.detailsState.breed.favorite, + breedName: viewModel.state.breed.name, + isBreedFavorite: viewModel.state.breed.favorite, onFavoriteClick: { viewModel.onFavoriteClick() } ) .onAppear(perform: { - viewModel.activate() + viewModel.subscribeState() }) .onDisappear(perform: { - viewModel.deactivate() + viewModel.unsubscribeState() }) } } diff --git a/ios/KaMPKitiOS/BreedDetails/BreedDetailsViewModel.swift b/ios/KaMPKitiOS/BreedDetails/BreedDetailsViewModel.swift index 77eafaaf..0d21c6aa 100644 --- a/ios/KaMPKitiOS/BreedDetails/BreedDetailsViewModel.swift +++ b/ios/KaMPKitiOS/BreedDetails/BreedDetailsViewModel.swift @@ -12,35 +12,33 @@ import shared import KMPNativeCoroutinesCombine class BreedDetailsViewModel: ObservableObject { - private var viewModelDelegate: BreedDetailsViewModelDelegate + @Published var state: BreedDetailsViewState = BreedDetailsViewState.companion.default() + + private var viewModelDelegate: BreedDetailsViewModelDelegate + private var cancellables = [AnyCancellable]() init(breedId: Int64) { self.viewModelDelegate = KotlinDependencies.shared.getBreedDetailsViewModel(breedId: breedId) } - @Published - var detailsState: BreedDetailsViewState = BreedDetailsViewState.companion.default() - - private var cancellables = [AnyCancellable]() - - func onFavoriteClick() { - viewModelDelegate.onFavoriteClick() + deinit { + viewModelDelegate.clear() } - - func activate() { + + func subscribeState() { createPublisher(for: viewModelDelegate.detailsStateFlow) .sink { _ in } receiveValue: { [weak self] (detailsState: BreedDetailsViewState) in - self?.detailsState = detailsState + self?.state = detailsState } .store(in: &cancellables) } - func deactivate() { + func unsubscribeState() { cancellables.forEach { $0.cancel() } cancellables.removeAll() } - deinit { - viewModelDelegate.clear() + func onFavoriteClick() { + viewModelDelegate.onFavoriteClick() } } diff --git a/ios/KaMPKitiOS/Breeds/BreedsViewModel.swift b/ios/KaMPKitiOS/Breeds/BreedsViewModel.swift index f7375eb3..8d6d725e 100644 --- a/ios/KaMPKitiOS/Breeds/BreedsViewModel.swift +++ b/ios/KaMPKitiOS/Breeds/BreedsViewModel.swift @@ -19,8 +19,8 @@ class BreedsViewModel: ObservableObject { private var viewModelDelegate: BreedsViewModelDelegate = KotlinDependencies.shared.getBreedsViewModel() private var cancellables = [AnyCancellable]() init(navCoordinator: BreedsNavCoordinator) { - self.navCoordinator = navCoordinator - } + self.navCoordinator = navCoordinator + } deinit { viewModelDelegate.clear() From 0275873283587db8bd3e22457b0a010ff08ad16a Mon Sep 17 00:00:00 2001 From: bpedryc Date: Fri, 4 Aug 2023 09:14:18 +0200 Subject: [PATCH 15/18] PR fixes --- .../main/kotlin/co/touchlab/kampkit/android/MainApp.kt | 2 +- .../co/touchlab/kampkit/android/ui/BreedsScreen.kt | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/src/main/kotlin/co/touchlab/kampkit/android/MainApp.kt b/app/src/main/kotlin/co/touchlab/kampkit/android/MainApp.kt index 6b261ada..c259a2df 100644 --- a/app/src/main/kotlin/co/touchlab/kampkit/android/MainApp.kt +++ b/app/src/main/kotlin/co/touchlab/kampkit/android/MainApp.kt @@ -22,7 +22,7 @@ class MainApp : Application() { viewModel { BreedsViewModel(get(), get { parametersOf("BreedsViewModel") }) } - viewModel {params -> + viewModel { params -> BreedDetailsViewModel( params.get(), get(), get { parametersOf("BreedDetailsViewModel") } ) diff --git a/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedsScreen.kt b/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedsScreen.kt index b0f0fc9c..9b7dcae6 100644 --- a/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedsScreen.kt +++ b/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedsScreen.kt @@ -158,15 +158,15 @@ fun DogRow(breed: Breed, onClick: (Breed) -> Unit) { @Composable fun FavoriteIcon(breed: Breed) { - if (!breed.favorite) { + if (breed.favorite) { Image( - painter = painterResource(id = R.drawable.ic_favorite_border_24px), - contentDescription = stringResource(R.string.favorite_breed, breed.name) + painter = painterResource(id = R.drawable.ic_favorite_24px), + contentDescription = stringResource(R.string.unfavorite_breed, breed.name) ) } else { Image( - painter = painterResource(id = R.drawable.ic_favorite_24px), - contentDescription = stringResource(R.string.unfavorite_breed, breed.name) + painter = painterResource(id = R.drawable.ic_favorite_border_24px), + contentDescription = stringResource(R.string.favorite_breed, breed.name) ) } } From e374c35179d9641276f6b094da474d3519dc3594 Mon Sep 17 00:00:00 2001 From: bpedryc Date: Wed, 22 Nov 2023 14:21:25 +0100 Subject: [PATCH 16/18] Handle error and loading on BreedsScreen --- .../kampkit/android/ui/BreedDetailsScreen.kt | 53 ++++++++++++------- .../kampkit/android/ui/BreedsScreen.kt | 2 +- ios/KaMPKitiOS.xcodeproj/project.pbxproj | 25 +++++++++ .../xcshareddata/swiftpm/Package.resolved | 23 ++++++++ .../ui/breedDetails/BreedDetailsViewState.kt | 2 +- 5 files changed, 85 insertions(+), 20 deletions(-) create mode 100644 ios/KaMPKitiOS.xcworkspace/xcshareddata/swiftpm/Package.resolved diff --git a/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedDetailsScreen.kt b/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedDetailsScreen.kt index 5abfd4e4..50fe89ea 100644 --- a/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedDetailsScreen.kt +++ b/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedDetailsScreen.kt @@ -6,6 +6,7 @@ import androidx.compose.animation.core.TweenSpec import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -15,7 +16,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -23,30 +23,47 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import co.touchlab.kampkit.android.R import co.touchlab.kampkit.domain.breed.Breed import co.touchlab.kampkit.ui.breedDetails.BreedDetailsViewModel -import co.touchlab.kermit.Logger +import co.touchlab.kampkit.ui.breedDetails.BreedDetailsViewState @Composable -fun BreedDetailsScreen( - viewModel: BreedDetailsViewModel, - log: Logger -) { +fun BreedDetailsScreen(viewModel: BreedDetailsViewModel) { val state by viewModel.detailsState.collectAsStateWithLifecycle() + val error = state.error Box(Modifier.fillMaxSize()) { - state.error?.let { error -> - Text(error, Modifier.align(Alignment.Center), color = Color.Red) - } - if (state.error == null) { - Row(Modifier.align(Alignment.Center)) { - Text(state.breed.name) - Spacer(Modifier.width(4.dp)) - FavoriteIcon( - breed = state.breed, - onClick = viewModel::onFavoriteClick - ) - } + when { + state.isLoading -> Loading() + error != null -> Error(error) + else -> DetailsContents( + state = state, + onFavoriteClick = viewModel::onFavoriteClick + ) } } +} +@Composable +private fun BoxScope.DetailsContents( + state: BreedDetailsViewState, + onFavoriteClick: () -> Unit +) { + Row(Modifier.align(Alignment.Center)) { + Text(state.breed?.name ?: "") + Spacer(Modifier.width(4.dp)) + state.breed?.let { breed -> + FavoriteIcon( + breed = breed, + onClick = onFavoriteClick + ) + } + } +} +@Composable +private fun Error(error: String) { + Text(error) +} +@Composable +fun Loading() { + Text("Loading") } @Composable diff --git a/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedsScreen.kt b/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedsScreen.kt index 9b7dcae6..c1bd08b3 100644 --- a/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedsScreen.kt +++ b/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedsScreen.kt @@ -112,7 +112,7 @@ fun Empty() { } @Composable -fun Error(error: String) { +private fun Error(error: String) { Column( modifier = Modifier .fillMaxSize() diff --git a/ios/KaMPKitiOS.xcodeproj/project.pbxproj b/ios/KaMPKitiOS.xcodeproj/project.pbxproj index 9ec20cfd..088a4ea6 100644 --- a/ios/KaMPKitiOS.xcodeproj/project.pbxproj +++ b/ios/KaMPKitiOS.xcodeproj/project.pbxproj @@ -113,6 +113,30 @@ path = Breeds; sourceTree = ""; }; + 579753BDA8BAA9A5A399C611 /* bartlomiejpedryc.xcuserdatad */ = { + isa = PBXGroup; + children = ( + 57975F3C51683ABE14191024 /* xcschemes */, + ); + path = bartlomiejpedryc.xcuserdatad; + sourceTree = ""; + }; + 5797556592C6669DF1E7702C /* xcuserdata */ = { + isa = PBXGroup; + children = ( + 579753BDA8BAA9A5A399C611 /* bartlomiejpedryc.xcuserdatad */, + ); + name = xcuserdata; + path = KaMPKitiOS.xcodeproj/xcuserdata; + sourceTree = ""; + }; + 57975F3C51683ABE14191024 /* xcschemes */ = { + isa = PBXGroup; + children = ( + ); + path = xcschemes; + sourceTree = ""; + }; 6278498AD96A4D949D39BF44 /* Frameworks */ = { isa = PBXGroup; children = ( @@ -141,6 +165,7 @@ F1465EFE23AA94BF0055F7C3 /* Products */, DF9BBECBCD175B90105DA8D9 /* Pods */, 6278498AD96A4D949D39BF44 /* Frameworks */, + 5797556592C6669DF1E7702C /* xcuserdata */, ); sourceTree = ""; }; diff --git a/ios/KaMPKitiOS.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/KaMPKitiOS.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..b2334037 --- /dev/null +++ b/ios/KaMPKitiOS.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,23 @@ +{ + "pins" : [ + { + "identity" : "kmp-nativecoroutines", + "kind" : "remoteSourceControl", + "location" : "https://github.com/rickclephas/KMP-NativeCoroutines.git", + "state" : { + "revision" : "a1269d3052fcc32d3861eaea32b715d58a46fe32", + "version" : "1.0.0-ALPHA-9" + } + }, + { + "identity" : "rxswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ReactiveX/RxSwift.git", + "state" : { + "revision" : "9dcaa4b333db437b0fbfaf453fad29069044a8b4", + "version" : "6.6.0" + } + } + ], + "version" : 2 +} diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDetailsViewState.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDetailsViewState.kt index f188b128..d6e144ec 100644 --- a/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDetailsViewState.kt +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/ui/breedDetails/BreedDetailsViewState.kt @@ -3,7 +3,7 @@ package co.touchlab.kampkit.ui.breedDetails import co.touchlab.kampkit.domain.breed.Breed data class BreedDetailsViewState( - val breed: Breed = Breed(), + val breed: Breed? = null, val error: String? = null, val isLoading: Boolean = false ) { From 81953ebbb21a6227125075070fd4aae8d8d995e3 Mon Sep 17 00:00:00 2001 From: bpedryc Date: Thu, 23 Nov 2023 10:02:34 +0100 Subject: [PATCH 17/18] pr fixes --- .../kampkit/android/ui/BreedDetailsScreen.kt | 15 +++++++-------- .../touchlab/kampkit/android/ui/BreedsScreen.kt | 7 ++++--- .../kampkit/android/ui/MainNavCoordinator.kt | 2 -- ios/Pods/Pods.xcodeproj/project.pbxproj | 9 +++++++++ .../co/touchlab/kampkit/domain/breed/Breed.kt | 6 +++--- 5 files changed, 23 insertions(+), 16 deletions(-) diff --git a/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedDetailsScreen.kt b/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedDetailsScreen.kt index 50fe89ea..7fa9c0c6 100644 --- a/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedDetailsScreen.kt +++ b/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedDetailsScreen.kt @@ -5,17 +5,14 @@ import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.TweenSpec import androidx.compose.foundation.Image import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.* +import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -57,13 +54,15 @@ private fun BoxScope.DetailsContents( } } } + @Composable private fun Error(error: String) { - Text(error) + Text(error, color = Color.Red) } + @Composable fun Loading() { - Text("Loading") + CircularProgressIndicator() } @Composable diff --git a/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedsScreen.kt b/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedsScreen.kt index c1bd08b3..ac905db0 100644 --- a/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedsScreen.kt +++ b/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedsScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -99,7 +100,7 @@ fun BreedsScreenContent( } @Composable -fun Empty() { +private fun Empty() { Column( modifier = Modifier .fillMaxSize() @@ -120,12 +121,12 @@ private fun Error(error: String) { verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { - Text(text = error) + Text(text = error, color = Color.Red) } } @Composable -fun Success( +private fun Success( successData: List, onBreedClick: (breedId: Long) -> Unit ) { diff --git a/app/src/main/kotlin/co/touchlab/kampkit/android/ui/MainNavCoordinator.kt b/app/src/main/kotlin/co/touchlab/kampkit/android/ui/MainNavCoordinator.kt index 4ec264a3..426640e2 100644 --- a/app/src/main/kotlin/co/touchlab/kampkit/android/ui/MainNavCoordinator.kt +++ b/app/src/main/kotlin/co/touchlab/kampkit/android/ui/MainNavCoordinator.kt @@ -12,7 +12,6 @@ import org.koin.androidx.compose.koinViewModel import org.koin.core.parameter.parametersOf private const val BREEDS = "breeds" - private const val BREED_DETAILS = "breedDetails" private const val BREED_ID_ARG = "breedId" @@ -36,7 +35,6 @@ fun MainNavCoordinator() { val breedId = it.arguments?.getLong(BREED_ID_ARG) BreedDetailsScreen( viewModel = koinViewModel { parametersOf(breedId) }, - log = get { parametersOf("BreedDetailsScreen") } ) } } diff --git a/ios/Pods/Pods.xcodeproj/project.pbxproj b/ios/Pods/Pods.xcodeproj/project.pbxproj index 79a804f9..193e5a24 100644 --- a/ios/Pods/Pods.xcodeproj/project.pbxproj +++ b/ios/Pods/Pods.xcodeproj/project.pbxproj @@ -93,6 +93,7 @@ 46EB2E00000080 /* Pods */, 46EB2E00000020 /* Products */, 46EB2E00000070 /* Targets Support Files */, + 57975FCC640FD6F802D419B1 /* xcschemes */, ); sourceTree = ""; }; @@ -217,6 +218,14 @@ path = "Target Support Files/Pods-KaMPKitiOS"; sourceTree = ""; }; + 57975FCC640FD6F802D419B1 /* xcschemes */ = { + isa = PBXGroup; + children = ( + ); + name = xcschemes; + path = Pods.xcodeproj/xcuserdata/bartlomiejpedryc.xcuserdatad/xcschemes; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ diff --git a/shared/src/commonMain/kotlin/co/touchlab/kampkit/domain/breed/Breed.kt b/shared/src/commonMain/kotlin/co/touchlab/kampkit/domain/breed/Breed.kt index 2711e940..27c7787e 100644 --- a/shared/src/commonMain/kotlin/co/touchlab/kampkit/domain/breed/Breed.kt +++ b/shared/src/commonMain/kotlin/co/touchlab/kampkit/domain/breed/Breed.kt @@ -1,7 +1,7 @@ package co.touchlab.kampkit.domain.breed data class Breed( - val id: Long = 0, - val name: String = "", - val favorite: Boolean = false + val id: Long, + val name: String, + val favorite: Boolean ) From 0af4dc8fc578e9c7b00bd04de838e25b2ce1442e Mon Sep 17 00:00:00 2001 From: bpedryc Date: Thu, 23 Nov 2023 10:52:54 +0100 Subject: [PATCH 18/18] Handle errors and loading on ios --- .../kampkit/android/ui/BreedDetailsScreen.kt | 12 +++++++---- .../BreedDetails/BreedDetailsScreen.swift | 21 ++++++++++++++----- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedDetailsScreen.kt b/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedDetailsScreen.kt index 7fa9c0c6..2b9a8962 100644 --- a/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedDetailsScreen.kt +++ b/app/src/main/kotlin/co/touchlab/kampkit/android/ui/BreedDetailsScreen.kt @@ -56,13 +56,17 @@ private fun BoxScope.DetailsContents( } @Composable -private fun Error(error: String) { - Text(error, color = Color.Red) +private fun BoxScope.Error(error: String) { + Text( + text = error, + color = Color.Red, + modifier = Modifier.align(Alignment.Center) + ) } @Composable -fun Loading() { - CircularProgressIndicator() +fun BoxScope.Loading() { + CircularProgressIndicator(Modifier.align(Alignment.Center)) } @Composable diff --git a/ios/KaMPKitiOS/BreedDetails/BreedDetailsScreen.swift b/ios/KaMPKitiOS/BreedDetails/BreedDetailsScreen.swift index 41a54491..c6de5b2f 100644 --- a/ios/KaMPKitiOS/BreedDetails/BreedDetailsScreen.swift +++ b/ios/KaMPKitiOS/BreedDetails/BreedDetailsScreen.swift @@ -15,11 +15,22 @@ struct BreedDetailsScreen: View { var viewModel: BreedDetailsViewModel var body: some View { - BreedDetailsContent( - breedName: viewModel.state.breed.name, - isBreedFavorite: viewModel.state.breed.favorite, - onFavoriteClick: { viewModel.onFavoriteClick() } - ) + let state = viewModel.state + ZStack { + if let error = state.error { + Text(error) + } + if state.error == nil && state.isLoading { + ProgressView() + } + if let breed = state.breed, state.error == nil, !state.isLoading { + BreedDetailsContent( + breedName: breed.name, + isBreedFavorite: breed.favorite, + onFavoriteClick: { viewModel.onFavoriteClick() } + ) + } + } .onAppear(perform: { viewModel.subscribeState() })