Use transmission for snackbar, fix issue with ownership of currently viewed item

The list object of the root library view had ownership of the
currently selected item, so object modifications that removed
the item from the current library list (like move or archive)
could cause the object to be released and the current screen
to continue operating on an invalid object.
This commit is contained in:
Jackson Harper
2024-02-05 14:02:31 +08:00
parent 03766734e5
commit 85caaa7ea3
8 changed files with 106 additions and 66 deletions

View File

@ -24,9 +24,9 @@
@State var showLabelsModal = false @State var showLabelsModal = false
@State var showNotebookView = false @State var showNotebookView = false
@State var showOperationToast = false @State var showSnackbar = false
@State var operationStatus: OperationStatus = .none @State var operationStatus: OperationStatus = .none
@State var operationMessage: String? @State var snackbarMessage: String?
var playPauseButtonImage: String { var playPauseButtonImage: String {
switch audioController.state { switch audioController.state {
@ -384,8 +384,8 @@
func playerContent(_: LinkedItemAudioProperties) -> some View { func playerContent(_: LinkedItemAudioProperties) -> some View {
ZStack { ZStack {
WindowLink(level: .alert, transition: .move(edge: .bottom), isPresented: $showOperationToast) { WindowLink(level: .alert, transition: .move(edge: .bottom), isPresented: $showSnackbar) {
OperationToast(operationMessage: $operationMessage, showOperationToast: $showOperationToast, operationStatus: $operationStatus) OperationToast(operationMessage: $snackbarMessage, showOperationToast: $showSnackbar, operationStatus: $operationStatus)
.offset(y: -90) .offset(y: -90)
} label: { } label: {
EmptyView() EmptyView()

View File

@ -29,29 +29,15 @@ struct LibraryItemListNavigationLink: View {
@EnvironmentObject var dataService: DataService @EnvironmentObject var dataService: DataService
@EnvironmentObject var audioController: AudioController @EnvironmentObject var audioController: AudioController
@ObservedObject var item: Models.LibraryItem let item: Models.LibraryItem
@ObservedObject var viewModel: HomeFeedViewModel let viewModel: HomeFeedViewModel
var body: some View { var body: some View {
ZStack { Button(action: {
viewModel.presentItem(item: item)
}, label: {
LibraryItemCard(item: LibraryItemData.make(from: item), viewer: dataService.currentViewer) LibraryItemCard(item: LibraryItemData.make(from: item), viewer: dataService.currentViewer)
PresentationLink( })
transition: PresentationLinkTransition.slide(
options: PresentationLinkTransition.SlideTransitionOptions(edge: .trailing,
options:
PresentationLinkTransition.Options(
modalPresentationCapturesStatusBarAppearance: true
))),
destination: {
LinkItemDetailView(
linkedItemObjectID: item.objectID,
isPDF: item.isPDF
)
}, label: {
EmptyView()
}
)
}
} }
} }

View File

@ -461,6 +461,15 @@ struct AnimatingCellHeight: AnimatableModifier {
@ObservedObject var viewModel: HomeFeedViewModel @ObservedObject var viewModel: HomeFeedViewModel
let showFeatureCards: Bool let showFeatureCards: Bool
var slideTransition: PresentationLinkTransition {
PresentationLinkTransition.slide(
options: PresentationLinkTransition.SlideTransitionOptions(edge: .trailing,
options:
PresentationLinkTransition.Options(
modalPresentationCapturesStatusBarAppearance: true
)
))
}
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
@ -481,6 +490,16 @@ struct AnimatingCellHeight: AnimatableModifier {
} }
) )
} }
PresentationLink(transition: slideTransition, isPresented: $viewModel.linkIsActive) {
if let presentingItem = viewModel.selectedItem {
WebReaderContainerView(item: presentingItem)
} else {
EmptyView()
}
} label: {
EmptyView()
}.buttonStyle(.plain)
if prefersListLayout || !enableGrid { if prefersListLayout || !enableGrid {
HomeFeedListView( HomeFeedListView(
listTitle: $listTitle, listTitle: $listTitle,

View File

@ -13,7 +13,7 @@ enum LoadingBarStyle {
@MainActor final class HomeFeedViewModel: NSObject, ObservableObject { @MainActor final class HomeFeedViewModel: NSObject, ObservableObject {
let filterKey: String let filterKey: String
@ObservedObject var fetcher: LibraryItemFetcher @Published var fetcher: LibraryItemFetcher
let folderConfigs: [String: LibraryListConfig] let folderConfigs: [String: LibraryListConfig]
@Published var isLoading = false @Published var isLoading = false
@ -67,6 +67,13 @@ enum LoadingBarStyle {
super.init() super.init()
} }
func presentItem(item: Models.LibraryItem) {
withAnimation {
self.selectedItem = item
self.linkIsActive = true
}
}
private var filterState: FetcherFilterState? { private var filterState: FetcherFilterState? {
if let appliedFilter = appliedFilter { if let appliedFilter = appliedFilter {
return FetcherFilterState( return FetcherFilterState(
@ -307,7 +314,7 @@ enum LoadingBarStyle {
Task { Task {
do { do {
try await dataService.moveItem(itemID: item.unwrappedID, folder: folder) try await dataService.moveItem(itemID: item.unwrappedID, folder: folder)
snackbar("Item moved") snackbar("Moved to library")
} catch { } catch {
snackbar("Error moving item to \(folder)") snackbar("Error moving item to \(folder)")
} }

View File

@ -137,18 +137,6 @@ struct LibraryTabView: View {
) )
} }
.navigationBarHidden(true) .navigationBarHidden(true)
.onReceive(NSNotification.snackBarPublisher) { notification in
if let message = notification.userInfo?["message"] as? String {
showOperationToast = true
operationMessage = message
operationStatus = .isPerforming
if let dismissAfter = notification.userInfo?["dismissAfter"] as? Int {
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(dismissAfter)) {
showOperationToast = false
}
}
}
}
.onReceive(NSNotification.performSyncPublisher) { _ in .onReceive(NSNotification.performSyncPublisher) { _ in
Task { Task {
await syncManager.syncUpdates(dataService: dataService) await syncManager.syncUpdates(dataService: dataService)

View File

@ -71,7 +71,7 @@ struct LinkItemDetailView: View {
} }
.navigationViewStyle(.stack) .navigationViewStyle(.stack)
} else if let item = viewModel.item { } else if let item = viewModel.item {
WebReaderContainerView(item: item, pop: { dismiss() }) WebReaderContainerView(item: item)
.background(ThemeManager.currentBgColor) .background(ThemeManager.currentBgColor)
} }
} }

View File

@ -2,12 +2,39 @@ import Models
import Services import Services
import SwiftUI import SwiftUI
import Views import Views
import Transmission
@MainActor public struct PrimaryContentView: View { @MainActor public struct PrimaryContentView: View {
@State var searchTerm: String = "" @State var showSnackbar = false
@State var snackbarMessage: String?
@State var snackbarUndoAction: (() -> Void)?
@State private var snackbarTimer: Timer?
public var body: some View { public var body: some View {
innerBody ZStack {
WindowLink(level: .alert, transition: .move(edge: .bottom), isPresented: $showSnackbar) {
InformationalSnackbar(message: snackbarMessage, undoAction: snackbarUndoAction)
} label: {
EmptyView()
}.buttonStyle(.plain)
innerBody
}
.onReceive(NSNotification.snackBarPublisher) { notification in
if let message = notification.userInfo?["message"] as? String {
snackbarUndoAction = notification.userInfo?["undoAction"] as? (() -> Void)
snackbarMessage = message
showSnackbar = true
let dismissAfter = notification.userInfo?["dismissAfter"] as? Int ?? 2000
if snackbarTimer == nil {
startTimer(amount: dismissAfter)
} else {
increaseTimeout(amount: dismissAfter)
}
}
}
} }
public var innerBody: some View { public var innerBody: some View {
@ -25,4 +52,21 @@ import Views
return AnyView(splitView) return AnyView(splitView)
#endif #endif
} }
func startTimer(amount: Int) {
self.snackbarTimer = Timer.scheduledTimer(withTimeInterval: TimeInterval(amount / 1000), repeats: false) { _ in
DispatchQueue.main.async {
self.showSnackbar = false
}
}
}
func stopTimer() {
snackbarTimer?.invalidate()
}
func increaseTimeout(amount: Int) {
stopTimer()
startTimer(amount: amount)
}
} }

View File

@ -9,8 +9,8 @@ import WebKit
// swiftlint:disable file_length type_body_length // swiftlint:disable file_length type_body_length
struct WebReaderContainerView: View { struct WebReaderContainerView: View {
let item: Models.LibraryItem @State var item: Models.LibraryItem
let pop: () -> Void @Environment(\.dismiss) private var dismiss
@State private var showPreferencesPopover = false @State private var showPreferencesPopover = false
@State private var showPreferencesFormsheet = false @State private var showPreferencesFormsheet = false
@ -44,7 +44,6 @@ struct WebReaderContainerView: View {
@EnvironmentObject var audioController: AudioController @EnvironmentObject var audioController: AudioController
@Environment(\.openURL) var openURL @Environment(\.openURL) var openURL
@StateObject var viewModel = WebReaderViewModel() @StateObject var viewModel = WebReaderViewModel()
@Environment(\.dismiss) var dismiss
@AppStorage(UserDefaultKey.prefersHideStatusBarInReader.rawValue) var prefersHideStatusBarInReader = false @AppStorage(UserDefaultKey.prefersHideStatusBarInReader.rawValue) var prefersHideStatusBarInReader = false
@ -252,7 +251,7 @@ struct WebReaderContainerView: View {
#if os(iOS) #if os(iOS)
Button( Button(
action: { action: {
pop() dismiss()
}, },
label: { label: {
Image.chevronRight Image.chevronRight
@ -492,13 +491,6 @@ struct WebReaderContainerView: View {
} }
} }
} }
.sheet(isPresented: $showLabelsModal) {
ApplyLabelsView(mode: .item(item), onSave: { labels in
showLabelsModal = false
item.labels = NSSet(array: labels)
readerSettingsChangedTransactionID = UUID()
})
}
.sheet(isPresented: $showTitleEdit) { .sheet(isPresented: $showTitleEdit) {
LinkedItemMetadataEditView(item: item, onSave: { title, _ in LinkedItemMetadataEditView(item: item, onSave: { title, _ in
item.title = title item.title = title
@ -506,6 +498,13 @@ struct WebReaderContainerView: View {
readerSettingsChangedTransactionID = UUID() readerSettingsChangedTransactionID = UUID()
}) })
} }
.sheet(isPresented: $showLabelsModal) {
ApplyLabelsView(mode: .item(item), onSave: { labels in
showLabelsModal = false
item.labels = NSSet(array: labels)
readerSettingsChangedTransactionID = UUID()
})
}
#if os(iOS) #if os(iOS)
.sheet(isPresented: $showNotebookView, onDismiss: onNotebookViewDismissal) { .sheet(isPresented: $showNotebookView, onDismiss: onNotebookViewDismissal) {
NotebookView( NotebookView(
@ -616,26 +615,18 @@ struct WebReaderContainerView: View {
WebViewManager.shared().loadHTMLString(WebReaderContent.emptyContent(isDark: Color.isDarkMode), baseURL: nil) WebViewManager.shared().loadHTMLString(WebReaderContent.emptyContent(isDark: Color.isDarkMode), baseURL: nil)
} }
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("PopToRoot"))) { _ in .onReceive(NotificationCenter.default.publisher(for: Notification.Name("PopToRoot"))) { _ in
pop() dismiss()
} }
.ignoresSafeArea(.all, edges: .bottom) .ignoresSafeArea(.all, edges: .bottom)
} }
func moveToInbox() { func moveToInbox() {
Task { Task {
viewModel.showOperationToast = true
viewModel.operationMessage = "Moving to library..."
viewModel.operationStatus = .isPerforming
do { do {
try await dataService.moveItem(itemID: item.unwrappedID, folder: "inbox") try await dataService.moveItem(itemID: item.unwrappedID, folder: "inbox")
viewModel.operationMessage = "Moved to library" Snackbar.show(message: "Moved to library", dismissAfter: 2000)
viewModel.operationStatus = .success
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1500)) {
viewModel.showOperationToast = false
}
} catch { } catch {
viewModel.operationMessage = "Error moving" Snackbar.show(message: "Error moving item to inbox", dismissAfter: 2000)
viewModel.operationStatus = .failure
} }
} }
} }
@ -644,8 +635,12 @@ struct WebReaderContainerView: View {
let isArchived = item.isArchived let isArchived = item.isArchived
dataService.archiveLink(objectID: item.objectID, archived: !isArchived) dataService.archiveLink(objectID: item.objectID, archived: !isArchived)
#if os(iOS) #if os(iOS)
pop() dismiss()
Snackbar.show(message: isArchived ? "Unarchived" : "Archived", dismissAfter: 2000)
Snackbar.show(message: isArchived ? "Unarchived" : "Archived", undoAction: {
dataService.archiveLink(objectID: item.objectID, archived: isArchived)
Snackbar.show(message: isArchived ? "Archived" : "Unarchived", dismissAfter: 2000)
}, dismissAfter: 2000)
#endif #endif
} }
@ -673,7 +668,8 @@ struct WebReaderContainerView: View {
} }
func delete() { func delete() {
pop() dismiss()
#if os(iOS) #if os(iOS)
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
removeLibraryItemAction(dataService: dataService, objectID: item.objectID) removeLibraryItemAction(dataService: dataService, objectID: item.objectID)