From 4291c1149b575eeb2e706472c0df61e4bf59bde3 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Mon, 18 Dec 2023 17:35:08 +0800 Subject: [PATCH] Better unsubscribe view --- .../Sources/App/Views/LibrarySplitView.swift | 24 +- .../Sources/App/Views/LibraryTabView.swift | 18 +- .../App/Views/Profile/Subscriptions.swift | 278 +++++++++++++----- 3 files changed, 242 insertions(+), 78 deletions(-) diff --git a/apple/OmnivoreKit/Sources/App/Views/LibrarySplitView.swift b/apple/OmnivoreKit/Sources/App/Views/LibrarySplitView.swift index 1da9c8612..c7f0b144a 100644 --- a/apple/OmnivoreKit/Sources/App/Views/LibrarySplitView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/LibrarySplitView.swift @@ -1,4 +1,5 @@ import Foundation +import Models import Services import SwiftUI @@ -25,7 +26,7 @@ public struct LibrarySplitView: View { hasFeatureCards: false, hasReadNowSection: false, leadingSwipeActions: [.moveToInbox], - trailingSwipeActions: [.archive, .delete], + trailingSwipeActions: [.delete], cardStyle: .library ) ) @@ -49,6 +50,27 @@ public struct LibrarySplitView: View { $0.preferredPrimaryColumnWidth = 230 $0.displayModeButtonVisibility = .always } + .onOpenURL { url in + inboxViewModel.linkRequest = nil + if let deepLink = DeepLink.make(from: url) { + switch deepLink { + case let .search(query): + inboxViewModel.searchTerm = query + case let .savedSearch(named): + if let filter = inboxViewModel.findFilter(dataService, named: named) { + inboxViewModel.appliedFilter = filter + } + case let .webAppLinkRequest(requestID): + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { + withoutAnimation { + inboxViewModel.linkRequest = LinkRequest(id: UUID(), serverID: requestID) + inboxViewModel.presentWebContainer = true + } + } + } + } + // selectedTab = "inbox" + } .onReceive(NSNotification.performSyncPublisher) { _ in Task { await syncManager.syncItems(dataService: dataService) diff --git a/apple/OmnivoreKit/Sources/App/Views/LibraryTabView.swift b/apple/OmnivoreKit/Sources/App/Views/LibraryTabView.swift index 1ca4c9e33..3b63712c3 100644 --- a/apple/OmnivoreKit/Sources/App/Views/LibraryTabView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/LibraryTabView.swift @@ -29,7 +29,7 @@ struct LibraryTabView: View { UITabBar.appearance().isHidden = true } - @StateObject private var libraryViewModel = HomeFeedViewModel( + @StateObject private var inboxViewModel = HomeFeedViewModel( folder: "inbox", fetcher: LibraryItemFetcher(), listConfig: LibraryListConfig( @@ -48,7 +48,7 @@ struct LibraryTabView: View { hasFeatureCards: false, hasReadNowSection: false, leadingSwipeActions: [.moveToInbox], - trailingSwipeActions: [.archive, .delete], + trailingSwipeActions: [.delete], cardStyle: .library ) ) @@ -67,7 +67,7 @@ struct LibraryTabView: View { } NavigationView { - HomeFeedContainerView(viewModel: libraryViewModel) + HomeFeedContainerView(viewModel: inboxViewModel) .navigationBarTitleDisplayMode(.inline) .navigationViewStyle(.stack) }.tag("inbox") @@ -100,20 +100,20 @@ struct LibraryTabView: View { } } .onOpenURL { url in - libraryViewModel.linkRequest = nil + inboxViewModel.linkRequest = nil if let deepLink = DeepLink.make(from: url) { switch deepLink { case let .search(query): - libraryViewModel.searchTerm = query + inboxViewModel.searchTerm = query case let .savedSearch(named): - if let filter = libraryViewModel.findFilter(dataService, named: named) { - libraryViewModel.appliedFilter = filter + if let filter = inboxViewModel.findFilter(dataService, named: named) { + inboxViewModel.appliedFilter = filter } case let .webAppLinkRequest(requestID): DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { withoutAnimation { - libraryViewModel.linkRequest = LinkRequest(id: UUID(), serverID: requestID) - libraryViewModel.presentWebContainer = true + inboxViewModel.linkRequest = LinkRequest(id: UUID(), serverID: requestID) + inboxViewModel.presentWebContainer = true } } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/Subscriptions.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/Subscriptions.swift index b8b194c59..ea7fec9bb 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Profile/Subscriptions.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/Subscriptions.swift @@ -1,14 +1,24 @@ import Models import Services import SwiftUI +import Transmission import Views +enum UnsubscribeState { + case none + case isUnsubscribing + case unsubscribeSuccess + case unsubscribeFailure +} + @MainActor final class SubscriptionsViewModel: ObservableObject { @Published var isLoading = true @Published var feeds = [Subscription]() @Published var newsletters = [Subscription]() @Published var hasNetworkError = false @Published var subscriptionNameToCancel: String? + @Published var presentingSubscription: Subscription? + @Published var unsubscribeState: UnsubscribeState = .none func loadSubscriptions(dataService: DataService) async { isLoading = true @@ -24,20 +34,56 @@ import Views isLoading = false } - func cancelSubscription(dataService: DataService) async -> Bool { - guard let subscriptionName = subscriptionNameToCancel else { return false } + func cancelSubscription(dataService _: DataService, subscription: Subscription) async { + unsubscribeState = .isUnsubscribing + let subscriptionName = subscription.name - do { - try await dataService.deleteSubscription(subscriptionName: subscriptionName) + // do { + // try await dataService.deleteSubscription(subscriptionName: subscriptionName) // let index = subscriptions.firstIndex { $0.name == subscriptionName } // if let index = index { // subscriptions.remove(at: index) // } - return true - } catch { - appLogger.debug("failed to remove subscription") - return false +// unsubscribeState = .unsubscribeSuccess +// } catch { +// appLogger.debug("failed to remove subscription") +// unsubscribeState = .unsubscribeFailure +// } + } +} + +struct UnsubscribeToast: View { + @ObservedObject var viewModel: SubscriptionsViewModel + + var body: some View { + HStack { + if viewModel.unsubscribeState == .isUnsubscribing { + Text("Unsubscribing...") + Spacer() + ProgressView() + } else if viewModel.unsubscribeState == .unsubscribeSuccess { + Text("You have been unsubscribed.") + Spacer() + Button(action: { + + }, label: { + Text("Done").bold() + }) + } else if viewModel.unsubscribeState == .unsubscribeFailure { + Text("There was an error unsubscribing") + Spacer() + Button(action: { + + }, label: { + Text("Done").bold() + }) + } } + .frame(minHeight: 50) + .frame(maxWidth: .infinity) + .padding(.bottom, 30) + .padding(.horizontal, 15) + .background(Color.systemBackground) } } @@ -45,10 +91,18 @@ struct SubscriptionsView: View { @EnvironmentObject var dataService: DataService @StateObject var viewModel = SubscriptionsViewModel() @State private var deleteConfirmationShown = false - @State private var progressViewOpacity = 0.0 + @State private var showDeleteCompleted = false + + @State private var showSubscriptionsSheet = false + @State private var showUnsubscribeToast = false var body: some View { Group { + WindowLink(level: .alert, transition: .move(edge: .bottom).combined(with: .opacity), isPresented: $showUnsubscribeToast) { + UnsubscribeToast(viewModel: viewModel) + } label: { + EmptyView() + } if viewModel.isLoading { ProgressView() } else if viewModel.hasNetworkError { @@ -79,6 +133,25 @@ struct SubscriptionsView: View { #endif } } + .formSheet(isPresented: $showSubscriptionsSheet) { + if let presentingSubscription = viewModel.presentingSubscription { + SubscriptionSettingsView( + subscription: presentingSubscription, + viewModel: viewModel, + dataService: dataService, + dismiss: { showSubscriptionsSheet = false }, + unsubscribe: { subscription in + showSubscriptionsSheet = false + + viewModel.unsubscribeState = .isUnsubscribing + showUnsubscribeToast = true + Task { + await viewModel.cancelSubscription(dataService: dataService, subscription: subscription) + } + } + ) + } + } .task { await viewModel.loadSubscriptions(dataService: dataService) } @@ -92,59 +165,72 @@ struct SubscriptionsView: View { Group { Section("Feeds") { ForEach(viewModel.feeds, id: \.subscriptionID) { subscription in - SubscriptionCell(subscription: subscription) - .swipeActions(edge: .trailing) { - Button( - role: .destructive, - action: { - deleteConfirmationShown = true - viewModel.subscriptionNameToCancel = subscription.name - }, - label: { - Image(systemName: "trash") - } - ) - } + Button(action: { + viewModel.presentingSubscription = subscription + showSubscriptionsSheet = true + }, label: { + SubscriptionCell(subscription: subscription) + }) } } - Section("Newsletters") { - ForEach(viewModel.newsletters, id: \.subscriptionID) { subscription in - SubscriptionCell(subscription: subscription) - .swipeActions(edge: .trailing) { - Button( - role: .destructive, - action: { - deleteConfirmationShown = true - viewModel.subscriptionNameToCancel = subscription.name - }, - label: { - Image(systemName: "trash") - } - ) - } - } - } - } - .alert("Are you sure you want to cancel this subscription?", isPresented: $deleteConfirmationShown) { - Button("Yes", role: .destructive) { - Task { - let unsubscribed = await viewModel.cancelSubscription(dataService: dataService) - // Snackbar.show(message: unsubscribed ? "Subscription cancelled." : "Could not unsubscribe.") - } - } - Button("No", role: .cancel) { - viewModel.subscriptionNameToCancel = nil - } +// Section("Newsletters") { +// ForEach(viewModel.newsletters, id: \.subscriptionID) { subscription in +// SubscriptionCell(subscription: subscription) +// .swipeActions(edge: .trailing) { +// Button( +// role: .destructive, +// action: { +// deleteConfirmationShown = true +// viewModel.subscriptionNameToCancel = subscription.name +// }, +// label: { +// Image(systemName: "trash") +// } +// ) +// } +// .onTapGesture { +// expandedSubscription = subscription +// showSubscriptionsSheet = true +// } +// } +// } } .navigationTitle(LocalText.subscriptionsGeneric) } } -struct SubscriptionCell: View { +struct SubscriptionRow: View { let subscription: Subscription + let useImageSpacer: Bool + + @ViewBuilder let trailingButton: Content var body: some View { HStack { + Group { + if let icon = subscription.icon, let imageURL = URL(string: icon) { + AsyncImage(url: imageURL) { phase in + if let image = phase.image { + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 40, height: 40) + .cornerRadius(6) + } else if phase.error != nil { + Color.clear.frame(width: 40, height: 40, alignment: .top) + } else { + Color.clear + .frame(width: 40, height: 40) + .cornerRadius(2) + } + } + } else if useImageSpacer { + Color.clear + .frame(width: 40, height: 40) + .cornerRadius(2) + } + }.padding(.trailing, 10) + VStack(alignment: .leading, spacing: 6) { Text(subscription.name) .font(.appCallout) @@ -164,25 +250,81 @@ struct SubscriptionCell: View { Spacer() - Group { - if let icon = subscription.icon, let imageURL = URL(string: icon) { - AsyncImage(url: imageURL) { phase in - if let image = phase.image { - image - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: 40, height: 40) - .cornerRadius(6) - } else if phase.error != nil { - EmptyView().frame(width: 40, height: 40, alignment: .top) - } else { - Color.appButtonBackground - .frame(width: 40, height: 40) - .cornerRadius(2) - } + trailingButton + }.frame(minHeight: 50) + } +} + +struct SubscriptionCell: View { + let subscription: Subscription + + var body: some View { + SubscriptionRow(subscription: subscription, useImageSpacer: true, trailingButton: { + Image(systemName: "ellipsis") + }) + } +} + +struct SubscriptionSettingsView: View { + let subscription: Subscription + let viewModel: SubscriptionsViewModel + let dataService: DataService + + @State var prefetchContent = false + @State var deleteConfirmationShown = false + @State var showDeleteCompleted = false + + let dismiss: () -> Void + let unsubscribe: (_: Subscription) -> Void + + var body: some View { + VStack { + SubscriptionRow(subscription: subscription, useImageSpacer: false, trailingButton: { + Button(action: { + dismiss() + }, label: { + ZStack { + Circle() + .foregroundColor(Color.circleButtonBackground) + .frame(width: 30, height: 30) + + Image(systemName: "xmark") + .resizable(resizingMode: Image.ResizingMode.stretch) + .foregroundColor(Color.circleButtonForeground) + .aspectRatio(contentMode: .fit) + .font(Font.title.weight(.bold)) + .frame(width: 12, height: 12) } + }) + }) + .padding(.top, 15) + .padding(.horizontal, 15) + + List { + Toggle(isOn: $prefetchContent, label: { Text("Prefetch Content:").foregroundColor(.appGrayText) }) + HStack { + Text("Destination Folder:") + .foregroundColor(.appGrayText) + Spacer() + Menu(content: {}, label: { + Text("Following <>") + }) } - }.frame(minHeight: 50) + }.listStyle(.insetGrouped) + + Spacer() + Button("Unsubscribe", role: .destructive) { deleteConfirmationShown = true } + .frame(maxWidth: .infinity) + .buttonStyle(RoundedRectButtonStyle(color: Color.red, textColor: Color.white)) + } + .frame(minWidth: 200, minHeight: 200) + .alert("Are you sure you want to cancel this subscription?", isPresented: $deleteConfirmationShown) { + Button("Yes", role: .destructive) { + unsubscribe(subscription) + } + Button("No", role: .cancel) { + viewModel.subscriptionNameToCancel = nil + } } } }