Performance improvements. Backup status screen

This commit is contained in:
Rukira 2026-03-04 14:19:19 +00:00
parent 171bf78be8
commit 0c8290f16e
8 changed files with 455 additions and 54 deletions

View file

@ -15,6 +15,8 @@ import androidx.compose.ui.unit.dp
import com.rukira.wowbackup.ui.Screen import com.rukira.wowbackup.ui.Screen
import com.rukira.wowbackup.ui.config.ConfigScreen import com.rukira.wowbackup.ui.config.ConfigScreen
import com.rukira.wowbackup.ui.config.ConfigViewModel import com.rukira.wowbackup.ui.config.ConfigViewModel
import com.rukira.wowbackup.ui.status.StatusScreen
import com.rukira.wowbackup.ui.status.StatusViewModel
@Composable @Composable
fun App( fun App(
@ -24,7 +26,14 @@ fun App(
MaterialTheme { MaterialTheme {
Surface(modifier = Modifier.fillMaxSize()) { Surface(modifier = Modifier.fillMaxSize()) {
when (currentScreen) { when (currentScreen) {
Screen.STATUS -> PlaceholderScreen("Status", "Backup status will appear here.") Screen.STATUS -> {
val viewModel = remember { StatusViewModel() }
StatusScreen(
viewModel = viewModel,
onNavigateToConfig = { onNavigate(Screen.CONFIG) },
onNavigateToRestore = { onNavigate(Screen.RESTORE) },
)
}
Screen.CONFIG -> { Screen.CONFIG -> {
val viewModel = remember { ConfigViewModel() } val viewModel = remember { ConfigViewModel() }
ConfigScreen( ConfigScreen(

View file

@ -2,20 +2,33 @@ package com.rukira.wowbackup.backup
import com.rukira.wowbackup.config.AppConfig import com.rukira.wowbackup.config.AppConfig
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardCopyOption
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong
import java.util.zip.ZipEntry import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream import java.util.zip.ZipOutputStream
private val logger = KotlinLogging.logger {} private val logger = KotlinLogging.logger {}
private const val THROTTLE_INTERVAL_NS = 100_000_000L // 100ms
data class BackupProgress( data class BackupProgress(
val totalFiles: Int, val totalFiles: Int,
val completedFiles: Int, val completedFiles: Int,
@ -34,14 +47,14 @@ object BackupEngine {
private val _progress = MutableStateFlow<BackupProgress?>(null) private val _progress = MutableStateFlow<BackupProgress?>(null)
val progress: StateFlow<BackupProgress?> = _progress.asStateFlow() val progress: StateFlow<BackupProgress?> = _progress.asStateFlow()
suspend fun runBackup(config: AppConfig): BackupResult { suspend fun runBackup(config: AppConfig): BackupResult = withContext(Dispatchers.IO) {
val wowPath = config.wowInstallPath ?: return BackupResult.Failure("WoW install path not set.") val wowPath = config.wowInstallPath ?: return@withContext BackupResult.Failure("WoW install path not set.")
val backupPath = config.backupPath ?: return BackupResult.Failure("Backup path not set.") val backupPath = config.backupPath ?: return@withContext BackupResult.Failure("Backup path not set.")
val wowDir = File(wowPath) val wowDir = File(wowPath)
val backupDir = File(backupPath) val backupDir = File(backupPath)
if (!wowDir.exists()) return BackupResult.Failure("WoW directory does not exist: $wowPath") if (!wowDir.exists()) return@withContext BackupResult.Failure("WoW directory does not exist: $wowPath")
if (!backupDir.exists()) return BackupResult.Failure("Backup directory does not exist: $backupPath") if (!backupDir.exists()) return@withContext BackupResult.Failure("Backup directory does not exist: $backupPath")
// Collect source folders // Collect source folders
val sourceFolders = buildList { val sourceFolders = buildList {
@ -56,24 +69,25 @@ object BackupEngine {
} }
if (sourceFolders.isEmpty()) { if (sourceFolders.isEmpty()) {
return BackupResult.Failure("No folders found to backup.") return@withContext BackupResult.Failure("No folders found to backup.")
} }
// Count total files // NIO count-only pass — fast, no List<File> allocation
val allFiles = sourceFolders.flatMap { folder -> val totalFiles = sourceFolders.sumOf { folder ->
folder.walkTopDown().filter { it.isFile }.toList() Files.walk(folder.toPath()).use { stream ->
stream.filter { Files.isRegularFile(it) }.count().toInt()
}
} }
val totalFiles = allFiles.size
logger.info { "Starting backup of $totalFiles files from ${sourceFolders.map { it.name }}" } logger.info { "Starting backup of $totalFiles files from ${sourceFolders.map { it.name }}" }
val startTime = System.currentTimeMillis() val startTime = System.currentTimeMillis()
val timestamp = LocalDateTime.now().format(TIMESTAMP_FORMAT) val timestamp = LocalDateTime.now().format(TIMESTAMP_FORMAT)
return try { try {
if (config.compressionEnabled) { if (config.compressionEnabled) {
backupZip(wowDir, sourceFolders, allFiles, backupDir, timestamp, totalFiles) backupZip(wowDir, sourceFolders, backupDir, timestamp, totalFiles)
} else { } else {
backupCopy(wowDir, sourceFolders, allFiles, backupDir, timestamp, totalFiles) backupCopy(wowDir, sourceFolders, backupDir, timestamp, totalFiles)
} }
} catch (e: kotlinx.coroutines.CancellationException) { } catch (e: kotlinx.coroutines.CancellationException) {
logger.info { "Backup cancelled" } logger.info { "Backup cancelled" }
@ -91,70 +105,105 @@ object BackupEngine {
private suspend fun backupCopy( private suspend fun backupCopy(
wowDir: File, wowDir: File,
sourceFolders: List<File>, sourceFolders: List<File>,
allFiles: List<File>,
backupDir: File, backupDir: File,
timestamp: String, timestamp: String,
totalFiles: Int, totalFiles: Int,
): BackupResult { ): BackupResult = coroutineScope {
val destDir = File(backupDir, timestamp) val destDir = File(backupDir, timestamp).toPath()
destDir.mkdirs() Files.createDirectories(destDir)
var completed = 0 val wowPath = wowDir.toPath()
var totalSize = 0L val completed = AtomicInteger(0)
val totalSize = AtomicLong(0)
val channel = Channel<Pair<Path, Path>>(capacity = 256)
// Producer: walk filesystem lazily and send (source, relative) pairs
val producer = launch {
for (folder in sourceFolders) { for (folder in sourceFolders) {
for (file in folder.walkTopDown().filter { it.isFile }) { for (file in folder.walkTopDown().filter { it.isFile }) {
currentCoroutineContext().ensureActive() ensureActive()
val source = file.toPath()
val relative = wowPath.relativize(source)
channel.send(source to relative)
}
}
channel.close()
}
val relativePath = file.relativeTo(wowDir) // Workers: consume from channel and copy files in parallel
val destFile = File(destDir, relativePath.path) val workerCount = Runtime.getRuntime().availableProcessors().coerceIn(2, 8)
destFile.parentFile?.mkdirs() val workers = (1..workerCount).map {
launch {
var lastEmitNs = 0L
for ((source, relative) in channel) {
ensureActive()
_progress.value = BackupProgress(totalFiles, completed, relativePath.path) val dest = destDir.resolve(relative)
Files.createDirectories(dest.parent)
Files.copy(source, dest, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.COPY_ATTRIBUTES)
file.copyTo(destFile, overwrite = true) totalSize.addAndGet(Files.size(source))
totalSize += file.length() val done = completed.incrementAndGet()
completed++
// Throttle progress emissions to ~10/sec per worker
val now = System.nanoTime()
if (now - lastEmitNs >= THROTTLE_INTERVAL_NS || done == totalFiles) {
lastEmitNs = now
_progress.value = BackupProgress(totalFiles, done, relative.toString())
}
}
} }
} }
val elapsed = System.currentTimeMillis() workers.joinAll()
logger.info { "Backup complete: $completed files copied to $destDir" } producer.join()
return BackupResult.Success( val finalCompleted = completed.get()
backupPath = destDir, logger.info { "Backup complete: $finalCompleted files copied to $destDir" }
fileCount = completed,
sizeBytes = totalSize, BackupResult.Success(
durationMs = 0, // caller can measure backupPath = destDir.toFile(),
fileCount = finalCompleted,
sizeBytes = totalSize.get(),
durationMs = 0, // caller measures
) )
} }
private suspend fun backupZip( private suspend fun backupZip(
wowDir: File, wowDir: File,
sourceFolders: List<File>, sourceFolders: List<File>,
allFiles: List<File>,
backupDir: File, backupDir: File,
timestamp: String, timestamp: String,
totalFiles: Int, totalFiles: Int,
): BackupResult { ): BackupResult {
val zipFile = File(backupDir, "$timestamp.zip") val zipFile = File(backupDir, "$timestamp.zip")
val wowPath = wowDir.toPath()
var completed = 0 var completed = 0
var lastEmitNs = 0L
ZipOutputStream(FileOutputStream(zipFile).buffered()).use { zos -> ZipOutputStream(FileOutputStream(zipFile).buffered()).use { zos ->
for (folder in sourceFolders) { for (folder in sourceFolders) {
for (file in folder.walkTopDown().filter { it.isFile }) { for (file in folder.walkTopDown().filter { it.isFile }) {
currentCoroutineContext().ensureActive() currentCoroutineContext().ensureActive()
val relativePath = file.relativeTo(wowDir).path val source = file.toPath()
_progress.value = BackupProgress(totalFiles, completed, relativePath) val relativePath = wowPath.relativize(source).toString()
val entry = ZipEntry(relativePath) val entry = ZipEntry(relativePath)
entry.time = file.lastModified() entry.time = Files.getLastModifiedTime(source).toMillis()
zos.putNextEntry(entry) zos.putNextEntry(entry)
file.inputStream().buffered().use { it.copyTo(zos) } Files.newInputStream(source).buffered().use { it.copyTo(zos) }
zos.closeEntry() zos.closeEntry()
completed++ completed++
// Throttle progress emissions to ~10/sec
val now = System.nanoTime()
if (now - lastEmitNs >= THROTTLE_INTERVAL_NS || completed == totalFiles) {
lastEmitNs = now
_progress.value = BackupProgress(totalFiles, completed, relativePath)
}
} }
} }
} }

View file

@ -8,12 +8,14 @@ import java.time.format.DateTimeParseException
private val logger = KotlinLogging.logger {} private val logger = KotlinLogging.logger {}
data class BackupEntry( class BackupEntry(
val path: File, val path: File,
val timestamp: LocalDateTime, val timestamp: LocalDateTime,
val isCompressed: Boolean, val isCompressed: Boolean,
val sizeBytes: Long, private val sizeComputer: () -> Long,
) ) {
val sizeBytes: Long by lazy { sizeComputer() }
}
object BackupHistory { object BackupHistory {
@ -75,17 +77,15 @@ object BackupHistory {
javaTimestamp.hour, javaTimestamp.minute, javaTimestamp.second, javaTimestamp.hour, javaTimestamp.minute, javaTimestamp.second,
) )
val size = if (isZip) {
file.length()
} else {
file.walkTopDown().filter { it.isFile }.sumOf { it.length() }
}
return BackupEntry( return BackupEntry(
path = file, path = file,
timestamp = timestamp, timestamp = timestamp,
isCompressed = isZip, isCompressed = isZip,
sizeBytes = size, sizeComputer = if (isZip) {
{ file.length() }
} else {
{ file.walkTopDown().filter { it.isFile }.sumOf { it.length() } }
},
) )
} }
} }

View file

@ -11,7 +11,7 @@ data class AppConfig(
val backupHistoryCount: Int = 5, val backupHistoryCount: Int = 5,
val forceBackupWhileRunning: Boolean = false, val forceBackupWhileRunning: Boolean = false,
val backupWtf: Boolean = true, val backupWtf: Boolean = true,
val backupInterface: Boolean = true, val backupInterface: Boolean = false,
val compressionEnabled: Boolean = false, val compressionEnabled: Boolean = false,
val notificationsEnabled: Boolean = true, val notificationsEnabled: Boolean = true,
val runAtStartup: Boolean = false, val runAtStartup: Boolean = false,

View file

@ -0,0 +1,18 @@
package com.rukira.wowbackup.platform
import io.github.oshai.kotlinlogging.KotlinLogging
import java.awt.Desktop
import java.io.File
private val logger = KotlinLogging.logger {}
object DesktopActions {
fun openFolder(path: File) {
try {
Desktop.getDesktop().open(path)
} catch (e: Exception) {
logger.error(e) { "Failed to open folder: $path" }
}
}
}

View file

@ -158,7 +158,7 @@ fun ConfigScreen(
checked = config.backupInterface, checked = config.backupInterface,
onCheckedChange = { viewModel.updateBackupInterface(it) }, onCheckedChange = { viewModel.updateBackupInterface(it) },
label = "Interface", label = "Interface",
description = "Contains installed addons and their configuration.", description = "Contains installed addons.",
) )
// === Options === // === Options ===

View file

@ -0,0 +1,215 @@
package com.rukira.wowbackup.ui.status
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.rukira.wowbackup.backup.BackupScheduler
import com.rukira.wowbackup.platform.DesktopActions
import java.io.File
@Composable
fun StatusScreen(
viewModel: StatusViewModel,
onNavigateToConfig: () -> Unit,
onNavigateToRestore: () -> Unit,
) {
val uiState by viewModel.state.collectAsState()
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text("Status", style = MaterialTheme.typography.headlineMedium)
// Missing config warning
if (!uiState.isConfigured) {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
),
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
"Configuration incomplete",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onErrorContainer,
)
Text(
"Set up WoW Backup to get started.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
)
Spacer(Modifier.height(8.dp))
Button(onClick = onNavigateToConfig) {
Text("Open Settings")
}
}
}
}
// Backup progress (shown when running)
if (uiState.isBackupRunning) {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
),
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
"Backup in progress...",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
Spacer(Modifier.height(8.dp))
val progress = uiState.backupProgress
if (progress != null && progress.totalFiles > 0) {
LinearProgressIndicator(
progress = { progress.completedFiles.toFloat() / progress.totalFiles },
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(4.dp))
Text(
"${progress.completedFiles} / ${progress.totalFiles} files",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
Text(
progress.currentFile,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer,
maxLines = 1,
)
} else {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
}
}
}
// Last backup card
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text("Last backup", style = MaterialTheme.typography.titleSmall)
Spacer(Modifier.height(4.dp))
if (uiState.lastBackupTime != null) {
Text(uiState.lastBackupTime!!, style = MaterialTheme.typography.bodyLarge)
Spacer(Modifier.height(4.dp))
val statusColor = if (uiState.lastBackupSuccess == true) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.error
}
Text(
uiState.lastBackupStatus ?: "",
style = MaterialTheme.typography.bodyMedium,
color = statusColor,
)
} else {
Text(
"Never",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
// Next backup card
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text("Next backup", style = MaterialTheme.typography.titleSmall)
Spacer(Modifier.height(4.dp))
if (uiState.nextBackupTime != null) {
Text(uiState.nextBackupTime!!, style = MaterialTheme.typography.bodyLarge)
} else {
Text(
"Not scheduled",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (uiState.isWoWRunning) {
Spacer(Modifier.height(4.dp))
val note = if (uiState.forceBackupEnabled) {
"WoW is running (force backup enabled)"
} else {
"WoW is running — backup will be skipped"
}
Text(
note,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.tertiary,
)
}
}
}
// Backup count
if (uiState.totalBackups > 0) {
Text(
"${uiState.totalBackups} backup(s) stored",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Action buttons
Spacer(Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Button(
onClick = { BackupScheduler.triggerBackupNow() },
enabled = !uiState.isBackupRunning && uiState.isConfigured,
) {
Text("Backup Now")
}
OutlinedButton(onClick = onNavigateToConfig) {
Text("Settings")
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedButton(
onClick = {
uiState.backupPath?.let { DesktopActions.openFolder(File(it)) }
},
enabled = uiState.backupPath != null,
) {
Text("Open Folder")
}
OutlinedButton(onClick = onNavigateToRestore, enabled = false) {
Text("Restore (Coming Soon)")
}
}
}
}

View file

@ -0,0 +1,110 @@
package com.rukira.wowbackup.ui.status
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.rukira.wowbackup.backup.BackupHistory
import com.rukira.wowbackup.backup.BackupProgress
import com.rukira.wowbackup.backup.BackupResult
import com.rukira.wowbackup.backup.BackupScheduler
import com.rukira.wowbackup.config.ConfigManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import java.io.File
import kotlin.time.Clock
data class StatusUiState(
val isConfigured: Boolean = false,
val lastBackupTime: String? = null,
val lastBackupSuccess: Boolean? = null,
val lastBackupStatus: String? = null,
val nextBackupTime: String? = null,
val isWoWRunning: Boolean = false,
val forceBackupEnabled: Boolean = false,
val isBackupRunning: Boolean = false,
val backupProgress: BackupProgress? = null,
val totalBackups: Int = 0,
val backupPath: String? = null,
)
@OptIn(kotlin.time.ExperimentalTime::class)
class StatusViewModel : ViewModel() {
// Only recomputes when backupPath or lastBackupResult changes — not on every progress tick
private val totalBackups = combine(
ConfigManager.config.map { it.backupPath }.distinctUntilChanged(),
BackupScheduler.state.map { it.lastBackupResult }.distinctUntilChanged(),
) { backupPath, _ ->
if (backupPath != null) {
BackupHistory.listBackups(File(backupPath)).size
} else {
0
}
}.flowOn(Dispatchers.IO).stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
val state = combine(
ConfigManager.config,
BackupScheduler.state,
totalBackups,
) { config, scheduler, backupCount ->
StatusUiState(
isConfigured = config.isConfigured,
lastBackupTime = scheduler.lastBackupTime?.let { formatRelativeTime(it) },
lastBackupSuccess = scheduler.lastBackupResult?.let { it is BackupResult.Success },
lastBackupStatus = formatBackupResult(scheduler.lastBackupResult),
nextBackupTime = scheduler.nextBackupTime?.let { formatNextTime(it) },
isWoWRunning = scheduler.isWoWRunning,
forceBackupEnabled = config.forceBackupWhileRunning,
isBackupRunning = scheduler.isRunning,
backupProgress = scheduler.currentProgress,
totalBackups = backupCount,
backupPath = config.backupPath,
)
}.flowOn(Dispatchers.IO).stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), StatusUiState())
private fun formatBackupResult(result: BackupResult?): String? = when (result) {
is BackupResult.Success -> {
val sizeMb = "%.1f MB".format(result.sizeBytes / (1024.0 * 1024.0))
"Success (${result.fileCount} files, $sizeMb)"
}
is BackupResult.Failure -> "Failed: ${result.reason}"
null -> null
}
@OptIn(kotlin.time.ExperimentalTime::class)
private fun formatRelativeTime(time: LocalDateTime): String {
val tz = TimeZone.currentSystemDefault()
val now = Clock.System.now().toLocalDateTime(tz)
val hoursDiff = (now.date.toEpochDays() - time.date.toEpochDays()) * 24L +
(now.hour - time.hour)
return when {
hoursDiff < 1 -> "Less than an hour ago"
hoursDiff < 24 -> "${hoursDiff}h ago"
hoursDiff < 48 -> "Yesterday"
else -> "${hoursDiff / 24} days ago"
}
}
private fun formatNextTime(time: LocalDateTime): String {
val tz = TimeZone.currentSystemDefault()
@OptIn(kotlin.time.ExperimentalTime::class)
val now = Clock.System.now().toLocalDateTime(tz)
val timeStr = "%02d:%02d".format(time.hour, time.minute)
return when {
time.date == now.date -> "Today at $timeStr"
time.date.toEpochDays() - now.date.toEpochDays() == 1L -> "Tomorrow at $timeStr"
else -> "${time.date} at $timeStr"
}
}
}