Implementation SQLDelight database

This commit is contained in:
2026-05-20 13:56:42 +07:00
parent 28901df627
commit 66fe3be32f
19 changed files with 710 additions and 363 deletions
+1
View File
@@ -7,4 +7,5 @@ plugins {
alias(libs.plugins.composeCompiler) apply false alias(libs.plugins.composeCompiler) apply false
alias(libs.plugins.kotlinMultiplatform) apply false alias(libs.plugins.kotlinMultiplatform) apply false
alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.sqldelight) apply false
} }
+18 -6
View File
@@ -7,14 +7,19 @@ plugins {
alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler) alias(libs.plugins.composeCompiler)
alias(libs.plugins.kotlin.serialization) alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.sqldelight)
}
sqldelight {
databases {
create("AppDatabase") {
packageName.set("com.digitoolsolutions.app.torquevaultkmp.data.storage.db")
}
}
} }
kotlin { kotlin {
androidTarget { androidTarget()
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
listOf( listOf(
iosArm64(), iosArm64(),
@@ -22,7 +27,8 @@ kotlin {
).forEach { iosTarget -> ).forEach { iosTarget ->
iosTarget.binaries.framework { iosTarget.binaries.framework {
baseName = "ComposeApp" baseName = "ComposeApp"
isStatic = true isStatic = false
linkerOpts("-lsqlite3")
} }
} }
@@ -36,6 +42,7 @@ kotlin {
implementation(libs.koin.compose) implementation(libs.koin.compose)
implementation(libs.koin.compose.viewmodel) implementation(libs.koin.compose.viewmodel)
implementation(libs.ktor.client.okhttp) implementation(libs.ktor.client.okhttp)
implementation(libs.sqldelight.android)
} }
commonMain.dependencies { commonMain.dependencies {
implementation(libs.compose.runtime) implementation(libs.compose.runtime)
@@ -56,10 +63,15 @@ kotlin {
implementation(libs.koin.core) implementation(libs.koin.core)
implementation(libs.koin.compose.viewmodel) implementation(libs.koin.compose.viewmodel)
implementation(libs.sqldelight.runtime)
implementation(libs.sqldelight.coroutines)
implementation(libs.kotlinx.datetime)
implementation(libs.logger.napier) implementation(libs.logger.napier)
} }
iosMain.dependencies { iosMain.dependencies {
implementation(libs.ktor.client.darwin) implementation(libs.ktor.client.darwin)
implementation(libs.sqldelight.native)
} }
commonTest.dependencies { commonTest.dependencies {
implementation(libs.kotlin.test) implementation(libs.kotlin.test)
@@ -1,10 +1,20 @@
package com.digitoolsolutions.app.torquevaultkmp.di 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.AppStoragePlatform
import com.digitoolsolutions.app.torquevaultkmp.data.storage.AppStorage 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.android.ext.koin.androidContext
import org.koin.dsl.module import org.koin.dsl.module
actual val platformModule = module { actual val platformModule = module {
single<AppStorage> { AppStoragePlatform(androidContext()) } single<AppStorage> { AppStoragePlatform(androidContext()) }
single<SqlDriver> {
AndroidSqliteDriver(
schema = AppDatabase.Schema,
context = androidContext(),
name = "app.db"
)
}
} }
@@ -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<List<Device>> =
dbQueries.getAllDevices()
.asFlow()
.mapToList(Dispatchers.IO)
suspend fun getDevicesPaged(limit: Long, lastId: String?): List<Device> {
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)
}
@@ -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<List<Log>> =
dbQueries.getAllLogs()
.asFlow()
.mapToList(Dispatchers.IO)
suspend fun getLogsPaged(limit: Long, lastTimestamp: Long?, lastId: Long?): List<Log> {
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<Log> {
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<Log> {
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()
}
@@ -8,6 +8,7 @@ fun initKoin(config: KoinAppDeclaration? = null) {
config?.invoke(this) config?.invoke(this)
modules( modules(
appModule, appModule,
storageModule,
networkModule, networkModule,
viewModelModule, viewModelModule,
platformModule platformModule
@@ -4,12 +4,17 @@ import com.digitoolsolutions.app.torquevaultkmp.AppViewModel
import com.digitoolsolutions.app.torquevaultkmp.createPlatformHttpClient import com.digitoolsolutions.app.torquevaultkmp.createPlatformHttpClient
import com.digitoolsolutions.app.torquevaultkmp.data.network.api.AuthApi import com.digitoolsolutions.app.torquevaultkmp.data.network.api.AuthApi
import com.digitoolsolutions.app.torquevaultkmp.data.network.api.WorkOrderApi 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.WorkOrderRepository
import com.digitoolsolutions.app.torquevaultkmp.data.repository.WorkOrderRepositoryImpl import com.digitoolsolutions.app.torquevaultkmp.data.repository.WorkOrderRepositoryImpl
import com.digitoolsolutions.app.torquevaultkmp.data.storage.TokenManager 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.domain.usecase.FetchWorkOrdersUseCase
import com.digitoolsolutions.app.torquevaultkmp.screens.auth.AuthViewModel 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.home.HomeViewModel
import com.digitoolsolutions.app.torquevaultkmp.screens.logs.LogViewModel
import com.digitoolsolutions.app.torquevaultkmp.screens.settings.SettingViewModel import com.digitoolsolutions.app.torquevaultkmp.screens.settings.SettingViewModel
import org.koin.core.module.Module import org.koin.core.module.Module
import org.koin.core.module.dsl.factoryOf import org.koin.core.module.dsl.factoryOf
@@ -24,6 +29,13 @@ val appModule = module {
single { TokenManager(get()) } single { TokenManager(get()) }
} }
val storageModule = module {
single { AppDatabase(get()) }
single { get<AppDatabase>().appDatabaseQueries }
single { LogRepository(get()) }
single { DeviceRepository(get()) }
}
val networkModule = module { val networkModule = module {
// Auth client (no interceptor to avoid circular dependency and recursion) // Auth client (no interceptor to avoid circular dependency and recursion)
single(named("authClient")) { createPlatformHttpClient(get(), null) } single(named("authClient")) { createPlatformHttpClient(get(), null) }
@@ -44,4 +56,6 @@ val viewModelModule = module {
factory { AuthViewModel(get(), get()) } factory { AuthViewModel(get(), get()) }
singleOf(::AppViewModel) singleOf(::AppViewModel)
factoryOf(::SettingViewModel) factoryOf(::SettingViewModel)
factoryOf(::BondedViewModel)
factoryOf(::LogViewModel)
} }
@@ -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
}
}
}
@@ -1,9 +1,19 @@
package com.digitoolsolutions.app.torquevaultkmp.screens.bonded package com.digitoolsolutions.app.torquevaultkmp.screens.bonded
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding 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.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState 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.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable 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.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.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.NavController import androidx.navigation.NavController
import kotlin.uuid.ExperimentalUuidApi
import com.digitoolsolutions.app.torquevaultkmp.components.AppBar import com.digitoolsolutions.app.torquevaultkmp.components.AppBar
import kotlinx.coroutines.flow.flowOf import com.digitoolsolutions.app.torquevaultkmp.data.storage.db.Device
import kotlinx.coroutines.launch 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.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import torquevaultkmp.composeapp.generated.resources.Res import torquevaultkmp.composeapp.generated.resources.Res
import torquevaultkmp.composeapp.generated.resources.bonded_title import torquevaultkmp.composeapp.generated.resources.bonded_title
@OptIn(ExperimentalMaterial3Api::class, ExperimentalUuidApi::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun BondedScreen( fun BondedScreen(
navController: NavController, navController: NavController,
// bleScannerViewModel: ScannerViewModel, viewModel: BondedViewModel = koinViewModel()
// viewModel: BondedViewModel = hiltViewModel()
) { ) {
// val devices = viewModel.bondedDevices.collectAsLazyPagingItems() val devices by viewModel.bondedDevices.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
BondedContent( BondedContent(
// devices = devices, devices = devices,
// onRefresh = {devices.refresh()}, isLoading = isLoading,
// onDelete = { onAddFake = { viewModel.addFakeDevice() },
// viewModel.removeDevice(it) onDelete = { viewModel.removeDevice(it.id) },
// bleScannerViewModel.removeBondedCache(it.id) onLoadMore = { viewModel.loadNextPage() },
// } onRefresh = { viewModel.refresh() }
) )
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
internal fun BondedContent( internal fun BondedContent(
// devices: LazyPagingItems<Device>, devices: List<Device>,
// onRefresh: () -> Unit, isLoading: Boolean,
// onDelete: (Device) -> Unit onAddFake: () -> Unit,
onDelete: (Device) -> Unit,
onLoadMore: () -> Unit,
onRefresh: () -> Unit
) { ) {
val snackbarHostState = remember { SnackbarHostState() } 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 ( Scaffold (
modifier = Modifier.imePadding(), modifier = Modifier.imePadding(),
topBar = { topBar = {
@@ -59,50 +98,41 @@ internal fun BondedContent(
title = {Text(stringResource(Res.string.bonded_title))}, title = {Text(stringResource(Res.string.bonded_title))},
) )
}, },
floatingActionButton = {
FloatingActionButton(onClick = onAddFake) {
Icon(Icons.Default.Add, contentDescription = "Add Fake Device")
}
},
snackbarHost = { SnackbarHost(snackbarHostState) }) snackbarHost = { SnackbarHost(snackbarHostState) })
{ padding -> { padding ->
val pullToRefreshState = rememberPullToRefreshState() val pullToRefreshState = rememberPullToRefreshState()
val scope = rememberCoroutineScope()
PullToRefreshBox( PullToRefreshBox(
isRefreshing = false, isRefreshing = isLoading && devices.isEmpty(),
onRefresh = { onRefresh = {
// onRefresh() onRefresh()
scope.launch {
pullToRefreshState.animateToHidden()
}
}, },
state = pullToRefreshState, state = pullToRefreshState,
modifier = Modifier.fillMaxSize().imePadding().padding(padding).padding(horizontal = 16.dp, vertical = 12.dp), modifier = Modifier.fillMaxSize().imePadding().padding(padding).padding(horizontal = 16.dp, vertical = 12.dp),
){ ){
// if (devices.itemCount == 0) { if (devices.isEmpty() && !isLoading) {
// EmptyBondedView() EmptyBondedView()
// }else { } else {
// LazyColumn{ LazyColumn(state = listState) {
// items(count = devices.itemCount, key = { index -> devices[index]?.id ?: index }) { index -> items(items = devices, key = { it.id }) { device ->
// val device = devices[index] BondedDeviceItem(device, onDelete = { onDelete(it) })
// device?.let { it -> }
// BondedDeviceItem(it, 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 = {}
// )
} }
@@ -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<List<Device>>(emptyList())
val bondedDevices: StateFlow<List<Device>> = _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 }
}
}
}
@@ -24,6 +24,7 @@ import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.rememberSwipeToDismissBoxState import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.digitoolsolutions.app.torquevaultkmp.components.CircularIcon import com.digitoolsolutions.app.torquevaultkmp.components.CircularIcon
import com.digitoolsolutions.app.torquevaultkmp.data.storage.db.Device
@Composable @Composable
fun BondedDeviceItem(/** device: Device, onDelete: (Device) -> Unit,*/ modifier: Modifier = Modifier) { fun BondedDeviceItem(device: Device, onDelete: (Device) -> Unit, modifier: Modifier = Modifier) {
val swipeToDismissBoxState = rememberSwipeToDismissBoxState( val swipeToDismissBoxState = rememberSwipeToDismissBoxState(
SwipeToDismissBoxValue.Settled, SwipeToDismissBoxValue.Settled,
SwipeToDismissBoxDefaults.positionalThreshold SwipeToDismissBoxDefaults.positionalThreshold
) )
if (swipeToDismissBoxState.currentValue == SwipeToDismissBoxValue.EndToStart) {
LaunchedEffect(Unit) {
onDelete(device)
swipeToDismissBoxState.snapTo(SwipeToDismissBoxValue.Settled)
}
}
SwipeToDismissBox( SwipeToDismissBox(
state = swipeToDismissBoxState, state = swipeToDismissBoxState,
modifier = modifier.fillMaxSize().padding(vertical = 4.dp), modifier = modifier.fillMaxSize().padding(vertical = 4.dp),
@@ -46,13 +56,14 @@ fun BondedDeviceItem(/** device: Device, onDelete: (Device) -> Unit,*/ modifier:
backgroundContent = { backgroundContent = {
when (swipeToDismissBoxState.dismissDirection) { when (swipeToDismissBoxState.dismissDirection) {
SwipeToDismissBoxValue.StartToEnd -> { 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 -> {} SwipeToDismissBoxValue.Settled -> {}
} }
}, }
// onDismiss = {onDelete(device)},
) { ) {
OutlinedCard( OutlinedCard(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -65,15 +76,15 @@ fun BondedDeviceItem(/** device: Device, onDelete: (Device) -> Unit,*/ modifier:
CircularIcon( imageVector = Icons.Default.Bluetooth, backgroundColor = Color(0xFF0082FC)) CircularIcon( imageVector = Icons.Default.Bluetooth, backgroundColor = Color(0xFF0082FC))
Spacer(modifier = Modifier.width(16.dp)) Spacer(modifier = Modifier.width(16.dp))
Column { Column {
// Text( Text(
// text = device.name, text = device.name,
// style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.titleMedium
// ) )
// Text( Text(
// text = device.id, text = device.id,
// style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
// color = MaterialTheme.colorScheme.onSurfaceVariant 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 = {}
)
}
@@ -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<LogFilter>
//// val sortingOptions: List<LogSortOption>
// val activeFilter: LogFilter
//// val activeSort: LogSortOption
// val isEmpty: Boolean
//
// fun setFilter(filter: LogFilter)
//// fun sortBy(option: LogSortOption)
// fun clear()
//}
//
//val logFilterSaver = Saver<LogFilter, String>(
// 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<LogSortOption, String>(
//// save = { sort ->
//// when (sort) {
//// LogSortOption.ByDate -> "ByDate"
//// }
//// },
//// restore = { str ->
//// when (str) {
//// "ByDate" -> LogSortOption.ByDate
//// else -> LogSortOption.ByDate
//// }
//// }
////)
//
//
//@Composable
//fun rememberLogFilterState(
// filters: List<LogFilter> = listOf(LogFilter.All, LogFilter.System, LogFilter.Device),
//// sortingOptions: List<LogSortOption> = listOf()
//): LogFilterState {
// var selectedFilter by rememberSaveable(stateSaver = logFilterSaver) {
// mutableStateOf<LogFilter>(LogFilter.All)
// }
//// var selectedSort by rememberSaveable(stateSaver = logSortSaver) {
//// mutableStateOf<LogSortOption>(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
// }
// }
//}
@@ -1,13 +1,21 @@
package com.digitoolsolutions.app.torquevaultkmp.screens.logs package com.digitoolsolutions.app.torquevaultkmp.screens.logs
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding 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.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.DeleteForever import androidx.compose.material.icons.filled.DeleteForever
import androidx.compose.material.icons.outlined.FilterList import androidx.compose.material.icons.outlined.FilterList
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold 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.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable 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.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.NavController import androidx.navigation.NavController
import com.digitoolsolutions.app.torquevaultkmp.screens.logs.view.FilterBottomSheet
import com.digitoolsolutions.app.torquevaultkmp.components.AppBar 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.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import torquevaultkmp.composeapp.generated.resources.Res import torquevaultkmp.composeapp.generated.resources.Res
import torquevaultkmp.composeapp.generated.resources.log_title import torquevaultkmp.composeapp.generated.resources.log_title
import kotlin.uuid.ExperimentalUuidApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalUuidApi::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun LogScreen( fun LogScreen(
navController: NavController, navController: NavController,
// viewModel: LogViewModel = hiltViewModel() viewModel: LogViewModel = koinViewModel()
) { ) {
// val state = rememberLogFilterState() val logs by viewModel.logs.collectAsState()
// val logs = viewModel.getLogs(filter = state.activeFilter).collectAsLazyPagingItems() val activeFilter by viewModel.activeFilter.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
LogContent( LogContent(
// state = state, logs = logs,
// logs = logs, activeFilter = activeFilter,
// onRefresh = {logs.refresh()}, isLoading = isLoading,
// onDeleteAllLogs = {viewModel.removeAllLog()}, onFilterSelected = { viewModel.setFilter(it) },
onAddFakeLog = { viewModel.addFakeLog() },
onDeleteAllLogs = { viewModel.clearAllLogs() },
onLoadMore = { viewModel.loadNextPage() },
onRefresh = { viewModel.refresh() }
) )
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
internal fun LogContent( internal fun LogContent(
// state: LogFilterState, logs: List<Log>,
// logs: LazyPagingItems<Log>, activeFilter: LogFilter,
// onRefresh: () -> Unit, isLoading: Boolean,
// onDeleteAllLogs: () -> Unit, onFilterSelected: (LogFilter) -> Unit,
onAddFakeLog: () -> Unit,
onDeleteAllLogs: () -> Unit,
onLoadMore: () -> Unit,
onRefresh: () -> Unit
) { ) {
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
var showConfirmDialog by remember { mutableStateOf(false) } var showConfirmDialog by remember { mutableStateOf(false) }
var showBottomSheet 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 ( Scaffold (
modifier = Modifier.imePadding(), modifier = Modifier.imePadding(),
topBar = { topBar = {
@@ -82,23 +126,25 @@ internal fun LogContent(
Icon( Icon(
Icons.Outlined.FilterList, Icons.Outlined.FilterList,
contentDescription = "Filter logs", 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) }) snackbarHost = { SnackbarHost(snackbarHostState) })
{ padding -> { padding ->
val pullToRefreshState = rememberPullToRefreshState() val pullToRefreshState = rememberPullToRefreshState()
val scope = rememberCoroutineScope()
PullToRefreshBox( PullToRefreshBox(
isRefreshing = false, isRefreshing = isLoading && logs.isEmpty(),
onRefresh = { onRefresh = {
// onRefresh() onRefresh()
scope.launch {
pullToRefreshState.animateToHidden()
}
}, },
state = pullToRefreshState, state = pullToRefreshState,
modifier = Modifier modifier = Modifier
@@ -107,18 +153,25 @@ internal fun LogContent(
.padding(padding) .padding(padding)
.padding(horizontal = 16.dp, vertical = 12.dp), .padding(horizontal = 16.dp, vertical = 12.dp),
){ ){
// if (logs.itemCount == 0) { if (logs.isEmpty() && !isLoading) {
// EmptyLogView() EmptyLogView()
// }else { } else {
// LazyColumn{ LazyColumn(state = listState) {
// items(logs.itemCount, key = { index -> logs[index]?.id ?: index }) { index -> items(items = logs, key = { it.id }) { log ->
// val log = logs[index] LogItem(log)
// log?.let { it -> }
// LogItem(it) if (isLoading && logs.isNotEmpty()) {
// } item {
// } Box(
// } modifier = Modifier.fillMaxWidth().padding(16.dp),
// } contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
}
}
}
} }
if (showConfirmDialog) { if (showConfirmDialog) {
AlertDialog( AlertDialog(
@@ -127,7 +180,7 @@ internal fun LogContent(
text = { Text("Warning: This will permanently delete all current logs. This action cannot be undone.") }, text = { Text("Warning: This will permanently delete all current logs. This action cannot be undone.") },
confirmButton = { confirmButton = {
TextButton(onClick = { TextButton(onClick = {
// onDeleteAllLogs() onDeleteAllLogs()
showConfirmDialog = false showConfirmDialog = false
}) { }) {
Text("Delete", color = Color.Red) Text("Delete", color = Color.Red)
@@ -135,35 +188,17 @@ internal fun LogContent(
}, },
dismissButton = { dismissButton = {
TextButton(onClick = { showConfirmDialog = false }) { TextButton(onClick = { showConfirmDialog = false }) {
Text("Cancel", color = Color.White) Text("Cancel", color = Color.Gray)
} }
} }
) )
} }
if (showBottomSheet) { if (showBottomSheet) {
FilterBottomSheet( FilterBottomSheet(
// state = state, activeFilter = activeFilter,
onFilterSelected = onFilterSelected,
onDismissRequest = {showBottomSheet = false} 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 = {}
)
}
@@ -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<List<Log>>(emptyList())
val logs: StateFlow<List<Log>> = _logs.asStateFlow()
private val _activeFilter = MutableStateFlow(LogFilter.All)
val activeFilter: StateFlow<LogFilter> = _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
}
}
}
@@ -4,8 +4,8 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width 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.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.digitoolsolutions.app.torquevaultkmp.domain.model.LogFilter
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import torquevaultkmp.composeapp.generated.resources.Res import torquevaultkmp.composeapp.generated.resources.Res
import torquevaultkmp.composeapp.generated.resources.clear import torquevaultkmp.composeapp.generated.resources.clear
@@ -35,13 +35,12 @@ import torquevaultkmp.composeapp.generated.resources.clear
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun FilterBottomSheet( fun FilterBottomSheet(
// state: LogFilterState, activeFilter: LogFilter,
onFilterSelected: (LogFilter) -> Unit,
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
) { ) {
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = { onDismissRequest = onDismissRequest,
onDismissRequest()
},
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
containerColor = MaterialTheme.colorScheme.surface, containerColor = MaterialTheme.colorScheme.surface,
tonalElevation = 16.dp, 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 @Composable
internal fun FilterContent( internal fun FilterContent(
// state: LogFilterState, activeFilter: LogFilter,
onFilterSelected: (LogFilter) -> Unit,
onClear: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onDismissRequest: () -> Unit,
) { ) {
Column( Column(
modifier = modifier, modifier = modifier.padding(bottom = 32.dp),
) { ) {
Header( Header(
modifier = Modifier.padding(horizontal = 16.dp), modifier = Modifier.padding(horizontal = 16.dp),
text = "Filter", text = "Filter",
isSelectedFilterEmpty = true ,// state.isEmpty, isSelectedFilterEmpty = activeFilter == LogFilter.All,
onFilterReset = { onFilterReset = onClear
// state.clear()
onDismissRequest()
}
) )
// if (state.filters.isNotEmpty()) { LogFilter.entries.forEach { filter ->
// FlowRow( Row(
// modifier = Modifier.padding(horizontal = 8.dp), modifier = Modifier
// horizontalArrangement = Arrangement.spacedBy(8.dp), .fillMaxWidth()
// verticalArrangement = Arrangement.spacedBy(0.dp), .selectable(
// ) { selected = activeFilter == filter,
// state.filters.forEach { filter -> onClick = { onFilterSelected(filter) },
// Row( role = Role.RadioButton
// modifier = Modifier )
// .clip(RoundedCornerShape(8.dp)) .padding(horizontal = 16.dp, vertical = 8.dp),
// .selectable( verticalAlignment = Alignment.CenterVertically
// selected = state.activeFilter == filter, ) {
// onClick = { state.setFilter(filter) }, RadioButton(
// role = Role.RadioButton selected = activeFilter == filter,
// ) onClick = { onFilterSelected(filter) }
// .padding(end = 16.dp), )
// verticalAlignment = Alignment.CenterVertically Text(
// ) { text = filter.name,
// RadioButton( style = MaterialTheme.typography.bodyLarge,
// selected = state.activeFilter == filter, modifier = Modifier.padding(start = 8.dp)
// 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() })
// }
// }
// }
} }
} }
@@ -156,8 +127,7 @@ internal fun Header(
Text( Text(
text = text, text = text,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
modifier = Modifier modifier = Modifier.weight(1f)
.weight(1f)
) )
TextButton( TextButton(
onClick = onFilterReset, 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 = {})
}
@@ -9,48 +9,44 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
//import com.digitoolsolutions.android.storage.data.local.db.entity.Log import com.digitoolsolutions.app.torquevaultkmp.data.storage.db.Log
//import com.digitoolsolutions.android.storage.model.LogFilter import com.digitoolsolutions.app.torquevaultkmp.domain.model.LogFilter
//import java.time.Instant import kotlin.time.Instant
//import java.time.ZoneId import kotlinx.datetime.TimeZone
//import java.time.format.DateTimeFormatter import kotlinx.datetime.toLocalDateTime
@Composable @Composable
fun LogItem(/** log: Log,*/ modifier: Modifier = Modifier) { fun LogItem(log: Log, modifier: Modifier = Modifier) {
// val formatter = DateTimeFormatter.ofPattern("MM/dd/yyyy HH:mm:ss") val dateTime = Instant.fromEpochMilliseconds(log.timestamp)
// .withZone(ZoneId.systemDefault()) .toLocalDateTime(TimeZone.currentSystemDefault())
// val formattedTime = formatter.format(Instant.ofEpochMilli(log.timestamp)) val formattedTime = "${dateTime.date} ${dateTime.hour}:${dateTime.minute}:${dateTime.second}"
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
){ ){
// Column( Column(
// modifier = Modifier.padding(16.dp), modifier = Modifier.padding(16.dp),
// ) { ) {
// Text( Text(
// text = formattedTime, text = formattedTime,
// style = MaterialTheme.typography.bodySmall style = MaterialTheme.typography.bodySmall
// ) )
// Text( Text(
// text = log.title, text = log.title,
// style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.titleMedium
// ) )
// Text( val sourceText = if (log.source == LogFilter.Device.name) {
// text = "${if(log.source == LogFilter.Device.toString()) log.deviceId ?: "Unknown" else log.source.replaceFirstChar{it.uppercase()}} - ${log.content}", log.deviceId ?: "Unknown Device"
// style = MaterialTheme.typography.bodyMedium, } else {
// color = MaterialTheme.colorScheme.onSurfaceVariant log.source
// )
// }
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
} }
} Text(
text = "$sourceText - ${log.content}",
@Preview style = MaterialTheme.typography.bodyMedium,
@Composable color = MaterialTheme.colorScheme.onSurfaceVariant
fun BondedDeviceItemPreview() {
LogItem(
// Log( 1, "Log 1", "This is a log"),
) )
} }
HorizontalDivider(thickness = DividerDefaults.Thickness, color = DividerDefaults.color)
}
}
@@ -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;
@@ -1,9 +1,18 @@
package com.digitoolsolutions.app.torquevaultkmp.di 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.AppStoragePlatform
import com.digitoolsolutions.app.torquevaultkmp.data.storage.AppStorage import com.digitoolsolutions.app.torquevaultkmp.data.storage.AppStorage
import com.digitoolsolutions.app.torquevaultkmp.data.storage.db.AppDatabase
import org.koin.dsl.module import org.koin.dsl.module
actual val platformModule = module { actual val platformModule = module {
single<AppStorage> { AppStoragePlatform() } single<AppStorage> { AppStoragePlatform() }
single<SqlDriver> {
NativeSqliteDriver(
schema = AppDatabase.Schema,
name = "app.db"
)
}
} }
+11 -1
View File
@@ -9,7 +9,7 @@ androidx-core = "1.18.0"
androidx-espresso = "3.7.0" androidx-espresso = "3.7.0"
androidx-lifecycle = "2.10.0" androidx-lifecycle = "2.10.0"
androidx-testExt = "1.3.0" androidx-testExt = "1.3.0"
composeMultiplatform = "1.10.3" composeMultiplatform = "1.11.0"
junit = "4.13.2" junit = "4.13.2"
kotlin = "2.3.21" kotlin = "2.3.21"
material3 = "1.10.0-alpha05" material3 = "1.10.0-alpha05"
@@ -18,6 +18,8 @@ ktor = "3.4.3"
material-icons-extended = "1.7.3" material-icons-extended = "1.7.3"
navigation-compose = "2.9.2" navigation-compose = "2.9.2"
logger = "2.7.1" logger = "2.7.1"
sqldelight = "2.3.2"
kotlinx-datetime = "0.8.0"
[libraries] [libraries]
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } 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" } ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
logger-napier = { module = "io.github.aakira:napier", version.ref = "logger" } 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] [plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" } androidApplication = { id = "com.android.application", version.ref = "agp" }
androidLibrary = { id = "com.android.library", 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" } composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin"} kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin"}
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }
[bundles] [bundles]
ktor = [ ktor = [