Improve startup UX with placeholders and prefetch

This commit is contained in:
Micha
2025-11-19 23:28:12 +01:00
parent 562023519a
commit 6b8d458605
4 changed files with 150 additions and 31 deletions

View File

@@ -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))
}
}

View File

@@ -103,16 +103,26 @@ struct MainView: View {
} }
} }
.onAppear { .onAppear {
let initialID: UUID?
if let storedID = UserDefaults.standard.string(forKey: "selectedServerID"), if let storedID = UserDefaults.standard.string(forKey: "selectedServerID"),
let uuid = UUID(uuidString: storedID), let uuid = UUID(uuidString: storedID),
servers.contains(where: { $0.id == uuid }) { servers.contains(where: { $0.id == uuid }) {
print("✅ [MainView] Restored selected server \(uuid)") print("✅ [MainView] Restored selected server \(uuid)")
selectedServerID = uuid initialID = uuid
} else if selectedServerID == nil, let first = servers.first { } else if let first = servers.first {
print("✅ [MainView] Selecting first server \(first.hostname)") print("✅ [MainView] Selecting first server \(first.hostname)")
selectedServerID = first.id initialID = first.id
} else { } else {
print(" [MainView] No stored selection") print(" [MainView] No stored selection")
initialID = nil
}
selectedServerID = initialID
if let initialID {
Task {
await prefetchOtherServers(activeID: initialID)
}
} }
pingAllServers() pingAllServers()
pingTimer = Timer.scheduledTimer(withTimeInterval: 10.0, repeats: true) { _ in pingTimer = Timer.scheduledTimer(withTimeInterval: 10.0, repeats: true) { _ in
@@ -158,6 +168,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) { private func moveServer(from source: IndexSet, to destination: Int) {
servers.move(fromOffsets: source, toOffset: destination) servers.move(fromOffsets: source, toOffset: destination)
saveServerOrder() saveServerOrder()

View File

@@ -17,6 +17,7 @@ struct TableRowView<Label: View, Value: View>: View {
HStack(alignment: .top) { HStack(alignment: .top) {
label() label()
.frame(width: 180, alignment: .leading) .frame(width: 180, alignment: .leading)
.unredacted()
value() value()
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)

View File

@@ -12,6 +12,10 @@ struct ServerDetailView: View {
var isFetching: Bool var isFetching: Bool
@AppStorage("showIntervalIndicator") private var showIntervalIndicator: Bool = true @AppStorage("showIntervalIndicator") private var showIntervalIndicator: Bool = true
private var showPlaceholder: Bool {
server.info == nil
}
@State private var progress: Double = 0 @State private var progress: Double = 0
let timer = Timer.publish(every: 1.0 / 60.0, on: .main, in: .common).autoconnect() let timer = Timer.publish(every: 1.0 / 60.0, on: .main, in: .common).autoconnect()
@@ -24,38 +28,34 @@ struct ServerDetailView: View {
.frame(height: 2) .frame(height: 2)
} }
if server.info == nil {
ProgressView("Fetching server info...")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ZStack(alignment: .topTrailing) { ZStack(alignment: .topTrailing) {
VStack(spacing: 0) { VStack(spacing: 0) {
Spacer().frame(height: 6) Spacer().frame(height: 6)
TabView { TabView {
GeneralView(server: $server) GeneralView(server: resolvedBinding)
.tabItem { .tabItem {
Text("General") Text("General").unredacted()
} }
ResourcesView(server: $server) ResourcesView(server: resolvedBinding)
.tabItem { .tabItem {
Text("Resources") Text("Resources").unredacted()
} }
ServicesView(server: $server) ServicesView(server: resolvedBinding)
.tabItem { .tabItem {
Text("Services") Text("Services").unredacted()
} }
} }
.redacted(reason: showPlaceholder ? .placeholder : [])
.shimmering(active: showPlaceholder)
} }
if isFetching { if showPlaceholder || isFetching {
ProgressView() LoadingBadge()
.scaleEffect(0.5)
.padding() .padding()
} }
} }
.padding(0) .padding(0)
} }
}
.onReceive(timer) { _ in .onReceive(timer) { _ in
guard showIntervalIndicator else { return } guard showIntervalIndicator else { return }
withAnimation(.linear(duration: 1.0 / 60.0)) { withAnimation(.linear(duration: 1.0 / 60.0)) {
@@ -64,6 +64,17 @@ struct ServerDetailView: View {
} }
} }
} }
private var resolvedBinding: Binding<Server> {
if showPlaceholder {
return .constant(placeholderServer())
}
return $server
}
private func placeholderServer() -> Server {
Server(id: server.id, hostname: server.hostname, info: .placeholder, pingable: server.pingable)
}
} }
#Preview { #Preview {
@@ -72,3 +83,16 @@ struct ServerDetailView: View {
isFetching: false 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())
}
}