mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Merge pull request #3238 from omnivore-app/feat/ios-add-feed
Add feed buttons for iOS
This commit is contained in:
commit
7caa6f5a12
179 changed files with 4909 additions and 952 deletions
|
|
@ -0,0 +1,97 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1420"
|
||||
wasCreatedForAppExtension = "YES"
|
||||
version = "2.0">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "42FF1B15271154A700B38C38"
|
||||
BuildableName = "SafariExtension.appex"
|
||||
BlueprintName = "SafariExtension (iOS)"
|
||||
ReferencedContainer = "container:Omnivore.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "8260E9C026EF983F8970EC05"
|
||||
BuildableName = "Omnivore.app"
|
||||
BlueprintName = "Omnivore-iOS"
|
||||
ReferencedContainer = "container:Omnivore.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<Testables>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = ""
|
||||
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
|
||||
launchStyle = "0"
|
||||
askForAppToLaunch = "Yes"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES"
|
||||
launchAutomaticallySubstyle = "2">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "8260E9C026EF983F8970EC05"
|
||||
BuildableName = "Omnivore.app"
|
||||
BlueprintName = "Omnivore-iOS"
|
||||
ReferencedContainer = "container:Omnivore.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
askForAppToLaunch = "Yes"
|
||||
launchAutomaticallySubstyle = "2">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "8260E9C026EF983F8970EC05"
|
||||
BuildableName = "Omnivore.app"
|
||||
BlueprintName = "Omnivore-iOS"
|
||||
ReferencedContainer = "container:Omnivore.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1500"
|
||||
wasCreatedForAppExtension = "YES"
|
||||
version = "2.0">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "42FF1B1F271154A700B38C38"
|
||||
BuildableName = "SafariExtension.appex"
|
||||
BlueprintName = "SafariExtension (macOS)"
|
||||
ReferencedContainer = "container:Omnivore.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "048ECFFB26A0B1CB00469E57"
|
||||
BuildableName = "Omnivore.app"
|
||||
BlueprintName = "Omnivore"
|
||||
ReferencedContainer = "container:Omnivore.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = ""
|
||||
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
|
||||
launchStyle = "0"
|
||||
askForAppToLaunch = "Yes"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES"
|
||||
launchAutomaticallySubstyle = "2">
|
||||
<RemoteRunnable
|
||||
runnableDebuggingMode = "0"
|
||||
BundleIdentifier = "com.apple.Safari"
|
||||
RemotePath = "/Applications/Safari.app">
|
||||
</RemoteRunnable>
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "048ECFFB26A0B1CB00469E57"
|
||||
BuildableName = "Omnivore.app"
|
||||
BlueprintName = "Omnivore"
|
||||
ReferencedContainer = "container:Omnivore.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
askForAppToLaunch = "Yes"
|
||||
launchAutomaticallySubstyle = "2">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "048ECFFB26A0B1CB00469E57"
|
||||
BuildableName = "Omnivore.app"
|
||||
BlueprintName = "Omnivore"
|
||||
ReferencedContainer = "container:Omnivore.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1340"
|
||||
wasCreatedForAppExtension = "YES"
|
||||
version = "2.0">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "42FF1B1F271154A700B38C38"
|
||||
BuildableName = "SafariExtension.appex"
|
||||
BlueprintName = "SafariExtension (macOS)"
|
||||
ReferencedContainer = "container:Omnivore.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "048ECFFB26A0B1CB00469E57"
|
||||
BuildableName = "Omnivore.app"
|
||||
BlueprintName = "Omnivore"
|
||||
ReferencedContainer = "container:Omnivore.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<Testables>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = ""
|
||||
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
|
||||
launchStyle = "0"
|
||||
askForAppToLaunch = "Yes"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES"
|
||||
launchAutomaticallySubstyle = "2">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "048ECFFB26A0B1CB00469E57"
|
||||
BuildableName = "Omnivore.app"
|
||||
BlueprintName = "Omnivore"
|
||||
ReferencedContainer = "container:Omnivore.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
askForAppToLaunch = "Yes"
|
||||
launchAutomaticallySubstyle = "2">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "048ECFFB26A0B1CB00469E57"
|
||||
BuildableName = "Omnivore.app"
|
||||
BlueprintName = "Omnivore"
|
||||
ReferencedContainer = "container:Omnivore.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1500"
|
||||
wasCreatedForAppExtension = "YES"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "046C5CD126A3F89A00AC5349"
|
||||
BuildableName = "ShareExtension-Mac.appex"
|
||||
BlueprintName = "ShareExtension-Mac"
|
||||
ReferencedContainer = "container:Omnivore.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "048ECFFB26A0B1CB00469E57"
|
||||
BuildableName = "Omnivore.app"
|
||||
BlueprintName = "Omnivore"
|
||||
ReferencedContainer = "container:Omnivore.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
debugAsWhichUser = "root"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<PathRunnable
|
||||
runnableDebuggingMode = "0"
|
||||
BundleIdentifier = "com.apple.Safari"
|
||||
FilePath = "/System/Volumes/Preboot/Cryptexes/App/System/Applications/Safari.app">
|
||||
</PathRunnable>
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "048ECFFB26A0B1CB00469E57"
|
||||
BuildableName = "Omnivore.app"
|
||||
BlueprintName = "Omnivore"
|
||||
ReferencedContainer = "container:Omnivore.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
askForAppToLaunch = "Yes"
|
||||
launchAutomaticallySubstyle = "2">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "048ECFFB26A0B1CB00469E57"
|
||||
BuildableName = "Omnivore.app"
|
||||
BlueprintName = "Omnivore"
|
||||
ReferencedContainer = "container:Omnivore.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1340"
|
||||
wasCreatedForAppExtension = "YES"
|
||||
version = "2.0">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "3D30FF2BDEFCACE5A2A13AD1"
|
||||
BuildableName = "ShareExtension.appex"
|
||||
BlueprintName = "ShareExtension"
|
||||
ReferencedContainer = "container:Omnivore.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "8260E9C026EF983F8970EC05"
|
||||
BuildableName = "Omnivore.app"
|
||||
BlueprintName = "Omnivore-iOS"
|
||||
ReferencedContainer = "container:Omnivore.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "42E2BFB028E458E0007F29B2"
|
||||
BuildableName = "AppStoreScreenshots.xctest"
|
||||
BlueprintName = "AppStoreScreenshots"
|
||||
ReferencedContainer = "container:Omnivore.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = ""
|
||||
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
|
||||
launchStyle = "0"
|
||||
askForAppToLaunch = "Yes"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES"
|
||||
launchAutomaticallySubstyle = "2">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "8260E9C026EF983F8970EC05"
|
||||
BuildableName = "Omnivore.app"
|
||||
BlueprintName = "Omnivore-iOS"
|
||||
ReferencedContainer = "container:Omnivore.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
askForAppToLaunch = "Yes"
|
||||
launchAutomaticallySubstyle = "2">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "8260E9C026EF983F8970EC05"
|
||||
BuildableName = "Omnivore.app"
|
||||
BlueprintName = "Omnivore-iOS"
|
||||
ReferencedContainer = "container:Omnivore.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
|
|
@ -5,6 +5,7 @@ import SwiftUI
|
|||
import Utils
|
||||
import Views
|
||||
|
||||
@MainActor
|
||||
public class ShareExtensionViewModel: ObservableObject {
|
||||
@Published public var status: ShareExtensionStatus = .processing
|
||||
@Published public var title: String = ""
|
||||
|
|
|
|||
|
|
@ -237,7 +237,7 @@ import Utils
|
|||
}
|
||||
.fullScreenCover(isPresented: $showNotebookView, onDismiss: onNotebookViewDismissal) {
|
||||
NotebookView(
|
||||
itemObjectID: viewModel.pdfItem.objectID,
|
||||
viewModel: NotebookViewModel(item: viewModel.pdfItem.item),
|
||||
hasHighlightMutations: $hasPerformedHighlightMutations,
|
||||
onDeleteHighlight: { highlightId in
|
||||
coordinator.removeHighlightFromPDF(highlightId: highlightId)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import OSLog
|
|||
import Services
|
||||
import Utils
|
||||
|
||||
@MainActor
|
||||
public final class Services {
|
||||
static let fetchTaskID = "app.omnivore.fetchLinkedItems"
|
||||
static let secondsToWaitBeforeNextBackgroundRefresh: TimeInterval = isDebug ? 0 : 3600 // 1 hour
|
||||
|
|
@ -84,6 +85,7 @@ public final class Services {
|
|||
Task {
|
||||
do {
|
||||
let fetchedItemCount = try await services.dataService.fetchLinkedItemsBackgroundTask()
|
||||
BadgeCountHandler.updateBadgeCount(dataService: services.dataService)
|
||||
task.setTaskCompleted(success: true)
|
||||
} catch {
|
||||
EventTracker.track(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
import Foundation
|
||||
import SwiftUI
|
||||
import Views
|
||||
|
||||
struct CustomToolBar: View {
|
||||
let isFollowing: Bool
|
||||
let isArchived: Bool
|
||||
let moveToInboxAction: () -> Void
|
||||
let archiveAction: () -> Void
|
||||
let unarchiveAction: () -> Void
|
||||
let shareAction: () -> Void
|
||||
let deleteAction: () -> Void
|
||||
|
||||
var barColor: Color {
|
||||
switch ThemeManager.currentTheme {
|
||||
case .apollo:
|
||||
return Color.themeMiddleGray
|
||||
case .dark:
|
||||
return Color.themeMiddleGray
|
||||
case .light:
|
||||
return Color.themeDarkWhiteGray
|
||||
case .sepia:
|
||||
return Color.themeDarkWhiteGray
|
||||
case .system:
|
||||
return Color.isDarkMode ? Color.themeMiddleGray : Color.themeDarkWhiteGray
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
barColor
|
||||
.frame(height: 0.5)
|
||||
.frame(maxWidth: .infinity)
|
||||
HStack(spacing: 0) {
|
||||
if isFollowing {
|
||||
ToolBarButton(image: Image.tabLibrary, action: moveToInboxAction)
|
||||
} else if isArchived {
|
||||
ToolBarButton(image: Image.toolbarUnarchive, action: unarchiveAction)
|
||||
} else {
|
||||
ToolBarButton(image: Image.toolbarArchive, action: archiveAction)
|
||||
}
|
||||
ToolBarButton(image: Image.toolbarShare, action: shareAction)
|
||||
ToolBarButton(image: Image.toolbarTrash, action: deleteAction)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.padding(.bottom, 35)
|
||||
.background(ThemeManager.currentBgColor)
|
||||
}
|
||||
}
|
||||
|
||||
struct ToolBarButton: View {
|
||||
let image: Image
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: {
|
||||
action()
|
||||
}, label: {
|
||||
image
|
||||
.frame(width: 28, height: 28)
|
||||
.foregroundColor(ThemeManager.currentTheme.toolbarColor)
|
||||
.frame(maxWidth: .infinity)
|
||||
})
|
||||
}
|
||||
}
|
||||
24
apple/OmnivoreKit/Sources/App/Views/DeleteAccountView.swift
Normal file
24
apple/OmnivoreKit/Sources/App/Views/DeleteAccountView.swift
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Utils
|
||||
import Views
|
||||
|
||||
struct DeleteAccountView: View {
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@EnvironmentObject var authenticator: Authenticator
|
||||
|
||||
public var body: some View {
|
||||
VStack(alignment: .center) {
|
||||
Text("Deleting account...")
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.task {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(300)) {
|
||||
authenticator.logout(dataService: dataService, isAccountDeletion: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -9,16 +9,16 @@
|
|||
typealias DeleteHighlightAction = (String) -> Void
|
||||
|
||||
struct NotebookView: View {
|
||||
@StateObject var viewModel: NotebookViewModel
|
||||
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@Environment(\.presentationMode) private var presentationMode
|
||||
@StateObject var viewModel = NotebookViewModel()
|
||||
|
||||
@State var showAnnotationModal = false
|
||||
@State var errorAlertMessage: String?
|
||||
@State var showErrorAlertMessage = false
|
||||
@State var noteAnnotation = ""
|
||||
|
||||
let itemObjectID: NSManagedObjectID
|
||||
@Binding var hasHighlightMutations: Bool
|
||||
@State var setLabelsHighlight: Highlight?
|
||||
@State var showShareView: Bool = false
|
||||
|
|
@ -168,7 +168,7 @@
|
|||
annotation: $noteAnnotation,
|
||||
onSave: {
|
||||
viewModel.updateNoteAnnotation(
|
||||
itemObjectID: itemObjectID,
|
||||
itemObjectID: viewModel.item.objectID,
|
||||
annotation: noteAnnotation,
|
||||
dataService: dataService
|
||||
)
|
||||
|
|
@ -224,7 +224,7 @@
|
|||
#endif
|
||||
}
|
||||
.task {
|
||||
viewModel.load(itemObjectID: itemObjectID, dataService: dataService)
|
||||
viewModel.load(itemObjectID: viewModel.item.objectID, dataService: dataService)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,9 +21,15 @@ struct NoteItemParams: Identifiable {
|
|||
}
|
||||
|
||||
@MainActor final class NotebookViewModel: ObservableObject {
|
||||
let item: Models.LibraryItem
|
||||
|
||||
@Published var noteItem: NoteItemParams?
|
||||
@Published var highlightItems = [HighlightListItemParams]()
|
||||
|
||||
init(item: Models.LibraryItem) {
|
||||
self.item = item
|
||||
}
|
||||
|
||||
func load(itemObjectID: NSManagedObjectID, dataService: DataService) {
|
||||
if let linkedItem = dataService.viewContext.object(with: itemObjectID) as? Models.LibraryItem {
|
||||
loadHighlights(item: linkedItem)
|
||||
|
|
@ -102,7 +108,14 @@ struct NoteItemParams: Identifiable {
|
|||
}
|
||||
|
||||
func highlightsAsMarkdown() -> String {
|
||||
highlightItems.map { highlightAsMarkdown(item: $0) }.lazy.joined(separator: "\n\n")
|
||||
var buffer = "\(item.unwrappedTitle)\n"
|
||||
if let author = item.author {
|
||||
buffer += "by: \(author)\n"
|
||||
}
|
||||
if let url = item.pageURLString {
|
||||
buffer += "url: \(url)\n"
|
||||
}
|
||||
return buffer + "\n\n" + highlightItems.map { highlightAsMarkdown(item: $0) }.lazy.joined(separator: "\n\n")
|
||||
}
|
||||
|
||||
private func loadHighlights(item: Models.LibraryItem) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
import CoreData
|
||||
import Foundation
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
enum BadgeCountHandler {
|
||||
@AppStorage("Filters::badgeFilter") public static var badgeFilter = "in:inbox"
|
||||
|
||||
public static func updateBadgeCount(dataService: DataService) {
|
||||
// if let badgeFilterId = badgeFilterId {
|
||||
dataService.backgroundContext.performAndWait {
|
||||
if let filter = Filter.lookup(byFilter: badgeFilter, inContext: dataService.backgroundContext),
|
||||
let internalFilter = InternalFilter.make(from: [filter]).first
|
||||
{
|
||||
let fetchRequest: NSFetchRequest<Models.LibraryItem> = LibraryItem.fetchRequest()
|
||||
fetchRequest.predicate = internalFilter.predicate
|
||||
|
||||
if let count = try? dataService.backgroundContext.count(for: fetchRequest) {
|
||||
UIApplication.shared.applicationIconBadgeNumber = count
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -39,7 +39,7 @@ struct LibraryFeatureCardNavigationLink: View {
|
|||
}
|
||||
)
|
||||
.confirmationDialog("", isPresented: $showFeatureActions) {
|
||||
if FeaturedItemFilter(rawValue: viewModel.featureFilter) == .pinned {
|
||||
if FeaturedItemFilter(rawValue: viewModel.fetcher.featureFilter) == .pinned {
|
||||
Button("Unpin", action: {
|
||||
viewModel.unpinItem(dataService: dataService, item: item)
|
||||
})
|
||||
|
|
@ -53,7 +53,7 @@ struct LibraryFeatureCardNavigationLink: View {
|
|||
Button("Remove", action: {
|
||||
viewModel.removeLibraryItem(dataService: dataService, objectID: item.objectID)
|
||||
})
|
||||
if FeaturedItemFilter(rawValue: viewModel.featureFilter) != .pinned {
|
||||
if FeaturedItemFilter(rawValue: viewModel.fetcher.featureFilter) != .pinned {
|
||||
Button("Mark Read", action: {
|
||||
viewModel.markRead(dataService: dataService, item: item)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -9,13 +9,13 @@ import Utils
|
|||
import Views
|
||||
|
||||
@MainActor final class LibraryItemFetcher: NSObject, ObservableObject {
|
||||
let folder = "inbox"
|
||||
|
||||
@Published var items = [Models.LibraryItem]()
|
||||
var itemsPublisher: Published<[Models.LibraryItem]>.Publisher { $items }
|
||||
@Published var featureItems = [Models.LibraryItem]()
|
||||
|
||||
private var fetchedResultsController: NSFetchedResultsController<Models.LibraryItem>?
|
||||
|
||||
@AppStorage(UserDefaultKey.lastSelectedFeaturedItemFilter.rawValue) var featureFilter = FeaturedItemFilter.continueReading.rawValue
|
||||
|
||||
var cursor: String?
|
||||
|
||||
// These are used to make sure we handle search result
|
||||
|
|
@ -23,10 +23,11 @@ import Views
|
|||
var searchIdx = 0
|
||||
var receivedIdx = 0
|
||||
|
||||
var syncCursor: String?
|
||||
|
||||
func setItems(_: NSManagedObjectContext, _ items: [Models.LibraryItem]) {
|
||||
func setItems(_ context: NSManagedObjectContext, _ items: [Models.LibraryItem]) {
|
||||
self.items = items
|
||||
if let filter = FeaturedItemFilter(rawValue: featureFilter) {
|
||||
updateFeatureFilter(context: context, filter: filter)
|
||||
}
|
||||
}
|
||||
|
||||
func loadCurrentViewer(dataService: DataService) async {
|
||||
|
|
@ -45,37 +46,7 @@ import Views
|
|||
}
|
||||
}
|
||||
|
||||
func syncItems(dataService: DataService) async {
|
||||
let syncStart = Date.now
|
||||
let lastSyncDate = dataService.lastItemSyncTime
|
||||
|
||||
try? await dataService.syncOfflineItemsWithServerIfNeeded()
|
||||
|
||||
let syncResult = try? await dataService.syncLinkedItems(since: lastSyncDate,
|
||||
cursor: nil)
|
||||
|
||||
syncCursor = syncResult?.cursor
|
||||
if let syncResult = syncResult, syncResult.hasMore {
|
||||
dataService.syncLinkedItemsInBackground(since: lastSyncDate) {
|
||||
// do nothing
|
||||
}
|
||||
} else {
|
||||
dataService.lastItemSyncTime = syncStart
|
||||
}
|
||||
|
||||
// If possible start prefetching new pages in the background
|
||||
if
|
||||
let itemIDs = syncResult?.updatedItemIDs,
|
||||
let username = dataService.currentViewer?.username,
|
||||
!itemIDs.isEmpty
|
||||
{
|
||||
Task.detached(priority: .background) {
|
||||
await dataService.prefetchPages(itemIDs: itemIDs, username: username)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loadSearchQuery(dataService: DataService, filterState: FetcherFilterState, isRefresh: Bool) async {
|
||||
func loadSearchQuery(dataService: DataService, filterState: FetcherFilterState, isRefresh: Bool, loadCursor: String? = nil) async {
|
||||
let thisSearchIdx = searchIdx
|
||||
searchIdx += 1
|
||||
|
||||
|
|
@ -86,7 +57,7 @@ import Views
|
|||
let queryResult = try? await dataService.loadLinkedItems(
|
||||
limit: 10,
|
||||
searchQuery: searchQuery(filterState),
|
||||
cursor: isRefresh ? nil : cursor
|
||||
cursor: isRefresh ? nil : loadCursor ?? cursor
|
||||
)
|
||||
|
||||
if let appliedFilter = filterState.appliedFilter, let queryResult = queryResult {
|
||||
|
|
@ -123,7 +94,6 @@ import Views
|
|||
await withTaskGroup(of: Void.self) { group in
|
||||
group.addTask { await self.loadCurrentViewer(dataService: dataService) }
|
||||
group.addTask { await self.loadLabels(dataService: dataService) }
|
||||
group.addTask { await self.syncItems(dataService: dataService) }
|
||||
group.addTask { await self.updateFetchController(dataService: dataService, filterState: filterState) }
|
||||
await group.waitForAll()
|
||||
}
|
||||
|
|
@ -136,11 +106,16 @@ import Views
|
|||
updateFetchController(dataService: dataService, filterState: filterState)
|
||||
}
|
||||
}
|
||||
|
||||
NotificationCenter.default.post(name: NSNotification.PerformSync, object: nil, userInfo: nil)
|
||||
|
||||
BadgeCountHandler.updateBadgeCount(dataService: dataService)
|
||||
}
|
||||
|
||||
func loadMoreItems(dataService: DataService, filterState: FetcherFilterState, isRefresh: Bool) async {
|
||||
func loadMoreItems(dataService: DataService, filterState: FetcherFilterState) async {
|
||||
if let appliedFilter = filterState.appliedFilter, appliedFilter.shouldRemoteSearch {
|
||||
await loadSearchQuery(dataService: dataService, filterState: filterState, isRefresh: isRefresh)
|
||||
let idx = max(items.count - 1, 0)
|
||||
await loadSearchQuery(dataService: dataService, filterState: filterState, isRefresh: false, loadCursor: idx.description)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -216,10 +191,14 @@ import Views
|
|||
|
||||
private func searchQuery(_ filterState: FetcherFilterState) -> String {
|
||||
let sort = LinkedItemSort(rawValue: filterState.appliedSort) ?? .newest
|
||||
var query = sort.queryString
|
||||
var query = ""
|
||||
|
||||
if let queryString = filterState.appliedFilter?.filter {
|
||||
query = "\(queryString) \(sort.queryString)"
|
||||
query = "\(queryString)"
|
||||
}
|
||||
|
||||
if !query.contains("sort:") {
|
||||
query = "\(query) \(sort.queryString)"
|
||||
}
|
||||
|
||||
if !filterState.searchTerm.isEmpty {
|
||||
|
|
@ -251,6 +230,28 @@ import Views
|
|||
|
||||
return query
|
||||
}
|
||||
|
||||
func refreshFeatureItems(dataService: DataService) {
|
||||
if let featureFilter = FeaturedItemFilter(rawValue: self.featureFilter) {
|
||||
updateFeatureFilter(context: dataService.viewContext, filter: featureFilter)
|
||||
}
|
||||
}
|
||||
|
||||
func updateFeatureFilter(context: NSManagedObjectContext, filter: FeaturedItemFilter?) {
|
||||
if let filter = filter {
|
||||
Task {
|
||||
featureFilter = filter.rawValue
|
||||
|
||||
featureItems = await loadFeatureItems(
|
||||
context: context,
|
||||
predicate: filter.predicate,
|
||||
sort: filter.sortDescriptor
|
||||
)
|
||||
}
|
||||
} else {
|
||||
featureItems = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension LibraryItemFetcher: NSFetchedResultsControllerDelegate {
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ struct MacFeedCardNavigationLink: View {
|
|||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
LibraryItemCard(item: item, viewer: dataService.currentViewer)
|
||||
LibraryItemCard(item: LibraryItemData.make(from: item), viewer: dataService.currentViewer)
|
||||
NavigationLink(destination: LinkItemDetailView(
|
||||
linkedItemObjectID: item.objectID,
|
||||
isPDF: item.isPDF
|
||||
|
|
@ -22,23 +22,19 @@ struct MacFeedCardNavigationLink: View {
|
|||
EmptyView()
|
||||
}).opacity(0)
|
||||
}
|
||||
.onAppear {
|
||||
Task { await viewModel.itemAppeared(item: item, dataService: dataService) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FeedCardNavigationLink: View {
|
||||
struct LibraryItemListNavigationLink: View {
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@EnvironmentObject var audioController: AudioController
|
||||
|
||||
let item: Models.LibraryItem
|
||||
let isInMultiSelectMode: Bool
|
||||
@ObservedObject var item: Models.LibraryItem
|
||||
@ObservedObject var viewModel: HomeFeedViewModel
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
LibraryItemCard(item: item, viewer: dataService.currentViewer)
|
||||
LibraryItemCard(item: LibraryItemData.make(from: item), viewer: dataService.currentViewer)
|
||||
PresentationLink(
|
||||
transition: PresentationLinkTransition.slide(
|
||||
options: PresentationLinkTransition.SlideTransitionOptions(edge: .trailing,
|
||||
|
|
@ -56,19 +52,17 @@ struct FeedCardNavigationLink: View {
|
|||
}
|
||||
)
|
||||
}
|
||||
.task {
|
||||
await viewModel.itemAppeared(item: item, dataService: dataService)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct GridCardNavigationLink: View {
|
||||
struct LibraryItemGridCardNavigationLink: View {
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@EnvironmentObject var audioController: AudioController
|
||||
|
||||
@State private var scale = 1.0
|
||||
|
||||
let item: Models.LibraryItem
|
||||
@ObservedObject var item: Models.LibraryItem
|
||||
|
||||
let actionHandler: (GridCardAction) -> Void
|
||||
|
||||
@Binding var isContextMenuOpen: Bool
|
||||
|
|
@ -89,12 +83,9 @@ struct GridCardNavigationLink: View {
|
|||
isPDF: item.isPDF
|
||||
)
|
||||
}, label: {
|
||||
GridCard(item: item, isContextMenuOpen: $isContextMenuOpen, actionHandler: actionHandler)
|
||||
GridCard(item: LibraryItemData.make(from: item), isContextMenuOpen: $isContextMenuOpen, actionHandler: actionHandler)
|
||||
}
|
||||
)
|
||||
.task {
|
||||
await viewModel.itemAppeared(item: item, dataService: dataService)
|
||||
}
|
||||
.aspectRatio(1.0, contentMode: .fill)
|
||||
.background(
|
||||
Color.secondarySystemGroupedBackground
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import Foundation
|
||||
import Services
|
||||
|
||||
class LibrarySyncManager {
|
||||
var syncCursor: String?
|
||||
|
||||
func syncItems(dataService: DataService) async {
|
||||
let syncStart = Date.now
|
||||
let lastSyncDate = dataService.lastItemSyncTime
|
||||
|
||||
if lastSyncDate.timeIntervalSinceNow > -4 {
|
||||
print("skipping sync as last sync was too recent: ", lastSyncDate)
|
||||
return
|
||||
}
|
||||
|
||||
try? await dataService.syncOfflineItemsWithServerIfNeeded()
|
||||
|
||||
let syncResult = try? await dataService.syncLinkedItems(since: lastSyncDate,
|
||||
cursor: nil)
|
||||
|
||||
syncCursor = syncResult?.cursor
|
||||
if let syncResult = syncResult, syncResult.hasMore {
|
||||
dataService.syncLinkedItemsInBackground(since: lastSyncDate) {
|
||||
// do nothing
|
||||
}
|
||||
} else {
|
||||
dataService.lastItemSyncTime = syncStart
|
||||
}
|
||||
|
||||
// If possible start prefetching new pages in the background
|
||||
if
|
||||
let itemIDs = syncResult?.updatedItemIDs,
|
||||
!itemIDs.isEmpty
|
||||
{
|
||||
Task.detached(priority: .background) {
|
||||
await dataService.prefetchPages(itemIDs: itemIDs, username: "username")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -28,7 +28,8 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
struct HomeFeedContainerView: View {
|
||||
@State var hasHighlightMutations = false
|
||||
@State var searchPresented = false
|
||||
@State var addLinkPresented = false
|
||||
@State var showAddLinkView = false
|
||||
@State var showAddFeedView = false
|
||||
@State var isListScrolled = false
|
||||
@State var listTitle = ""
|
||||
@State var isEditMode: EditMode = .inactive
|
||||
|
|
@ -37,6 +38,7 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@EnvironmentObject var audioController: AudioController
|
||||
@Environment(\.horizontalSizeClass) var horizontalSizeClass
|
||||
|
||||
@AppStorage(UserDefaultKey.homeFeedlayoutPreference.rawValue) var prefersListLayout = true
|
||||
@AppStorage(UserDefaultKey.openAIPrimerDisplayed.rawValue) var openAIPrimerDisplayed = false
|
||||
|
|
@ -70,6 +72,7 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
isListScrolled: $isListScrolled,
|
||||
prefersListLayout: $prefersListLayout,
|
||||
isEditMode: $isEditMode,
|
||||
showAddFeedView: $showAddFeedView,
|
||||
selection: $selection,
|
||||
viewModel: viewModel,
|
||||
showFeatureCards: showFeatureCards
|
||||
|
|
@ -123,20 +126,11 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
LinkedItemMetadataEditView(item: item)
|
||||
}
|
||||
.sheet(item: $viewModel.itemForHighlightsView) { item in
|
||||
NotebookView(itemObjectID: item.objectID, hasHighlightMutations: $hasHighlightMutations)
|
||||
NotebookView(viewModel: NotebookViewModel(item: item), hasHighlightMutations: $hasHighlightMutations)
|
||||
}
|
||||
.fullScreenCover(isPresented: $showExpandedAudioPlayer) {
|
||||
ExpandedAudioPlayer()
|
||||
}
|
||||
.sheet(isPresented: $showOpenAIVoices) {
|
||||
OpenAIVoicesModal(audioController: audioController)
|
||||
}
|
||||
.onAppear {
|
||||
if !openAIPrimerDisplayed, !Voices.isOpenAIVoice(self.audioController.currentVoice) {
|
||||
showOpenAIVoices = true
|
||||
openAIPrimerDisplayed = true
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
toolbarItems
|
||||
}
|
||||
|
|
@ -151,61 +145,54 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
viewModel.selectedItem = linkedItem
|
||||
viewModel.linkIsActive = true
|
||||
}
|
||||
.onOpenURL { url in
|
||||
viewModel.linkRequest = nil
|
||||
if let deepLink = DeepLink.make(from: url) {
|
||||
switch deepLink {
|
||||
case let .search(query):
|
||||
viewModel.searchTerm = query
|
||||
case let .savedSearch(named):
|
||||
if let filter = viewModel.findFilter(dataService, named: named) {
|
||||
viewModel.appliedFilter = filter
|
||||
}
|
||||
case let .webAppLinkRequest(requestID):
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
|
||||
withoutAnimation {
|
||||
viewModel.linkRequest = LinkRequest(id: UUID(), serverID: requestID)
|
||||
viewModel.presentWebContainer = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.fullScreenCover(isPresented: $searchPresented) {
|
||||
LibrarySearchView(homeFeedViewModel: self.viewModel)
|
||||
}
|
||||
.sheet(isPresented: $addLinkPresented) {
|
||||
.sheet(isPresented: $showAddLinkView) {
|
||||
NavigationView {
|
||||
LibraryAddLinkView()
|
||||
}
|
||||
}
|
||||
.introspectNavigationController { nav in
|
||||
nav.delegate = viewModel
|
||||
}
|
||||
.task {
|
||||
if viewModel.fetcher.items.isEmpty {
|
||||
loadItems(isRefresh: false)
|
||||
}
|
||||
await viewModel.loadFilters(dataService: dataService)
|
||||
}
|
||||
.environment(\.editMode, self.$isEditMode)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
var toolbarItems: some ToolbarContent {
|
||||
Group {
|
||||
ToolbarItem(placement: .barLeading) {
|
||||
VStack(alignment: .leading) {
|
||||
let showDate = isListScrolled && !listTitle.isEmpty
|
||||
if let title = viewModel.appliedFilter?.name {
|
||||
Text(title)
|
||||
.font(Font.system(size: showDate ? 10 : 24, weight: .semibold))
|
||||
if showDate, prefersListLayout, isListScrolled || !showFeatureCards {
|
||||
Text(listTitle)
|
||||
.font(Font.system(size: 15, weight: .regular))
|
||||
.foregroundColor(Color.appGrayText)
|
||||
ToolbarItemGroup(placement: .barLeading) {
|
||||
if UIDevice.isIPhone || horizontalSizeClass != .compact {
|
||||
VStack(alignment: .leading) {
|
||||
let showDate = isListScrolled && !listTitle.isEmpty
|
||||
if let title = viewModel.appliedFilter?.name {
|
||||
Text(title)
|
||||
.font(Font.system(size: showDate ? 10 : 24, weight: .semibold))
|
||||
if showDate, prefersListLayout, isListScrolled || !showFeatureCards {
|
||||
Text(listTitle)
|
||||
.font(Font.system(size: 15, weight: .regular))
|
||||
.foregroundColor(Color.appGrayText)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .bottomLeading)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .bottomLeading)
|
||||
}
|
||||
ToolbarItem(placement: UIDevice.isIPhone ? .barLeading : .barTrailing) {
|
||||
|
||||
ToolbarItemGroup(placement: .barTrailing) {
|
||||
if prefersListLayout {
|
||||
Button(
|
||||
action: { isEditMode = isEditMode == .active ? .inactive : .active },
|
||||
label: {
|
||||
Image.selectMultiple
|
||||
.foregroundColor(Color.appGrayTextContrast)
|
||||
}
|
||||
)
|
||||
}
|
||||
if enableGrid {
|
||||
Button(
|
||||
action: { prefersListLayout.toggle() },
|
||||
|
|
@ -214,8 +201,19 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .barTrailing) {
|
||||
Button(
|
||||
action: {
|
||||
if viewModel.folder == "inbox" {
|
||||
showAddLinkView = true
|
||||
} else if viewModel.folder == "following" {
|
||||
showAddFeedView = true
|
||||
}
|
||||
},
|
||||
label: {
|
||||
Image.addLink
|
||||
.foregroundColor(Color.appGrayTextContrast)
|
||||
}
|
||||
)
|
||||
Button(
|
||||
action: { searchPresented = true },
|
||||
label: {
|
||||
|
|
@ -224,28 +222,7 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
}
|
||||
)
|
||||
}
|
||||
ToolbarItem(placement: .barTrailing) {
|
||||
Button(
|
||||
action: { isEditMode = isEditMode == .active ? .inactive : .active },
|
||||
label: {
|
||||
Image.selectMultiple
|
||||
.foregroundColor(Color.appGrayTextContrast)
|
||||
}
|
||||
)
|
||||
}
|
||||
ToolbarItem(placement: .barTrailing) {
|
||||
if viewModel.folder == "inbox" {
|
||||
Button(
|
||||
action: { addLinkPresented = true },
|
||||
label: {
|
||||
Image.addLink
|
||||
.foregroundColor(Color.appGrayTextContrast)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItemGroup(placement: .bottomBar) {
|
||||
if isEditMode == .active {
|
||||
Button(action: {
|
||||
|
|
@ -274,6 +251,7 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
@Binding var isListScrolled: Bool
|
||||
@Binding var prefersListLayout: Bool
|
||||
@Binding var isEditMode: EditMode
|
||||
@Binding var showAddFeedView: Bool
|
||||
@Binding var selection: Set<String>
|
||||
@ObservedObject var viewModel: HomeFeedViewModel
|
||||
|
||||
|
|
@ -281,7 +259,7 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
if let linkRequest = viewModel.linkRequest {
|
||||
if let linkRequest = viewModel.linkRequest, viewModel.listConfig.hasReadNowSection {
|
||||
PresentationLink(
|
||||
transition: PresentationLinkTransition.slide(
|
||||
options: PresentationLinkTransition.SlideTransitionOptions(edge: .trailing,
|
||||
|
|
@ -304,6 +282,7 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
isListScrolled: $isListScrolled,
|
||||
prefersListLayout: $prefersListLayout,
|
||||
isEditMode: $isEditMode,
|
||||
showAddFeedView: $showAddFeedView,
|
||||
selection: $selection,
|
||||
viewModel: viewModel,
|
||||
showFeatureCards: showFeatureCards
|
||||
|
|
@ -357,7 +336,7 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
@Binding var isListScrolled: Bool
|
||||
@Binding var prefersListLayout: Bool
|
||||
@Binding var isEditMode: EditMode
|
||||
@State private var showAddFeedView = false
|
||||
@Binding var showAddFeedView: Bool
|
||||
@State private var showHideFeatureAlert = false
|
||||
@State private var showHideFollowingAlert = false
|
||||
|
||||
|
|
@ -379,21 +358,23 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
viewModel.searchTerm = ""
|
||||
}.frame(maxWidth: reader.size.width * 0.66)
|
||||
} else {
|
||||
Menu(
|
||||
content: {
|
||||
ForEach(viewModel.filters) { filter in
|
||||
Button(filter.name, action: {
|
||||
viewModel.appliedFilter = filter
|
||||
})
|
||||
if UIDevice.isIPhone {
|
||||
Menu(
|
||||
content: {
|
||||
ForEach(viewModel.filters) { filter in
|
||||
Button(filter.name, action: {
|
||||
viewModel.appliedFilter = filter
|
||||
})
|
||||
}
|
||||
},
|
||||
label: {
|
||||
TextChipButton.makeMenuButton(
|
||||
title: viewModel.appliedFilter?.name ?? "-",
|
||||
color: .systemGray6
|
||||
)
|
||||
}
|
||||
},
|
||||
label: {
|
||||
TextChipButton.makeMenuButton(
|
||||
title: viewModel.appliedFilter?.name ?? "-",
|
||||
color: .systemGray6
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
Menu(
|
||||
content: {
|
||||
|
|
@ -450,17 +431,17 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
HStack {
|
||||
Menu(content: {
|
||||
Button(action: {
|
||||
viewModel.updateFeatureFilter(context: dataService.viewContext, filter: .continueReading)
|
||||
viewModel.fetcher.updateFeatureFilter(context: dataService.viewContext, filter: .continueReading)
|
||||
}, label: {
|
||||
Text("Continue Reading")
|
||||
})
|
||||
Button(action: {
|
||||
viewModel.updateFeatureFilter(context: dataService.viewContext, filter: .pinned)
|
||||
viewModel.fetcher.updateFeatureFilter(context: dataService.viewContext, filter: .pinned)
|
||||
}, label: {
|
||||
Text("Pinned")
|
||||
})
|
||||
Button(action: {
|
||||
viewModel.updateFeatureFilter(context: dataService.viewContext, filter: .newsletters)
|
||||
viewModel.fetcher.updateFeatureFilter(context: dataService.viewContext, filter: .newsletters)
|
||||
}, label: {
|
||||
Text("Newsletters")
|
||||
})
|
||||
|
|
@ -474,7 +455,7 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
HStack(alignment: .center) {
|
||||
Image(systemName: "line.3.horizontal.decrease")
|
||||
.font(Font.system(size: 13, weight: .regular))
|
||||
Text((FeaturedItemFilter(rawValue: viewModel.featureFilter) ?? .continueReading).title)
|
||||
Text((FeaturedItemFilter(rawValue: viewModel.fetcher.featureFilter) ?? .continueReading).title)
|
||||
.font(Font.system(size: 13, weight: .medium))
|
||||
}
|
||||
.tint(Color(hex: "#007AFF"))
|
||||
|
|
@ -491,17 +472,17 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
|
||||
GeometryReader { geo in
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
if viewModel.featureItems.count > 0 {
|
||||
if viewModel.fetcher.featureItems.count > 0 {
|
||||
HStack(alignment: .top, spacing: 15) {
|
||||
Spacer(minLength: 1).frame(width: 1)
|
||||
ForEach(viewModel.featureItems) { item in
|
||||
ForEach(viewModel.fetcher.featureItems) { item in
|
||||
LibraryFeatureCardNavigationLink(item: item, viewModel: viewModel)
|
||||
}
|
||||
Spacer(minLength: 1).frame(width: 1)
|
||||
}
|
||||
.padding(.top, 0)
|
||||
} else {
|
||||
Text((FeaturedItemFilter(rawValue: viewModel.featureFilter) ?? .continueReading).emptyMessage)
|
||||
Text((FeaturedItemFilter(rawValue: viewModel.fetcher.featureFilter) ?? .continueReading).emptyMessage)
|
||||
.padding(.horizontal, UIDevice.isIPad ? 20 : 10)
|
||||
.font(Font.system(size: 14, weight: .regular))
|
||||
.foregroundColor(Color(hex: "#898989"))
|
||||
|
|
@ -578,7 +559,7 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
}
|
||||
|
||||
var redactedItems: some View {
|
||||
ForEach(Array(fakeLibraryItems(dataService: dataService).enumerated()), id: \.1.unwrappedID) { _, item in
|
||||
ForEach(Array(fakeLibraryItems(dataService: dataService).enumerated()), id: \.1.id) { _, item in
|
||||
let horizontalInset = CGFloat(UIDevice.isIPad ? 20 : 10)
|
||||
LibraryItemCard(item: item, viewer: dataService.currentViewer)
|
||||
.listRowSeparatorTint(Color.thBorderColor)
|
||||
|
|
@ -630,9 +611,8 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
ForEach(Array(viewModel.fetcher.items.enumerated()), id: \.1.unwrappedID) { _, item in
|
||||
let horizontalInset = CGFloat(UIDevice.isIPad ? 20 : 10)
|
||||
|
||||
FeedCardNavigationLink(
|
||||
LibraryItemListNavigationLink(
|
||||
item: item,
|
||||
isInMultiSelectMode: viewModel.isInMultiSelectMode,
|
||||
viewModel: viewModel
|
||||
)
|
||||
.background(GeometryReader { geometry in
|
||||
|
|
@ -715,6 +695,7 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
}, header: {
|
||||
filtersHeader
|
||||
})
|
||||
BottomView(viewModel: viewModel)
|
||||
}
|
||||
.padding(0)
|
||||
.listStyle(.plain)
|
||||
|
|
@ -735,7 +716,9 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
}
|
||||
.sheet(isPresented: $showAddFeedView) {
|
||||
NavigationView {
|
||||
LibraryAddFeedView()
|
||||
LibraryAddFeedView(dismiss: {
|
||||
showAddFeedView = false
|
||||
}, toastOperationHandler: nil)
|
||||
}
|
||||
}
|
||||
.alert("The Feature Section will be removed from your library. You can add it back from the filter settings in your profile.",
|
||||
|
|
@ -912,17 +895,24 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
ScrollView {
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 325, maximum: 400), spacing: 16)], alignment: .center, spacing: 30) {
|
||||
if viewModel.showLoadingBar {
|
||||
ForEach(fakeLibraryItems(dataService: dataService)) { item in
|
||||
GridCardNavigationLink(
|
||||
item: item,
|
||||
actionHandler: { contextMenuActionHandler(item: item, action: $0) },
|
||||
isContextMenuOpen: $isContextMenuOpen,
|
||||
viewModel: viewModel
|
||||
)
|
||||
ForEach(Array(fakeLibraryItems(dataService: dataService).enumerated()), id: \.1.id) { _, item in
|
||||
GridCard(item: item, isContextMenuOpen: $isContextMenuOpen, actionHandler: { _ in
|
||||
|
||||
})
|
||||
.aspectRatio(1.0, contentMode: .fill)
|
||||
.background(
|
||||
Color.secondarySystemGroupedBackground
|
||||
.onTapGesture {
|
||||
if isContextMenuOpen {
|
||||
isContextMenuOpen = false
|
||||
}
|
||||
}
|
||||
)
|
||||
.cornerRadius(6)
|
||||
}.redacted(reason: .placeholder)
|
||||
} else {
|
||||
ForEach(viewModel.fetcher.items) { item in
|
||||
GridCardNavigationLink(
|
||||
LibraryItemGridCardNavigationLink(
|
||||
item: item,
|
||||
actionHandler: { contextMenuActionHandler(item: item, action: $0) },
|
||||
isContextMenuOpen: $isContextMenuOpen,
|
||||
|
|
@ -930,6 +920,7 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
)
|
||||
}
|
||||
}
|
||||
BottomView(viewModel: viewModel)
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
|
|
@ -974,7 +965,7 @@ struct ScrollViewOffsetPreferenceKey: PreferenceKey {
|
|||
#if os(iOS)
|
||||
// Allows us to present a sheet without animation
|
||||
// Used to configure full screen modal view coming from share extension read now button action
|
||||
private extension View {
|
||||
public extension View {
|
||||
func withoutAnimation(_ completion: @escaping () -> Void) {
|
||||
UIView.setAnimationsEnabled(false)
|
||||
completion()
|
||||
|
|
@ -1009,20 +1000,71 @@ struct LinkDestination: View {
|
|||
}
|
||||
}
|
||||
|
||||
func fakeLibraryItems(dataService: DataService) -> [Models.LibraryItem] {
|
||||
let temp = Models.LibraryItem(context: dataService.viewContext)
|
||||
temp.id = UUID().uuidString
|
||||
temp.wordsCount = 100
|
||||
temp.author = "the author"
|
||||
temp.siteName = "omnivore dot app"
|
||||
temp.title = "This is a temporary title for a fake item"
|
||||
temp.highlights = []
|
||||
temp.imageURLString = "https://localhost/"
|
||||
return Array(
|
||||
repeatElement(temp, count: 20)
|
||||
.map { item in
|
||||
item.id = UUID().uuidString
|
||||
return item
|
||||
}
|
||||
)
|
||||
func fakeLibraryItems(dataService _: DataService) -> [LibraryItemData] {
|
||||
Array(
|
||||
repeatElement(0, count: 20)
|
||||
.map { _ in
|
||||
LibraryItemData(
|
||||
id: UUID().uuidString,
|
||||
title: "fake title that is kind of long so it looks better",
|
||||
pageURLString: "",
|
||||
isArchived: false,
|
||||
author: "fake author",
|
||||
deepLink: nil,
|
||||
hasLabels: false,
|
||||
noteText: nil,
|
||||
readingProgress: 10,
|
||||
wordsCount: 10,
|
||||
isPDF: false,
|
||||
highlights: nil,
|
||||
sortedLabels: [],
|
||||
imageURL: nil,
|
||||
publisherDisplayName: "fake publisher",
|
||||
descriptionText: "This is a fake description"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
struct BottomView: View {
|
||||
@ObservedObject var viewModel: HomeFeedViewModel
|
||||
@EnvironmentObject var dataService: DataService
|
||||
|
||||
@State var autoLoading = false
|
||||
|
||||
var body: some View {
|
||||
innerBody
|
||||
.listRowSeparator(.hidden)
|
||||
.onAppear {
|
||||
Task {
|
||||
autoLoading = true
|
||||
await viewModel.loadMore(dataService: dataService)
|
||||
autoLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var innerBody: some View {
|
||||
if viewModel.fetcher.items.count < 5 {
|
||||
AnyView(Color.clear)
|
||||
} else {
|
||||
AnyView(HStack {
|
||||
if !autoLoading {
|
||||
Text("You are all caught up.")
|
||||
}
|
||||
Spacer()
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
} else {
|
||||
Button(action: {
|
||||
Task {
|
||||
await viewModel.loadMore(dataService: dataService)
|
||||
}
|
||||
}, label: {
|
||||
Text("Refresh library")
|
||||
})
|
||||
.foregroundColor(Color.blue)
|
||||
}
|
||||
}.padding(10))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import SwiftUI
|
|||
import Utils
|
||||
import Views
|
||||
|
||||
@MainActor final class HomeFeedViewModel: NSObject, ObservableObject, NSFetchedResultsControllerDelegate {
|
||||
@MainActor final class HomeFeedViewModel: NSObject, ObservableObject, UINavigationControllerDelegate {
|
||||
let folder: String
|
||||
let fetcher: LibraryItemFetcher
|
||||
let listConfig: LibraryListConfig
|
||||
|
|
@ -19,18 +19,12 @@ import Views
|
|||
@Published var itemForHighlightsView: Models.LibraryItem?
|
||||
@Published var linkRequest: LinkRequest?
|
||||
@Published var presentWebContainer = false
|
||||
@Published var showLoadingBar = false
|
||||
@Published var isInMultiSelectMode = false
|
||||
@Published var showLoadingBar = true
|
||||
|
||||
@Published var selectedLinkItem: NSManagedObjectID? // used by mac app only
|
||||
@Published var selectedItem: Models.LibraryItem?
|
||||
@Published var linkIsActive = false
|
||||
|
||||
@Published var showLabelsSheet = false
|
||||
@Published var showFiltersModal = false
|
||||
@Published var showCommunityModal = false
|
||||
@Published var featureItems = [Models.LibraryItem]()
|
||||
|
||||
@Published var showSnackbar = false
|
||||
@Published var snackbarOperation: SnackbarOperation?
|
||||
|
||||
|
|
@ -41,9 +35,9 @@ import Views
|
|||
@Published var negatedLabels = [LinkedItemLabel]()
|
||||
@Published var appliedSort = LinkedItemSort.newest.rawValue
|
||||
|
||||
@AppStorage(UserDefaultKey.hideFeatureSection.rawValue) var hideFeatureSection = false
|
||||
@AppStorage(UserDefaultKey.lastSelectedFeaturedItemFilter.rawValue) var featureFilter = FeaturedItemFilter.continueReading.rawValue
|
||||
@State var lastMoreFetched: Date?
|
||||
|
||||
@AppStorage(UserDefaultKey.hideFeatureSection.rawValue) var hideFeatureSection = false
|
||||
@AppStorage("LibraryTabView::hideFollowingTab") var hideFollowingTab = false
|
||||
|
||||
@Published var appliedFilter: InternalFilter? {
|
||||
|
|
@ -64,26 +58,10 @@ import Views
|
|||
super.init()
|
||||
}
|
||||
|
||||
func updateFeatureFilter(context: NSManagedObjectContext, filter: FeaturedItemFilter?) {
|
||||
if let filter = filter {
|
||||
Task {
|
||||
featureFilter = filter.rawValue
|
||||
|
||||
featureItems = await loadFeatureItems(
|
||||
context: context,
|
||||
predicate: filter.predicate,
|
||||
sort: filter.sortDescriptor
|
||||
)
|
||||
}
|
||||
} else {
|
||||
featureItems = []
|
||||
}
|
||||
}
|
||||
|
||||
func loadFilters(dataService: DataService) async {
|
||||
switch folder {
|
||||
case "following":
|
||||
updateFilters(newFilters: InternalFilter.DefaultFollowingFilters, defaultName: "rss")
|
||||
updateFilters(newFilters: InternalFilter.DefaultFollowingFilters, defaultName: "following")
|
||||
default:
|
||||
var hasLocalResults = false
|
||||
let fetchRequest: NSFetchRequest<Models.Filter> = Filter.fetchRequest()
|
||||
|
|
@ -105,17 +83,20 @@ import Views
|
|||
}
|
||||
}
|
||||
|
||||
func itemAppeared(item: Models.LibraryItem, dataService: DataService) async {
|
||||
func loadMore(dataService: DataService) async {
|
||||
if isLoading { return }
|
||||
let itemIndex = fetcher.items.firstIndex(where: { $0.id == item.id })
|
||||
let thresholdIndex = fetcher.items.index(fetcher.items.endIndex, offsetBy: -5)
|
||||
|
||||
// Check if user has scrolled to the last five items in the list
|
||||
// Make sure we aren't currently loading though, as this would get triggered when the first set
|
||||
// of items are presented to the user.
|
||||
if let itemIndex = itemIndex, itemIndex > thresholdIndex {
|
||||
await loadMoreItems(dataService: dataService, filterState: filterState, isRefresh: false)
|
||||
let start = Date.now
|
||||
if let lastMoreFetched, lastMoreFetched.timeIntervalSinceNow > -4 {
|
||||
print("skipping fetching more as last fetch was too recent: ", lastMoreFetched)
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
await fetcher.loadMoreItems(dataService: dataService, filterState: filterState)
|
||||
isLoading = false
|
||||
|
||||
lastMoreFetched = start
|
||||
}
|
||||
|
||||
func pushFeedItem(item _: Models.LibraryItem) {
|
||||
|
|
@ -145,7 +126,7 @@ import Views
|
|||
filters = newFilters
|
||||
.filter { $0.folder == filterState.folder }
|
||||
.sorted(by: { $0.position < $1.position })
|
||||
+ (folder == "inbox" ? [InternalFilter.DeletedFilter, InternalFilter.DownloadedFilter] : [InternalFilter.DownloadedFilter])
|
||||
+ (folder == "inbox" ? [InternalFilter.UnreadFilter, InternalFilter.DeletedFilter, InternalFilter.DownloadedFilter] : [InternalFilter.DownloadedFilter])
|
||||
|
||||
if let newFilter = filters.first(where: { $0.name.lowercased() == appliedFilterName }), newFilter.id != appliedFilter?.id {
|
||||
appliedFilter = newFilter
|
||||
|
|
@ -157,27 +138,25 @@ import Views
|
|||
showLoadingBar = isRefresh
|
||||
|
||||
await fetcher.loadItems(dataService: dataService, filterState: filterState, isRefresh: isRefresh)
|
||||
updateFeatureFilter(context: dataService.viewContext, filter: FeaturedItemFilter(rawValue: featureFilter))
|
||||
|
||||
isLoading = false
|
||||
showLoadingBar = false
|
||||
}
|
||||
|
||||
func loadMoreItems(dataService: DataService, filterState: FetcherFilterState, isRefresh: Bool) async {
|
||||
isLoading = true
|
||||
|
||||
await fetcher.loadMoreItems(dataService: dataService, filterState: filterState, isRefresh: isRefresh)
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func loadFeatureItems(context: NSManagedObjectContext, predicate: NSPredicate, sort: NSSortDescriptor) async -> [Models.LibraryItem] {
|
||||
let fetchRequest: NSFetchRequest<Models.LibraryItem> = LibraryItem.fetchRequest()
|
||||
fetchRequest.fetchLimit = 25
|
||||
fetchRequest.predicate = predicate
|
||||
fetchRequest.sortDescriptors = [sort]
|
||||
|
||||
return (try? context.fetch(fetchRequest)) ?? []
|
||||
do {
|
||||
let fetched = try context.fetch(fetchRequest)
|
||||
return fetched
|
||||
} catch {
|
||||
print("ERROR FETCHING: ", error)
|
||||
}
|
||||
return []
|
||||
// return (try? context.fetch(fetchRequest)) ?? []
|
||||
}
|
||||
|
||||
func snackbar(_ message: String, undoAction: SnackbarUndoAction? = nil) {
|
||||
|
|
@ -187,7 +166,7 @@ import Views
|
|||
|
||||
func setLinkArchived(dataService: DataService, objectID: NSManagedObjectID, archived: Bool) {
|
||||
dataService.archiveLink(objectID: objectID, archived: archived)
|
||||
snackbar(archived ? "Link archived" : "Link moved to Inbox")
|
||||
snackbar(archived ? "Link archived" : "Link unarchived")
|
||||
}
|
||||
|
||||
func removeLibraryItem(dataService: DataService, objectID: NSManagedObjectID) {
|
||||
|
|
@ -221,7 +200,7 @@ import Views
|
|||
dataService.setItemLabels(itemID: item.unwrappedID, labels: InternalLinkedItemLabel.make(Set(existingLabels + [label]) as NSSet))
|
||||
|
||||
item.update(inContext: dataService.viewContext)
|
||||
updateFeatureFilter(context: dataService.viewContext, filter: FeaturedItemFilter(rawValue: featureFilter))
|
||||
fetcher.refreshFeatureItems(dataService: dataService)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -235,16 +214,12 @@ import Views
|
|||
|
||||
func pinItem(dataService: DataService, item: Models.LibraryItem) {
|
||||
addLabel(dataService: dataService, item: item, label: "Pinned", color: "#0A84FF")
|
||||
if featureFilter == FeaturedItemFilter.pinned.rawValue {
|
||||
updateFeatureFilter(context: dataService.viewContext, filter: .pinned)
|
||||
}
|
||||
fetcher.refreshFeatureItems(dataService: dataService)
|
||||
}
|
||||
|
||||
func unpinItem(dataService: DataService, item: Models.LibraryItem) {
|
||||
removeLabel(dataService: dataService, item: item, named: "Pinned")
|
||||
if featureFilter == FeaturedItemFilter.pinned.rawValue {
|
||||
updateFeatureFilter(context: dataService.viewContext, filter: .pinned)
|
||||
}
|
||||
fetcher.refreshFeatureItems(dataService: dataService)
|
||||
}
|
||||
|
||||
func markRead(dataService: DataService, item: Models.LibraryItem) {
|
||||
|
|
|
|||
|
|
@ -5,43 +5,16 @@ import Services
|
|||
import SwiftUI
|
||||
import Views
|
||||
|
||||
@MainActor final class LibraryAddFeedViewModel: NSObject, ObservableObject {
|
||||
@Published var isLoading = false
|
||||
@Published var errorMessage: String = ""
|
||||
@Published var showErrorMessage: Bool = false
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
func addLink(dataService: DataService, newLinkURL: String, dismiss: DismissAction) {
|
||||
isLoading = true
|
||||
Task {
|
||||
if URL(string: newLinkURL) == nil {
|
||||
error("Invalid link")
|
||||
} else {
|
||||
let result = try? await dataService.saveURL(id: UUID().uuidString, url: newLinkURL)
|
||||
if result == nil {
|
||||
error("Error adding link")
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
func error(_ msg: String) {
|
||||
errorMessage = msg
|
||||
showErrorMessage = true
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
struct LibraryAddFeedView: View {
|
||||
@StateObject var viewModel = LibraryAddFeedViewModel()
|
||||
|
||||
@State var newLinkURL: String = ""
|
||||
let dismiss: () -> Void
|
||||
@State var feedURL: String = ""
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State var prefetchContent = true
|
||||
@State var folderSelection = "following"
|
||||
@State var selectedLabels = [LinkedItemLabel]()
|
||||
|
||||
let toastOperationHandler: ToastOperationHandler?
|
||||
|
||||
enum FocusField: Hashable {
|
||||
case addLinkEditor
|
||||
|
|
@ -52,11 +25,9 @@ struct LibraryAddFeedView: View {
|
|||
var body: some View {
|
||||
Group {
|
||||
#if os(iOS)
|
||||
Form {
|
||||
innerBody
|
||||
.navigationTitle("Add Link")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
innerBody
|
||||
.navigationTitle("Add Feed URL")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#else
|
||||
innerBody
|
||||
#endif
|
||||
|
|
@ -67,7 +38,6 @@ struct LibraryAddFeedView: View {
|
|||
.onAppear {
|
||||
focusedField = .addLinkEditor
|
||||
}
|
||||
.navigationTitle("Add Link")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
|
|
@ -75,14 +45,23 @@ struct LibraryAddFeedView: View {
|
|||
dismissButton
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
viewModel.isLoading ? AnyView(ProgressView()) : AnyView(addButton)
|
||||
NavigationLink(
|
||||
destination: LibraryScanFeedView(
|
||||
dismiss: self.dismiss,
|
||||
viewModel: LibraryAddFeedViewModel(
|
||||
dataService: dataService,
|
||||
feedURL: feedURL,
|
||||
prefetchContent: prefetchContent,
|
||||
folder: folderSelection,
|
||||
selectedLabels: selectedLabels,
|
||||
toastOperationHandler: toastOperationHandler
|
||||
)
|
||||
),
|
||||
label: { Text("Add").bold().disabled(feedURL.isEmpty) }
|
||||
)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
.alert(viewModel.errorMessage,
|
||||
isPresented: $viewModel.showErrorMessage) {
|
||||
Button(LocalText.genericOk, role: .cancel) { viewModel.showErrorMessage = false }
|
||||
}
|
||||
}
|
||||
|
||||
var cancelButton: some View {
|
||||
|
|
@ -101,49 +80,36 @@ struct LibraryAddFeedView: View {
|
|||
}
|
||||
|
||||
var innerBody: some View {
|
||||
Group {
|
||||
TextField("Add Link", text: $newLinkURL)
|
||||
#if os(iOS)
|
||||
.keyboardType(.URL)
|
||||
#endif
|
||||
.autocorrectionDisabled(true)
|
||||
.textFieldStyle(StandardTextFieldStyle())
|
||||
.focused($focusedField, equals: .addLinkEditor)
|
||||
List {
|
||||
Section {
|
||||
TextField("Feed or site URL", text: $feedURL)
|
||||
#if os(iOS)
|
||||
.keyboardType(.URL)
|
||||
#endif
|
||||
.autocorrectionDisabled(true)
|
||||
.textFieldStyle(StandardTextFieldStyle())
|
||||
.focused($focusedField, equals: .addLinkEditor)
|
||||
|
||||
Button(action: {
|
||||
if let url = pasteboardString {
|
||||
newLinkURL = url
|
||||
} else {
|
||||
viewModel.error("No URL on pasteboard")
|
||||
}
|
||||
}, label: {
|
||||
Text("Get from pasteboard")
|
||||
})
|
||||
Button(action: {
|
||||
if let url = pasteboardString {
|
||||
feedURL = url
|
||||
} else {
|
||||
// viewModel.error("No URL on pasteboard")
|
||||
}
|
||||
}, label: {
|
||||
Text("Get from pasteboard")
|
||||
})
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
Spacer()
|
||||
HStack {
|
||||
cancelButton
|
||||
Spacer()
|
||||
addButton
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
var addButton: some View {
|
||||
Button(
|
||||
action: {
|
||||
viewModel.addLink(dataService: dataService, newLinkURL: newLinkURL, dismiss: dismiss)
|
||||
},
|
||||
label: { Text("Add").bold() }
|
||||
)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.onSubmit {
|
||||
viewModel.addLink(dataService: dataService, newLinkURL: newLinkURL, dismiss: dismiss)
|
||||
}
|
||||
.disabled(viewModel.isLoading)
|
||||
Section {
|
||||
SubscriptionSettings(
|
||||
feedURL: $feedURL,
|
||||
prefetchContent: $prefetchContent,
|
||||
folderSelection: $folderSelection,
|
||||
selectedLabels: $selectedLabels
|
||||
)
|
||||
}
|
||||
}.listStyle(.insetGrouped)
|
||||
}
|
||||
|
||||
var dismissButton: some View {
|
||||
|
|
@ -151,6 +117,53 @@ struct LibraryAddFeedView: View {
|
|||
action: { dismiss() },
|
||||
label: { Text(LocalText.genericClose) }
|
||||
)
|
||||
.disabled(viewModel.isLoading)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SubscriptionSettings: View {
|
||||
@Binding var feedURL: String
|
||||
@Binding var prefetchContent: Bool
|
||||
@Binding var folderSelection: String
|
||||
@Binding var selectedLabels: [LinkedItemLabel]
|
||||
|
||||
@State var showLabelsSelector = false
|
||||
|
||||
var folderRow: some View {
|
||||
HStack {
|
||||
Picker("Destination Folder", selection: $folderSelection) {
|
||||
Text("Inbox").tag("inbox")
|
||||
Text("Following").tag("following")
|
||||
}
|
||||
.pickerStyle(MenuPickerStyle())
|
||||
}
|
||||
}
|
||||
|
||||
var labelRuleRow: some View {
|
||||
HStack {
|
||||
Text("Add Labels")
|
||||
Spacer(minLength: 30)
|
||||
Button(action: { showLabelsSelector = true }, label: {
|
||||
if selectedLabels.count > 0 {
|
||||
let labelNames = selectedLabels.map(\.unwrappedName)
|
||||
Text("[\(labelNames.joined(separator: ","))]")
|
||||
.lineLimit(1)
|
||||
} else {
|
||||
Text("Create Rule")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
// Toggle(isOn: $prefetchContent, label: { Text("Prefetch Content:") })
|
||||
folderRow
|
||||
// labelRuleRow
|
||||
}
|
||||
.sheet(isPresented: $showLabelsSelector) {
|
||||
ApplyLabelsView(mode: .list(selectedLabels), onSave: { labels in
|
||||
selectedLabels = labels
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,33 +17,6 @@ import Views
|
|||
action: { viewModel.itemUnderLabelEdit = item },
|
||||
label: { Label(item.labels?.count == 0 ? "Add Labels" : "Edit Labels", systemImage: "tag") }
|
||||
)
|
||||
// Button(action: {
|
||||
// withAnimation(.linear(duration: 0.4)) {
|
||||
// viewModel.setLinkArchived(
|
||||
// dataService: dataService,
|
||||
// objectID: item.objectID,
|
||||
// archived: !item.isArchived
|
||||
// )
|
||||
// }
|
||||
// }, label: {
|
||||
// Label(
|
||||
// item.isArchived ? "Unarchive" : "Archive",
|
||||
// systemImage: item.isArchived ? "tray.and.arrow.down.fill" : "archivebox"
|
||||
// )
|
||||
// })
|
||||
// Button("Remove Item", role: .destructive) {
|
||||
// viewModel.removeLink(dataService: dataService, objectID: item.objectID)
|
||||
// }
|
||||
// if let author = item.author {
|
||||
// Button(
|
||||
// action: {
|
||||
// viewModel.filterState.searchTerm = "author:\"\(author)\""
|
||||
// },
|
||||
// label: {
|
||||
// Label(String("More by \(author)"), systemImage: "person")
|
||||
// }
|
||||
// )
|
||||
// }
|
||||
} else {
|
||||
Button(
|
||||
action: { viewModel.recoverItem(dataService: dataService, itemID: item.unwrappedID) },
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ enum CardStyle {
|
|||
|
||||
struct LibraryListConfig {
|
||||
var hasFeatureCards = false
|
||||
var hasReadNowSection = false
|
||||
var leadingSwipeActions = [SwipeAction]()
|
||||
var trailingSwipeActions = [SwipeAction]()
|
||||
var cardStyle = CardStyle.library
|
||||
|
|
|
|||
|
|
@ -1,42 +0,0 @@
|
|||
//
|
||||
// File.swift
|
||||
//
|
||||
//
|
||||
// Created by Jackson Harper on 6/29/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Models
|
||||
import SwiftUI
|
||||
|
||||
struct LibraryListView: View {
|
||||
@StateObject private var libraryViewModel = HomeFeedViewModel(
|
||||
folder: "inbox",
|
||||
fetcher: LibraryItemFetcher(),
|
||||
listConfig: LibraryListConfig(
|
||||
hasFeatureCards: true,
|
||||
leadingSwipeActions: [.pin],
|
||||
trailingSwipeActions: [.archive, .delete],
|
||||
cardStyle: .library
|
||||
)
|
||||
)
|
||||
|
||||
var body: some View {
|
||||
// ZStack {
|
||||
// NavigationLink(
|
||||
// destination: LinkDestination(selectedItem: libraryViewModel.selectedItem),
|
||||
// isActive: $libraryViewModel.linkIsActive
|
||||
// ) {
|
||||
// EmptyView()
|
||||
// }
|
||||
HomeView(viewModel: libraryViewModel)
|
||||
.tabItem {
|
||||
Label {
|
||||
Text("Library")
|
||||
} icon: {
|
||||
Image.tabLibrary
|
||||
}
|
||||
}
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,231 @@
|
|||
import Foundation
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Utils
|
||||
|
||||
@MainActor
|
||||
public class LibraryAddFeedViewModel: NSObject, ObservableObject {
|
||||
let dataService: DataService
|
||||
let feedURL: String
|
||||
let prefetchContent: Bool
|
||||
let folder: String
|
||||
let selectedLabels: [LinkedItemLabel]
|
||||
let toastOperationHandler: ToastOperationHandler?
|
||||
|
||||
@Published var isLoading = true
|
||||
@Published var errorMessage: String = ""
|
||||
@Published var showErrorMessage: Bool = false
|
||||
|
||||
@Published var feeds: [Feed] = []
|
||||
@Published var selected: [String] = []
|
||||
|
||||
init(dataService: DataService, feedURL: String, prefetchContent: Bool, folder: String, selectedLabels: [LinkedItemLabel], toastOperationHandler: ToastOperationHandler?) {
|
||||
self.dataService = dataService
|
||||
self.feedURL = feedURL
|
||||
self.prefetchContent = prefetchContent
|
||||
self.folder = folder
|
||||
self.selectedLabels = selectedLabels
|
||||
self.toastOperationHandler = toastOperationHandler
|
||||
}
|
||||
|
||||
func scanFeed() async {
|
||||
isLoading = true
|
||||
if let feedURL = URL(string: feedURL) {
|
||||
let result = try? await dataService.scanFeed(feedURL: feedURL)
|
||||
if let feeds = result {
|
||||
self.feeds = feeds
|
||||
selected = feeds.map(\.url)
|
||||
} else {
|
||||
feeds = []
|
||||
error("Error adding feed")
|
||||
}
|
||||
} else {
|
||||
error("invalid URL")
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func addFeeds() async {
|
||||
if let toastOperationHandler = toastOperationHandler {
|
||||
toastOperationHandler.update(OperationStatus.isPerforming, "Subscribing...")
|
||||
|
||||
let selected = self.selected
|
||||
|
||||
let addTask = Task.detached(priority: .background) {
|
||||
_ = await withTaskGroup(of: Bool.self) { group in
|
||||
for feedURL in selected {
|
||||
group.addTask {
|
||||
(try? await self.dataService.subscribeToFeed(feedURL: feedURL, folder: self.folder, fetchContent: self.prefetchContent)) ?? false
|
||||
}
|
||||
}
|
||||
|
||||
var successCount = 0
|
||||
var failureCount = 0
|
||||
for await value in group {
|
||||
if value {
|
||||
successCount += 1
|
||||
} else {
|
||||
failureCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
let hasFailures = failureCount
|
||||
DispatchQueue.main.async {
|
||||
if hasFailures > 0 {
|
||||
toastOperationHandler.update(OperationStatus.failure, "Failed to subscribe to \(hasFailures) feeds")
|
||||
} else {
|
||||
toastOperationHandler.update(OperationStatus.success, "Subscribed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
toastOperationHandler.performOperation(addTask)
|
||||
} else {
|
||||
_ = await withTaskGroup(of: Bool.self) { group in
|
||||
for feedURL in selected {
|
||||
group.addTask {
|
||||
(try? await self.dataService.subscribeToFeed(feedURL: feedURL, folder: self.folder, fetchContent: self.prefetchContent)) ?? false
|
||||
}
|
||||
}
|
||||
|
||||
var successCount = 0
|
||||
var failureCount = 0
|
||||
for await value in group {
|
||||
if value {
|
||||
successCount += 1
|
||||
} else {
|
||||
failureCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(4000)) {
|
||||
if failureCount > 0 {
|
||||
showInLibrarySnackbar("Failed to add \(failureCount) feeds")
|
||||
} else {
|
||||
showInLibrarySnackbar("Added \(successCount) feed\(successCount == 0 ? "" : "s")")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setLabelsRule(dataService _: DataService, existingRule _: Rule?, ruleName _: String, filter _: String, labelIDs _: [String]) async {
|
||||
// Task {
|
||||
// operationMessage = "Creating label rule..."
|
||||
// operationStatus = .isPerforming
|
||||
// do {
|
||||
// // Make sure the labels have been created
|
||||
// await loadLabels(dataService: dataService)
|
||||
// let existingLabelIDs = labels?.map(\.unwrappedID) ?? []
|
||||
// if labelIDs.first(where: { !existingLabelIDs.contains($0) }) != nil {
|
||||
// throw BasicError.message(messageText: "Label not created")
|
||||
// }
|
||||
//
|
||||
// _ = try await dataService.createOrUpdateAddLabelsRule(
|
||||
// existingID: existingRule?.id,
|
||||
// name: ruleName,
|
||||
// filter: filter,
|
||||
// labelIDs: labelIDs
|
||||
// )
|
||||
// if let newRules = try? await dataService.rules() {
|
||||
// if !newRules.contains(where: { $0.name == ruleName }) {
|
||||
// throw BasicError.message(messageText: "Rule not created")
|
||||
// }
|
||||
// rules = newRules
|
||||
// }
|
||||
// operationMessage = "Rule created"
|
||||
// operationStatus = .success
|
||||
// } catch {
|
||||
// operationMessage = "Failed to create label rule"
|
||||
// operationStatus = .failure
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
func error(_ msg: String) {
|
||||
errorMessage = msg
|
||||
showErrorMessage = true
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public struct LibraryScanFeedView: View {
|
||||
let dismiss: () -> Void
|
||||
@StateObject var viewModel: LibraryAddFeedViewModel
|
||||
|
||||
func isSelected(_ url: String) -> Bool {
|
||||
viewModel.selected.contains(url)
|
||||
}
|
||||
|
||||
var innerBody: some View {
|
||||
if viewModel.isLoading {
|
||||
AnyView(ProgressView().frame(maxWidth: .infinity, alignment: .center))
|
||||
} else if viewModel.feeds.count == 0 {
|
||||
AnyView(Text("No feeds found for URL"))
|
||||
} else {
|
||||
AnyView(List {
|
||||
Section("Choose the feeds to add") {
|
||||
ForEach(viewModel.feeds, id: \.title) { feed in
|
||||
Button(action: {
|
||||
if !isSelected(feed.url) {
|
||||
viewModel.selected.append(feed.url)
|
||||
} else {
|
||||
if let idx = viewModel.selected.firstIndex(of: feed.url) {
|
||||
viewModel.selected.remove(at: idx)
|
||||
}
|
||||
}
|
||||
}, label: {
|
||||
HStack {
|
||||
Text(feed.title)
|
||||
Spacer()
|
||||
if isSelected(feed.url) {
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Group {
|
||||
#if os(iOS)
|
||||
Form {
|
||||
innerBody
|
||||
.navigationTitle("Select Feeds")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}.task {
|
||||
await viewModel.scanFeed()
|
||||
}
|
||||
#else
|
||||
innerBody
|
||||
#endif
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .barTrailing) {
|
||||
if viewModel.selected.count > 0 {
|
||||
Button(action: {
|
||||
dismiss()
|
||||
showInLibrarySnackbar("Adding feeds...")
|
||||
Task {
|
||||
await viewModel.addFeeds()
|
||||
}
|
||||
}, label: {
|
||||
Text("Add").bold().disabled(viewModel.selected.count < 1)
|
||||
})
|
||||
} else {
|
||||
Button(action: {
|
||||
dismiss()
|
||||
}, label: {
|
||||
Text("Done").bold()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -75,6 +75,7 @@
|
|||
innerBody
|
||||
}.introspectViewController { controller in
|
||||
searchBar = Introspect.findChild(ofType: UISearchBar.self, in: controller.view)
|
||||
searchBar?.smartQuotesType = .no
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Utils
|
||||
import Views
|
||||
|
||||
@MainActor
|
||||
|
|
@ -195,15 +196,21 @@ struct ApplyLabelsView: View {
|
|||
}
|
||||
}
|
||||
|
||||
func isSystemLabel(_ label: LinkedItemLabel) -> Bool {
|
||||
label.name == "RSS" || label.name == "Newsletter" || label.name == "Pinned"
|
||||
}
|
||||
|
||||
extension Sequence where Element == LinkedItemLabel {
|
||||
func applySearchFilter(_ searchFilter: String) -> [LinkedItemLabel] {
|
||||
let hideSystemLabels = UserDefaults(suiteName: "group.app.omnivoreapp")?.bool(forKey: UserDefaultKey.hideSystemLabels.rawValue) ?? false
|
||||
|
||||
if searchFilter.isEmpty || searchFilter == ZWSP {
|
||||
return map { $0 } // return the identity of the sequence
|
||||
}
|
||||
if searchFilter.starts(with: ZWSP) {
|
||||
let index = searchFilter.index(searchFilter.startIndex, offsetBy: 1)
|
||||
let trimmed = searchFilter.suffix(from: index).lowercased()
|
||||
return filter { ($0.name ?? "").lowercased().contains(trimmed) }
|
||||
return filter { ($0.name ?? "").lowercased().contains(trimmed) && (!hideSystemLabels || !isSystemLabel($0)) }
|
||||
}
|
||||
return filter { ($0.name ?? "").lowercased().contains(searchFilter.lowercased()) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,11 +2,12 @@ import CoreData
|
|||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Utils
|
||||
import Views
|
||||
|
||||
@MainActor final class FilterByLabelsViewModel: ObservableObject {
|
||||
@Published var isLoading = false
|
||||
@Published var errorMessage: String? = nil
|
||||
@Published var errorMessage: String?
|
||||
@Published var labels = [LinkedItemLabel]()
|
||||
@Published var selectedLabels = [LinkedItemLabel]()
|
||||
@Published var negatedLabels = [LinkedItemLabel]()
|
||||
|
|
@ -14,7 +15,9 @@ import Views
|
|||
@Published var labelSearchFilter = ""
|
||||
|
||||
func setLabels(_ labels: [LinkedItemLabel]) {
|
||||
self.labels = labels.sorted { left, right in
|
||||
let hideSystemLabels = UserDefaults(suiteName: "group.app.omnivoreapp")?.bool(forKey: UserDefaultKey.hideSystemLabels.rawValue) ?? false
|
||||
|
||||
self.labels = labels.filter { !hideSystemLabels || !isSystemLabel($0) }.sorted { left, right in
|
||||
let aTrimmed = left.unwrappedName.trimmingCharacters(in: .whitespaces)
|
||||
let bTrimmed = right.unwrappedName.trimmingCharacters(in: .whitespaces)
|
||||
return aTrimmed.caseInsensitiveCompare(bTrimmed) == .orderedAscending
|
||||
|
|
|
|||
|
|
@ -10,22 +10,31 @@ struct LabelsView: View {
|
|||
@State private var showDeleteConfirmation = false
|
||||
@State private var labelToRemove: LinkedItemLabel?
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@AppStorage(UserDefaultKey.hideSystemLabels.rawValue, store: UserDefaults(suiteName: "group.app.omnivoreapp")) var hideSystemLabels = false
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach(viewModel.labels, id: \.id) { label in
|
||||
HStack {
|
||||
TextChip(feedItemLabel: label).allowsHitTesting(false)
|
||||
Spacer()
|
||||
Button(
|
||||
action: {
|
||||
labelToRemove = label
|
||||
showDeleteConfirmation = true
|
||||
},
|
||||
label: { Image(systemName: "trash") }
|
||||
)
|
||||
Section {
|
||||
ForEach(viewModel.labels, id: \.id) { label in
|
||||
HStack {
|
||||
TextChip(feedItemLabel: label).allowsHitTesting(false)
|
||||
Spacer()
|
||||
Button(
|
||||
action: {
|
||||
labelToRemove = label
|
||||
showDeleteConfirmation = true
|
||||
},
|
||||
label: { Image(systemName: "trash") }
|
||||
)
|
||||
}
|
||||
}
|
||||
createLabelButton
|
||||
}
|
||||
Section("Label settings") {
|
||||
Toggle("Hide system labels", isOn: $hideSystemLabels)
|
||||
}
|
||||
createLabelButton
|
||||
}
|
||||
.navigationTitle(LocalText.labelsGeneric)
|
||||
.alert("Are you sure you want to delete this label?", isPresented: $showDeleteConfirmation) {
|
||||
|
|
@ -43,6 +52,14 @@ struct LabelsView: View {
|
|||
}
|
||||
Button(LocalText.cancelGeneric, role: .cancel) { self.labelToRemove = nil }
|
||||
}
|
||||
.onChange(of: hideSystemLabels) { _ in
|
||||
Task {
|
||||
await viewModel.loadLabels(dataService: dataService, item: nil)
|
||||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("ScrollToTop"))) { _ in
|
||||
dismiss()
|
||||
}
|
||||
.sheet(isPresented: $viewModel.showCreateLabelModal) {
|
||||
CreateLabelView(viewModel: viewModel, newLabelName: viewModel.labelSearchFilter)
|
||||
}
|
||||
|
|
@ -57,7 +74,7 @@ struct LabelsView: View {
|
|||
let trimmedLabelName = viewModel.labelSearchFilter.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
Image(systemName: "tag").foregroundColor(.blue)
|
||||
Text(
|
||||
viewModel.labelSearchFilter.count > 0 ?
|
||||
viewModel.labelSearchFilter.count > 0 && viewModel.labelSearchFilter != ZWSP ?
|
||||
"Create: \"\(trimmedLabelName)\" label" :
|
||||
LocalText.createLabelMessage
|
||||
).foregroundColor(.blue)
|
||||
|
|
@ -106,16 +123,6 @@ struct CreateLabelView: View {
|
|||
|
||||
var innerBody: some View {
|
||||
VStack {
|
||||
HStack {
|
||||
if !newLabelName.isEmpty, newLabelColor != .clear {
|
||||
TextChip(text: newLabelName, color: newLabelColor)
|
||||
} else {
|
||||
Text(LocalText.labelsViewAssignNameColor).font(.appBody)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
|
||||
TextField(LocalText.labelNamePlaceholder, text: $newLabelName)
|
||||
.textFieldStyle(StandardTextFieldStyle())
|
||||
.onChange(of: newLabelName) { inputLabelName in
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import CoreData
|
|||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Utils
|
||||
|
||||
@MainActor public final class LabelsViewModel: ObservableObject {
|
||||
let labelNameMaxLength = 64
|
||||
|
|
@ -16,7 +17,9 @@ import SwiftUI
|
|||
public init() {}
|
||||
|
||||
func setLabels(_ labels: [LinkedItemLabel]) {
|
||||
self.labels = labels.sorted { left, right in
|
||||
let hideSystemLabels = UserDefaults(suiteName: "group.app.omnivoreapp")?.bool(forKey: UserDefaultKey.hideSystemLabels.rawValue) ?? false
|
||||
|
||||
self.labels = labels.filter { !hideSystemLabels || !isSystemLabel($0) }.sorted { left, right in
|
||||
let aTrimmed = left.unwrappedName.trimmingCharacters(in: .whitespaces)
|
||||
let bTrimmed = right.unwrappedName.trimmingCharacters(in: .whitespaces)
|
||||
return aTrimmed.caseInsensitiveCompare(bTrimmed) == .orderedAscending
|
||||
|
|
|
|||
|
|
@ -21,36 +21,6 @@ import SwiftUI
|
|||
@AppStorage("inboxMenuState") var inboxMenuState = "open"
|
||||
@AppStorage("followingMenuState") var followingMenuState = "open"
|
||||
|
||||
func createInboxViewModel(_ filter: InternalFilter) -> HomeFeedViewModel {
|
||||
let result = HomeFeedViewModel(
|
||||
folder: "inbox",
|
||||
fetcher: LibraryItemFetcher(),
|
||||
listConfig: LibraryListConfig(
|
||||
hasFeatureCards: true,
|
||||
leadingSwipeActions: [.pin],
|
||||
trailingSwipeActions: [.archive, .delete],
|
||||
cardStyle: .library
|
||||
)
|
||||
)
|
||||
result.appliedFilter = filter
|
||||
return result
|
||||
}
|
||||
|
||||
func createFollowingViewModel(_ filter: InternalFilter) -> HomeFeedViewModel {
|
||||
let result = HomeFeedViewModel(
|
||||
folder: "following",
|
||||
fetcher: LibraryItemFetcher(),
|
||||
listConfig: LibraryListConfig(
|
||||
hasFeatureCards: false,
|
||||
leadingSwipeActions: [.moveToInbox],
|
||||
trailingSwipeActions: [.archive, .delete],
|
||||
cardStyle: .library
|
||||
)
|
||||
)
|
||||
result.appliedFilter = filter
|
||||
return result
|
||||
}
|
||||
|
||||
var innerBody: some View {
|
||||
ZStack {
|
||||
NavigationLink("", destination: HomeView(viewModel: inboxViewModel), isActive: $inboxActive)
|
||||
|
|
|
|||
|
|
@ -1,16 +1,18 @@
|
|||
import Foundation
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
public struct LibrarySplitView: View {
|
||||
@EnvironmentObject var audioController: AudioController
|
||||
@EnvironmentObject var dataService: DataService
|
||||
|
||||
@StateObject private var inboxViewModel = HomeFeedViewModel(
|
||||
folder: "inbox",
|
||||
fetcher: LibraryItemFetcher(),
|
||||
listConfig: LibraryListConfig(
|
||||
hasFeatureCards: true,
|
||||
hasReadNowSection: true,
|
||||
leadingSwipeActions: [.pin],
|
||||
trailingSwipeActions: [.archive, .delete],
|
||||
cardStyle: .library
|
||||
|
|
@ -22,25 +24,25 @@ public struct LibrarySplitView: View {
|
|||
fetcher: LibraryItemFetcher(),
|
||||
listConfig: LibraryListConfig(
|
||||
hasFeatureCards: false,
|
||||
hasReadNowSection: false,
|
||||
leadingSwipeActions: [.moveToInbox],
|
||||
trailingSwipeActions: [.archive, .delete],
|
||||
trailingSwipeActions: [.delete],
|
||||
cardStyle: .library
|
||||
)
|
||||
)
|
||||
|
||||
@State var selected = "home"
|
||||
private let syncManager = LibrarySyncManager()
|
||||
|
||||
#if os(iOS)
|
||||
public var body: some View {
|
||||
NavigationView {
|
||||
LibrarySidebar(inboxViewModel: inboxViewModel, followingViewModel: followingViewModel)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.tag("inbox")
|
||||
.navigationTitle("")
|
||||
|
||||
HomeFeedContainerView(viewModel: inboxViewModel)
|
||||
.navigationViewStyle(.stack)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.tag("following")
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.accentColor(.appGrayTextContrast)
|
||||
|
|
@ -48,6 +50,32 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
|
|
|
|||
|
|
@ -29,28 +29,32 @@ struct LibraryTabView: View {
|
|||
UITabBar.appearance().isHidden = true
|
||||
}
|
||||
|
||||
@StateObject private var followingViewModel = HomeFeedViewModel(
|
||||
folder: "following",
|
||||
fetcher: LibraryItemFetcher(),
|
||||
listConfig: LibraryListConfig(
|
||||
hasFeatureCards: false,
|
||||
leadingSwipeActions: [.moveToInbox],
|
||||
trailingSwipeActions: [.archive, .delete],
|
||||
cardStyle: .library
|
||||
)
|
||||
)
|
||||
|
||||
@StateObject private var libraryViewModel = HomeFeedViewModel(
|
||||
@StateObject private var inboxViewModel = HomeFeedViewModel(
|
||||
folder: "inbox",
|
||||
fetcher: LibraryItemFetcher(),
|
||||
listConfig: LibraryListConfig(
|
||||
hasFeatureCards: true,
|
||||
hasReadNowSection: true,
|
||||
leadingSwipeActions: [.pin],
|
||||
trailingSwipeActions: [.archive, .delete],
|
||||
cardStyle: .library
|
||||
)
|
||||
)
|
||||
|
||||
@StateObject private var followingViewModel = HomeFeedViewModel(
|
||||
folder: "following",
|
||||
fetcher: LibraryItemFetcher(),
|
||||
listConfig: LibraryListConfig(
|
||||
hasFeatureCards: false,
|
||||
hasReadNowSection: false,
|
||||
leadingSwipeActions: [.moveToInbox],
|
||||
trailingSwipeActions: [.delete],
|
||||
cardStyle: .library
|
||||
)
|
||||
)
|
||||
|
||||
private let syncManager = LibrarySyncManager()
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
TabView(selection: $selectedTab) {
|
||||
|
|
@ -63,7 +67,7 @@ struct LibraryTabView: View {
|
|||
}
|
||||
|
||||
NavigationView {
|
||||
HomeFeedContainerView(viewModel: libraryViewModel)
|
||||
HomeFeedContainerView(viewModel: inboxViewModel)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationViewStyle(.stack)
|
||||
}.tag("inbox")
|
||||
|
|
@ -90,5 +94,31 @@ struct LibraryTabView: View {
|
|||
ExpandedAudioPlayer()
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
.onReceive(NSNotification.performSyncPublisher) { _ in
|
||||
Task {
|
||||
await syncManager.syncItems(dataService: dataService)
|
||||
}
|
||||
}
|
||||
.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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,28 +22,6 @@ import Views
|
|||
trackReadEvent()
|
||||
}
|
||||
|
||||
func handleArchiveAction(dataService: DataService) {
|
||||
guard let objectID = item?.objectID ?? pdfItem?.objectID else { return }
|
||||
dataService.archiveLink(objectID: objectID, archived: !isItemArchived)
|
||||
showInLibrarySnackbar(!isItemArchived ? "Link archived" : "Link moved to Inbox")
|
||||
}
|
||||
|
||||
func handleDeleteAction(dataService: DataService) {
|
||||
guard let objectID = item?.objectID ?? pdfItem?.objectID else { return }
|
||||
removeLibraryItemAction(dataService: dataService, objectID: objectID)
|
||||
}
|
||||
|
||||
func updateItemReadStatus(dataService: DataService) {
|
||||
guard let itemID = item?.unwrappedID ?? pdfItem?.itemID else { return }
|
||||
|
||||
dataService.updateLinkReadingProgress(
|
||||
itemID: itemID,
|
||||
readingProgress: isItemRead ? 0 : 100,
|
||||
anchorIndex: 0,
|
||||
force: false
|
||||
)
|
||||
}
|
||||
|
||||
private func trackReadEvent() {
|
||||
guard let itemID = item?.unwrappedID ?? pdfItem?.itemID else { return }
|
||||
guard let slug = item?.unwrappedSlug ?? pdfItem?.slug else { return }
|
||||
|
|
|
|||
33
apple/OmnivoreKit/Sources/App/Views/LogoutView.swift
Normal file
33
apple/OmnivoreKit/Sources/App/Views/LogoutView.swift
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Utils
|
||||
import Views
|
||||
|
||||
struct LogoutView: View {
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@EnvironmentObject var authenticator: Authenticator
|
||||
@Environment(\.openURL) var openURL
|
||||
|
||||
let deletedAccountConfirmationMessage = "Your account has been deleted. Additional steps may be needed if Sign in with Apple was used to register."
|
||||
|
||||
public var body: some View {
|
||||
VStack(alignment: .center) {
|
||||
Text("Logging out...")
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.task {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(300)) {
|
||||
authenticator.logout(dataService: dataService)
|
||||
}
|
||||
}
|
||||
.alert(deletedAccountConfirmationMessage, isPresented: $authenticator.showAppleRevokeTokenAlert) {
|
||||
Button("View Details") {
|
||||
openURL(URL(string: "https://support.apple.com/en-us/HT210426")!)
|
||||
}
|
||||
Button(LocalText.dismissButton) { self.authenticator.showAppleRevokeTokenAlert = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
46
apple/OmnivoreKit/Sources/App/Views/OperationToast.swift
Normal file
46
apple/OmnivoreKit/Sources/App/Views/OperationToast.swift
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
|
||||
import SwiftUI
|
||||
|
||||
enum OperationStatus {
|
||||
case none
|
||||
case isPerforming
|
||||
case success
|
||||
case failure
|
||||
}
|
||||
|
||||
struct OperationToast: View {
|
||||
@Binding var operationMessage: String?
|
||||
@Binding var showOperationToast: Bool
|
||||
@Binding var operationStatus: OperationStatus
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
HStack {
|
||||
if operationStatus == .isPerforming {
|
||||
Text(operationMessage ?? "Performing...")
|
||||
Spacer()
|
||||
ProgressView()
|
||||
} else if operationStatus == .success {
|
||||
Text(operationMessage ?? "Success")
|
||||
Spacer()
|
||||
} else if operationStatus == .failure {
|
||||
Text(operationMessage ?? "Failure")
|
||||
Spacer()
|
||||
Button(action: { showOperationToast = false }, label: {
|
||||
Text("Done").bold()
|
||||
})
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.frame(minHeight: 50)
|
||||
.frame(maxWidth: 380)
|
||||
.background(Color(hex: "2A2A2A"))
|
||||
.foregroundColor(Color(hex: "EBEBEB"))
|
||||
.cornerRadius(4.0)
|
||||
.tint(Color.green)
|
||||
}
|
||||
.padding(.bottom, 60)
|
||||
.padding(.horizontal, 10)
|
||||
.ignoresSafeArea(.all, edges: .bottom)
|
||||
}
|
||||
}
|
||||
|
|
@ -49,9 +49,11 @@
|
|||
}
|
||||
}
|
||||
|
||||
struct CreateRecommendationGroupView: View {
|
||||
struct ClubsView: View {
|
||||
@State var name = ""
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@StateObject var viewModel = RecommendationsGroupsViewModel()
|
||||
|
||||
var nextButton: some View {
|
||||
|
|
@ -99,6 +101,9 @@
|
|||
)
|
||||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("ScrollToTop"))) { _ in
|
||||
dismiss()
|
||||
}
|
||||
#if os(iOS)
|
||||
.navigationViewStyle(.stack)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
|
|
@ -131,7 +136,7 @@
|
|||
}
|
||||
.sheet(isPresented: $viewModel.showCreateSheet) {
|
||||
NavigationView {
|
||||
CreateRecommendationGroupView(viewModel: self.viewModel)
|
||||
ClubsView(viewModel: self.viewModel)
|
||||
}
|
||||
}
|
||||
.task { await viewModel.loadGroups(dataService: dataService) }
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
|
|
@ -9,8 +8,16 @@ import Views
|
|||
@Published var isLoading = false
|
||||
@Published var isCreating = false
|
||||
@Published var networkError = false
|
||||
|
||||
@Published var hasBadgePermission = false
|
||||
@Published var libraryFilters = [InternalFilter]()
|
||||
|
||||
@Published var badgeFilter = BadgeCountHandler.badgeFilter {
|
||||
didSet {
|
||||
BadgeCountHandler.badgeFilter = badgeFilter
|
||||
}
|
||||
}
|
||||
|
||||
@AppStorage("LibraryTabView::hideFollowingTab") var hideFollowingTab = false
|
||||
@AppStorage(UserDefaultKey.hideFeatureSection.rawValue) var hideFeatureSection = false
|
||||
|
||||
|
|
@ -25,10 +32,35 @@ import Views
|
|||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func loadBadgePermission() {
|
||||
UNUserNotificationCenter.current().getNotificationSettings { settings in
|
||||
DispatchQueue.main.async {
|
||||
if settings.badgeSetting == .enabled {
|
||||
self.hasBadgePermission = true
|
||||
} else {
|
||||
self.hasBadgePermission = false
|
||||
}
|
||||
|
||||
print("notification settings: ", settings.badgeSetting.rawValue)
|
||||
print("got the notification settings")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func requestBadgePermission() {
|
||||
UNUserNotificationCenter.current().requestAuthorization(options: UNAuthorizationOptions.badge) { success, error in
|
||||
DispatchQueue.main.async {
|
||||
print("requested badge permission: ", success, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FiltersView: View {
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@StateObject var viewModel = FiltersViewModel()
|
||||
|
||||
var body: some View {
|
||||
|
|
@ -45,12 +77,18 @@ struct FiltersView: View {
|
|||
#endif
|
||||
}
|
||||
.navigationTitle(LocalText.filtersGeneric)
|
||||
.task { await viewModel.loadFilters(dataService: dataService) }
|
||||
.task {
|
||||
viewModel.loadBadgePermission()
|
||||
await viewModel.loadFilters(dataService: dataService)
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("ScrollToTop"))) { _ in
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private var innerBody: some View {
|
||||
List {
|
||||
Section {
|
||||
Section(header: Text("User Interface")) {
|
||||
Toggle("Hide following tab", isOn: $viewModel.hideFollowingTab)
|
||||
Toggle("Hide feature section", isOn: $viewModel.hideFeatureSection)
|
||||
}
|
||||
|
|
@ -60,6 +98,25 @@ struct FiltersView: View {
|
|||
Text(filter.name)
|
||||
}
|
||||
}
|
||||
|
||||
// Section(header: Text("Application Badge")) {
|
||||
// Toggle("Display Badge Count", isOn: $viewModel.hasBadgePermission)
|
||||
// .onChange(of: viewModel.hasBadgePermission) { _ in
|
||||
// if viewModel.hasBadgePermission {
|
||||
// viewModel.requestBadgePermission()
|
||||
// } else {
|
||||
// UIApplication.shared.applicationIconBadgeNumber = 0
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// if viewModel.hasBadgePermission {
|
||||
// NavigationLink(destination: {
|
||||
// SelectBadgeFilterView(viewModel: viewModel)
|
||||
// }, label: {
|
||||
// Text(viewModel.badgeFilter)
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,19 +2,28 @@ import Models
|
|||
import PopupView
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Transmission
|
||||
import Views
|
||||
|
||||
@MainActor final class NewsletterEmailsViewModel: ObservableObject {
|
||||
@Published var isLoading = false
|
||||
@Published var showAddressCopied = false
|
||||
@Published var emails = [NewsletterEmail]()
|
||||
|
||||
@Published var showOperationToast = false
|
||||
@Published var operationStatus: OperationStatus = .none
|
||||
@Published var operationMessage: String?
|
||||
|
||||
func loadEmails(dataService: DataService) async {
|
||||
isLoading = true
|
||||
|
||||
if let objectIDs = try? await dataService.newsletterEmails() {
|
||||
do {
|
||||
let objectIDs = try await dataService.newsletterEmails()
|
||||
await dataService.viewContext.perform { [weak self] in
|
||||
self?.emails = objectIDs.compactMap { dataService.viewContext.object(with: $0) as? NewsletterEmail }
|
||||
}
|
||||
} catch {
|
||||
print("ERROR LOADING EMAILS: ", error)
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
|
|
@ -33,22 +42,47 @@ import Views
|
|||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func updateEmail(dataService: DataService, email: NewsletterEmail, folder: String? = nil, description: String? = nil) async {
|
||||
operationMessage = "Updating email..."
|
||||
operationStatus = .isPerforming
|
||||
do {
|
||||
_ = try await dataService.updateNewsletterEmail(
|
||||
emailID: email.unwrappedEmailId,
|
||||
folder: folder,
|
||||
description: description
|
||||
)
|
||||
await loadEmails(dataService: dataService)
|
||||
operationMessage = "Email updated"
|
||||
operationStatus = .success
|
||||
} catch {
|
||||
operationMessage = "Failed to update email"
|
||||
operationStatus = .failure
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NewsletterEmailsView: View {
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@StateObject var viewModel = NewsletterEmailsViewModel()
|
||||
|
||||
@State var showSnackbar = false
|
||||
@State var snackbarOperation: SnackbarOperation?
|
||||
|
||||
func snackbar(message: String) {
|
||||
snackbarOperation = SnackbarOperation(message: message, undoAction: nil)
|
||||
showSnackbar = true
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
WindowLink(level: .alert, transition: .move(edge: .bottom), isPresented: $viewModel.showOperationToast) {
|
||||
OperationToast(operationMessage: $viewModel.operationMessage, showOperationToast: $viewModel.showOperationToast, operationStatus: $viewModel.operationStatus)
|
||||
} label: {
|
||||
EmptyView()
|
||||
}
|
||||
WindowLink(level: .alert, transition: .move(edge: .bottom), isPresented: $viewModel.showAddressCopied) {
|
||||
MessageToast()
|
||||
} label: {
|
||||
EmptyView()
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
Form {
|
||||
innerBody
|
||||
|
|
@ -60,65 +94,118 @@ struct NewsletterEmailsView: View {
|
|||
.listStyle(InsetListStyle())
|
||||
#endif
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("ScrollToTop"))) { _ in
|
||||
dismiss()
|
||||
}
|
||||
.task { await viewModel.loadEmails(dataService: dataService) }
|
||||
.popup(isPresented: $showSnackbar) {
|
||||
if let operation = snackbarOperation {
|
||||
Snackbar(isShowing: $showSnackbar, operation: operation)
|
||||
} else {
|
||||
EmptyView()
|
||||
.refreshable {
|
||||
Task {
|
||||
await viewModel.loadEmails(dataService: dataService)
|
||||
}
|
||||
} customize: {
|
||||
$0
|
||||
.type(.toast)
|
||||
.autohideIn(2)
|
||||
.position(.bottom)
|
||||
.animation(.spring())
|
||||
.closeOnTapOutside(true)
|
||||
}
|
||||
}
|
||||
|
||||
private var innerBody: some View {
|
||||
Group {
|
||||
Section(footer: Text(LocalText.newslettersDescription)) {
|
||||
if !viewModel.emails.isEmpty {
|
||||
ForEach(viewModel.emails) { email in
|
||||
Section {
|
||||
NewsletterEmailRow(viewModel: viewModel, email: email, folderSelection: email.folder ?? "inbox")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Text(LocalText.newslettersDescription)
|
||||
Button(
|
||||
action: {
|
||||
Task { await viewModel.createEmail(dataService: dataService) }
|
||||
},
|
||||
label: {
|
||||
HStack {
|
||||
Image(systemName: "plus.circle.fill").foregroundColor(.green)
|
||||
Label(title: {
|
||||
Text(LocalText.createNewEmailMessage)
|
||||
Spacer()
|
||||
}
|
||||
}, icon: {
|
||||
Image.addLink
|
||||
})
|
||||
}
|
||||
)
|
||||
.disabled(viewModel.isLoading)
|
||||
}
|
||||
}
|
||||
.navigationTitle(LocalText.emailsGeneric)
|
||||
}
|
||||
}
|
||||
|
||||
if !viewModel.emails.isEmpty {
|
||||
Section(header: Text(LocalText.newsletterEmailsExisting)) {
|
||||
ForEach(viewModel.emails) { newsletterEmail in
|
||||
Button(
|
||||
action: {
|
||||
#if os(iOS)
|
||||
UIPasteboard.general.string = newsletterEmail.email
|
||||
#endif
|
||||
struct NewsletterEmailRow: View {
|
||||
@StateObject var viewModel: NewsletterEmailsViewModel
|
||||
@EnvironmentObject var dataService: DataService
|
||||
|
||||
#if os(macOS)
|
||||
let pasteBoard = NSPasteboard.general
|
||||
pasteBoard.clearContents()
|
||||
pasteBoard.writeObjects([newsletterEmail.unwrappedEmail as NSString])
|
||||
#endif
|
||||
@State var email: NewsletterEmail
|
||||
@State var folderSelection: String
|
||||
|
||||
snackbar(message: "Email copied")
|
||||
},
|
||||
label: { Text(newsletterEmail.unwrappedEmail) }
|
||||
)
|
||||
var body: some View {
|
||||
VStack {
|
||||
HStack {
|
||||
Text(email.unwrappedEmail).bold()
|
||||
Spacer()
|
||||
|
||||
Button(
|
||||
action: {
|
||||
#if os(iOS)
|
||||
UIPasteboard.general.string = email.email
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
let pasteBoard = NSPasteboard.general
|
||||
pasteBoard.clearContents()
|
||||
pasteBoard.writeObjects([newsletterEmail.unwrappedEmail as NSString])
|
||||
#endif
|
||||
|
||||
viewModel.showAddressCopied = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(2000)) {
|
||||
viewModel.showAddressCopied = false
|
||||
}
|
||||
},
|
||||
label: {
|
||||
Text("Copy")
|
||||
}
|
||||
)
|
||||
}
|
||||
Divider()
|
||||
Picker("Destination Folder", selection: $folderSelection) {
|
||||
Text("Inbox").tag("inbox")
|
||||
Text("Following").tag("following")
|
||||
}
|
||||
.pickerStyle(MenuPickerStyle())
|
||||
.onChange(of: folderSelection) { newValue in
|
||||
Task {
|
||||
viewModel.showOperationToast = true
|
||||
await viewModel.updateEmail(dataService: dataService, email: email, folder: newValue)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1500)) {
|
||||
viewModel.showOperationToast = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.navigationTitle(LocalText.emailsGeneric)
|
||||
}
|
||||
}
|
||||
|
||||
struct MessageToast: View {
|
||||
var body: some View {
|
||||
VStack {
|
||||
HStack {
|
||||
Text("Address copied")
|
||||
Spacer()
|
||||
}
|
||||
.padding(10)
|
||||
.frame(minHeight: 50)
|
||||
.frame(maxWidth: 380)
|
||||
.background(Color(hex: "2A2A2A"))
|
||||
.cornerRadius(4.0)
|
||||
.tint(Color.green)
|
||||
}
|
||||
.padding(.bottom, 70)
|
||||
.padding(.horizontal, 10)
|
||||
.ignoresSafeArea(.all, edges: .bottom)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,8 +25,10 @@ import Views
|
|||
loadProfileCardData(name: name, username: username, profileImageURL: currentViewer.profileImageURL)
|
||||
}
|
||||
|
||||
if let viewer = try? await dataService.fetchViewer() {
|
||||
loadProfileCardData(name: viewer.name, username: viewer.username, profileImageURL: viewer.profileImageURL)
|
||||
if profileCardData.name.isEmpty {
|
||||
if let viewer = try? await dataService.fetchViewer() {
|
||||
loadProfileCardData(name: viewer.name, username: viewer.username, profileImageURL: viewer.profileImageURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -62,12 +64,13 @@ struct ProfileView: View {
|
|||
|
||||
@StateObject private var viewModel = ProfileContainerViewModel()
|
||||
|
||||
@State var shouldScrollToTop = false
|
||||
@State private var showLogoutConfirmation = false
|
||||
|
||||
var body: some View {
|
||||
#if os(iOS)
|
||||
Form {
|
||||
innerBody
|
||||
List {
|
||||
innerBody.tag("TOP")
|
||||
}
|
||||
.toolbar {
|
||||
toolbarItems
|
||||
|
|
@ -123,6 +126,7 @@ struct ProfileView: View {
|
|||
Group {
|
||||
Section {
|
||||
ProfileCard(data: viewModel.profileCardData)
|
||||
.tag("PROFILE")
|
||||
.task {
|
||||
await viewModel.loadProfileData(dataService: dataService)
|
||||
}
|
||||
|
|
@ -194,7 +198,7 @@ struct ProfileView: View {
|
|||
primaryButton: .destructive(Text(LocalText.genericConfirm)) {
|
||||
dismiss()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
|
||||
authenticator.logout(dataService: dataService)
|
||||
authenticator.beginLogout()
|
||||
}
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ import Views
|
|||
|
||||
struct PushNotificationDevicesView: View {
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@StateObject var viewModel = PushNotificationDevicesViewModel()
|
||||
|
||||
var body: some View {
|
||||
|
|
@ -43,6 +45,9 @@ struct PushNotificationDevicesView: View {
|
|||
.listStyle(InsetListStyle())
|
||||
#endif
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("ScrollToTop"))) { _ in
|
||||
dismiss()
|
||||
}
|
||||
.task { viewModel.loadDevices(dataService: dataService) }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -47,6 +47,8 @@
|
|||
|
||||
struct PushNotificationSettingsView: View {
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@StateObject var viewModel = PushNotificationSettingsViewModel()
|
||||
@State var desiredNotificationsEnabled: Bool = false
|
||||
|
||||
|
|
@ -63,6 +65,9 @@
|
|||
.listStyle(InsetListStyle())
|
||||
#endif
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("ScrollToTop"))) { _ in
|
||||
dismiss()
|
||||
}
|
||||
.task { viewModel.checkPushNotificationsStatus() }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
import Foundation
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
public struct SelectBadgeFilterView: View {
|
||||
@ObservedObject var viewModel: FiltersViewModel
|
||||
|
||||
public var body: some View {
|
||||
List {
|
||||
Section(header: Text("Filter")) {
|
||||
ForEach(viewModel.libraryFilters) { filter in
|
||||
Button {
|
||||
viewModel.badgeFilter = filter.filter
|
||||
} label: {
|
||||
HStack {
|
||||
Text(filter.name)
|
||||
Spacer()
|
||||
if isSelected(filter) {
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}.onTapGesture {}
|
||||
}.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
Section {
|
||||
Text("Your selected filter will be used to display a badge value on the application icon.")
|
||||
}
|
||||
}.navigationTitle("Badge Filter")
|
||||
}
|
||||
|
||||
func isSelected(_ filter: InternalFilter) -> Bool {
|
||||
viewModel.badgeFilter == filter.filter
|
||||
}
|
||||
}
|
||||
|
|
@ -1,173 +0,0 @@
|
|||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Views
|
||||
|
||||
@MainActor final class SubscriptionsViewModel: ObservableObject {
|
||||
@Published var isLoading = true
|
||||
@Published var subscriptions = [Subscription]()
|
||||
@Published var popularSubscriptions = [Subscription]()
|
||||
@Published var hasNetworkError = false
|
||||
@Published var subscriptionNameToCancel: String?
|
||||
|
||||
func loadSubscriptions(dataService: DataService) async {
|
||||
isLoading = true
|
||||
|
||||
do {
|
||||
subscriptions = try await dataService.subscriptions().filter { $0.status == SubscriptionStatus.active }
|
||||
} catch {
|
||||
hasNetworkError = true
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func cancelSubscription(dataService: DataService) async -> Bool {
|
||||
guard let subscriptionName = subscriptionNameToCancel else { return false }
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SubscriptionsView: View {
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@StateObject var viewModel = SubscriptionsViewModel()
|
||||
@State private var deleteConfirmationShown = false
|
||||
@State private var progressViewOpacity = 0.0
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
.opacity(progressViewOpacity)
|
||||
.onAppear {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1000)) {
|
||||
progressViewOpacity = 1
|
||||
}
|
||||
}
|
||||
.task { await viewModel.loadSubscriptions(dataService: dataService) }
|
||||
} else if viewModel.hasNetworkError {
|
||||
VStack {
|
||||
Text(LocalText.subscriptionsErrorRetrieving).multilineTextAlignment(.center)
|
||||
Button(
|
||||
action: { Task { await viewModel.loadSubscriptions(dataService: dataService) } },
|
||||
label: { Text(LocalText.genericRetry) }
|
||||
)
|
||||
.buttonStyle(RoundedRectButtonStyle())
|
||||
}
|
||||
} else if viewModel.subscriptions.isEmpty {
|
||||
VStack(alignment: .center) {
|
||||
Spacer()
|
||||
Text(LocalText.subscriptionsNone)
|
||||
Spacer()
|
||||
}
|
||||
} else {
|
||||
Group {
|
||||
#if os(iOS)
|
||||
Form {
|
||||
innerBody
|
||||
}
|
||||
#elseif os(macOS)
|
||||
List {
|
||||
innerBody
|
||||
}
|
||||
.listStyle(InsetListStyle())
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Subscriptions")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
}
|
||||
|
||||
private var innerBody: some View {
|
||||
Group {
|
||||
ForEach(viewModel.subscriptions, 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
|
||||
}
|
||||
}
|
||||
.navigationTitle(LocalText.subscriptionsGeneric)
|
||||
}
|
||||
}
|
||||
|
||||
struct SubscriptionCell: View {
|
||||
let subscription: Subscription
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(subscription.name)
|
||||
.font(.appCallout)
|
||||
.lineSpacing(1.25)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
if let updatedDate = subscription.updatedAt {
|
||||
Text("Last received: \(updatedDate.formatted())")
|
||||
.font(.appCaption)
|
||||
.foregroundColor(.appGrayText)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.multilineTextAlignment(.leading)
|
||||
.padding(.vertical, 8)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.frame(minHeight: 50)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,550 @@
|
|||
import CoreData
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Transmission
|
||||
import Views
|
||||
|
||||
@MainActor
|
||||
struct ToastOperationHandler {
|
||||
let performOperation: (_: Sendable?) -> Void
|
||||
let update: (_: OperationStatus, _: String) -> Void
|
||||
}
|
||||
|
||||
typealias OperationStatusHandler = (_: OperationStatus) -> Void
|
||||
|
||||
@MainActor final class SubscriptionsViewModel: ObservableObject {
|
||||
@Published var isLoading = true
|
||||
@Published var feeds = [Subscription]()
|
||||
@Published var newsletters = [Subscription]()
|
||||
@Published var rules: [Rule]?
|
||||
@Published var labels: [LinkedItemLabel]?
|
||||
|
||||
@Published var hasNetworkError = false
|
||||
@Published var subscriptionNameToCancel: String?
|
||||
@Published var presentingSubscription: Subscription?
|
||||
|
||||
@Published var showOperationToast = false
|
||||
@Published var operationStatus: OperationStatus = .none
|
||||
@Published var operationMessage: String?
|
||||
|
||||
func loadSubscriptions(dataService: DataService) async {
|
||||
isLoading = true
|
||||
|
||||
do {
|
||||
let subscriptions = try await dataService.subscriptions().filter { $0.status == SubscriptionStatus.active }
|
||||
feeds = subscriptions.filter { $0.type == .feed }
|
||||
newsletters = subscriptions.filter { $0.type == .newsletter }
|
||||
hasNetworkError = false
|
||||
} catch {
|
||||
print("error fetching subscriptions: ", error)
|
||||
hasNetworkError = true
|
||||
}
|
||||
|
||||
do {
|
||||
// Also try to get the rules for auto labeling
|
||||
rules = try await dataService.rules()
|
||||
} catch {
|
||||
print("error fetching rules and labels", error)
|
||||
rules = []
|
||||
}
|
||||
|
||||
await loadLabels(dataService: dataService)
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func loadLabels(dataService: DataService) async {
|
||||
_ = try? await dataService.labels()
|
||||
await loadLabelsFromStore(dataService: dataService)
|
||||
}
|
||||
|
||||
func loadLabelsFromStore(dataService: DataService) async {
|
||||
let fetchRequest: NSFetchRequest<Models.LinkedItemLabel> = LinkedItemLabel.fetchRequest()
|
||||
|
||||
let fetchedLabels = await dataService.viewContext.perform {
|
||||
try? fetchRequest.execute()
|
||||
}
|
||||
|
||||
labels = fetchedLabels ?? []
|
||||
}
|
||||
|
||||
func cancelSubscription(dataService: DataService, subscription: Subscription) async {
|
||||
operationMessage = "Unsubscribing..."
|
||||
operationStatus = .isPerforming
|
||||
|
||||
do {
|
||||
try await dataService.deleteSubscription(subscriptionName: subscription.name, subscriptionId: subscription.subscriptionID)
|
||||
var list = subscription.type == .feed ? feeds : newsletters
|
||||
let index = list.firstIndex { $0.subscriptionID == subscription.subscriptionID }
|
||||
if let index = index {
|
||||
list.remove(at: index)
|
||||
switch subscription.type {
|
||||
case .feed:
|
||||
feeds = list
|
||||
case .newsletter:
|
||||
newsletters = list
|
||||
}
|
||||
}
|
||||
operationMessage = "Unsubscribed"
|
||||
operationStatus = .success
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(2000)) {
|
||||
self.showOperationToast = false
|
||||
}
|
||||
} catch {
|
||||
appLogger.debug("failed to remove subscription")
|
||||
operationMessage = "Failed to unsubscribe"
|
||||
operationStatus = .failure
|
||||
}
|
||||
}
|
||||
|
||||
func updateSubscription(dataService: DataService, subscription: Subscription, folder: String? = nil, fetchContent: Bool? = nil) async {
|
||||
operationMessage = "Updating subscription..."
|
||||
operationStatus = .isPerforming
|
||||
do {
|
||||
try await dataService.updateSubscription(subscription.subscriptionID, folder: folder, fetchContent: fetchContent)
|
||||
operationMessage = "Subscription updated"
|
||||
operationStatus = .success
|
||||
} catch {
|
||||
operationMessage = "Failed to update subscription"
|
||||
operationStatus = .failure
|
||||
}
|
||||
}
|
||||
|
||||
func setLabelsRule(dataService: DataService, existingRule: Rule?, ruleName: String, filter: String, labelIDs: [String]) async {
|
||||
Task {
|
||||
operationMessage = "Creating label rule..."
|
||||
operationStatus = .isPerforming
|
||||
do {
|
||||
// Make sure the labels have been created
|
||||
await loadLabels(dataService: dataService)
|
||||
let existingLabelIDs = labels?.map(\.unwrappedID) ?? []
|
||||
if labelIDs.first(where: { !existingLabelIDs.contains($0) }) != nil {
|
||||
throw BasicError.message(messageText: "Label not created")
|
||||
}
|
||||
|
||||
_ = try await dataService.createOrUpdateAddLabelsRule(
|
||||
existingID: existingRule?.id,
|
||||
name: ruleName,
|
||||
filter: filter,
|
||||
labelIDs: labelIDs
|
||||
)
|
||||
if let newRules = try? await dataService.rules() {
|
||||
if !newRules.contains(where: { $0.name == ruleName }) {
|
||||
throw BasicError.message(messageText: "Rule not created")
|
||||
}
|
||||
rules = newRules
|
||||
}
|
||||
operationMessage = "Rule created"
|
||||
operationStatus = .success
|
||||
} catch {
|
||||
operationMessage = "Failed to create label rule"
|
||||
operationStatus = .failure
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SubscriptionsView: View {
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@StateObject var viewModel = SubscriptionsViewModel()
|
||||
@State private var deleteConfirmationShown = false
|
||||
@State private var showDeleteCompleted = false
|
||||
|
||||
@State private var showAddFeedView = false
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
WindowLink(level: .alert, transition: .move(edge: .bottom), isPresented: $viewModel.showOperationToast) {
|
||||
OperationToast(operationMessage: $viewModel.operationMessage, showOperationToast: $viewModel.showOperationToast, operationStatus: $viewModel.operationStatus)
|
||||
} label: {
|
||||
EmptyView()
|
||||
}
|
||||
if viewModel.feeds.isEmpty, viewModel.newsletters.isEmpty, viewModel.isLoading {
|
||||
ProgressView()
|
||||
} else if viewModel.hasNetworkError {
|
||||
VStack {
|
||||
Text(LocalText.subscriptionsErrorRetrieving).multilineTextAlignment(.center)
|
||||
Button(
|
||||
action: { Task { await viewModel.loadSubscriptions(dataService: dataService) } },
|
||||
label: { Text(LocalText.genericRetry) }
|
||||
)
|
||||
.buttonStyle(RoundedRectButtonStyle())
|
||||
}
|
||||
} else if viewModel.feeds.isEmpty, viewModel.newsletters.isEmpty {
|
||||
VStack(alignment: .center) {
|
||||
Spacer()
|
||||
Text(LocalText.subscriptionsNone)
|
||||
Spacer()
|
||||
}
|
||||
} else {
|
||||
#if os(iOS)
|
||||
Form {
|
||||
innerBody
|
||||
}
|
||||
#elseif os(macOS)
|
||||
List {
|
||||
innerBody
|
||||
}
|
||||
.listStyle(InsetListStyle())
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("ScrollToTop"))) { _ in
|
||||
dismiss()
|
||||
}
|
||||
.sheet(isPresented: $showAddFeedView) {
|
||||
let handler = ToastOperationHandler(performOperation: { sendable in
|
||||
self.viewModel.showOperationToast = true
|
||||
|
||||
Task {
|
||||
_ = await sendable
|
||||
viewModel.isLoading = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(2000)) {
|
||||
Task {
|
||||
await self.viewModel.loadSubscriptions(dataService: dataService)
|
||||
self.viewModel.showOperationToast = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}, update: { state, text in
|
||||
viewModel.operationStatus = state
|
||||
viewModel.operationMessage = text
|
||||
})
|
||||
|
||||
NavigationView {
|
||||
LibraryAddFeedView(dismiss: {
|
||||
showAddFeedView = false
|
||||
}, toastOperationHandler: handler)
|
||||
.navigationViewStyle(.stack)
|
||||
}
|
||||
.navigationViewStyle(.stack)
|
||||
}
|
||||
.task {
|
||||
await viewModel.loadSubscriptions(dataService: dataService)
|
||||
}
|
||||
.refreshable {
|
||||
Task {
|
||||
await viewModel.loadSubscriptions(dataService: dataService)
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
viewModel.showOperationToast = false
|
||||
}
|
||||
.navigationTitle("Subscriptions")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
}
|
||||
|
||||
private var innerBody: some View {
|
||||
Group {
|
||||
Section("Feeds") {
|
||||
if viewModel.feeds.count <= 0, !viewModel.isLoading {
|
||||
VStack(alignment: .center, spacing: 20) {
|
||||
Text("You don't have any Feed items.")
|
||||
.font(Font.system(size: 18, weight: .bold))
|
||||
|
||||
Text("Add an RSS/Atom feed")
|
||||
.foregroundColor(Color.blue)
|
||||
.onTapGesture {
|
||||
showAddFeedView = true
|
||||
}
|
||||
}
|
||||
.frame(minHeight: 80)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
} else {
|
||||
ForEach(viewModel.feeds, id: \.subscriptionID) { subscription in
|
||||
PresentationLink(transition: UIDevice.isIPad ? .popover : .sheet(detents: [.medium])) {
|
||||
SubscriptionSettingsView(
|
||||
subscription: subscription,
|
||||
viewModel: viewModel,
|
||||
dataService: dataService,
|
||||
prefetchContent: subscription.fetchContent,
|
||||
folderSelection: subscription.folder,
|
||||
unsubscribe: { _ in
|
||||
viewModel.operationStatus = .isPerforming
|
||||
viewModel.showOperationToast = true
|
||||
Task {
|
||||
await viewModel.cancelSubscription(dataService: dataService, subscription: subscription)
|
||||
}
|
||||
}
|
||||
)
|
||||
} label: {
|
||||
SubscriptionCell(subscription: subscription)
|
||||
}
|
||||
}
|
||||
Button(action: { showAddFeedView = true }, label: {
|
||||
Label(title: {
|
||||
Text("Add a feed")
|
||||
}, icon: {
|
||||
Image.addLink
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if viewModel.newsletters.count > 0, !viewModel.isLoading {
|
||||
Section("Newsletters") {
|
||||
ForEach(viewModel.newsletters, id: \.subscriptionID) { subscription in
|
||||
PresentationLink(transition: UIDevice.isIPad ? .popover : .sheet(detents: [.medium])) {
|
||||
SubscriptionSettingsView(
|
||||
subscription: subscription,
|
||||
viewModel: viewModel,
|
||||
dataService: dataService,
|
||||
prefetchContent: subscription.fetchContent,
|
||||
folderSelection: subscription.folder,
|
||||
unsubscribe: { _ in
|
||||
viewModel.operationStatus = .isPerforming
|
||||
viewModel.showOperationToast = true
|
||||
Task {
|
||||
await viewModel.cancelSubscription(dataService: dataService, subscription: subscription)
|
||||
}
|
||||
}
|
||||
)
|
||||
} label: {
|
||||
SubscriptionCell(subscription: subscription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(LocalText.subscriptionsGeneric)
|
||||
}
|
||||
}
|
||||
|
||||
struct SubscriptionRow<Content: View>: 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)
|
||||
.lineSpacing(1.25)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
if let updatedDate = subscription.updatedAt {
|
||||
Text("Last received: \(updatedDate.formatted())")
|
||||
.font(.appCaption)
|
||||
.foregroundColor(.appGrayText)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.multilineTextAlignment(.leading)
|
||||
.padding(.vertical, 8)
|
||||
|
||||
Spacer()
|
||||
|
||||
trailingButton
|
||||
}.frame(minHeight: 50)
|
||||
}
|
||||
}
|
||||
|
||||
struct SubscriptionCell: View {
|
||||
let subscription: Subscription
|
||||
|
||||
var body: some View {
|
||||
SubscriptionRow(subscription: subscription, useImageSpacer: true, trailingButton: {
|
||||
Image(systemName: "ellipsis")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct SubscriptionSettingsView: View {
|
||||
let subscription: Subscription
|
||||
let viewModel: SubscriptionsViewModel
|
||||
let dataService: DataService
|
||||
|
||||
@State var prefetchContent = false
|
||||
@State var deleteConfirmationShown = false
|
||||
@State var showDeleteCompleted = false
|
||||
@State var folderSelection: String = ""
|
||||
@State var showLabelsSelector = false
|
||||
|
||||
@State var isLoadingRule = false
|
||||
|
||||
let unsubscribe: (_: Subscription) -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var existingRule: Rule? {
|
||||
viewModel.rules?.first { $0.name == ruleName }
|
||||
}
|
||||
|
||||
var ruleName: String {
|
||||
if let url = subscription.url, subscription.type == .newsletter {
|
||||
return "system.autoLabel.(\(url))"
|
||||
}
|
||||
return "system.autoLabel.(\(subscription.name))"
|
||||
}
|
||||
|
||||
var ruleFilter: String {
|
||||
if let url = subscription.url, subscription.type == .newsletter {
|
||||
return "rss:\"\(url)\""
|
||||
}
|
||||
return "subscription:\"\(subscription.name)\""
|
||||
}
|
||||
|
||||
var ruleLabels: [LinkedItemLabel]? {
|
||||
if let labelIDs = existingRule?.actions.flatMap(\.params) {
|
||||
return Array(labelIDs.compactMap { labelID in
|
||||
viewModel.labels?.first(where: { $0.unwrappedID == labelID })
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var folderRow: some View {
|
||||
HStack {
|
||||
Picker("Destination Folder", selection: $folderSelection) {
|
||||
Text("Inbox").tag("inbox")
|
||||
Text("Following").tag("following")
|
||||
}
|
||||
.pickerStyle(MenuPickerStyle())
|
||||
.onChange(of: folderSelection) { newValue in
|
||||
Task {
|
||||
viewModel.showOperationToast = true
|
||||
await viewModel.updateSubscription(dataService: dataService, subscription: subscription, folder: newValue)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1500)) {
|
||||
viewModel.showOperationToast = false
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: prefetchContent) { newValue in
|
||||
Task {
|
||||
viewModel.showOperationToast = true
|
||||
await viewModel.updateSubscription(
|
||||
dataService: dataService,
|
||||
subscription: subscription,
|
||||
fetchContent: newValue
|
||||
)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1500)) {
|
||||
viewModel.showOperationToast = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var labelRuleRow: some View {
|
||||
HStack {
|
||||
Text("Add Labels")
|
||||
Spacer()
|
||||
if isLoadingRule || viewModel.rules != nil {
|
||||
Button(action: { showLabelsSelector = true }, label: {
|
||||
if let ruleLabels = ruleLabels {
|
||||
let labelNames = ruleLabels.map(\.unwrappedName)
|
||||
Text("[\(labelNames.joined(separator: ","))]")
|
||||
} else {
|
||||
Text("Create Rule")
|
||||
}
|
||||
})
|
||||
} else {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
// if subscription.type != .newsletter {
|
||||
// Toggle(isOn: $prefetchContent, label: { Text("Prefetch Content:") })
|
||||
// .onAppear {
|
||||
// prefetchContent = subscription.fetchContent
|
||||
// }
|
||||
// }
|
||||
folderRow
|
||||
labelRuleRow
|
||||
}.listStyle(.insetGrouped)
|
||||
|
||||
Spacer()
|
||||
Button("Unsubscribe", role: .destructive) { deleteConfirmationShown = true }
|
||||
.frame(maxWidth: .infinity)
|
||||
.buttonStyle(RoundedRectButtonStyle(color: Color.red, textColor: Color.white))
|
||||
}
|
||||
.frame(width: UIDevice.isIPad ? 400 : nil, height: UIDevice.isIPad ? 300 : nil)
|
||||
.alert("Are you sure you want to cancel this subscription?", isPresented: $deleteConfirmationShown) {
|
||||
Button("Yes", role: .destructive) {
|
||||
dismiss()
|
||||
unsubscribe(subscription)
|
||||
}
|
||||
Button("No", role: .cancel) {
|
||||
viewModel.subscriptionNameToCancel = nil
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showLabelsSelector) {
|
||||
ApplyLabelsView(mode: .list(ruleLabels ?? []), onSave: { labels in
|
||||
Task {
|
||||
isLoadingRule = true
|
||||
viewModel.showOperationToast = true
|
||||
await viewModel.setLabelsRule(
|
||||
dataService: dataService,
|
||||
existingRule: existingRule,
|
||||
ruleName: ruleName,
|
||||
filter: ruleFilter,
|
||||
labelIDs: labels.map(\.unwrappedID)
|
||||
)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1500)) {
|
||||
viewModel.showOperationToast = false
|
||||
isLoadingRule = true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
struct TextToSpeechLanguageView: View {
|
||||
@EnvironmentObject var audioController: AudioController
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
|
|
@ -20,6 +21,9 @@
|
|||
.listStyle(InsetListStyle())
|
||||
#endif
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("ScrollToTop"))) { _ in
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private var innerBody: some View {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
// swiftlint:disable line_length
|
||||
struct TextToSpeechView: View {
|
||||
@EnvironmentObject var audioController: AudioController
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
|
|
@ -20,6 +21,9 @@
|
|||
innerBody
|
||||
}
|
||||
}.navigationTitle(LocalText.textToSpeechGeneric)
|
||||
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("ScrollToTop"))) { _ in
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private var innerBody: some View {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
struct TextToSpeechVoiceSelectionView: View {
|
||||
@EnvironmentObject var audioController: AudioController
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@StateObject var viewModel = TextToSpeechVoiceSelectionViewModel()
|
||||
|
||||
|
|
@ -87,6 +88,8 @@
|
|||
} else if !value {
|
||||
audioController.useUltraRealisticVoices = false
|
||||
}
|
||||
}.onReceive(NotificationCenter.default.publisher(for: Notification.Name("ScrollToTop"))) { _ in
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -52,9 +52,13 @@ struct InnerRootView: View {
|
|||
if authenticator.isLoggedIn {
|
||||
PrimaryContentView()
|
||||
} else {
|
||||
WelcomeView()
|
||||
.accessibilityElement()
|
||||
.accessibilityIdentifier("welcomeView")
|
||||
if authenticator.isLoggingOut {
|
||||
LogoutView()
|
||||
} else {
|
||||
WelcomeView()
|
||||
.accessibilityElement()
|
||||
.accessibilityIdentifier("welcomeView")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import Views
|
|||
let isMacApp = true
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
public final class RootViewModel: ObservableObject {
|
||||
let services = Services()
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ struct WebReader: PlatformViewRepresentable {
|
|||
@Binding var showNavBarActionID: UUID?
|
||||
@Binding var shareActionID: UUID?
|
||||
@Binding var annotation: String
|
||||
@Binding var showBottomBar: Bool
|
||||
@Binding var showHighlightAnnotationModal: Bool
|
||||
|
||||
func makeCoordinator() -> WebReaderCoordinator {
|
||||
|
|
@ -91,9 +90,6 @@ struct WebReader: PlatformViewRepresentable {
|
|||
context.coordinator.webViewActionHandler = webViewActionHandler
|
||||
context.coordinator.updateNavBarVisibility = navBarVisibilityUpdater
|
||||
context.coordinator.scrollPercentHandler = scrollPercentHandler
|
||||
context.coordinator.updateShowBottomBar = { newValue in
|
||||
self.showBottomBar = newValue
|
||||
}
|
||||
|
||||
context.coordinator.articleContentID = articleContent.id
|
||||
loadContent(webView: webView)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import Models
|
|||
import PopupView
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Transmission
|
||||
import Utils
|
||||
import Views
|
||||
import WebKit
|
||||
|
|
@ -28,7 +29,6 @@ struct WebReaderContainerView: View {
|
|||
@State var showExpandedAudioPlayer = false
|
||||
@State var shareActionID: UUID?
|
||||
@State var annotation = String()
|
||||
@State var showBottomBar = false
|
||||
@State private var bottomBarOpacity = 0.0
|
||||
@State private var errorAlertMessage: String?
|
||||
@State private var showErrorAlertMessage = false
|
||||
|
|
@ -88,7 +88,6 @@ struct WebReaderContainerView: View {
|
|||
private func tapHandler() {
|
||||
withAnimation(.easeIn(duration: 0.08)) {
|
||||
navBarVisible = !navBarVisible
|
||||
showBottomBar = navBarVisible
|
||||
showNavBarActionID = UUID()
|
||||
}
|
||||
}
|
||||
|
|
@ -112,13 +111,11 @@ struct WebReaderContainerView: View {
|
|||
case "pageTapped":
|
||||
withAnimation {
|
||||
navBarVisible = !navBarVisible
|
||||
showBottomBar = navBarVisible
|
||||
showNavBarActionID = UUID()
|
||||
}
|
||||
case "dismissNavBars":
|
||||
withAnimation {
|
||||
navBarVisible = false
|
||||
showBottomBar = false
|
||||
showNavBarActionID = UUID()
|
||||
}
|
||||
default:
|
||||
|
|
@ -160,45 +157,15 @@ struct WebReaderContainerView: View {
|
|||
|
||||
var textToSpeechButtonImage: some View {
|
||||
if audioController.playbackError || audioController.state == .stopped || audioController.itemAudioProperties?.itemID != self.item.id {
|
||||
return AnyView(Image.headphones)
|
||||
return AnyView(Image.audioPlay.frame(width: 48, height: 48))
|
||||
}
|
||||
let name = audioController.isPlayingItem(itemID: item.unwrappedID) ? "pause.circle" : "play.circle"
|
||||
return AnyView(Image(systemName: name).font(.appNavbarIcon))
|
||||
if audioController.isPlayingItem(itemID: item.unwrappedID) {
|
||||
return AnyView(Image.audioPause.frame(width: 48, height: 48))
|
||||
}
|
||||
return AnyView(Image.audioPlay.frame(width: 48, height: 48))
|
||||
}
|
||||
#endif
|
||||
|
||||
var bottomButtons: some View {
|
||||
HStack(alignment: .center) {
|
||||
Button(action: archive, label: {
|
||||
item.isArchived ? Image.unarchive : Image.archive
|
||||
}).frame(width: 48, height: 48)
|
||||
.padding(.leading, 8)
|
||||
Divider().opacity(0.8)
|
||||
|
||||
Button(action: delete, label: {
|
||||
Image.remove
|
||||
}).frame(width: 48, height: 48)
|
||||
Divider().opacity(0.8)
|
||||
|
||||
Button(action: editLabels, label: {
|
||||
Image.label
|
||||
}).frame(width: 48, height: 48)
|
||||
Divider().opacity(0.8)
|
||||
|
||||
Button(action: recommend, label: {
|
||||
Image(systemName: "sparkles")
|
||||
}).frame(width: 48, height: 48)
|
||||
|
||||
// We don't have a single note function yet
|
||||
// Divider()
|
||||
//
|
||||
// Button(action: addNote, label: {
|
||||
// Image(systemName: "note")
|
||||
// }).frame(width: 48, height: 48)
|
||||
.padding(.trailing, 8)
|
||||
}.foregroundColor(.appGrayTextContrast)
|
||||
}
|
||||
|
||||
func audioMenuItem() -> some View {
|
||||
Button(
|
||||
action: {
|
||||
|
|
@ -361,7 +328,7 @@ struct WebReaderContainerView: View {
|
|||
.tint(Color(hex: "#2A2A2A"))
|
||||
.frame(height: readerViewNavBarHeight)
|
||||
.frame(maxWidth: .infinity)
|
||||
.foregroundColor(ThemeManager.currentTheme.isDark ? .white : .black)
|
||||
.foregroundColor(ThemeManager.currentTheme.toolbarColor)
|
||||
.background(ThemeManager.currentBgColor)
|
||||
.sheet(isPresented: $showLabelsModal) {
|
||||
ApplyLabelsView(mode: .item(item), onSave: { labels in
|
||||
|
|
@ -380,7 +347,7 @@ struct WebReaderContainerView: View {
|
|||
#if os(iOS)
|
||||
.sheet(isPresented: $showNotebookView, onDismiss: onNotebookViewDismissal) {
|
||||
NotebookView(
|
||||
itemObjectID: item.objectID,
|
||||
viewModel: NotebookViewModel(item: item),
|
||||
hasHighlightMutations: $hasPerformedHighlightMutations
|
||||
)
|
||||
}
|
||||
|
|
@ -401,6 +368,11 @@ struct WebReaderContainerView: View {
|
|||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
WindowLink(level: .alert, transition: .move(edge: .bottom), isPresented: $viewModel.showOperationToast) {
|
||||
OperationToast(operationMessage: $viewModel.operationMessage, showOperationToast: $viewModel.showOperationToast, operationStatus: $viewModel.operationStatus)
|
||||
} label: {
|
||||
EmptyView()
|
||||
}
|
||||
if let articleContent = viewModel.articleContent {
|
||||
WebReader(
|
||||
item: item,
|
||||
|
|
@ -431,7 +403,6 @@ struct WebReaderContainerView: View {
|
|||
showNavBarActionID: $showNavBarActionID,
|
||||
shareActionID: $shareActionID,
|
||||
annotation: $annotation,
|
||||
showBottomBar: $showBottomBar,
|
||||
showHighlightAnnotationModal: $showHighlightAnnotationModal
|
||||
)
|
||||
.background(ThemeManager.currentBgColor)
|
||||
|
|
@ -592,30 +563,26 @@ struct WebReaderContainerView: View {
|
|||
.offset(y: navBarVisible ? 0 : -150)
|
||||
|
||||
Spacer()
|
||||
if showBottomBar {
|
||||
bottomButtons
|
||||
.frame(height: 48)
|
||||
.background(Color.webControlButtonBackground)
|
||||
.cornerRadius(6)
|
||||
.padding(.bottom, 34)
|
||||
.shadow(color: .gray.opacity(0.13), radius: 8, x: 0, y: 4)
|
||||
.opacity(bottomBarOpacity)
|
||||
.onAppear {
|
||||
withAnimation(Animation.linear(duration: 0.25)) { self.bottomBarOpacity = 1 }
|
||||
}
|
||||
.onDisappear {
|
||||
self.bottomBarOpacity = 0
|
||||
}
|
||||
}
|
||||
if let audioProperties = audioController.itemAudioProperties {
|
||||
MiniPlayerViewer(itemAudioProperties: audioProperties)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 40)
|
||||
.padding(.bottom, navBarVisible ? 10 : 40)
|
||||
.background(Color.themeTabBarColor)
|
||||
.onTapGesture {
|
||||
showExpandedAudioPlayer = true
|
||||
}
|
||||
}
|
||||
if navBarVisible {
|
||||
CustomToolBar(
|
||||
isFollowing: item.folder == "following",
|
||||
isArchived: item.isArchived,
|
||||
moveToInboxAction: moveToInbox,
|
||||
archiveAction: archive,
|
||||
unarchiveAction: archive,
|
||||
shareAction: share,
|
||||
deleteAction: delete
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
|
@ -656,8 +623,28 @@ struct WebReaderContainerView: View {
|
|||
}
|
||||
}
|
||||
|
||||
func moveToInbox() {
|
||||
Task {
|
||||
viewModel.showOperationToast = true
|
||||
viewModel.operationMessage = "Moving to library..."
|
||||
viewModel.operationStatus = .isPerforming
|
||||
do {
|
||||
try await dataService.moveItem(itemID: item.unwrappedID, folder: "inbox")
|
||||
viewModel.operationMessage = "Moved to library"
|
||||
viewModel.operationStatus = .success
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1500)) {
|
||||
viewModel.showOperationToast = false
|
||||
}
|
||||
} catch {
|
||||
viewModel.operationMessage = "Error moving"
|
||||
viewModel.operationStatus = .failure
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func archive() {
|
||||
dataService.archiveLink(objectID: item.objectID, archived: !item.isArchived)
|
||||
let isArchived = item.isArchived
|
||||
dataService.archiveLink(objectID: item.objectID, archived: !isArchived)
|
||||
#if os(iOS)
|
||||
pop()
|
||||
#endif
|
||||
|
|
@ -687,10 +674,10 @@ struct WebReaderContainerView: View {
|
|||
}
|
||||
|
||||
func delete() {
|
||||
removeLibraryItemAction(dataService: dataService, objectID: item.objectID)
|
||||
pop()
|
||||
#if os(iOS)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
|
||||
pop()
|
||||
removeLibraryItemAction(dataService: dataService, objectID: item.objectID)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ final class WebReaderCoordinator: NSObject {
|
|||
var previousShowNavBarActionID: UUID?
|
||||
var previousShareActionID: UUID?
|
||||
var updateNavBarVisibility: (Bool) -> Void = { _ in }
|
||||
var updateShowBottomBar: (Bool) -> Void = { _ in }
|
||||
var articleContentID = UUID()
|
||||
private var yOffsetAtStartOfDrag: Double?
|
||||
private var lastYOffset: Double = 0
|
||||
|
|
@ -122,10 +121,10 @@ extension WebReaderCoordinator: WKNavigationDelegate {
|
|||
scrollView.contentInset.top = navBarVisible ? readerViewNavBarHeight : 0
|
||||
}
|
||||
|
||||
// if at bottom show the controls
|
||||
if yOffset + scrollView.visibleSize.height > scrollView.contentSize.height - 140 {
|
||||
updateShowBottomBar(true)
|
||||
} else {
|
||||
updateShowBottomBar(false)
|
||||
navBarVisible = true
|
||||
scrollView.contentInset.top = navBarVisible ? readerViewNavBarHeight : 0
|
||||
}
|
||||
|
||||
let percent = Int(((yOffset + scrollView.visibleSize.height) / scrollView.contentSize.height) * 100)
|
||||
|
|
|
|||
|
|
@ -54,6 +54,8 @@ public struct WebReaderLoadingContainer: View {
|
|||
PDFWrapperView(pdfURL: pdfURL)
|
||||
}
|
||||
#endif
|
||||
} else if item.state == "CONTENT_NOT_FETCHED" {
|
||||
ProgressView()
|
||||
} else {
|
||||
WebReaderContainerView(item: item, pop: { dismiss() })
|
||||
#if os(iOS)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@ struct SafariWebLink: Identifiable {
|
|||
@Published var isDownloadingAudio: Bool = false
|
||||
@Published var audioDownloadTask: Task<Void, Error>?
|
||||
|
||||
@Published var operationMessage: String?
|
||||
@Published var showOperationToast: Bool = false
|
||||
@Published var operationStatus: OperationStatus = .none
|
||||
|
||||
@Published var showSnackbar: Bool = false
|
||||
var snackbarOperation: SnackbarOperation?
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22225" systemVersion="23B81" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22522" systemVersion="23B81" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<entity name="Filter" representedClassName="Filter" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="defaultFilter" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="filter" optional="YES" attributeType="String"/>
|
||||
|
|
@ -96,8 +96,10 @@
|
|||
</entity>
|
||||
<entity name="NewsletterEmail" representedClassName="NewsletterEmail" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="confirmationCode" optional="YES" attributeType="String"/>
|
||||
<attribute name="descriptionNote" optional="YES" attributeType="String"/>
|
||||
<attribute name="email" attributeType="String"/>
|
||||
<attribute name="emailId" attributeType="String"/>
|
||||
<attribute name="folder" optional="YES" attributeType="String"/>
|
||||
<uniquenessConstraints>
|
||||
<uniquenessConstraint>
|
||||
<constraint value="emailId"/>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import CoreData
|
||||
import Foundation
|
||||
import Models
|
||||
import Utils
|
||||
|
||||
public struct PDFItem {
|
||||
public let item: Models.LibraryItem
|
||||
public let objectID: NSManagedObjectID
|
||||
public let itemID: String
|
||||
public let pdfURL: URL?
|
||||
|
|
@ -21,6 +23,7 @@ public struct PDFItem {
|
|||
guard item.isPDF else { return nil }
|
||||
|
||||
return PDFItem(
|
||||
item: item,
|
||||
objectID: item.objectID,
|
||||
itemID: item.unwrappedID,
|
||||
pdfURL: URL(string: item.unwrappedPageURLString),
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
import Foundation
|
||||
import SwiftGraphQL
|
||||
|
||||
public struct Subscription {
|
||||
public let createdAt: Date?
|
||||
public let description: String?
|
||||
public let subscriptionID: String
|
||||
public let name: String
|
||||
public let type: SubscriptionType
|
||||
public let folder: String
|
||||
public let fetchContent: Bool
|
||||
public let newsletterEmailAddress: String?
|
||||
public let status: SubscriptionStatus
|
||||
public let unsubscribeHttpUrl: String?
|
||||
|
|
@ -18,6 +22,9 @@ public struct Subscription {
|
|||
description: String?,
|
||||
subscriptionID: String,
|
||||
name: String,
|
||||
type: SubscriptionType,
|
||||
folder: String,
|
||||
fetchContent: Bool,
|
||||
newsletterEmailAddress: String?,
|
||||
status: SubscriptionStatus,
|
||||
unsubscribeHttpUrl: String?,
|
||||
|
|
@ -30,6 +37,9 @@ public struct Subscription {
|
|||
self.description = description
|
||||
self.subscriptionID = subscriptionID
|
||||
self.name = name
|
||||
self.type = type
|
||||
self.folder = folder
|
||||
self.fetchContent = fetchContent
|
||||
self.newsletterEmailAddress = newsletterEmailAddress
|
||||
self.status = status
|
||||
self.unsubscribeHttpUrl = unsubscribeHttpUrl
|
||||
|
|
@ -45,3 +55,8 @@ public enum SubscriptionStatus {
|
|||
case deleted
|
||||
case unsubscribed
|
||||
}
|
||||
|
||||
public enum SubscriptionType {
|
||||
case newsletter
|
||||
case feed
|
||||
}
|
||||
|
|
|
|||
|
|
@ -172,7 +172,9 @@ public extension FeaturedItemFilter {
|
|||
|
||||
var predicate: NSPredicate {
|
||||
let undeletedPredicate = NSPredicate(
|
||||
format: "%K != %i", #keyPath(LibraryItem.serverSyncStatus), Int64(ServerSyncStatus.needsDeletion.rawValue)
|
||||
format: "%K != %i AND %K != \"DELETED\"",
|
||||
#keyPath(LibraryItem.serverSyncStatus), Int64(ServerSyncStatus.needsDeletion.rawValue),
|
||||
#keyPath(LibraryItem.state)
|
||||
)
|
||||
let notInArchivePredicate = NSPredicate(
|
||||
format: "%K == %@", #keyPath(LibraryItem.isArchived), Int(truncating: false) as NSNumber
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ public enum VoiceCategory: String, CaseIterable {
|
|||
case itIT = "Italian (Italy)"
|
||||
case esES = "Spanish (Spain)"
|
||||
case jaJP = "Japanese (Japan)"
|
||||
case nlNL = "Dutch (Netherlands)"
|
||||
case ptBR = "Portuguese (Brazil)"
|
||||
case taIN = "Tamil (India)"
|
||||
case taLK = "Tamil (Sri Lanka)"
|
||||
|
|
@ -80,6 +81,7 @@ public enum Voices {
|
|||
VoiceLanguage(key: "it", name: "Italian", defaultVoice: "it-IT-BenignoNeural", categories: [.itIT]),
|
||||
VoiceLanguage(key: "ja", name: "Japanese", defaultVoice: "ja-JP-NanamiNeural", categories: [.jaJP]),
|
||||
VoiceLanguage(key: "es", name: "Spanish", defaultVoice: "es-ES-AlvaroNeural", categories: [.esES]),
|
||||
VoiceLanguage(key: "nl", name: "Dutch", defaultVoice: "nl-NL-XiaochenNeural", categories: [.nlNL]),
|
||||
VoiceLanguage(key: "pt", name: "Portuguese", defaultVoice: "pt-BR-AntonioNeural", categories: [.ptBR]),
|
||||
VoiceLanguage(key: "ta", name: "Tamil", defaultVoice: "ta-IN-PallaviNeural", categories: [.taIN, .taLK, .taMY, .taSG])
|
||||
]
|
||||
|
|
@ -113,7 +115,8 @@ public enum Voices {
|
|||
VoicePair(firstKey: "ta-LK-KumarNeural", secondKey: "ta-LK-SaranyaNeural", firstName: "Kumar", secondName: "Saranya", language: "ta-LK", category: .taLK),
|
||||
VoicePair(firstKey: "ta-MY-KaniNeural", secondKey: "ta-MY-SuryaNeural", firstName: "Kani", secondName: "Surya", language: "ta-MY", category: .taMY),
|
||||
VoicePair(firstKey: "ta-SG-AnbuNeural", secondKey: "ta-SG-VenbaNeural", firstName: "Anbu", secondName: "Venba", language: "ta-SG", category: .taSG),
|
||||
VoicePair(firstKey: "it-IT-BenignoNeural", secondKey: "it-IT-IsabellaNeural", firstName: "Benigno", secondName: "Isabella", language: "it-IT", category: .itIT)
|
||||
VoicePair(firstKey: "it-IT-BenignoNeural", secondKey: "it-IT-IsabellaNeural", firstName: "Benigno", secondName: "Isabella", language: "it-IT", category: .itIT),
|
||||
VoicePair(firstKey: "nl-NL-MaartenNeural", secondKey: "nl-NL-FennaNeural", firstName: "Maarten", secondName: "Fenna", language: "nl-NL", category: .nlNL)
|
||||
]
|
||||
|
||||
public static let UltraPairs = [
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import CoreData
|
||||
import Foundation
|
||||
import GoogleSignIn
|
||||
import Models
|
||||
|
|
@ -18,6 +19,7 @@ public final class Authenticator: ObservableObject {
|
|||
}
|
||||
|
||||
@Published public internal(set) var isLoggedIn: Bool
|
||||
@Published public internal(set) var isLoggingOut = false
|
||||
@Published public var showAppleRevokeTokenAlert = false
|
||||
|
||||
let networker: Networker
|
||||
|
|
@ -37,13 +39,21 @@ public final class Authenticator: ObservableObject {
|
|||
ValetKey.authToken.value()
|
||||
}
|
||||
|
||||
public func beginLogout() {
|
||||
isLoggingOut = true
|
||||
isLoggedIn = false
|
||||
}
|
||||
|
||||
public func logout(dataService: DataService, isAccountDeletion: Bool = false) {
|
||||
dataService.resetLocalStorage()
|
||||
|
||||
clearCreds()
|
||||
Authenticator.unregisterIntercomUser?()
|
||||
isLoggedIn = false
|
||||
showAppleRevokeTokenAlert = isAccountDeletion
|
||||
EventTracker.reset()
|
||||
|
||||
isLoggedIn = false
|
||||
isLoggingOut = false
|
||||
}
|
||||
|
||||
public func clearCreds() {
|
||||
|
|
|
|||
|
|
@ -128,6 +128,17 @@ public final class DataService: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
func deleteAllEntities(entityName: String, inContext context: NSManagedObjectContext) {
|
||||
let deleteFetch = NSFetchRequest<NSFetchRequestResult>(entityName: entityName)
|
||||
let deleteRequest = NSBatchDeleteRequest(fetchRequest: deleteFetch)
|
||||
do {
|
||||
try context.execute(deleteRequest)
|
||||
try context.save()
|
||||
} catch {
|
||||
print("Error deleting all \(entityName) items.", error)
|
||||
}
|
||||
}
|
||||
|
||||
private func clearDownloadedFiles() {
|
||||
let relevantTypes = ["pdf", "mp3", "speechMarks"]
|
||||
let fileMgr = FileManager()
|
||||
|
|
@ -176,6 +187,17 @@ public final class DataService: ObservableObject {
|
|||
}
|
||||
|
||||
public func resetLocalStorage() {
|
||||
viewContext.perform {
|
||||
// We want to specify the order of deleting items to better handle relationships
|
||||
let entities = ["LibraryItem", "Viewer", "Filter", "Highlight", "NewsletterEmail",
|
||||
"LinkedItemLabel", "RecentSearchItem", "Recommendation", "RecommendationGroup", "UserProfile"]
|
||||
entities.forEach { entityName in
|
||||
self.deleteAllEntities(entityName: entityName, inContext: self.viewContext)
|
||||
}
|
||||
}
|
||||
|
||||
UserDefaults.standard.set(nil, forKey: UserDefaultKey.lastSelectedTabItem.rawValue)
|
||||
|
||||
lastItemSyncTime = Date(timeIntervalSinceReferenceDate: 0)
|
||||
|
||||
clearCoreData()
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -15,7 +15,7 @@ extension DataService {
|
|||
// Send update to server
|
||||
self.syncLinkArchiveStatus(itemID: linkedItem.unwrappedID, archived: archived)
|
||||
|
||||
let message = archived ? "Link archived" : "Link moved to Inbox"
|
||||
let message = archived ? "Link archived" : "Link unarchived"
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(300)) {
|
||||
showInLibrarySnackbar(message)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ public extension DataService {
|
|||
InternalNewsletterEmail(
|
||||
emailId: try $0.id(),
|
||||
email: try $0.address(),
|
||||
folder: try $0.folder(),
|
||||
descriptionNote: try $0.description(),
|
||||
confirmationCode: try $0.confirmationCode()
|
||||
)
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import Models
|
|||
import SwiftGraphQL
|
||||
|
||||
public extension DataService {
|
||||
func deleteSubscription(subscriptionName: String) async throws {
|
||||
func deleteSubscription(subscriptionName: String, subscriptionId: String) async throws {
|
||||
enum MutationResult {
|
||||
case success(id: String)
|
||||
case error(errorMessage: String)
|
||||
|
|
@ -20,7 +20,7 @@ public extension DataService {
|
|||
}
|
||||
|
||||
let mutation = Selection.Mutation {
|
||||
try $0.unsubscribe(name: subscriptionName, selection: selection)
|
||||
try $0.unsubscribe(name: subscriptionName, subscriptionId: OptionalArgument(subscriptionId), selection: selection)
|
||||
}
|
||||
|
||||
let path = appEnvironment.graphqlPath
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
import CoreData
|
||||
import Foundation
|
||||
import Models
|
||||
import SwiftGraphQL
|
||||
|
||||
public extension DataService {
|
||||
func fetchContent(itemID: String) async throws {
|
||||
enum MutationResult {
|
||||
case result(success: Bool)
|
||||
case error(errorMessage: String)
|
||||
}
|
||||
|
||||
let selection = Selection<MutationResult, Unions.FetchContentResult> {
|
||||
try $0.on(
|
||||
fetchContentError: .init { .error(errorMessage: try $0.errorCodes().first?.rawValue ?? "Unknown Error") },
|
||||
fetchContentSuccess: .init { .result(success: try $0.success()) }
|
||||
)
|
||||
}
|
||||
|
||||
let mutation = Selection.Mutation {
|
||||
try $0.fetchContent(id: itemID, selection: selection)
|
||||
}
|
||||
|
||||
let path = appEnvironment.graphqlPath
|
||||
let headers = networker.defaultHeaders
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
send(mutation, to: path, headers: headers) { queryResult in
|
||||
guard let payload = try? queryResult.get() else {
|
||||
continuation.resume(throwing: BasicError.message(messageText: "network error"))
|
||||
return
|
||||
}
|
||||
|
||||
switch payload.data {
|
||||
case let .result(success: success):
|
||||
if success {
|
||||
continuation.resume()
|
||||
} else {
|
||||
continuation.resume(throwing: BasicError.message(messageText: "Operation failed"))
|
||||
}
|
||||
case let .error(errorMessage: errorMessage):
|
||||
continuation.resume(throwing: BasicError.message(messageText: errorMessage))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import CoreData
|
||||
import Foundation
|
||||
import Models
|
||||
import SwiftGraphQL
|
||||
|
||||
public struct Rule {
|
||||
public let id: String
|
||||
public let name: String
|
||||
public let actions: [RuleAction]
|
||||
}
|
||||
|
||||
public enum RuleActionType {
|
||||
case addLabel
|
||||
case archive
|
||||
case markAsRead
|
||||
case sendNotification
|
||||
|
||||
static func from(_ other: Enums.RuleActionType) -> RuleActionType {
|
||||
switch other {
|
||||
case Enums.RuleActionType.addLabel:
|
||||
return .addLabel
|
||||
case Enums.RuleActionType.archive:
|
||||
return .archive
|
||||
case Enums.RuleActionType.markAsRead:
|
||||
return .markAsRead
|
||||
case Enums.RuleActionType.sendNotification:
|
||||
return .sendNotification
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct RuleAction {
|
||||
public let params: [String]
|
||||
public let type: RuleActionType
|
||||
}
|
||||
|
||||
let actionSelection = Selection.RuleAction {
|
||||
RuleAction(params: try $0.params(), type: RuleActionType.from(try $0.type()))
|
||||
}
|
||||
|
||||
let ruleSelection = Selection.Rule {
|
||||
Rule(id: try $0.id(), name: try $0.name(), actions: try $0.actions(selection: actionSelection.list))
|
||||
}
|
||||
|
||||
public extension DataService {
|
||||
func createOrUpdateAddLabelsRule(existingID: String?, name: String, filter: String, labelIDs: [String]) async throws -> Rule {
|
||||
enum MutationResult {
|
||||
case result(rule: Rule)
|
||||
case error(errorMessage: String)
|
||||
}
|
||||
|
||||
let selection = Selection<MutationResult, Unions.SetRuleResult> {
|
||||
try $0.on(
|
||||
setRuleError: .init { .error(errorMessage: try $0.errorCodes().first?.rawValue ?? "Unknown Error") },
|
||||
setRuleSuccess: .init { .result(rule: try $0.rule(selection: ruleSelection)) }
|
||||
)
|
||||
}
|
||||
|
||||
let mutation = Selection.Mutation {
|
||||
try $0.setRule(
|
||||
input: InputObjects.SetRuleInput(
|
||||
actions: [InputObjects.RuleActionInput(params: labelIDs, type: .addLabel)],
|
||||
enabled: true,
|
||||
eventTypes: [.pageCreated],
|
||||
filter: filter,
|
||||
id: OptionalArgument(existingID),
|
||||
name: name
|
||||
),
|
||||
selection: selection
|
||||
)
|
||||
}
|
||||
|
||||
let path = appEnvironment.graphqlPath
|
||||
let headers = networker.defaultHeaders
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
send(mutation, to: path, headers: headers) { queryResult in
|
||||
guard let payload = try? queryResult.get() else {
|
||||
continuation.resume(throwing: BasicError.message(messageText: "network error"))
|
||||
return
|
||||
}
|
||||
|
||||
switch payload.data {
|
||||
case let .result(rule: rule):
|
||||
continuation.resume(returning: rule)
|
||||
case let .error(errorMessage: errorMessage):
|
||||
continuation.resume(throwing: BasicError.message(messageText: errorMessage))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import Foundation
|
||||
import Models
|
||||
import SwiftGraphQL
|
||||
|
||||
public extension DataService {
|
||||
func subscribeToFeed(feedURL: String, folder: String? = nil, fetchContent: Bool? = nil) async throws -> Bool {
|
||||
enum MutationResult {
|
||||
case success(subscriptionIds: [String])
|
||||
case error(errorMessage: String)
|
||||
}
|
||||
|
||||
let subscriptionIdSelection = Selection.Subscription {
|
||||
try $0.id()
|
||||
}
|
||||
|
||||
let selection = Selection<MutationResult, Unions.SubscribeResult> {
|
||||
try $0.on(
|
||||
subscribeError: .init {
|
||||
.error(errorMessage: try $0.errorCodes().first?.rawValue ?? "unknown error")
|
||||
},
|
||||
subscribeSuccess: .init {
|
||||
.success(subscriptionIds: try $0.subscriptions(selection: subscriptionIdSelection.list))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
let mutation = Selection.Mutation {
|
||||
try $0.subscribe(input: InputObjects.SubscribeInput(
|
||||
fetchContent: OptionalArgument(fetchContent),
|
||||
folder: OptionalArgument(folder),
|
||||
url: feedURL
|
||||
), selection: selection)
|
||||
}
|
||||
|
||||
let path = appEnvironment.graphqlPath
|
||||
let headers = networker.defaultHeaders
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
send(mutation, to: path, headers: headers) { mutationResult in
|
||||
guard let payload = try? mutationResult.get() else {
|
||||
continuation.resume(throwing: BasicError.message(messageText: "failed to add feed \(feedURL)"))
|
||||
return
|
||||
}
|
||||
|
||||
switch payload.data {
|
||||
case .success:
|
||||
print("subscribed to feed:", feedURL)
|
||||
continuation.resume(returning: true)
|
||||
case .error:
|
||||
print("failed to subscribe to feed:", feedURL)
|
||||
continuation.resume(throwing: BasicError.message(messageText: "failed to add feed \(feedURL)"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import CoreData
|
||||
import Foundation
|
||||
import Models
|
||||
import SwiftGraphQL
|
||||
|
||||
public extension DataService {
|
||||
func updateNewsletterEmail(
|
||||
emailID: String, folder: String? = nil, description: String? = nil
|
||||
) async throws -> InternalNewsletterEmail {
|
||||
enum MutationResult {
|
||||
case result(email: InternalNewsletterEmail)
|
||||
case error(errorMessage: String)
|
||||
}
|
||||
|
||||
let selection = Selection<MutationResult, Unions.UpdateNewsletterEmailResult> {
|
||||
try $0.on(
|
||||
updateNewsletterEmailError: .init { .error(errorMessage: try $0.errorCodes().first?.rawValue ?? "Unknown Error") },
|
||||
updateNewsletterEmailSuccess: .init { .result(email: try $0.newsletterEmail(selection: newsletterEmailSelection)) }
|
||||
)
|
||||
}
|
||||
|
||||
let mutation = Selection.Mutation {
|
||||
try $0.updateNewsletterEmail(
|
||||
input: InputObjects.UpdateNewsletterEmailInput(
|
||||
description: OptionalArgument(description),
|
||||
folder: OptionalArgument(folder),
|
||||
|
||||
id: emailID
|
||||
),
|
||||
selection: selection
|
||||
)
|
||||
}
|
||||
|
||||
let path = appEnvironment.graphqlPath
|
||||
let headers = networker.defaultHeaders
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
send(mutation, to: path, headers: headers) { queryResult in
|
||||
guard let payload = try? queryResult.get() else {
|
||||
continuation.resume(throwing: BasicError.message(messageText: "network error"))
|
||||
return
|
||||
}
|
||||
|
||||
switch payload.data {
|
||||
case let .result(email: email):
|
||||
continuation.resume(returning: email)
|
||||
case let .error(errorMessage: errorMessage):
|
||||
continuation.resume(throwing: BasicError.message(messageText: errorMessage))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import CoreData
|
||||
import Foundation
|
||||
import Models
|
||||
import SwiftGraphQL
|
||||
|
||||
public extension DataService {
|
||||
func updateSubscription(_ subscriptionID: String, folder: String? = nil, fetchContent: Bool? = nil) async throws {
|
||||
enum MutationResult {
|
||||
case success(subscriptionID: String)
|
||||
case error(errorMessage: String)
|
||||
}
|
||||
|
||||
let selection = Selection<MutationResult, Unions.UpdateSubscriptionResult> {
|
||||
try $0.on(
|
||||
updateSubscriptionError: .init { .error(errorMessage: try $0.errorCodes().first?.rawValue ?? "Unknown Error") },
|
||||
updateSubscriptionSuccess: .init { .success(subscriptionID: try $0.subscription(selection: subscriptionSelection).subscriptionID) }
|
||||
)
|
||||
}
|
||||
|
||||
let mutation = Selection.Mutation {
|
||||
try $0.updateSubscription(
|
||||
input: InputObjects.UpdateSubscriptionInput(
|
||||
fetchContent: OptionalArgument(fetchContent),
|
||||
folder: OptionalArgument(folder),
|
||||
id: subscriptionID
|
||||
),
|
||||
selection: selection
|
||||
)
|
||||
}
|
||||
|
||||
let path = appEnvironment.graphqlPath
|
||||
let headers = networker.defaultHeaders
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
send(mutation, to: path, headers: headers) { queryResult in
|
||||
guard let payload = try? queryResult.get() else {
|
||||
continuation.resume(throwing: BasicError.message(messageText: "network error"))
|
||||
return
|
||||
}
|
||||
|
||||
switch payload.data {
|
||||
case .success:
|
||||
continuation.resume()
|
||||
case let .error(errorMessage: errorMessage):
|
||||
continuation.resume(throwing: BasicError.message(messageText: errorMessage))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,16 @@ import Foundation
|
|||
import Models
|
||||
import SwiftGraphQL
|
||||
|
||||
let newsletterEmailSelection = Selection.NewsletterEmail {
|
||||
InternalNewsletterEmail(
|
||||
emailId: try $0.id(),
|
||||
email: try $0.address(),
|
||||
folder: try $0.folder(),
|
||||
descriptionNote: try $0.description(),
|
||||
confirmationCode: try $0.confirmationCode()
|
||||
)
|
||||
}
|
||||
|
||||
public extension DataService {
|
||||
func newsletterEmails() async throws -> [NSManagedObjectID] {
|
||||
enum QueryResult {
|
||||
|
|
@ -10,14 +20,6 @@ public extension DataService {
|
|||
case error(error: String)
|
||||
}
|
||||
|
||||
let newsletterEmailSelection = Selection.NewsletterEmail {
|
||||
InternalNewsletterEmail(
|
||||
emailId: try $0.id(),
|
||||
email: try $0.address(),
|
||||
confirmationCode: try $0.confirmationCode()
|
||||
)
|
||||
}
|
||||
|
||||
let selection = Selection<QueryResult, Unions.NewsletterEmailsResult> {
|
||||
try $0.on(
|
||||
newsletterEmailsError: .init {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
import Foundation
|
||||
import Models
|
||||
import SwiftGraphQL
|
||||
|
||||
public extension DataService {
|
||||
func rules() async throws -> [Rule] {
|
||||
enum QueryResult {
|
||||
case success(result: [Rule])
|
||||
case error(error: String)
|
||||
}
|
||||
|
||||
let selection = Selection<QueryResult, Unions.RulesResult> {
|
||||
try $0.on(
|
||||
rulesError: .init {
|
||||
QueryResult.error(error: try $0.errorCodes().description)
|
||||
},
|
||||
rulesSuccess: .init {
|
||||
QueryResult.success(result: try $0.rules(selection: ruleSelection.list))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
let query = Selection.Query {
|
||||
try $0.rules(selection: selection)
|
||||
}
|
||||
|
||||
let path = appEnvironment.graphqlPath
|
||||
let headers = networker.defaultHeaders
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
send(query, to: path, headers: headers) { queryResult in
|
||||
guard let payload = try? queryResult.get() else {
|
||||
continuation.resume(throwing: BasicError.message(messageText: "network request failed"))
|
||||
return
|
||||
}
|
||||
|
||||
switch payload.data {
|
||||
case let .success(result: result):
|
||||
continuation.resume(returning: result)
|
||||
case .error:
|
||||
continuation.resume(throwing: BasicError.message(messageText: "Rules fetch error"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import CoreData
|
||||
import Foundation
|
||||
import Models
|
||||
import SwiftGraphQL
|
||||
|
||||
public struct Feed {
|
||||
public var title: String
|
||||
public var url: String
|
||||
public var description: String?
|
||||
public var image: String?
|
||||
public var author: String?
|
||||
}
|
||||
|
||||
let feedSelection = Selection.Feed {
|
||||
Feed(
|
||||
title: try $0.title(),
|
||||
url: try $0.url(),
|
||||
description: try $0.description(),
|
||||
image: try $0.image(),
|
||||
author: try $0.author()
|
||||
)
|
||||
}
|
||||
|
||||
public extension DataService {
|
||||
func scanFeed(feedURL: URL) async throws -> [Feed] {
|
||||
enum QueryResult {
|
||||
case success(result: [Feed])
|
||||
case error(error: String)
|
||||
}
|
||||
|
||||
let selection = Selection<QueryResult, Unions.ScanFeedsResult> {
|
||||
try $0.on(
|
||||
scanFeedsError: .init {
|
||||
QueryResult.error(error: try $0.errorCodes().description)
|
||||
},
|
||||
scanFeedsSuccess: .init {
|
||||
QueryResult.success(result: try $0.feeds(selection: feedSelection.list))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
let query = Selection.Query {
|
||||
try $0.scanFeeds(
|
||||
input: InputObjects.ScanFeedsInput(url: OptionalArgument(feedURL.absoluteString)),
|
||||
selection: selection
|
||||
)
|
||||
}
|
||||
|
||||
let path = appEnvironment.graphqlPath
|
||||
let headers = networker.defaultHeaders
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
send(query, to: path, headers: headers) { queryResult in
|
||||
print("QUERY RESULT: ", queryResult)
|
||||
guard let payload = try? queryResult.get() else {
|
||||
continuation.resume(throwing: BasicError.message(messageText: "network request failed"))
|
||||
return
|
||||
}
|
||||
|
||||
switch payload.data {
|
||||
case let .success(result: feeds):
|
||||
continuation.resume(returning: feeds)
|
||||
case .error:
|
||||
continuation.resume(throwing: BasicError.message(messageText: "scanFeed fetch error"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -9,29 +9,13 @@ public extension DataService {
|
|||
case error(error: String)
|
||||
}
|
||||
|
||||
let subsciptionSelection = Selection.Subscription {
|
||||
Subscription(
|
||||
createdAt: try $0.createdAt().value,
|
||||
description: try $0.description(),
|
||||
subscriptionID: try $0.id(),
|
||||
name: try $0.name(),
|
||||
newsletterEmailAddress: try $0.newsletterEmail(),
|
||||
status: try SubscriptionStatus.make(from: $0.status()),
|
||||
unsubscribeHttpUrl: try $0.unsubscribeHttpUrl(),
|
||||
unsubscribeMailTo: try $0.unsubscribeMailTo(),
|
||||
updatedAt: try $0.updatedAt()?.value ?? Date(),
|
||||
url: try $0.url(),
|
||||
icon: try $0.icon()
|
||||
)
|
||||
}
|
||||
|
||||
let selection = Selection<QueryResult, Unions.SubscriptionsResult> {
|
||||
try $0.on(
|
||||
subscriptionsError: .init {
|
||||
QueryResult.error(error: try $0.errorCodes().description)
|
||||
},
|
||||
subscriptionsSuccess: .init {
|
||||
QueryResult.success(result: try $0.subscriptions(selection: subsciptionSelection.list))
|
||||
QueryResult.success(result: try $0.subscriptions(selection: subscriptionSelection.list))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -54,22 +38,10 @@ public extension DataService {
|
|||
case let .success(result: result):
|
||||
continuation.resume(returning: result)
|
||||
case .error:
|
||||
print("QUERY RESULT: ", queryResult)
|
||||
continuation.resume(throwing: BasicError.message(messageText: "Subscriptions fetch error"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SubscriptionStatus {
|
||||
static func make(from status: Enums.SubscriptionStatus) -> SubscriptionStatus {
|
||||
switch status {
|
||||
case .active:
|
||||
return .active
|
||||
case .deleted:
|
||||
return .deleted
|
||||
case .unsubscribed:
|
||||
return .unsubscribed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ let filterSelection = Selection.Filter {
|
|||
InternalFilter(
|
||||
id: try $0.id(),
|
||||
name: try $0.name(),
|
||||
folder: try $0.folder(),
|
||||
folder: try $0.folder() ?? "inbox",
|
||||
filter: try $0.filter(),
|
||||
visible: try $0.visible() ?? true,
|
||||
position: try $0.position(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
|
||||
import Foundation
|
||||
import Models
|
||||
import SwiftGraphQL
|
||||
|
||||
let subscriptionSelection = Selection.Subscription {
|
||||
Subscription(
|
||||
createdAt: try $0.createdAt().value,
|
||||
description: try $0.description(),
|
||||
subscriptionID: try $0.id(),
|
||||
name: try $0.name(),
|
||||
type: try SubscriptionType.from($0.type()),
|
||||
folder: try $0.folder(),
|
||||
fetchContent: try $0.fetchContent(),
|
||||
newsletterEmailAddress: try $0.newsletterEmail(),
|
||||
status: try SubscriptionStatus.make(from: $0.status()),
|
||||
unsubscribeHttpUrl: try $0.unsubscribeHttpUrl(),
|
||||
unsubscribeMailTo: try $0.unsubscribeMailTo(),
|
||||
updatedAt: try $0.updatedAt()?.value ?? Date(),
|
||||
url: try $0.url(),
|
||||
icon: try $0.icon()
|
||||
)
|
||||
}
|
||||
|
||||
extension SubscriptionStatus {
|
||||
static func make(from status: Enums.SubscriptionStatus) -> SubscriptionStatus {
|
||||
switch status {
|
||||
case .active:
|
||||
return .active
|
||||
case .deleted:
|
||||
return .deleted
|
||||
case .unsubscribed:
|
||||
return .unsubscribed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SubscriptionType {
|
||||
static func from(_ other: Enums.SubscriptionType) -> SubscriptionType {
|
||||
switch other {
|
||||
case .rss:
|
||||
return .feed
|
||||
case .newsletter:
|
||||
return .newsletter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -39,6 +39,18 @@ public struct InternalFilter: Encodable, Identifiable, Hashable, Equatable {
|
|||
)
|
||||
}
|
||||
|
||||
public static var UnreadFilter: InternalFilter {
|
||||
InternalFilter(
|
||||
id: "unread",
|
||||
name: "Unread",
|
||||
folder: "inbox",
|
||||
filter: "in:inbox is:unread",
|
||||
visible: true,
|
||||
position: -1,
|
||||
defaultFilter: true
|
||||
)
|
||||
}
|
||||
|
||||
public static var DefaultInboxFilters: [InternalFilter] {
|
||||
[
|
||||
InternalFilter(
|
||||
|
|
@ -120,11 +132,29 @@ public struct InternalFilter: Encodable, Identifiable, Hashable, Equatable {
|
|||
[
|
||||
InternalFilter(
|
||||
id: "following",
|
||||
name: "RSS",
|
||||
name: "Following",
|
||||
folder: "following",
|
||||
filter: "in:following",
|
||||
visible: true,
|
||||
position: 1,
|
||||
defaultFilter: true
|
||||
),
|
||||
InternalFilter(
|
||||
id: "rss",
|
||||
name: "Feeds",
|
||||
folder: "following",
|
||||
filter: "in:following label:RSS",
|
||||
visible: true,
|
||||
position: 1,
|
||||
position: 2,
|
||||
defaultFilter: true
|
||||
),
|
||||
InternalFilter(
|
||||
id: "newsletters",
|
||||
name: "Newsletters",
|
||||
folder: "following",
|
||||
filter: "in:following label:Newsletter",
|
||||
visible: true,
|
||||
position: 3,
|
||||
defaultFilter: true
|
||||
)
|
||||
]
|
||||
|
|
@ -143,10 +173,9 @@ public struct InternalFilter: Encodable, Identifiable, Hashable, Equatable {
|
|||
}
|
||||
|
||||
public var predicate: NSPredicate? {
|
||||
if !defaultFilter {
|
||||
return nil
|
||||
}
|
||||
|
||||
let folderPredicate = NSPredicate(
|
||||
format: "%K == %@", #keyPath(Models.LibraryItem.folder), folder
|
||||
)
|
||||
let undeletedPredicate = NSPredicate(
|
||||
format: "%K != %i AND %K != \"DELETED\"",
|
||||
#keyPath(Models.LibraryItem.serverSyncStatus), Int64(ServerSyncStatus.needsDeletion.rawValue),
|
||||
|
|
@ -161,10 +190,16 @@ public struct InternalFilter: Encodable, Identifiable, Hashable, Equatable {
|
|||
let feedLabelPredicate = NSPredicate(
|
||||
format: "SUBQUERY(labels, $label, $label.name == \"RSS\").@count > 0"
|
||||
)
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [notInArchivePredicate, undeletedPredicate, feedLabelPredicate])
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [folderPredicate, notInArchivePredicate, undeletedPredicate, feedLabelPredicate])
|
||||
case "Following":
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [folderPredicate, notInArchivePredicate, undeletedPredicate])
|
||||
case "Inbox":
|
||||
// non-archived items
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, notInArchivePredicate])
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [folderPredicate, undeletedPredicate, notInArchivePredicate])
|
||||
case "Unread":
|
||||
let isUnread = NSPredicate(
|
||||
format: "readAt == nil"
|
||||
)
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [folderPredicate, undeletedPredicate, notInArchivePredicate, isUnread])
|
||||
case "Non-Feed Items":
|
||||
// non-archived or deleted items without the Newsletter label
|
||||
let nonNewsletterLabelPredicate = NSPredicate(
|
||||
|
|
@ -174,7 +209,7 @@ public struct InternalFilter: Encodable, Identifiable, Hashable, Equatable {
|
|||
format: "NOT SUBQUERY(labels, $label, $label.name == \"RSS\") .@count > 0"
|
||||
)
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [
|
||||
undeletedPredicate, notInArchivePredicate, nonNewsletterLabelPredicate, nonRSSPredicate
|
||||
folderPredicate, undeletedPredicate, notInArchivePredicate, nonNewsletterLabelPredicate, nonRSSPredicate
|
||||
])
|
||||
case "Downloaded":
|
||||
// include pdf only
|
||||
|
|
@ -188,50 +223,51 @@ public struct InternalFilter: Encodable, Identifiable, Hashable, Equatable {
|
|||
format: "localPDF.length > 0"
|
||||
)
|
||||
let downloadedPDF = NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, isPDFPredicate, localPDFURL])
|
||||
return NSCompoundPredicate(orPredicateWithSubpredicates: [hasHTMLContent, downloadedPDF])
|
||||
return NSCompoundPredicate(orPredicateWithSubpredicates: [folderPredicate, hasHTMLContent, downloadedPDF])
|
||||
case "Newsletters":
|
||||
// non-archived or deleted items with the Newsletter label
|
||||
let newsletterLabelPredicate = NSPredicate(
|
||||
format: "SUBQUERY(labels, $label, $label.name == \"Newsletter\").@count > 0"
|
||||
)
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, notInArchivePredicate, newsletterLabelPredicate])
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [folderPredicate, undeletedPredicate, notInArchivePredicate, newsletterLabelPredicate])
|
||||
case "Feeds":
|
||||
let feedLabelPredicate = NSPredicate(
|
||||
format: "SUBQUERY(labels, $label, $label.name == \"RSS\").@count > 0"
|
||||
)
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, notInArchivePredicate, feedLabelPredicate])
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [folderPredicate, undeletedPredicate, notInArchivePredicate, feedLabelPredicate])
|
||||
case "Recommended":
|
||||
// non-archived or deleted items with the Newsletter label
|
||||
let recommendedPredicate = NSPredicate(
|
||||
format: "recommendations.@count > 0"
|
||||
)
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, notInArchivePredicate, recommendedPredicate])
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [folderPredicate, undeletedPredicate, notInArchivePredicate, recommendedPredicate])
|
||||
case "All":
|
||||
// include everything undeleted
|
||||
return undeletedPredicate
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [folderPredicate, undeletedPredicate])
|
||||
case "Archived":
|
||||
let inArchivePredicate = NSPredicate(
|
||||
format: "%K == %@", #keyPath(Models.LibraryItem.isArchived), Int(truncating: true) as NSNumber
|
||||
)
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, inArchivePredicate])
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [folderPredicate, undeletedPredicate, inArchivePredicate])
|
||||
case "Deleted":
|
||||
let deletedPredicate = NSPredicate(
|
||||
format: "%K == %i OR %K == \"DELETED\"",
|
||||
#keyPath(Models.LibraryItem.serverSyncStatus), Int64(ServerSyncStatus.needsDeletion.rawValue),
|
||||
#keyPath(Models.LibraryItem.state)
|
||||
)
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [deletedPredicate])
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [folderPredicate, deletedPredicate])
|
||||
case "Files":
|
||||
// include pdf only
|
||||
let isPDFPredicate = NSPredicate(
|
||||
format: "%K == %@", #keyPath(Models.LibraryItem.contentReader), "PDF"
|
||||
)
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, isPDFPredicate])
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [folderPredicate, undeletedPredicate, isPDFPredicate])
|
||||
case "Highlights":
|
||||
let hasHighlightsPredicate = NSPredicate(
|
||||
format: "highlights.@count > 0"
|
||||
)
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [
|
||||
folderPredicate,
|
||||
undeletedPredicate,
|
||||
hasHighlightsPredicate
|
||||
])
|
||||
|
|
@ -310,6 +346,20 @@ public extension Filter {
|
|||
|
||||
return filter
|
||||
}
|
||||
|
||||
static func lookup(byFilter filter: String, inContext context: NSManagedObjectContext) -> Filter? {
|
||||
let fetchRequest: NSFetchRequest<Models.Filter> = Filter.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(
|
||||
format: "filter == %@", filter
|
||||
)
|
||||
|
||||
var filter: Filter?
|
||||
context.performAndWait {
|
||||
filter = (try? context.fetch(fetchRequest))?.first
|
||||
}
|
||||
|
||||
return filter
|
||||
}
|
||||
}
|
||||
|
||||
extension Sequence where Element == InternalFilter {
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@ import CoreData
|
|||
import Foundation
|
||||
import Models
|
||||
|
||||
struct InternalNewsletterEmail {
|
||||
public struct InternalNewsletterEmail {
|
||||
let emailId: String
|
||||
let email: String
|
||||
let folder: String
|
||||
let descriptionNote: String?
|
||||
let confirmationCode: String?
|
||||
|
||||
func persist(context: NSManagedObjectContext) -> NSManagedObjectID? {
|
||||
|
|
@ -32,7 +34,10 @@ struct InternalNewsletterEmail {
|
|||
|
||||
newsletterEmail.emailId = emailId
|
||||
newsletterEmail.email = email
|
||||
newsletterEmail.folder = folder
|
||||
newsletterEmail.descriptionNote = descriptionNote
|
||||
newsletterEmail.confirmationCode = confirmationCode
|
||||
|
||||
return newsletterEmail
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,11 @@ public extension NSNotification {
|
|||
static let DisplayProfile = Notification.Name("DisplayProfile")
|
||||
static let Logout = Notification.Name("Logout")
|
||||
static let ScrollToTop = Notification.Name("ScrollToTop")
|
||||
static let PerformSync = Notification.Name("PerformSync")
|
||||
|
||||
static var performSyncPublisher: NotificationCenter.Publisher {
|
||||
NotificationCenter.default.publisher(for: PerformSync)
|
||||
}
|
||||
|
||||
static var pushFeedItemPublisher: NotificationCenter.Publisher {
|
||||
NotificationCenter.default.publisher(for: PushJSONArticle)
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ public enum UserDefaultKey: String {
|
|||
case deviceTokenID
|
||||
case userWordsPerMinute
|
||||
case hideFeatureSection
|
||||
case hideSystemLabels
|
||||
case justifyText
|
||||
case prefersHideStatusBarInReader
|
||||
case visibleShareExtensionTab
|
||||
|
|
|
|||
|
|
@ -48,6 +48,9 @@ public extension Color {
|
|||
|
||||
static var thFeatureSeparator: Color { Color("featureSeparator", bundle: .module) }
|
||||
|
||||
static var thFallbackImageForeground: Color { Color("thFallbackImageForeground", bundle: .module) }
|
||||
static var thFallbackImageBackground: Color { Color("thFallbackImageBackground", bundle: .module) }
|
||||
|
||||
static var circleButtonBackground: Color { Color("_circleButtonBackground", bundle: .module) }
|
||||
static var circleButtonForeground: Color { Color("_circleButtonForeground", bundle: .module) }
|
||||
static var extensionBackground: Color { Color("_extensionBackground", bundle: .module) }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0xED",
|
||||
"green" : "0xED",
|
||||
"red" : "0xED"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x3C",
|
||||
"green" : "0x3C",
|
||||
"red" : "0x3C"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x2A",
|
||||
"green" : "0x2A",
|
||||
"red" : "0x2A"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0xFF",
|
||||
"green" : "0xFF",
|
||||
"red" : "0xFE"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0xFF",
|
||||
"green" : "0xFF",
|
||||
"red" : "0xFE"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x2A",
|
||||
"green" : "0x2A",
|
||||
"red" : "0x2A"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
|
@ -12,12 +12,12 @@ public enum GridCardAction {
|
|||
|
||||
public struct GridCard: View {
|
||||
@Binding var isContextMenuOpen: Bool
|
||||
let item: Models.LibraryItem
|
||||
let item: LibraryItemData
|
||||
let actionHandler: (GridCardAction) -> Void
|
||||
// let tapAction: () -> Void
|
||||
|
||||
public init(
|
||||
item: Models.LibraryItem,
|
||||
item: LibraryItemData,
|
||||
isContextMenuOpen: Binding<Bool>,
|
||||
actionHandler: @escaping (GridCardAction) -> Void
|
||||
) {
|
||||
|
|
@ -51,7 +51,7 @@ public struct GridCard: View {
|
|||
)
|
||||
Button(
|
||||
action: { menuActionHandler(.editLabels) },
|
||||
label: { Label(item.labels?.count == 0 ? "Add Labels" : "Edit Labels", systemImage: "tag") }
|
||||
label: { Label(item.sortedLabels.count == 0 ? "Add Labels" : "Edit Labels", systemImage: "tag") }
|
||||
)
|
||||
Button(
|
||||
action: { menuActionHandler(.toggleArchiveStatus) },
|
||||
|
|
@ -77,7 +77,7 @@ public struct GridCard: View {
|
|||
CachedAsyncImage(url: imageURL) { phase in
|
||||
switch phase {
|
||||
case .empty:
|
||||
Color.systemBackground
|
||||
Color.clear
|
||||
.frame(maxWidth: .infinity, maxHeight: geo.size.height)
|
||||
case let .success(image):
|
||||
image.resizable()
|
||||
|
|
@ -93,7 +93,7 @@ public struct GridCard: View {
|
|||
// we need to add this currently unused fallback
|
||||
// to handle any new cases that might be added
|
||||
// in the future:
|
||||
Color.systemBackground
|
||||
Color.clear
|
||||
.frame(maxWidth: .infinity, maxHeight: geo.size.height)
|
||||
}
|
||||
}
|
||||
|
|
@ -117,16 +117,16 @@ public struct GridCard: View {
|
|||
var fallbackImage: some View {
|
||||
GeometryReader { geo in
|
||||
HStack {
|
||||
Text(item.unwrappedTitle)
|
||||
Text(item.title)
|
||||
.font(fallbackFont)
|
||||
.frame(alignment: .center)
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineLimit(2)
|
||||
.padding(10)
|
||||
.foregroundColor(Color.themeMiddleGray)
|
||||
.foregroundColor(Color.thFallbackImageForeground)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.thLightWhiteGrey)
|
||||
.background(Color.thFallbackImageBackground)
|
||||
.frame(width: geo.size.width, height: geo.size.height)
|
||||
}
|
||||
}
|
||||
|
|
@ -168,7 +168,7 @@ public struct GridCard: View {
|
|||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(item.unwrappedTitle)
|
||||
Text(item.title)
|
||||
.font(.appHeadline)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.lineLimit(1)
|
||||
|
|
@ -183,7 +183,7 @@ public struct GridCard: View {
|
|||
|
||||
// Link description and image
|
||||
HStack(alignment: .top) {
|
||||
Text(item.descriptionText ?? item.unwrappedTitle)
|
||||
Text(item.descriptionText ?? item.title)
|
||||
.font(.appSubheadline)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.lineLimit(3)
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ public struct LibraryFeatureCard: View {
|
|||
CachedAsyncImage(url: imageURL) { phase in
|
||||
switch phase {
|
||||
case .empty:
|
||||
Color.systemBackground
|
||||
Color.clear
|
||||
.frame(width: 146, height: 90)
|
||||
case let .success(image):
|
||||
image.resizable()
|
||||
|
|
@ -81,10 +81,10 @@ public struct LibraryFeatureCard: View {
|
|||
.multilineTextAlignment(.leading)
|
||||
.lineLimit(2)
|
||||
.padding(10)
|
||||
.foregroundColor(Color.themeMiddleGray)
|
||||
.foregroundColor(Color.thFallbackImageForeground)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.thLightWhiteGrey)
|
||||
.background(Color.thFallbackImageBackground)
|
||||
.frame(width: 146, height: 90)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,11 +32,11 @@ enum FlairLabels: String {
|
|||
}
|
||||
|
||||
public extension View {
|
||||
func draggableItem(item: Models.LibraryItem) -> some View {
|
||||
func draggableItem(item: LibraryItemData) -> some View {
|
||||
#if os(iOS)
|
||||
if #available(iOS 16.0, *), let url = item.deepLink {
|
||||
return AnyView(self.draggable(url) {
|
||||
Label(item.unwrappedTitle, systemImage: "link")
|
||||
Label(item.title, systemImage: "link")
|
||||
})
|
||||
}
|
||||
#endif
|
||||
|
|
@ -44,12 +44,75 @@ public extension View {
|
|||
}
|
||||
}
|
||||
|
||||
public struct LibraryItemData {
|
||||
public var id: String
|
||||
public let title: String
|
||||
public let pageURLString: String
|
||||
public var isArchived: Bool
|
||||
public let author: String?
|
||||
public let deepLink: URL?
|
||||
public let hasLabels: Bool
|
||||
public let noteText: String?
|
||||
public let readingProgress: Double
|
||||
public let wordsCount: Int64
|
||||
public let isPDF: Bool
|
||||
public let highlights: NSSet?
|
||||
public let sortedLabels: [LinkedItemLabel]
|
||||
public let imageURL: URL?
|
||||
public let publisherDisplayName: String?
|
||||
public let descriptionText: String?
|
||||
|
||||
public init(id: String, title: String, pageURLString: String, isArchived: Bool, author: String?,
|
||||
deepLink: URL?, hasLabels: Bool, noteText: String?,
|
||||
readingProgress: Double, wordsCount: Int64, isPDF: Bool, highlights: NSSet?,
|
||||
sortedLabels: [LinkedItemLabel], imageURL: URL?, publisherDisplayName: String?, descriptionText: String?)
|
||||
{
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.pageURLString = pageURLString
|
||||
self.isArchived = isArchived
|
||||
self.author = author
|
||||
self.deepLink = deepLink
|
||||
self.hasLabels = hasLabels
|
||||
self.noteText = noteText
|
||||
self.readingProgress = readingProgress
|
||||
self.wordsCount = wordsCount
|
||||
self.isPDF = isPDF
|
||||
self.highlights = highlights
|
||||
self.sortedLabels = sortedLabels
|
||||
self.imageURL = imageURL
|
||||
self.publisherDisplayName = publisherDisplayName
|
||||
self.descriptionText = descriptionText
|
||||
}
|
||||
|
||||
public static func make(from item: Models.LibraryItem) -> LibraryItemData {
|
||||
LibraryItemData(
|
||||
id: item.unwrappedID,
|
||||
title: item.unwrappedTitle,
|
||||
pageURLString: item.unwrappedPageURLString,
|
||||
isArchived: item.isArchived,
|
||||
author: item.author,
|
||||
deepLink: item.deepLink,
|
||||
hasLabels: item.hasLabels,
|
||||
noteText: item.noteText,
|
||||
readingProgress: item.readingProgress,
|
||||
wordsCount: item.wordsCount,
|
||||
isPDF: item.isPDF,
|
||||
highlights: item.highlights,
|
||||
sortedLabels: item.sortedLabels,
|
||||
imageURL: item.imageURL,
|
||||
publisherDisplayName: item.publisherDisplayName,
|
||||
descriptionText: item.descriptionText
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public struct LibraryItemCard: View {
|
||||
let viewer: Viewer?
|
||||
@ObservedObject var item: Models.LibraryItem
|
||||
var item: LibraryItemData
|
||||
@State var noteLineLimit: Int? = 3
|
||||
|
||||
public init(item: Models.LibraryItem, viewer: Viewer?) {
|
||||
public init(item: LibraryItemData, viewer: Viewer?) {
|
||||
self.item = item
|
||||
self.viewer = viewer
|
||||
}
|
||||
|
|
@ -249,7 +312,7 @@ public struct LibraryItemCard: View {
|
|||
.cornerRadius(5)
|
||||
.padding(.top, 2)
|
||||
} else {
|
||||
Color.systemBackground
|
||||
Color.clear
|
||||
.frame(width: 50, height: 75)
|
||||
.cornerRadius(5)
|
||||
.padding(.top, 2)
|
||||
|
|
@ -299,7 +362,7 @@ public struct LibraryItemCard: View {
|
|||
readInfo
|
||||
.dynamicTypeSize(.xSmall ... .medium)
|
||||
|
||||
Text(item.unwrappedTitle)
|
||||
Text(item.title)
|
||||
.font(.body).fontWeight(.semibold)
|
||||
.lineSpacing(1.25)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,3 @@
|
|||
//
|
||||
// FormSheet.swift
|
||||
//
|
||||
//
|
||||
// Created by Jackson Harper on 1/29/22.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
#if os(iOS)
|
||||
|
|
|
|||
|
|
@ -13,11 +13,20 @@ public extension Image {
|
|||
static var tabHighlights: Image { Image("_tab_highlights", bundle: .module).renderingMode(.template) }
|
||||
static var tabProfile: Image { Image("_tab_profile", bundle: .module).renderingMode(.template) }
|
||||
|
||||
static var toolbarArchive: Image { Image("toolbar-archive", bundle: .module).renderingMode(.template) }
|
||||
static var toolbarUnarchive: Image { Image("toolbar-unarchive", bundle: .module).renderingMode(.template) }
|
||||
|
||||
static var toolbarShare: Image { Image("toolbar-share", bundle: .module).renderingMode(.template) }
|
||||
static var toolbarTrash: Image { Image("toolbar-trash", bundle: .module).renderingMode(.template) }
|
||||
|
||||
static var pinRotated: Image { Image("pin-rotated", bundle: .module) }
|
||||
|
||||
static var chevronRight: Image { Image("chevron-right", bundle: .module) }
|
||||
static var notebook: Image { Image("notebook", bundle: .module) }
|
||||
static var headphones: Image { Image("headphones", bundle: .module) }
|
||||
|
||||
static var audioPlay: Image { Image("header-play", bundle: .module) }
|
||||
static var audioPause: Image { Image("header-pause", bundle: .module) }
|
||||
static var readerSettings: Image { Image("reader-settings", bundle: .module) }
|
||||
static var utilityMenu: Image { Image("utility-menu", bundle: .module) }
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "chart-line-up.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="32" height="32" viewBox="0 0 32 32" version="1.1">
|
||||
<g id="surface1">
|
||||
<path style="fill:none;stroke-width:16;stroke-linecap:round;stroke-linejoin:round;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-miterlimit:4;" d="M 224 208 L 32 208 L 32 48 " transform="matrix(0.125,0,0,0.125,0,0)"/>
|
||||
<path style="fill:none;stroke-width:16;stroke-linecap:round;stroke-linejoin:round;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-miterlimit:4;" d="M 208 64 L 128 144 L 96 112 L 32 176 " transform="matrix(0.125,0,0,0.125,0,0)"/>
|
||||
<path style="fill:none;stroke-width:16;stroke-linecap:round;stroke-linejoin:round;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-miterlimit:4;" d="M 208 104 L 208 64 L 168 64 " transform="matrix(0.125,0,0,0.125,0,0)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 869 B |
24
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/header-pause.imageset/Contents.json
vendored
Normal file
24
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/header-pause.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "header-pause.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
11
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/header-pause.imageset/header-pause.svg
vendored
Normal file
11
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/header-pause.imageset/header-pause.svg
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_6919_3680)">
|
||||
<path d="M6.25024 6.25065C6.25024 5.97438 6.35999 5.70943 6.55534 5.51408C6.75069 5.31873 7.01564 5.20898 7.29191 5.20898H9.37524C9.65151 5.20898 9.91646 5.31873 10.1118 5.51408C10.3072 5.70943 10.4169 5.97438 10.4169 6.25065V18.7507C10.4169 19.0269 10.3072 19.2919 10.1118 19.4872C9.91646 19.6826 9.65151 19.7923 9.37524 19.7923H7.29191C7.01564 19.7923 6.75069 19.6826 6.55534 19.4872C6.35999 19.2919 6.25024 19.0269 6.25024 18.7507V6.25065Z" stroke="#6A6968" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14.5835 6.25065C14.5835 5.97438 14.6932 5.70943 14.8886 5.51408C15.0839 5.31873 15.3489 5.20898 15.6252 5.20898H17.7085C17.9848 5.20898 18.2497 5.31873 18.4451 5.51408C18.6404 5.70943 18.7502 5.97438 18.7502 6.25065V18.7507C18.7502 19.0269 18.6404 19.2919 18.4451 19.4872C18.2497 19.6826 17.9848 19.7923 17.7085 19.7923H15.6252C15.3489 19.7923 15.0839 19.6826 14.8886 19.4872C14.6932 19.2919 14.5835 19.0269 14.5835 18.7507V6.25065Z" stroke="#6A6968" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_6919_3680">
|
||||
<rect width="25" height="25" fill="white" transform="translate(0.000244141)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
24
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/header-play.imageset/Contents.json
vendored
Normal file
24
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/header-play.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "PlayCircle.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
3
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/header-play.imageset/PlayCircle.svg
vendored
Normal file
3
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/header-play.imageset/PlayCircle.svg
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="26" height="26" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13 2.4375C10.9109 2.4375 8.86879 3.05698 7.13179 4.2176C5.3948 5.37822 4.04098 7.02786 3.24153 8.95791C2.44208 10.8879 2.2329 13.0117 2.64046 15.0606C3.04802 17.1096 4.054 18.9916 5.53119 20.4688C7.00838 21.946 8.89044 22.952 10.9394 23.3595C12.9883 23.7671 15.1121 23.5579 17.0421 22.7585C18.9721 21.959 20.6218 20.6052 21.7824 18.8682C22.943 17.1312 23.5625 15.0891 23.5625 13C23.5595 10.1996 22.4458 7.51466 20.4656 5.53445C18.4853 3.55424 15.8004 2.44046 13 2.4375ZM13 21.9375C11.2323 21.9375 9.50436 21.4133 8.0346 20.4313C6.56483 19.4492 5.41929 18.0533 4.74283 16.4202C4.06637 14.7871 3.88938 12.9901 4.23424 11.2564C4.57909 9.52268 5.43031 7.93016 6.68024 6.68023C7.93017 5.4303 9.52268 4.57909 11.2564 4.23423C12.9901 3.88938 14.7871 4.06637 16.4202 4.74283C18.0534 5.41928 19.4492 6.56483 20.4313 8.03459C21.4133 9.50435 21.9375 11.2323 21.9375 13C21.9348 15.3695 20.9923 17.6413 19.3168 19.3168C17.6413 20.9923 15.3696 21.9348 13 21.9375ZM16.7009 12.3236L11.8259 9.07359C11.7036 8.99193 11.5613 8.94504 11.4143 8.93792C11.2674 8.9308 11.1212 8.96372 10.9915 9.03317C10.8618 9.10261 10.7534 9.20597 10.6778 9.33222C10.6023 9.45847 10.5624 9.60287 10.5625 9.75V16.25C10.5624 16.3971 10.6023 16.5415 10.6778 16.6678C10.7534 16.794 10.8618 16.8974 10.9915 16.9668C11.1212 17.0363 11.2674 17.0692 11.4143 17.0621C11.5613 17.055 11.7036 17.0081 11.8259 16.9264L16.7009 13.6764C16.8124 13.6022 16.9038 13.5017 16.967 13.3837C17.0302 13.2657 17.0633 13.1339 17.0633 13C17.0633 12.8661 17.0302 12.7343 16.967 12.6163C16.9038 12.4983 16.8124 12.3978 16.7009 12.3236ZM12.1875 14.7316V11.2734L14.7855 13L12.1875 14.7316Z" fill="#D9D9D9"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
|
|
@ -1,17 +1,15 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "label.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "label@2x.png",
|
||||
"filename" : "header-label.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "label@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
|
|
|
|||
11
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/label.imageset/header-label.svg
vendored
Normal file
11
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/label.imageset/header-label.svg
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_6919_3673)">
|
||||
<path d="M8.85441 9.89583C9.42971 9.89583 9.89608 9.42946 9.89608 8.85417C9.89608 8.27887 9.42971 7.8125 8.85441 7.8125C8.27911 7.8125 7.81274 8.27887 7.81274 8.85417C7.81274 9.42946 8.27911 9.89583 8.85441 9.89583Z" stroke="#6A6968" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4.16699 7.29102V11.3108C4.16699 11.8702 4.38887 12.4066 4.7847 12.8025L13.2389 21.2566C13.4347 21.4525 13.6673 21.608 13.9232 21.714C14.1792 21.82 14.4535 21.8746 14.7305 21.8746C15.0076 21.8746 15.2819 21.82 15.5378 21.714C15.7938 21.608 16.0263 21.4525 16.2222 21.2566L21.2576 16.2212C21.4535 16.0253 21.6089 15.7928 21.715 15.5369C21.821 15.2809 21.8756 15.0066 21.8756 14.7296C21.8756 14.4525 21.821 14.1782 21.715 13.9223C21.6089 13.6663 21.4535 13.4338 21.2576 13.2379L12.8024 4.78372C12.407 4.38841 11.8709 4.16623 11.3118 4.16602H7.29199C6.46319 4.16602 5.66833 4.49526 5.08228 5.08131C4.49623 5.66736 4.16699 6.46221 4.16699 7.29102Z" stroke="#6A6968" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_6919_3673">
|
||||
<rect width="25" height="25" fill="white" transform="translate(0.000244141)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 499 B |
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue