Performance improvements. Backup status screen
This commit is contained in:
parent
171bf78be8
commit
0c8290f16e
8 changed files with 455 additions and 54 deletions
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
for (folder in sourceFolders) {
|
val channel = Channel<Pair<Path, Path>>(capacity = 256)
|
||||||
for (file in folder.walkTopDown().filter { it.isFile }) {
|
|
||||||
currentCoroutineContext().ensureActive()
|
|
||||||
|
|
||||||
val relativePath = file.relativeTo(wowDir)
|
// Producer: walk filesystem lazily and send (source, relative) pairs
|
||||||
val destFile = File(destDir, relativePath.path)
|
val producer = launch {
|
||||||
destFile.parentFile?.mkdirs()
|
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)
|
val dest = destDir.resolve(relative)
|
||||||
totalSize += file.length()
|
Files.createDirectories(dest.parent)
|
||||||
completed++
|
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()
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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() } }
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 ===
|
||||||
|
|
|
||||||
|
|
@ -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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue