From c5890ffcbf86e40be52197037fce1b654a719448 Mon Sep 17 00:00:00 2001 From: hfhbd Date: Wed, 9 Nov 2022 13:29:39 +0100 Subject: [PATCH] Use molecule --- build.gradle.kts | 1 + clients/build.gradle.kts | 37 +++++++- .../composetodo/viewmodels/LoginViewModel.kt | 47 ++++++++-- .../kotlin/app/softwork/composetodo/Flows.kt | 0 .../app/softwork/composetodo/IosContainer.kt | 7 +- .../composetodo/viewmodels/ViewModeliOS.kt | 0 .../app/softwork/composetodo/FlowsTest.kt | 0 .../composetodo/UserDefaultsCookieStorage.kt | 18 ---- .../app/softwork/composetodo/FlowsTest.kt | 51 ----------- .../app/softwork/composetodo/views/Login.kt | 21 ++--- gradle.properties | 2 +- iosApp/Shared/AsyncStream.swift | 6 +- iosApp/Shared/ContentView.swift | 89 ++++++++++++++----- iosApp/Shared/ViewModel.swift | 4 + iosApp/Shared/composetodoApp.swift | 23 ++++- kotlin-js-store/yarn.lock | 8 -- shared/build.gradle.kts | 4 + .../app/softwork/composetodo/login/Login.kt | 24 ++--- 18 files changed, 201 insertions(+), 141 deletions(-) rename clients/src/{iosArm64Main => darwinMain}/kotlin/app/softwork/composetodo/Flows.kt (100%) rename clients/src/{iosArm64Main => darwinMain}/kotlin/app/softwork/composetodo/IosContainer.kt (88%) rename clients/src/{iosArm64Main => darwinMain}/kotlin/app/softwork/composetodo/viewmodels/ViewModeliOS.kt (100%) create mode 100644 clients/src/darwinTest/kotlin/app/softwork/composetodo/FlowsTest.kt delete mode 100644 clients/src/iosArm64Main/kotlin/app/softwork/composetodo/UserDefaultsCookieStorage.kt delete mode 100644 clients/src/iosArm64Test/kotlin/app/softwork/composetodo/FlowsTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index 54bfa720..461e8302 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -14,6 +14,7 @@ plugins { id("io.gitlab.arturbosch.detekt") version "1.21.0" } +// https://issuetracker.google.com/issues/240445963 buildscript { dependencies { classpath("org.apache.commons:commons-compress:1.22") diff --git a/clients/build.gradle.kts b/clients/build.gradle.kts index 6afd5960..13a80130 100644 --- a/clients/build.gradle.kts +++ b/clients/build.gradle.kts @@ -40,6 +40,13 @@ kotlin { config() } + watchosArm64 { + config() + } + watchosSimulatorArm64 { + config() + } + js(IR) { browser() } @@ -51,7 +58,7 @@ kotlin { dependencies { api(projects.shared) implementation("app.cash.sqldelight:coroutines-extensions:$sqlDelight") - + api("app.cash.molecule:molecule-runtime:0.6.0") api("io.ktor:ktor-client-logging:$ktor") } } @@ -70,21 +77,45 @@ kotlin { } } - val iosArm64Main by getting { + val darwinMain by creating { + dependsOn(commonMain.get()) dependencies { implementation("io.ktor:ktor-client-darwin:$ktor") implementation("app.cash.sqldelight:native-driver:$sqlDelight") } } + val darwinTest by creating { + dependsOn(commonTest.get()) + } + + val iosArm64Main by getting { + dependsOn(darwinMain) + } val iosSimulatorArm64Main by getting { dependsOn(iosArm64Main) } - val iosArm64Test by getting + val iosArm64Test by getting { + dependsOn(darwinTest) + } val iosSimulatorArm64Test by getting { dependsOn(iosArm64Test) } + + val watchosArm64Main by getting { + dependsOn(darwinMain) + } + val watchosArm64Test by getting { + dependsOn(darwinTest) + } + val watchosSimulatorArm64Main by getting { + dependsOn(darwinMain) + } + val watchosSimulatorArm64Test by getting { + dependsOn(darwinTest) + } + val jsMain by getting { dependencies { api("app.cash.sqldelight:sqljs-driver:$sqlDelight") diff --git a/clients/src/commonMain/kotlin/app/softwork/composetodo/viewmodels/LoginViewModel.kt b/clients/src/commonMain/kotlin/app/softwork/composetodo/viewmodels/LoginViewModel.kt index 81c4d5f5..0da36b8b 100644 --- a/clients/src/commonMain/kotlin/app/softwork/composetodo/viewmodels/LoginViewModel.kt +++ b/clients/src/commonMain/kotlin/app/softwork/composetodo/viewmodels/LoginViewModel.kt @@ -1,5 +1,7 @@ package app.softwork.composetodo.viewmodels +import androidx.compose.runtime.* +import app.cash.molecule.* import app.softwork.composetodo.* import kotlinx.coroutines.* import kotlinx.coroutines.flow.* @@ -8,27 +10,54 @@ class LoginViewModel( private val api: API.LoggedOut, private val onLogin: (API.LoggedIn) -> Unit ) : ViewModel() { - val userName = MutableStateFlow("") - val password = MutableStateFlow("") + data class LoginState( + val userName: String, + val password: String, + val enableLogin: Boolean, + val error: Failure? + ) - val error = MutableStateFlow(null) + private var userName by mutableStateOf("") + fun updateUserName(new: String) { + userName = new + } + + private var password by mutableStateOf("") + fun updatePassword(new: String) { + password = new + } + + private var error by mutableStateOf(null) + fun dismissError() { + error = null + } + + fun state( + coroutineScope: CoroutineScope, + clock: RecompositionClock = RecompositionClock.ContextClock + ): StateFlow = coroutineScope.launchMolecule(clock) { + val isError = userName.isNotEmpty() && password.isNotEmpty() - val enableLogin = userName.combine(password) { userName, password -> - userName.isNotEmpty() && password.isNotEmpty() + LoginState( + userName = userName, + password = password, + enableLogin = isError, + error = error + ) } fun login() { - error.value = null + error = null lifecycleScope.launch { api.networkCall( action = { - login(username = userName.value, password = password.value) + login(username = userName, password = password) }, onSuccess = { - error.value = null + error = null onLogin(it) } ) { - error.value = it + error = it } } } diff --git a/clients/src/iosArm64Main/kotlin/app/softwork/composetodo/Flows.kt b/clients/src/darwinMain/kotlin/app/softwork/composetodo/Flows.kt similarity index 100% rename from clients/src/iosArm64Main/kotlin/app/softwork/composetodo/Flows.kt rename to clients/src/darwinMain/kotlin/app/softwork/composetodo/Flows.kt diff --git a/clients/src/iosArm64Main/kotlin/app/softwork/composetodo/IosContainer.kt b/clients/src/darwinMain/kotlin/app/softwork/composetodo/IosContainer.kt similarity index 88% rename from clients/src/iosArm64Main/kotlin/app/softwork/composetodo/IosContainer.kt rename to clients/src/darwinMain/kotlin/app/softwork/composetodo/IosContainer.kt index c9016ea8..13ec12ba 100644 --- a/clients/src/iosArm64Main/kotlin/app/softwork/composetodo/IosContainer.kt +++ b/clients/src/darwinMain/kotlin/app/softwork/composetodo/IosContainer.kt @@ -16,15 +16,16 @@ import kotlinx.coroutines.flow.* class IosContainer( protocol: URLProtocol, - host: String + host: String, + storage: CookiesStorage ) : AppContainer { private val db = TodoRepository.createDatabase(NativeSqliteDriver(ComposeTodoDB.Schema, "composetodo.db")) - constructor() : this(protocol = URLProtocol.HTTPS, host = "api.todo.softwork.app") + constructor(storage: CookiesStorage) : this(protocol = URLProtocol.HTTPS, host = "api.todo.softwork.app", storage = storage) override val client: HttpClient = HttpClient(Darwin) { install(HttpCookies) { - storage = UserDefaultsCookieStorage() + this.storage = storage } install(DefaultRequest) { url { diff --git a/clients/src/iosArm64Main/kotlin/app/softwork/composetodo/viewmodels/ViewModeliOS.kt b/clients/src/darwinMain/kotlin/app/softwork/composetodo/viewmodels/ViewModeliOS.kt similarity index 100% rename from clients/src/iosArm64Main/kotlin/app/softwork/composetodo/viewmodels/ViewModeliOS.kt rename to clients/src/darwinMain/kotlin/app/softwork/composetodo/viewmodels/ViewModeliOS.kt diff --git a/clients/src/darwinTest/kotlin/app/softwork/composetodo/FlowsTest.kt b/clients/src/darwinTest/kotlin/app/softwork/composetodo/FlowsTest.kt new file mode 100644 index 00000000..e69de29b diff --git a/clients/src/iosArm64Main/kotlin/app/softwork/composetodo/UserDefaultsCookieStorage.kt b/clients/src/iosArm64Main/kotlin/app/softwork/composetodo/UserDefaultsCookieStorage.kt deleted file mode 100644 index 0a04ac86..00000000 --- a/clients/src/iosArm64Main/kotlin/app/softwork/composetodo/UserDefaultsCookieStorage.kt +++ /dev/null @@ -1,18 +0,0 @@ -package app.softwork.composetodo - -import io.ktor.client.plugins.cookies.* -import io.ktor.http.* -import platform.Foundation.* - -class UserDefaultsCookieStorage : CookiesStorage { - override suspend fun addCookie(requestUrl: Url, cookie: Cookie) { - NSUserDefaults.standardUserDefaults.setValue(cookie.value, forKey = "refreshToken") - } - - override fun close() {} - - override suspend fun get(requestUrl: Url): List = - NSUserDefaults.standardUserDefaults.stringForKey("refreshToken")?.let { - listOf(Cookie(name = "SESSION", value = it, secure = true, httpOnly = true)) - } ?: emptyList() -} diff --git a/clients/src/iosArm64Test/kotlin/app/softwork/composetodo/FlowsTest.kt b/clients/src/iosArm64Test/kotlin/app/softwork/composetodo/FlowsTest.kt deleted file mode 100644 index 0e8bbbfa..00000000 --- a/clients/src/iosArm64Test/kotlin/app/softwork/composetodo/FlowsTest.kt +++ /dev/null @@ -1,51 +0,0 @@ -package app.softwork.composetodo - -import kotlinx.coroutines.* -import kotlinx.coroutines.flow.* -import kotlinx.coroutines.test.* -import kotlin.test.* - -@ExperimentalCoroutinesApi -class FlowsTest { - @Test - fun toAsyncTest() = runTest { - val called = mutableListOf() - val expected = flowOf(1, 2, 3).onEach { - called += it - } - val iterator = expected.asAsyncIterable(coroutineContext) - val values = buildList { - while (true) { - val next = iterator.next() ?: break - add(next) - } - } - assertEquals(listOf(1, 2, 3), values) - assertEquals(listOf(1, 2, 3), called) - } - - @Test - fun toAsyncCancelTest() = runTest { - val computed = mutableListOf() - val expected = flowOf(1, 2, 3).onEach { - computed += it - } - val iterator = expected.asAsyncIterable(coroutineContext) - val next = iterator.next() - assertNotNull(next) - iterator.cancel() - assertEquals(1, next) - assertEquals(listOf(1), computed) - } - - @Test - fun toAsyncCancelNoEmitsTest() = runTest { - val computed = mutableListOf() - val expected = flowOf(1, 2, 3).onEach { - computed += it - } - val iterator = expected.asAsyncIterable(coroutineContext) - iterator.cancel() - assertEquals(emptyList(), computed) - } -} diff --git a/composeClients/src/commonMain/kotlin/app/softwork/composetodo/views/Login.kt b/composeClients/src/commonMain/kotlin/app/softwork/composetodo/views/Login.kt index 5e3fa60b..e47ea56f 100644 --- a/composeClients/src/commonMain/kotlin/app/softwork/composetodo/views/Login.kt +++ b/composeClients/src/commonMain/kotlin/app/softwork/composetodo/views/Login.kt @@ -8,31 +8,28 @@ import app.softwork.composetodo.viewmodels.* @Composable fun Login(viewModel: LoginViewModel) { + val coroutineScope = rememberCoroutineScope() + val state by remember(viewModel, coroutineScope) { viewModel.state(coroutineScope) }.collectAsState() + Column { - val userName by remember { viewModel.userName }.collectAsState() TextField( label = "Username", - value = userName, - onValueChange = { viewModel.userName.value = it }, + value = state.userName, + onValueChange = viewModel::updateUserName, isPassword = false, placeholder = "John Doe" ) - val password by remember { viewModel.password }.collectAsState() TextField( label = "Password", - value = password, - onValueChange = { viewModel.password.value = it }, + value = state.password, + onValueChange = viewModel::updatePassword, isPassword = true, placeholder = "" ) - val enableLogin by remember { viewModel.enableLogin }.collectAsState(false) - - Button("Login", enabled = enableLogin) { viewModel.login() } - - val error by remember { viewModel.error }.collectAsState() + Button("Login", enabled = state.enableLogin) { viewModel.login() } - error?.let { + state.error?.let { Text("ERROR: ${it.reason}") } } diff --git a/gradle.properties b/gradle.properties index de7c9973..78356290 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,10 +2,10 @@ org.gradle.jvmargs=-Xmx2048m org.gradle.parallel=true android.useAndroidX=true -android.enableJetifier=true android.nonTransitiveRClass=true android.disableAutomaticComponentCreation=true kotlin.code.style=official +kotlin.native.cacheKind=none kotlin.native.binary.objcExportSuspendFunctionLaunchThreadRestriction=none diff --git a/iosApp/Shared/AsyncStream.swift b/iosApp/Shared/AsyncStream.swift index 6038b2a8..3bd4096a 100644 --- a/iosApp/Shared/AsyncStream.swift +++ b/iosApp/Shared/AsyncStream.swift @@ -21,9 +21,7 @@ struct FlowStream: AsyncSequence { let iterator: IteratorAsync func next() async -> T? { - return try! await withTaskCancellationHandler(handler: { - iterator.cancel() - }) { + return try! await withTaskCancellationHandler { do { let next = try await iterator.next() if (next == nil) { @@ -39,6 +37,8 @@ struct FlowStream: AsyncSequence { throw error } } + } onCancel: { + iterator.cancel() } } diff --git a/iosApp/Shared/ContentView.swift b/iosApp/Shared/ContentView.swift index adbe4528..9a309d6b 100644 --- a/iosApp/Shared/ContentView.swift +++ b/iosApp/Shared/ContentView.swift @@ -16,7 +16,10 @@ struct ContentView: View { if let isLoggedIn = isLoggedIn as? APILoggedOut { TabView { NavigationView { - Login(viewModel: container.loginViewModel(api: isLoggedIn)) + let loginViewModel = container.loginViewModel(api: isLoggedIn) + Login(state: { + loginViewModel + }) .navigationTitle("Login") }.tabItem { Label("Login", systemImage: "person") @@ -43,36 +46,47 @@ struct ContentView: View { } struct Login: View { - init(viewModel: @autoclosure @escaping () -> LoginViewModel) { - self._viewModel = StateObject(wrappedValue: viewModel()) + init( + state: @escaping () -> StateFlow, + updateUserName: @escaping (String) -> Void, + updatePassword: @escaping (String) -> Void + ) { + self._stateFlow = StateObject(wrappedValue: { + SwiftStateFlow(flow: state()) + }()) + self.updateUserName = updateUserName + self.updatePassword = updatePassword } - - @StateObject var viewModel: LoginViewModel - - @State private var error: Failure? = nil - @State private var disableLogin = true + let updateUserName: (String) -> Void + let updatePassword: (String) -> Void + + @StateObject private var stateFlow: SwiftStateFlow var body: some View { - Form { - TextField("Username", text: viewModel.binding(\.userName)) - SecureField("Password", text: viewModel.binding(\.password)) - - if let error = error { + let state = stateFlow.value as! LoginViewModel.LoginState + + return Form { + TextField("Username", text: Binding(get: { + state.userName + }, set: { + updateUserName(new: $0) + })) + SecureField("Password", text: Binding(get: { + state.userName + }, set: { + updatePassword(new: $0) + })) + + if let error = state.error { Text(error.reason) } }.toolbar { Button("Login") { viewModel.login() } - .disabled(disableLogin) - }.task { - for await newError in viewModel.error.stream(Failure?.self) { - self.error = newError - } + .disabled(!state.enableLogin) }.task { - for await newEnabled in viewModel.enableLogin.stream(Bool.self) { - self.disableLogin = !newEnabled - } + for await _ in stateFlow.stream(LoginViewModel.LoginState.self) { } } } } @@ -114,3 +128,36 @@ struct Register: View { } extension Todo: Swift.Identifiable { } + +class SwiftStateFlow: StateFlow, ObservableObject { + let flow: StateFlow + + var value: Any? { + flow.value + } + + var replayCache: [Any] { flow.replayCache } + + func collect(collector: FlowCollector) async throws { + try await flow.collect(collector: SwiftFlowCollector(collector: collector, objectWillChange: objectWillChange)) + } + + init(flow: StateFlow) { + self.flow = flow + } + + private class SwiftFlowCollector: FlowCollector { + func emit(value: Any?) async throws { + objectWillChange.send() + try await collector.emit(value: value) + } + + let collector: FlowCollector + let objectWillChange: ObjectWillChangePublisher + + init(collector: FlowCollector, objectWillChange: ObjectWillChangePublisher) { + self.collector = collector + self.objectWillChange = objectWillChange + } + } +} diff --git a/iosApp/Shared/ViewModel.swift b/iosApp/Shared/ViewModel.swift index 09d2432c..16a645d6 100644 --- a/iosApp/Shared/ViewModel.swift +++ b/iosApp/Shared/ViewModel.swift @@ -13,18 +13,22 @@ extension ViewModel: ObservableObject { } extension ObservableObject where Self: ViewModel { + @MainActor func binding(_ keyPath: KeyPath) -> Binding { binding(flow: self[keyPath: keyPath], t: String.self) } + @MainActor func binding(_ keyPath: KeyPath) -> Binding { binding(flow: self[keyPath: keyPath], t: Int.self) } + @MainActor func binding(_ keyPath: KeyPath) -> Binding { binding(flow: self[keyPath: keyPath], t: Bool.self) } + @MainActor func binding(_ keyPath: KeyPath, t: T.Type) -> Binding where T: Equatable { binding(flow: self[keyPath: keyPath], t: t) } diff --git a/iosApp/Shared/composetodoApp.swift b/iosApp/Shared/composetodoApp.swift index fcd9de9f..4053d10c 100644 --- a/iosApp/Shared/composetodoApp.swift +++ b/iosApp/Shared/composetodoApp.swift @@ -10,7 +10,7 @@ import clients @main struct ComposeTodoApp: App { - @StateObject var container = IosContainer() + @StateObject var container = IosContainer(storage: UserDefaultStorage()) var body: some Scene { WindowGroup { @@ -18,3 +18,24 @@ struct ComposeTodoApp: App { } } } + +actor UserDefaultStorage: NSObject, Ktor_client_coreCookiesStorage { + @AppStorage("refreshToken") + private var token: String? + + func addCookie(requestUrl: Ktor_httpUrl, cookie: Ktor_httpCookie) async throws { + token = cookie.value + } + + func get(requestUrl: Ktor_httpUrl) async throws -> [Ktor_httpCookie]? { + if let token { + return [Ktor_httpCookie(name: "SESSION", value: token, encoding: Ktor_httpCookieEncoding.uriEncoding, maxAge: 0, expires: nil, domain: nil, path: nil, secure: true, httpOnly: true, extensions: [:])] + } else { + return [] + } + } + + nonisolated func close() { + + } +} diff --git a/kotlin-js-store/yarn.lock b/kotlin-js-store/yarn.lock index a85deff2..d5393615 100644 --- a/kotlin-js-store/yarn.lock +++ b/kotlin-js-store/yarn.lock @@ -2353,14 +2353,6 @@ sass-loader@13.0.0: klona "^2.0.4" neo-async "^2.6.2" -sass-loader@^13.0.0: - version "13.1.0" - resolved "https://registry.yarnpkg.com/sass-loader/-/sass-loader-13.1.0.tgz#e5b9acf14199a9bc6eaed7a0b8b23951c2cebf6f" - integrity sha512-tZS1RJQ2n2+QNyf3CCAo1H562WjL/5AM6Gi8YcPVVoNxQX8d19mx8E+8fRrMWsyc93ZL6Q8vZDSM0FHVTJaVnQ== - dependencies: - klona "^2.0.4" - neo-async "^2.6.2" - sass@1.52.3: version "1.52.3" resolved "https://registry.yarnpkg.com/sass/-/sass-1.52.3.tgz#b7cc7ffea2341ccc9a0c4fd372bf1b3f9be1b6cb" diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index f2a479b4..03d78a4f 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -10,6 +10,10 @@ kotlin { } iosArm64() iosSimulatorArm64() + + watchosArm64() + watchosSimulatorArm64() + jvm() explicitApi() diff --git a/web/src/main/kotlin/app/softwork/composetodo/login/Login.kt b/web/src/main/kotlin/app/softwork/composetodo/login/Login.kt index 04621873..f0acff5b 100644 --- a/web/src/main/kotlin/app/softwork/composetodo/login/Login.kt +++ b/web/src/main/kotlin/app/softwork/composetodo/login/Login.kt @@ -1,6 +1,7 @@ package app.softwork.composetodo.login import androidx.compose.runtime.* +import app.cash.molecule.* import app.softwork.bootstrapcompose.* import app.softwork.composetodo.viewmodels.* import org.jetbrains.compose.web.attributes.* @@ -8,43 +9,44 @@ import org.jetbrains.compose.web.dom.* @Composable fun Login(viewModel: LoginViewModel) { + val coroutineScope = rememberCoroutineScope() + val state by remember(viewModel, coroutineScope) { viewModel.state(coroutineScope) }.collectAsState() + Row { Column { - val username by viewModel.userName.collectAsState() H1 { - Text("Login $username") + Text("Login ${state.userName}") } Input( - value = username, + value = state.userName, placeholder = "user.name", label = "Username", autocomplete = AutoComplete.username, type = InputType.Text ) { - viewModel.userName.value = it.value + viewModel.updateUserName(it.value) } - val password by viewModel.password.collectAsState() + Input( type = InputType.Password, placeholder = "password", label = "Password", autocomplete = AutoComplete.currentPassword, - value = password + value = state.password ) { - viewModel.password.value = it.value + viewModel.updatePassword(it.value) } - val enableLogin by viewModel.enableLogin.collectAsState(false) - Button(title = "Login $username", disabled = !enableLogin) { + Button(title = "Login ${state.userName}", disabled = !state.enableLogin) { viewModel.login() } - val error = viewModel.error.collectAsState().value + val error = state.error if (error != null) { Alert(color = Color.Danger) { Text(error.reason) CloseButton { - viewModel.error.value = null + viewModel.dismissError() } } }