diff --git a/Sources/Extensions/View+Shimmer.swift b/Sources/Extensions/View+Shimmer.swift new file mode 100644 index 0000000..89ee7bc --- /dev/null +++ b/Sources/Extensions/View+Shimmer.swift @@ -0,0 +1,51 @@ +import SwiftUI + +struct ShimmerModifier: ViewModifier { + var active: Bool + @State private var phase: CGFloat = -1 + + func body(content: Content) -> some View { + content + .overlay( + shimmer + .mask(content) + .opacity(active ? 1 : 0) + ) + .onAppear { + guard active else { return } + animate() + } + .onChange(of: active) { isActive in + if isActive { + phase = -1 + animate() + } + } + } + + private var shimmer: some View { + LinearGradient( + gradient: Gradient(colors: [ + .clear, + Color.white.opacity(0.6), + .clear + ]), + startPoint: .top, + endPoint: .bottom + ) + .rotationEffect(.degrees(70)) + .offset(x: phase * 250) + } + + private func animate() { + withAnimation(.linear(duration: 1.2).repeatForever(autoreverses: false)) { + phase = 1 + } + } +} + +extension View { + func shimmering(active: Bool) -> some View { + modifier(ShimmerModifier(active: active)) + } +} diff --git a/Sources/Views/MainView.swift b/Sources/Views/MainView.swift index f315158..0ee50b9 100644 --- a/Sources/Views/MainView.swift +++ b/Sources/Views/MainView.swift @@ -103,16 +103,26 @@ struct MainView: View { } } .onAppear { + let initialID: UUID? if let storedID = UserDefaults.standard.string(forKey: "selectedServerID"), let uuid = UUID(uuidString: storedID), servers.contains(where: { $0.id == uuid }) { print("✅ [MainView] Restored selected server \(uuid)") - selectedServerID = uuid - } else if selectedServerID == nil, let first = servers.first { + initialID = uuid + } else if let first = servers.first { print("✅ [MainView] Selecting first server \(first.hostname)") - selectedServerID = first.id + initialID = first.id } else { print("ℹ️ [MainView] No stored selection") + initialID = nil + } + + selectedServerID = initialID + + if let initialID { + Task { + await prefetchOtherServers(activeID: initialID) + } } pingAllServers() pingTimer = Timer.scheduledTimer(withTimeInterval: 10.0, repeats: true) { _ in @@ -157,6 +167,39 @@ struct MainView: View { } } } + + private func prefetchOtherServers(activeID: UUID) async { + let others = servers.filter { $0.id != activeID } + await withTaskGroup(of: Void.self) { group in + for server in others { + group.addTask { + await fetchServerInfoAsync(for: server.id) + } + } + } + } + + private func fetchServerInfoAsync(for id: UUID) async { + guard let server = servers.first(where: { $0.id == id }) else { return } + guard let apiKey = KeychainHelper.loadApiKey(for: server.hostname)?.trimmingCharacters(in: .whitespacesAndNewlines), + !apiKey.isEmpty, + let baseURL = URL(string: "https://\(server.hostname)") + else { return } + + do { + let api = try await APIFactory.detectAndCreateAPI(baseURL: baseURL, apiKey: apiKey) + let info = try await api.fetchServerSummary(apiKey: apiKey) + await MainActor.run { + if let index = servers.firstIndex(where: { $0.id == id }) { + var updated = servers[index] + updated.info = info + servers[index] = updated + } + } + } catch { + print("❌ Prefetch failed for \(server.hostname): \(error)") + } + } private func moveServer(from source: IndexSet, to destination: Int) { servers.move(fromOffsets: source, toOffset: destination) diff --git a/Sources/Views/Rows/TableRowView.swift b/Sources/Views/Rows/TableRowView.swift index aafa070..60ce89c 100644 --- a/Sources/Views/Rows/TableRowView.swift +++ b/Sources/Views/Rows/TableRowView.swift @@ -17,6 +17,7 @@ struct TableRowView: View { HStack(alignment: .top) { label() .frame(width: 180, alignment: .leading) + .unredacted() value() .frame(maxWidth: .infinity, alignment: .leading) diff --git a/Sources/Views/ServerDetailView.swift b/Sources/Views/ServerDetailView.swift index 718aaa0..7c7c6e8 100644 --- a/Sources/Views/ServerDetailView.swift +++ b/Sources/Views/ServerDetailView.swift @@ -12,6 +12,10 @@ struct ServerDetailView: View { var isFetching: Bool @AppStorage("showIntervalIndicator") private var showIntervalIndicator: Bool = true + private var showPlaceholder: Bool { + server.info == nil + } + @State private var progress: Double = 0 let timer = Timer.publish(every: 1.0 / 60.0, on: .main, in: .common).autoconnect() @@ -24,37 +28,33 @@ struct ServerDetailView: View { .frame(height: 2) } - if server.info == nil { - ProgressView("Fetching server info...") - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - ZStack(alignment: .topTrailing) { - VStack(spacing: 0) { - Spacer().frame(height: 6) - TabView { - GeneralView(server: $server) - .tabItem { - Text("General") - } - ResourcesView(server: $server) - .tabItem { - Text("Resources") - } - ServicesView(server: $server) - .tabItem { - Text("Services") - } - } - } - - if isFetching { - ProgressView() - .scaleEffect(0.5) - .padding() + ZStack(alignment: .topTrailing) { + VStack(spacing: 0) { + Spacer().frame(height: 6) + TabView { + GeneralView(server: resolvedBinding) + .tabItem { + Text("General").unredacted() + } + ResourcesView(server: resolvedBinding) + .tabItem { + Text("Resources").unredacted() + } + ServicesView(server: resolvedBinding) + .tabItem { + Text("Services").unredacted() + } } + .redacted(reason: showPlaceholder ? .placeholder : []) + .shimmering(active: showPlaceholder) + } + + if showPlaceholder || isFetching { + LoadingBadge() + .padding() } - .padding(0) } + .padding(0) } .onReceive(timer) { _ in guard showIntervalIndicator else { return } @@ -64,6 +64,17 @@ struct ServerDetailView: View { } } } + + private var resolvedBinding: Binding { + if showPlaceholder { + return .constant(placeholderServer()) + } + return $server + } + + private func placeholderServer() -> Server { + Server(id: server.id, hostname: server.hostname, info: .placeholder, pingable: server.pingable) + } } #Preview { @@ -72,3 +83,16 @@ struct ServerDetailView: View { isFetching: false ) } + +private struct LoadingBadge: View { + var body: some View { + HStack(spacing: 6) { + ProgressView() + .scaleEffect(0.5) + Text("Fetching latest data…") + .font(.caption) + } + .padding(8) + .background(.ultraThinMaterial, in: Capsule()) + } +}