From d6dee77f280881cb7abd7a11570f93b6da65d7da Mon Sep 17 00:00:00 2001 From: hfhbd Date: Wed, 9 Nov 2022 13:29:39 +0100 Subject: [PATCH] Use molecule --- .../app/softwork/composetodo/Container.kt | 45 ---------- .../app/softwork/composetodo/MainActivity.kt | 33 ++++++- build.gradle.kts | 1 + clients/build.gradle.kts | 37 +++++++- .../app/softwork/composetodo/AppContainer.kt | 48 +++++++--- .../composetodo/viewmodels/LoginViewModel.kt | 47 ++++++++-- .../kotlin/app/softwork/composetodo/Flows.kt | 0 .../app/softwork/composetodo/IosContainer.kt | 41 +++++++++ .../composetodo/viewmodels/ViewModeliOS.kt | 2 +- .../app/softwork/composetodo/FlowsTest.kt | 0 .../composetodo/viewmodels/ViewModel.kt | 2 +- .../app/softwork/composetodo/IosContainer.kt | 56 ------------ .../composetodo/UserDefaultsCookieStorage.kt | 18 ---- .../composetodo/viewmodels/ViewModel.kt | 2 +- .../app/softwork/composetodo/MainView.kt | 8 +- .../app/softwork/composetodo/views/Login.kt | 42 ++++++--- config/detekt/detekt.yml | 4 + .../softwork/composetodo/DesktopContainer.kt | 48 ---------- .../kotlin/app/softwork/composetodo/main.kt | 34 ++++++- detekt-baseline.xml | 7 +- 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/MainApp.kt | 18 ++-- .../app/softwork/composetodo/WebContainer.kt | 42 --------- .../app/softwork/composetodo/login/Login.kt | 40 +++++++-- .../kotlin/app/softwork/composetodo/main.kt | 29 +++++- .../app/softwork/composetodo/users/Users.kt | 2 +- 32 files changed, 423 insertions(+), 319 deletions(-) delete mode 100644 androidApp/src/main/kotlin/app/softwork/composetodo/Container.kt rename clients/src/{iosArm64Main => darwinMain}/kotlin/app/softwork/composetodo/Flows.kt (100%) create mode 100644 clients/src/darwinMain/kotlin/app/softwork/composetodo/IosContainer.kt rename clients/src/{iosArm64Main => darwinMain}/kotlin/app/softwork/composetodo/viewmodels/ViewModeliOS.kt (77%) rename clients/src/{iosArm64Test => darwinTest}/kotlin/app/softwork/composetodo/FlowsTest.kt (100%) delete mode 100644 clients/src/iosArm64Main/kotlin/app/softwork/composetodo/IosContainer.kt delete mode 100644 clients/src/iosArm64Main/kotlin/app/softwork/composetodo/UserDefaultsCookieStorage.kt delete mode 100644 desktop/src/main/kotlin/app/softwork/composetodo/DesktopContainer.kt delete mode 100644 web/src/main/kotlin/app/softwork/composetodo/WebContainer.kt diff --git a/androidApp/src/main/kotlin/app/softwork/composetodo/Container.kt b/androidApp/src/main/kotlin/app/softwork/composetodo/Container.kt deleted file mode 100644 index 31582ede..00000000 --- a/androidApp/src/main/kotlin/app/softwork/composetodo/Container.kt +++ /dev/null @@ -1,45 +0,0 @@ -package app.softwork.composetodo - -import android.content.* -import app.cash.sqldelight.driver.android.* -import app.softwork.composetodo.repository.* -import app.softwork.composetodo.repository.TodoRepository.Companion.createDatabase -import app.softwork.composetodo.viewmodels.* -import io.ktor.client.* -import io.ktor.client.engine.android.* -import io.ktor.client.plugins.* -import io.ktor.client.plugins.contentnegotiation.* -import io.ktor.client.plugins.resources.* -import io.ktor.http.* -import io.ktor.serialization.kotlinx.json.* -import kotlinx.coroutines.flow.* - -class Container(applicationContext: Context) : AppContainer { - private val db = createDatabase(AndroidSqliteDriver(ComposeTodoDB.Schema, applicationContext, "composetodo.db")) - - override val client = HttpClient(Android) { - defaultRequest { - url { - protocol = URLProtocol.HTTPS - host = "api.todo.softwork.app" - } - } - install(Resources) - install(ContentNegotiation) { - json() - } - } - - override val api: MutableStateFlow = MutableStateFlow(API.LoggedOut(client)) - - override fun loginViewModel(api: API.LoggedOut) = LoginViewModel(api = api) { - this.api.value = it - } - - override fun registerViewModel(api: API.LoggedOut) = RegisterViewModel(api) { - this.api.value = it - } - - override fun todoViewModel(api: API.LoggedIn) = - TodoViewModel(TodoRepository(api = api, dao = db.todoQueries)) -} diff --git a/androidApp/src/main/kotlin/app/softwork/composetodo/MainActivity.kt b/androidApp/src/main/kotlin/app/softwork/composetodo/MainActivity.kt index c18252d1..ab04212a 100644 --- a/androidApp/src/main/kotlin/app/softwork/composetodo/MainActivity.kt +++ b/androidApp/src/main/kotlin/app/softwork/composetodo/MainActivity.kt @@ -3,12 +3,43 @@ package app.softwork.composetodo import android.os.* import androidx.activity.* import androidx.activity.compose.* +import app.cash.sqldelight.driver.android.* +import app.softwork.composetodo.repository.* +import io.ktor.client.* +import io.ktor.client.engine.android.* +import io.ktor.client.plugins.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.plugins.resources.* +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.* class MainActivity : ComponentActivity() { + private val client = HttpClient(Android) { + defaultRequest { + url { + protocol = URLProtocol.HTTPS + host = "api.todo.softwork.app" + } + } + install(Resources) + install(ContentNegotiation) { + json() + } + } + + private val db = TodoRepository.createDatabase( + AndroidSqliteDriver( + ComposeTodoDB.Schema, + applicationContext, + "composetodo.db" + ) + ) + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val appContainer = Container(applicationContext) + + val appContainer = AppContainer(client, db) setContent { MainView(appContainer) 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/AppContainer.kt b/clients/src/commonMain/kotlin/app/softwork/composetodo/AppContainer.kt index 51992b84..9773cfe7 100644 --- a/clients/src/commonMain/kotlin/app/softwork/composetodo/AppContainer.kt +++ b/clients/src/commonMain/kotlin/app/softwork/composetodo/AppContainer.kt @@ -1,29 +1,49 @@ package app.softwork.composetodo +import app.softwork.composetodo.repository.* import app.softwork.composetodo.viewmodels.* import io.ktor.client.* import io.ktor.utils.io.errors.* +import kotlinx.coroutines.* import kotlinx.coroutines.flow.* -interface AppContainer { - fun todoViewModel(api: API.LoggedIn): TodoViewModel - fun loginViewModel(api: API.LoggedOut): LoginViewModel - fun registerViewModel(api: API.LoggedOut): RegisterViewModel +class AppContainer( + val client: HttpClient, + val db: ComposeTodoDB +) : ViewModel() { - val client: HttpClient + fun todoViewModel(): TodoViewModel { + val api = api.value as API.LoggedIn + return TodoViewModel(TodoRepository(api, db.todoQueries)) + } + fun loginViewModel(): LoginViewModel { + val api = api.value as API.LoggedOut + return LoginViewModel(api) { + this.api.value = it + } + } + fun registerViewModel(): RegisterViewModel { + val api = api.value as API.LoggedOut + return RegisterViewModel(api) { + this.api.value = it + } + } - suspend fun logout() { - when (val login = api.value) { - is API.LoggedIn -> { - try { - login.logout() - } catch (_: IOException) { + fun logout() { + lifecycleScope.launch { + when (val login = api.value) { + is API.LoggedIn -> { + try { + login.logout() + } catch (_: IOException) { + } + api.value = API.LoggedOut(client) } - api.value = API.LoggedOut(client) + + is API.LoggedOut -> {} } - is API.LoggedOut -> {} } } - val api: MutableStateFlow + val api: MutableStateFlow = MutableStateFlow(API.LoggedOut(client)) } 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..f6eaca67 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/darwinMain/kotlin/app/softwork/composetodo/IosContainer.kt b/clients/src/darwinMain/kotlin/app/softwork/composetodo/IosContainer.kt new file mode 100644 index 00000000..60edec40 --- /dev/null +++ b/clients/src/darwinMain/kotlin/app/softwork/composetodo/IosContainer.kt @@ -0,0 +1,41 @@ +package app.softwork.composetodo + +import app.cash.sqldelight.driver.native.* +import app.softwork.composetodo.repository.* +import io.ktor.client.* +import io.ktor.client.engine.darwin.* +import io.ktor.client.plugins.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.plugins.cookies.* +import io.ktor.client.plugins.logging.* +import io.ktor.client.plugins.resources.* +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.* + +fun IosContainer( + protocol: URLProtocol, + host: String, + storage: CookiesStorage +): AppContainer { + val client = HttpClient(Darwin) { + install(HttpCookies) { + this.storage = storage + } + install(DefaultRequest) { + url { + this.protocol = protocol + this.host = host + } + } + install(Logging) { + level = LogLevel.ALL + } + install(Resources) + install(ContentNegotiation) { + json() + } + } + val db = TodoRepository.createDatabase(NativeSqliteDriver(ComposeTodoDB.Schema, "composetodo.db")) + + return AppContainer(client, db) +} 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 77% rename from clients/src/iosArm64Main/kotlin/app/softwork/composetodo/viewmodels/ViewModeliOS.kt rename to clients/src/darwinMain/kotlin/app/softwork/composetodo/viewmodels/ViewModeliOS.kt index 3d10b1fa..16bc47f5 100644 --- a/clients/src/iosArm64Main/kotlin/app/softwork/composetodo/viewmodels/ViewModeliOS.kt +++ b/clients/src/darwinMain/kotlin/app/softwork/composetodo/viewmodels/ViewModeliOS.kt @@ -3,7 +3,7 @@ package app.softwork.composetodo.viewmodels import kotlinx.coroutines.* actual abstract class ViewModel actual constructor() { - val lifecycleScope = MainScope() + val lifecycleScope = CoroutineScope(Dispatchers.Default) } actual val ViewModel.lifecycleScope: CoroutineScope diff --git a/clients/src/iosArm64Test/kotlin/app/softwork/composetodo/FlowsTest.kt b/clients/src/darwinTest/kotlin/app/softwork/composetodo/FlowsTest.kt similarity index 100% rename from clients/src/iosArm64Test/kotlin/app/softwork/composetodo/FlowsTest.kt rename to clients/src/darwinTest/kotlin/app/softwork/composetodo/FlowsTest.kt diff --git a/clients/src/desktopMain/kotlin/app/softwork/composetodo/viewmodels/ViewModel.kt b/clients/src/desktopMain/kotlin/app/softwork/composetodo/viewmodels/ViewModel.kt index 101061e5..16bc47f5 100644 --- a/clients/src/desktopMain/kotlin/app/softwork/composetodo/viewmodels/ViewModel.kt +++ b/clients/src/desktopMain/kotlin/app/softwork/composetodo/viewmodels/ViewModel.kt @@ -3,7 +3,7 @@ package app.softwork.composetodo.viewmodels import kotlinx.coroutines.* actual abstract class ViewModel actual constructor() { - val lifecycleScope: CoroutineScope = MainScope() + val lifecycleScope = CoroutineScope(Dispatchers.Default) } actual val ViewModel.lifecycleScope: CoroutineScope diff --git a/clients/src/iosArm64Main/kotlin/app/softwork/composetodo/IosContainer.kt b/clients/src/iosArm64Main/kotlin/app/softwork/composetodo/IosContainer.kt deleted file mode 100644 index c9016ea8..00000000 --- a/clients/src/iosArm64Main/kotlin/app/softwork/composetodo/IosContainer.kt +++ /dev/null @@ -1,56 +0,0 @@ -package app.softwork.composetodo - -import app.cash.sqldelight.driver.native.* -import app.softwork.composetodo.repository.* -import app.softwork.composetodo.viewmodels.* -import io.ktor.client.* -import io.ktor.client.engine.darwin.* -import io.ktor.client.plugins.* -import io.ktor.client.plugins.contentnegotiation.* -import io.ktor.client.plugins.cookies.* -import io.ktor.client.plugins.logging.* -import io.ktor.client.plugins.resources.* -import io.ktor.http.* -import io.ktor.serialization.kotlinx.json.* -import kotlinx.coroutines.flow.* - -class IosContainer( - protocol: URLProtocol, - host: String -) : AppContainer { - private val db = TodoRepository.createDatabase(NativeSqliteDriver(ComposeTodoDB.Schema, "composetodo.db")) - - constructor() : this(protocol = URLProtocol.HTTPS, host = "api.todo.softwork.app") - - override val client: HttpClient = HttpClient(Darwin) { - install(HttpCookies) { - storage = UserDefaultsCookieStorage() - } - install(DefaultRequest) { - url { - this.protocol = protocol - this.host = host - } - } - install(Logging) { - level = LogLevel.ALL - } - install(Resources) - install(ContentNegotiation) { - json() - } - } - - override fun loginViewModel(api: API.LoggedOut) = LoginViewModel(api) { - this.api.value = it - } - - override fun todoViewModel(api: API.LoggedIn) = - TodoViewModel(repo = TodoRepository(api, db.todoQueries)) - - override fun registerViewModel(api: API.LoggedOut) = RegisterViewModel(api) { - this.api.value = it - } - - override val api: MutableStateFlow = MutableStateFlow(API.LoggedOut(client)) -} 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/jsMain/kotlin/app/softwork/composetodo/viewmodels/ViewModel.kt b/clients/src/jsMain/kotlin/app/softwork/composetodo/viewmodels/ViewModel.kt index 101061e5..16bc47f5 100644 --- a/clients/src/jsMain/kotlin/app/softwork/composetodo/viewmodels/ViewModel.kt +++ b/clients/src/jsMain/kotlin/app/softwork/composetodo/viewmodels/ViewModel.kt @@ -3,7 +3,7 @@ package app.softwork.composetodo.viewmodels import kotlinx.coroutines.* actual abstract class ViewModel actual constructor() { - val lifecycleScope: CoroutineScope = MainScope() + val lifecycleScope = CoroutineScope(Dispatchers.Default) } actual val ViewModel.lifecycleScope: CoroutineScope diff --git a/composeClients/src/commonMain/kotlin/app/softwork/composetodo/MainView.kt b/composeClients/src/commonMain/kotlin/app/softwork/composetodo/MainView.kt index 77a4f9af..5c2ccbea 100644 --- a/composeClients/src/commonMain/kotlin/app/softwork/composetodo/MainView.kt +++ b/composeClients/src/commonMain/kotlin/app/softwork/composetodo/MainView.kt @@ -8,12 +8,12 @@ import app.softwork.composetodo.views.* fun MainView(appContainer: AppContainer) { Theme { Column { - when (val api = remember { appContainer.api }.collectAsState().value) { - is API.LoggedIn -> Todos(appContainer.todoViewModel(api)) + when (remember { appContainer.api }.collectAsState().value) { + is API.LoggedIn -> Todos(appContainer.todoViewModel()) is API.LoggedOut -> { Row { - Login(appContainer.loginViewModel(api)) - Register(appContainer.registerViewModel(api)) + Login(appContainer.loginViewModel()) + Register(appContainer.registerViewModel()) } } } 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..ae4ade45 100644 --- a/composeClients/src/commonMain/kotlin/app/softwork/composetodo/views/Login.kt +++ b/composeClients/src/commonMain/kotlin/app/softwork/composetodo/views/Login.kt @@ -8,32 +8,50 @@ import app.softwork.composetodo.viewmodels.* @Composable fun Login(viewModel: LoginViewModel) { + val coroutineScope = rememberCoroutineScope() + val state by remember(viewModel, coroutineScope) { viewModel.state(coroutineScope) }.collectAsState() + + Login( + username = state.username, + updateUsername = viewModel::updateUsername, + password = state.password, + updatePassword = viewModel::updatePassword, + enableLogin = state.enableLogin, + onLoginClick = viewModel::login, + error = state.error + ) +} + +@Composable +private fun Login( + username: String, + updateUsername: (String) -> Unit, + password: String, + updatePassword: (String) -> Unit, + enableLogin: Boolean, + onLoginClick: () -> Unit, + error: Failure? +) { Column { - val userName by remember { viewModel.userName }.collectAsState() TextField( label = "Username", - value = userName, - onValueChange = { viewModel.userName.value = it }, + value = username, + onValueChange = updateUsername, isPassword = false, placeholder = "John Doe" ) - val password by remember { viewModel.password }.collectAsState() TextField( label = "Password", value = password, - onValueChange = { viewModel.password.value = it }, + onValueChange = 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 = enableLogin) { onLoginClick() } - error?.let { - Text("ERROR: ${it.reason}") + if (error != null) { + Text("ERROR: ${error.reason}") } } } diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml index 34aa449e..27075321 100644 --- a/config/detekt/detekt.yml +++ b/config/detekt/detekt.yml @@ -9,3 +9,7 @@ naming: style: WildcardImport: active: false + +complexity: + LongParameterList: + ignoreAnnotated: ['Composable'] diff --git a/desktop/src/main/kotlin/app/softwork/composetodo/DesktopContainer.kt b/desktop/src/main/kotlin/app/softwork/composetodo/DesktopContainer.kt deleted file mode 100644 index 7b8b06e8..00000000 --- a/desktop/src/main/kotlin/app/softwork/composetodo/DesktopContainer.kt +++ /dev/null @@ -1,48 +0,0 @@ -package app.softwork.composetodo - -import app.cash.sqldelight.db.* -import app.cash.sqldelight.driver.jdbc.sqlite.* -import app.softwork.composetodo.repository.* -import app.softwork.composetodo.repository.TodoRepository.Companion.createDatabase -import app.softwork.composetodo.viewmodels.* -import io.ktor.client.* -import io.ktor.client.engine.cio.* -import io.ktor.client.plugins.* -import io.ktor.client.plugins.contentnegotiation.* -import io.ktor.client.plugins.resources.* -import io.ktor.http.* -import io.ktor.serialization.kotlinx.json.* -import kotlinx.coroutines.flow.* - -class DesktopContainer : AppContainer { - private val db: ComposeTodoDB - init { - val driver: SqlDriver = JdbcSqliteDriver("jdbc:sqlite:composetodo.db") - ComposeTodoDB.Schema.migrate(driver, 0, 1) - db = createDatabase(driver) - } - override fun todoViewModel(api: API.LoggedIn): TodoViewModel = - TodoViewModel(TodoRepository(api, db.todoQueries)) - - override fun loginViewModel(api: API.LoggedOut) = LoginViewModel(api) { - this.api.value = it - } - - override fun registerViewModel(api: API.LoggedOut) = RegisterViewModel(api) { - this.api.value = it - } - - override val client = HttpClient(CIO) { - defaultRequest { - url { - protocol = URLProtocol.HTTPS - host = "api.todo.softwork.app" - } - } - install(Resources) - install(ContentNegotiation) { - json() - } - } - override val api: MutableStateFlow = MutableStateFlow(API.LoggedOut(client)) -} diff --git a/desktop/src/main/kotlin/app/softwork/composetodo/main.kt b/desktop/src/main/kotlin/app/softwork/composetodo/main.kt index 72f66ff2..fc9b4ef9 100644 --- a/desktop/src/main/kotlin/app/softwork/composetodo/main.kt +++ b/desktop/src/main/kotlin/app/softwork/composetodo/main.kt @@ -1,10 +1,36 @@ package app.softwork.composetodo -import androidx.compose.runtime.* import androidx.compose.ui.window.* +import app.cash.sqldelight.db.* +import app.cash.sqldelight.driver.jdbc.sqlite.* +import app.softwork.composetodo.repository.* +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.plugins.resources.* +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.* -fun main() = singleWindowApplication { - val appContainer = remember { DesktopContainer() } +fun main() { + val client = HttpClient(CIO) { + defaultRequest { + url { + protocol = URLProtocol.HTTPS + host = "api.todo.softwork.app" + } + } + install(Resources) + install(ContentNegotiation) { + json() + } + } + val driver = JdbcSqliteDriver("jdbc:sqlite:composetodo.db") + ComposeTodoDB.Schema.migrate(driver, 0, 1) - MainView(appContainer) + val appContainer = AppContainer(client, TodoRepository.createDatabase(driver)) + + singleWindowApplication { + MainView(appContainer) + } } diff --git a/detekt-baseline.xml b/detekt-baseline.xml index 0d39dda6..09e15214 100644 --- a/detekt-baseline.xml +++ b/detekt-baseline.xml @@ -1,17 +1,16 @@ - + - EmptyFunctionBlock:UserDefaultsCookieStorage.kt$UserDefaultsCookieStorage${} + FunctionNaming:IosContainer.kt$fun IosContainer( protocol: URLProtocol, host: String, storage: CookiesStorage ): AppContainer FunctionNaming:TodoModule.kt$fun Application.TodoModule(db: Client.Database, jwtProvider: JWTProvider) LongMethod:Register.kt$@Composable fun Register(viewModel: RegisterViewModel) LongMethod:TodoModule.kt$fun Application.TodoModule(db: Client.Database, jwtProvider: JWTProvider) MagicNumber:FlowsTest.kt$FlowsTest$3 - MatchingDeclarationName:Flows.kt$IteratorAsync<out T> + MatchingDeclarationName:Flows.kt$IteratorAsync<out T> MatchingDeclarationName:ViewModeliOS.kt$ViewModel ThrowsCount:TodoModule.kt$fun Application.TodoModule(db: Client.Database, jwtProvider: JWTProvider) UnnecessaryAbstractClass:ViewModel.kt$ViewModel$ViewModel UnnecessaryAbstractClass:ViewModeliOS.kt$ViewModel$ViewModel - UnusedPrivateMember:Users.kt$api: API.LoggedIn 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/MainApp.kt b/web/src/main/kotlin/app/softwork/composetodo/MainApp.kt index ab64a746..b0d93262 100644 --- a/web/src/main/kotlin/app/softwork/composetodo/MainApp.kt +++ b/web/src/main/kotlin/app/softwork/composetodo/MainApp.kt @@ -7,7 +7,6 @@ import app.softwork.composetodo.login.* import app.softwork.composetodo.todos.* import app.softwork.composetodo.users.* import app.softwork.routingcompose.* -import kotlinx.coroutines.* import org.jetbrains.compose.web.dom.* @Composable @@ -27,19 +26,20 @@ fun MainApp(appContainer: AppContainer) { is API.LoggedIn -> { MainContent(appContainer, currentApi) } + is API.LoggedOut -> { - LoginView(appContainer, currentApi) + LoginView(appContainer) } } } } @Composable -private fun LoginView(appContainer: AppContainer, api: API.LoggedOut) { +private fun LoginView(appContainer: AppContainer) { Content(emptyList(), onLogout = null) { Text("This application uses a cold Google Cloud Run server, which usually takes 2 seconds to start.") - Login(appContainer.loginViewModel(api)) - Register(appContainer.registerViewModel(api)) + Login(appContainer.loginViewModel()) + Register(appContainer.registerViewModel()) } } @@ -62,19 +62,17 @@ private fun RouteBuilder.MainContent(appContainer: AppContainer, api: API.Logged val links = listOf("To-Dos" to "/todos", "Users" to "/users") Content(links, { - scope.launch { - appContainer.logout() - } + appContainer.logout() }) { route("users") { - Users(api) + Users() } route("todos") { uuid { todoID -> Todo(api, TodoDTO.ID(todoID)) } noMatch { - Todos(appContainer.todoViewModel(api)) + Todos(appContainer.todoViewModel()) } } noMatch { diff --git a/web/src/main/kotlin/app/softwork/composetodo/WebContainer.kt b/web/src/main/kotlin/app/softwork/composetodo/WebContainer.kt deleted file mode 100644 index 15f662ef..00000000 --- a/web/src/main/kotlin/app/softwork/composetodo/WebContainer.kt +++ /dev/null @@ -1,42 +0,0 @@ -package app.softwork.composetodo - -import app.cash.sqldelight.db.* -import app.softwork.composetodo.repository.* -import app.softwork.composetodo.viewmodels.* -import io.ktor.client.* -import io.ktor.client.engine.js.* -import io.ktor.client.plugins.* -import io.ktor.client.plugins.contentnegotiation.* -import io.ktor.client.plugins.cookies.* -import io.ktor.client.plugins.resources.* -import io.ktor.http.* -import io.ktor.serialization.kotlinx.json.* -import kotlinx.coroutines.flow.* - -class WebContainer(driver: SqlDriver) : AppContainer { - private val db = TodoRepository.createDatabase(driver) - override fun loginViewModel(api: API.LoggedOut) = LoginViewModel(api = api) { - this.api.value = it - } - - override fun registerViewModel(api: API.LoggedOut) = RegisterViewModel(api) { - this.api.value = it - } - - override fun todoViewModel(api: API.LoggedIn) = TodoViewModel(TodoRepository(api = api, db.todoQueries)) - - override val client = HttpClient(Js) { - install(HttpCookies) - defaultRequest { - url { - protocol = URLProtocol.HTTPS - host = "api.todo.softwork.app" - } - } - install(Resources) - install(ContentNegotiation) { - json() - } - } - override val api: MutableStateFlow = MutableStateFlow(API.LoggedOut(client)) -} 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..babe2211 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,9 +9,34 @@ import org.jetbrains.compose.web.dom.* @Composable fun Login(viewModel: LoginViewModel) { + val coroutineScope = rememberCoroutineScope() + val state by remember(viewModel, coroutineScope) { viewModel.state(coroutineScope) }.collectAsState() + + Login( + username = state.username, + updateUsername = viewModel::updateUsername, + password = state.password, + updatePassword = viewModel::updatePassword, + enableLogin = state.enableLogin, + onLoginClick = viewModel::login, + error = state.error, + dismissError = viewModel::dismissError + ) +} + +@Composable +private fun Login( + username: String, + updateUsername: (String) -> Unit, + password: String, + updatePassword: (String) -> Unit, + enableLogin: Boolean, + onLoginClick: () -> Unit, + error: Failure?, + dismissError: () -> Unit +) { Row { Column { - val username by viewModel.userName.collectAsState() H1 { Text("Login $username") } @@ -21,9 +47,9 @@ fun Login(viewModel: LoginViewModel) { autocomplete = AutoComplete.username, type = InputType.Text ) { - viewModel.userName.value = it.value + updateUsername(it.value) } - val password by viewModel.password.collectAsState() + Input( type = InputType.Password, placeholder = "password", @@ -31,20 +57,18 @@ fun Login(viewModel: LoginViewModel) { autocomplete = AutoComplete.currentPassword, value = password ) { - viewModel.password.value = it.value + updatePassword(it.value) } - val enableLogin by viewModel.enableLogin.collectAsState(false) Button(title = "Login $username", disabled = !enableLogin) { - viewModel.login() + onLoginClick() } - val error = viewModel.error.collectAsState().value if (error != null) { Alert(color = Color.Danger) { Text(error.reason) CloseButton { - viewModel.error.value = null + dismissError() } } } diff --git a/web/src/main/kotlin/app/softwork/composetodo/main.kt b/web/src/main/kotlin/app/softwork/composetodo/main.kt index 8383e297..5afdf835 100644 --- a/web/src/main/kotlin/app/softwork/composetodo/main.kt +++ b/web/src/main/kotlin/app/softwork/composetodo/main.kt @@ -1,6 +1,15 @@ package app.softwork.composetodo import app.cash.sqldelight.driver.sqljs.* +import app.softwork.composetodo.repository.* +import io.ktor.client.* +import io.ktor.client.engine.js.* +import io.ktor.client.plugins.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.plugins.cookies.* +import io.ktor.client.plugins.resources.* +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.* import kotlinx.coroutines.* import org.jetbrains.compose.web.* @@ -14,11 +23,25 @@ window.fetch = function (resource, init) { }; """ ) + val client = HttpClient(Js) { + install(HttpCookies) + defaultRequest { + url { + protocol = URLProtocol.HTTPS + host = "api.todo.softwork.app" + } + } + install(Resources) + install(ContentNegotiation) { + json() + } + } + val driver = initSqlDriver(ComposeTodoDB.Schema).await() + val db = TodoRepository.createDatabase(driver) + val appContainer = AppContainer(client, db) + renderComposable(rootElementId = "root") { - val appContainer = WebContainer(driver) MainApp(appContainer) } } - -val scope = MainScope() diff --git a/web/src/main/kotlin/app/softwork/composetodo/users/Users.kt b/web/src/main/kotlin/app/softwork/composetodo/users/Users.kt index c7a36aa0..874f2621 100644 --- a/web/src/main/kotlin/app/softwork/composetodo/users/Users.kt +++ b/web/src/main/kotlin/app/softwork/composetodo/users/Users.kt @@ -5,6 +5,6 @@ import app.softwork.composetodo.* import org.jetbrains.compose.web.dom.* @Composable -fun Users(api: API.LoggedIn) { +fun Users() { Text("User placeholder") }