diff --git a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/App.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/App.kt index 742271c..e053ea8 100644 --- a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/App.kt +++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/App.kt @@ -15,6 +15,8 @@ import androidx.compose.ui.unit.dp import com.rukira.wowbackup.ui.Screen import com.rukira.wowbackup.ui.config.ConfigScreen import com.rukira.wowbackup.ui.config.ConfigViewModel +import com.rukira.wowbackup.ui.status.StatusScreen +import com.rukira.wowbackup.ui.status.StatusViewModel @Composable fun App( @@ -24,7 +26,14 @@ fun App( MaterialTheme { Surface(modifier = Modifier.fillMaxSize()) { 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 -> { val viewModel = remember { ConfigViewModel() } ConfigScreen( diff --git a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/backup/BackupEngine.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/backup/BackupEngine.kt index 12d50a4..3f3b2e5 100644 --- a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/backup/BackupEngine.kt +++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/backup/BackupEngine.kt @@ -2,20 +2,33 @@ package com.rukira.wowbackup.backup import com.rukira.wowbackup.config.AppConfig 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.ensureActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.io.File 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.format.DateTimeFormatter +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream private val logger = KotlinLogging.logger {} +private const val THROTTLE_INTERVAL_NS = 100_000_000L // 100ms + data class BackupProgress( val totalFiles: Int, val completedFiles: Int, @@ -34,14 +47,14 @@ object BackupEngine { private val _progress = MutableStateFlow(null) val progress: StateFlow = _progress.asStateFlow() - suspend fun runBackup(config: AppConfig): BackupResult { - val wowPath = config.wowInstallPath ?: return BackupResult.Failure("WoW install path not set.") - val backupPath = config.backupPath ?: return BackupResult.Failure("Backup path not set.") + suspend fun runBackup(config: AppConfig): BackupResult = withContext(Dispatchers.IO) { + val wowPath = config.wowInstallPath ?: return@withContext BackupResult.Failure("WoW install path not set.") + val backupPath = config.backupPath ?: return@withContext BackupResult.Failure("Backup path not set.") val wowDir = File(wowPath) val backupDir = File(backupPath) - if (!wowDir.exists()) return BackupResult.Failure("WoW directory does not exist: $wowPath") - if (!backupDir.exists()) return BackupResult.Failure("Backup directory does not exist: $backupPath") + if (!wowDir.exists()) return@withContext BackupResult.Failure("WoW directory does not exist: $wowPath") + if (!backupDir.exists()) return@withContext BackupResult.Failure("Backup directory does not exist: $backupPath") // Collect source folders val sourceFolders = buildList { @@ -56,24 +69,25 @@ object BackupEngine { } if (sourceFolders.isEmpty()) { - return BackupResult.Failure("No folders found to backup.") + return@withContext BackupResult.Failure("No folders found to backup.") } - // Count total files - val allFiles = sourceFolders.flatMap { folder -> - folder.walkTopDown().filter { it.isFile }.toList() + // NIO count-only pass — fast, no List allocation + val totalFiles = sourceFolders.sumOf { folder -> + 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 }}" } val startTime = System.currentTimeMillis() val timestamp = LocalDateTime.now().format(TIMESTAMP_FORMAT) - return try { + try { if (config.compressionEnabled) { - backupZip(wowDir, sourceFolders, allFiles, backupDir, timestamp, totalFiles) + backupZip(wowDir, sourceFolders, backupDir, timestamp, totalFiles) } else { - backupCopy(wowDir, sourceFolders, allFiles, backupDir, timestamp, totalFiles) + backupCopy(wowDir, sourceFolders, backupDir, timestamp, totalFiles) } } catch (e: kotlinx.coroutines.CancellationException) { logger.info { "Backup cancelled" } @@ -91,70 +105,105 @@ object BackupEngine { private suspend fun backupCopy( wowDir: File, sourceFolders: List, - allFiles: List, backupDir: File, timestamp: String, totalFiles: Int, - ): BackupResult { - val destDir = File(backupDir, timestamp) - destDir.mkdirs() + ): BackupResult = coroutineScope { + val destDir = File(backupDir, timestamp).toPath() + Files.createDirectories(destDir) - var completed = 0 - var totalSize = 0L + val wowPath = wowDir.toPath() + val completed = AtomicInteger(0) + val totalSize = AtomicLong(0) - for (folder in sourceFolders) { - for (file in folder.walkTopDown().filter { it.isFile }) { - currentCoroutineContext().ensureActive() + val channel = Channel>(capacity = 256) - val relativePath = file.relativeTo(wowDir) - val destFile = File(destDir, relativePath.path) - destFile.parentFile?.mkdirs() + // Producer: walk filesystem lazily and send (source, relative) pairs + val producer = launch { + for (folder in sourceFolders) { + for (file in folder.walkTopDown().filter { it.isFile }) { + ensureActive() + val source = file.toPath() + val relative = wowPath.relativize(source) + channel.send(source to relative) + } + } + channel.close() + } - _progress.value = BackupProgress(totalFiles, completed, relativePath.path) + // Workers: consume from channel and copy files in parallel + val workerCount = Runtime.getRuntime().availableProcessors().coerceIn(2, 8) + val workers = (1..workerCount).map { + launch { + var lastEmitNs = 0L + for ((source, relative) in channel) { + ensureActive() - file.copyTo(destFile, overwrite = true) - totalSize += file.length() - completed++ + val dest = destDir.resolve(relative) + Files.createDirectories(dest.parent) + Files.copy(source, dest, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.COPY_ATTRIBUTES) + + totalSize.addAndGet(Files.size(source)) + val done = completed.incrementAndGet() + + // 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() - logger.info { "Backup complete: $completed files copied to $destDir" } + workers.joinAll() + producer.join() - return BackupResult.Success( - backupPath = destDir, - fileCount = completed, - sizeBytes = totalSize, - durationMs = 0, // caller can measure + val finalCompleted = completed.get() + logger.info { "Backup complete: $finalCompleted files copied to $destDir" } + + BackupResult.Success( + backupPath = destDir.toFile(), + fileCount = finalCompleted, + sizeBytes = totalSize.get(), + durationMs = 0, // caller measures ) } private suspend fun backupZip( wowDir: File, sourceFolders: List, - allFiles: List, backupDir: File, timestamp: String, totalFiles: Int, ): BackupResult { val zipFile = File(backupDir, "$timestamp.zip") + val wowPath = wowDir.toPath() var completed = 0 + var lastEmitNs = 0L ZipOutputStream(FileOutputStream(zipFile).buffered()).use { zos -> for (folder in sourceFolders) { for (file in folder.walkTopDown().filter { it.isFile }) { currentCoroutineContext().ensureActive() - val relativePath = file.relativeTo(wowDir).path - _progress.value = BackupProgress(totalFiles, completed, relativePath) + val source = file.toPath() + val relativePath = wowPath.relativize(source).toString() val entry = ZipEntry(relativePath) - entry.time = file.lastModified() + entry.time = Files.getLastModifiedTime(source).toMillis() zos.putNextEntry(entry) - file.inputStream().buffered().use { it.copyTo(zos) } + Files.newInputStream(source).buffered().use { it.copyTo(zos) } zos.closeEntry() 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) + } } } } diff --git a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/backup/BackupHistory.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/backup/BackupHistory.kt index 77fcb26..a06ceff 100644 --- a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/backup/BackupHistory.kt +++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/backup/BackupHistory.kt @@ -8,12 +8,14 @@ import java.time.format.DateTimeParseException private val logger = KotlinLogging.logger {} -data class BackupEntry( +class BackupEntry( val path: File, val timestamp: LocalDateTime, val isCompressed: Boolean, - val sizeBytes: Long, -) + private val sizeComputer: () -> Long, +) { + val sizeBytes: Long by lazy { sizeComputer() } +} object BackupHistory { @@ -75,17 +77,15 @@ object BackupHistory { javaTimestamp.hour, javaTimestamp.minute, javaTimestamp.second, ) - val size = if (isZip) { - file.length() - } else { - file.walkTopDown().filter { it.isFile }.sumOf { it.length() } - } - return BackupEntry( path = file, timestamp = timestamp, isCompressed = isZip, - sizeBytes = size, + sizeComputer = if (isZip) { + { file.length() } + } else { + { file.walkTopDown().filter { it.isFile }.sumOf { it.length() } } + }, ) } } diff --git a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/config/AppConfig.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/config/AppConfig.kt index b68591a..65ddf1d 100644 --- a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/config/AppConfig.kt +++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/config/AppConfig.kt @@ -11,7 +11,7 @@ data class AppConfig( val backupHistoryCount: Int = 5, val forceBackupWhileRunning: Boolean = false, val backupWtf: Boolean = true, - val backupInterface: Boolean = true, + val backupInterface: Boolean = false, val compressionEnabled: Boolean = false, val notificationsEnabled: Boolean = true, val runAtStartup: Boolean = false, diff --git a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/platform/DesktopActions.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/platform/DesktopActions.kt new file mode 100644 index 0000000..5c41dcb --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/platform/DesktopActions.kt @@ -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" } + } + } +} diff --git a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/config/ConfigScreen.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/config/ConfigScreen.kt index e32a3d0..a5ef35a 100644 --- a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/config/ConfigScreen.kt +++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/config/ConfigScreen.kt @@ -158,7 +158,7 @@ fun ConfigScreen( checked = config.backupInterface, onCheckedChange = { viewModel.updateBackupInterface(it) }, label = "Interface", - description = "Contains installed addons and their configuration.", + description = "Contains installed addons.", ) // === Options === diff --git a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/status/StatusScreen.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/status/StatusScreen.kt new file mode 100644 index 0000000..e26d6a6 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/status/StatusScreen.kt @@ -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)") + } + } + } +} diff --git a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/status/StatusViewModel.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/status/StatusViewModel.kt new file mode 100644 index 0000000..08a3fee --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/status/StatusViewModel.kt @@ -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" + } + } +}