Add web app for the TodoApp example (#778)

* Prepare the TodoApp example for adding the JavaScript app

* Add the JavaScript app for the TodoApp example

* TodoApp. Update Compose to 0.5.0-build225.
This commit is contained in:
Arkadii Ivanov
2021-06-17 07:57:06 +01:00
committed by GitHub
parent 971a2a45dd
commit 8f87cda24d
38 changed files with 1124 additions and 160 deletions

View File

@@ -10,11 +10,11 @@ import com.arkivanov.decompose.extensions.compose.jetbrains.rememberRootComponen
import com.arkivanov.mvikotlin.logging.store.LoggingStoreFactory
import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory
import com.arkivanov.mvikotlin.timetravel.store.TimeTravelStoreFactory
import example.todo.common.database.DefaultTodoSharedDatabase
import example.todo.common.database.TodoDatabaseDriver
import example.todo.common.root.TodoRoot
import example.todo.common.root.integration.TodoRootComponent
import example.todo.common.ui.TodoRootContent
import example.todo.database.TodoDatabase
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -33,6 +33,6 @@ class MainActivity : AppCompatActivity() {
TodoRootComponent(
componentContext = componentContext,
storeFactory = LoggingStoreFactory(TimeTravelStoreFactory(DefaultStoreFactory)),
database = TodoDatabase(TodoDatabaseDriver(context = this))
database = DefaultTodoSharedDatabase(TodoDatabaseDriver(context = this))
)
}

View File

@@ -7,12 +7,13 @@ object Deps {
const val gradlePlugin = "org.jetbrains.kotlin:kotlin-gradle-plugin:$VERSION"
const val testCommon = "org.jetbrains.kotlin:kotlin-test-common:$VERSION"
const val testJunit = "org.jetbrains.kotlin:kotlin-test-junit:$VERSION"
const val testJs = "org.jetbrains.kotlin:kotlin-test-js:$VERSION"
const val testAnnotationsCommon = "org.jetbrains.kotlin:kotlin-test-annotations-common:$VERSION"
}
object Compose {
// __LATEST_COMPOSE_RELEASE_VERSION__
private const val VERSION = "0.4.0"
private const val VERSION = "0.5.0-build225"
const val gradlePlugin = "org.jetbrains.compose:compose-gradle-plugin:$VERSION"
}
}
@@ -37,7 +38,7 @@ object Deps {
object ArkIvanov {
object MVIKotlin {
private const val VERSION = "2.0.2"
private const val VERSION = "2.0.3"
const val rx = "com.arkivanov.mvikotlin:rx:$VERSION"
const val mvikotlin = "com.arkivanov.mvikotlin:mvikotlin:$VERSION"
const val mvikotlinMain = "com.arkivanov.mvikotlin:mvikotlin-main:$VERSION"
@@ -71,6 +72,7 @@ object Deps {
const val androidDriver = "com.squareup.sqldelight:android-driver:$VERSION"
const val sqliteDriver = "com.squareup.sqldelight:sqlite-driver:$VERSION"
const val nativeDriver = "com.squareup.sqldelight:native-driver:$VERSION"
const val sqljsDriver = "com.squareup.sqldelight:sqljs-driver:$VERSION"
}
}
}

View File

@@ -8,6 +8,10 @@ kotlin {
android()
ios()
js(IR) {
browser()
}
sourceSets {
named("commonTest") {
dependencies {
@@ -26,6 +30,11 @@ kotlin {
implementation(Deps.JetBrains.Kotlin.testJunit)
}
}
named("jsTest") {
dependencies {
implementation(Deps.JetBrains.Kotlin.testJs)
}
}
}
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {

View File

@@ -24,6 +24,7 @@ import androidx.compose.material.icons.filled.Delete
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.onKeyEvent
@@ -130,6 +131,7 @@ private fun Item(
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun TodoInput(
text: String,

View File

@@ -36,5 +36,11 @@ kotlin {
implementation(Deps.Squareup.SQLDelight.nativeDriver)
}
}
jsMain {
dependencies {
implementation(Deps.Squareup.SQLDelight.sqljsDriver)
}
}
}
}

View File

@@ -1,13 +0,0 @@
package example.todo.common.database
import com.squareup.sqldelight.db.SqlDriver
import com.squareup.sqldelight.sqlite.driver.JdbcSqliteDriver
import example.todo.database.TodoDatabase
@Suppress("FunctionName") // FactoryFunction
actual fun TestDatabaseDriver(): SqlDriver {
val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)
TodoDatabase.Schema.create(driver)
return driver
}

View File

@@ -0,0 +1,91 @@
package example.todo.common.database
import com.badoo.reaktive.base.setCancellable
import com.badoo.reaktive.completable.Completable
import com.badoo.reaktive.maybe.Maybe
import com.badoo.reaktive.observable.Observable
import com.badoo.reaktive.observable.autoConnect
import com.badoo.reaktive.observable.firstOrError
import com.badoo.reaktive.observable.map
import com.badoo.reaktive.observable.observable
import com.badoo.reaktive.observable.observeOn
import com.badoo.reaktive.observable.replay
import com.badoo.reaktive.scheduler.ioScheduler
import com.badoo.reaktive.single.Single
import com.badoo.reaktive.single.asCompletable
import com.badoo.reaktive.single.asObservable
import com.badoo.reaktive.single.doOnBeforeSuccess
import com.badoo.reaktive.single.flatMapObservable
import com.badoo.reaktive.single.map
import com.badoo.reaktive.single.mapNotNull
import com.badoo.reaktive.single.observeOn
import com.badoo.reaktive.single.singleOf
import com.squareup.sqldelight.Query
import com.squareup.sqldelight.db.SqlDriver
import example.todo.database.TodoDatabase
class DefaultTodoSharedDatabase(driver: Single<SqlDriver>) : TodoSharedDatabase {
constructor(driver: SqlDriver) : this(singleOf(driver))
private val queries: Single<TodoDatabaseQueries> =
driver
.map { TodoDatabase(it).todoDatabaseQueries }
.asObservable()
.replay()
.autoConnect()
.firstOrError()
override fun observeAll(): Observable<List<TodoItemEntity>> =
query(TodoDatabaseQueries::selectAll)
.observe { it.executeAsList() }
override fun select(id: Long): Maybe<TodoItemEntity> =
query { it.select(id = id) }
.mapNotNull { it.executeAsOneOrNull() }
override fun add(text: String): Completable =
execute { it.add(text = text) }
override fun setText(id: Long, text: String): Completable =
execute { it.setText(id = id, text = text) }
override fun setDone(id: Long, isDone: Boolean): Completable =
execute { it.setDone(id = id, isDone = isDone) }
override fun delete(id: Long): Completable =
execute { it.delete(id = id) }
override fun clear(): Completable =
execute { it.clear() }
private fun <T : Any> query(query: (TodoDatabaseQueries) -> Query<T>): Single<Query<T>> =
queries
.observeOn(ioScheduler)
.map(query)
private fun execute(query: (TodoDatabaseQueries) -> Unit): Completable =
queries
.observeOn(ioScheduler)
.doOnBeforeSuccess(query)
.asCompletable()
private fun <T : Any, R> Single<Query<T>>.observe(get: (Query<T>) -> R): Observable<R> =
flatMapObservable { it.observed() }
.observeOn(ioScheduler)
.map(get)
private fun <T : Any> Query<T>.observed(): Observable<Query<T>> =
observable { emitter ->
val listener =
object : Query.Listener {
override fun queryResultsChanged() {
emitter.onNext(this@observed)
}
}
emitter.onNext(this@observed)
addListener(listener)
emitter.setCancellable { removeListener(listener) }
}
}

View File

@@ -1,28 +0,0 @@
package example.todo.common.database
import com.badoo.reaktive.base.setCancellable
import com.badoo.reaktive.observable.Observable
import com.badoo.reaktive.observable.map
import com.badoo.reaktive.observable.observable
import com.badoo.reaktive.observable.observeOn
import com.badoo.reaktive.scheduler.ioScheduler
import com.squareup.sqldelight.Query
fun <T : Any, R> Query<T>.asObservable(execute: (Query<T>) -> R): Observable<R> =
asObservable()
.observeOn(ioScheduler)
.map(execute)
fun <T : Any> Query<T>.asObservable(): Observable<Query<T>> =
observable { emitter ->
val listener =
object : Query.Listener {
override fun queryResultsChanged() {
emitter.onNext(this@asObservable)
}
}
emitter.onNext(this@asObservable)
addListener(listener)
emitter.setCancellable { removeListener(listener) }
}

View File

@@ -1,6 +0,0 @@
package example.todo.common.database
import com.squareup.sqldelight.db.SqlDriver
@Suppress("FunctionName")
expect fun TestDatabaseDriver(): SqlDriver

View File

@@ -0,0 +1,105 @@
package example.todo.common.database
import com.badoo.reaktive.base.invoke
import com.badoo.reaktive.completable.Completable
import com.badoo.reaktive.completable.completableFromFunction
import com.badoo.reaktive.completable.observeOn
import com.badoo.reaktive.maybe.Maybe
import com.badoo.reaktive.maybe.observeOn
import com.badoo.reaktive.observable.Observable
import com.badoo.reaktive.observable.map
import com.badoo.reaktive.observable.observeOn
import com.badoo.reaktive.scheduler.Scheduler
import com.badoo.reaktive.single.notNull
import com.badoo.reaktive.single.singleFromFunction
import com.badoo.reaktive.subject.behavior.BehaviorSubject
// There were problems when using real database in JS tests, hence the in-memory test implementation
class TestTodoSharedDatabase(
private val scheduler: Scheduler
) : TodoSharedDatabase {
private val itemsSubject = BehaviorSubject<Map<Long, TodoItemEntity>>(emptyMap())
private val itemsObservable = itemsSubject.observeOn(scheduler)
val testing: Testing = Testing()
override fun observeAll(): Observable<List<TodoItemEntity>> =
itemsObservable.map { it.values.toList() }
override fun select(id: Long): Maybe<TodoItemEntity> =
singleFromFunction { testing.select(id = id) }
.notNull()
.observeOn(scheduler)
override fun add(text: String): Completable =
execute { testing.add(text = text) }
override fun setText(id: Long, text: String): Completable =
execute { testing.setText(id = id, text = text) }
override fun setDone(id: Long, isDone: Boolean): Completable =
execute { testing.setDone(id = id, isDone = isDone) }
override fun delete(id: Long): Completable =
execute { testing.delete(id = id) }
override fun clear(): Completable =
execute { testing.clear() }
private fun execute(block: () -> Unit): Completable =
completableFromFunction(block)
.observeOn(scheduler)
inner class Testing {
fun select(id: Long): TodoItemEntity? =
itemsSubject.value[id]
fun selectRequired(id: Long): TodoItemEntity =
requireNotNull(select(id = id))
fun add(text: String) {
updateItems { items ->
val nextId = items.keys.maxOrNull()?.plus(1L) ?: 1L
val item =
TodoItemEntity(
id = nextId,
orderNum = items.size.toLong(),
text = text,
isDone = false
)
items + (nextId to item)
}
}
fun setText(id: Long, text: String) {
updateItem(id = id) { it.copy(text = text) }
}
fun setDone(id: Long, isDone: Boolean) {
updateItem(id = id) { it.copy(isDone = isDone) }
}
fun delete(id: Long) {
updateItems { it - id }
}
fun clear() {
updateItems { emptyMap() }
}
fun getLastInsertId(): Long? =
itemsSubject.value.values.lastOrNull()?.id
private fun updateItems(func: (Map<Long, TodoItemEntity>) -> Map<Long, TodoItemEntity>) {
itemsSubject(func(itemsSubject.value))
}
private fun updateItem(id: Long, func: (TodoItemEntity) -> TodoItemEntity) {
updateItems {
it + (id to it.getValue(id).let(func))
}
}
}
}

View File

@@ -0,0 +1,22 @@
package example.todo.common.database
import com.badoo.reaktive.completable.Completable
import com.badoo.reaktive.maybe.Maybe
import com.badoo.reaktive.observable.Observable
interface TodoSharedDatabase {
fun observeAll(): Observable<List<TodoItemEntity>>
fun select(id: Long): Maybe<TodoItemEntity>
fun add(text: String): Completable
fun setText(id: Long, text: String): Completable
fun setDone(id: Long, isDone: Boolean): Completable
fun delete(id: Long): Completable
fun clear(): Completable
}

View File

@@ -1,13 +0,0 @@
package example.todo.common.database
import com.squareup.sqldelight.db.SqlDriver
import com.squareup.sqldelight.sqlite.driver.JdbcSqliteDriver
import example.todo.database.TodoDatabase
@Suppress("FunctionName") // FactoryFunction
actual fun TestDatabaseDriver(): SqlDriver {
val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)
TodoDatabase.Schema.create(driver)
return driver
}

View File

@@ -1,26 +0,0 @@
package example.todo.common.database
import co.touchlab.sqliter.DatabaseConfiguration
import com.squareup.sqldelight.db.SqlDriver
import com.squareup.sqldelight.drivers.native.NativeSqliteDriver
import com.squareup.sqldelight.drivers.native.wrapConnection
import example.todo.database.TodoDatabase
@Suppress("FunctionName") // Factory function
actual fun TestDatabaseDriver(): SqlDriver {
val schema = TodoDatabase.Schema
return NativeSqliteDriver(
DatabaseConfiguration(
name = ":memory:",
version = schema.version,
create = { wrapConnection(it, schema::create) },
upgrade = { connection, oldVersion, newVersion ->
wrapConnection(connection) {
schema.migrate(it, oldVersion, newVersion)
}
},
inMemory = true
)
)
}

View File

@@ -0,0 +1,10 @@
package example.todo.common.database
import com.badoo.reaktive.promise.asSingle
import com.badoo.reaktive.single.Single
import com.squareup.sqldelight.db.SqlDriver
import com.squareup.sqldelight.drivers.sqljs.initSqlDriver
import example.todo.database.TodoDatabase
fun todoDatabaseDriver(): Single<SqlDriver> =
initSqlDriver(TodoDatabase.Schema).asSingle()

View File

@@ -6,6 +6,7 @@ import com.arkivanov.decompose.value.operator.map
import com.arkivanov.mvikotlin.core.store.StoreFactory
import com.badoo.reaktive.base.Consumer
import com.badoo.reaktive.base.invoke
import example.todo.common.database.TodoSharedDatabase
import example.todo.common.edit.TodoEdit
import example.todo.common.edit.TodoEdit.Model
import example.todo.common.edit.TodoEdit.Output
@@ -13,12 +14,11 @@ import example.todo.common.edit.store.TodoEditStore.Intent
import example.todo.common.edit.store.TodoEditStoreProvider
import example.todo.common.utils.asValue
import example.todo.common.utils.getStore
import example.todo.database.TodoDatabase
class TodoEditComponent(
componentContext: ComponentContext,
storeFactory: StoreFactory,
database: TodoDatabase,
database: TodoSharedDatabase,
itemId: Long,
private val output: Consumer<Output>
) : TodoEdit, ComponentContext by componentContext {
@@ -27,7 +27,7 @@ class TodoEditComponent(
instanceKeeper.getStore {
TodoEditStoreProvider(
storeFactory = storeFactory,
database = TodoEditStoreDatabase(queries = database.todoDatabaseQueries),
database = TodoEditStoreDatabase(database = database),
id = itemId
).provide()
}

View File

@@ -1,29 +1,20 @@
package example.todo.common.edit.integration
import com.badoo.reaktive.completable.Completable
import com.badoo.reaktive.completable.completableFromFunction
import com.badoo.reaktive.completable.subscribeOn
import com.badoo.reaktive.maybe.Maybe
import com.badoo.reaktive.maybe.map
import com.badoo.reaktive.maybe.maybeFromFunction
import com.badoo.reaktive.maybe.notNull
import com.badoo.reaktive.maybe.subscribeOn
import com.badoo.reaktive.scheduler.ioScheduler
import com.squareup.sqldelight.Query
import example.todo.common.database.TodoDatabaseQueries
import example.todo.common.database.TodoItemEntity
import example.todo.common.database.TodoSharedDatabase
import example.todo.common.edit.TodoItem
import example.todo.common.edit.store.TodoEditStoreProvider.Database
internal class TodoEditStoreDatabase(
private val queries: TodoDatabaseQueries
private val database: TodoSharedDatabase
) : Database {
override fun load(id: Long): Maybe<TodoItem> =
maybeFromFunction { queries.select(id = id) }
.subscribeOn(ioScheduler)
.map(Query<TodoItemEntity>::executeAsOne)
.notNull()
database
.select(id = id)
.map { it.toItem() }
private fun TodoItemEntity.toItem(): TodoItem =
@@ -33,10 +24,8 @@ internal class TodoEditStoreDatabase(
)
override fun setText(id: Long, text: String): Completable =
completableFromFunction { queries.setText(id = id, text = text) }
.subscribeOn(ioScheduler)
database.setText(id = id, text = text)
override fun setDone(id: Long, isDone: Boolean): Completable =
completableFromFunction { queries.setDone(id = id, isDone = isDone) }
.subscribeOn(ioScheduler)
database.setDone(id = id, isDone = isDone)
}

View File

@@ -6,6 +6,7 @@ import com.arkivanov.decompose.value.operator.map
import com.arkivanov.mvikotlin.core.store.StoreFactory
import com.badoo.reaktive.base.Consumer
import com.badoo.reaktive.base.invoke
import example.todo.common.database.TodoSharedDatabase
import example.todo.common.main.TodoMain
import example.todo.common.main.TodoMain.Model
import example.todo.common.main.TodoMain.Output
@@ -13,12 +14,11 @@ import example.todo.common.main.store.TodoMainStore.Intent
import example.todo.common.main.store.TodoMainStoreProvider
import example.todo.common.utils.asValue
import example.todo.common.utils.getStore
import example.todo.database.TodoDatabase
class TodoMainComponent(
componentContext: ComponentContext,
storeFactory: StoreFactory,
database: TodoDatabase,
database: TodoSharedDatabase,
private val output: Consumer<Output>
) : TodoMain, ComponentContext by componentContext {
@@ -26,7 +26,7 @@ class TodoMainComponent(
instanceKeeper.getStore {
TodoMainStoreProvider(
storeFactory = storeFactory,
database = TodoMainStoreDatabase(queries = database.todoDatabaseQueries)
database = TodoMainStoreDatabase(database = database)
).provide()
}

View File

@@ -1,26 +1,20 @@
package example.todo.common.main.integration
import com.badoo.reaktive.completable.Completable
import com.badoo.reaktive.completable.completableFromFunction
import com.badoo.reaktive.completable.subscribeOn
import com.badoo.reaktive.observable.Observable
import com.badoo.reaktive.observable.mapIterable
import com.badoo.reaktive.scheduler.ioScheduler
import com.squareup.sqldelight.Query
import example.todo.common.database.TodoDatabaseQueries
import example.todo.common.database.TodoItemEntity
import example.todo.common.database.asObservable
import example.todo.common.database.TodoSharedDatabase
import example.todo.common.main.TodoItem
import example.todo.common.main.store.TodoMainStoreProvider
internal class TodoMainStoreDatabase(
private val queries: TodoDatabaseQueries
private val database: TodoSharedDatabase
) : TodoMainStoreProvider.Database {
override val updates: Observable<List<TodoItem>> =
queries
.selectAll()
.asObservable(Query<TodoItemEntity>::executeAsList)
database
.observeAll()
.mapIterable { it.toItem() }
private fun TodoItemEntity.toItem(): TodoItem =
@@ -32,14 +26,11 @@ internal class TodoMainStoreDatabase(
)
override fun setDone(id: Long, isDone: Boolean): Completable =
completableFromFunction { queries.setDone(id = id, isDone = isDone) }
.subscribeOn(ioScheduler)
database.setDone(id = id, isDone = isDone)
override fun delete(id: Long): Completable =
completableFromFunction { queries.delete(id = id) }
.subscribeOn(ioScheduler)
database.delete(id = id)
override fun add(text: String): Completable =
completableFromFunction { queries.add(text = text) }
.subscribeOn(ioScheduler)
database.add(text = text)
}

View File

@@ -8,12 +8,11 @@ import com.badoo.reaktive.subject.publish.PublishSubject
import com.badoo.reaktive.test.observable.assertValue
import com.badoo.reaktive.test.observable.test
import com.badoo.reaktive.test.scheduler.TestScheduler
import example.todo.common.database.TestDatabaseDriver
import example.todo.common.database.TestTodoSharedDatabase
import example.todo.common.database.TodoItemEntity
import example.todo.common.main.TodoItem
import example.todo.common.main.TodoMain.Model
import example.todo.common.main.TodoMain.Output
import example.todo.database.TodoDatabase
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
@@ -25,11 +24,10 @@ import kotlin.test.assertTrue
class TodoMainTest {
private val lifecycle = LifecycleRegistry()
private val database = TodoDatabase(TestDatabaseDriver())
private val database = TestTodoSharedDatabase(TestScheduler())
private val outputSubject = PublishSubject<Output>()
private val output = outputSubject.test()
private val queries = database.todoDatabaseQueries
private val databaseTesting = database.testing
private val impl by lazy {
TodoMainComponent(
@@ -49,29 +47,29 @@ class TodoMainTest {
io = { TestScheduler() }
)
queries.clear()
databaseTesting.clear()
}
@Test
fun WHEN_item_added_to_database_THEN_item_displayed() {
queries.add("Item1")
databaseTesting.add("Item1")
assertEquals("Item1", firstItem().text)
}
@Test
fun WHEN_item_deleted_from_database_THEN_item_not_displayed() {
queries.add("Item1")
databaseTesting.add("Item1")
val id = lastInsertItem().id
queries.delete(id = id)
databaseTesting.delete(id = id)
assertFalse(model.items.any { it.id == id })
}
@Test
fun WHEN_item_clicked_THEN_Output_Selected_emitted() {
queries.add("Item1")
databaseTesting.add("Item1")
val id = firstItem().id
impl.onItemClicked(id = id)
@@ -81,42 +79,42 @@ class TodoMainTest {
@Test
fun GIVEN_item_isDone_false_WHEN_done_changed_to_true_THEN_item_isDone_true_in_database() {
queries.add("Item1")
databaseTesting.add("Item1")
val id = firstItem().id
queries.setDone(id = id, isDone = false)
databaseTesting.setDone(id = id, isDone = false)
impl.onItemDoneChanged(id = id, isDone = true)
assertTrue(queries.select(id = id).executeAsOne().isDone)
assertTrue(databaseTesting.selectRequired(id = id).isDone)
}
@Test
fun GIVEN_item_isDone_true_WHEN_done_changed_to_false_THEN_item_isDone_false_in_database() {
queries.add("Item1")
databaseTesting.add("Item1")
val id = firstItem().id
queries.setDone(id = id, isDone = true)
databaseTesting.setDone(id = id, isDone = true)
impl.onItemDoneChanged(id = id, isDone = false)
assertFalse(queries.select(id = id).executeAsOne().isDone)
assertFalse(databaseTesting.selectRequired(id = id).isDone)
}
@Test
fun WHEN_item_delete_clicked_THEN_item_deleted_in_database() {
queries.add("Item1")
databaseTesting.add("Item1")
val id = firstItem().id
impl.onItemDeleteClicked(id = id)
assertNull(queries.select(id = id).executeAsOneOrNull())
assertNull(databaseTesting.select(id = id))
}
@Test
fun WHEN_item_text_changed_in_database_THEN_item_updated() {
queries.add("Item1")
databaseTesting.add("Item1")
val id = firstItem().id
queries.setText(id = id, text = "New text")
databaseTesting.setText(id = id, text = "New text")
assertEquals("New text", firstItem().text)
}
@@ -139,9 +137,6 @@ class TodoMainTest {
private fun firstItem(): TodoItem = model.items[0]
private fun lastInsertItem(): TodoItemEntity {
val lastInsertId = queries.transactionWithResult<Long> { queries.getLastInsertId().executeAsOne() }
return queries.select(id = lastInsertId).executeAsOne()
}
private fun lastInsertItem(): TodoItemEntity =
databaseTesting.selectRequired(id = requireNotNull(databaseTesting.getLastInsertId()))
}

View File

@@ -10,6 +10,7 @@ import com.arkivanov.decompose.statekeeper.Parcelize
import com.arkivanov.decompose.value.Value
import com.arkivanov.mvikotlin.core.store.StoreFactory
import com.badoo.reaktive.base.Consumer
import example.todo.common.database.TodoSharedDatabase
import example.todo.common.edit.TodoEdit
import example.todo.common.edit.integration.TodoEditComponent
import example.todo.common.main.TodoMain
@@ -17,7 +18,6 @@ import example.todo.common.main.integration.TodoMainComponent
import example.todo.common.root.TodoRoot
import example.todo.common.root.TodoRoot.Child
import example.todo.common.utils.Consumer
import example.todo.database.TodoDatabase
class TodoRootComponent internal constructor(
componentContext: ComponentContext,
@@ -28,7 +28,7 @@ class TodoRootComponent internal constructor(
constructor(
componentContext: ComponentContext,
storeFactory: StoreFactory,
database: TodoDatabase
database: TodoSharedDatabase
) : this(
componentContext = componentContext,
todoMain = { childContext, output ->

View File

@@ -11,11 +11,11 @@ import com.arkivanov.decompose.extensions.compose.jetbrains.rememberRootComponen
import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory
import com.badoo.reaktive.coroutinesinterop.asScheduler
import com.badoo.reaktive.scheduler.overrideSchedulers
import example.todo.common.database.DefaultTodoSharedDatabase
import example.todo.common.database.TodoDatabaseDriver
import example.todo.common.root.TodoRoot
import example.todo.common.root.integration.TodoRootComponent
import example.todo.common.ui.TodoRootContent
import example.todo.database.TodoDatabase
import kotlinx.coroutines.Dispatchers
fun main() {
@@ -36,5 +36,5 @@ private fun todoRoot(componentContext: ComponentContext): TodoRoot =
TodoRootComponent(
componentContext = componentContext,
storeFactory = DefaultStoreFactory,
database = TodoDatabase(TodoDatabaseDriver())
database = DefaultTodoSharedDatabase(TodoDatabaseDriver())
)

View File

@@ -8,7 +8,7 @@ struct ContentView: View {
TodoRootComponent(
componentContext: $0,
storeFactory: DefaultStoreFactory(),
database: TodoDatabaseCompanion().invoke(driver: TodoDatabaseDriverFactoryKt.TodoDatabaseDriver())
database: DefaultTodoSharedDatabase(driver: TodoDatabaseDriverFactoryKt.TodoDatabaseDriver())
)
}

View File

@@ -6,5 +6,6 @@ include(
":common:root",
":common:compose-ui",
":android",
":desktop"
":desktop",
":web"
)

View File

@@ -0,0 +1,34 @@
import org.jetbrains.compose.compose
plugins {
kotlin("multiplatform")
id("org.jetbrains.compose")
}
kotlin {
js(IR) {
browser {
useCommonJs()
binaries.executable()
}
}
sourceSets {
named("jsMain") {
dependencies {
implementation(compose.runtime)
implementation(compose.web.widgets)
implementation(project(":common:utils"))
implementation(project(":common:database"))
implementation(project(":common:root"))
implementation(project(":common:main"))
implementation(project(":common:edit"))
implementation(Deps.ArkIvanov.Decompose.decompose)
implementation(Deps.ArkIvanov.MVIKotlin.mvikotlin)
implementation(Deps.ArkIvanov.MVIKotlin.mvikotlinMain)
implementation(npm("copy-webpack-plugin", "9.0.0"))
implementation(npm("@material-ui/icons", "4.11.2"))
}
}
}
}

View File

@@ -0,0 +1,35 @@
package example.todo.web
import com.arkivanov.decompose.DefaultComponentContext
import com.arkivanov.decompose.lifecycle.LifecycleRegistry
import com.arkivanov.decompose.lifecycle.resume
import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory
import example.todo.common.database.DefaultTodoSharedDatabase
import example.todo.common.database.todoDatabaseDriver
import example.todo.common.root.integration.TodoRootComponent
import kotlinx.browser.document
import org.jetbrains.compose.web.css.Style
import org.jetbrains.compose.web.renderComposable
import org.jetbrains.compose.web.ui.Styles
import org.w3c.dom.HTMLElement
fun main() {
val rootElement = document.getElementById("root") as HTMLElement
val lifecycle = LifecycleRegistry()
val root =
TodoRootComponent(
componentContext = DefaultComponentContext(lifecycle = lifecycle),
storeFactory = DefaultStoreFactory,
database = DefaultTodoSharedDatabase(todoDatabaseDriver())
)
lifecycle.resume()
renderComposable(root = rootElement) {
Style(Styles)
TodoRootUi(root)
}
}

View File

@@ -0,0 +1,174 @@
package example.todo.web
import androidx.compose.runtime.Composable
import org.jetbrains.compose.common.material.Text
import org.jetbrains.compose.web.attributes.InputType
import org.jetbrains.compose.web.attributes.checked
import org.jetbrains.compose.web.css.AlignItems
import org.jetbrains.compose.web.css.DisplayStyle
import org.jetbrains.compose.web.css.JustifyContent
import org.jetbrains.compose.web.css.alignItems
import org.jetbrains.compose.web.css.display
import org.jetbrains.compose.web.css.height
import org.jetbrains.compose.web.css.justifyContent
import org.jetbrains.compose.web.css.percent
import org.jetbrains.compose.web.css.px
import org.jetbrains.compose.web.css.width
import org.jetbrains.compose.web.dom.A
import org.jetbrains.compose.web.dom.AttrBuilderContext
import org.jetbrains.compose.web.dom.Div
import org.jetbrains.compose.web.dom.ElementScope
import org.jetbrains.compose.web.dom.I
import org.jetbrains.compose.web.dom.Input
import org.jetbrains.compose.web.dom.Label
import org.jetbrains.compose.web.dom.Li
import org.jetbrains.compose.web.dom.Nav
import org.jetbrains.compose.web.dom.Span
import org.jetbrains.compose.web.dom.Text
import org.jetbrains.compose.web.dom.TextArea
import org.jetbrains.compose.web.dom.Ul
import org.w3c.dom.HTMLUListElement
@Composable
fun MaterialCheckbox(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
attrs: AttrBuilderContext<*> = {},
content: @Composable () -> Unit = {}
) {
Div(attrs = attrs) {
Label {
Input(
type = InputType.Checkbox,
attrs = {
classes("filled-in")
if (checked) checked()
onCheckboxInput { onCheckedChange(it.checked) }
}
)
Span {
content()
}
}
}
}
@Composable
fun Card(attrs: AttrBuilderContext<*> = {}, content: @Composable () -> Unit) {
Div(
attrs = {
classes("card")
attrs()
}
) {
content()
}
}
@Composable
fun MaterialTextArea(
id: String,
label: String,
text: String,
onTextChanged: (String) -> Unit,
attrs: AttrBuilderContext<*> = {}
) {
Div(
attrs = {
classes("input-field", "col", "s12")
attrs()
}
) {
TextArea(
value = text,
attrs = {
id("text_area_add_todo")
classes("materialize-textarea")
onTextInput { onTextChanged(it.inputValue) }
style {
width(100.percent)
height(100.percent)
}
}
)
Label(forId = id) {
Text(text = label)
}
}
}
@Composable
fun ImageButton(
onClick: () -> Unit,
iconName: String,
attrs: AttrBuilderContext<*> = {}
) {
A(
attrs = {
classes("waves-effect", "waves-teal", "btn-flat")
style {
width(48.px)
height(48.px)
display(DisplayStyle.Flex)
alignItems(AlignItems.Center)
justifyContent(JustifyContent.Center)
}
this.onClick { onClick() }
attrs()
}
) {
MaterialIcon(name = iconName)
}
}
@Composable
fun MaterialIcon(name: String) {
I(attrs = { classes("material-icons") }) { Text(value = name) }
}
@Composable
fun NavBar(
title: String,
navigationIcon: NavBarIcon? = null
) {
Nav {
Div(attrs = { classes("nav-wrapper") }) {
if (navigationIcon != null) {
Ul(attrs = { classes("left") }) {
NavBarIcon(icon = navigationIcon)
}
}
A(
attrs = {
classes("brand-logo")
style {
property("padding-left", 16.px)
}
}
) {
Text(value = title)
}
}
}
}
@Composable
private fun ElementScope<HTMLUListElement>.NavBarIcon(icon: NavBarIcon) {
Li {
A(
attrs = {
onClick { icon.onClick() }
}
) {
MaterialIcon(name = icon.name)
}
}
}
class NavBarIcon(
val name: String,
val onClick: () -> Unit
)

View File

@@ -0,0 +1,115 @@
package example.todo.web
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import kotlinx.coroutines.delay
import org.jetbrains.compose.web.css.Position
import org.jetbrains.compose.web.css.height
import org.jetbrains.compose.web.css.left
import org.jetbrains.compose.web.css.opacity
import org.jetbrains.compose.web.css.percent
import org.jetbrains.compose.web.css.position
import org.jetbrains.compose.web.css.px
import org.jetbrains.compose.web.css.top
import org.jetbrains.compose.web.css.width
import org.jetbrains.compose.web.dom.AttrBuilderContext
import org.jetbrains.compose.web.dom.Div
import kotlin.js.Date
import kotlin.math.sqrt
@Composable
fun <T : Any> Crossfade(target: T, attrs: AttrBuilderContext<*> = {}, content: @Composable (T) -> Unit) {
val holder = remember { TargetHolder<T>(null) }
val previousTarget: T? = holder.value
if (previousTarget == null) {
holder.value = target
Div(attrs = attrs) {
content(target)
}
return
}
if (previousTarget == target) {
Div(attrs = attrs) {
content(target)
}
return
}
holder.value = target
val animationFactor by animateFloatFactor(key = target, durationMillis = 300L, easing = ::sqrt)
Div(attrs = attrs) {
if (animationFactor < 1F) {
Div(
attrs = {
style {
width(100.percent)
height(100.percent)
position(Position.Absolute)
top(0.px)
left(0.px)
opacity(1F - animationFactor)
}
}
) {
content(previousTarget)
}
}
Div(
attrs = {
style {
width(100.percent)
height(100.percent)
position(Position.Absolute)
top(0.px)
left(0.px)
if (animationFactor < 1F) {
opacity(animationFactor)
}
}
}
) {
content(target)
}
}
}
private class TargetHolder<T : Any>(
var value: T?
)
@Composable
private fun animateFloatFactor(key: Any, durationMillis: Long, easing: Easing = Easing { it }): State<Float> {
val state = remember(key) { mutableStateOf(0F) }
LaunchedEffect(key) {
var date = Date.now()
val startMillis = date
val endMillis = startMillis + durationMillis.toDouble()
while (true) {
date = Date.now()
if (date >= endMillis) {
break
}
state.value = easing.transform(((date - startMillis) / durationMillis.toDouble()).toFloat())
delay(16L)
}
state.value = 1F
}
return state
}
private fun interface Easing {
fun transform(fraction: Float): Float
}

View File

@@ -0,0 +1,96 @@
package example.todo.web
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import example.todo.common.edit.TodoEdit
import org.jetbrains.compose.web.css.DisplayStyle
import org.jetbrains.compose.web.css.FlexDirection
import org.jetbrains.compose.web.css.FlexWrap
import org.jetbrains.compose.web.css.JustifyContent
import org.jetbrains.compose.web.css.display
import org.jetbrains.compose.web.css.flexFlow
import org.jetbrains.compose.web.css.height
import org.jetbrains.compose.web.css.justifyContent
import org.jetbrains.compose.web.css.percent
import org.jetbrains.compose.web.css.width
import org.jetbrains.compose.web.dom.Div
import org.jetbrains.compose.web.dom.Text
@Composable
fun TodoEditUi(component: TodoEdit) {
val model by component.models.subscribeAsState()
Div(
attrs = {
style {
width(100.percent)
height(100.percent)
display(DisplayStyle.Flex)
flexFlow(FlexDirection.Column, FlexWrap.Nowrap)
}
}
) {
Div(
attrs = {
style {
width(100.percent)
property("flex", "0 1 auto")
}
}
) {
NavBar(
title = "Edit todo",
navigationIcon = NavBarIcon(
name = "arrow_back",
onClick = component::onCloseClicked
)
)
}
Div(
attrs = {
style {
width(100.percent)
property("flex", "1 1 auto")
property("padding", "0px 16px 0px 16px")
display(DisplayStyle.Flex)
flexFlow(FlexDirection.Column, FlexWrap.Nowrap)
}
}
) {
MaterialTextArea(
id = "text_area_edit_todo",
label = "",
text = model.text,
onTextChanged = component::onTextChanged,
attrs = {
style {
width(100.percent)
property("flex", "1 1 auto")
}
}
)
}
Div(
attrs = {
style {
width(100.percent)
property("flex", "0 1 auto")
property("padding-bottom", "16px")
display(DisplayStyle.Flex)
justifyContent(JustifyContent.Center)
}
}
) {
MaterialCheckbox(
checked = model.isDone,
onCheckedChange = component::onDoneChanged,
content = {
Text(value = "Completed")
}
)
}
}
}

View File

@@ -0,0 +1,189 @@
package example.todo.web
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import example.todo.common.main.TodoItem
import example.todo.common.main.TodoMain
import org.jetbrains.compose.web.css.AlignItems
import org.jetbrains.compose.web.css.DisplayStyle
import org.jetbrains.compose.web.css.FlexDirection
import org.jetbrains.compose.web.css.FlexWrap
import org.jetbrains.compose.web.css.alignItems
import org.jetbrains.compose.web.css.display
import org.jetbrains.compose.web.css.flexFlow
import org.jetbrains.compose.web.css.height
import org.jetbrains.compose.web.css.margin
import org.jetbrains.compose.web.css.marginLeft
import org.jetbrains.compose.web.css.percent
import org.jetbrains.compose.web.css.px
import org.jetbrains.compose.web.css.width
import org.jetbrains.compose.web.dom.DOMScope
import org.jetbrains.compose.web.dom.Div
import org.jetbrains.compose.web.dom.Li
import org.jetbrains.compose.web.dom.Text
import org.jetbrains.compose.web.dom.Ul
import org.w3c.dom.HTMLUListElement
@Composable
fun TodoMainUi(component: TodoMain) {
val model by component.models.subscribeAsState()
Div(
attrs = {
style {
width(100.percent)
height(100.percent)
display(DisplayStyle.Flex)
flexFlow(FlexDirection.Column, FlexWrap.Nowrap)
}
}
) {
Div(
attrs = {
style {
width(100.percent)
property("flex", "0 1 auto")
}
}
) {
NavBar(title = "Todo List")
}
Ul(
attrs = {
style {
width(100.percent)
margin(0.px)
property("flex", "1 1 auto")
property("overflow-y", "scroll")
}
}
) {
model.items.forEach { item ->
Item(
item = item,
onClicked = component::onItemClicked,
onDoneChanged = component::onItemDoneChanged,
onDeleteClicked = component::onItemDeleteClicked
)
}
}
Div(
attrs = {
style {
width(100.percent)
property("flex", "0 1 auto")
}
}
) {
TodoInput(
text = model.text,
onTextChanged = component::onInputTextChanged,
onAddClicked = component::onAddItemClicked
)
}
}
}
@Composable
private fun DOMScope<HTMLUListElement>.Item(
item: TodoItem,
onClicked: (id: Long) -> Unit,
onDoneChanged: (id: Long, isDone: Boolean) -> Unit,
onDeleteClicked: (id: Long) -> Unit
) {
Li(
attrs = {
style {
width(100.percent)
display(DisplayStyle.Flex)
flexFlow(FlexDirection.Row, FlexWrap.Nowrap)
alignItems(AlignItems.Center)
property("padding", "0px 0px 0px 16px")
}
}
) {
MaterialCheckbox(
checked = item.isDone,
onCheckedChange = { onDoneChanged(item.id, !item.isDone) },
attrs = {
style {
property("flex", "0 1 auto")
property("padding-top", 10.px) // Fix for the checkbox not being centered vertically
}
}
)
Div(
attrs = {
style {
height(48.px)
property("flex", "1 1 auto")
property("white-space", "nowrap")
property("text-overflow", "ellipsis")
property("overflow", "hidden")
display(DisplayStyle.Flex)
alignItems(AlignItems.Center)
}
onClick { onClicked(item.id) }
}
) {
Text(value = item.text)
}
ImageButton(
onClick = { onDeleteClicked(item.id) },
iconName = "delete",
attrs = {
style {
property("flex", "0 1 auto")
marginLeft(8.px)
}
}
)
}
}
@Composable
private fun TodoInput(
text: String,
onTextChanged: (String) -> Unit,
onAddClicked: () -> Unit
) {
Div(
attrs = {
style {
width(100.percent)
display(DisplayStyle.Flex)
flexFlow(FlexDirection.Row, FlexWrap.Nowrap)
alignItems(AlignItems.Center)
}
}
) {
MaterialTextArea(
id = "text_area_add_todo",
label = "Add todo",
text = text,
onTextChanged = onTextChanged,
attrs = {
style {
property("flex", "1 1 auto")
margin(16.px)
}
}
)
ImageButton(
onClick = onAddClicked,
iconName = "add",
attrs = {
style {
property("flex", "0 1 auto")
property("margin", "0px 16px 0px 0px")
}
}
)
}
}

View File

@@ -0,0 +1,54 @@
package example.todo.web
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import example.todo.common.root.TodoRoot
import org.jetbrains.compose.web.css.Position
import org.jetbrains.compose.web.css.auto
import org.jetbrains.compose.web.css.bottom
import org.jetbrains.compose.web.css.height
import org.jetbrains.compose.web.css.left
import org.jetbrains.compose.web.css.percent
import org.jetbrains.compose.web.css.position
import org.jetbrains.compose.web.css.px
import org.jetbrains.compose.web.css.right
import org.jetbrains.compose.web.css.top
import org.jetbrains.compose.web.css.width
@Composable
fun TodoRootUi(component: TodoRoot) {
Card(
attrs = {
style {
position(Position.Absolute)
height(700.px)
property("max-width", 640.px)
top(0.px)
bottom(0.px)
left(0.px)
right(0.px)
property("margin", auto)
}
}
) {
val routerState by component.routerState.subscribeAsState()
Crossfade(
target = routerState.activeChild.instance,
attrs = {
style {
width(100.percent)
height(100.percent)
position(Position.Relative)
left(0.px)
top(0.px)
}
}
) { child ->
when (child) {
is TodoRoot.Child.Main -> TodoMainUi(child.component)
is TodoRoot.Child.Edit -> TodoEditUi(child.component)
}
}
}
}

View File

@@ -0,0 +1,26 @@
package example.todo.web
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import com.arkivanov.decompose.value.Value
import com.arkivanov.decompose.value.ValueObserver
@Composable
fun <T : Any> Value<T>.subscribeAsState(): State<T> {
val state = remember(this) { mutableStateOf(value) }
DisposableEffect(this) {
val observer: ValueObserver<T> = { state.value = it }
subscribe(observer)
onDispose {
unsubscribe(observer)
}
}
return state
}

View File

@@ -0,0 +1,30 @@
<!--
~ Copyright 2021 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Todo</title>
<link href="styles.css" rel="stylesheet" type="text/css"/>
<link href="materialize.min.css" media="screen,projection" rel="stylesheet" type="text/css"/>
</head>
<body>
<div id="root"></div>
<script src="web.js"></script>
<script src="materialize.min.js" type="text/javascript"></script>
</body>
</html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,34 @@
@font-face {
font-family: 'Material Icons';
font-style: normal;
font-weight: 400;
src: local('Material Icons'),
local('MaterialIcons-Regular'),
url(MaterialIcons-Regular.ttf) format('truetype');
}
.material-icons {
font-family: 'Material Icons';
font-weight: normal;
font-style: normal;
font-size: 24px; /* Preferred icon size */
display: inline-block;
line-height: 1;
text-transform: none;
letter-spacing: normal;
word-wrap: normal;
white-space: nowrap;
direction: ltr;
/* Support for all WebKit browsers. */
-webkit-font-smoothing: antialiased;
/* Support for Safari and Chrome. */
text-rendering: optimizeLegibility;
/* Support for Firefox. */
-moz-osx-font-smoothing: grayscale;
/* Support for IE. */
font-feature-settings: 'liga';
}

View File

@@ -0,0 +1,9 @@
// As per https://cashapp.github.io/sqldelight/js_sqlite/
config.resolve = {
fallback: {
fs: false,
path: false,
crypto: false
}
}

View File

@@ -0,0 +1,12 @@
// As per https://cashapp.github.io/sqldelight/js_sqlite/
var CopyWebpackPlugin = require('copy-webpack-plugin');
config.plugins.push(
new CopyWebpackPlugin(
{
patterns: [
{from: '../../node_modules/sql.js/dist/sql-wasm.wasm', to: '../../../web/build/distributions'}
]
}
)
);