package com.syncflow.ui.addpair import android.content.Context import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.syncflow.data.db.CloudAccountDao import com.syncflow.data.db.SyncFileStateDao import com.syncflow.data.db.SyncPairDao import com.syncflow.data.db.entities.CloudAccountEntity import com.syncflow.data.db.entities.SyncPairEntity import com.syncflow.domain.model.* import com.syncflow.worker.FileWatchService import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import javax.inject.Inject data class AddPairUiState( // ── Identity ───────────────────────────────────────────────────────────── val name: String = "", // ── Folders ────────────────────────────────────────────────────────────── val localPath: String = "", val remotePath: String = "", val selectedAccountId: Long = -1L, val accounts: List = emptyList(), // ── Sync type ──────────────────────────────────────────────────────────── val syncDirection: SyncDirection = SyncDirection.TWO_WAY, val conflictStrategy: ConflictStrategy = ConflictStrategy.KEEP_NEWEST, val deleteBehavior: DeleteBehavior = recommendedDeleteBehavior(SyncDirection.TWO_WAY), // True once the user explicitly picks a deletion behaviour, so changing direction stops // auto-overriding their choice. val deleteBehaviorTouched: Boolean = false, val recursive: Boolean = true, // ── Schedule ───────────────────────────────────────────────────────────── val scheduleType: ScheduleType = ScheduleType.INTERVAL, val intervalMinutes: Int = 30, val dailyTime: String = "02:00", val weekdays: Int = 0b1111111, // ── Constraints ────────────────────────────────────────────────────────── val wifiOnly: Boolean = true, val wifiSsid: String = "", val chargingOnly: Boolean = false, val minBatteryPct: Int = 0, // ── File filters ───────────────────────────────────────────────────────── val excludePatterns: String = ".DS_Store\n*.tmp\n.nomedia\nThumbs.db", val includeExtensions: String = "", val excludeExtensions: String = "", val skipHiddenFiles: Boolean = true, val minFileSizeKb: Long = 0L, val maxFileSizeKb: Long = 0L, // ── Notifications ──────────────────────────────────────────────────────── val notifyOnComplete: Boolean = false, val notifyOnError: Boolean = true, // ── Form state ─────────────────────────────────────────────────────────── val isSaving: Boolean = false, val error: String? = null, val done: Boolean = false, ) /** * Safe default deletion behaviour for a given direction. One-way backups must NOT propagate a * local deletion to the cloud (the whole point of a backup), so they default to KEEP; two-way * sync defaults to MIRROR. The user can always override — all three options stay selectable. */ internal fun recommendedDeleteBehavior(direction: SyncDirection): DeleteBehavior = when (direction) { SyncDirection.UPLOAD_ONLY, SyncDirection.DOWNLOAD_ONLY -> DeleteBehavior.KEEP SyncDirection.TWO_WAY -> DeleteBehavior.MIRROR } @HiltViewModel class AddPairViewModel @Inject constructor( private val syncPairDao: SyncPairDao, private val fileStateDao: SyncFileStateDao, private val accountDao: CloudAccountDao, @ApplicationContext private val context: Context, savedState: SavedStateHandle, ) : ViewModel() { private val editPairId = savedState.get("pairId").takeIf { it != -1L } private val _state = MutableStateFlow(AddPairUiState()) val state = _state.asStateFlow() init { viewModelScope.launch { accountDao.observeAll().collect { accounts -> _state.update { s -> s.copy( accounts = accounts, selectedAccountId = if (s.selectedAccountId == -1L) accounts.firstOrNull()?.id ?: -1L else s.selectedAccountId, ) } } } editPairId?.let { id -> viewModelScope.launch { syncPairDao.getById(id)?.let { pair -> _state.update { _ -> AddPairUiState( name = pair.name, localPath = pair.localPath, remotePath = pair.remotePath, selectedAccountId = pair.accountId, syncDirection = pair.syncDirection, conflictStrategy = pair.conflictStrategy, deleteBehavior = pair.deleteBehavior, deleteBehaviorTouched = true, // preserve the saved choice when editing recursive = pair.recursive, scheduleType = pair.scheduleType, intervalMinutes = pair.scheduleIntervalMinutes, dailyTime = pair.scheduleDailyTime ?: "02:00", weekdays = pair.scheduleWeekdays, wifiOnly = pair.wifiOnly, wifiSsid = pair.wifiSsid, chargingOnly = pair.chargingOnly, minBatteryPct = pair.minBatteryPct, excludePatterns = pair.excludePatterns, includeExtensions = pair.includeExtensions, excludeExtensions = pair.excludeExtensions, skipHiddenFiles = pair.skipHiddenFiles, minFileSizeKb = pair.minFileSizeKb, maxFileSizeKb = pair.maxFileSizeKb, notifyOnComplete = pair.notifyOnComplete, notifyOnError = pair.notifyOnError, ) } } } } } fun update(transform: AddPairUiState.() -> AddPairUiState) = _state.update(transform) /** Changing direction re-applies the safe deletion default unless the user already chose one. */ fun setDirection(direction: SyncDirection) = _state.update { s -> s.copy( syncDirection = direction, deleteBehavior = if (s.deleteBehaviorTouched) s.deleteBehavior else recommendedDeleteBehavior(direction), ) } fun setDeleteBehavior(behavior: DeleteBehavior) = _state.update { it.copy(deleteBehavior = behavior, deleteBehaviorTouched = true) } fun save() { val s = _state.value val errors = buildList { if (s.name.isBlank()) add("Name is required") if (s.localPath.isBlank()) add("Local folder is required") if (s.remotePath.isBlank()) add("Remote folder is required") if (s.selectedAccountId == -1L) add("Select a cloud account") if (s.scheduleType == ScheduleType.INTERVAL && s.intervalMinutes < 15) add("Minimum interval is 15 minutes") } if (errors.isNotEmpty()) { _state.update { it.copy(error = errors.first()) }; return } viewModelScope.launch { _state.update { it.copy(isSaving = true, error = null) } runCatching { val entity = SyncPairEntity( id = editPairId ?: 0L, name = s.name, localPath = s.localPath, remotePath = s.remotePath, accountId = s.selectedAccountId, syncDirection = s.syncDirection, conflictStrategy = s.conflictStrategy, deleteBehavior = s.deleteBehavior, recursive = s.recursive, scheduleType = s.scheduleType, scheduleIntervalMinutes = s.intervalMinutes, scheduleDailyTime = if (s.scheduleType == ScheduleType.DAILY || s.scheduleType == ScheduleType.WEEKLY) s.dailyTime else null, scheduleWeekdays = s.weekdays, wifiOnly = s.wifiOnly, wifiSsid = s.wifiSsid, chargingOnly = s.chargingOnly, minBatteryPct = s.minBatteryPct, excludePatterns = s.excludePatterns, includeExtensions = s.includeExtensions, excludeExtensions = s.excludeExtensions, skipHiddenFiles = s.skipHiddenFiles, minFileSizeKb = s.minFileSizeKb, maxFileSizeKb = s.maxFileSizeKb, notifyOnComplete = s.notifyOnComplete, notifyOnError = s.notifyOnError, isEnabled = true, lastSyncAt = null, lastSyncResult = SyncStatus.IDLE, pendingConflicts = 0, ) if (editPairId == null) { syncPairDao.insert(entity) } else { val existing = syncPairDao.getById(editPairId) syncPairDao.update(entity) // If local or remote folder changed, old file-state records no longer // correspond to any real path — wipe them so the next sync starts fresh // instead of trying to delete/re-upload stale paths. if (existing != null && (existing.localPath != entity.localPath || existing.remotePath != entity.remotePath) ) { fileStateDao.deleteForPair(editPairId) } } } .onSuccess { if (s.scheduleType == ScheduleType.ON_CHANGE) FileWatchService.start(context) _state.update { it.copy(done = true) } } .onFailure { e -> _state.update { it.copy(isSaving = false, error = e.message) } } } } }