From 66fe3be32fcd37c9be13870aacab9ad94a5728ab Mon Sep 17 00:00:00 2001 From: lamtruong28 Date: Wed, 20 May 2026 13:56:42 +0700 Subject: [PATCH] Implementation SQLDelight database --- build.gradle.kts | 1 + composeApp/build.gradle.kts | 24 ++- .../app/torquevaultkmp/di/PlatformModules.kt | 12 +- .../data/repository/DeviceRepository.kt | 35 ++++ .../data/repository/LogRepository.kt | 62 +++++++ .../app/torquevaultkmp/di/Koin.kt | 1 + .../app/torquevaultkmp/di/Modules.kt | 14 ++ .../torquevaultkmp/domain/model/LogFilter.kt | 13 ++ .../screens/bonded/BondedScreen.kt | 136 ++++++++++------ .../screens/bonded/BondedViewModel.kt | 75 +++++++++ .../screens/bonded/view/BondedDeviceItem.kt | 48 +++--- .../screens/logs/LogFilterState.kt | 86 ---------- .../torquevaultkmp/screens/logs/LogScreen.kt | 151 +++++++++++------- .../screens/logs/LogViewModel.kt | 98 ++++++++++++ .../screens/logs/view/FilterBottomSheet.kt | 143 ++++++----------- .../screens/logs/view/LogItem.kt | 70 ++++---- .../data/storage/db/AppDatabase.sq | 81 ++++++++++ .../app/torquevaultkmp/di/PlatformModules.kt | 11 +- gradle/libs.versions.toml | 12 +- 19 files changed, 710 insertions(+), 363 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/repository/DeviceRepository.kt create mode 100644 composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/repository/LogRepository.kt create mode 100644 composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/domain/model/LogFilter.kt create mode 100644 composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bonded/BondedViewModel.kt delete mode 100644 composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/LogFilterState.kt create mode 100644 composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/LogViewModel.kt create mode 100644 composeApp/src/commonMain/sqldelight/com/digitoolsolutions/app/torquevaultkmp/data/storage/db/AppDatabase.sq diff --git a/build.gradle.kts b/build.gradle.kts index d63f5ec..5fde3ee 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,4 +7,5 @@ plugins { alias(libs.plugins.composeCompiler) apply false alias(libs.plugins.kotlinMultiplatform) apply false alias(libs.plugins.kotlin.serialization) apply false + alias(libs.plugins.sqldelight) apply false } \ No newline at end of file diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 86cf895..804f27f 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -7,14 +7,19 @@ plugins { alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeCompiler) alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.sqldelight) +} + +sqldelight { + databases { + create("AppDatabase") { + packageName.set("com.digitoolsolutions.app.torquevaultkmp.data.storage.db") + } + } } kotlin { - androidTarget { - compilerOptions { - jvmTarget.set(JvmTarget.JVM_11) - } - } + androidTarget() listOf( iosArm64(), @@ -22,7 +27,8 @@ kotlin { ).forEach { iosTarget -> iosTarget.binaries.framework { baseName = "ComposeApp" - isStatic = true + isStatic = false + linkerOpts("-lsqlite3") } } @@ -36,6 +42,7 @@ kotlin { implementation(libs.koin.compose) implementation(libs.koin.compose.viewmodel) implementation(libs.ktor.client.okhttp) + implementation(libs.sqldelight.android) } commonMain.dependencies { implementation(libs.compose.runtime) @@ -56,10 +63,15 @@ kotlin { implementation(libs.koin.core) implementation(libs.koin.compose.viewmodel) + implementation(libs.sqldelight.runtime) + implementation(libs.sqldelight.coroutines) + implementation(libs.kotlinx.datetime) + implementation(libs.logger.napier) } iosMain.dependencies { implementation(libs.ktor.client.darwin) + implementation(libs.sqldelight.native) } commonTest.dependencies { implementation(libs.kotlin.test) diff --git a/composeApp/src/androidMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/PlatformModules.kt b/composeApp/src/androidMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/PlatformModules.kt index 1dcc915..80b404f 100644 --- a/composeApp/src/androidMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/PlatformModules.kt +++ b/composeApp/src/androidMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/PlatformModules.kt @@ -1,10 +1,20 @@ package com.digitoolsolutions.app.torquevaultkmp.di +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.driver.android.AndroidSqliteDriver import com.digitoolsolutions.app.torquevaultkmp.AppStoragePlatform import com.digitoolsolutions.app.torquevaultkmp.data.storage.AppStorage +import com.digitoolsolutions.app.torquevaultkmp.data.storage.db.AppDatabase import org.koin.android.ext.koin.androidContext import org.koin.dsl.module actual val platformModule = module { single { AppStoragePlatform(androidContext()) } -} \ No newline at end of file + single { + AndroidSqliteDriver( + schema = AppDatabase.Schema, + context = androidContext(), + name = "app.db" + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/repository/DeviceRepository.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/repository/DeviceRepository.kt new file mode 100644 index 0000000..017805b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/repository/DeviceRepository.kt @@ -0,0 +1,35 @@ +package com.digitoolsolutions.app.torquevaultkmp.data.repository + +import app.cash.sqldelight.coroutines.asFlow +import app.cash.sqldelight.coroutines.mapToList +import com.digitoolsolutions.app.torquevaultkmp.data.storage.db.AppDatabaseQueries +import com.digitoolsolutions.app.torquevaultkmp.data.storage.db.Device +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.flow.Flow + +class DeviceRepository( + private val dbQueries: AppDatabaseQueries +) { + suspend fun saveDevice(id: String, name: String) { + dbQueries.insertDevice(id, name) + } + + fun getAllDevices(): Flow> = + dbQueries.getAllDevices() + .asFlow() + .mapToList(Dispatchers.IO) + + suspend fun getDevicesPaged(limit: Long, lastId: String?): List { + return if (lastId == null) { + dbQueries.getDevicesFirstPage(limit).executeAsList() + } else { + dbQueries.getDevicesNextPage(lastId = lastId, limit = limit).executeAsList() + } + } + + suspend fun getDeviceById(id: String): Device? = + dbQueries.getDeviceById(id).executeAsOneOrNull() + + suspend fun deleteDevice(id: String) = dbQueries.deleteDeviceById(id) +} diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/repository/LogRepository.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/repository/LogRepository.kt new file mode 100644 index 0000000..fef7ba6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/data/repository/LogRepository.kt @@ -0,0 +1,62 @@ +package com.digitoolsolutions.app.torquevaultkmp.data.repository + +import app.cash.sqldelight.coroutines.asFlow +import app.cash.sqldelight.coroutines.mapToList +import com.digitoolsolutions.app.torquevaultkmp.data.storage.db.AppDatabaseQueries +import com.digitoolsolutions.app.torquevaultkmp.data.storage.db.Log +import com.digitoolsolutions.app.torquevaultkmp.domain.model.LogFilter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.flow.Flow +import kotlin.time.Clock + +class LogRepository( + private val dbQueries: AppDatabaseQueries +) { + suspend fun addLog(title: String, content: String, deviceId: String? = null) { + val source = if (deviceId == null) LogFilter.System.name else LogFilter.Device.name + dbQueries.insertLog( + title = title, + content = content, + timestamp = Clock.System.now().toEpochMilliseconds(), + deviceId = deviceId, + source = source + ) + } + + fun getAllLogs(): Flow> = + dbQueries.getAllLogs() + .asFlow() + .mapToList(Dispatchers.IO) + + suspend fun getLogsPaged(limit: Long, lastTimestamp: Long?, lastId: Long?): List { + return if (lastTimestamp == null || lastId == null) { + dbQueries.getLogsFirstPage(limit).executeAsList() + } else { + dbQueries.getLogsNextPage(lastTimestamp = lastTimestamp, lastId = lastId, limit = limit).executeAsList() + } + } + + suspend fun getLogsByFilterPaged(filter: LogFilter, limit: Long, lastTimestamp: Long?, lastId: Long?): List { + return when (filter) { + LogFilter.All -> getLogsPaged(limit, lastTimestamp, lastId) + LogFilter.System, LogFilter.Device -> { + if (lastTimestamp == null || lastId == null) { + dbQueries.getLogsByTypeFirstPage(filter.name, limit).executeAsList() + } else { + dbQueries.getLogsByTypeNextPage(source = filter.name, lastTimestamp = lastTimestamp, lastId = lastId, limit = limit).executeAsList() + } + } + } + } + + suspend fun getLogsByDevicePaged(deviceId: String, limit: Long, lastTimestamp: Long?, lastId: Long?): List { + return if (lastTimestamp == null || lastId == null) { + dbQueries.getLogsByDeviceFirstPage(deviceId, limit).executeAsList() + } else { + dbQueries.getLogsByDeviceNextPage(deviceId = deviceId, lastTimestamp = lastTimestamp, lastId = lastId, limit = limit).executeAsList() + } + } + + suspend fun clearLogs() = dbQueries.clearAllLogs() +} diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/Koin.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/Koin.kt index d45f6ab..0a490f8 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/Koin.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/Koin.kt @@ -8,6 +8,7 @@ fun initKoin(config: KoinAppDeclaration? = null) { config?.invoke(this) modules( appModule, + storageModule, networkModule, viewModelModule, platformModule diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/Modules.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/Modules.kt index 0138db0..ae9dc05 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/Modules.kt @@ -4,12 +4,17 @@ import com.digitoolsolutions.app.torquevaultkmp.AppViewModel import com.digitoolsolutions.app.torquevaultkmp.createPlatformHttpClient import com.digitoolsolutions.app.torquevaultkmp.data.network.api.AuthApi import com.digitoolsolutions.app.torquevaultkmp.data.network.api.WorkOrderApi +import com.digitoolsolutions.app.torquevaultkmp.data.repository.DeviceRepository +import com.digitoolsolutions.app.torquevaultkmp.data.repository.LogRepository import com.digitoolsolutions.app.torquevaultkmp.data.repository.WorkOrderRepository import com.digitoolsolutions.app.torquevaultkmp.data.repository.WorkOrderRepositoryImpl import com.digitoolsolutions.app.torquevaultkmp.data.storage.TokenManager +import com.digitoolsolutions.app.torquevaultkmp.data.storage.db.AppDatabase import com.digitoolsolutions.app.torquevaultkmp.domain.usecase.FetchWorkOrdersUseCase import com.digitoolsolutions.app.torquevaultkmp.screens.auth.AuthViewModel +import com.digitoolsolutions.app.torquevaultkmp.screens.bonded.BondedViewModel import com.digitoolsolutions.app.torquevaultkmp.screens.home.HomeViewModel +import com.digitoolsolutions.app.torquevaultkmp.screens.logs.LogViewModel import com.digitoolsolutions.app.torquevaultkmp.screens.settings.SettingViewModel import org.koin.core.module.Module import org.koin.core.module.dsl.factoryOf @@ -24,6 +29,13 @@ val appModule = module { single { TokenManager(get()) } } +val storageModule = module { + single { AppDatabase(get()) } + single { get().appDatabaseQueries } + single { LogRepository(get()) } + single { DeviceRepository(get()) } +} + val networkModule = module { // Auth client (no interceptor to avoid circular dependency and recursion) single(named("authClient")) { createPlatformHttpClient(get(), null) } @@ -44,4 +56,6 @@ val viewModelModule = module { factory { AuthViewModel(get(), get()) } singleOf(::AppViewModel) factoryOf(::SettingViewModel) + factoryOf(::BondedViewModel) + factoryOf(::LogViewModel) } diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/domain/model/LogFilter.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/domain/model/LogFilter.kt new file mode 100644 index 0000000..b388327 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/domain/model/LogFilter.kt @@ -0,0 +1,13 @@ +package com.digitoolsolutions.app.torquevaultkmp.domain.model + +enum class LogFilter { + All, + System, + Device; + + companion object { + fun fromString(value: String): LogFilter { + return entries.find { it.name == value } ?: All + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bonded/BondedScreen.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bonded/BondedScreen.kt index 3a2a694..29194f7 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bonded/BondedScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bonded/BondedScreen.kt @@ -1,9 +1,19 @@ package com.digitoolsolutions.app.torquevaultkmp.screens.bonded +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState @@ -11,47 +21,76 @@ import androidx.compose.material3.Text import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.navigation.NavController -import kotlin.uuid.ExperimentalUuidApi import com.digitoolsolutions.app.torquevaultkmp.components.AppBar -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.launch +import com.digitoolsolutions.app.torquevaultkmp.data.storage.db.Device +import com.digitoolsolutions.app.torquevaultkmp.screens.bonded.view.BondedDeviceItem +import com.digitoolsolutions.app.torquevaultkmp.screens.bonded.view.EmptyBondedView +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel import torquevaultkmp.composeapp.generated.resources.Res import torquevaultkmp.composeapp.generated.resources.bonded_title -@OptIn(ExperimentalMaterial3Api::class, ExperimentalUuidApi::class) +@OptIn(ExperimentalMaterial3Api::class) @Composable fun BondedScreen( navController: NavController, -// bleScannerViewModel: ScannerViewModel, -// viewModel: BondedViewModel = hiltViewModel() + viewModel: BondedViewModel = koinViewModel() ) { -// val devices = viewModel.bondedDevices.collectAsLazyPagingItems() + val devices by viewModel.bondedDevices.collectAsState() + val isLoading by viewModel.isLoading.collectAsState() BondedContent( -// devices = devices, -// onRefresh = {devices.refresh()}, -// onDelete = { -// viewModel.removeDevice(it) -// bleScannerViewModel.removeBondedCache(it.id) -// } + devices = devices, + isLoading = isLoading, + onAddFake = { viewModel.addFakeDevice() }, + onDelete = { viewModel.removeDevice(it.id) }, + onLoadMore = { viewModel.loadNextPage() }, + onRefresh = { viewModel.refresh() } ) } @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun BondedContent( -// devices: LazyPagingItems, -// onRefresh: () -> Unit, -// onDelete: (Device) -> Unit + devices: List, + isLoading: Boolean, + onAddFake: () -> Unit, + onDelete: (Device) -> Unit, + onLoadMore: () -> Unit, + onRefresh: () -> Unit ) { val snackbarHostState = remember { SnackbarHostState() } + val listState = rememberLazyListState() + + // Infinite Scroll logic + val shouldLoadMore = remember { + derivedStateOf { + val lastVisibleItemIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 + lastVisibleItemIndex >= devices.size - 5 && devices.isNotEmpty() + } + } + + LaunchedEffect(shouldLoadMore) { + snapshotFlow { shouldLoadMore.value } + .distinctUntilChanged() + .filter { it } + .collect { + onLoadMore() + } + } + Scaffold ( modifier = Modifier.imePadding(), topBar = { @@ -59,50 +98,41 @@ internal fun BondedContent( title = {Text(stringResource(Res.string.bonded_title))}, ) }, + floatingActionButton = { + FloatingActionButton(onClick = onAddFake) { + Icon(Icons.Default.Add, contentDescription = "Add Fake Device") + } + }, snackbarHost = { SnackbarHost(snackbarHostState) }) { padding -> val pullToRefreshState = rememberPullToRefreshState() - val scope = rememberCoroutineScope() PullToRefreshBox( - isRefreshing = false, + isRefreshing = isLoading && devices.isEmpty(), onRefresh = { -// onRefresh() - scope.launch { - pullToRefreshState.animateToHidden() - } + onRefresh() }, state = pullToRefreshState, modifier = Modifier.fillMaxSize().imePadding().padding(padding).padding(horizontal = 16.dp, vertical = 12.dp), ){ -// if (devices.itemCount == 0) { -// EmptyBondedView() -// }else { -// LazyColumn{ -// items(count = devices.itemCount, key = { index -> devices[index]?.id ?: index }) { index -> -// val device = devices[index] -// device?.let { it -> -// BondedDeviceItem(it, onDelete = { onDelete(it) }) -// } -// } -// } -// } + if (devices.isEmpty() && !isLoading) { + EmptyBondedView() + } else { + LazyColumn(state = listState) { + items(items = devices, key = { it.id }) { device -> + BondedDeviceItem(device, onDelete = { onDelete(it) }) + } + if (isLoading && devices.isNotEmpty()) { + item { + Box( + modifier = Modifier.fillMaxWidth().padding(16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + } + } + } } } } - -@Preview(showBackground = true) -@Composable -private fun LogScreenPreview() { -// val fakeDevices = listOf( -// Device("AA:BB:CC:DD:EE", "Device 1"), -// Device("AA:BB:CC:DD:FF", "Device 2") -// ) -// val pagingData = PagingData.from(fakeDevices) -// val lazyPagingItems = flowOf(pagingData).collectAsLazyPagingItems() -// -// BondedContent( -// devices = lazyPagingItems, -// onRefresh = {lazyPagingItems.refresh()}, -// onDelete = {} -// ) -} diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bonded/BondedViewModel.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bonded/BondedViewModel.kt new file mode 100644 index 0000000..6ab027e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bonded/BondedViewModel.kt @@ -0,0 +1,75 @@ +package com.digitoolsolutions.app.torquevaultkmp.screens.bonded + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.digitoolsolutions.app.torquevaultkmp.data.repository.DeviceRepository +import com.digitoolsolutions.app.torquevaultkmp.data.storage.db.Device +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlin.random.Random + +class BondedViewModel( + private val deviceRepository: DeviceRepository +) : ViewModel() { + + private val _bondedDevices = MutableStateFlow>(emptyList()) + val bondedDevices: StateFlow> = _bondedDevices.asStateFlow() + + private var lastId: String? = null + private val pageSize = 20L + private var isLastPage = false + var isLoading = MutableStateFlow(false) + private set + + init { + loadNextPage() + } + + fun loadNextPage() { + if (isLoading.value || isLastPage) return + + viewModelScope.launch { + isLoading.value = true + val newDevices = deviceRepository.getDevicesPaged(limit = pageSize, lastId = lastId) + + if (newDevices.size < pageSize) { + isLastPage = true + } + + if (newDevices.isNotEmpty()) { + lastId = newDevices.last().id + _bondedDevices.value += newDevices + } + + isLoading.value = false + } + } + + fun refresh() { + viewModelScope.launch { + lastId = null + isLastPage = false + _bondedDevices.value = emptyList() + loadNextPage() + } + } + + fun addFakeDevice() { + viewModelScope.launch { + val id = "AA:BB:CC:DD:EE:${Random.nextInt(10, 99)}" + val name = "Torque Tool ${Random.nextInt(1, 100)}" + deviceRepository.saveDevice(id, name) + refresh() + } + } + + fun removeDevice(id: String) { + viewModelScope.launch { + deviceRepository.deleteDevice(id) + // If we remove an item, we might just update the local list + _bondedDevices.value = _bondedDevices.value.filter { it.id != id } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bonded/view/BondedDeviceItem.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bonded/view/BondedDeviceItem.kt index a54d9b3..e621940 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bonded/view/BondedDeviceItem.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/bonded/view/BondedDeviceItem.kt @@ -24,6 +24,7 @@ import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.Text import androidx.compose.material3.rememberSwipeToDismissBoxState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -32,13 +33,22 @@ import androidx.compose.ui.graphics.lerp import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.digitoolsolutions.app.torquevaultkmp.components.CircularIcon +import com.digitoolsolutions.app.torquevaultkmp.data.storage.db.Device @Composable -fun BondedDeviceItem(/** device: Device, onDelete: (Device) -> Unit,*/ modifier: Modifier = Modifier) { +fun BondedDeviceItem(device: Device, onDelete: (Device) -> Unit, modifier: Modifier = Modifier) { val swipeToDismissBoxState = rememberSwipeToDismissBoxState( SwipeToDismissBoxValue.Settled, SwipeToDismissBoxDefaults.positionalThreshold ) + + if (swipeToDismissBoxState.currentValue == SwipeToDismissBoxValue.EndToStart) { + LaunchedEffect(Unit) { + onDelete(device) + swipeToDismissBoxState.snapTo(SwipeToDismissBoxValue.Settled) + } + } + SwipeToDismissBox( state = swipeToDismissBoxState, modifier = modifier.fillMaxSize().padding(vertical = 4.dp), @@ -46,13 +56,14 @@ fun BondedDeviceItem(/** device: Device, onDelete: (Device) -> Unit,*/ modifier: backgroundContent = { when (swipeToDismissBoxState.dismissDirection) { SwipeToDismissBoxValue.StartToEnd -> { - SwipeToDismissBackgroundItem(label = "Swipe to delete item", contentAlignment = Alignment.CenterStart, state = swipeToDismissBoxState) + SwipeToDismissBackgroundItem(label = "Delete", contentAlignment = Alignment.CenterStart, state = swipeToDismissBoxState) + } + SwipeToDismissBoxValue.EndToStart -> { + SwipeToDismissBackgroundItem(label = "Delete", state = swipeToDismissBoxState) } - SwipeToDismissBoxValue.EndToStart -> {SwipeToDismissBackgroundItem(label = "Swipe to delete item", state = swipeToDismissBoxState)} SwipeToDismissBoxValue.Settled -> {} } - }, -// onDismiss = {onDelete(device)}, + } ) { OutlinedCard( modifier = Modifier.fillMaxWidth(), @@ -65,15 +76,15 @@ fun BondedDeviceItem(/** device: Device, onDelete: (Device) -> Unit,*/ modifier: CircularIcon( imageVector = Icons.Default.Bluetooth, backgroundColor = Color(0xFF0082FC)) Spacer(modifier = Modifier.width(16.dp)) Column { -// Text( -// text = device.name, -// style = MaterialTheme.typography.titleMedium -// ) -// Text( -// text = device.id, -// style = MaterialTheme.typography.bodyMedium, -// color = MaterialTheme.colorScheme.onSurfaceVariant -// ) + Text( + text = device.name, + style = MaterialTheme.typography.titleMedium + ) + Text( + text = device.id, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } } } @@ -111,12 +122,3 @@ internal fun SwipeToDismissBackgroundItem( } } } - -@Preview -@Composable -fun BondedDeviceItemPreview() { - BondedDeviceItem( -// Device("AA:BB:CC:DD:EE", "Device 1"), -// onDelete = {} - ) -} diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/LogFilterState.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/LogFilterState.kt deleted file mode 100644 index 5efd355..0000000 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/LogFilterState.kt +++ /dev/null @@ -1,86 +0,0 @@ -package com.digitoolsolutions.app.torquevaultkmp.screens.logs//package com.digitoolsolutions.app.torquevaultkmp.screens.logs -// -//import androidx.compose.runtime.Composable -//import androidx.compose.runtime.Stable -//import androidx.compose.runtime.getValue -//import androidx.compose.runtime.mutableStateOf -//import androidx.compose.runtime.saveable.Saver -//import androidx.compose.runtime.saveable.rememberSaveable -//import androidx.compose.runtime.setValue -//import com.digitoolsolutions.android.storage.model.LogFilter -// -//@Stable -//interface LogFilterState { -// val filters: List -//// val sortingOptions: List -// val activeFilter: LogFilter -//// val activeSort: LogSortOption -// val isEmpty: Boolean -// -// fun setFilter(filter: LogFilter) -//// fun sortBy(option: LogSortOption) -// fun clear() -//} -// -//val logFilterSaver = Saver( -// save = { filter -> -// when (filter) { -// LogFilter.All -> "All" -// LogFilter.System -> "System" -// LogFilter.Device -> "Device" -// is LogFilter.ByDeviceId -> "ByDeviceId:${filter.deviceId}" -// } -// }, -// restore = { str -> -// when { -// str == "All" -> LogFilter.All -// str == "System" -> LogFilter.System -// str == "Device" -> LogFilter.Device -// str.startsWith("ByDeviceId:") -> LogFilter.ByDeviceId(str.removePrefix("ByDeviceId:")) -// else -> LogFilter.All -// } -// } -//) -// -////val logSortSaver = Saver( -//// save = { sort -> -//// when (sort) { -//// LogSortOption.ByDate -> "ByDate" -//// } -//// }, -//// restore = { str -> -//// when (str) { -//// "ByDate" -> LogSortOption.ByDate -//// else -> LogSortOption.ByDate -//// } -//// } -////) -// -// -//@Composable -//fun rememberLogFilterState( -// filters: List = listOf(LogFilter.All, LogFilter.System, LogFilter.Device), -//// sortingOptions: List = listOf() -//): LogFilterState { -// var selectedFilter by rememberSaveable(stateSaver = logFilterSaver) { -// mutableStateOf(LogFilter.All) -// } -//// var selectedSort by rememberSaveable(stateSaver = logSortSaver) { -//// mutableStateOf(LogSortOption.ByDate) -//// } -// -// return object : LogFilterState { -// override val filters = filters -//// override val sortingOptions = sortingOptions -// override val activeFilter: LogFilter = selectedFilter -//// override val activeSort: LogSortOption = selectedSort -// override val isEmpty: Boolean get() = selectedFilter == LogFilter.All // && selectedSort == LogSortOption.ByDate -// -// override fun setFilter(filter: LogFilter) { selectedFilter = filter } -//// override fun sortBy(option: LogSortOption) { selectedSort = option } -// override fun clear() { -// selectedFilter = LogFilter.All -//// selectedSort = LogSortOption.ByDate -// } -// } -//} diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/LogScreen.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/LogScreen.kt index 7ad677f..6523bd6 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/LogScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/LogScreen.kt @@ -1,13 +1,21 @@ package com.digitoolsolutions.app.torquevaultkmp.screens.logs +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.DeleteForever import androidx.compose.material.icons.outlined.FilterList import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Scaffold @@ -18,53 +26,89 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.navigation.NavController -import com.digitoolsolutions.app.torquevaultkmp.screens.logs.view.FilterBottomSheet import com.digitoolsolutions.app.torquevaultkmp.components.AppBar -import kotlinx.coroutines.launch +import com.digitoolsolutions.app.torquevaultkmp.data.storage.db.Log +import com.digitoolsolutions.app.torquevaultkmp.domain.model.LogFilter +import com.digitoolsolutions.app.torquevaultkmp.screens.logs.view.EmptyLogView +import com.digitoolsolutions.app.torquevaultkmp.screens.logs.view.FilterBottomSheet +import com.digitoolsolutions.app.torquevaultkmp.screens.logs.view.LogItem +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel import torquevaultkmp.composeapp.generated.resources.Res import torquevaultkmp.composeapp.generated.resources.log_title -import kotlin.uuid.ExperimentalUuidApi -@OptIn(ExperimentalMaterial3Api::class, ExperimentalUuidApi::class) +@OptIn(ExperimentalMaterial3Api::class) @Composable fun LogScreen( navController: NavController, -// viewModel: LogViewModel = hiltViewModel() + viewModel: LogViewModel = koinViewModel() ) { -// val state = rememberLogFilterState() -// val logs = viewModel.getLogs(filter = state.activeFilter).collectAsLazyPagingItems() + val logs by viewModel.logs.collectAsState() + val activeFilter by viewModel.activeFilter.collectAsState() + val isLoading by viewModel.isLoading.collectAsState() LogContent( -// state = state, -// logs = logs, -// onRefresh = {logs.refresh()}, -// onDeleteAllLogs = {viewModel.removeAllLog()}, + logs = logs, + activeFilter = activeFilter, + isLoading = isLoading, + onFilterSelected = { viewModel.setFilter(it) }, + onAddFakeLog = { viewModel.addFakeLog() }, + onDeleteAllLogs = { viewModel.clearAllLogs() }, + onLoadMore = { viewModel.loadNextPage() }, + onRefresh = { viewModel.refresh() } ) } @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun LogContent( -// state: LogFilterState, -// logs: LazyPagingItems, -// onRefresh: () -> Unit, -// onDeleteAllLogs: () -> Unit, + logs: List, + activeFilter: LogFilter, + isLoading: Boolean, + onFilterSelected: (LogFilter) -> Unit, + onAddFakeLog: () -> Unit, + onDeleteAllLogs: () -> Unit, + onLoadMore: () -> Unit, + onRefresh: () -> Unit ) { val snackbarHostState = remember { SnackbarHostState() } var showConfirmDialog by remember { mutableStateOf(false) } var showBottomSheet by remember { mutableStateOf(false) } + val listState = rememberLazyListState() + + // Infinite Scroll logic + val shouldLoadMore = remember { + derivedStateOf { + val lastVisibleItemIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 + lastVisibleItemIndex >= logs.size - 5 && logs.isNotEmpty() + } + } + + LaunchedEffect(shouldLoadMore) { + snapshotFlow { shouldLoadMore.value } + .distinctUntilChanged() + .filter { it } + .collect { + onLoadMore() + } + } + Scaffold ( modifier = Modifier.imePadding(), topBar = { @@ -82,23 +126,25 @@ internal fun LogContent( Icon( Icons.Outlined.FilterList, contentDescription = "Filter logs", - tint = Color.White + tint = if (activeFilter == LogFilter.All) Color.White else Color.Yellow ) } } ) }, + floatingActionButton = { + FloatingActionButton(onClick = onAddFakeLog) { + Icon(Icons.Default.Add, contentDescription = "Add Fake Log") + } + }, snackbarHost = { SnackbarHost(snackbarHostState) }) { padding -> val pullToRefreshState = rememberPullToRefreshState() - val scope = rememberCoroutineScope() + PullToRefreshBox( - isRefreshing = false, + isRefreshing = isLoading && logs.isEmpty(), onRefresh = { -// onRefresh() - scope.launch { - pullToRefreshState.animateToHidden() - } + onRefresh() }, state = pullToRefreshState, modifier = Modifier @@ -107,18 +153,25 @@ internal fun LogContent( .padding(padding) .padding(horizontal = 16.dp, vertical = 12.dp), ){ -// if (logs.itemCount == 0) { -// EmptyLogView() -// }else { -// LazyColumn{ -// items(logs.itemCount, key = { index -> logs[index]?.id ?: index }) { index -> -// val log = logs[index] -// log?.let { it -> -// LogItem(it) -// } -// } -// } -// } + if (logs.isEmpty() && !isLoading) { + EmptyLogView() + } else { + LazyColumn(state = listState) { + items(items = logs, key = { it.id }) { log -> + LogItem(log) + } + if (isLoading && logs.isNotEmpty()) { + item { + Box( + modifier = Modifier.fillMaxWidth().padding(16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + } + } + } } if (showConfirmDialog) { AlertDialog( @@ -127,7 +180,7 @@ internal fun LogContent( text = { Text("Warning: This will permanently delete all current logs. This action cannot be undone.") }, confirmButton = { TextButton(onClick = { -// onDeleteAllLogs() + onDeleteAllLogs() showConfirmDialog = false }) { Text("Delete", color = Color.Red) @@ -135,35 +188,17 @@ internal fun LogContent( }, dismissButton = { TextButton(onClick = { showConfirmDialog = false }) { - Text("Cancel", color = Color.White) + Text("Cancel", color = Color.Gray) } } ) } if (showBottomSheet) { FilterBottomSheet( -// state = state, + activeFilter = activeFilter, + onFilterSelected = onFilterSelected, onDismissRequest = {showBottomSheet = false} ) } } } - -@Preview(showBackground = true) -@Composable -private fun LogScreenPreview() { -// val fakeDevices = listOf( -// Log(1, "Log 1", "This is log 1"), -// Log(2, "Log 2", "This is log 2"), -// Log(3, "Log 3", "This is log 3"), -// ) -// val pagingData = PagingData.from(fakeDevices) -// val lazyPagingItems = flowOf(pagingData).collectAsLazyPagingItems() - - LogContent( -// state = rememberLogFilterState(), -// logs = lazyPagingItems, -// onRefresh = {lazyPagingItems.refresh()}, -// onDeleteAllLogs = {} - ) -} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/LogViewModel.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/LogViewModel.kt new file mode 100644 index 0000000..151876a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/LogViewModel.kt @@ -0,0 +1,98 @@ +package com.digitoolsolutions.app.torquevaultkmp.screens.logs + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.digitoolsolutions.app.torquevaultkmp.data.repository.LogRepository +import com.digitoolsolutions.app.torquevaultkmp.data.storage.db.Log +import com.digitoolsolutions.app.torquevaultkmp.domain.model.LogFilter +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class LogViewModel( + private val logRepository: LogRepository +) : ViewModel() { + + private val _logs = MutableStateFlow>(emptyList()) + val logs: StateFlow> = _logs.asStateFlow() + + private val _activeFilter = MutableStateFlow(LogFilter.All) + val activeFilter: StateFlow = _activeFilter.asStateFlow() + + private var lastTimestamp: Long? = null + private var lastId: Long? = null + private val pageSize = 20L + private var isLastPage = false + var isLoading = MutableStateFlow(false) + private set + + init { + loadNextPage() + } + + fun setFilter(filter: LogFilter) { + if (_activeFilter.value == filter) return + _activeFilter.value = filter + refresh() + } + + fun loadNextPage() { + if (isLoading.value || isLastPage) return + + viewModelScope.launch { + isLoading.value = true + val newLogs = logRepository.getLogsByFilterPaged( + filter = _activeFilter.value, + limit = pageSize, + lastTimestamp = lastTimestamp, + lastId = lastId + ) + + if (newLogs.size < pageSize) { + isLastPage = true + } + + if (newLogs.isNotEmpty()) { + val lastLog = newLogs.last() + lastTimestamp = lastLog.timestamp + lastId = lastLog.id + _logs.value = _logs.value + newLogs + } + + isLoading.value = false + } + } + + fun refresh() { + viewModelScope.launch { + lastTimestamp = null + lastId = null + isLastPage = false + _logs.value = emptyList() + loadNextPage() + } + } + + fun addFakeLog() { + viewModelScope.launch { + val titles = listOf("System Alert", "Device Connected", "Torque Applied", "Calibration Sync") + val contents = listOf("Voltage stable", "Tool #42 joined", "15.5 Nm recorded", "Sync successful") + logRepository.addLog( + title = titles.random(), + content = contents.random() + ) + refresh() + } + } + + fun clearAllLogs() { + viewModelScope.launch { + logRepository.clearLogs() + _logs.value = emptyList() + lastTimestamp = null + lastId = null + isLastPage = true + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/view/FilterBottomSheet.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/view/FilterBottomSheet.kt index d0991b9..40a5dd6 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/view/FilterBottomSheet.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/view/FilterBottomSheet.kt @@ -4,8 +4,8 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width @@ -26,8 +26,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.semantics.Role -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.digitoolsolutions.app.torquevaultkmp.domain.model.LogFilter import org.jetbrains.compose.resources.stringResource import torquevaultkmp.composeapp.generated.resources.Res import torquevaultkmp.composeapp.generated.resources.clear @@ -35,13 +35,12 @@ import torquevaultkmp.composeapp.generated.resources.clear @OptIn(ExperimentalMaterial3Api::class) @Composable fun FilterBottomSheet( -// state: LogFilterState, + activeFilter: LogFilter, + onFilterSelected: (LogFilter) -> Unit, onDismissRequest: () -> Unit, ) { ModalBottomSheet( - onDismissRequest = { - onDismissRequest() - }, + onDismissRequest = onDismissRequest, shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), containerColor = MaterialTheme.colorScheme.surface, tonalElevation = 16.dp, @@ -56,88 +55,60 @@ fun FilterBottomSheet( ) } ) { - FilterContent(/** state = state,*/ onDismissRequest = onDismissRequest) + FilterContent( + activeFilter = activeFilter, + onFilterSelected = { + onFilterSelected(it) + onDismissRequest() + }, + onClear = { + onFilterSelected(LogFilter.All) + onDismissRequest() + } + ) } } @Composable internal fun FilterContent( -// state: LogFilterState, + activeFilter: LogFilter, + onFilterSelected: (LogFilter) -> Unit, + onClear: () -> Unit, modifier: Modifier = Modifier, - onDismissRequest: () -> Unit, ) { Column( - modifier = modifier, + modifier = modifier.padding(bottom = 32.dp), ) { Header( modifier = Modifier.padding(horizontal = 16.dp), text = "Filter", - isSelectedFilterEmpty = true ,// state.isEmpty, - onFilterReset = { -// state.clear() - onDismissRequest() - } + isSelectedFilterEmpty = activeFilter == LogFilter.All, + onFilterReset = onClear ) -// if (state.filters.isNotEmpty()) { -// FlowRow( -// modifier = Modifier.padding(horizontal = 8.dp), -// horizontalArrangement = Arrangement.spacedBy(8.dp), -// verticalArrangement = Arrangement.spacedBy(0.dp), -// ) { -// state.filters.forEach { filter -> -// Row( -// modifier = Modifier -// .clip(RoundedCornerShape(8.dp)) -// .selectable( -// selected = state.activeFilter == filter, -// onClick = { state.setFilter(filter) }, -// role = Role.RadioButton -// ) -// .padding(end = 16.dp), -// verticalAlignment = Alignment.CenterVertically -// ) { -// RadioButton( -// selected = state.activeFilter == filter, -// onClick = { state.setFilter(filter) } -// ) -// Text(filter.toString().replaceFirstChar { it.uppercase() }) -// } -// } -// } -// } - -// if (state.filters.isNotEmpty() && state.sortingOptions.isNotEmpty()) { -// HorizontalDivider() -// -// Text( -// text = "Sort by", -// style = MaterialTheme.typography.titleMedium, -// modifier = Modifier.padding(horizontal = 16.dp).padding(top = 8.dp) -// ) -// } -// -// if (state.sortingOptions.isNotEmpty()) { -// state.sortingOptions.forEach { sort -> -// Row( -// modifier = Modifier -// .clip(RoundedCornerShape(8.dp)) -// .selectable( -// selected = state.activeSort == sort, -// onClick = { state.sortBy(sort) }, -// role = Role.RadioButton -// ) -// .padding(horizontal = 16.dp), -// verticalAlignment = Alignment.CenterVertically -// ) { -// RadioButton( -// selected = state.activeSort == sort, -// onClick = { state.sortBy(sort) } -// ) -// Text(sort.toString().replaceFirstChar { it.uppercase() }) -// } -// } -// } + LogFilter.entries.forEach { filter -> + Row( + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = activeFilter == filter, + onClick = { onFilterSelected(filter) }, + role = Role.RadioButton + ) + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = activeFilter == filter, + onClick = { onFilterSelected(filter) } + ) + Text( + text = filter.name, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = 8.dp) + ) + } + } } } @@ -156,8 +127,7 @@ internal fun Header( Text( text = text, style = MaterialTheme.typography.titleMedium, - modifier = Modifier - .weight(1f) + modifier = Modifier.weight(1f) ) TextButton( onClick = onFilterReset, @@ -178,24 +148,3 @@ internal fun Header( } } } - -@Preview(showBackground = true) -@Composable -fun FilterBottomSheetPreview() { - FilterBottomSheet( -// state = rememberLogFilterState(), - onDismissRequest = {} - ) -} - -@Preview(showBackground = true) -@Composable -fun HeaderPreview(){ - Header(text = "Filter", isSelectedFilterEmpty = true, onFilterReset = {}) -} - -@Preview(showBackground = true) -@Composable -fun FilterContentPreview(){ -// FilterContent(state = rememberLogFilterState(), onDismissRequest = {}) -} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/view/LogItem.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/view/LogItem.kt index 491bbe9..0e68881 100644 --- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/view/LogItem.kt +++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/view/LogItem.kt @@ -9,48 +9,44 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -//import com.digitoolsolutions.android.storage.data.local.db.entity.Log -//import com.digitoolsolutions.android.storage.model.LogFilter -//import java.time.Instant -//import java.time.ZoneId -//import java.time.format.DateTimeFormatter +import com.digitoolsolutions.app.torquevaultkmp.data.storage.db.Log +import com.digitoolsolutions.app.torquevaultkmp.domain.model.LogFilter +import kotlin.time.Instant +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime @Composable -fun LogItem(/** log: Log,*/ modifier: Modifier = Modifier) { -// val formatter = DateTimeFormatter.ofPattern("MM/dd/yyyy HH:mm:ss") -// .withZone(ZoneId.systemDefault()) -// val formattedTime = formatter.format(Instant.ofEpochMilli(log.timestamp)) +fun LogItem(log: Log, modifier: Modifier = Modifier) { + val dateTime = Instant.fromEpochMilliseconds(log.timestamp) + .toLocalDateTime(TimeZone.currentSystemDefault()) + val formattedTime = "${dateTime.date} ${dateTime.hour}:${dateTime.minute}:${dateTime.second}" Column( - modifier = Modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth(), ){ -// Column( -// modifier = Modifier.padding(16.dp), -// ) { -// Text( -// text = formattedTime, -// style = MaterialTheme.typography.bodySmall -// ) -// Text( -// text = log.title, -// style = MaterialTheme.typography.titleMedium -// ) -// Text( -// text = "${if(log.source == LogFilter.Device.toString()) log.deviceId ?: "Unknown" else log.source.replaceFirstChar{it.uppercase()}} - ${log.content}", -// style = MaterialTheme.typography.bodyMedium, -// color = MaterialTheme.colorScheme.onSurfaceVariant -// ) -// } - HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color) + Column( + modifier = Modifier.padding(16.dp), + ) { + Text( + text = formattedTime, + style = MaterialTheme.typography.bodySmall + ) + Text( + text = log.title, + style = MaterialTheme.typography.titleMedium + ) + val sourceText = if (log.source == LogFilter.Device.name) { + log.deviceId ?: "Unknown Device" + } else { + log.source + } + Text( + text = "$sourceText - ${log.content}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + HorizontalDivider(thickness = DividerDefaults.Thickness, color = DividerDefaults.color) } } - -@Preview -@Composable -fun BondedDeviceItemPreview() { - LogItem( -// Log( 1, "Log 1", "This is a log"), - ) -} diff --git a/composeApp/src/commonMain/sqldelight/com/digitoolsolutions/app/torquevaultkmp/data/storage/db/AppDatabase.sq b/composeApp/src/commonMain/sqldelight/com/digitoolsolutions/app/torquevaultkmp/data/storage/db/AppDatabase.sq new file mode 100644 index 0000000..cb5b0c5 --- /dev/null +++ b/composeApp/src/commonMain/sqldelight/com/digitoolsolutions/app/torquevaultkmp/data/storage/db/AppDatabase.sq @@ -0,0 +1,81 @@ +CREATE TABLE Device ( + id TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL +); + +CREATE TABLE Log ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + content TEXT NOT NULL, + timestamp INTEGER NOT NULL, + deviceId TEXT, + source TEXT NOT NULL, + FOREIGN KEY(deviceId) REFERENCES Device(id) ON DELETE SET NULL +); + +CREATE INDEX log_device_id ON Log(deviceId); +CREATE INDEX log_timestamp_id ON Log(timestamp, id); + +-- Device Queries +insertDevice: +INSERT OR REPLACE INTO Device(id, name) +VALUES (?, ?); + +getAllDevices: +SELECT * FROM Device; + +getDevicesFirstPage: +SELECT * FROM Device ORDER BY id ASC LIMIT :limit; + +getDevicesNextPage: +SELECT * FROM Device WHERE id > :lastId ORDER BY id ASC LIMIT :limit; + +getDeviceById: +SELECT * FROM Device WHERE id = ?; + +deleteDeviceById: +DELETE FROM Device WHERE id = ?; + +-- Log Queries +insertLog: +INSERT INTO Log(title, content, timestamp, deviceId, source) +VALUES (?, ?, ?, ?, ?); + +getAllLogs: +SELECT * FROM Log ORDER BY timestamp DESC, id DESC; + +getLogsFirstPage: +SELECT * FROM Log ORDER BY timestamp DESC, id DESC LIMIT :limit; + +getLogsNextPage: +SELECT * FROM Log +WHERE (timestamp < :lastTimestamp) OR (timestamp = :lastTimestamp AND id < :lastId) +ORDER BY timestamp DESC, id DESC +LIMIT :limit; + +getLogsByTypeFirstPage: +SELECT * FROM Log +WHERE source = :source +ORDER BY timestamp DESC, id DESC +LIMIT :limit; + +getLogsByTypeNextPage: +SELECT * FROM Log +WHERE source = :source AND ((timestamp < :lastTimestamp) OR (timestamp = :lastTimestamp AND id < :lastId)) +ORDER BY timestamp DESC, id DESC +LIMIT :limit; + +getLogsByDeviceFirstPage: +SELECT * FROM Log +WHERE deviceId = :deviceId +ORDER BY timestamp DESC, id DESC +LIMIT :limit; + +getLogsByDeviceNextPage: +SELECT * FROM Log +WHERE deviceId = :deviceId AND ((timestamp < :lastTimestamp) OR (timestamp = :lastTimestamp AND id < :lastId)) +ORDER BY timestamp DESC, id DESC +LIMIT :limit; + +clearAllLogs: +DELETE FROM Log; diff --git a/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/PlatformModules.kt b/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/PlatformModules.kt index 9732439..3bd600c 100644 --- a/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/PlatformModules.kt +++ b/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/di/PlatformModules.kt @@ -1,9 +1,18 @@ package com.digitoolsolutions.app.torquevaultkmp.di +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.driver.native.NativeSqliteDriver import com.digitoolsolutions.app.torquevaultkmp.AppStoragePlatform import com.digitoolsolutions.app.torquevaultkmp.data.storage.AppStorage +import com.digitoolsolutions.app.torquevaultkmp.data.storage.db.AppDatabase import org.koin.dsl.module actual val platformModule = module { single { AppStoragePlatform() } -} \ No newline at end of file + single { + NativeSqliteDriver( + schema = AppDatabase.Schema, + name = "app.db" + ) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 315265e..2f8b32a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,7 +9,7 @@ androidx-core = "1.18.0" androidx-espresso = "3.7.0" androidx-lifecycle = "2.10.0" androidx-testExt = "1.3.0" -composeMultiplatform = "1.10.3" +composeMultiplatform = "1.11.0" junit = "4.13.2" kotlin = "2.3.21" material3 = "1.10.0-alpha05" @@ -18,6 +18,8 @@ ktor = "3.4.3" material-icons-extended = "1.7.3" navigation-compose = "2.9.2" logger = "2.7.1" +sqldelight = "2.3.2" +kotlinx-datetime = "0.8.0" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } @@ -58,6 +60,13 @@ ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } logger-napier = { module = "io.github.aakira:napier", version.ref = "logger" } +sqldelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqldelight" } +sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" } +sqldelight-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" } +sqldelight-native = { module = "app.cash.sqldelight:native-driver", version.ref = "sqldelight" } + +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } + [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } androidLibrary = { id = "com.android.library", version.ref = "agp" } @@ -65,6 +74,7 @@ composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMul composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin"} +sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" } [bundles] ktor = [