Parent directory

HistoryView.swift

7076 bytes
  1import SwiftUI
  2
  3struct HistoryView: View {
  4    let settings: SettingsStore
  5    @Binding var isSearchVisible: Bool
  6
  7    @Namespace private var glassNamespace
  8    @State private var videos: [Video] = []
  9    @State private var page = 1
 10    @State private var searchText = ""
 11    @State private var isLoading = false
 12    @State private var alertMessage: String?
 13
 14    private var canShowNextPage: Bool {
 15        videos.count == 100
 16    }
 17
 18    var body: some View {
 19        GlassEffectContainer {
 20            List {
 21                    if isLoading && videos.isEmpty {
 22                        ProgressView()
 23                            .padding(32)
 24                    } else if videos.isEmpty {
 25                        ContentUnavailableView(
 26                            "No watched videos",
 27                            systemImage: "clock",
 28                            description: Text("Videos that you mark as watched appear here.")
 29                        )
 30                        .frame(maxWidth: .infinity)
 31                        .padding(.vertical, 80)
 32
 33                        if page > 1 {
 34                            pageControls
 35                        }
 36                    } else if displayedVideos.isEmpty {
 37                        ContentUnavailableView.search(text: searchText)
 38                            .frame(maxWidth: .infinity)
 39                            .padding(.vertical, 80)
 40                    } else {
 41                        ForEach(displayedVideos) { video in
 42                            VideoCard(
 43                                video: video,
 44                                tint: .orange,
 45                                updateProgress: { progress in
 46                                    try await updateProgress(for: video, to: progress)
 47                                },
 48                                glassNamespace: glassNamespace
 49                            )
 50                            .listRowInsets(EdgeInsets(top: 10, leading: 16, bottom: 10, trailing: 16))
 51                            .listRowSeparator(.hidden)
 52                            .listRowBackground(Color.clear)
 53                            .swipeActions(edge: .trailing, allowsFullSwipe: true) {
 54                                Button(role: .destructive) {
 55                                    markUnwatched(video)
 56                                } label: {
 57                                    Label("Mark Unwatched", systemImage: "arrow.uturn.backward")
 58                                }
 59                                .tint(.orange)
 60                            }
 61                        }
 62
 63                        pageControls
 64                    }
 65        }
 66        .listStyle(.plain)
 67            .scrollContentBackground(.hidden)
 68        }
 69        .navigationTitle(isSearchVisible ? "" : "History")
 70        .toolbar {
 71            if isSearchVisible {
 72                ToolbarItem(placement: .topBarLeading) {
 73                    SearchToolbarField(text: $searchText, isPresented: $isSearchVisible)
 74                }
 75            } else {
 76                ToolbarItemGroup(placement: .topBarTrailing) {
 77                    Button {
 78                        Task {
 79                            await reload()
 80                        }
 81                    } label: {
 82                        Image(systemName: "arrow.clockwise")
 83                    }
 84                    .accessibilityLabel("Refresh history")
 85                    .disabled(isLoading)
 86
 87                    NavigationLink {
 88                        SettingsView(settings: settings)
 89                    } label: {
 90                        Image(systemName: "gear")
 91                    }
 92                    .accessibilityLabel("Settings")
 93                }
 94            }
 95        }
 96        .refreshable {
 97            await reload()
 98        }
 99        .task(id: settings.serverURL + settings.token) {
100            page = 1
101            await reload()
102        }
103        .alert(
104            "Could not load history",
105            isPresented: Binding(
106                get: { alertMessage != nil },
107                set: { if !$0 { alertMessage = nil } }
108            )
109        ) {
110            Button("OK", role: .cancel) {}
111        } message: {
112            Text(alertMessage ?? "")
113        }
114    }
115
116    private var pageControls: some View {
117        HStack {
118            Button("Previous") {
119                page -= 1
120                Task {
121                    await reload()
122                }
123            }
124            .buttonStyle(.glass)
125            .disabled(page == 1 || isLoading)
126
127            Spacer()
128
129            Text("Page \(page)")
130                .font(.footnote)
131                .foregroundStyle(.secondary)
132                .padding(.horizontal, 12)
133                .padding(.vertical, 8)
134                .glassEffect(in: .capsule)
135
136            Spacer()
137
138            Button("Next") {
139                page += 1
140                Task {
141                    await reload()
142                }
143            }
144            .buttonStyle(.glass)
145            .disabled(!canShowNextPage || isLoading)
146        }
147    }
148
149    private var displayedVideos: [Video] {
150        guard !searchText.isEmpty else {
151            return videos
152        }
153
154        return videos.filter {
155            $0.title.localizedCaseInsensitiveContains(searchText) ||
156                $0.channelName.localizedCaseInsensitiveContains(searchText)
157        }
158    }
159
160    private func reload() async {
161        guard let configuration = settings.configuration else {
162            return
163        }
164
165        isLoading = true
166        defer { isLoading = false }
167
168        do {
169            videos = try await APIClient().fetchWatchedVideos(page: page, using: configuration)
170        } catch {
171            alertMessage = error.localizedDescription
172        }
173    }
174
175    private func updateProgress(for video: Video, to progress: String) async throws {
176        guard let configuration = settings.configuration else {
177            return
178        }
179
180        let updatedVideo = try await APIClient().updateVideoProgress(
181            videoID: video.videoID,
182            progress: progress,
183            using: configuration
184        )
185        if let index = videos.firstIndex(where: { $0.id == updatedVideo.id }) {
186            videos[index] = updatedVideo
187        }
188    }
189
190    private func markUnwatched(_ video: Video) {
191        guard let configuration = settings.configuration,
192              let index = videos.firstIndex(where: { $0.id == video.id }) else {
193            return
194        }
195
196        withAnimation(.easeInOut) {
197            _ = videos.remove(at: index)
198        }
199
200        Task {
201            do {
202                try await APIClient().markUnwatched(videoID: video.videoID, using: configuration)
203                await reload()
204            } catch {
205                if !videos.contains(where: { $0.id == video.id }) {
206                    withAnimation(.easeInOut) {
207                        videos.insert(video, at: min(index, videos.count))
208                    }
209                }
210                alertMessage = error.localizedDescription
211            }
212        }
213    }
214}