diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml
index 4e2854e..4d598f9 100644
--- a/composeApp/src/androidMain/AndroidManifest.xml
+++ b/composeApp/src/androidMain/AndroidManifest.xml
@@ -37,6 +37,15 @@
+
+
+
\ No newline at end of file
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 016b129..515329b 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
@@ -8,11 +8,13 @@ import com.digitoolsolutions.app.torquevaultkmp.data.storage.db.AppDatabase
import com.digitoolsolutions.app.torquevaultkmp.utils.AndroidAppInfo
import com.digitoolsolutions.app.torquevaultkmp.utils.AndroidBluetoothManager
import com.digitoolsolutions.app.torquevaultkmp.utils.AndroidNetworkMonitor
+import com.digitoolsolutions.app.torquevaultkmp.utils.AndroidShareLog
import com.digitoolsolutions.app.torquevaultkmp.utils.AppInfo
import com.digitoolsolutions.app.torquevaultkmp.utils.BluetoothManager
import com.digitoolsolutions.app.torquevaultkmp.utils.NetworkMonitor
import com.digitoolsolutions.app.torquevaultkmp.utils.PermissionHandler
import com.digitoolsolutions.app.torquevaultkmp.utils.PermissionHandlerPlatform
+import com.digitoolsolutions.app.torquevaultkmp.utils.ShareLog
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
@@ -29,4 +31,5 @@ actual val platformModule = module {
}
single { AndroidNetworkMonitor(androidContext()) }
single { AndroidAppInfo(androidContext()) }
+ single { AndroidShareLog(androidContext()) }
}
diff --git a/composeApp/src/androidMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/ShareLog.android.kt b/composeApp/src/androidMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/ShareLog.android.kt
new file mode 100644
index 0000000..118383c
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/ShareLog.android.kt
@@ -0,0 +1,60 @@
+package com.digitoolsolutions.app.torquevaultkmp.utils
+
+import android.app.Activity
+import android.content.ClipData
+import android.content.Context
+import android.content.Intent
+import androidx.core.content.FileProvider
+import java.io.File
+import java.time.LocalDateTime
+import java.time.format.DateTimeFormatter
+
+class AndroidShareLog(private val context: Context) : ShareLog {
+ init {
+ cleanOldLogFiles()
+ }
+ override fun exportTextFile(content: String) {
+ val fileName = generateFileName("txt")
+ shareFile(fileName, "text/plain", "Share plain text log", content)
+ }
+
+ override fun exportCSVFile(content: String) {
+ val fileName = generateFileName("csv")
+ shareFile(fileName, "text/csv", "Share CSV log", content)
+ }
+
+ private fun generateFileName(extension: String): String {
+ val formatter = DateTimeFormatter.ofPattern("yy-MM-dd_HH-mm-ss")
+ val timestamp = LocalDateTime.now().format(formatter)
+ return "TV_logs_${timestamp}.$extension"
+ }
+
+ private fun shareFile(fileName: String, mimeType: String, title: String, content: String) {
+ val file = File(context.cacheDir, fileName)
+ file.writeText(content)
+
+ val uri = FileProvider.getUriForFile(context, "${context.packageName}.provider", file)
+ val intent = Intent(Intent.ACTION_SEND).apply {
+ type = mimeType
+ putExtra(Intent.EXTRA_STREAM, uri)
+ clipData = ClipData.newRawUri("", uri)
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+
+ val chooser = Intent.createChooser(intent, title).apply {
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ if (context !is Activity) {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ }
+ context.startActivity(chooser)
+ }
+ private fun cleanOldLogFiles() {
+ context.cacheDir?.listFiles()
+ ?.filter { it.name.startsWith("TV_logs_") }
+ ?.forEach {
+ it.delete()
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/composeApp/src/androidMain/res/xml/file_paths.xml b/composeApp/src/androidMain/res/xml/file_paths.xml
new file mode 100644
index 0000000..8711378
--- /dev/null
+++ b/composeApp/src/androidMain/res/xml/file_paths.xml
@@ -0,0 +1,6 @@
+
+
+
+
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
index 15a2293..0dd8b63 100644
--- 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
@@ -1,73 +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 kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.update
+import kotlin.collections.plus
import kotlin.time.Clock
+import kotlin.uuid.ExperimentalUuidApi
+import kotlin.uuid.Uuid
-class LogRepository(
- private val dbQueries: AppDatabaseQueries
-) {
- fun addLog(title: String, content: String, deviceId: String? = null) {
+data class Log(
+ val id: String,
+ val title: String,
+ val content: String,
+ val timestamp: Long,
+ val deviceId: String?,
+ val source: String,
+)
+class LogRepository() {
+ companion object {
+ private const val MAX_LOGS = 1000
+ }
+ private val _logs = MutableStateFlow>(emptyList())
+ val logs: StateFlow> = _logs
+ @OptIn(ExperimentalUuidApi::class)
+ fun appendLog(title: String, content: String, deviceId: String? = null) {
val source = if (deviceId == null) LogFilter.SYSTEM else LogFilter.DEVICE
- dbQueries.insertLog(
+ val newLog = Log(
+ id = Uuid.generateV4().toString(),
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()
+ _logs.update { current ->
+ (listOf(newLog) + current).take(MAX_LOGS)
}
}
-
- suspend fun getLogsByFilterPaged(filter: LogFilter, limit: Long, lastTimestamp: Long?, lastId: Long?): List {
- return when (filter) {
- LogFilter.All -> getLogsPaged(limit, lastTimestamp, lastId)
- LogFilter.System -> {
- if (lastTimestamp == null || lastId == null) {
- dbQueries.getLogsByTypeFirstPage(filter.name, limit).executeAsList()
- } else {
- dbQueries.getLogsByTypeNextPage(source = filter.name, lastTimestamp = lastTimestamp, lastId = lastId, limit = limit).executeAsList()
- }
- }
- is LogFilter.Device -> {
- if (filter.deviceId != null) {
- getLogsByDevicePaged(filter.deviceId, limit, lastTimestamp, lastId)
- } else {
- if (lastTimestamp == null || lastId == null) {
- dbQueries.getLogsByTypeFirstPage(filter.name, limit).executeAsList()
+ fun getAllLogs(): Flow> = logs
+ fun getLogsWithFilter(filter: LogFilter): Flow> =
+ logs.map { list ->
+ when (filter) {
+ LogFilter.All -> list
+ LogFilter.System -> list.filter { it.source == LogFilter.SYSTEM }
+ is LogFilter.Device -> {
+ if (filter.deviceId != null) {
+ list.filter { it.deviceId == filter.deviceId }
} else {
- dbQueries.getLogsByTypeNextPage(source = filter.name, lastTimestamp = lastTimestamp, lastId = lastId, limit = limit).executeAsList()
+ list.filter { it.source == LogFilter.DEVICE }
}
}
}
}
- }
- 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()
- }
+ fun clearLogs() {
+ _logs.value = emptyList()
}
-
- suspend fun clearLogs() = dbQueries.clearAllLogs()
}
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 b735990..ca05c84 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
@@ -36,7 +36,7 @@ val appModule = module {
val storageModule = module {
single { AppDatabase(get()) }
single { get().appDatabaseQueries }
- single { LogRepository(get()) }
+ single { LogRepository() }
single { DeviceRepository(get()) }
single { ScannerRepository(get()) }
}
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 9b76b3e..daaa9a5 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,8 +1,11 @@
package com.digitoolsolutions.app.torquevaultkmp.screens.logs
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
@@ -11,8 +14,9 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DeleteForever
import androidx.compose.material.icons.outlined.FilterList
+import androidx.compose.material.icons.rounded.KeyboardArrowDown
import androidx.compose.material.icons.rounded.KeyboardArrowUp
-import androidx.compose.material3.AlertDialog
+import androidx.compose.material.icons.rounded.Share
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
@@ -23,7 +27,6 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable
@@ -41,15 +44,20 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.digitoolsolutions.app.torquevaultkmp.components.AppBar
-import com.digitoolsolutions.app.torquevaultkmp.data.storage.db.Log
+import com.digitoolsolutions.app.torquevaultkmp.data.repository.Log
import com.digitoolsolutions.app.torquevaultkmp.domain.model.LogFilter
+import com.digitoolsolutions.app.torquevaultkmp.screens.logs.view.DeleteLogDialog
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.LogFormat
import com.digitoolsolutions.app.torquevaultkmp.screens.logs.view.LogItem
+import com.digitoolsolutions.app.torquevaultkmp.screens.logs.view.ShareLogDialog
+import com.digitoolsolutions.app.torquevaultkmp.utils.ShareLog
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
+import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import torquevaultkmp.composeapp.generated.resources.Res
import torquevaultkmp.composeapp.generated.resources.log_title
@@ -61,19 +69,14 @@ fun LogScreen(
) {
val logs by viewModel.logs.collectAsState()
val activeFilter by viewModel.activeFilter.collectAsState()
- val isLoading by viewModel.isLoading.collectAsState()
- LaunchedEffect(Unit) {
- viewModel.refresh()
- }
LogContent(
logs = logs,
activeFilter = activeFilter,
- isLoading = isLoading,
onFilterSelected = { viewModel.setFilter(it) },
onDeleteAllLogs = { viewModel.clearAllLogs() },
- onLoadMore = { viewModel.loadNextPage() },
- onRefresh = { viewModel.refresh() }
+ txtContent = viewModel.exportLogsAsText(),
+ csvContent = viewModel.exportLogsAsCSV()
)
}
@@ -82,49 +85,40 @@ fun LogScreen(
internal fun LogContent(
logs: List,
activeFilter: LogFilter,
- isLoading: Boolean,
onFilterSelected: (LogFilter) -> Unit,
onDeleteAllLogs: () -> Unit,
- onLoadMore: () -> Unit,
- onRefresh: () -> Unit
+ txtContent: String,
+ csvContent: String
) {
val snackbarHostState = remember { SnackbarHostState() }
- var showConfirmDialog by remember { mutableStateOf(false) }
+ var showDeleteDialog by remember { mutableStateOf(false) }
var showBottomSheet by remember { mutableStateOf(false) }
+ var showShareDialog by remember { mutableStateOf(false) }
+ val shareLog: ShareLog = koinInject()
val listState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope()
- // Infinite Scroll logic
- val shouldLoadMore = remember {
- derivedStateOf {
- val lastVisibleItemIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
- lastVisibleItemIndex >= logs.size - 5 && logs.isNotEmpty()
- }
- }
-
LaunchedEffect(logs) {
if (logs.isNotEmpty()) {
listState.scrollToItem(0)
}
}
- LaunchedEffect(shouldLoadMore) {
- snapshotFlow { shouldLoadMore.value }
- .distinctUntilChanged()
- .filter { it }
- .collect {
- onLoadMore()
- }
- }
-
Scaffold (
modifier = Modifier.imePadding(),
topBar = {
AppBar(
title = {Text(stringResource(Res.string.log_title))},
actions = {
- IconButton(onClick = {showConfirmDialog = true}){
+ IconButton(onClick = { showShareDialog = true }){
+ Icon(
+ Icons.Rounded.Share,
+ contentDescription = "Share logs",
+ tint = Color.White
+ )
+ }
+ IconButton(onClick = { showDeleteDialog = true }){
Icon(
Icons.Default.DeleteForever,
contentDescription = "Delete all log",
@@ -142,17 +136,33 @@ internal fun LogContent(
)
},
floatingActionButton = {
- FloatingActionButton(
- onClick = {
- onRefresh()
- coroutineScope.launch {
- listState.animateScrollToItem(0)
- }
- },
- containerColor = MaterialTheme.colorScheme.primary,
- contentColor = MaterialTheme.colorScheme.onPrimary
- ) {
- Icon(Icons.Rounded.KeyboardArrowUp, contentDescription = "Go to top")
+ Column {
+ FloatingActionButton(
+ onClick = {
+ coroutineScope.launch {
+ listState.animateScrollToItem(0)
+ }
+ },
+ containerColor = MaterialTheme.colorScheme.primary,
+ contentColor = MaterialTheme.colorScheme.onPrimary
+ ) {
+ Icon(Icons.Rounded.KeyboardArrowUp, contentDescription = "Go to top")
+ }
+ Spacer(modifier = Modifier.height(4.dp))
+ FloatingActionButton(
+ onClick = {
+ coroutineScope.launch {
+ val lastIndex = logs.size - 1
+ if (lastIndex >= 0) {
+ listState.animateScrollToItem(lastIndex)
+ }
+ }
+ },
+ containerColor = MaterialTheme.colorScheme.primary,
+ contentColor = MaterialTheme.colorScheme.onPrimary
+ ) {
+ Icon(Icons.Rounded.KeyboardArrowDown, contentDescription = "Go to end")
+ }
}
},
snackbarHost = { SnackbarHost(snackbarHostState) })
@@ -160,10 +170,8 @@ internal fun LogContent(
val pullToRefreshState = rememberPullToRefreshState()
PullToRefreshBox(
- isRefreshing = isLoading && logs.isEmpty(),
- onRefresh = {
- onRefresh()
- },
+ isRefreshing = false,
+ onRefresh = {},
state = pullToRefreshState,
modifier = Modifier
.fillMaxSize()
@@ -171,52 +179,33 @@ internal fun LogContent(
.padding(padding)
.padding(horizontal = 16.dp, vertical = 12.dp),
){
- if (logs.isEmpty() && !isLoading) {
+ if (logs.isEmpty()) {
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(
- onDismissRequest = { showConfirmDialog = false },
- title = { Text("Delete Logs") },
- text = { Text("Warning: This will permanently delete all current logs. This action cannot be undone.") },
- confirmButton = {
- TextButton(onClick = {
- onDeleteAllLogs()
- showConfirmDialog = false
- }) {
- Text("Delete", color = Color.Red)
- }
- },
- dismissButton = {
- TextButton(onClick = { showConfirmDialog = false }) {
- Text("Cancel", color = Color.Gray)
- }
- }
- )
- }
- if (showBottomSheet) {
- FilterBottomSheet(
- activeFilter = activeFilter,
- onFilterSelected = onFilterSelected,
- onDismissRequest = {showBottomSheet = false}
- )
- }
+ ShareLogDialog(show = showShareDialog, onDismiss = { showShareDialog = false }, onConfirm = { format ->
+ when (format) {
+ LogFormat.PlainText -> { shareLog.exportTextFile(txtContent) }
+ LogFormat.CSV -> { shareLog.exportCSVFile(csvContent) }
+ }
+ showShareDialog = false
+ })
+ DeleteLogDialog(show = showDeleteDialog, onDismiss = { showDeleteDialog = false }, onConfirm = {
+ onDeleteAllLogs()
+ showDeleteDialog = false
+ })
+ FilterBottomSheet(
+ show = showBottomSheet,
+ activeFilter = activeFilter,
+ onFilterSelected = onFilterSelected,
+ onDismissRequest = {showBottomSheet = false}
+ )
+
}
}
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
index af0e288..86137bc 100644
--- 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
@@ -2,84 +2,84 @@ package com.digitoolsolutions.app.torquevaultkmp.screens.logs
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.digitoolsolutions.app.torquevaultkmp.data.repository.Log
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.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.launch
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.offsetAt
+import kotlinx.datetime.toLocalDateTime
+import kotlin.time.Instant
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()
- }
-
+ @OptIn(ExperimentalCoroutinesApi::class)
+ val logs: StateFlow> = _activeFilter
+ .flatMapLatest { filter ->
+ logRepository.getLogsWithFilter(filter)
+ }
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(5000),
+ initialValue = emptyList()
+ )
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 += newLogs
- }
-
- isLoading.value = false
- }
- }
-
- fun refresh() {
- viewModelScope.launch {
- lastTimestamp = null
- lastId = null
- isLastPage = false
- _logs.value = emptyList()
- loadNextPage()
- }
}
fun clearAllLogs() {
- viewModelScope.launch {
- logRepository.clearLogs()
- _logs.value = emptyList()
- lastTimestamp = null
- lastId = null
- isLastPage = true
+ logRepository.clearLogs()
+ }
+
+ fun formatTimestamp(epochMillis: Long): String {
+ val instant = Instant.fromEpochMilliseconds(epochMillis)
+ val tz = TimeZone.currentSystemDefault()
+ val localDateTime = instant.toLocalDateTime(tz)
+
+ // Format pattern ex: "Sat Jun 27 10:19:21 GMT+07:00 2026"
+ val dayOfWeek = localDateTime.dayOfWeek.name.take(3).lowercase().replaceFirstChar { it.uppercase() }
+ val month = localDateTime.month.name.take(3).lowercase().replaceFirstChar { it.uppercase() }
+ val day = localDateTime.day
+ val year = localDateTime.year
+ val hour = localDateTime.hour.toString().padStart(2, '0')
+ val minute = localDateTime.minute.toString().padStart(2, '0')
+ val second = localDateTime.second.toString().padStart(2, '0')
+
+ val offset = tz.offsetAt(instant) // Example: GMT+07:00
+
+ return "$dayOfWeek $month $day $hour:$minute:$second $offset $year"
+ }
+
+ fun escapeCsv(value: String?): String {
+ if (value == null) return ""
+ val escaped = value.replace("\"", "\"\"") // escape "
+ return "\"$escaped\""
+ }
+
+ fun exportLogsAsText(): String {
+ val logs = logRepository.logs.value
+ return logs.joinToString(separator = "\n") { log ->
+ "${formatTimestamp(log.timestamp)}: [${log.deviceId}] - ${log.title} - ${log.content}"
}
}
+
+ fun exportLogsAsCSV(): String {
+ val logs = logRepository.logs.value
+ val header = "type,deviceId,time,title,content"
+ val rows = logs.joinToString(separator = "\n") { log ->
+ "${log.source},${log.deviceId},${formatTimestamp(log.timestamp)},${log.title},${escapeCsv(log.content)}"
+ }
+ return "$header\n$rows"
+ }
+
}
diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/view/DeleteLogDialog.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/view/DeleteLogDialog.kt
new file mode 100644
index 0000000..c62c4e9
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/view/DeleteLogDialog.kt
@@ -0,0 +1,31 @@
+package com.digitoolsolutions.app.torquevaultkmp.screens.logs.view
+
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+
+@Composable
+fun DeleteLogDialog(
+ show: Boolean = false,
+ onDismiss: () -> Unit,
+ onConfirm: () -> Unit
+) {
+ if(!show) return
+ AlertDialog(
+ onDismissRequest = onDismiss ,
+ title = { Text("Delete Logs") },
+ text = { Text("Warning: This will permanently delete all current logs. This action cannot be undone.") },
+ confirmButton = {
+ TextButton(onClick = onConfirm) {
+ Text("Delete", color = Color.Red)
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss ) {
+ Text("Cancel", color = Color.Gray)
+ }
+ }
+ )
+}
\ No newline at end of file
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 38c2612..0580f57 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
@@ -53,10 +53,12 @@ import torquevaultkmp.composeapp.generated.resources.clear
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FilterBottomSheet(
+ show: Boolean = false,
activeFilter: LogFilter,
onFilterSelected: (LogFilter) -> Unit,
onDismissRequest: () -> Unit,
) {
+ if(!show) return
ModalBottomSheet(
onDismissRequest = onDismissRequest,
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
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 3461fb2..0cd96bb 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
@@ -10,7 +10,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
-import com.digitoolsolutions.app.torquevaultkmp.data.storage.db.Log
+import com.digitoolsolutions.app.torquevaultkmp.data.repository.Log
import com.digitoolsolutions.app.torquevaultkmp.domain.model.LogFilter
import kotlin.time.Instant
import kotlinx.datetime.TimeZone
@@ -20,7 +20,7 @@ import kotlinx.datetime.toLocalDateTime
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}"
+ val formattedTime = "${dateTime.date} ${dateTime.hour.toString().padStart(2, '0')}:${dateTime.minute.toString().padStart(2, '0')}:${dateTime.second.toString().padStart(2, '0')}"
Column(
modifier = modifier.fillMaxWidth(),
diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/view/ShareLogDialog.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/view/ShareLogDialog.kt
new file mode 100644
index 0000000..5680440
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/logs/view/ShareLogDialog.kt
@@ -0,0 +1,78 @@
+package com.digitoolsolutions.app.torquevaultkmp.screens.logs.view
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.selection.selectable
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.RadioButton
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.unit.dp
+
+enum class LogFormat { PlainText, CSV }
+
+@Composable
+fun ShareLogDialog(
+ show: Boolean = false,
+ onDismiss: () -> Unit,
+ onConfirm: (LogFormat) -> Unit
+) {
+ var selectedFormat by remember { mutableStateOf(LogFormat.PlainText) }
+ if(!show) return
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text("Export Logs") },
+ text = {
+ Column {
+ Row(
+ modifier = Modifier.fillMaxWidth().clickable {
+ selectedFormat = LogFormat.PlainText
+ },
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ RadioButton(
+ selected = selectedFormat == LogFormat.PlainText,
+ onClick = { selectedFormat = LogFormat.PlainText }
+ )
+ Text("Plain text")
+ }
+ Row(
+ modifier = Modifier.fillMaxWidth().clickable {
+ selectedFormat = LogFormat.CSV
+ },
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ RadioButton(
+ selected = selectedFormat == LogFormat.CSV,
+ onClick = { selectedFormat = LogFormat.CSV }
+ )
+ Text("CSV file")
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = { onConfirm(selectedFormat) }) {
+ Text("OK", color = MaterialTheme.colorScheme.primary)
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) {
+ Text("Cancel", color = Color.Gray)
+ }
+ }
+ )
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/scanner/ScannerViewModel.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/scanner/ScannerViewModel.kt
index 9160ac0..4a83576 100644
--- a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/scanner/ScannerViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/screens/scanner/ScannerViewModel.kt
@@ -37,7 +37,10 @@ import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
+import kotlin.math.ceil
+import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
+import kotlin.time.TimeMark
import kotlin.time.TimeSource
import kotlin.uuid.ExperimentalUuidApi
@@ -58,7 +61,7 @@ class ScannerViewModel(
private val scannerRepository: ScannerRepository
) : ViewModel() {
companion object {
- private val ADV_EXPIRATION_TIME = 5.seconds
+ private var ADV_EXPIRATION_TIME = 3.seconds
private val CONNECTION_TIMEOUT = 5.seconds
private const val MAX_LOG_ENTRIES = 50
}
@@ -78,7 +81,6 @@ class ScannerViewModel(
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
val isAutoConnect: StateFlow = _autoConnect
- private val _onlyWithName = MutableStateFlow(true)
private var scanJob: Job? = null
private var cleanupJob: Job? = null
private val connectionJobs = mutableMapOf()
@@ -87,7 +89,7 @@ class ScannerViewModel(
/** Bonded device */
private val bondedIds = mutableSetOf() // Cache
- suspend fun preloadBondedIds() {
+ fun preloadBondedIds() {
viewModelScope.launch {
deviceRepository.getAllDeviceIdsFlow().collect { ids ->
bondedIds.clear()
@@ -98,17 +100,74 @@ class ScannerViewModel(
fun isBonded(id: String): Boolean = bondedIds.contains(id)
init {
+ logRepository.appendLog("Scanner - Calibration", "Initial advertising expiration time: $ADV_EXPIRATION_TIME")
viewModelScope.launch {
preloadBondedIds()
}
}
+ private val calibrateCleanupTime = mutableMapOf()
+ private val intervals = mutableListOf()
+ private var stabilized = false
+
+ /**
+ * Record advertisement: collect (20 times) intervals between consecutive ads
+ * to calibrate the cleanup expiration time when a device stops advertising.
+ */
+ private fun calibrateCleanupTime(deviceId: String, now: TimeMark) {
+ if (stabilized) return
+ val firstDeviceId = calibrateCleanupTime.keys.firstOrNull()
+ if (firstDeviceId != null && deviceId != firstDeviceId) {
+ return
+ }
+
+ if (firstDeviceId == null) {
+ logRepository.appendLog("Scanner Calibration", "Starting calibration with device $deviceId")
+ }
+
+ calibrateCleanupTime[deviceId]?.let { last ->
+ val delta = last.elapsedNow()
+ // Only consider reasonable intervals between consecutive ads of the same device
+ if (delta.inWholeMilliseconds < 5000) { // ms
+ intervals.add(delta)
+ if (intervals.size > 20) intervals.removeAt(0)
+ if (intervals.size >= 20) {
+ val maxMs = intervals.maxOf { it.inWholeMilliseconds }
+ val rounded = ceil((maxMs * 1.2) / 1000.0).toLong().seconds // *1.2: margin 20%
+ ADV_EXPIRATION_TIME = rounded.coerceAtLeast(1.seconds)
+ stabilized = true
+ logRepository.appendLog("Scanner Calibration", "Expiration time for removing stopped advertising devices set to: $ADV_EXPIRATION_TIME")
+ }
+ }
+ }
+ calibrateCleanupTime[deviceId] = now
+ }
+
+ /**
+ * Reset calibration when the currently calibrated device is removed
+ * (i.e., it stopped advertising and was cleaned up).
+ */
+ private fun resetCalibrate(id: String) {
+ val firstDeviceId = calibrateCleanupTime.keys.firstOrNull()
+ if(!stabilized && id == firstDeviceId) {
+ calibrateCleanupTime.clear()
+ intervals.clear()
+ logRepository.appendLog(
+ "Scanner Calibration",
+ "Calibration cancelled for $id — device stopped advertising and was cleaned up"
+ )
+ }
+ }
+
fun startScan() {
if (_isScanning.value) return
scanJob = viewModelScope.launch {
_isScanning.value = true
_devices.value = emptyList()
advertisementMap.clear()
+ calibrateCleanupTime.clear()
+ intervals.clear()
+ stabilized = false
bleManager.scanDevices()
.catch {
@@ -126,24 +185,33 @@ class ScannerViewModel(
if (entry != null) {
advertisementMap[bleId] = entry.copy(advertisement = advertisement, timeMark = now)
} else {
+ logRepository.appendLog(title="Scanner status", content="${advertisement.name} ($bleId) peripheral discovered")
advertisementMap[bleId] = AdvertisementMark(advertisement, now)
}
+ calibrateCleanupTime(bleId, now)
startCleanupJob()
updateDeviceList()
}
}
}
- logRepository.addLog(title="Scanner status", content="Scanning ...")
+ logRepository.appendLog(title="Scanner status", content="Scanning ...")
}
+ /**
+ * Cleanup job: runs periodically to remove devices that stopped advertising.
+ * Also resets calibration if the calibrated device is removed.
+ */
private fun startCleanupJob() {
if (cleanupJob?.isActive == true) return
cleanupJob = viewModelScope.launch {
while (advertisementMap.isNotEmpty() && isActive) {
- delay(5000)
+ delay(1000)
val keysToRemove = advertisementMap.filter { it.value.timeMark.elapsedNow() > ADV_EXPIRATION_TIME }.keys
if (keysToRemove.isNotEmpty()) {
- keysToRemove.forEach { advertisementMap.remove(it) }
+ keysToRemove.forEach {
+ advertisementMap.remove(it)
+ resetCalibrate(it)
+ }
updateDeviceList()
}
}
@@ -219,14 +287,14 @@ class ScannerViewModel(
viewModelScope.launch {
bleManager.sendCommand(peripheral, Helper.buildUartCommand(command))
appendHistory(deviceId, "Sent: $command")
- logRepository.addLog(title = "Communicate - Sent", content = command, deviceId)
+ logRepository.appendLog(title = "Communicate - Sent", content = command, deviceId)
}
}
fun connect(adv: Advertisement) {
val deviceIdentifier = adv.identifier.toString()
if (connectionJobs[deviceIdentifier]?.isActive == true) return
- logRepository.addLog(title = "Connection - Status", deviceId = deviceIdentifier ,content = "Device [${adv.name}] is connecting...")
+ logRepository.appendLog(title = "Connection - Status", deviceId = deviceIdentifier ,content = "Device [${adv.name}] is connecting...")
val job = viewModelScope.launch {
try {
val peripheral = bleManager.connect(adv)
@@ -241,7 +309,7 @@ class ScannerViewModel(
/** Add bonded device */
addBondedDevice(deviceIdentifier, adv.name ?: "No name")
_connectionReasons.value -= deviceIdentifier
- logRepository.addLog(title = "Connection - Status", deviceId = deviceIdentifier ,content = "Device [${adv.name}] has been connected.")
+ logRepository.appendLog(title = "Connection - Status", deviceId = deviceIdentifier ,content = "Device [${adv.name}] has been connected.")
}
if (state is State.Disconnected && hasEmitted) {
if (_connectionReasons.value[deviceIdentifier] == null) {
@@ -269,10 +337,10 @@ class ScannerViewModel(
if (e is TimeoutCancellationException) {
Napier.e(">>>>> Connection timed out for $deviceIdentifier")
_connectionReasons.value += (deviceIdentifier to ConnectionReason.TIMEOUT)
- logRepository.addLog(title = "Connection - Status", content = "Connect to device [${adv.name}] failed. ${e.message}")
+ logRepository.appendLog(title = "Connection - Status", content = "Connect to device [${adv.name}] failed. ${e.message}")
} else if (e !is CancellationException) {
Napier.e(">>>>> Connection failed for $deviceIdentifier", e)
- logRepository.addLog(title = "Connection - Status", content = "Connect to device [${adv.name}] failed. ${e.message}")
+ logRepository.appendLog(title = "Connection - Status", content = "Connect to device [${adv.name}] failed. ${e.message}")
}
} finally {
_connectedDevices.value -= deviceIdentifier
@@ -301,7 +369,7 @@ class ScannerViewModel(
sendWorkOrdersToDevice(peripheral,orders)
} catch (e: Exception) {
Napier.e("Failed to load work orders", e)
- logRepository.addLog(title = "HTTP", content = "${e.message}".substringBefore("[url="))
+ logRepository.appendLog(title = "HTTP", content = "${e.message}".substringBefore("[url="))
}
}
}
@@ -323,7 +391,7 @@ class ScannerViewModel(
}
private fun handleMessage(peripheral: Peripheral, msg: String, deviceIdentifier: String) {
appendHistory(deviceIdentifier, "Received: $msg")
- logRepository.addLog(title = "Communicate - Received", content = msg, deviceId = deviceIdentifier)
+ logRepository.appendLog(title = "Communicate - Received", content = msg, deviceId = deviceIdentifier)
val decoded = Helper.decodeRxData(msg) ?: return
val data = decoded.data
val woId = decoded.id
@@ -342,7 +410,7 @@ class ScannerViewModel(
} catch (e: Exception) {
Napier.e(">>>>> Failed to handle message: $msg", e)
sendCommand(peripheral, Helper.CMD_NOT_AVAILABLE_WO)
- logRepository.addLog(title = "HTTP", content = "${e.message}".substringBefore("[url="))
+ logRepository.appendLog(title = "HTTP", content = "${e.message}".substringBefore("[url="))
}
}
else -> {
@@ -363,7 +431,7 @@ class ScannerViewModel(
scannerRepository.sendMeasurementResult(woId, dto)
} catch (e: Exception) {
Napier.e("An error occurred while sending the measurement", e, tag = "ScannerViewModel")
- logRepository.addLog(title = "HTTP", content = "${e.message}".substringBefore("[url="))
+ logRepository.appendLog(title = "HTTP", content = "${e.message}".substringBefore("[url="))
} finally {
// Currently, always send UPLOADED_WO regardless of success or failure
sendCommand(peripheral, Helper.CMD_UPLOADED_WO)
@@ -374,7 +442,7 @@ class ScannerViewModel(
scannerRepository.sendMeasurementResult(woId, dto, true)
} catch (e: Exception) {
Napier.e("An error occurred while sending the re-measurement", e, tag = "ScannerViewModel")
- logRepository.addLog(title = "HTTP", content = "${e.message}".substringBefore("[url="))
+ logRepository.appendLog(title = "HTTP", content = "${e.message}".substringBefore("[url="))
} finally {
// Currently, always send UPLOADED_WO regardless of success or failure
sendCommand(peripheral, Helper.CMD_UPLOADED_WO)
@@ -385,7 +453,7 @@ class ScannerViewModel(
scannerRepository.sendCancelWorkOrder(woId, dto)
} catch (e: Exception) {
Napier.e("An error occurred while cancel work order", e, tag = "ScannerViewModel")
- logRepository.addLog(title = "HTTP", content = "${e.message}".substringBefore("[url="))
+ logRepository.appendLog(title = "HTTP", content = "${e.message}".substringBefore("[url="))
} finally {
// Currently, always send UPLOADED_WO regardless of success or failure
sendCommand(peripheral, Helper.CMD_UPLOADED_WO)
diff --git a/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/ShareLog.kt b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/ShareLog.kt
new file mode 100644
index 0000000..7fa271f
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/ShareLog.kt
@@ -0,0 +1,6 @@
+package com.digitoolsolutions.app.torquevaultkmp.utils
+
+interface ShareLog {
+ fun exportTextFile(content: String)
+ fun exportCSVFile(content: String)
+}
\ No newline at end of file
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 7f6ab40..276195c 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
@@ -9,10 +9,12 @@ import com.digitoolsolutions.app.torquevaultkmp.utils.AppInfo
import com.digitoolsolutions.app.torquevaultkmp.utils.BluetoothManager
import com.digitoolsolutions.app.torquevaultkmp.utils.IOSAppInfo
import com.digitoolsolutions.app.torquevaultkmp.utils.IOSNetworkMonitor
+import com.digitoolsolutions.app.torquevaultkmp.utils.IOSShareLog
import com.digitoolsolutions.app.torquevaultkmp.utils.IosBluetoothManager
import com.digitoolsolutions.app.torquevaultkmp.utils.NetworkMonitor
import com.digitoolsolutions.app.torquevaultkmp.utils.PermissionHandler
import com.digitoolsolutions.app.torquevaultkmp.utils.PermissionHandlerPlatform
+import com.digitoolsolutions.app.torquevaultkmp.utils.ShareLog
import org.koin.dsl.module
actual val platformModule = module {
@@ -27,4 +29,5 @@ actual val platformModule = module {
}
single { IOSNetworkMonitor() }
single { IOSAppInfo() }
+ single { IOSShareLog() }
}
diff --git a/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/ShareLog.ios.kt b/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/ShareLog.ios.kt
new file mode 100644
index 0000000..02aeb63
--- /dev/null
+++ b/composeApp/src/iosMain/kotlin/com/digitoolsolutions/app/torquevaultkmp/utils/ShareLog.ios.kt
@@ -0,0 +1,91 @@
+package com.digitoolsolutions.app.torquevaultkmp.utils
+
+import kotlinx.cinterop.BetaInteropApi
+import kotlinx.cinterop.ExperimentalForeignApi
+import kotlinx.cinterop.readValue
+import platform.CoreGraphics.CGRectZero
+import platform.Foundation.NSDate
+import platform.Foundation.NSDateFormatter
+import platform.Foundation.NSFileManager
+import platform.Foundation.NSLocale
+import platform.Foundation.NSString
+import platform.Foundation.NSTemporaryDirectory
+import platform.Foundation.NSURL
+import platform.Foundation.NSUTF8StringEncoding
+import platform.Foundation.create
+import platform.Foundation.dataUsingEncoding
+import platform.Foundation.localeWithLocaleIdentifier
+import platform.Foundation.writeToURL
+import platform.UIKit.UIActivityViewController
+import platform.UIKit.UIApplication
+import platform.UIKit.popoverPresentationController
+
+class IOSShareLog : ShareLog {
+ init {
+ cleanOldLogFiles()
+ }
+
+ override fun exportTextFile(content: String) {
+ val fileName = generateFileName("txt")
+ shareFile(content, fileName)
+ }
+
+ override fun exportCSVFile(content: String) {
+ val fileName = generateFileName("csv")
+ shareFile(content, fileName)
+ }
+ private fun generateFileName(extension: String): String {
+ val formatter = NSDateFormatter().apply {
+ dateFormat = "yy-MM-dd_HH-mm-ss"
+ locale = NSLocale.localeWithLocaleIdentifier("en_US_POSIX")
+ }
+ val timestamp = formatter.stringFromDate(NSDate())
+ return "TV_logs_${timestamp}.$extension"
+ }
+ @OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
+ private fun shareFile(content: String, fileName: String) {
+ val nsString = NSString.create(string = content)
+ val data = nsString.dataUsingEncoding(NSUTF8StringEncoding)
+ val tempDir = NSTemporaryDirectory()
+ val fileURL = NSURL.fileURLWithPath(tempDir).URLByAppendingPathComponent(fileName)
+
+ if ((fileURL != null) && (data != null)) {
+ data.writeToURL(url = fileURL, atomically = true)
+
+ val activityViewController = UIActivityViewController(
+ activityItems = listOf(fileURL),
+ applicationActivities = null,
+ )
+
+ val rootViewController = UIApplication.sharedApplication.keyWindow?.rootViewController
+
+ // For iPad, we need to provide a sourceView or barButtonItem for the popover
+ activityViewController.popoverPresentationController()?.let { popover ->
+ val rootView = rootViewController?.view
+ if (rootView != null) {
+ popover.sourceView = rootView
+ popover.sourceRect = rootView.bounds
+ } else {
+ popover.sourceRect = CGRectZero.readValue()
+ }
+ }
+
+ rootViewController?.presentViewController(activityViewController, true, null)
+ }
+ }
+
+ @OptIn(ExperimentalForeignApi::class)
+ private fun cleanOldLogFiles() {
+ val tempDir = NSTemporaryDirectory()
+ val fm = NSFileManager.defaultManager
+ val files = fm.contentsOfDirectoryAtPath(tempDir, null) ?: return
+
+ for (item in files) {
+ val name = item as? String ?: continue
+ if (name.startsWith("TV_logs_")) {
+ val path = tempDir + name
+ fm.removeItemAtPath(path, null)
+ }
+ }
+ }
+}
\ No newline at end of file