ReadKeep/readeck/UI/AppViewModel.swift
Ilyas Hallak 03cd32dd4d fix: Prevent sync during onboarding and improve Share Extension UX
- Add guard checks in AppViewModel to prevent sync when hasFinishedSetup is false
- Share Extension now shows friendly UI when app not configured or session expired
- Check token existence via KeychainHelper instead of attempting API calls
- Improve 401 error messages: "Session expired. Please log in via the Readeck app."
- Replace #if DEBUG with BuildEnvironment.isDebugBuild for runtime checks
- Enable Debug Menu in both DEBUG and TestFlight builds
- Replace print() statements with Logger.sync/Logger.viewModel
2025-12-19 21:34:22 +01:00

162 lines
5.2 KiB
Swift

//
// AppViewModel.swift
// readeck
//
// Created by Ilyas Hallak on 27.08.25.
//
import Foundation
import SwiftUI
import Combine
@MainActor
@Observable
class AppViewModel {
private let settingsRepository = SettingsRepository()
private let factory: UseCaseFactory
private let syncTagsUseCase: PSyncTagsUseCase
let networkMonitorUseCase: PNetworkMonitorUseCase
var hasFinishedSetup: Bool = true
var isServerReachable: Bool = false
var isNetworkConnected: Bool = true
private var lastAppStartTagSyncTime: Date?
private var cancellables = Set<AnyCancellable>()
init(factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
self.factory = factory
self.syncTagsUseCase = factory.makeSyncTagsUseCase()
self.networkMonitorUseCase = factory.makeNetworkMonitorUseCase()
setupNotificationObservers()
setupNetworkMonitoring()
loadSetupStatus()
}
private func setupNotificationObservers() {
NotificationCenter.default.addObserver(
forName: .unauthorizedAPIResponse,
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor in
await self?.handleUnauthorizedResponse()
}
}
NotificationCenter.default.addObserver(
forName: .setupStatusChanged,
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor in
self?.loadSetupStatus()
}
}
}
private func handleUnauthorizedResponse() async {
Logger.viewModel.info("Handling 401 Unauthorized - logging out user")
do {
try await factory.makeLogoutUseCase().execute()
loadSetupStatus()
Logger.viewModel.info("User successfully logged out due to 401 error")
} catch {
Logger.viewModel.error("Error during logout: \(error.localizedDescription)")
}
}
private func setupNetworkMonitoring() {
// Start monitoring network status
networkMonitorUseCase.startMonitoring()
// Bind network status to our published property
networkMonitorUseCase.isConnected
.receive(on: DispatchQueue.main)
.assign(to: \.isNetworkConnected, on: self)
.store(in: &cancellables)
}
func bindNetworkStatus(to appSettings: AppSettings) {
// Bind network status to AppSettings for global access
networkMonitorUseCase.isConnected
.receive(on: DispatchQueue.main)
.sink { isConnected in
Logger.viewModel.info("🌐 Network status changed: \(isConnected ? "Connected" : "Disconnected")")
appSettings.isNetworkConnected = isConnected
}
.store(in: &cancellables)
}
private func loadSetupStatus() {
hasFinishedSetup = settingsRepository.hasFinishedSetup
}
func onAppResume() async {
await checkServerReachability()
await syncTagsOnAppStart()
syncOfflineArticlesIfNeeded()
}
private func checkServerReachability() async {
isServerReachable = await factory.makeCheckServerReachabilityUseCase().execute()
}
private func syncTagsOnAppStart() async {
// Don't sync if onboarding is not complete (no token/endpoint available)
guard settingsRepository.hasFinishedSetup else {
Logger.sync.debug("Skipping tag sync - onboarding not completed")
return
}
let now = Date()
// Check if last sync was less than 2 minutes ago
if let lastSync = lastAppStartTagSyncTime,
now.timeIntervalSince(lastSync) < 120 {
Logger.sync.debug("Skipping tag sync - last sync was less than 2 minutes ago")
return
}
// Sync tags from server to Core Data
Logger.sync.info("Syncing tags on app start")
try? await syncTagsUseCase.execute()
lastAppStartTagSyncTime = now
}
private func syncOfflineArticlesIfNeeded() {
// Don't sync if onboarding is not complete (no token/endpoint available)
guard settingsRepository.hasFinishedSetup else {
Logger.sync.debug("Skipping offline sync - onboarding not completed")
return
}
// Run offline sync in background without blocking app start
Task.detached(priority: .background) { [weak self] in
guard let self = self else { return }
do {
let settings = try await self.settingsRepository.loadOfflineSettings()
guard settings.shouldSyncOnAppStart else {
Logger.sync.debug("Offline sync not needed (disabled or synced recently)")
return
}
Logger.sync.info("Auto-sync triggered on app start")
let offlineCacheSyncUseCase = self.factory.makeOfflineCacheSyncUseCase()
await offlineCacheSyncUseCase.syncOfflineArticles(settings: settings)
} catch {
Logger.sync.error("Failed to load offline settings for auto-sync: \(error.localizedDescription)")
}
}
}
deinit {
NotificationCenter.default.removeObserver(self)
}
}