mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Merge pull request #1753 from omnivore-app/feat/highlights-export
Export highlights/notes from iOS
This commit is contained in:
commit
89119add8a
178 changed files with 6173 additions and 2159 deletions
File diff suppressed because one or more lines are too long
|
|
@ -64,8 +64,8 @@ final class AppStoreScreenshots: XCTestCase {
|
|||
//
|
||||
snapshot("ReaderTTSPlayer")
|
||||
|
||||
/// app.children(matching: .window).element(boundBy: 0).tap()
|
||||
// XCUIApplication().buttons["Back"].tap()
|
||||
// app.children(matching: .window).element(boundBy: 0).tap()
|
||||
// XCUIApplication().buttons["Back"].tap()
|
||||
}
|
||||
|
||||
func testScreenshotReaderActions() throws {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
// swiftlint:disable line_length
|
||||
//
|
||||
// SnapshotHelper.swift
|
||||
// Example
|
||||
|
|
@ -32,7 +33,8 @@ func snapshot(_ name: String, waitForLoadingIndicator: Bool) {
|
|||
|
||||
/// - Parameters:
|
||||
/// - name: The name of the snapshot
|
||||
/// - timeout: Amount of seconds to wait until the network loading indicator disappears. Pass `0` if you don't want to wait.
|
||||
/// - timeout: Amount of seconds to wait until the network loading indicator disappears.
|
||||
/// Pass `0` if you don't want to wait.
|
||||
func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) {
|
||||
Snapshot.snapshot(name, timeWaitingForIdle: timeout)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 52;
|
||||
objectVersion = 54;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
045B1681279147E7005047F7 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = 045B1680279147E7005047F7 /* FirebaseMessaging */; };
|
||||
0465B9BE26CDD35F005558CD /* MainApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D81BE98F0CB588F5FC577A13 /* MainApp.swift */; };
|
||||
046C5CD526A3F89A00AC5349 /* icon.icns in Resources */ = {isa = PBXBuildFile; fileRef = 046C5CD426A3F89A00AC5349 /* icon.icns */; };
|
||||
046C5CDF26A3F89A00AC5349 /* ShareExtension-Mac.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 046C5CD226A3F89A00AC5349 /* ShareExtension-Mac.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
046C5CDF26A3F89A00AC5349 /* ShareExtension-Mac.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 046C5CD226A3F89A00AC5349 /* ShareExtension-Mac.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
046C5CE626A3FE8C00AC5349 /* App in Frameworks */ = {isa = PBXBuildFile; productRef = 046C5CE526A3FE8C00AC5349 /* App */; };
|
||||
047AD6F22724C934004FD5CF /* App in Frameworks */ = {isa = PBXBuildFile; productRef = 047AD6F12724C934004FD5CF /* App */; };
|
||||
047AD6F42724C941004FD5CF /* App in Frameworks */ = {isa = PBXBuildFile; productRef = 047AD6F32724C941004FD5CF /* App */; };
|
||||
|
|
@ -30,10 +30,10 @@
|
|||
04E9707226BDE5F1002A523D /* ShareExtension.js in Resources */ = {isa = PBXBuildFile; fileRef = 04E9706E26BDE47D002A523D /* ShareExtension.js */; };
|
||||
04EE742126DFD2EF001B3483 /* ScreenIdentifierHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04EE741F26DFD2CA001B3483 /* ScreenIdentifierHelpers.swift */; };
|
||||
04EE742226DFD2F0001B3483 /* ScreenIdentifierHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04EE741F26DFD2CA001B3483 /* ScreenIdentifierHelpers.swift */; };
|
||||
1BE07906EAA5555B6140B6DB /* ShareExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 95D337DDCEA2007A9705A969 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
1BE07906EAA5555B6140B6DB /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 95D337DDCEA2007A9705A969 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
260BBE4168E3C57D6D2E225D /* App in Frameworks */ = {isa = PBXBuildFile; productRef = 7DD0EF4D497D71D248C7B0DF /* App */; };
|
||||
4214F6EF2714D2260096B644 /* SafariExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 42FF1B16271154A700B38C38 /* SafariExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
4214F6F22714D28E0096B644 /* SafariExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 42FF1B20271154A700B38C38 /* SafariExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
4214F6EF2714D2260096B644 /* SafariExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 42FF1B16271154A700B38C38 /* SafariExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
4214F6F22714D28E0096B644 /* SafariExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 42FF1B20271154A700B38C38 /* SafariExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
4225D59227164F82002A680F /* fonts in Resources */ = {isa = PBXBuildFile; fileRef = 4225D59127164F82002A680F /* fonts */; };
|
||||
4225D59327164F82002A680F /* fonts in Resources */ = {isa = PBXBuildFile; fileRef = 4225D59127164F82002A680F /* fonts */; };
|
||||
42321E852714E6B00056429F /* scripts in Resources */ = {isa = PBXBuildFile; fileRef = 42321E822714E6B00056429F /* scripts */; };
|
||||
|
|
@ -127,16 +127,16 @@
|
|||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
046C5CE026A3F89A00AC5349 /* Embed App Extensions */ = {
|
||||
046C5CE026A3F89A00AC5349 /* Embed Foundation Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
046C5CDF26A3F89A00AC5349 /* ShareExtension-Mac.appex in Embed App Extensions */,
|
||||
4214F6F22714D28E0096B644 /* SafariExtension.appex in Embed App Extensions */,
|
||||
046C5CDF26A3F89A00AC5349 /* ShareExtension-Mac.appex in Embed Foundation Extensions */,
|
||||
4214F6F22714D28E0096B644 /* SafariExtension.appex in Embed Foundation Extensions */,
|
||||
);
|
||||
name = "Embed App Extensions";
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
089E832985F9DC4E3D727F87 /* Embed Frameworks */ = {
|
||||
|
|
@ -169,15 +169,15 @@
|
|||
name = "Embed Frameworks";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
BA17F8C8424E5121F1EE345C /* Embed App Extensions */ = {
|
||||
BA17F8C8424E5121F1EE345C /* Embed Foundation Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
1BE07906EAA5555B6140B6DB /* ShareExtension.appex in Embed App Extensions */,
|
||||
4214F6EF2714D2260096B644 /* SafariExtension.appex in Embed App Extensions */,
|
||||
1BE07906EAA5555B6140B6DB /* ShareExtension.appex in Embed Foundation Extensions */,
|
||||
4214F6EF2714D2260096B644 /* SafariExtension.appex in Embed Foundation Extensions */,
|
||||
);
|
||||
name = "Embed App Extensions";
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
|
@ -640,8 +640,8 @@
|
|||
048ECFF826A0B1CB00469E57 /* Sources */,
|
||||
048ECFF926A0B1CB00469E57 /* Frameworks */,
|
||||
048ECFFA26A0B1CB00469E57 /* Resources */,
|
||||
04B8B00426A0C19900D3ED0C /* ShellScript */,
|
||||
046C5CE026A3F89A00AC5349 /* Embed App Extensions */,
|
||||
04B8B00426A0C19900D3ED0C /* Format and Lint */,
|
||||
046C5CE026A3F89A00AC5349 /* Embed Foundation Extensions */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
|
|
@ -663,7 +663,6 @@
|
|||
buildPhases = (
|
||||
43512AEB8FEA59E4C1B62535 /* Sources */,
|
||||
B147F11D6548C5B600AC0CBD /* Resources */,
|
||||
AAC826E9061AEBA0BF527814 /* Embed Precompiled Frameworks */,
|
||||
76DAC57C99AFCDF06896A42F /* Embed Frameworks */,
|
||||
3C5F49DFFF5F79D9D0C7412E /* Frameworks */,
|
||||
);
|
||||
|
|
@ -743,8 +742,7 @@
|
|||
buildPhases = (
|
||||
8E4C1300A69A749FE3B720A3 /* Sources */,
|
||||
ABA2CCAE947C7AFC88C991A5 /* Resources */,
|
||||
BA17F8C8424E5121F1EE345C /* Embed App Extensions */,
|
||||
6FD84AF08CA126EA8F6906A4 /* Embed Precompiled Frameworks */,
|
||||
BA17F8C8424E5121F1EE345C /* Embed Foundation Extensions */,
|
||||
4C02552307C731B3D3F28680 /* Embed Frameworks */,
|
||||
F06D5ADCFDD68AC8775386BB /* Frameworks */,
|
||||
909EF91C2946F26E9123C65F /* Format and Lint */,
|
||||
|
|
@ -772,7 +770,6 @@
|
|||
buildPhases = (
|
||||
041E0C13E0057CDDC868A4DB /* Sources */,
|
||||
BC2DC89A3D7E1E86540ECA14 /* Resources */,
|
||||
DEA43E9E9C46BFC9EBE0D359 /* Embed Precompiled Frameworks */,
|
||||
089E832985F9DC4E3D727F87 /* Embed Frameworks */,
|
||||
7D03721CA2BD53145181C839 /* Frameworks */,
|
||||
);
|
||||
|
|
@ -793,7 +790,7 @@
|
|||
isa = PBXProject;
|
||||
attributes = {
|
||||
LastSwiftUpdateCheck = 1400;
|
||||
LastUpgradeCheck = 1300;
|
||||
LastUpgradeCheck = 1420;
|
||||
TargetAttributes = {
|
||||
0411792626A22860004AE24F = {
|
||||
CreatedOnToolsVersion = 12.5;
|
||||
|
|
@ -962,39 +959,9 @@
|
|||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
04B8B00426A0C19900D3ED0C /* ShellScript */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"$SRCROOT\"/Scripts/format_and_lint.sh \n";
|
||||
};
|
||||
6FD84AF08CA126EA8F6906A4 /* Embed Precompiled Frameworks */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "Embed Precompiled Frameworks";
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "echo \"Skipping, nothing to be embedded.\"";
|
||||
};
|
||||
909EF91C2946F26E9123C65F /* Format and Lint */ = {
|
||||
04B8B00426A0C19900D3ED0C /* Format and Lint */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
|
|
@ -1011,33 +978,24 @@
|
|||
shellPath = /bin/sh;
|
||||
shellScript = "\"$SRCROOT\"/Scripts/format_and_lint.sh \n";
|
||||
};
|
||||
AAC826E9061AEBA0BF527814 /* Embed Precompiled Frameworks */ = {
|
||||
909EF91C2946F26E9123C65F /* Format and Lint */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "Embed Precompiled Frameworks";
|
||||
name = "Format and Lint";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "echo \"Skipping, nothing to be embedded.\"";
|
||||
};
|
||||
DEA43E9E9C46BFC9EBE0D359 /* Embed Precompiled Frameworks */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "Embed Precompiled Frameworks";
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "echo \"Skipping, nothing to be embedded.\"";
|
||||
shellScript = "\"$SRCROOT\"/Scripts/format_and_lint.sh \n";
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
|
|
@ -1208,6 +1166,7 @@
|
|||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = QJF2XZ86HB;
|
||||
INFOPLIST_FILE = InfoPlists/MacUnitTests.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
|
|
@ -1234,6 +1193,7 @@
|
|||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = QJF2XZ86HB;
|
||||
INFOPLIST_FILE = InfoPlists/MacUnitTests.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
|
|
@ -1311,6 +1271,7 @@
|
|||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 80;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = QJF2XZ86HB;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
INFOPLIST_FILE = InfoPlists/ShareExtensionMac.plist;
|
||||
|
|
@ -1343,6 +1304,7 @@
|
|||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 80;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = QJF2XZ86HB;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
INFOPLIST_FILE = InfoPlists/ShareExtensionMac.plist;
|
||||
|
|
@ -1370,6 +1332,7 @@
|
|||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = QJF2XZ86HB;
|
||||
INFOPLIST_FILE = InfoPlists/MacUITests.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
|
|
@ -1395,6 +1358,7 @@
|
|||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = QJF2XZ86HB;
|
||||
INFOPLIST_FILE = InfoPlists/MacUITests.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
|
|
@ -1425,6 +1389,7 @@
|
|||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 80;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_ASSET_PATHS = "";
|
||||
DEVELOPMENT_TEAM = QJF2XZ86HB;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
|
|
@ -1459,6 +1424,7 @@
|
|||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 80;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_ASSET_PATHS = "";
|
||||
DEVELOPMENT_TEAM = QJF2XZ86HB;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
|
|
@ -1669,6 +1635,7 @@
|
|||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 80;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = QJF2XZ86HB;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
|
|
@ -1708,6 +1675,7 @@
|
|||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 80;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = QJF2XZ86HB;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
|
|
@ -1773,6 +1741,7 @@
|
|||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
|
|
@ -1940,6 +1909,7 @@
|
|||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1400"
|
||||
LastUpgradeVersion = "1420"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1300"
|
||||
LastUpgradeVersion = "1420"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1300"
|
||||
LastUpgradeVersion = "1420"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
|
|
|
|||
|
|
@ -189,6 +189,15 @@
|
|||
"version" : "2.3.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-markdown-ui",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/gonzalezreal/swift-markdown-ui",
|
||||
"state" : {
|
||||
"revision" : "4392c3cefd08db10f13ffb019d7c7a4a622824f5",
|
||||
"version" : "2.0.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-protobuf",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
|
|
|||
|
|
@ -24,7 +24,8 @@ let package = Package(
|
|||
name: "Views",
|
||||
dependencies: [
|
||||
"Models",
|
||||
.product(name: "Introspect", package: "SwiftUI-Introspect")
|
||||
.product(name: "Introspect", package: "SwiftUI-Introspect"),
|
||||
.product(name: "MarkdownUI", package: "swift-markdown-ui")
|
||||
],
|
||||
resources: [.process("Resources")]
|
||||
),
|
||||
|
|
@ -66,7 +67,8 @@ var dependencies: [Package.Dependency] {
|
|||
.package(url: "https://github.com/maticzav/swift-graphql", from: "2.3.1"),
|
||||
.package(url: "https://github.com/siteline/SwiftUI-Introspect.git", from: "0.1.4"),
|
||||
.package(url: "https://github.com/segmentio/analytics-swift.git", .upToNextMajor(from: "1.0.0")),
|
||||
.package(url: "https://github.com/google/GoogleSignIn-iOS", from: "6.2.2")
|
||||
.package(url: "https://github.com/google/GoogleSignIn-iOS", from: "6.2.2"),
|
||||
.package(url: "https://github.com/gonzalezreal/swift-markdown-ui", from: "2.0.0")
|
||||
]
|
||||
// Comment out following line for macOS build
|
||||
deps.append(.package(url: "https://github.com/PSPDFKit/PSPDFKit-SP", from: "12.0.1"))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
#if os(iOS)
|
||||
import AppIntents
|
||||
import Services
|
||||
import SwiftUI
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
struct SaveLinkToOmnivoreIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Show Sport Progress"
|
||||
|
||||
static var parameterSummary: some ParameterSummary {
|
||||
Summary("Save \(\.$link) to your Omnivore library.")
|
||||
}
|
||||
|
||||
@Parameter(title: "link")
|
||||
var link: URL
|
||||
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult {
|
||||
do {
|
||||
let services = Services()
|
||||
let requestId = UUID().uuidString.lowercased()
|
||||
_ = try await services.dataService.saveURL(id: requestId, url: link.absoluteString)
|
||||
|
||||
return .result(dialog: "Link saved to Omnivore")
|
||||
} catch {
|
||||
print("error saving URL: ", error)
|
||||
}
|
||||
return .result(dialog: "Error saving link")
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
|
@ -164,9 +164,10 @@ public class ShareExtensionViewModel: ObservableObject {
|
|||
updateStatusOnMain(requestId: newRequestID, newStatus: .synced)
|
||||
|
||||
// Prefetch the newly saved content
|
||||
if let itemID = newRequestID,
|
||||
let currentViewer = services.dataService.currentViewer?.username,
|
||||
(try? await services.dataService.loadArticleContentWithRetries(itemID: itemID, username: currentViewer)) != nil
|
||||
if
|
||||
let itemID = newRequestID,
|
||||
let currentViewer = services.dataService.currentViewer?.username,
|
||||
(try? await services.dataService.loadArticleContentWithRetries(itemID: itemID, username: currentViewer)) != nil
|
||||
{
|
||||
updateStatusOnMain(requestId: requestId, newStatus: .synced, objectID: linkedItemObjectID)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import SwiftUI
|
|||
import Utils
|
||||
import Views
|
||||
|
||||
// swiftlint:disable:next type_body_length
|
||||
// swiftlint:disable file_length type_body_length
|
||||
public struct ShareExtensionView: View {
|
||||
let extensionContext: NSExtensionContext?
|
||||
@StateObject var labelsViewModel = LabelsViewModel()
|
||||
|
|
@ -177,6 +177,7 @@ public struct ShareExtensionView: View {
|
|||
VStack(spacing: 15) {
|
||||
SearchBar(searchTerm: $labelsViewModel.labelSearchFilter)
|
||||
|
||||
// swiftlint:disable line_length
|
||||
ScrollView {
|
||||
LabelsMasonaryView(labels: labelsViewModel.labels.applySearchFilter(labelsViewModel.labelSearchFilter),
|
||||
selectedLabels: labelsViewModel.selectedLabels.applySearchFilter(labelsViewModel.labelSearchFilter),
|
||||
|
|
@ -199,6 +200,7 @@ public struct ShareExtensionView: View {
|
|||
.buttonStyle(PlainButtonStyle())
|
||||
.padding(10)
|
||||
}.background(Color.appButtonBackground)
|
||||
// swiftlint:enable line_length
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import Views
|
|||
)
|
||||
@AppStorage(UserDefaultKey.preferredWebLineSpacing.rawValue) var storedLineSpacing = 150
|
||||
@AppStorage(UserDefaultKey.preferredWebMaxWidthPercentage.rawValue) var storedMaxWidthPercentage = 100
|
||||
@AppStorage(UserDefaultKey.enableHighlightOnRelease.rawValue) var enableHighlightOnRelease = false
|
||||
|
||||
@Binding var preferredFont: String
|
||||
@Binding var prefersHighContrastText: Bool
|
||||
|
|
@ -114,6 +115,11 @@ import Views
|
|||
isOn: $prefersHighContrastText,
|
||||
label: { Text(LocalText.genericHighContrastText) }
|
||||
)
|
||||
|
||||
Toggle(
|
||||
isOn: $enableHighlightOnRelease,
|
||||
label: { Text(LocalText.genericHighContrastText) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import Combine
|
|||
import SwiftUI
|
||||
import Utils
|
||||
|
||||
// swiftlint:disable file_length
|
||||
// swiftlint:disable file_length type_body_length
|
||||
#if os(iOS)
|
||||
import PSPDFKit
|
||||
import PSPDFKitUI
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ final class PDFViewerViewModel: ObservableObject {
|
|||
onComplete(pdfItem.highlights.map { $0.patch ?? "" })
|
||||
}
|
||||
|
||||
// swiftlint:disable:next function_parameter_count
|
||||
func createHighlight(
|
||||
dataService: DataService,
|
||||
shortId: String,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
#if os(iOS)
|
||||
|
||||
import Foundation
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Views
|
||||
|
||||
// swiftlint:disable file_length type_body_length
|
||||
public struct ExpandedPlayer: View {
|
||||
@EnvironmentObject var audioController: AudioController
|
||||
@Environment(\.colorScheme) private var colorScheme: ColorScheme
|
||||
|
|
@ -47,7 +47,9 @@
|
|||
},
|
||||
label: {
|
||||
Image(systemName: playPauseButtonImage)
|
||||
.font(.appIconLarge)
|
||||
.resizable(resizingMode: Image.ResizingMode.stretch)
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.font(Font.title.weight(.light))
|
||||
}
|
||||
))
|
||||
}
|
||||
|
|
@ -113,42 +115,55 @@
|
|||
}
|
||||
}
|
||||
|
||||
func defaultArtwork(forDimensions dim: Double) -> some View {
|
||||
ZStack(alignment: .center) {
|
||||
Color.appButtonBackground
|
||||
.frame(width: dim, height: dim)
|
||||
.cornerRadius(6)
|
||||
|
||||
Image(systemName: "headphones")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: dim / 2, height: dim / 2)
|
||||
}
|
||||
}
|
||||
|
||||
struct SpeechCard: View {
|
||||
let id: Int
|
||||
@EnvironmentObject var audioController: AudioController
|
||||
|
||||
var intervalFormatter: DateComponentsFormatter {
|
||||
let formatter = DateComponentsFormatter()
|
||||
formatter.allowedUnits = [.minute, .second]
|
||||
formatter.zeroFormattingBehavior = .pad
|
||||
return formatter
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
let isCurrent = id == self.audioController.currentAudioIndex
|
||||
|
||||
ZStack(alignment: .top) {
|
||||
Text(intervalFormatter.string(from: self.audioController.offsets?[id] ?? 0.0) ?? "")
|
||||
.font(Font.system(size: 11, weight: isCurrent ? .medium : .regular))
|
||||
.foregroundColor(isCurrent ? Color.themeTTSReadingText : Color(hex: "#898989"))
|
||||
.padding(.leading, 8)
|
||||
.padding(.top, 2)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
if id != self.audioController.currentAudioIndex || self.audioController.isLoading {
|
||||
Text(self.audioController.textItems?[id] ?? "\(id)")
|
||||
.font(.textToSpeechRead.leading(.loose))
|
||||
.foregroundColor(Color.appGrayTextContrast)
|
||||
.font(.appCallout)
|
||||
.foregroundColor(Color(hex: "#898989"))
|
||||
.padding(.leading, 48 + 8)
|
||||
.padding(.trailing, 16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
} else {
|
||||
Group {
|
||||
Text(audioController.readText)
|
||||
.font(.textToSpeechRead.leading(.loose))
|
||||
.foregroundColor(Color.appGrayTextContrast)
|
||||
.font(.appTextToSpeechCurrent)
|
||||
.foregroundColor(Color.themeTTSReadingText)
|
||||
+
|
||||
Text(audioController.unreadText)
|
||||
.font(.textToSpeechRead.leading(.loose))
|
||||
.foregroundColor(audioController.useUltraRealisticVoices ? Color.appGrayTextContrast : Color.appGrayText)
|
||||
.font(.appTextToSpeechCurrent)
|
||||
.foregroundColor(Color.themeTTSReadingText)
|
||||
}
|
||||
.padding(.leading, 48 + 8)
|
||||
.padding(.trailing, 16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 15)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(isCurrent ? Color.themeHighlightColor : Color.themeDisabledBG)
|
||||
}
|
||||
|
||||
init(id: Int) {
|
||||
|
|
@ -160,27 +175,51 @@
|
|||
ZStack {
|
||||
let textItems = self.audioController.textItems ?? []
|
||||
if textItems.count > 0 {
|
||||
TabView(selection: $tabIndex) {
|
||||
ForEach(0 ..< textItems.count, id: \.self) { id in
|
||||
SpeechCard(id: id)
|
||||
.tag(id)
|
||||
ScrollViewReader { scroller in
|
||||
List {
|
||||
ForEach(0 ..< textItems.count, id: \.self) { id in
|
||||
SpeechCard(id: id)
|
||||
.tag(id)
|
||||
.onTapGesture {
|
||||
audioController.seek(toUtterance: id)
|
||||
}
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
}
|
||||
// Extra bottom padding, so the controls appear to be over the list
|
||||
Color.themeDisabledBG
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 150)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
}
|
||||
}
|
||||
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
|
||||
.onChange(of: tabIndex, perform: { index in
|
||||
if index != audioController.currentAudioIndex, index < (audioController.textItems?.count ?? 0) {
|
||||
audioController.seek(toUtterance: index)
|
||||
}
|
||||
})
|
||||
.onChange(of: audioController.currentAudioIndex, perform: { index in
|
||||
if index >= textItems.count {
|
||||
return
|
||||
.listStyle(.plain)
|
||||
.onAppear {
|
||||
if audioController.currentAudioIndex < textItems.count {
|
||||
withAnimation {
|
||||
scroller.scrollTo(audioController.currentAudioIndex, anchor: .center)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: audioController.currentAudioIndex, perform: { index in
|
||||
if index >= textItems.count {
|
||||
return
|
||||
}
|
||||
|
||||
if self.audioController.state != .reachedEnd {
|
||||
tabIndex = index
|
||||
}
|
||||
})
|
||||
if self.audioController.state != .reachedEnd {
|
||||
withAnimation {
|
||||
scroller.scrollTo(index, anchor: .center)
|
||||
}
|
||||
}
|
||||
})
|
||||
// .simultaneousGesture(
|
||||
// DragGesture().onChanged {
|
||||
// let isScrollDown = $0.translation.height > 0
|
||||
// print(isScrollDown)
|
||||
// }
|
||||
// )
|
||||
}
|
||||
.background(Color.themeDisabledBG)
|
||||
}
|
||||
|
||||
if audioController.state == .reachedEnd {
|
||||
|
|
@ -208,29 +247,12 @@
|
|||
Text(LocalText.audioPlayerReplay)
|
||||
}
|
||||
}
|
||||
).buttonStyle(RoundedRectButtonStyle())
|
||||
)
|
||||
.padding(.bottom, 138)
|
||||
.buttonStyle(RoundedRectButtonStyle())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var header: some View {
|
||||
ZStack {
|
||||
closeButton
|
||||
.padding(.top, 24)
|
||||
.padding(.leading, 16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Capsule()
|
||||
.fill(.gray)
|
||||
.frame(width: 60, height: 4)
|
||||
.padding(.top, 8)
|
||||
.transition(.opacity)
|
||||
|
||||
menuButton
|
||||
.padding(.top, 24)
|
||||
.padding(.trailing, 16)
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
var scrubber: some View {
|
||||
|
|
@ -244,65 +266,102 @@
|
|||
self.audioController.scrubState = .scrubEnded(self.audioController.timeElapsed)
|
||||
}
|
||||
})
|
||||
.padding(.top, 26)
|
||||
|
||||
HStack {
|
||||
Text(audioController.timeElapsedString ?? "0:00")
|
||||
.font(.appCaptionTwo)
|
||||
.foregroundColor(.appGrayText)
|
||||
.font(Font.system(size: 10))
|
||||
.foregroundColor(Color(hex: "#9D9D9B"))
|
||||
Spacer()
|
||||
Text(audioController.durationString ?? "0:00")
|
||||
.font(.appCaptionTwo)
|
||||
.foregroundColor(.appGrayText)
|
||||
.font(Font.system(size: 10))
|
||||
.foregroundColor(Color(hex: "#9D9D9B"))
|
||||
}
|
||||
}
|
||||
.padding(.leading, 16)
|
||||
.padding(.trailing, 16)
|
||||
.padding(.leading, 42)
|
||||
.padding(.trailing, 42)
|
||||
}
|
||||
|
||||
var audioButtons: some View {
|
||||
HStack(alignment: .center) {
|
||||
Spacer()
|
||||
|
||||
Button(action: { showVoiceSheet = true }, label: {
|
||||
Image(systemName: "person.wave.2")
|
||||
.resizable()
|
||||
.frame(width: 18, height: 18)
|
||||
})
|
||||
.padding(.trailing, 32)
|
||||
|
||||
Button(
|
||||
action: { self.audioController.skipBackwards(seconds: 30) },
|
||||
label: {
|
||||
Image(systemName: "gobackward.30")
|
||||
.font(.appTitleTwo)
|
||||
.resizable()
|
||||
.font(Font.title.weight(.light))
|
||||
}
|
||||
)
|
||||
|
||||
Spacer()
|
||||
.frame(width: 16, height: 16)
|
||||
.padding(.trailing, 16)
|
||||
.foregroundColor(.themeDarkGray)
|
||||
|
||||
playPauseButtonItem
|
||||
.frame(width: 56, height: 56)
|
||||
|
||||
Spacer()
|
||||
.frame(width: 45, height: 45)
|
||||
.padding(.trailing, 16)
|
||||
.foregroundColor(.themeDarkGray)
|
||||
|
||||
Button(
|
||||
action: { self.audioController.skipForward(seconds: 30) },
|
||||
label: {
|
||||
Image(systemName: "goforward.30")
|
||||
.font(.appTitleTwo)
|
||||
.resizable()
|
||||
.font(Font.title.weight(.light))
|
||||
}
|
||||
)
|
||||
Spacer()
|
||||
.frame(width: 16, height: 16)
|
||||
.padding(.trailing, 32 - 4) // -4 to account for the menu touch padding
|
||||
.foregroundColor(.themeDarkGray)
|
||||
|
||||
}.padding(.bottom, 16)
|
||||
Menu(content: {
|
||||
playbackRateButton(rate: 0.8, title: "0.8×", selected: audioController.playbackRate == 0.8)
|
||||
playbackRateButton(rate: 0.9, title: "0.9×", selected: audioController.playbackRate == 0.9)
|
||||
playbackRateButton(rate: 1.0, title: "1.0×", selected: audioController.playbackRate == 1.0)
|
||||
playbackRateButton(rate: 1.1, title: "1.1×", selected: audioController.playbackRate == 1.1)
|
||||
playbackRateButton(rate: 1.2, title: "1.2×", selected: audioController.playbackRate == 1.2)
|
||||
playbackRateButton(rate: 1.5, title: "1.5×", selected: audioController.playbackRate == 1.5)
|
||||
playbackRateButton(rate: 1.7, title: "1.7×", selected: audioController.playbackRate == 1.7)
|
||||
playbackRateButton(rate: 2.0, title: "2.0×", selected: audioController.playbackRate == 2.0)
|
||||
}, label: {
|
||||
Text("\(String(format: "%.1f", audioController.playbackRate))×")
|
||||
.font(.appCaption)
|
||||
})
|
||||
.padding(4)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.foregroundColor(Color.themeMediumGray)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
|
||||
func playerContent(_: LinkedItemAudioProperties) -> some View {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
|
||||
ZStack {
|
||||
audioCards
|
||||
.frame(maxHeight: .infinity)
|
||||
|
||||
Spacer()
|
||||
VStack {
|
||||
Spacer()
|
||||
|
||||
scrubber
|
||||
|
||||
audioButtons
|
||||
VStack {
|
||||
scrubber
|
||||
audioButtons
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
.cornerRadius(8)
|
||||
.padding(.bottom, -8)
|
||||
.frame(maxWidth: .infinity, maxHeight: 138)
|
||||
.background(Color.themeSolidBackground.ignoresSafeArea())
|
||||
}
|
||||
}
|
||||
.padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
.onAppear {
|
||||
self.tabIndex = audioController.currentAudioIndex
|
||||
}
|
||||
|
|
@ -317,10 +376,8 @@
|
|||
TextToSpeechVoiceSelectionView(forLanguage: audioController.currentVoiceLanguage, showLanguageChanger: true)
|
||||
.navigationBarTitle("Voice")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarItems(leading: Button(action: { self.showVoiceSheet = false }, label: {
|
||||
Image(systemName: "chevron.backward")
|
||||
.font(.appNavbarIcon)
|
||||
.tint(.appGrayTextContrast)
|
||||
.navigationBarItems(trailing: Button(action: { self.showVoiceSheet = false }, label: {
|
||||
Text("Done").bold()
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
|
@ -342,16 +399,33 @@
|
|||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
|
||||
@State var queryString: String = ""
|
||||
|
||||
public var body: some View {
|
||||
NavigationView {
|
||||
innerBody
|
||||
.background(Color.themeDisabledBG)
|
||||
.navigationTitle(LocalText.textToSpeechGeneric)
|
||||
.navigationBarItems(trailing: Button(action: { dismiss() }, label: { Text("Hide") }))
|
||||
.navigationBarTitleDisplayMode(NavigationBarItem.TitleDisplayMode.inline)
|
||||
// .searchable(text: $queryString, placement: .navigationBarDrawer(displayMode: .always)) {
|
||||
// // print("searching: ", queryString)
|
||||
// Text("content")
|
||||
// }
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
public var innerBody: some View {
|
||||
if let itemAudioProperties = self.audioController.itemAudioProperties {
|
||||
playerContent(itemAudioProperties)
|
||||
return AnyView(playerContent(itemAudioProperties)
|
||||
.tint(.appGrayTextContrast)
|
||||
.alert("There was an error playing back your audio.",
|
||||
isPresented: $audioController.playbackError) {
|
||||
Button(LocalText.dismissButton, role: .none) {}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
EmptyView()
|
||||
return AnyView(EmptyView())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -53,7 +53,9 @@
|
|||
},
|
||||
label: {
|
||||
Image(systemName: playPauseButtonImage)
|
||||
.font(.appTitleTwo)
|
||||
.resizable(resizingMode: Image.ResizingMode.stretch)
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.font(Font.title.weight(.light))
|
||||
}
|
||||
))
|
||||
}
|
||||
|
|
@ -65,10 +67,21 @@
|
|||
audioController.stop()
|
||||
},
|
||||
label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.appTitleTwo)
|
||||
ZStack {
|
||||
Circle()
|
||||
.foregroundColor(Color(hex: "#3D3D3D"))
|
||||
|
||||
Image(systemName: "xmark")
|
||||
.resizable(resizingMode: Image.ResizingMode.stretch)
|
||||
.foregroundColor(Color(hex: "#D9D9D9"))
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.font(Font.title.weight(.medium))
|
||||
.frame(width: 14, height: 14)
|
||||
}
|
||||
}
|
||||
)
|
||||
.background(Color.clear)
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
|
||||
func artwork(_ itemAudioProperties: LinkedItemAudioProperties, forDimensions dim: Double) -> some View {
|
||||
|
|
@ -107,31 +120,25 @@
|
|||
|
||||
func playerContent(_ itemAudioProperties: LinkedItemAudioProperties) -> some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
artwork(itemAudioProperties, forDimensions: 64)
|
||||
HStack(alignment: .center, spacing: 15) {
|
||||
artwork(itemAudioProperties, forDimensions: 50)
|
||||
|
||||
VStack {
|
||||
Text(itemAudioProperties.title)
|
||||
.font(.appCallout)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.fixedSize(horizontal: false, vertical: false)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Text(itemAudioProperties.title)
|
||||
.font(Font.system(size: 17, weight: .medium))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.lineLimit(2)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.frame(maxHeight: 40, alignment: .leading)
|
||||
|
||||
if let byline = itemAudioProperties.byline {
|
||||
Text(byline)
|
||||
.font(.appCaption)
|
||||
.lineSpacing(1.25)
|
||||
.foregroundColor(.appGrayText)
|
||||
.fixedSize(horizontal: false, vertical: false)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
|
||||
playPauseButtonItem
|
||||
.frame(width: 28, height: 28)
|
||||
.frame(width: 40, height: 40)
|
||||
.foregroundColor(.themeDarkGray)
|
||||
|
||||
stopButton
|
||||
.frame(width: 28, height: 28)
|
||||
.frame(width: 40, height: 40)
|
||||
.foregroundColor(.themeDarkGray)
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxHeight: .infinity)
|
||||
|
|
@ -144,11 +151,10 @@
|
|||
)
|
||||
.onTapGesture {
|
||||
withAnimation(.easeIn(duration: 0.08)) { expanded = true }
|
||||
}.sheet(isPresented: $expanded) {
|
||||
}.fullScreenCover(isPresented: $expanded) {
|
||||
ExpandedPlayer()
|
||||
}
|
||||
.offset(y: expanded ? 88 : 0)
|
||||
.opacity(expanded ? 0.0 : 1.0)
|
||||
.offset(y: expanded ? 110 : 0)
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
|
|
@ -157,14 +163,14 @@
|
|||
if let itemAudioProperties = self.audioController.itemAudioProperties {
|
||||
ZStack(alignment: .bottom) {
|
||||
Color.systemBackground.edgesIgnoringSafeArea(.bottom)
|
||||
.frame(height: expanded ? 0 : 88, alignment: .bottom)
|
||||
.frame(height: expanded ? 0 : 110, alignment: .bottom)
|
||||
|
||||
VStack {
|
||||
Spacer(minLength: 0)
|
||||
playerContent(itemAudioProperties)
|
||||
.frame(maxHeight: expanded ? 0 : 88)
|
||||
.tint(.appGrayTextContrast)
|
||||
.background(Color.systemBackground)
|
||||
.frame(maxHeight: expanded ? 0 : 110)
|
||||
// .tint(.appGrayTextContrast)
|
||||
// .background(Color.systemBackground)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,15 @@
|
|||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
extension UIColor {
|
||||
func image(_ size: CGSize = CGSize(width: 1, height: 1)) -> UIImage {
|
||||
UIGraphicsImageRenderer(size: size).image { rendererContext in
|
||||
self.setFill()
|
||||
rendererContext.fill(CGRect(origin: .zero, size: size))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ScrubberView: UIViewRepresentable {
|
||||
typealias UIViewType = UISlider
|
||||
|
||||
|
|
@ -22,20 +31,25 @@
|
|||
slider.maximumValue = Float(maxValue)
|
||||
|
||||
let tintColor = UIColor(Color.appCtaYellow)
|
||||
|
||||
let image = UIImage(systemName: "circle.fill",
|
||||
withConfiguration: UIImage.SymbolConfiguration(scale: .small))?
|
||||
let thumbImage = UIImage(systemName: "circle.fill",
|
||||
withConfiguration: UIImage.SymbolConfiguration(scale: .medium))?
|
||||
.withTintColor(tintColor)
|
||||
.withRenderingMode(.alwaysOriginal)
|
||||
|
||||
slider.setThumbImage(image, for: .selected)
|
||||
slider.setThumbImage(image, for: .normal)
|
||||
slider.setThumbImage(thumbImage, for: .selected)
|
||||
slider.setThumbImage(thumbImage, for: .normal)
|
||||
|
||||
slider.minimumTrackTintColor = tintColor
|
||||
slider.addTarget(context.coordinator,
|
||||
action: #selector(Coordinator.valueChanged(_:)),
|
||||
for: .valueChanged)
|
||||
|
||||
// let minImage = UIColor(Color.themeMediumGray).image(CGSize(width: 1, height: 5))
|
||||
// let maxImage = UIColor(Color.themeLightGray).image(CGSize(width: 1, height: 5))
|
||||
//
|
||||
// slider.setMinimumTrackImage(minImage, for: .normal)
|
||||
// slider.setMaximumTrackImage(maxImage, for: .normal)
|
||||
|
||||
return slider
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,170 +1,202 @@
|
|||
import Models
|
||||
import SwiftUI
|
||||
import Views
|
||||
#if os(iOS)
|
||||
import MarkdownUI
|
||||
import Models
|
||||
import SwiftUI
|
||||
import Views
|
||||
|
||||
struct HighlightsListCard: View {
|
||||
@State var isContextMenuOpen = false
|
||||
@State var annotation = String()
|
||||
@State var showAnnotationModal = false
|
||||
struct HighlightsListCard: View {
|
||||
@State var isContextMenuOpen = false
|
||||
@State var annotation = String()
|
||||
@State var showAnnotationModal = false
|
||||
|
||||
let highlightParams: HighlightListItemParams
|
||||
@Binding var hasHighlightMutations: Bool
|
||||
let onSaveAnnotation: (String) -> Void
|
||||
let onDeleteHighlight: () -> Void
|
||||
let onSetLabels: (String) -> Void
|
||||
let viewModel: HighlightsListViewModel
|
||||
let highlightParams: HighlightListItemParams
|
||||
@Binding var hasHighlightMutations: Bool
|
||||
let onSaveAnnotation: (String) -> Void
|
||||
let onDeleteHighlight: () -> Void
|
||||
let onSetLabels: (String) -> Void
|
||||
|
||||
@State var errorAlertMessage: String?
|
||||
@State var showErrorAlertMessage = false
|
||||
@State var errorAlertMessage: String?
|
||||
@State var showErrorAlertMessage = false
|
||||
@State var showShareView = false
|
||||
|
||||
var contextMenuView: some View {
|
||||
Group {
|
||||
Button(
|
||||
action: {
|
||||
#if os(iOS)
|
||||
UIPasteboard.general.string = highlightParams.quote
|
||||
#endif
|
||||
var contextMenuView: some View {
|
||||
Group {
|
||||
Button(
|
||||
action: {
|
||||
#if os(iOS)
|
||||
UIPasteboard.general.string = highlightParams.quote
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
let pasteBoard = NSPasteboard.general
|
||||
pasteBoard.clearContents()
|
||||
pasteBoard.writeObjects([highlightParams.quote as NSString])
|
||||
#endif
|
||||
#if os(macOS)
|
||||
let pasteBoard = NSPasteboard.general
|
||||
pasteBoard.clearContents()
|
||||
pasteBoard.writeObjects([highlightParams.quote as NSString])
|
||||
#endif
|
||||
|
||||
Snackbar.show(message: "Highlight copied")
|
||||
},
|
||||
label: { Label("Copy", systemImage: "doc.on.doc") }
|
||||
)
|
||||
Button(
|
||||
action: {
|
||||
onSetLabels(highlightParams.highlightID)
|
||||
},
|
||||
label: { Label(LocalText.labelsGeneric, systemImage: "tag") }
|
||||
)
|
||||
Button(
|
||||
action: onDeleteHighlight,
|
||||
label: { Label("Delete", systemImage: "trash") }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var noteSection: some View {
|
||||
HStack {
|
||||
let isEmpty = highlightParams.annotation.isEmpty
|
||||
Spacer(minLength: 6)
|
||||
|
||||
Text(isEmpty ? "Add Notes..." : highlightParams.annotation)
|
||||
.lineSpacing(6)
|
||||
.accentColor(.appGraySolid)
|
||||
.foregroundColor(isEmpty ? .appGrayText : .appGrayTextContrast)
|
||||
.font(.appSubheadline)
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.appButtonBackground)
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.onTapGesture {
|
||||
annotation = highlightParams.annotation
|
||||
showAnnotationModal = true
|
||||
}
|
||||
}
|
||||
|
||||
var labelsView: some View {
|
||||
if highlightParams.labels.count > 0 {
|
||||
return AnyView(ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack {
|
||||
ForEach(highlightParams.labels, id: \.self) {
|
||||
TextChip(feedItemLabel: $0)
|
||||
.padding(.leading, 0)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}.introspectScrollView { scrollView in
|
||||
scrollView.bounces = false
|
||||
}
|
||||
.padding(.top, 0)
|
||||
.padding(.leading, 0)
|
||||
.padding(.bottom, 0))
|
||||
} else {
|
||||
return AnyView(EmptyView())
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
HStack {
|
||||
Spacer()
|
||||
|
||||
Menu(
|
||||
content: { contextMenuView },
|
||||
label: {
|
||||
Image(systemName: "ellipsis")
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.padding()
|
||||
}
|
||||
Snackbar.show(message: "Highlight copied")
|
||||
},
|
||||
label: { Label("Copy", systemImage: "doc.on.doc") }
|
||||
)
|
||||
Button(
|
||||
action: {
|
||||
onSetLabels(highlightParams.highlightID)
|
||||
},
|
||||
label: { Label(LocalText.labelsGeneric, systemImage: "tag") }
|
||||
)
|
||||
Button(
|
||||
action: { showShareView = true },
|
||||
label: { Label(LocalText.exportGeneric, systemImage: "square.and.arrow.up") }
|
||||
)
|
||||
Button(
|
||||
action: onDeleteHighlight,
|
||||
label: { Label("Delete", systemImage: "trash") }
|
||||
)
|
||||
.frame(width: 16, height: 16, alignment: .center)
|
||||
.onTapGesture { isContextMenuOpen = true }
|
||||
}
|
||||
.padding(.top, 16)
|
||||
|
||||
if let createdBy = highlightParams.createdBy {
|
||||
HStack(alignment: .center) {
|
||||
if let profileImageURL = createdBy.profileImageURL, let url = URL(string: profileImageURL) {
|
||||
AsyncImage(
|
||||
url: url,
|
||||
content: { $0.resizable() },
|
||||
placeholder: {
|
||||
Image(systemName: "person.crop.circle")
|
||||
.resizable()
|
||||
.foregroundColor(.appGrayText)
|
||||
}
|
||||
)
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 14, height: 14, alignment: .center)
|
||||
.clipShape(Circle())
|
||||
} else {
|
||||
Image(systemName: "person.crop.circle")
|
||||
.resizable()
|
||||
.foregroundColor(.appGrayText)
|
||||
.frame(width: 14, height: 14)
|
||||
}
|
||||
Text("\(LocalText.highlightCardHighlightByOther)\(highlightParams.createdBy?.name ?? "you")") // TODO: fix translation...add other/you
|
||||
.font(.appFootnote)
|
||||
.foregroundColor(.appGrayText)
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Divider()
|
||||
.frame(width: 2)
|
||||
.overlay(highlightParams.createdBy != nil ? Color(red: 206 / 255.0, green: 239 / 255.0, blue: 159 / 255.0) : Color.appYellow48)
|
||||
.opacity(0.8)
|
||||
.padding(.top, 2)
|
||||
.padding(.trailing, 6)
|
||||
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text(highlightParams.quote)
|
||||
labelsView
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 4)
|
||||
|
||||
noteSection
|
||||
}
|
||||
.sheet(isPresented: $showAnnotationModal) {
|
||||
HighlightAnnotationSheet(
|
||||
annotation: $annotation,
|
||||
onSave: {
|
||||
onSaveAnnotation(annotation)
|
||||
showAnnotationModal = false
|
||||
hasHighlightMutations = true
|
||||
},
|
||||
onCancel: {
|
||||
showAnnotationModal = false
|
||||
},
|
||||
errorAlertMessage: $errorAlertMessage,
|
||||
showErrorAlertMessage: $showErrorAlertMessage
|
||||
)
|
||||
|
||||
var noteSection: some View {
|
||||
HStack {
|
||||
let isEmpty = highlightParams.annotation.isEmpty
|
||||
Spacer(minLength: 6)
|
||||
|
||||
if isEmpty {
|
||||
Text("Add Notes...")
|
||||
.lineSpacing(6)
|
||||
.accentColor(.appGraySolid)
|
||||
.foregroundColor(isEmpty ? .appGrayText : .appGrayTextContrast)
|
||||
.font(.appSubheadline)
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.appButtonBackground)
|
||||
.cornerRadius(8)
|
||||
} else {
|
||||
Markdown(highlightParams.annotation)
|
||||
.lineSpacing(6)
|
||||
.accentColor(.appGraySolid)
|
||||
.foregroundColor(isEmpty ? .appGrayText : .appGrayTextContrast)
|
||||
.font(.appSubheadline)
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.appButtonBackground)
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
annotation = highlightParams.annotation
|
||||
showAnnotationModal = true
|
||||
}
|
||||
}
|
||||
|
||||
var labelsView: some View {
|
||||
if highlightParams.labels.count > 0 {
|
||||
return AnyView(ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack {
|
||||
ForEach(highlightParams.labels, id: \.self) {
|
||||
TextChip(feedItemLabel: $0)
|
||||
.padding(.leading, 0)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}.introspectScrollView { scrollView in
|
||||
#if os(iOS)
|
||||
scrollView.bounces = false
|
||||
#endif
|
||||
}
|
||||
.padding(.top, 0)
|
||||
.padding(.leading, 0)
|
||||
.padding(.bottom, 0))
|
||||
} else {
|
||||
return AnyView(EmptyView())
|
||||
}
|
||||
}
|
||||
|
||||
func markdownText(str: String) -> some View {
|
||||
Markdown(str)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
HStack {
|
||||
Spacer()
|
||||
|
||||
Menu(
|
||||
content: { contextMenuView },
|
||||
label: {
|
||||
Image(systemName: "ellipsis")
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.padding()
|
||||
}
|
||||
)
|
||||
.frame(width: 16, height: 16, alignment: .center)
|
||||
.onTapGesture { isContextMenuOpen = true }
|
||||
}
|
||||
.padding(.top, 16)
|
||||
|
||||
if let createdBy = highlightParams.createdBy {
|
||||
HStack(alignment: .center) {
|
||||
if let profileImageURL = createdBy.profileImageURL, let url = URL(string: profileImageURL) {
|
||||
AsyncImage(
|
||||
url: url,
|
||||
content: { $0.resizable() },
|
||||
placeholder: {
|
||||
Image(systemName: "person.crop.circle")
|
||||
.resizable()
|
||||
.foregroundColor(.appGrayText)
|
||||
}
|
||||
)
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 14, height: 14, alignment: .center)
|
||||
.clipShape(Circle())
|
||||
} else {
|
||||
Image(systemName: "person.crop.circle")
|
||||
.resizable()
|
||||
.foregroundColor(.appGrayText)
|
||||
.frame(width: 14, height: 14)
|
||||
}
|
||||
// fix translation...add other/you
|
||||
Text("\(LocalText.highlightCardHighlightByOther)\(highlightParams.createdBy?.name ?? "you")")
|
||||
.font(.appFootnote)
|
||||
.foregroundColor(.appGrayText)
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Divider()
|
||||
.frame(width: 2)
|
||||
// swiftlint:disable:next line_length
|
||||
.overlay(highlightParams.createdBy != nil ? Color(red: 206 / 255.0, green: 239 / 255.0, blue: 159 / 255.0) : Color.appYellow48)
|
||||
.opacity(0.8)
|
||||
.padding(.top, 2)
|
||||
.padding(.trailing, 6)
|
||||
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Markdown(highlightParams.quote)
|
||||
labelsView
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 4)
|
||||
|
||||
noteSection
|
||||
}
|
||||
.sheet(isPresented: $showAnnotationModal) {
|
||||
HighlightAnnotationSheet(
|
||||
annotation: $annotation,
|
||||
onSave: {
|
||||
onSaveAnnotation(annotation)
|
||||
showAnnotationModal = false
|
||||
hasHighlightMutations = true
|
||||
},
|
||||
onCancel: {
|
||||
showAnnotationModal = false
|
||||
},
|
||||
errorAlertMessage: $errorAlertMessage,
|
||||
showErrorAlertMessage: $showErrorAlertMessage
|
||||
)
|
||||
}
|
||||
.formSheet(isPresented: $showShareView) {
|
||||
ShareSheet(activityItems: [viewModel.highlightAsMarkdown(item: self.highlightParams)])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -1,104 +1,127 @@
|
|||
import CoreData
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Views
|
||||
#if os(iOS)
|
||||
import CoreData
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Views
|
||||
|
||||
struct HighlightsListView: View {
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@Environment(\.presentationMode) private var presentationMode
|
||||
@StateObject var viewModel = HighlightsListViewModel()
|
||||
struct HighlightsListView: View {
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@Environment(\.presentationMode) private var presentationMode
|
||||
@StateObject var viewModel = HighlightsListViewModel()
|
||||
|
||||
let itemObjectID: NSManagedObjectID
|
||||
@Binding var hasHighlightMutations: Bool
|
||||
@State var setLabelsHighlight: Highlight?
|
||||
let itemObjectID: NSManagedObjectID
|
||||
@Binding var hasHighlightMutations: Bool
|
||||
@State var setLabelsHighlight: Highlight?
|
||||
@State var showShareView: Bool = false
|
||||
|
||||
var emptyView: some View {
|
||||
Text(LocalText.highlightCardNoHighlightsOnPage)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(16)
|
||||
}
|
||||
|
||||
var innerBody: some View {
|
||||
(viewModel.highlightItems.count > 0 ? AnyView(listView) : AnyView(emptyView))
|
||||
.navigationTitle("Notebook")
|
||||
.listStyle(PlainListStyle())
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
dismissButton
|
||||
}
|
||||
}
|
||||
#else
|
||||
.toolbar {
|
||||
ToolbarItemGroup {
|
||||
dismissButton
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
var listView: some View {
|
||||
List {
|
||||
Section {
|
||||
ForEach(viewModel.highlightItems) { highlightParams in
|
||||
HighlightsListCard(
|
||||
highlightParams: highlightParams,
|
||||
hasHighlightMutations: $hasHighlightMutations,
|
||||
onSaveAnnotation: {
|
||||
viewModel.updateAnnotation(
|
||||
highlightID: highlightParams.highlightID,
|
||||
annotation: $0,
|
||||
dataService: dataService
|
||||
)
|
||||
},
|
||||
onDeleteHighlight: {
|
||||
hasHighlightMutations = true
|
||||
|
||||
viewModel.deleteHighlight(
|
||||
highlightID: highlightParams.highlightID,
|
||||
dataService: dataService
|
||||
)
|
||||
},
|
||||
onSetLabels: { highlightID in
|
||||
setLabelsHighlight = Highlight.lookup(byID: highlightID, inContext: dataService.viewContext)
|
||||
}
|
||||
)
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
}
|
||||
}.sheet(item: $setLabelsHighlight) { highlight in
|
||||
ApplyLabelsView(mode: .highlight(highlight), isSearchFocused: false, onSave: { selectedLabels in
|
||||
hasHighlightMutations = true
|
||||
|
||||
viewModel.setLabelsForHighlight(highlightID: highlight.unwrappedID,
|
||||
labels: selectedLabels,
|
||||
dataService: dataService)
|
||||
})
|
||||
var emptyView: some View {
|
||||
Text(LocalText.highlightCardNoHighlightsOnPage)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
|
||||
var dismissButton: some View {
|
||||
Button(
|
||||
action: { presentationMode.wrappedValue.dismiss() },
|
||||
label: { Text(LocalText.doneGeneric).foregroundColor(.appGrayTextContrast) }
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
var innerBody: some View {
|
||||
(viewModel.highlightItems.count > 0 ? AnyView(listView) : AnyView(emptyView))
|
||||
.navigationTitle("Notebook")
|
||||
.listStyle(PlainListStyle())
|
||||
#if os(iOS)
|
||||
NavigationView {
|
||||
innerBody
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
dismissButton
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
actionsMenu
|
||||
}
|
||||
}.formSheet(isPresented: $showShareView) {
|
||||
ShareSheet(activityItems: [viewModel.highlightsAsMarkdown()])
|
||||
}
|
||||
#else
|
||||
.toolbar {
|
||||
ToolbarItemGroup {
|
||||
dismissButton
|
||||
}
|
||||
}
|
||||
#elseif os(macOS)
|
||||
innerBody
|
||||
.frame(minWidth: 400, minHeight: 400)
|
||||
#endif
|
||||
}
|
||||
.task {
|
||||
viewModel.load(itemObjectID: itemObjectID, dataService: dataService)
|
||||
|
||||
var listView: some View {
|
||||
List {
|
||||
Section {
|
||||
ForEach(viewModel.highlightItems) { highlightParams in
|
||||
HighlightsListCard(
|
||||
viewModel: self.viewModel,
|
||||
highlightParams: highlightParams,
|
||||
hasHighlightMutations: $hasHighlightMutations,
|
||||
onSaveAnnotation: {
|
||||
viewModel.updateAnnotation(
|
||||
highlightID: highlightParams.highlightID,
|
||||
annotation: $0,
|
||||
dataService: dataService
|
||||
)
|
||||
},
|
||||
onDeleteHighlight: {
|
||||
hasHighlightMutations = true
|
||||
viewModel.deleteHighlight(
|
||||
highlightID: highlightParams.highlightID,
|
||||
dataService: dataService
|
||||
)
|
||||
},
|
||||
onSetLabels: { highlightID in
|
||||
setLabelsHighlight = Highlight.lookup(byID: highlightID, inContext: dataService.viewContext)
|
||||
}
|
||||
)
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
}
|
||||
}.sheet(item: $setLabelsHighlight) { highlight in
|
||||
ApplyLabelsView(mode: .highlight(highlight), isSearchFocused: false, onSave: { selectedLabels in
|
||||
hasHighlightMutations = true
|
||||
|
||||
viewModel.setLabelsForHighlight(highlightID: highlight.unwrappedID,
|
||||
labels: selectedLabels,
|
||||
dataService: dataService)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var dismissButton: some View {
|
||||
Button(
|
||||
action: { presentationMode.wrappedValue.dismiss() },
|
||||
label: { Text(LocalText.genericClose) }
|
||||
)
|
||||
}
|
||||
|
||||
var actionsMenu: some View {
|
||||
Menu(
|
||||
content: {
|
||||
Button(
|
||||
action: { showShareView = true },
|
||||
label: { Label(LocalText.exportGeneric, systemImage: "square.and.arrow.up") }
|
||||
)
|
||||
},
|
||||
label: {
|
||||
Image(systemName: "ellipsis")
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
#if os(iOS)
|
||||
NavigationView {
|
||||
innerBody
|
||||
}
|
||||
#elseif os(macOS)
|
||||
innerBody
|
||||
.frame(minWidth: 400, minHeight: 400)
|
||||
#endif
|
||||
}
|
||||
.task {
|
||||
viewModel.load(itemObjectID: itemObjectID, dataService: dataService)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -58,11 +58,27 @@ struct HighlightListItemParams: Identifiable {
|
|||
}
|
||||
}
|
||||
|
||||
private func loadHighlights(item: LinkedItem) {
|
||||
let unsortedHighlights = item.highlights.asArray(of: Highlight.self)
|
||||
func highlightAsMarkdown(item: HighlightListItemParams) -> String {
|
||||
var buffer = "> \(item.quote)"
|
||||
if !item.annotation.isEmpty {
|
||||
buffer += "\n\n\(item.annotation)"
|
||||
}
|
||||
buffer += "\n"
|
||||
return buffer
|
||||
}
|
||||
|
||||
let highlights = unsortedHighlights.sorted {
|
||||
($0.createdAt ?? Date()) < ($1.createdAt ?? Date())
|
||||
func highlightsAsMarkdown() -> String {
|
||||
highlightItems.map { highlightAsMarkdown(item: $0) }.lazy.joined(separator: "\n\n")
|
||||
}
|
||||
|
||||
private func loadHighlights(item: LinkedItem) {
|
||||
let unsortedHighlights = item.highlights.asArray(of: Highlight.self).filter { $0.type == "HIGHLIGHT" }
|
||||
|
||||
let highlights = unsortedHighlights.sorted { left, right in
|
||||
if left.positionPercent > 0, right.positionPercent > 0 {
|
||||
return left.positionPercent < right.positionPercent
|
||||
}
|
||||
return (left.createdAt ?? Date()) < (right.createdAt ?? Date())
|
||||
}
|
||||
|
||||
highlightItems = highlights.map {
|
||||
|
|
|
|||
|
|
@ -45,11 +45,10 @@ struct FeedCardNavigationLink: View {
|
|||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Button(
|
||||
action: {
|
||||
viewModel.selectedItem = item
|
||||
viewModel.linkIsActive = true
|
||||
}) {
|
||||
Button {
|
||||
viewModel.selectedItem = item
|
||||
viewModel.linkIsActive = true
|
||||
} label: {
|
||||
NavigationLink(destination: EmptyView()) {
|
||||
EmptyView()
|
||||
}
|
||||
|
|
@ -58,7 +57,7 @@ struct FeedCardNavigationLink: View {
|
|||
.onAppear {
|
||||
Task { await viewModel.itemAppeared(item: item, dataService: dataService) }
|
||||
}
|
||||
FeedCard(item: item, viewer: dataService.currentViewer)
|
||||
LibraryItemCard(item: item, viewer: dataService.currentViewer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
//
|
||||
// LibraryFeatureCardNavigationLink.swift
|
||||
//
|
||||
//
|
||||
// Created by Jackson Harper on 4/4/23.
|
||||
//
|
||||
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Views
|
||||
|
||||
struct LibraryFeatureCardNavigationLink: View {
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@EnvironmentObject var audioController: AudioController
|
||||
|
||||
let item: LinkedItem
|
||||
|
||||
@ObservedObject var viewModel: HomeFeedViewModel
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Button {
|
||||
viewModel.selectedItem = item
|
||||
viewModel.linkIsActive = true
|
||||
} label: {
|
||||
NavigationLink(destination: EmptyView()) {
|
||||
EmptyView()
|
||||
}
|
||||
.opacity(0)
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.onAppear {
|
||||
Task { await viewModel.itemAppeared(item: item, dataService: dataService) }
|
||||
}
|
||||
LibraryFeatureCard(item: item, viewer: dataService.currentViewer)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import Foundation
|
||||
import Models
|
||||
import Views
|
||||
|
||||
extension LinkedItemFilter {
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .inbox:
|
||||
return LocalText.inboxGeneric
|
||||
case .readlater:
|
||||
return LocalText.readLaterGeneric
|
||||
case .newsletters:
|
||||
return LocalText.newslettersGeneric
|
||||
case .recommended:
|
||||
return "Recommended"
|
||||
case .all:
|
||||
return LocalText.allGeneric
|
||||
case .archived:
|
||||
return LocalText.archivedGeneric
|
||||
case .hasHighlights:
|
||||
return LocalText.highlightedGeneric
|
||||
case .files:
|
||||
return LocalText.filesGeneric
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension LinkedItemSort {
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .newest:
|
||||
return LocalText.newestGeneric
|
||||
case .oldest:
|
||||
return LocalText.oldestGeneric
|
||||
case .recentlyRead:
|
||||
return LocalText.recentlyReadGeneric
|
||||
case .recentlyPublished:
|
||||
return LocalText.recentlyPublishedGeneric
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,19 @@ import UserNotifications
|
|||
import Utils
|
||||
import Views
|
||||
|
||||
struct AnimatingCellHeight: AnimatableModifier {
|
||||
var height: CGFloat = 0
|
||||
|
||||
var animatableData: CGFloat {
|
||||
get { height }
|
||||
set { height = newValue }
|
||||
}
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content.frame(height: height, alignment: .top).clipped()
|
||||
}
|
||||
}
|
||||
|
||||
// swiftlint:disable file_length
|
||||
#if os(iOS)
|
||||
private let enableGrid = UIDevice.isIPad || FeatureFlag.enableGridCardsOnPhone
|
||||
|
|
@ -13,6 +26,8 @@ import Views
|
|||
struct HomeFeedContainerView: View {
|
||||
@State var hasHighlightMutations = false
|
||||
@State var searchPresented = false
|
||||
@State var addLinkPresented = false
|
||||
@State var settingsPresented = false
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@EnvironmentObject var audioController: AudioController
|
||||
|
||||
|
|
@ -129,16 +144,18 @@ import Views
|
|||
}
|
||||
ToolbarItem(placement: .barTrailing) {
|
||||
if UIDevice.isIPhone {
|
||||
NavigationLink(
|
||||
destination: { ProfileView() },
|
||||
label: {
|
||||
Image(systemName: "person.circle")
|
||||
.resizable()
|
||||
.frame(width: 22, height: 22)
|
||||
.padding(.vertical, 16)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
}
|
||||
)
|
||||
Menu(content: {
|
||||
Button(action: { settingsPresented = true }, label: {
|
||||
Label(LocalText.genericProfile, systemImage: "person.circle")
|
||||
})
|
||||
Button(action: { addLinkPresented = true }, label: {
|
||||
Label("Add Link", systemImage: "plus.square")
|
||||
})
|
||||
}, label: {
|
||||
Image(systemName: "ellipsis")
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.frame(width: 24, height: 24)
|
||||
})
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
|
|
@ -189,6 +206,16 @@ import Views
|
|||
.fullScreenCover(isPresented: $searchPresented) {
|
||||
LibrarySearchView(homeFeedViewModel: self.viewModel)
|
||||
}
|
||||
.sheet(isPresented: $addLinkPresented) {
|
||||
NavigationView {
|
||||
LibraryAddLinkView()
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $settingsPresented) {
|
||||
NavigationView {
|
||||
ProfileView()
|
||||
}
|
||||
}
|
||||
.task {
|
||||
if viewModel.items.isEmpty {
|
||||
loadItems(isRefresh: false)
|
||||
|
|
@ -229,6 +256,7 @@ import Views
|
|||
|
||||
@State private var itemToRemove: LinkedItem?
|
||||
@State private var confirmationShown = false
|
||||
@State private var showHideFeatureAlert = false
|
||||
|
||||
@ObservedObject var viewModel: HomeFeedViewModel
|
||||
|
||||
|
|
@ -340,6 +368,69 @@ import Views
|
|||
}
|
||||
}
|
||||
|
||||
var featureCard: some View {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
Menu(content: {
|
||||
Button(action: {
|
||||
viewModel.updateFeatureFilter(.continueReading)
|
||||
}, label: {
|
||||
Text("Continue Reading")
|
||||
})
|
||||
Button(action: {
|
||||
viewModel.updateFeatureFilter(.pinned)
|
||||
}, label: {
|
||||
Text("Pinned")
|
||||
})
|
||||
Button(action: {
|
||||
viewModel.updateFeatureFilter(.newsletters)
|
||||
}, label: {
|
||||
Text("Newsletters")
|
||||
})
|
||||
Button(action: {
|
||||
showHideFeatureAlert = true
|
||||
}, label: {
|
||||
Text("Hide this Section")
|
||||
})
|
||||
}, label: {
|
||||
HStack(alignment: .center) {
|
||||
Text(viewModel.featureFilter.title.uppercased())
|
||||
.font(Font.system(size: 14, weight: .regular))
|
||||
Image(systemName: "chevron.down")
|
||||
}.frame(maxWidth: .infinity, alignment: .leading)
|
||||
})
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 0)
|
||||
|
||||
GeometryReader { geo in
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
if viewModel.featureItems.count > 0 {
|
||||
LazyHStack(alignment: .top, spacing: 20) {
|
||||
ForEach(viewModel.featureItems) { item in
|
||||
LibraryFeatureCardNavigationLink(item: item, viewModel: viewModel)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12) // << tune as needed
|
||||
.fill(Color(UIColor.systemBackground)) // << fill with system color
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text(viewModel.featureFilter.emptyMessage)
|
||||
.font(Font.system(size: 14, weight: .regular))
|
||||
.foregroundColor(Color(hex: "#898989"))
|
||||
.frame(maxWidth: geo.size.width)
|
||||
.frame(height: 60, alignment: .topLeading)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text((LinkedItemFilter(rawValue: viewModel.appliedFilter)?.displayName ?? "Inbox").uppercased())
|
||||
.font(Font.system(size: 14, weight: .regular))
|
||||
.padding(.bottom, 5)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
NavigationLink(
|
||||
|
|
@ -357,13 +448,22 @@ import Views
|
|||
|
||||
List {
|
||||
filtersHeader
|
||||
.listRowSeparator(.hidden, edges: .top)
|
||||
.listRowInsets(.init(top: 0, leading: 10, bottom: 10, trailing: 10))
|
||||
|
||||
// Only show the feature card section if we have items loaded
|
||||
if !viewModel.hideFeatureSection, viewModel.items.count > 0 {
|
||||
featureCard
|
||||
.listRowInsets(.init(top: 0, leading: 10, bottom: 10, trailing: 10))
|
||||
.modifier(AnimatingCellHeight(height: viewModel.featureItems.count > 0 ? 260 : 130))
|
||||
}
|
||||
|
||||
ForEach(viewModel.items) { item in
|
||||
FeedCardNavigationLink(
|
||||
item: item,
|
||||
viewModel: viewModel
|
||||
)
|
||||
.listRowSeparatorTint(Color.thBorderColor)
|
||||
.listRowInsets(.init(top: 0, leading: 10, bottom: 10, trailing: 10))
|
||||
.contextMenu {
|
||||
menuItems(for: item)
|
||||
}
|
||||
|
|
@ -407,8 +507,9 @@ import Views
|
|||
}
|
||||
}
|
||||
}
|
||||
.padding(.top, 0)
|
||||
.padding(0)
|
||||
.listStyle(PlainListStyle())
|
||||
.listRowInsets(.init(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
.alert("Are you sure you want to delete this item? All associated notes and highlights will be deleted.",
|
||||
isPresented: $confirmationShown) {
|
||||
Button("Remove Item", role: .destructive) {
|
||||
|
|
@ -422,6 +523,13 @@ import Views
|
|||
Button(LocalText.cancelGeneric, role: .cancel) { self.itemToRemove = nil }
|
||||
}
|
||||
}
|
||||
.alert("The Feature Section will be removed from your library. You can add it back from the filter settings in your profile.",
|
||||
isPresented: $showHideFeatureAlert) {
|
||||
Button("OK", role: .destructive) {
|
||||
viewModel.hideFeatureSection = true
|
||||
}
|
||||
Button(LocalText.cancelGeneric, role: .cancel) { self.showHideFeatureAlert = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -498,6 +606,7 @@ import Views
|
|||
}
|
||||
}
|
||||
}
|
||||
// swiftlint:disable:next line_length
|
||||
.alert("Are you sure you want to delete this item? All associated notes and highlights will be deleted.", isPresented: $confirmationShown) {
|
||||
Button("Delete Item", role: .destructive) {
|
||||
if let itemToRemove = itemToRemove {
|
||||
|
|
@ -559,42 +668,3 @@ struct LinkDestination: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: move everything below this to another file
|
||||
extension LinkedItemFilter {
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .inbox:
|
||||
return LocalText.inboxGeneric
|
||||
case .readlater:
|
||||
return LocalText.readLaterGeneric
|
||||
case .newsletters:
|
||||
return LocalText.newslettersGeneric
|
||||
case .recommended:
|
||||
return "Recommended"
|
||||
case .all:
|
||||
return LocalText.allGeneric
|
||||
case .archived:
|
||||
return LocalText.archivedGeneric
|
||||
case .hasHighlights:
|
||||
return LocalText.highlightedGeneric
|
||||
case .files:
|
||||
return LocalText.filesGeneric
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension LinkedItemSort {
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .newest:
|
||||
return LocalText.newestGeneric
|
||||
case .oldest:
|
||||
return LocalText.oldestGeneric
|
||||
case .recentlyRead:
|
||||
return LocalText.recentlyReadGeneric
|
||||
case .recentlyPublished:
|
||||
return LocalText.recentlyPublishedGeneric
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ import Views
|
|||
Task {
|
||||
await viewModel.loadItems(
|
||||
dataService: dataService,
|
||||
audioController: audioController,
|
||||
isRefresh: isRefresh
|
||||
)
|
||||
}
|
||||
|
|
@ -43,7 +42,6 @@ import Views
|
|||
viewModel: viewModel
|
||||
)
|
||||
.contextMenu {
|
||||
// TODO: add highlights view button
|
||||
Button(
|
||||
action: { viewModel.itemUnderTitleEdit = item },
|
||||
label: { Label("Edit Info", systemImage: "info.circle") }
|
||||
|
|
@ -140,12 +138,11 @@ import Views
|
|||
Button(LocalText.cancelGeneric, role: .cancel) { self.itemToRemove = nil }
|
||||
}
|
||||
.sheet(item: $viewModel.itemUnderLabelEdit) { item in
|
||||
ApplyLabelsView(mode: .item(item), onSave: nil)
|
||||
ApplyLabelsView(mode: .item(item), isSearchFocused: false, onSave: nil)
|
||||
}
|
||||
.sheet(item: $viewModel.itemUnderTitleEdit) { item in
|
||||
LinkedItemMetadataEditView(item: item)
|
||||
}
|
||||
// TODO: add highlights view sheet
|
||||
.task {
|
||||
if viewModel.items.isEmpty {
|
||||
loadItems(isRefresh: true)
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@ import Utils
|
|||
import Views
|
||||
|
||||
@MainActor final class HomeFeedViewModel: NSObject, ObservableObject {
|
||||
let dateFormatter = DateFormatter.formatterISO8601
|
||||
|
||||
var currentDetailViewModel: LinkItemDetailViewModel?
|
||||
|
||||
private var fetchedResultsController: NSFetchedResultsController<LinkedItem>?
|
||||
|
|
@ -35,6 +33,9 @@ import Views
|
|||
@Published var showLabelsSheet = false
|
||||
@Published var showCommunityModal = false
|
||||
|
||||
@Published var featureFilter = FeaturedItemFilter.continueReading
|
||||
@Published var featureItems = [LinkedItem]()
|
||||
|
||||
var cursor: String?
|
||||
|
||||
// These are used to make sure we handle search result
|
||||
|
|
@ -44,8 +45,24 @@ import Views
|
|||
|
||||
var syncCursor: String?
|
||||
|
||||
@AppStorage(UserDefaultKey.hideFeatureSection.rawValue) var hideFeatureSection = false
|
||||
@AppStorage(UserDefaultKey.lastSelectedLinkedItemFilter.rawValue) var appliedFilter = LinkedItemFilter.inbox.rawValue
|
||||
|
||||
func setItems(_ items: [LinkedItem]) {
|
||||
self.items = items
|
||||
updateFeatureFilter(featureFilter)
|
||||
}
|
||||
|
||||
func updateFeatureFilter(_ filter: FeaturedItemFilter) {
|
||||
// now try to update the continue reading items:
|
||||
featureItems = (items.filter { item in
|
||||
filter.predicate.evaluate(with: item)
|
||||
} as NSArray)
|
||||
.sortedArray(using: [filter.sortDescriptor])
|
||||
.compactMap { $0 as? LinkedItem }
|
||||
featureFilter = filter
|
||||
}
|
||||
|
||||
func handleReaderItemNotification(objectID: NSManagedObjectID, dataService: DataService) {
|
||||
// Pop the current selected item if needed
|
||||
if selectedItem != nil, selectedItem?.objectID != objectID {
|
||||
|
|
@ -110,7 +127,7 @@ import Views
|
|||
|
||||
func syncItems(dataService: DataService) async {
|
||||
let syncStart = Date.now
|
||||
let lastSyncDate = dateFormatter.date(from: dataService.lastItemSyncTime) ?? Date(timeIntervalSinceReferenceDate: 0)
|
||||
let lastSyncDate = dataService.lastItemSyncTime
|
||||
|
||||
try? await dataService.syncOfflineItemsWithServerIfNeeded()
|
||||
|
||||
|
|
@ -124,13 +141,14 @@ import Views
|
|||
self.isLoading = false
|
||||
}
|
||||
} else {
|
||||
dataService.lastItemSyncTime = DateFormatter.formatterISO8601.string(from: syncStart)
|
||||
dataService.lastItemSyncTime = syncStart
|
||||
}
|
||||
|
||||
// If possible start prefetching new pages in the background
|
||||
if let itemIDs = syncResult?.updatedItemIDs,
|
||||
let username = dataService.currentViewer?.username,
|
||||
itemIDs.count > 0
|
||||
if
|
||||
let itemIDs = syncResult?.updatedItemIDs,
|
||||
let username = dataService.currentViewer?.username,
|
||||
!itemIDs.isEmpty
|
||||
{
|
||||
Task.detached(priority: .background) {
|
||||
await dataService.prefetchPages(itemIDs: itemIDs, username: username)
|
||||
|
|
@ -167,9 +185,9 @@ import Views
|
|||
// Don't use FRC for searching. Use server results directly.
|
||||
if fetchedResultsController != nil {
|
||||
fetchedResultsController = nil
|
||||
items = []
|
||||
setItems([])
|
||||
}
|
||||
items = isRefresh ? newItems : items + newItems
|
||||
setItems(isRefresh ? newItems : items + newItems)
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
|
|
@ -267,7 +285,7 @@ import Views
|
|||
|
||||
fetchedResultsController.delegate = self
|
||||
try? fetchedResultsController.performFetch()
|
||||
items = fetchedResultsController.fetchedObjects ?? []
|
||||
setItems(fetchedResultsController.fetchedObjects ?? [])
|
||||
}
|
||||
|
||||
func setLinkArchived(dataService: DataService, objectID: NSManagedObjectID, archived: Bool) {
|
||||
|
|
@ -303,10 +321,21 @@ import Views
|
|||
isLoading = false
|
||||
}
|
||||
|
||||
private var queryContainsFilter: Bool {
|
||||
if searchTerm.contains("in:inbox") || searchTerm.contains("in:all") || searchTerm.contains("in:archive") {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private var searchQuery: String {
|
||||
let filter = LinkedItemFilter(rawValue: appliedFilter) ?? .inbox
|
||||
let sort = LinkedItemSort(rawValue: appliedSort) ?? .newest
|
||||
var query = "\(filter.queryString) \(sort.queryString)"
|
||||
var query = sort.queryString
|
||||
|
||||
if !queryContainsFilter, let filter = LinkedItemFilter(rawValue: appliedFilter) {
|
||||
query = "\(filter.queryString) \(sort.queryString)"
|
||||
}
|
||||
|
||||
if !searchTerm.isEmpty {
|
||||
query.append(" \(searchTerm)")
|
||||
|
|
@ -322,12 +351,14 @@ import Views
|
|||
query.append(negatedLabels.map { $0.name ?? "" }.joined(separator: ","))
|
||||
}
|
||||
|
||||
print("QUERY: `\(query)`")
|
||||
|
||||
return query
|
||||
}
|
||||
}
|
||||
|
||||
extension HomeFeedViewModel: NSFetchedResultsControllerDelegate {
|
||||
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
|
||||
items = controller.fetchedObjects as? [LinkedItem] ?? []
|
||||
setItems(controller.fetchedObjects as? [LinkedItem] ?? [])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
#if os(iOS)
|
||||
import Introspect
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import Views
|
||||
|
||||
@MainActor final class LibraryAddLinkViewModel: NSObject, ObservableObject {
|
||||
@Published var isLoading = false
|
||||
@Published var errorMessage: String = ""
|
||||
@Published var showErrorMessage: Bool = false
|
||||
|
||||
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 LibraryAddLinkView: View {
|
||||
@StateObject var viewModel = LibraryAddLinkViewModel()
|
||||
|
||||
@State var newLinkURL: String = ""
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
enum FocusField: Hashable {
|
||||
case addLinkEditor
|
||||
}
|
||||
|
||||
@FocusState private var focusedField: FocusField?
|
||||
|
||||
var body: some View {
|
||||
innerBody
|
||||
.navigationTitle("Add Link")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear {
|
||||
focusedField = .addLinkEditor
|
||||
}
|
||||
}
|
||||
|
||||
var innerBody: some View {
|
||||
Form {
|
||||
TextField("Add Link", text: $newLinkURL)
|
||||
.keyboardType(.URL)
|
||||
.textFieldStyle(StandardTextFieldStyle())
|
||||
.focused($focusedField, equals: .addLinkEditor)
|
||||
|
||||
Button(action: {
|
||||
if let url = UIPasteboard.general.url {
|
||||
newLinkURL = url.absoluteString
|
||||
} else {
|
||||
viewModel.error("No URL on pasteboard")
|
||||
}
|
||||
}, label: {
|
||||
Text("Get from pasteboard")
|
||||
})
|
||||
}
|
||||
.navigationTitle("Add Link")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
dismissButton
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
viewModel.isLoading ? AnyView(ProgressView()) : AnyView(addButton)
|
||||
}
|
||||
}
|
||||
.alert(viewModel.errorMessage,
|
||||
isPresented: $viewModel.showErrorMessage) {
|
||||
Button(LocalText.genericOk, role: .cancel) { viewModel.showErrorMessage = false }
|
||||
}
|
||||
}
|
||||
|
||||
var addButton: some View {
|
||||
Button(
|
||||
action: {
|
||||
viewModel.addLink(dataService: dataService, newLinkURL: newLinkURL, dismiss: dismiss)
|
||||
},
|
||||
label: { Text("Add").bold() }
|
||||
)
|
||||
.disabled(viewModel.isLoading)
|
||||
}
|
||||
|
||||
var dismissButton: some View {
|
||||
Button(
|
||||
action: { dismiss() },
|
||||
label: { Text(LocalText.genericClose) }
|
||||
)
|
||||
.disabled(viewModel.isLoading)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
@ -44,7 +44,12 @@ import Views
|
|||
let fetchRequest: NSFetchRequest<Models.RecentSearchItem> = RecentSearchItem.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(format: "term == %@", searchTerm)
|
||||
|
||||
let item = ((try? dataService.viewContext.fetch(fetchRequest))?.first) ?? RecentSearchItem(context: dataService.viewContext)
|
||||
let item: RecentSearchItem
|
||||
if let fetchedItem = (try? dataService.viewContext.fetch(fetchRequest))?.first {
|
||||
item = fetchedItem
|
||||
} else {
|
||||
item = RecentSearchItem(context: dataService.viewContext)
|
||||
}
|
||||
item.term = searchTerm
|
||||
item.savedAt = Date()
|
||||
|
||||
|
|
|
|||
|
|
@ -128,7 +128,9 @@ struct ApplyLabelsView: View {
|
|||
)
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.disabled(viewModel.isLoading)
|
||||
.listRowSeparator(.hidden, edges: .bottom)
|
||||
#if os(iOS)
|
||||
.listRowSeparator(.hidden, edges: .bottom)
|
||||
#endif
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,10 +17,11 @@ struct LabelsMasonaryView: View {
|
|||
@State private var totalHeight = CGFloat.zero
|
||||
private var labelItems: [(label: LinkedItemLabel, selected: Bool)]
|
||||
|
||||
init(labels allLabels: [LinkedItemLabel],
|
||||
selectedLabels: [LinkedItemLabel],
|
||||
onLabelTap: @escaping (LinkedItemLabel, TextChip) -> Void)
|
||||
{
|
||||
init(
|
||||
labels allLabels: [LinkedItemLabel],
|
||||
selectedLabels: [LinkedItemLabel],
|
||||
onLabelTap: @escaping (LinkedItemLabel, TextChip) -> Void
|
||||
) {
|
||||
self.onLabelTap = onLabelTap
|
||||
|
||||
let selected = selectedLabels.map { (label: $0, selected: true) }
|
||||
|
|
|
|||
|
|
@ -67,7 +67,9 @@ struct LabelsView: View {
|
|||
)
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.disabled(viewModel.isLoading)
|
||||
.listRowSeparator(.hidden, edges: .bottom)
|
||||
#if os(iOS)
|
||||
.listRowSeparator(.hidden, edges: .bottom)
|
||||
#endif
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
}
|
||||
.frame(height: storedSize.height)
|
||||
.overlay(content: {
|
||||
// swiftlint:disable line_length
|
||||
HStack {
|
||||
let color: Color = .systemBackground
|
||||
|
||||
|
|
@ -47,6 +48,7 @@
|
|||
|
||||
LinearGradient(colors: [color, color.opacity(0.7), color.opacity(0.5), color.opacity(0.3)].reversed(), startPoint: .leading, endPoint: .trailing)
|
||||
.frame(width: 8)
|
||||
// swiftlint:enable line_length
|
||||
}
|
||||
})
|
||||
.disabled(true)
|
||||
|
|
|
|||
|
|
@ -168,7 +168,7 @@ struct LinkItemDetailView: View {
|
|||
}
|
||||
},
|
||||
label: {
|
||||
Image.profile
|
||||
Image(systemName: "ellipsis")
|
||||
.padding(.horizontal)
|
||||
.scaleEffect(navBarVisibilityRatio)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import Services
|
|||
import SwiftUI
|
||||
import Views
|
||||
|
||||
@MainActor final class LinkedItemTitleEditViewModel: ObservableObject {
|
||||
@MainActor final class LinkedItemMetadataEditViewModel: ObservableObject {
|
||||
@Published var title = ""
|
||||
@Published var description = ""
|
||||
@Published var author = ""
|
||||
|
|
@ -28,9 +28,15 @@ import Views
|
|||
struct LinkedItemMetadataEditView: View {
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@Environment(\.presentationMode) private var presentationMode
|
||||
@StateObject var viewModel = LinkedItemTitleEditViewModel()
|
||||
@StateObject var viewModel = LinkedItemMetadataEditViewModel()
|
||||
|
||||
let item: LinkedItem
|
||||
let onSave: ((String, String) -> Void)?
|
||||
|
||||
init(item: LinkedItem, onSave: ((String, String) -> Void)? = nil) {
|
||||
self.item = item
|
||||
self.onSave = onSave
|
||||
}
|
||||
|
||||
var editForm: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
|
|
@ -96,6 +102,9 @@ struct LinkedItemMetadataEditView: View {
|
|||
Button(
|
||||
action: {
|
||||
viewModel.submit(dataService: dataService, item: item)
|
||||
if let onSave = self.onSave {
|
||||
onSave(viewModel.title, viewModel.description)
|
||||
}
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
},
|
||||
label: { Text(LocalText.genericSave).foregroundColor(.appGrayTextContrast) }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Utils
|
||||
import Views
|
||||
|
||||
@MainActor final class FiltersViewModel: ObservableObject {
|
||||
@Published var isLoading = false
|
||||
@Published var isCreating = false
|
||||
@Published var networkError = false
|
||||
|
||||
@AppStorage(UserDefaultKey.hideFeatureSection.rawValue) var hideFeatureSection = false
|
||||
}
|
||||
|
||||
struct FiltersView: View {
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@StateObject var viewModel = FiltersViewModel()
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
#if os(iOS)
|
||||
Form {
|
||||
innerBody
|
||||
}
|
||||
#elseif os(macOS)
|
||||
List {
|
||||
innerBody
|
||||
}
|
||||
.listStyle(InsetListStyle())
|
||||
#endif
|
||||
}
|
||||
.navigationTitle(LocalText.filtersGeneric)
|
||||
}
|
||||
|
||||
private var innerBody: some View {
|
||||
Group {
|
||||
Section {
|
||||
Toggle("Hide Feature Section", isOn: $viewModel.hideFeatureSection)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -39,7 +39,7 @@ struct ManageAccountView: View {
|
|||
)
|
||||
Button(
|
||||
action: {
|
||||
dataService.resetCoreData()
|
||||
dataService.resetLocalStorage()
|
||||
},
|
||||
label: { Text(LocalText.manageAccountResetCache) }
|
||||
)
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ struct ProfileView: View {
|
|||
@EnvironmentObject var authenticator: Authenticator
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@Environment(\.openURL) var openURL
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
@StateObject private var viewModel = ProfileContainerViewModel()
|
||||
|
||||
|
|
@ -69,6 +70,13 @@ struct ProfileView: View {
|
|||
Form {
|
||||
innerBody
|
||||
}
|
||||
.navigationTitle(LocalText.genericProfile)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
dismissButton
|
||||
}
|
||||
}
|
||||
#elseif os(macOS)
|
||||
List {
|
||||
innerBody
|
||||
|
|
@ -77,6 +85,13 @@ struct ProfileView: View {
|
|||
#endif
|
||||
}
|
||||
|
||||
var dismissButton: some View {
|
||||
Button(
|
||||
action: { dismiss() },
|
||||
label: { Text(LocalText.genericClose) }
|
||||
)
|
||||
}
|
||||
|
||||
private var accountSection: some View {
|
||||
Section {
|
||||
NavigationLink(destination: LabelsView()) {
|
||||
|
|
@ -91,8 +106,14 @@ struct ProfileView: View {
|
|||
Text(LocalText.subscriptionsGeneric)
|
||||
}
|
||||
|
||||
NavigationLink(destination: GroupsView()) {
|
||||
Text(LocalText.clubsGeneric)
|
||||
#if os(iOS)
|
||||
NavigationLink(destination: GroupsView()) {
|
||||
Text(LocalText.clubsGeneric)
|
||||
}
|
||||
#endif
|
||||
|
||||
NavigationLink(destination: FiltersView()) {
|
||||
Text(LocalText.filtersGeneric)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -164,14 +185,16 @@ struct ProfileView: View {
|
|||
Alert(
|
||||
title: Text(LocalText.profileConfirmLogoutMessage),
|
||||
primaryButton: .destructive(Text(LocalText.genericConfirm)) {
|
||||
authenticator.logout(dataService: dataService)
|
||||
dismiss()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
|
||||
authenticator.logout(dataService: dataService)
|
||||
}
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(LocalText.genericProfile)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
|
|
@ -20,7 +19,7 @@ import Views
|
|||
func removeToken(dataService: DataService, tokenID: String) {
|
||||
if let idx = devices.firstIndex(where: { $0.id == tokenID }) {
|
||||
Task {
|
||||
try await dataService.syncDeviceToken(deviceTokenOperation: .deleteToken(tokenID: tokenID))
|
||||
_ = try await dataService.syncDeviceToken(deviceTokenOperation: .deleteToken(tokenID: tokenID))
|
||||
devices.remove(at: idx)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,90 +1,91 @@
|
|||
#if os(iOS)
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Utils
|
||||
import Views
|
||||
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Utils
|
||||
import Views
|
||||
@MainActor final class PushNotificationSettingsViewModel: ObservableObject {
|
||||
@Published var isLoading = false
|
||||
@Published var emails = [NewsletterEmail]()
|
||||
@Published var desiredNotificationsEnabled = false
|
||||
@AppStorage(UserDefaultKey.notificationsEnabled.rawValue) var notificationsEnabled = false
|
||||
|
||||
@MainActor final class PushNotificationSettingsViewModel: ObservableObject {
|
||||
@Published var isLoading = false
|
||||
@Published var emails = [NewsletterEmail]()
|
||||
@Published var desiredNotificationsEnabled = false
|
||||
@AppStorage(UserDefaultKey.notificationsEnabled.rawValue) var notificationsEnabled = false
|
||||
|
||||
func checkPushNotificationsStatus() {
|
||||
UNUserNotificationCenter.current().getNotificationSettings { settings in
|
||||
DispatchQueue.main.async {
|
||||
self.desiredNotificationsEnabled = settings.alertSetting == UNNotificationSetting.enabled
|
||||
func checkPushNotificationsStatus() {
|
||||
UNUserNotificationCenter.current().getNotificationSettings { settings in
|
||||
DispatchQueue.main.async {
|
||||
self.desiredNotificationsEnabled = settings.alertSetting == UNNotificationSetting.enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func tryUpdateToDesired(dataService: DataService) {
|
||||
UserDefaults.standard.set(desiredNotificationsEnabled, forKey: UserDefaultKey.notificationsEnabled.rawValue)
|
||||
func tryUpdateToDesired(dataService: DataService) {
|
||||
UserDefaults.standard.set(desiredNotificationsEnabled, forKey: UserDefaultKey.notificationsEnabled.rawValue)
|
||||
|
||||
if desiredNotificationsEnabled {
|
||||
UNUserNotificationCenter.current().requestAuthorization(options: [.alert]) { granted, _ in
|
||||
DispatchQueue.main.async {
|
||||
self.desiredNotificationsEnabled = granted
|
||||
Task {
|
||||
if let savedToken = UserDefaults.standard.string(forKey: UserDefaultKey.firebasePushToken.rawValue) {
|
||||
try? await dataService.syncDeviceToken(
|
||||
deviceTokenOperation: DeviceTokenOperation.addToken(token: savedToken))
|
||||
if desiredNotificationsEnabled {
|
||||
UNUserNotificationCenter.current().requestAuthorization(options: [.alert]) { granted, _ in
|
||||
DispatchQueue.main.async {
|
||||
self.desiredNotificationsEnabled = granted
|
||||
Task {
|
||||
if let savedToken = UserDefaults.standard.string(forKey: UserDefaultKey.firebasePushToken.rawValue) {
|
||||
_ = try? await dataService.syncDeviceToken(
|
||||
deviceTokenOperation: DeviceTokenOperation.addToken(token: savedToken))
|
||||
}
|
||||
NotificationCenter.default.post(name: Notification.Name("ReconfigurePushNotifications"), object: nil)
|
||||
}
|
||||
NotificationCenter.default.post(name: Notification.Name("ReconfigurePushNotifications"), object: nil)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if let tokenID = UserDefaults.standard.string(forKey: UserDefaultKey.deviceTokenID.rawValue) {
|
||||
Task {
|
||||
try? await Services().dataService.syncDeviceToken(deviceTokenOperation: .deleteToken(tokenID: tokenID))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if let tokenID = UserDefaults.standard.string(forKey: UserDefaultKey.deviceTokenID.rawValue) {
|
||||
Task {
|
||||
try? await Services().dataService.syncDeviceToken(deviceTokenOperation: .deleteToken(tokenID: tokenID))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PushNotificationSettingsView: View {
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@StateObject var viewModel = PushNotificationSettingsViewModel()
|
||||
@State var desiredNotificationsEnabled: Bool = false
|
||||
struct PushNotificationSettingsView: View {
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@StateObject var viewModel = PushNotificationSettingsViewModel()
|
||||
@State var desiredNotificationsEnabled: Bool = false
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
#if os(iOS)
|
||||
Form {
|
||||
innerBody
|
||||
}
|
||||
#elseif os(macOS)
|
||||
List {
|
||||
innerBody
|
||||
}
|
||||
.listStyle(InsetListStyle())
|
||||
#endif
|
||||
var body: some View {
|
||||
Group {
|
||||
#if os(iOS)
|
||||
Form {
|
||||
innerBody
|
||||
}
|
||||
#elseif os(macOS)
|
||||
List {
|
||||
innerBody
|
||||
}
|
||||
.listStyle(InsetListStyle())
|
||||
#endif
|
||||
}
|
||||
.task { viewModel.checkPushNotificationsStatus() }
|
||||
}
|
||||
.task { viewModel.checkPushNotificationsStatus() }
|
||||
}
|
||||
|
||||
private var innerBody: some View {
|
||||
Group {
|
||||
Section {
|
||||
Toggle(isOn: $viewModel.desiredNotificationsEnabled, label: { Text(LocalText.notificationsEnabled) })
|
||||
}.onChange(of: viewModel.desiredNotificationsEnabled) { _ in
|
||||
viewModel.tryUpdateToDesired(dataService: dataService)
|
||||
}
|
||||
private var innerBody: some View {
|
||||
Group {
|
||||
Section {
|
||||
Toggle(isOn: $viewModel.desiredNotificationsEnabled, label: { Text(LocalText.notificationsEnabled) })
|
||||
}.onChange(of: viewModel.desiredNotificationsEnabled) { _ in
|
||||
viewModel.tryUpdateToDesired(dataService: dataService)
|
||||
}
|
||||
|
||||
Section { // TODO: double check this text
|
||||
Text("\(LocalText.notificationsExplainer)\n\(LocalText.notificationsTriggerExplainer)")
|
||||
.accentColor(.blue)
|
||||
}
|
||||
Section {
|
||||
Text("\(LocalText.notificationsExplainer)\n\(LocalText.notificationsTriggerExplainer)")
|
||||
.accentColor(.blue)
|
||||
}
|
||||
|
||||
Section {
|
||||
NavigationLink("Devices") {
|
||||
PushNotificationDevicesView()
|
||||
Section {
|
||||
NavigationLink("Devices") {
|
||||
PushNotificationDevicesView()
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(LocalText.pushNotificationsGeneric)
|
||||
}
|
||||
.navigationTitle(LocalText.pushNotificationsGeneric)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -1,212 +1,214 @@
|
|||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Views
|
||||
#if os(iOS)
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Views
|
||||
|
||||
@MainActor final class RecommendationsGroupViewModel: ObservableObject {
|
||||
@Published var isLoading = false
|
||||
@Published var isLeaving = false
|
||||
@Published var networkError = false
|
||||
@Published var showLeaveGroup = false
|
||||
@Published var recommendationGroup: InternalRecommendationGroup
|
||||
@MainActor final class RecommendationsGroupViewModel: ObservableObject {
|
||||
@Published var isLoading = false
|
||||
@Published var isLeaving = false
|
||||
@Published var networkError = false
|
||||
@Published var showLeaveGroup = false
|
||||
@Published var recommendationGroup: InternalRecommendationGroup
|
||||
|
||||
init(recommendationGroup: InternalRecommendationGroup) {
|
||||
self.recommendationGroup = recommendationGroup
|
||||
}
|
||||
|
||||
var nonAdmins: [InternalUserProfile] {
|
||||
recommendationGroup.members.filter { member in
|
||||
!recommendationGroup.admins.contains(where: { member.id == $0.id })
|
||||
}
|
||||
}
|
||||
|
||||
func leaveGroup(dataService: DataService) async -> Bool {
|
||||
isLeaving = true
|
||||
defer {
|
||||
isLeaving = false
|
||||
init(recommendationGroup: InternalRecommendationGroup) {
|
||||
self.recommendationGroup = recommendationGroup
|
||||
}
|
||||
|
||||
do {
|
||||
try await dataService.leaveGroup(groupID: recommendationGroup.id)
|
||||
Snackbar.show(message: "You have left the club.")
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private struct SmallUserCard: View {
|
||||
let data: ProfileCardData
|
||||
var size: CGFloat
|
||||
|
||||
public init(data: ProfileCardData, size: CGFloat = 36) {
|
||||
self.data = data
|
||||
self.size = size
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
HStack(alignment: .center, spacing: 16) {
|
||||
Group {
|
||||
AsyncImage(
|
||||
url: data.imageURL,
|
||||
content: { $0.resizable() },
|
||||
placeholder: {
|
||||
Image(systemName: "person.crop.circle")
|
||||
.resizable()
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
}
|
||||
)
|
||||
var nonAdmins: [InternalUserProfile] {
|
||||
recommendationGroup.members.filter { member in
|
||||
!recommendationGroup.admins.contains(where: { member.id == $0.id })
|
||||
}
|
||||
.padding(.leading, 0)
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: size, height: size, alignment: .center)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(data.name)
|
||||
.font(.appBody)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.lineLimit(1)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
func leaveGroup(dataService: DataService) async -> Bool {
|
||||
isLeaving = true
|
||||
defer {
|
||||
isLeaving = false
|
||||
}
|
||||
|
||||
Text("@\(data.username)")
|
||||
.font(.appCaption)
|
||||
.foregroundColor(.appGrayText)
|
||||
.lineLimit(1)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
do {
|
||||
try await dataService.leaveGroup(groupID: recommendationGroup.id)
|
||||
Snackbar.show(message: "You have left the club.")
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private struct SmallUserCard: View {
|
||||
let data: ProfileCardData
|
||||
var size: CGFloat
|
||||
|
||||
public init(data: ProfileCardData, size: CGFloat = 36) {
|
||||
self.data = data
|
||||
self.size = size
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
HStack(alignment: .center, spacing: 16) {
|
||||
Group {
|
||||
AsyncImage(
|
||||
url: data.imageURL,
|
||||
content: { $0.resizable() },
|
||||
placeholder: {
|
||||
Image(systemName: "person.crop.circle")
|
||||
.resizable()
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
}
|
||||
)
|
||||
}
|
||||
.padding(.leading, 0)
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: size, height: size, alignment: .center)
|
||||
.clipShape(Circle())
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(data.name)
|
||||
.font(.appBody)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.lineLimit(1)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Text("@\(data.username)")
|
||||
.font(.appCaption)
|
||||
.foregroundColor(.appGrayText)
|
||||
.lineLimit(1)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.multilineTextAlignment(.leading)
|
||||
.padding(0)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.multilineTextAlignment(.leading)
|
||||
.padding(0)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
struct RecommendationGroupView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@StateObject var viewModel: RecommendationsGroupViewModel
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
#if os(iOS)
|
||||
Form {
|
||||
innerBody
|
||||
}
|
||||
#elseif os(macOS)
|
||||
List {
|
||||
innerBody
|
||||
}
|
||||
.listStyle(InsetListStyle())
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private var shareView: some View {
|
||||
if let shareLink = URL(string: viewModel.recommendationGroup.inviteUrl) {
|
||||
return AnyView(ShareSheet(activityItems: [shareLink]))
|
||||
} else {
|
||||
return AnyView(Text(LocalText.clubsErrorCopying))
|
||||
}
|
||||
}
|
||||
struct RecommendationGroupView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private var adminsSection: some View {
|
||||
Section("Admins") {
|
||||
ForEach(viewModel.recommendationGroup.admins) { admin in
|
||||
SmallUserCard(data: ProfileCardData(
|
||||
name: admin.name,
|
||||
username: admin.username,
|
||||
imageURL: admin.profileImageURL != nil ? URL(string: admin.profileImageURL!) : nil
|
||||
))
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@StateObject var viewModel: RecommendationsGroupViewModel
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
#if os(iOS)
|
||||
Form {
|
||||
innerBody
|
||||
}
|
||||
#elseif os(macOS)
|
||||
List {
|
||||
innerBody
|
||||
}
|
||||
.listStyle(InsetListStyle())
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var membersSection: some View {
|
||||
Section("Members") {
|
||||
if !viewModel.recommendationGroup.canSeeMembers { // TODO: might need to fix text here
|
||||
Text("""
|
||||
\(LocalText.clubsAdminDenyViewing)
|
||||
private var shareView: some View {
|
||||
if let shareLink = URL(string: viewModel.recommendationGroup.inviteUrl) {
|
||||
return AnyView(ShareSheet(activityItems: [shareLink]))
|
||||
} else {
|
||||
return AnyView(Text(LocalText.clubsErrorCopying))
|
||||
}
|
||||
}
|
||||
|
||||
[Learn more about clubs](https://blog.omnivore.app/p/dca38ba4-8a74-42cc-90ca-d5ffa5d075cc)
|
||||
""")
|
||||
.accentColor(.blue)
|
||||
} else if viewModel.nonAdmins.count > 0 {
|
||||
ForEach(viewModel.nonAdmins) { member in
|
||||
private var adminsSection: some View {
|
||||
Section("Admins") {
|
||||
ForEach(viewModel.recommendationGroup.admins) { admin in
|
||||
SmallUserCard(data: ProfileCardData(
|
||||
name: member.name,
|
||||
username: member.username,
|
||||
imageURL: member.profileImageURL != nil ? URL(string: member.profileImageURL!) : nil
|
||||
name: admin.name,
|
||||
username: admin.username,
|
||||
imageURL: admin.profileImageURL != nil ? URL(string: admin.profileImageURL!) : nil
|
||||
))
|
||||
}
|
||||
} else { // TODO: fix link text to use translation
|
||||
Text("""
|
||||
\(LocalText.clubsNoMembers)
|
||||
|
||||
[Learn more about clubs](https://blog.omnivore.app/p/dca38ba4-8a74-42cc-90ca-d5ffa5d075cc)
|
||||
""")
|
||||
.accentColor(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var leaveSection: some View {
|
||||
if viewModel.isLeaving {
|
||||
return AnyView(ProgressView())
|
||||
}
|
||||
return AnyView(Button(action: {
|
||||
viewModel.showLeaveGroup = true
|
||||
}, label: { Text(LocalText.clubsLeave) })
|
||||
.accentColor(.red))
|
||||
}
|
||||
private var membersSection: some View {
|
||||
Section("Members") {
|
||||
if !viewModel.recommendationGroup.canSeeMembers {
|
||||
Text("""
|
||||
\(LocalText.clubsAdminDenyViewing)
|
||||
|
||||
private var innerBody: some View {
|
||||
Group {
|
||||
Section(LocalText.genericName) {
|
||||
Text(viewModel.recommendationGroup.name)
|
||||
}
|
||||
|
||||
Section("Invite Link") {
|
||||
Button(action: {
|
||||
#if os(iOS)
|
||||
UIPasteboard.general.string = viewModel.recommendationGroup.inviteUrl
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
let pasteBoard = NSPasteboard.general
|
||||
pasteBoard.clearContents()
|
||||
pasteBoard.writeObjects([highlightParams.quote as NSString])
|
||||
#endif
|
||||
|
||||
Snackbar.show(message: "Invite link copied")
|
||||
}, label: {
|
||||
Text("[\(viewModel.recommendationGroup.inviteUrl)](\(viewModel.recommendationGroup.inviteUrl))")
|
||||
.font(.appCaption)
|
||||
})
|
||||
}
|
||||
|
||||
adminsSection
|
||||
membersSection
|
||||
|
||||
leaveSection
|
||||
}
|
||||
.alert(isPresented: $viewModel.showLeaveGroup) {
|
||||
Alert(
|
||||
title: Text(LocalText.clubsLeaveConfirm),
|
||||
primaryButton: .destructive(Text(LocalText.clubsLeave)) {
|
||||
Task {
|
||||
let success = await viewModel.leaveGroup(dataService: dataService)
|
||||
if success {
|
||||
dismiss()
|
||||
}
|
||||
[Learn more about clubs](https://blog.omnivore.app/p/dca38ba4-8a74-42cc-90ca-d5ffa5d075cc)
|
||||
""")
|
||||
.accentColor(.blue)
|
||||
} else if viewModel.nonAdmins.count > 0 {
|
||||
ForEach(viewModel.nonAdmins) { member in
|
||||
SmallUserCard(data: ProfileCardData(
|
||||
name: member.name,
|
||||
username: member.username,
|
||||
imageURL: member.profileImageURL != nil ? URL(string: member.profileImageURL!) : nil
|
||||
))
|
||||
}
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
} else {
|
||||
Text("""
|
||||
\(LocalText.clubsNoMembers)
|
||||
|
||||
[Learn more about clubs](https://blog.omnivore.app/p/dca38ba4-8a74-42cc-90ca-d5ffa5d075cc)
|
||||
""")
|
||||
.accentColor(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var leaveSection: some View {
|
||||
if viewModel.isLeaving {
|
||||
return AnyView(ProgressView())
|
||||
}
|
||||
return AnyView(Button(action: {
|
||||
viewModel.showLeaveGroup = true
|
||||
}, label: { Text(LocalText.clubsLeave) })
|
||||
.accentColor(.red))
|
||||
}
|
||||
|
||||
private var innerBody: some View {
|
||||
Group {
|
||||
Section(LocalText.genericName) {
|
||||
Text(viewModel.recommendationGroup.name)
|
||||
}
|
||||
|
||||
Section("Invite Link") {
|
||||
Button(action: {
|
||||
#if os(iOS)
|
||||
UIPasteboard.general.string = viewModel.recommendationGroup.inviteUrl
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
let pasteBoard = NSPasteboard.general
|
||||
pasteBoard.clearContents()
|
||||
pasteBoard.writeObjects([highlightParams.quote as NSString])
|
||||
#endif
|
||||
|
||||
Snackbar.show(message: "Invite link copied")
|
||||
}, label: {
|
||||
Text("[\(viewModel.recommendationGroup.inviteUrl)](\(viewModel.recommendationGroup.inviteUrl))")
|
||||
.font(.appCaption)
|
||||
})
|
||||
}
|
||||
|
||||
adminsSection
|
||||
membersSection
|
||||
|
||||
leaveSection
|
||||
}
|
||||
.alert(isPresented: $viewModel.showLeaveGroup) {
|
||||
Alert(
|
||||
title: Text(LocalText.clubsLeaveConfirm),
|
||||
primaryButton: .destructive(Text(LocalText.clubsLeave)) {
|
||||
Task {
|
||||
let success = await viewModel.leaveGroup(dataService: dataService)
|
||||
if success {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
.navigationTitle(viewModel.recommendationGroup.name)
|
||||
}
|
||||
.navigationTitle(viewModel.recommendationGroup.name)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -1,174 +1,178 @@
|
|||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Views
|
||||
#if os(iOS)
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Views
|
||||
|
||||
@MainActor final class RecommendationsGroupsViewModel: ObservableObject {
|
||||
@Published var isLoading = false
|
||||
@Published var isCreating = false
|
||||
@Published var networkError = false
|
||||
@Published var recommendationGroups = [InternalRecommendationGroup]()
|
||||
@MainActor final class RecommendationsGroupsViewModel: ObservableObject {
|
||||
@Published var isLoading = false
|
||||
@Published var isCreating = false
|
||||
@Published var networkError = false
|
||||
@Published var recommendationGroups = [InternalRecommendationGroup]()
|
||||
|
||||
@Published var showCreateSheet = false
|
||||
@Published var showCreateSheet = false
|
||||
|
||||
@Published var showCreateError = false
|
||||
@Published var createGroupError: String?
|
||||
@Published var showCreateError = false
|
||||
@Published var createGroupError: String?
|
||||
|
||||
@Published var onlyAdminCanPost = false
|
||||
@Published var onlyAdminCanSeeMembers = false
|
||||
@Published var onlyAdminCanPost = false
|
||||
@Published var onlyAdminCanSeeMembers = false
|
||||
|
||||
func loadGroups(dataService: DataService) async {
|
||||
isLoading = true
|
||||
func loadGroups(dataService: DataService) async {
|
||||
isLoading = true
|
||||
|
||||
do {
|
||||
recommendationGroups = try await dataService.recommendationGroups()
|
||||
} catch {
|
||||
networkError = true
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func createGroup(dataService: DataService, name: String) async {
|
||||
isCreating = true
|
||||
|
||||
let group = try? await dataService.createRecommendationGroup(name: name,
|
||||
onlyAdminCanPost: onlyAdminCanPost,
|
||||
onlyAdminCanSeeMembers: onlyAdminCanSeeMembers)
|
||||
|
||||
if group != nil {
|
||||
await loadGroups(dataService: dataService)
|
||||
showCreateSheet = false
|
||||
} else {
|
||||
createGroupError = "Error creating club"
|
||||
showCreateError = true
|
||||
}
|
||||
|
||||
isCreating = false
|
||||
}
|
||||
}
|
||||
|
||||
struct CreateRecommendationGroupView: View {
|
||||
@State var name = ""
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@StateObject var viewModel = RecommendationsGroupsViewModel()
|
||||
|
||||
var nextButton: some View {
|
||||
if viewModel.isCreating {
|
||||
return AnyView(ProgressView())
|
||||
} else {
|
||||
return AnyView(Button(action: {
|
||||
Task {
|
||||
await viewModel.createGroup(dataService: dataService, name: self.name)
|
||||
}
|
||||
}, label: {
|
||||
Text(LocalText.genericNext)
|
||||
})
|
||||
.disabled(name.isEmpty)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
Form {
|
||||
TextField(LocalText.genericName, text: $name, prompt: Text(LocalText.clubsName))
|
||||
|
||||
Section("Club Rules") {
|
||||
Toggle("Only admins can post", isOn: $viewModel.onlyAdminCanPost)
|
||||
Toggle("Only admins can see members", isOn: $viewModel.onlyAdminCanSeeMembers)
|
||||
}
|
||||
|
||||
Section {
|
||||
Section {
|
||||
Text("""
|
||||
[Learn more about clubs](https://blog.omnivore.app/p/dca38ba4-8a74-42cc-90ca-d5ffa5d075cc)
|
||||
""")
|
||||
.accentColor(.blue)
|
||||
}
|
||||
}
|
||||
do {
|
||||
recommendationGroups = try await dataService.recommendationGroups()
|
||||
} catch {
|
||||
networkError = true
|
||||
}
|
||||
.alert(isPresented: $viewModel.showCreateError) {
|
||||
Alert(
|
||||
title: Text(viewModel.createGroupError ?? "Error creating group"),
|
||||
dismissButton: .cancel(Text(LocalText.genericOk)) {
|
||||
viewModel.createGroupError = nil
|
||||
viewModel.showCreateError = false
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func createGroup(dataService: DataService, name: String) async {
|
||||
isCreating = true
|
||||
|
||||
let group = try? await dataService.createRecommendationGroup(name: name,
|
||||
onlyAdminCanPost: onlyAdminCanPost,
|
||||
onlyAdminCanSeeMembers: onlyAdminCanSeeMembers)
|
||||
|
||||
if group != nil {
|
||||
await loadGroups(dataService: dataService)
|
||||
showCreateSheet = false
|
||||
} else {
|
||||
createGroupError = "Error creating club"
|
||||
showCreateError = true
|
||||
}
|
||||
|
||||
isCreating = false
|
||||
}
|
||||
}
|
||||
|
||||
struct CreateRecommendationGroupView: View {
|
||||
@State var name = ""
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@StateObject var viewModel = RecommendationsGroupsViewModel()
|
||||
|
||||
var nextButton: some View {
|
||||
if viewModel.isCreating {
|
||||
return AnyView(ProgressView())
|
||||
} else {
|
||||
return AnyView(Button(action: {
|
||||
Task {
|
||||
await viewModel.createGroup(dataService: dataService, name: self.name)
|
||||
}
|
||||
}, label: {
|
||||
Text(LocalText.genericNext)
|
||||
})
|
||||
.disabled(name.isEmpty)
|
||||
)
|
||||
}
|
||||
}
|
||||
.navigationViewStyle(.stack)
|
||||
.navigationTitle("Create Club")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarItems(leading:
|
||||
Button(action: {
|
||||
viewModel.showCreateSheet = false
|
||||
}, label: { Text(LocalText.cancelGeneric) }),
|
||||
trailing: nextButton)
|
||||
}
|
||||
}
|
||||
|
||||
struct GroupsView: View {
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@StateObject var viewModel = RecommendationsGroupsViewModel()
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
#if os(iOS)
|
||||
Form {
|
||||
innerBody
|
||||
}
|
||||
#elseif os(macOS)
|
||||
List {
|
||||
innerBody
|
||||
}
|
||||
.listStyle(InsetListStyle())
|
||||
#endif
|
||||
}
|
||||
.sheet(isPresented: $viewModel.showCreateSheet) {
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
CreateRecommendationGroupView(viewModel: self.viewModel)
|
||||
}
|
||||
}
|
||||
.task { await viewModel.loadGroups(dataService: dataService) }
|
||||
.navigationTitle(LocalText.clubsGeneric)
|
||||
}
|
||||
Form {
|
||||
TextField(LocalText.genericName, text: $name, prompt: Text(LocalText.clubsName))
|
||||
|
||||
private var innerBody: some View {
|
||||
Group {
|
||||
Section {
|
||||
Button(
|
||||
action: { viewModel.showCreateSheet = true },
|
||||
label: {
|
||||
HStack {
|
||||
Image(systemName: "plus.circle.fill").foregroundColor(.green)
|
||||
Text(LocalText.clubsCreate)
|
||||
Spacer()
|
||||
Section("Club Rules") {
|
||||
Toggle("Only admins can post", isOn: $viewModel.onlyAdminCanPost)
|
||||
Toggle("Only admins can see members", isOn: $viewModel.onlyAdminCanSeeMembers)
|
||||
}
|
||||
|
||||
Section {
|
||||
Section {
|
||||
Text("""
|
||||
[Learn more about clubs](https://blog.omnivore.app/p/dca38ba4-8a74-42cc-90ca-d5ffa5d075cc)
|
||||
""")
|
||||
.accentColor(.blue)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
.alert(isPresented: $viewModel.showCreateError) {
|
||||
Alert(
|
||||
title: Text(viewModel.createGroupError ?? "Error creating group"),
|
||||
dismissButton: .cancel(Text(LocalText.genericOk)) {
|
||||
viewModel.createGroupError = nil
|
||||
viewModel.showCreateError = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
.navigationViewStyle(.stack)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarItems(leading:
|
||||
Button(action: {
|
||||
viewModel.showCreateSheet = false
|
||||
}, label: { Text(LocalText.cancelGeneric) }),
|
||||
trailing: nextButton)
|
||||
#endif
|
||||
.navigationTitle("Create Club")
|
||||
}
|
||||
}
|
||||
|
||||
if !viewModel.isLoading {
|
||||
if viewModel.recommendationGroups.count > 0 {
|
||||
Section(header: Text(LocalText.clubsYours)) {
|
||||
ForEach(viewModel.recommendationGroups) { recommendationGroup in
|
||||
let vm = RecommendationsGroupViewModel(recommendationGroup: recommendationGroup)
|
||||
NavigationLink(
|
||||
destination: RecommendationGroupView(viewModel: vm)
|
||||
) {
|
||||
Text(recommendationGroup.name)
|
||||
struct GroupsView: View {
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@StateObject var viewModel = RecommendationsGroupsViewModel()
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
#if os(iOS)
|
||||
Form {
|
||||
innerBody
|
||||
}
|
||||
#elseif os(macOS)
|
||||
List {
|
||||
innerBody
|
||||
}
|
||||
.listStyle(InsetListStyle())
|
||||
#endif
|
||||
}
|
||||
.sheet(isPresented: $viewModel.showCreateSheet) {
|
||||
NavigationView {
|
||||
CreateRecommendationGroupView(viewModel: self.viewModel)
|
||||
}
|
||||
}
|
||||
.task { await viewModel.loadGroups(dataService: dataService) }
|
||||
.navigationTitle(LocalText.clubsGeneric)
|
||||
}
|
||||
|
||||
private var innerBody: some View {
|
||||
Group {
|
||||
Section {
|
||||
Button(
|
||||
action: { viewModel.showCreateSheet = true },
|
||||
label: {
|
||||
HStack {
|
||||
Image(systemName: "plus.circle.fill").foregroundColor(.green)
|
||||
Text(LocalText.clubsCreate)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Section {
|
||||
Text(LocalText.clubsNotAMemberMessage)
|
||||
.accentColor(.blue)
|
||||
)
|
||||
}
|
||||
|
||||
if !viewModel.isLoading {
|
||||
if viewModel.recommendationGroups.count > 0 {
|
||||
Section(header: Text(LocalText.clubsYours)) {
|
||||
ForEach(viewModel.recommendationGroups) { recommendationGroup in
|
||||
let viewModel = RecommendationsGroupViewModel(recommendationGroup: recommendationGroup)
|
||||
NavigationLink(
|
||||
destination: RecommendationGroupView(viewModel: viewModel)
|
||||
) {
|
||||
Text(recommendationGroup.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Section {
|
||||
Text(LocalText.clubsNotAMemberMessage)
|
||||
.accentColor(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -46,44 +46,50 @@ struct SubscriptionsView: View {
|
|||
@State private var progressViewOpacity = 0.0
|
||||
|
||||
var body: some View {
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
.opacity(progressViewOpacity)
|
||||
.onAppear {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1000)) {
|
||||
progressViewOpacity = 1
|
||||
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
|
||||
}
|
||||
.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 {
|
||||
|
|
|
|||
|
|
@ -24,9 +24,9 @@
|
|||
|
||||
private var innerBody: some View {
|
||||
ForEach(Voices.Languages, id: \.key.self) { language in
|
||||
Button(action: {
|
||||
Button {
|
||||
audioController.defaultLanguage = language.key
|
||||
}) {
|
||||
} label: {
|
||||
HStack {
|
||||
Text(language.name)
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
import SwiftUI
|
||||
import Views
|
||||
|
||||
// swiftlint:disable line_length
|
||||
struct TextToSpeechView: View {
|
||||
@EnvironmentObject var audioController: AudioController
|
||||
|
||||
|
|
|
|||
|
|
@ -47,6 +47,13 @@
|
|||
}
|
||||
|
||||
if audioController.useUltraRealisticVoices {
|
||||
if showLanguageChanger {
|
||||
Section("Language") {
|
||||
NavigationLink(destination: TextToSpeechLanguageView().navigationTitle("Language")) {
|
||||
Text(audioController.currentVoiceLanguage.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
ultraRealisticVoices
|
||||
} else {
|
||||
if showLanguageChanger {
|
||||
|
|
@ -62,8 +69,10 @@
|
|||
}
|
||||
.navigationTitle("Choose a Voice")
|
||||
.onAppear {
|
||||
// swiftlint:disable:next line_length
|
||||
viewModel.realisticVoicesToggle = (audioController.useUltraRealisticVoices && !audioController.ultraRealisticFeatureKey.isEmpty)
|
||||
}.onChange(of: viewModel.realisticVoicesToggle) { value in
|
||||
}
|
||||
.onChange(of: viewModel.realisticVoicesToggle) { value in
|
||||
if value, audioController.ultraRealisticFeatureKey.isEmpty {
|
||||
// User wants to sign up
|
||||
viewModel.waitingForRealisticVoices = true
|
||||
|
|
@ -92,8 +101,9 @@
|
|||
}
|
||||
|
||||
private var ultraRealisticVoices: some View {
|
||||
ForEach([VoiceCategory.enUS, VoiceCategory.enCA, VoiceCategory.enUK], id: \.self) { category in
|
||||
ForEach([VoiceCategory.enUS], id: \.self) { category in
|
||||
Section(category.rawValue) {
|
||||
// swiftlint:disable:next line_length
|
||||
ForEach(audioController.realisticVoiceList?.filter { $0.category == category } ?? [], id: \.key.self) { voice in
|
||||
voiceRow(for: voice)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,51 +5,53 @@
|
|||
// Created by Jackson Harper on 11/10/22.
|
||||
//
|
||||
|
||||
import CoreData
|
||||
import Foundation
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Views
|
||||
#if os(iOS)
|
||||
import CoreData
|
||||
import Foundation
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Views
|
||||
|
||||
@MainActor final class TextToSpeechVoiceSelectionViewModel: ObservableObject {
|
||||
@Published var playbackSample: String?
|
||||
@Published var realisticVoicesToggle: Bool = false
|
||||
@Published var waitingForRealisticVoices: Bool = false
|
||||
@MainActor final class TextToSpeechVoiceSelectionViewModel: ObservableObject {
|
||||
@Published var playbackSample: String?
|
||||
@Published var realisticVoicesToggle: Bool = false
|
||||
@Published var waitingForRealisticVoices: Bool = false
|
||||
|
||||
func requestUltraRealisticFeatureAccess(
|
||||
dataService: DataService,
|
||||
audioController: AudioController
|
||||
) async {
|
||||
do {
|
||||
let feature = try await dataService.optInFeature(name: "ultra-realistic-voice")
|
||||
DispatchQueue.main.async {
|
||||
if let feature = feature {
|
||||
audioController.useUltraRealisticVoices = true
|
||||
audioController.ultraRealisticFeatureRequested = true
|
||||
audioController.ultraRealisticFeatureKey = feature.granted ? feature.token : ""
|
||||
if feature.granted, !Voices.isUltraRealisticVoice(audioController.currentVoice) {
|
||||
// Attempt to set to an ultra voice
|
||||
if let voice = Voices.UltraPairs.first {
|
||||
audioController.currentVoice = voice.firstKey
|
||||
func requestUltraRealisticFeatureAccess(
|
||||
dataService: DataService,
|
||||
audioController: AudioController
|
||||
) async {
|
||||
do {
|
||||
let feature = try await dataService.optInFeature(name: "ultra-realistic-voice")
|
||||
DispatchQueue.main.async {
|
||||
if let feature = feature {
|
||||
audioController.useUltraRealisticVoices = true
|
||||
audioController.ultraRealisticFeatureRequested = true
|
||||
audioController.ultraRealisticFeatureKey = feature.granted ? feature.token : ""
|
||||
if feature.granted, !Voices.isUltraRealisticVoice(audioController.currentVoice) {
|
||||
// Attempt to set to an ultra voice
|
||||
if let voice = Voices.UltraPairs.first {
|
||||
audioController.currentVoice = voice.firstKey
|
||||
}
|
||||
}
|
||||
self.realisticVoicesToggle = true
|
||||
} else {
|
||||
audioController.useUltraRealisticVoices = false
|
||||
audioController.ultraRealisticFeatureKey = ""
|
||||
audioController.ultraRealisticFeatureRequested = false
|
||||
self.realisticVoicesToggle = false
|
||||
}
|
||||
self.realisticVoicesToggle = true
|
||||
} else {
|
||||
audioController.useUltraRealisticVoices = false
|
||||
audioController.ultraRealisticFeatureKey = ""
|
||||
audioController.ultraRealisticFeatureRequested = false
|
||||
self.realisticVoicesToggle = false
|
||||
self.waitingForRealisticVoices = false
|
||||
}
|
||||
self.waitingForRealisticVoices = false
|
||||
} catch {
|
||||
print("ERROR OPTING INTO FEATURE", error)
|
||||
audioController.useUltraRealisticVoices = false
|
||||
realisticVoicesToggle = false
|
||||
waitingForRealisticVoices = false
|
||||
audioController.ultraRealisticFeatureRequested = false
|
||||
Snackbar.show(message: "Error signing up for beta. Please try again.")
|
||||
}
|
||||
} catch {
|
||||
print("ERROR OPTING INTO FEATURE", error)
|
||||
audioController.useUltraRealisticVoices = false
|
||||
realisticVoicesToggle = false
|
||||
waitingForRealisticVoices = false
|
||||
audioController.ultraRealisticFeatureRequested = false
|
||||
Snackbar.show(message: "Error signing up for beta. Please try again.")
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -1,86 +1,90 @@
|
|||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Utils
|
||||
import Views
|
||||
#if os(iOS)
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Utils
|
||||
import Views
|
||||
|
||||
class SelfHostSettingsViewModel: ObservableObject {
|
||||
@State var showCreateError = false
|
||||
}
|
||||
|
||||
struct SelfHostSettingsView: View {
|
||||
@State var apiServerAddress = ""
|
||||
@State var webServerAddress = ""
|
||||
@State var ttsServerAddress = ""
|
||||
|
||||
@State var showConfirmAlert = false
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@StateObject var viewModel = SelfHostSettingsViewModel()
|
||||
|
||||
var allFieldsSet: Bool {
|
||||
apiServerAddress.count > 0 && webServerAddress.count > 0 && ttsServerAddress.count > 0
|
||||
class SelfHostSettingsViewModel: ObservableObject {
|
||||
@State var showCreateError = false
|
||||
}
|
||||
|
||||
var saveButton: some View {
|
||||
Button(action: {
|
||||
showConfirmAlert = true
|
||||
}, label: {
|
||||
Text(LocalText.genericSave)
|
||||
})
|
||||
.disabled(!allFieldsSet)
|
||||
}
|
||||
struct SelfHostSettingsView: View {
|
||||
@State var apiServerAddress = ""
|
||||
@State var webServerAddress = ""
|
||||
@State var ttsServerAddress = ""
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("API Server Base URL") {
|
||||
TextField("URL", text: $apiServerAddress, prompt: Text("https://api-prod.omnivore.app"))
|
||||
.keyboardType(.URL)
|
||||
}
|
||||
@State var showConfirmAlert = false
|
||||
|
||||
Section("Web Server URL") {
|
||||
TextField("URL", text: $webServerAddress, prompt: Text("https://omnivore.app"))
|
||||
.keyboardType(.URL)
|
||||
}
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
Section("Text-to-speech Server URL") {
|
||||
TextField("URL", text: $ttsServerAddress, prompt: Text("https://tts.omnivore.app"))
|
||||
.keyboardType(.URL)
|
||||
}
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@StateObject var viewModel = SelfHostSettingsViewModel()
|
||||
|
||||
Section {
|
||||
Section {
|
||||
Text("""
|
||||
Omnivore is a free and open-source project and allows self-hosting.
|
||||
|
||||
If you have chosen to deploy your own server instance, fill in the \
|
||||
above fields to connect to your private self-hosted instance.
|
||||
|
||||
[Learn more about self-hosting Omnivore](https://docs.omnivore.app/self-hosting/self-hosting.html)
|
||||
""")
|
||||
.accentColor(.blue)
|
||||
}
|
||||
}
|
||||
var allFieldsSet: Bool {
|
||||
apiServerAddress.count > 0 && webServerAddress.count > 0 && ttsServerAddress.count > 0
|
||||
}
|
||||
.accentColor(.appGrayText)
|
||||
.alert(isPresented: $showConfirmAlert) {
|
||||
Alert(
|
||||
title: Text("Changing your environment settings will close the app."),
|
||||
dismissButton: .cancel(Text(LocalText.genericOk)) {
|
||||
AppEnvironment.setCustom(serverBaseURL: apiServerAddress, webAppBaseURL: webServerAddress, ttsBaseURL: ttsServerAddress)
|
||||
dataService.switchAppEnvironment(appEnvironment: AppEnvironment.custom)
|
||||
}
|
||||
)
|
||||
}
|
||||
.navigationViewStyle(.stack)
|
||||
.navigationTitle("Self-hosting Options")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarItems(leading:
|
||||
|
||||
var saveButton: some View {
|
||||
Button(action: {
|
||||
dismiss()
|
||||
}, label: { Text(LocalText.cancelGeneric) }),
|
||||
trailing: saveButton)
|
||||
showConfirmAlert = true
|
||||
}, label: {
|
||||
Text(LocalText.genericSave)
|
||||
})
|
||||
.disabled(!allFieldsSet)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("API Server Base URL") {
|
||||
TextField("URL", text: $apiServerAddress, prompt: Text("https://api-prod.omnivore.app"))
|
||||
.keyboardType(.URL)
|
||||
}
|
||||
|
||||
Section("Web Server URL") {
|
||||
TextField("URL", text: $webServerAddress, prompt: Text("https://omnivore.app"))
|
||||
.keyboardType(.URL)
|
||||
}
|
||||
|
||||
Section("Text-to-speech Server URL") {
|
||||
TextField("URL", text: $ttsServerAddress, prompt: Text("https://tts.omnivore.app"))
|
||||
.keyboardType(.URL)
|
||||
}
|
||||
|
||||
Section {
|
||||
Section {
|
||||
Text("""
|
||||
Omnivore is a free and open-source project and allows self-hosting.
|
||||
|
||||
If you have chosen to deploy your own server instance, fill in the \
|
||||
above fields to connect to your private self-hosted instance.
|
||||
|
||||
[Learn more about self-hosting Omnivore](https://docs.omnivore.app/self-hosting/self-hosting.html)
|
||||
""")
|
||||
.accentColor(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
.accentColor(.appGrayText)
|
||||
.alert(isPresented: $showConfirmAlert) {
|
||||
Alert(
|
||||
title: Text("Changing your environment settings will close the app."),
|
||||
dismissButton: .cancel(Text(LocalText.genericOk)) {
|
||||
AppEnvironment.setCustom(serverBaseURL: apiServerAddress,
|
||||
webAppBaseURL: webServerAddress,
|
||||
ttsBaseURL: ttsServerAddress)
|
||||
dataService.switchAppEnvironment(appEnvironment: AppEnvironment.custom)
|
||||
}
|
||||
)
|
||||
}
|
||||
.navigationViewStyle(.stack)
|
||||
.navigationTitle("Self-hosting Options")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarItems(leading:
|
||||
Button(action: {
|
||||
dismiss()
|
||||
}, label: { Text(LocalText.cancelGeneric) }),
|
||||
trailing: saveButton)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -4,81 +4,83 @@ import Utils
|
|||
import Views
|
||||
import WebKit
|
||||
|
||||
struct HighlightViewer: PlatformViewRepresentable {
|
||||
let highlightData: HighlightData
|
||||
#if os(iOS)
|
||||
struct HighlightViewer: PlatformViewRepresentable {
|
||||
let highlightData: HighlightData
|
||||
|
||||
func makeCoordinator() -> WebReaderCoordinator {
|
||||
WebReaderCoordinator()
|
||||
}
|
||||
|
||||
private func makePlatformView(context: Context) -> WKWebView {
|
||||
let webView = WebViewManager.shared()
|
||||
let contentController = WKUserContentController()
|
||||
|
||||
webView.navigationDelegate = context.coordinator
|
||||
webView.configuration.userContentController = contentController
|
||||
webView.configuration.userContentController.removeAllScriptMessageHandlers()
|
||||
|
||||
#if os(iOS)
|
||||
webView.isOpaque = false
|
||||
webView.backgroundColor = .clear
|
||||
webView.scrollView.delegate = context.coordinator
|
||||
webView.scrollView.contentInset.top = readerViewNavBarHeight
|
||||
webView.scrollView.verticalScrollIndicatorInsets.top = readerViewNavBarHeight
|
||||
webView.configuration.userContentController.add(webView, name: "viewerAction")
|
||||
#else
|
||||
webView.setValue(false, forKey: "drawsBackground")
|
||||
#endif
|
||||
|
||||
for action in WebViewAction.allCases {
|
||||
webView.configuration.userContentController.add(context.coordinator, name: action.rawValue)
|
||||
func makeCoordinator() -> WebReaderCoordinator {
|
||||
WebReaderCoordinator()
|
||||
}
|
||||
|
||||
webView.configuration.userContentController.addScriptMessageHandler(
|
||||
context.coordinator, contentWorld: .page, name: "articleAction"
|
||||
)
|
||||
private func makePlatformView(context: Context) -> WKWebView {
|
||||
let webView = WebViewManager.shared()
|
||||
let contentController = WKUserContentController()
|
||||
|
||||
loadContent(webView: webView)
|
||||
webView.navigationDelegate = context.coordinator
|
||||
webView.configuration.userContentController = contentController
|
||||
webView.configuration.userContentController.removeAllScriptMessageHandlers()
|
||||
|
||||
return webView
|
||||
}
|
||||
#if os(iOS)
|
||||
webView.isOpaque = false
|
||||
webView.backgroundColor = .clear
|
||||
webView.scrollView.delegate = context.coordinator
|
||||
webView.scrollView.contentInset.top = readerViewNavBarHeight
|
||||
webView.scrollView.verticalScrollIndicatorInsets.top = readerViewNavBarHeight
|
||||
webView.configuration.userContentController.add(webView, name: "viewerAction")
|
||||
#else
|
||||
webView.setValue(false, forKey: "drawsBackground")
|
||||
#endif
|
||||
|
||||
private func updatePlatformView(_: WKWebView, context _: Context) {
|
||||
// If the webview had been terminated `needsReload` will have been set to true
|
||||
// Or if the articleContent value has changed then it's id will be different from the coordinator's
|
||||
for action in WebViewAction.allCases {
|
||||
webView.configuration.userContentController.add(context.coordinator, name: action.rawValue)
|
||||
}
|
||||
|
||||
webView.configuration.userContentController.addScriptMessageHandler(
|
||||
context.coordinator, contentWorld: .page, name: "articleAction"
|
||||
)
|
||||
|
||||
loadContent(webView: webView)
|
||||
|
||||
return webView
|
||||
}
|
||||
|
||||
private func updatePlatformView(_: WKWebView, context _: Context) {
|
||||
// If the webview had been terminated `needsReload` will have been set to true
|
||||
// Or if the articleContent value has changed then it's id will be different from the coordinator's
|
||||
// if context.coordinator.needsReload {
|
||||
// loadContent(webView: webView)
|
||||
// context.coordinator.needsReload = false
|
||||
// return
|
||||
// }
|
||||
}
|
||||
|
||||
private func loadContent(webView: WKWebView) {
|
||||
// swiftlint:disable line_length
|
||||
let themeKey = ThemeManager.currentThemeName
|
||||
let content = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no' />
|
||||
<style>
|
||||
@import url("highlight\(themeKey == "Gray" ? "-dark" : "").css");
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root" />
|
||||
<div id='_omnivore-highlight' class="highlight">
|
||||
\(highlightData.highlightHTML)
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
// swiftlint:enable line_length
|
||||
|
||||
webView.loadHTMLString(content, baseURL: ViewsPackage.resourceURL)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadContent(webView: WKWebView) {
|
||||
let themeKey = ThemeManager.currentThemeName
|
||||
let content = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no' />
|
||||
<style>
|
||||
@import url("highlight\(themeKey == "Gray" ? "-dark" : "").css");
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root" />
|
||||
<div id='_omnivore-highlight' class="highlight">
|
||||
\(highlightData.highlightHTML)
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
webView.loadHTMLString(content, baseURL: ViewsPackage.resourceURL)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
extension HighlightViewer {
|
||||
func makeUIView(context: Context) -> WKWebView {
|
||||
makePlatformView(context: context)
|
||||
|
|
@ -88,14 +90,4 @@ struct HighlightViewer: PlatformViewRepresentable {
|
|||
updatePlatformView(webView, context: context)
|
||||
}
|
||||
}
|
||||
#else
|
||||
extension WebReader {
|
||||
func makeNSView(context: Context) -> WKWebView {
|
||||
makePlatformView(context: context)
|
||||
}
|
||||
|
||||
func updateNSView(_ webView: WKWebView, context: Context) {
|
||||
updatePlatformView(webView, context: context)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -1,218 +1,220 @@
|
|||
import CoreData
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Views
|
||||
#if os(iOS)
|
||||
import CoreData
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Views
|
||||
|
||||
@MainActor final class RecommendToViewModel: ObservableObject {
|
||||
@Published var isLoading = false
|
||||
@Published var networkError = false
|
||||
@Published var recommendationGroups = [InternalRecommendationGroup]()
|
||||
@Published var selectedGroups = [InternalRecommendationGroup]()
|
||||
@Published var isRunning = false
|
||||
@Published var showError = false
|
||||
@Published var showNoteView = false
|
||||
@Published var note: String = ""
|
||||
@Published var withHighlights: Bool = true
|
||||
@MainActor final class RecommendToViewModel: ObservableObject {
|
||||
@Published var isLoading = false
|
||||
@Published var networkError = false
|
||||
@Published var recommendationGroups = [InternalRecommendationGroup]()
|
||||
@Published var selectedGroups = [InternalRecommendationGroup]()
|
||||
@Published var isRunning = false
|
||||
@Published var showError = false
|
||||
@Published var showNoteView = false
|
||||
@Published var note: String = ""
|
||||
@Published var withHighlights: Bool = true
|
||||
|
||||
let pageID: String
|
||||
let highlightCount: Int
|
||||
let pageID: String
|
||||
let highlightCount: Int
|
||||
|
||||
init(pageID: String, highlightCount: Int) {
|
||||
self.pageID = pageID
|
||||
self.highlightCount = highlightCount
|
||||
}
|
||||
|
||||
func loadGroups(dataService: DataService) async {
|
||||
isLoading = true
|
||||
|
||||
do {
|
||||
dataService.viewContext.performAndWait {
|
||||
let fetchRequest: NSFetchRequest<Models.RecommendationGroup> = RecommendationGroup.fetchRequest()
|
||||
let sort = NSSortDescriptor(key: #keyPath(RecommendationGroup.name), ascending: true)
|
||||
fetchRequest.predicate = NSPredicate(format: "canPost == %@", NSNumber(value: true))
|
||||
fetchRequest.sortDescriptors = [sort]
|
||||
|
||||
// If this fails we will fallback to making the API call
|
||||
let groups = try? dataService.viewContext.fetch(fetchRequest).compactMap { object in
|
||||
InternalRecommendationGroup.make(from: object)
|
||||
}
|
||||
if let groups = groups {
|
||||
self.recommendationGroups = groups
|
||||
}
|
||||
}
|
||||
recommendationGroups = try await dataService.recommendationGroups()
|
||||
.filter(\.canPost)
|
||||
.sorted(by: { $0.name < $1.name })
|
||||
|
||||
} catch {
|
||||
print("ERROR fetching recommendationGroups: ", error)
|
||||
networkError = true
|
||||
init(pageID: String, highlightCount: Int) {
|
||||
self.pageID = pageID
|
||||
self.highlightCount = highlightCount
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
func loadGroups(dataService: DataService) async {
|
||||
isLoading = true
|
||||
|
||||
func recommend(dataService: DataService) async -> Bool {
|
||||
isRunning = true
|
||||
defer { isRunning = false }
|
||||
do {
|
||||
dataService.viewContext.performAndWait {
|
||||
let fetchRequest: NSFetchRequest<Models.RecommendationGroup> = RecommendationGroup.fetchRequest()
|
||||
let sort = NSSortDescriptor(key: #keyPath(RecommendationGroup.name), ascending: true)
|
||||
fetchRequest.predicate = NSPredicate(format: "canPost == %@", NSNumber(value: true))
|
||||
fetchRequest.sortDescriptors = [sort]
|
||||
|
||||
do {
|
||||
try await dataService.recommendPage(pageID: pageID,
|
||||
groupIDs: selectedGroups.map(\.id),
|
||||
note: note.isEmpty ? nil : note,
|
||||
withHighlights: withHighlights)
|
||||
} catch {
|
||||
showError = true
|
||||
return false
|
||||
}
|
||||
|
||||
isRunning = false
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
struct RecommendToView: View {
|
||||
var dataService: DataService
|
||||
@StateObject var viewModel: RecommendToViewModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var nextButton: some View {
|
||||
Button(action: {
|
||||
self.viewModel.showNoteView = true
|
||||
}, label: {
|
||||
Text(LocalText.genericNext)
|
||||
.bold()
|
||||
})
|
||||
.disabled(viewModel.selectedGroups.isEmpty)
|
||||
}
|
||||
|
||||
var sendButton: some View {
|
||||
if viewModel.isRunning {
|
||||
return AnyView(ProgressView())
|
||||
} else {
|
||||
return AnyView(Button(action: {
|
||||
Task {
|
||||
if await viewModel.recommend(dataService: dataService) {
|
||||
Snackbar.show(message: "Recommendation sent")
|
||||
dismiss()
|
||||
// If this fails we will fallback to making the API call
|
||||
let groups = try? dataService.viewContext.fetch(fetchRequest).compactMap { object in
|
||||
InternalRecommendationGroup.make(from: object)
|
||||
}
|
||||
if let groups = groups {
|
||||
self.recommendationGroups = groups
|
||||
}
|
||||
}
|
||||
recommendationGroups = try await dataService.recommendationGroups()
|
||||
.filter(\.canPost)
|
||||
.sorted(by: { $0.name < $1.name })
|
||||
|
||||
} catch {
|
||||
print("ERROR fetching recommendationGroups: ", error)
|
||||
networkError = true
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func recommend(dataService: DataService) async -> Bool {
|
||||
isRunning = true
|
||||
defer { isRunning = false }
|
||||
|
||||
do {
|
||||
try await dataService.recommendPage(pageID: pageID,
|
||||
groupIDs: selectedGroups.map(\.id),
|
||||
note: note.isEmpty ? nil : note,
|
||||
withHighlights: withHighlights)
|
||||
} catch {
|
||||
showError = true
|
||||
return false
|
||||
}
|
||||
|
||||
isRunning = false
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
struct RecommendToView: View {
|
||||
var dataService: DataService
|
||||
@StateObject var viewModel: RecommendToViewModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var nextButton: some View {
|
||||
Button(action: {
|
||||
self.viewModel.showNoteView = true
|
||||
}, label: {
|
||||
Text(LocalText.genericSend)
|
||||
Text(LocalText.genericNext)
|
||||
.bold()
|
||||
})
|
||||
.disabled(viewModel.selectedGroups.isEmpty)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var noteView: some View {
|
||||
VStack {
|
||||
HStack {
|
||||
Text(LocalText.recommendationToPrefix)
|
||||
.font(.appCaption)
|
||||
.foregroundColor(.appGrayText)
|
||||
Text(InternalRecommendationGroup.readable(list: viewModel.selectedGroups))
|
||||
.font(.appCaption)
|
||||
var sendButton: some View {
|
||||
if viewModel.isRunning {
|
||||
return AnyView(ProgressView())
|
||||
} else {
|
||||
return AnyView(Button(action: {
|
||||
Task {
|
||||
if await viewModel.recommend(dataService: dataService) {
|
||||
Snackbar.show(message: "Recommendation sent")
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}, label: {
|
||||
Text(LocalText.genericSend)
|
||||
.bold()
|
||||
})
|
||||
.disabled(viewModel.selectedGroups.isEmpty)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var noteView: some View {
|
||||
VStack {
|
||||
HStack {
|
||||
Text(LocalText.recommendationToPrefix)
|
||||
.font(.appCaption)
|
||||
.foregroundColor(.appGrayText)
|
||||
Text(InternalRecommendationGroup.readable(list: viewModel.selectedGroups))
|
||||
.font(.appCaption)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
Spacer()
|
||||
}
|
||||
TextEditor(text: $viewModel.note)
|
||||
.lineSpacing(6)
|
||||
.accentColor(.appGraySolid)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.font(.appBody)
|
||||
.padding(12)
|
||||
.frame(height: 200)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.strokeBorder(Color.appGrayBorder, lineWidth: 1)
|
||||
.background(RoundedRectangle(cornerRadius: 8).fill(Color.systemBackground))
|
||||
)
|
||||
.overlay(
|
||||
Text(LocalText.recommendationAddNote)
|
||||
.allowsHitTesting(false)
|
||||
.opacity(viewModel.note.isEmpty ? 0.4 : 0.0)
|
||||
.font(.appBody)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.padding(.top, 24)
|
||||
.padding(.leading, 16)
|
||||
)
|
||||
if viewModel.highlightCount > 0 {
|
||||
Toggle(isOn: $viewModel.withHighlights, label: {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text("Include your \(viewModel.highlightCount) highlight\(viewModel.highlightCount > 1 ? "s" : "")")
|
||||
}
|
||||
})
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
TextEditor(text: $viewModel.note)
|
||||
.lineSpacing(6)
|
||||
.accentColor(.appGraySolid)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.font(.appBody)
|
||||
.padding(12)
|
||||
.frame(height: 200)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.strokeBorder(Color.appGrayBorder, lineWidth: 1)
|
||||
.background(RoundedRectangle(cornerRadius: 8).fill(Color.systemBackground))
|
||||
)
|
||||
.overlay(
|
||||
Text(LocalText.recommendationAddNote)
|
||||
.allowsHitTesting(false)
|
||||
.opacity(viewModel.note.isEmpty ? 0.4 : 0.0)
|
||||
.font(.appBody)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.padding(.top, 24)
|
||||
.padding(.leading, 16)
|
||||
)
|
||||
if viewModel.highlightCount > 0 {
|
||||
Toggle(isOn: $viewModel.withHighlights, label: {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text("Include your \(viewModel.highlightCount) highlight\(viewModel.highlightCount > 1 ? "s" : "")")
|
||||
}
|
||||
})
|
||||
}
|
||||
Spacer()
|
||||
.padding(16)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationViewStyle(.stack)
|
||||
.navigationBarItems(trailing: sendButton)
|
||||
}
|
||||
.padding(16)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationViewStyle(.stack)
|
||||
.navigationBarItems(trailing: sendButton)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
NavigationLink(destination: noteView,
|
||||
isActive: $viewModel.showNoteView) {
|
||||
EmptyView()
|
||||
}
|
||||
List {
|
||||
if !viewModel.isLoading, viewModel.recommendationGroups.count < 1 {
|
||||
Text("""
|
||||
\(LocalText.clubsNoneJoined)
|
||||
var body: some View {
|
||||
VStack {
|
||||
NavigationLink(destination: noteView,
|
||||
isActive: $viewModel.showNoteView) {
|
||||
EmptyView()
|
||||
}
|
||||
List {
|
||||
if !viewModel.isLoading, viewModel.recommendationGroups.count < 1 {
|
||||
Text("""
|
||||
\(LocalText.clubsNoneJoined)
|
||||
|
||||
[Learn more about clubs](https://blog.omnivore.app/p/dca38ba4-8a74-42cc-90ca-d5ffa5d075cc)
|
||||
""")
|
||||
.accentColor(.blue)
|
||||
} else {
|
||||
Section("Select clubs to recommend to") {
|
||||
ForEach(viewModel.recommendationGroups) { group in
|
||||
HStack {
|
||||
Text(group.name)
|
||||
[Learn more about clubs](https://blog.omnivore.app/p/dca38ba4-8a74-42cc-90ca-d5ffa5d075cc)
|
||||
""")
|
||||
.accentColor(.blue)
|
||||
} else {
|
||||
Section("Select clubs to recommend to") {
|
||||
ForEach(viewModel.recommendationGroups) { group in
|
||||
HStack {
|
||||
Text(group.name)
|
||||
|
||||
Spacer()
|
||||
Spacer()
|
||||
|
||||
if viewModel.selectedGroups.contains(where: { $0.id == group.id }) {
|
||||
Image(systemName: "checkmark")
|
||||
if viewModel.selectedGroups.contains(where: { $0.id == group.id }) {
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
let idx = viewModel.selectedGroups.firstIndex(where: { $0.id == group.id })
|
||||
if let idx = idx {
|
||||
viewModel.selectedGroups.remove(at: idx)
|
||||
} else {
|
||||
viewModel.selectedGroups.append(group)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
let idx = viewModel.selectedGroups.firstIndex(where: { $0.id == group.id })
|
||||
if let idx = idx {
|
||||
viewModel.selectedGroups.remove(at: idx)
|
||||
} else {
|
||||
viewModel.selectedGroups.append(group)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.grouped)
|
||||
.listStyle(.grouped)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.alert(isPresented: $viewModel.showError) {
|
||||
Alert(
|
||||
title: Text(LocalText.recommendationError),
|
||||
dismissButton: .cancel(Text(LocalText.genericOk)) {
|
||||
viewModel.showError = false
|
||||
}
|
||||
)
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationViewStyle(.stack)
|
||||
.navigationBarItems(leading: Button(action: {
|
||||
dismiss()
|
||||
}, label: { Text(LocalText.cancelGeneric) }),
|
||||
trailing: nextButton)
|
||||
.task {
|
||||
await viewModel.loadGroups(dataService: dataService)
|
||||
Spacer()
|
||||
}
|
||||
.alert(isPresented: $viewModel.showError) {
|
||||
Alert(
|
||||
title: Text(LocalText.recommendationError),
|
||||
dismissButton: .cancel(Text(LocalText.genericOk)) {
|
||||
viewModel.showError = false
|
||||
}
|
||||
)
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationViewStyle(.stack)
|
||||
.navigationBarItems(leading: Button(action: {
|
||||
dismiss()
|
||||
}, label: { Text(LocalText.cancelGeneric) }),
|
||||
trailing: nextButton)
|
||||
.task {
|
||||
await viewModel.loadGroups(dataService: dataService)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ struct WebReader: PlatformViewRepresentable {
|
|||
let item: LinkedItem
|
||||
let articleContent: ArticleContent
|
||||
let openLinkAction: (URL) -> Void
|
||||
let tapHandler: () -> Void
|
||||
let scrollPercentHandler: (Int) -> Void
|
||||
let webViewActionHandler: (WKScriptMessage, WKScriptMessageReplyHandler?) -> Void
|
||||
let navBarVisibilityRatioUpdater: (Double) -> Void
|
||||
|
||||
|
|
@ -46,13 +48,14 @@ struct WebReader: PlatformViewRepresentable {
|
|||
let webView = WebViewManager.shared()
|
||||
let contentController = WKUserContentController()
|
||||
|
||||
webView.tapHandler = tapHandler
|
||||
webView.navigationDelegate = context.coordinator
|
||||
webView.configuration.userContentController = contentController
|
||||
webView.configuration.userContentController.removeAllScriptMessageHandlers()
|
||||
|
||||
#if os(iOS)
|
||||
webView.isOpaque = false
|
||||
webView.backgroundColor = .clear
|
||||
webView.backgroundColor = Color.isDarkMode ? .systemBackground : .white
|
||||
webView.scrollView.delegate = context.coordinator
|
||||
webView.scrollView.contentInset.top = readerViewNavBarHeight
|
||||
webView.scrollView.verticalScrollIndicatorInsets.top = readerViewNavBarHeight
|
||||
|
|
@ -72,6 +75,7 @@ struct WebReader: PlatformViewRepresentable {
|
|||
context.coordinator.linkHandler = openLinkAction
|
||||
context.coordinator.webViewActionHandler = webViewActionHandler
|
||||
context.coordinator.updateNavBarVisibilityRatio = navBarVisibilityRatioUpdater
|
||||
context.coordinator.scrollPercentHandler = scrollPercentHandler
|
||||
context.coordinator.updateShowBottomBar = { newValue in
|
||||
self.showBottomBar = newValue
|
||||
}
|
||||
|
|
@ -98,9 +102,11 @@ struct WebReader: PlatformViewRepresentable {
|
|||
(webView as? OmnivoreWebView)?.updateFontFamily()
|
||||
(webView as? OmnivoreWebView)?.updateFontSize()
|
||||
(webView as? OmnivoreWebView)?.updateTextContrast()
|
||||
(webView as? OmnivoreWebView)?.updateAutoHighlightMode()
|
||||
(webView as? OmnivoreWebView)?.updateMaxWidthPercentage()
|
||||
(webView as? OmnivoreWebView)?.updateLineHeight()
|
||||
(webView as? OmnivoreWebView)?.updateLabels(labelsJSON: item.labelsJSONString)
|
||||
(webView as? OmnivoreWebView)?.updateTitle(title: item.title ?? "")
|
||||
}
|
||||
|
||||
if showNavBarActionID != context.coordinator.previousShowNavBarActionID {
|
||||
|
|
@ -149,6 +155,15 @@ struct WebReader: PlatformViewRepresentable {
|
|||
}
|
||||
}()
|
||||
|
||||
let enableHighlightOnRelease: Bool = {
|
||||
let key = UserDefaultKey.enableHighlightOnRelease.rawValue
|
||||
if UserDefaults.standard.object(forKey: key) != nil {
|
||||
return UserDefaults.standard.bool(forKey: key)
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}()
|
||||
|
||||
let fontFamily = fontFamilyValue.flatMap { WebFont(rawValue: $0) } ?? .system
|
||||
|
||||
let htmlString = WebReaderContent(
|
||||
|
|
@ -159,7 +174,8 @@ struct WebReader: PlatformViewRepresentable {
|
|||
lineHeight: lineHeight(),
|
||||
maxWidthPercentage: maxWidthPercentage(),
|
||||
fontFamily: fontFamily,
|
||||
prefersHighContrastText: prefersHighContrastText
|
||||
prefersHighContrastText: prefersHighContrastText,
|
||||
enableHighlightOnRelease: enableHighlightOnRelease
|
||||
)
|
||||
.styledContent
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import Utils
|
|||
import Views
|
||||
import WebKit
|
||||
|
||||
// swiftlint:disable:next type_body_length
|
||||
// swiftlint:disable file_length type_body_length
|
||||
struct WebReaderContainerView: View {
|
||||
let item: LinkedItem
|
||||
|
||||
|
|
@ -30,6 +30,7 @@ struct WebReaderContainerView: View {
|
|||
@State private var errorAlertMessage: String?
|
||||
@State private var showErrorAlertMessage = false
|
||||
@State private var showRecommendSheet = false
|
||||
@State private var lastScrollPercentage: Int?
|
||||
|
||||
@State var safariWebLink: SafariWebLink?
|
||||
@State var displayLinkSheet = false
|
||||
|
|
@ -56,6 +57,10 @@ struct WebReaderContainerView: View {
|
|||
}
|
||||
}
|
||||
|
||||
func scrollPercentHandler(percent: Int) {
|
||||
lastScrollPercentage = percent
|
||||
}
|
||||
|
||||
func onHighlightListViewDismissal() {
|
||||
// Reload the web view if mutation happened in highlights list modal
|
||||
guard hasPerformedHighlightMutations else { return }
|
||||
|
|
@ -73,6 +78,14 @@ struct WebReaderContainerView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private func tapHandler() {
|
||||
withAnimation(.easeIn(duration: 0.08)) {
|
||||
navBarVisibilityRatio = navBarVisibilityRatio == 1 ? 0 : 1
|
||||
showBottomBar = navBarVisibilityRatio == 1
|
||||
showNavBarActionID = UUID()
|
||||
}
|
||||
}
|
||||
|
||||
private func handleHighlightAction(message: WKScriptMessage) {
|
||||
guard let messageBody = message.body as? [String: String] else { return }
|
||||
guard let actionID = messageBody["actionID"] else { return }
|
||||
|
|
@ -89,6 +102,12 @@ struct WebReaderContainerView: View {
|
|||
case "setHighlightLabels":
|
||||
annotation = messageBody["highlightID"] ?? ""
|
||||
showHighlightLabelsModal = true
|
||||
case "pageTapped":
|
||||
withAnimation {
|
||||
navBarVisibilityRatio = navBarVisibilityRatio == 1 ? 0 : 1
|
||||
showBottomBar = navBarVisibilityRatio == 1
|
||||
showNavBarActionID = UUID()
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
|
@ -160,7 +179,7 @@ struct WebReaderContainerView: View {
|
|||
Image(systemName: "sparkles")
|
||||
}).frame(width: 48, height: 48)
|
||||
|
||||
// TODO: We don't have a single note function yet
|
||||
// We don't have a single note function yet
|
||||
// Divider()
|
||||
//
|
||||
// Button(action: addNote, label: {
|
||||
|
|
@ -176,6 +195,7 @@ struct WebReaderContainerView: View {
|
|||
viewModel.downloadAudio(audioController: audioController, item: item)
|
||||
},
|
||||
label: {
|
||||
// swiftlint:disable:next line_length
|
||||
Label(viewModel.isDownloadingAudio ? "Downloading Audio" : "Download Audio", systemImage: "icloud.and.arrow.down")
|
||||
}
|
||||
)
|
||||
|
|
@ -249,7 +269,7 @@ struct WebReaderContainerView: View {
|
|||
Image(systemName: "chevron.backward")
|
||||
.font(.appNavbarIcon)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.padding(.horizontal)
|
||||
.padding()
|
||||
}
|
||||
)
|
||||
.scaleEffect(navBarVisibilityRatio)
|
||||
|
|
@ -274,9 +294,13 @@ struct WebReaderContainerView: View {
|
|||
},
|
||||
label: {
|
||||
#if os(iOS)
|
||||
Image.profile
|
||||
.padding(.horizontal)
|
||||
Image(systemName: "ellipsis")
|
||||
.resizable(resizingMode: Image.ResizingMode.stretch)
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.frame(width: 20, height: 20)
|
||||
.scaleEffect(navBarVisibilityRatio)
|
||||
.padding()
|
||||
#else
|
||||
Text(LocalText.genericOptions)
|
||||
#endif
|
||||
|
|
@ -285,6 +309,9 @@ struct WebReaderContainerView: View {
|
|||
#if os(macOS)
|
||||
.frame(maxWidth: 100)
|
||||
.padding(.trailing, 16)
|
||||
#else
|
||||
.padding(.trailing, 3)
|
||||
.padding(.bottom, 10)
|
||||
#endif
|
||||
}
|
||||
.frame(height: readerViewNavBarHeight * navBarVisibilityRatio)
|
||||
|
|
@ -309,14 +336,20 @@ struct WebReaderContainerView: View {
|
|||
})
|
||||
}
|
||||
.sheet(isPresented: $showTitleEdit) {
|
||||
LinkedItemMetadataEditView(item: item)
|
||||
}
|
||||
.sheet(isPresented: $showHighlightsView, onDismiss: onHighlightListViewDismissal) {
|
||||
HighlightsListView(
|
||||
itemObjectID: item.objectID,
|
||||
hasHighlightMutations: $hasPerformedHighlightMutations
|
||||
)
|
||||
LinkedItemMetadataEditView(item: item, onSave: { title, _ in
|
||||
item.title = title
|
||||
// We dont need to update description because its never rendered in this view
|
||||
readerSettingsChangedTransactionID = UUID()
|
||||
})
|
||||
}
|
||||
#if os(iOS)
|
||||
.sheet(isPresented: $showHighlightsView, onDismiss: onHighlightListViewDismissal) {
|
||||
HighlightsListView(
|
||||
itemObjectID: item.objectID,
|
||||
hasHighlightMutations: $hasPerformedHighlightMutations
|
||||
)
|
||||
}
|
||||
#endif
|
||||
#if os(macOS)
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
#endif
|
||||
|
|
@ -349,6 +382,8 @@ struct WebReaderContainerView: View {
|
|||
}
|
||||
#endif
|
||||
},
|
||||
tapHandler: tapHandler,
|
||||
scrollPercentHandler: scrollPercentHandler,
|
||||
webViewActionHandler: webViewActionHandler,
|
||||
navBarVisibilityRatioUpdater: {
|
||||
navBarVisibilityRatio = $0
|
||||
|
|
@ -361,11 +396,22 @@ struct WebReaderContainerView: View {
|
|||
showBottomBar: $showBottomBar,
|
||||
showHighlightAnnotationModal: $showHighlightAnnotationModal
|
||||
)
|
||||
.onTapGesture {
|
||||
withAnimation {
|
||||
navBarVisibilityRatio = 1
|
||||
showNavBarActionID = UUID()
|
||||
.onAppear {
|
||||
if item.isUnread {
|
||||
dataService.updateLinkReadingProgress(itemID: item.unwrappedID, readingProgress: 0.1, anchorIndex: 0)
|
||||
}
|
||||
Task {
|
||||
await audioController.preload(itemIDs: [item.unwrappedID])
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
// if let lastScrollPercentage = self.lastScrollPercentage {
|
||||
// dataService.updateLinkReadingProgress(
|
||||
// itemID: item.unwrappedID,
|
||||
// readingProgress: Double(lastScrollPercentage),
|
||||
// anchorIndex: 0
|
||||
// )
|
||||
// }
|
||||
}
|
||||
.confirmationDialog(linkToOpen?.absoluteString ?? "", isPresented: $displayLinkSheet) {
|
||||
Button(action: {
|
||||
|
|
@ -374,7 +420,11 @@ struct WebReaderContainerView: View {
|
|||
}
|
||||
}, label: { Text(LocalText.genericOpen) })
|
||||
Button(action: {
|
||||
UIPasteboard.general.string = item.unwrappedPageURLString
|
||||
#if os(iOS)
|
||||
UIPasteboard.general.string = item.unwrappedPageURLString
|
||||
#else
|
||||
// Pasteboard.general.string = item.unwrappedPageURLString TODO: fix for mac
|
||||
#endif
|
||||
showInSnackbar("Link Copied")
|
||||
}, label: { Text(LocalText.readerCopyLink) })
|
||||
Button(action: {
|
||||
|
|
@ -394,18 +444,21 @@ struct WebReaderContainerView: View {
|
|||
showErrorAlertMessage = false
|
||||
})
|
||||
}
|
||||
.formSheet(isPresented: $showRecommendSheet) {
|
||||
let highlightCount = item.highlights.asArray(of: Highlight.self).filter(\.createdByMe).count
|
||||
NavigationView {
|
||||
RecommendToView(
|
||||
dataService: dataService,
|
||||
viewModel: RecommendToViewModel(pageID: item.unwrappedID,
|
||||
highlightCount: highlightCount)
|
||||
)
|
||||
}.onDisappear {
|
||||
showRecommendSheet = false
|
||||
#if os(iOS)
|
||||
.formSheet(isPresented: $showRecommendSheet) {
|
||||
let highlightCount = item.highlights.asArray(of: Highlight.self).filter(\.createdByMe).count
|
||||
|
||||
NavigationView {
|
||||
RecommendToView(
|
||||
dataService: dataService,
|
||||
viewModel: RecommendToViewModel(pageID: item.unwrappedID,
|
||||
highlightCount: highlightCount)
|
||||
)
|
||||
}.onDisappear {
|
||||
showRecommendSheet = false
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
.sheet(isPresented: $showHighlightAnnotationModal) {
|
||||
HighlightAnnotationSheet(
|
||||
annotation: $annotation,
|
||||
|
|
@ -481,9 +534,12 @@ struct WebReaderContainerView: View {
|
|||
readerSettingsChangedTransactionID = UUID()
|
||||
}
|
||||
#endif
|
||||
.onAppear {
|
||||
try? WebViewManager.shared().dispatchEvent(.saveReadPosition)
|
||||
}
|
||||
.onDisappear {
|
||||
// Clear the shared webview content when exiting
|
||||
WebViewManager.shared().loadHTMLString("<html></html>", baseURL: nil)
|
||||
// WebViewManager.shared().loadHTMLString("<html></html>", baseURL: nil)
|
||||
WebViewManager.shared().loadHTMLString(WebReaderContent.emptyContent(isDark: Color.isDarkMode), baseURL: nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -514,10 +570,11 @@ struct WebReaderContainerView: View {
|
|||
func scrollToTop() {}
|
||||
|
||||
func openOriginalURL(urlString: String?) {
|
||||
if let urlString = urlString,
|
||||
let url = URL(string: urlString)
|
||||
{
|
||||
openURL(url)
|
||||
}
|
||||
guard
|
||||
let urlString = urlString,
|
||||
let url = URL(string: urlString)
|
||||
else { return }
|
||||
|
||||
openURL(url)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,10 +8,12 @@ struct WebReaderContent {
|
|||
let lineHeight: Int
|
||||
let maxWidthPercentage: Int
|
||||
let item: LinkedItem
|
||||
let isDark: Bool
|
||||
let themeKey: String
|
||||
let fontFamily: WebFont
|
||||
let articleContent: ArticleContent
|
||||
let prefersHighContrastText: Bool
|
||||
let enableHighlightOnRelease: Bool
|
||||
|
||||
init(
|
||||
item: LinkedItem,
|
||||
|
|
@ -21,16 +23,19 @@ struct WebReaderContent {
|
|||
lineHeight: Int,
|
||||
maxWidthPercentage: Int,
|
||||
fontFamily: WebFont,
|
||||
prefersHighContrastText: Bool
|
||||
prefersHighContrastText: Bool,
|
||||
enableHighlightOnRelease: Bool
|
||||
) {
|
||||
self.textFontSize = fontSize
|
||||
self.lineHeight = lineHeight
|
||||
self.maxWidthPercentage = maxWidthPercentage
|
||||
self.item = item
|
||||
self.themeKey = isDark ? "Gray" : "LightGray"
|
||||
self.isDark = isDark
|
||||
self.themeKey = isDark ? "Dark" : "Light"
|
||||
self.fontFamily = fontFamily
|
||||
self.articleContent = articleContent
|
||||
self.prefersHighContrastText = prefersHighContrastText
|
||||
self.enableHighlightOnRelease = enableHighlightOnRelease
|
||||
}
|
||||
|
||||
// swiftlint:disable line_length
|
||||
|
|
@ -46,7 +51,7 @@ struct WebReaderContent {
|
|||
<meta charset="utf-8" />
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no' />
|
||||
<style>
|
||||
@import url("highlight\(themeKey == "Gray" ? "-dark" : "").css");
|
||||
@import url("highlight\(isDark ? "-dark" : "").css");
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -81,13 +86,80 @@ struct WebReaderContent {
|
|||
recommendations: \(item.recommendationsJSONString),
|
||||
}
|
||||
|
||||
window.themeKey = "\(themeKey)"
|
||||
window.fontSize = \(textFontSize)
|
||||
window.fontFamily = "\(fontFamily.rawValue)"
|
||||
window.maxWidthPercentage = \(maxWidthPercentage)
|
||||
window.lineHeight = \(lineHeight)
|
||||
window.localStorage.setItem("theme", "\(themeKey)")
|
||||
window.prefersHighContrastFont = \(prefersHighContrastText)
|
||||
window.enableHighlightBar = \(isMacApp)
|
||||
window.highlightOnRelease = \(enableHighlightOnRelease)
|
||||
</script>
|
||||
<script src="bundle.js"></script>
|
||||
<script src="mathJaxConfiguration.js" id="MathJax-script"></script>
|
||||
<script src="mathjax.js" id="MathJax-script"></script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
}
|
||||
|
||||
// swiftlint:disable line_length function_body_length
|
||||
static func emptyContent(isDark: Bool) -> String {
|
||||
let themeKey = isDark ? "Dark" : "Light"
|
||||
// let savedAt = "new Date(\(item.unwrappedSavedAt.timeIntervalSince1970 * 1000)).toISOString()"
|
||||
// let createdAt = "new Date(\(item.unwrappedCreatedAt.timeIntervalSince1970 * 1000)).toISOString()"
|
||||
// let publishedAt = item.publishDate != nil ? "new Date(\(item.publishDate!.timeIntervalSince1970 * 1000)).toISOString()" : "undefined"
|
||||
|
||||
return """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no' />
|
||||
<style>
|
||||
@import url("highlight\(isDark ? "-dark" : "").css");
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root" />
|
||||
<div id='_omnivore-htmlContent' style="display: none;">
|
||||
|
||||
</div>
|
||||
<script type="text/javascript">
|
||||
window.omnivoreEnv = {
|
||||
"NEXT_PUBLIC_APP_ENV": "prod",
|
||||
"NEXT_PUBLIC_BASE_URL": "unset",
|
||||
"NEXT_PUBLIC_SERVER_BASE_URL": "unset",
|
||||
"NEXT_PUBLIC_HIGHLIGHTS_BASE_URL": "unset"
|
||||
}
|
||||
|
||||
window.omnivoreArticle = {
|
||||
id: "-1",
|
||||
linkId: "-1",
|
||||
slug: "none",
|
||||
createdAt: new Date(),
|
||||
savedAt: new Date(),
|
||||
publishedAt: new Date(),
|
||||
url: ``,
|
||||
title: ``,
|
||||
content: document.getElementById('_omnivore-htmlContent').innerHTML,
|
||||
originalArticleUrl: "",
|
||||
contentReader: "WEB",
|
||||
readingProgressPercent: 0,
|
||||
readingProgressAnchorIndex: 0,
|
||||
labels: [],
|
||||
highlights: [],
|
||||
recommendations: [],
|
||||
}
|
||||
|
||||
window.fontSize = 0
|
||||
window.fontFamily = "Inter"
|
||||
window.maxWidthPercentage = 0
|
||||
window.lineHeight = 1.25
|
||||
window.themeKey = "\(themeKey)"
|
||||
window.prefersHighContrastFont = true
|
||||
window.enableHighlightBar = false
|
||||
window.highlightOnRelease = false
|
||||
</script>
|
||||
<script src="bundle.js"></script>
|
||||
<script src="mathJaxConfiguration.js" id="MathJax-script"></script>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ typealias WKScriptMessageReplyHandler = (Any?, String?) -> Void
|
|||
final class WebReaderCoordinator: NSObject {
|
||||
var webViewActionHandler: (WKScriptMessage, WKScriptMessageReplyHandler?) -> Void = { _, _ in }
|
||||
var linkHandler: (URL) -> Void = { _ in }
|
||||
var scrollPercentHandler: ((Int) -> Void) = { _ in }
|
||||
var needsReload = false
|
||||
var lastSavedAnnotationID: UUID?
|
||||
var previousReaderSettingsChangedUUID: UUID?
|
||||
|
|
@ -128,6 +129,9 @@ extension WebReaderCoordinator: WKNavigationDelegate {
|
|||
} else {
|
||||
updateShowBottomBar(false)
|
||||
}
|
||||
|
||||
let percent = Int(((yOffset + scrollView.visibleSize.height) / scrollView.contentSize.height) * 100)
|
||||
scrollPercentHandler(max(0, min(percent, 100)))
|
||||
}
|
||||
|
||||
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ public struct WebReaderLoadingContainer: View {
|
|||
let requestID: String
|
||||
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@EnvironmentObject var audioController: AudioController
|
||||
@StateObject var viewModel = WebReaderLoadingContainerViewModel()
|
||||
|
||||
public var body: some View {
|
||||
|
|
|
|||
|
|
@ -15,10 +15,6 @@ struct SafariWebLink: Identifiable {
|
|||
@Published var isDownloadingAudio: Bool = false
|
||||
@Published var audioDownloadTask: Task<Void, Error>?
|
||||
|
||||
deinit {
|
||||
print("deinit WebReaderViewModel")
|
||||
}
|
||||
|
||||
func hasOriginalUrl(_ item: LinkedItem) -> Bool {
|
||||
if let pageURLString = item.pageURLString, let host = URL(string: pageURLString)?.host {
|
||||
if host == "omnivore.app" {
|
||||
|
|
@ -162,6 +158,7 @@ struct SafariWebLink: Identifiable {
|
|||
let readingProgress = messageBody["readingProgressPercent"] as? Double
|
||||
let anchorIndex = messageBody["readingProgressAnchorIndex"] as? Int
|
||||
|
||||
print("READING PROGRESS FROM JS: ", messageBody)
|
||||
guard let itemID = itemID, let readingProgress = readingProgress, let anchorIndex = anchorIndex else {
|
||||
replyHandler(["result": false], nil)
|
||||
return
|
||||
|
|
@ -195,10 +192,11 @@ struct SafariWebLink: Identifiable {
|
|||
}
|
||||
}
|
||||
|
||||
func setLabelsForHighlight(highlightID: String,
|
||||
labelIDs: [String],
|
||||
dataService: DataService)
|
||||
{
|
||||
func setLabelsForHighlight(
|
||||
highlightID: String,
|
||||
labelIDs: [String],
|
||||
dataService: DataService
|
||||
) {
|
||||
dataService.setLabelsForHighlight(highlightID: highlightID, labelIDs: labelIDs)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -33,10 +33,9 @@ struct WelcomeView: View {
|
|||
}
|
||||
|
||||
var headlineText: some View {
|
||||
Group {
|
||||
Text(LocalText.welcomeTitle)
|
||||
}
|
||||
.font(.appLargeTitle)
|
||||
Text(LocalText.welcomeTitle)
|
||||
.font(.appLargeTitle)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
var headlineView: some View {
|
||||
|
|
@ -76,6 +75,7 @@ struct WelcomeView: View {
|
|||
+ Text(LocalText.privacyPolicyGeneric).underline()
|
||||
}
|
||||
.font(.appSubheadline)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.confirmationDialog("", isPresented: $showTermsLinks, titleVisibility: .hidden) {
|
||||
Button("View Terms of Service") {
|
||||
showTermsModal = true
|
||||
|
|
@ -225,18 +225,10 @@ struct WelcomeView: View {
|
|||
} else {
|
||||
VStack(alignment: .leading, spacing: containerSize.height < 500 ? 12 : 50) {
|
||||
logoView
|
||||
.padding(.bottom, 20)
|
||||
headlineView
|
||||
if containerSize.width > 500 {
|
||||
authProviderButtonStack
|
||||
} else {
|
||||
HStack {
|
||||
Spacer()
|
||||
authProviderButtonStack
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
authProviderButtonStack
|
||||
footerView
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(
|
||||
|
|
@ -249,7 +241,6 @@ struct WelcomeView: View {
|
|||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
)
|
||||
.padding(.vertical)
|
||||
}
|
||||
.padding()
|
||||
.sheet(isPresented: $showEmailLoginModal) {
|
||||
|
|
@ -258,11 +249,13 @@ struct WelcomeView: View {
|
|||
.sheet(isPresented: $showDebugModal) {
|
||||
DebugMenuView(selectedEnvironment: $selectedEnvironment)
|
||||
}
|
||||
.sheet(isPresented: $showAdvancedLogin) {
|
||||
NavigationView {
|
||||
SelfHostSettingsView()
|
||||
#if os(iOS)
|
||||
.sheet(isPresented: $showAdvancedLogin) {
|
||||
NavigationView {
|
||||
SelfHostSettingsView()
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
.alert(deletedAccountConfirmationMessage, isPresented: $authenticator.showAppleRevokeTokenAlert) {
|
||||
Button("View Details") {
|
||||
openURL(URL(string: "https://support.apple.com/en-us/HT210426")!)
|
||||
|
|
|
|||
|
|
@ -58,7 +58,10 @@ public extension AppEnvironment {
|
|||
case .test, .local:
|
||||
return URL(string: "http://localhost:4000")!
|
||||
case .custom:
|
||||
guard let str = UserDefaults.standard.string(forKey: AppEnvironmentUserDefaultKey.serverBaseURL.rawValue), let url = URL(string: str) else {
|
||||
guard
|
||||
let str = UserDefaults.standard.string(forKey: AppEnvironmentUserDefaultKey.serverBaseURL.rawValue),
|
||||
let url = URL(string: str)
|
||||
else {
|
||||
fatalError("custom serverBaseURL not set")
|
||||
}
|
||||
return url
|
||||
|
|
@ -74,7 +77,10 @@ public extension AppEnvironment {
|
|||
case .test, .local:
|
||||
return URL(string: "http://localhost:3000")!
|
||||
case .custom:
|
||||
guard let str = UserDefaults.standard.string(forKey: AppEnvironmentUserDefaultKey.webAppBaseURL.rawValue), let url = URL(string: str) else {
|
||||
guard
|
||||
let str = UserDefaults.standard.string(forKey: AppEnvironmentUserDefaultKey.webAppBaseURL.rawValue),
|
||||
let url = URL(string: str)
|
||||
else {
|
||||
fatalError("custom webAppBaseURL not set")
|
||||
}
|
||||
return url
|
||||
|
|
@ -90,7 +96,10 @@ public extension AppEnvironment {
|
|||
case .test, .local:
|
||||
return URL(string: "http://localhost:4000")!
|
||||
case .custom:
|
||||
guard let str = UserDefaults.standard.string(forKey: AppEnvironmentUserDefaultKey.ttsBaseURL.rawValue), let url = URL(string: str) else {
|
||||
guard
|
||||
let str = UserDefaults.standard.string(forKey: AppEnvironmentUserDefaultKey.ttsBaseURL.rawValue),
|
||||
let url = URL(string: str)
|
||||
else {
|
||||
fatalError("custom ttsBaseURL not set")
|
||||
}
|
||||
return url
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21513" systemVersion="21G115" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21513" systemVersion="21G419" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<entity name="Highlight" representedClassName="Highlight" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="annotation" optional="YES" attributeType="String"/>
|
||||
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
|
|
@ -14,6 +14,7 @@
|
|||
<attribute name="serverSyncStatus" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="shortId" attributeType="String"/>
|
||||
<attribute name="suffix" optional="YES" attributeType="String"/>
|
||||
<attribute name="type" optional="YES" attributeType="String"/>
|
||||
<attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<relationship name="createdBy" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="UserProfile"/>
|
||||
<relationship name="labels" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="LinkedItemLabel" inverseName="highlights" inverseEntity="LinkedItemLabel"/>
|
||||
|
|
@ -56,6 +57,7 @@
|
|||
<attribute name="tempPDFURL" optional="YES" attributeType="URI"/>
|
||||
<attribute name="title" attributeType="String"/>
|
||||
<attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="wordsCount" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<relationship name="highlights" toMany="YES" deletionRule="Cascade" destinationEntity="Highlight" inverseName="linkedItem" inverseEntity="Highlight"/>
|
||||
<relationship name="labels" toMany="YES" deletionRule="Nullify" destinationEntity="LinkedItemLabel" inverseName="linkedItems" inverseEntity="LinkedItemLabel"/>
|
||||
<relationship name="recommendations" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Recommendation" inverseName="linkeditem" inverseEntity="Recommendation"/>
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ public class PersistentContainer: NSPersistentContainer {
|
|||
#endif
|
||||
let appGroupContainer = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupID)
|
||||
let appGroupContainerURL = appGroupContainer?.appendingPathComponent("store.sqlite")
|
||||
print("starting with sqlite container", appGroupContainerURL)
|
||||
logger.debug("starting with sqlite container \(appGroupContainerURL?.absoluteString ?? "(nil)")")
|
||||
|
||||
container.persistentStoreDescriptions.first!.url = appGroupContainerURL
|
||||
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ public struct JSONArticle: Decodable {
|
|||
public let url: String
|
||||
public let isArchived: Bool
|
||||
public let language: String?
|
||||
public let wordsCount: Int?
|
||||
}
|
||||
|
||||
public extension LinkedItem {
|
||||
|
|
@ -69,6 +70,14 @@ public extension LinkedItem {
|
|||
(labels?.count ?? 0) > 0
|
||||
}
|
||||
|
||||
var isUnread: Bool {
|
||||
readingProgress <= 0
|
||||
}
|
||||
|
||||
var isPartiallyRead: Bool {
|
||||
Int(readingProgress) > 0
|
||||
}
|
||||
|
||||
var isRead: Bool {
|
||||
readingProgress >= 0.98
|
||||
}
|
||||
|
|
@ -146,12 +155,14 @@ public extension LinkedItem {
|
|||
"userID": NSString(string: recommendation.user?.userID ?? ""),
|
||||
"name": NSString(string: recommendation.user?.name ?? ""),
|
||||
"username": NSString(string: recommendation.user?.username ?? ""),
|
||||
"profileImageURL": recommendation.user?.profileImageURL == nil ? nil : NSString(string: recommendation.user?.profileImageURL ?? "")
|
||||
"profileImageURL": recommendation.user?.profileImageURL as NSString? as Any
|
||||
]),
|
||||
"recommendedAt": recommendedAt == nil ? nil : NSString(string: recommendedAt!)
|
||||
]
|
||||
}
|
||||
guard let JSON = (try? JSONSerialization.data(withJSONObject: recommendations, options: .prettyPrinted)) else { return "[]" }
|
||||
guard let JSON = try? JSONSerialization.data(withJSONObject: recommendations, options: .prettyPrinted) else {
|
||||
return "[]"
|
||||
}
|
||||
return String(data: JSON, encoding: .utf8) ?? "[]"
|
||||
}
|
||||
|
||||
|
|
@ -198,6 +209,7 @@ public extension LinkedItem {
|
|||
return item
|
||||
}
|
||||
|
||||
// swiftlint:disable:next cyclomatic_complexity
|
||||
func update(
|
||||
inContext context: NSManagedObjectContext,
|
||||
newReadingProgress: Double? = nil,
|
||||
|
|
|
|||
|
|
@ -89,3 +89,87 @@ public extension LinkedItemFilter {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum FeaturedItemFilter: String, CaseIterable {
|
||||
case continueReading
|
||||
case recommended
|
||||
case newsletters
|
||||
case pinned
|
||||
}
|
||||
|
||||
public extension FeaturedItemFilter {
|
||||
var title: String {
|
||||
switch self {
|
||||
case .continueReading:
|
||||
return "Continue Reading"
|
||||
default:
|
||||
return rawValue
|
||||
}
|
||||
}
|
||||
|
||||
var emptyMessage: String {
|
||||
switch self {
|
||||
case .continueReading:
|
||||
return "Your recently read items will appear here."
|
||||
case .pinned:
|
||||
return "Create a label named Pinned and add it to items you'd like to appear here"
|
||||
case .recommended:
|
||||
return "Reads recommended in your Clubs will appear here."
|
||||
case .newsletters:
|
||||
return "All your Newsletters will appear here."
|
||||
}
|
||||
}
|
||||
|
||||
var predicate: NSPredicate {
|
||||
let undeletedPredicate = NSPredicate(
|
||||
format: "%K != %i", #keyPath(LinkedItem.serverSyncStatus), Int64(ServerSyncStatus.needsDeletion.rawValue)
|
||||
)
|
||||
let notInArchivePredicate = NSPredicate(
|
||||
format: "%K == %@", #keyPath(LinkedItem.isArchived), Int(truncating: false) as NSNumber
|
||||
)
|
||||
|
||||
switch self {
|
||||
case .continueReading:
|
||||
// Use > 1 instead of 0 so its only reads they have made slight progress on.
|
||||
let continueReadingPredicate = NSPredicate(
|
||||
format: "readingProgress > 1 AND readingProgress < 100 AND readAt != nil"
|
||||
)
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [
|
||||
continueReadingPredicate, undeletedPredicate, notInArchivePredicate
|
||||
])
|
||||
case .pinned:
|
||||
let newsletterLabelPredicate = NSPredicate(
|
||||
format: "SUBQUERY(labels, $label, $label.name == \"Pinned\").@count > 0"
|
||||
)
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [
|
||||
notInArchivePredicate, undeletedPredicate, newsletterLabelPredicate
|
||||
])
|
||||
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: [
|
||||
notInArchivePredicate, undeletedPredicate, newsletterLabelPredicate
|
||||
])
|
||||
case .recommended:
|
||||
// non-archived or deleted items with the Newsletter label
|
||||
let recommendedPredicate = NSPredicate(
|
||||
format: "recommendations.@count > 0"
|
||||
)
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [
|
||||
notInArchivePredicate, undeletedPredicate, recommendedPredicate
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
var sortDescriptor: NSSortDescriptor {
|
||||
let savedAtSort = NSSortDescriptor(key: #keyPath(LinkedItem.savedAt), ascending: false)
|
||||
switch self {
|
||||
case .continueReading:
|
||||
return NSSortDescriptor(key: #keyPath(LinkedItem.readAt), ascending: false)
|
||||
default:
|
||||
return savedAtSort
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,9 +13,10 @@ public struct HighlightData {
|
|||
public let highlightText: String
|
||||
|
||||
public static func make(dict: NSDictionary?) -> HighlightData? {
|
||||
if let dict = dict,
|
||||
let highlightHTML = dict["highlightHTML"] as? String,
|
||||
let highlightText = dict["highlightText"] as? String
|
||||
if
|
||||
let dict = dict,
|
||||
let highlightHTML = dict["highlightHTML"] as? String,
|
||||
let highlightText = dict["highlightText"] as? String
|
||||
{
|
||||
return HighlightData(highlightHTML: highlightHTML, highlightText: highlightText)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@
|
|||
super.init()
|
||||
self.voiceList = generateVoiceList()
|
||||
self.realisticVoiceList = generateRealisticVoiceList()
|
||||
self._currentLanguage = defaultLanguage
|
||||
}
|
||||
|
||||
deinit {
|
||||
|
|
@ -82,6 +83,17 @@
|
|||
)
|
||||
}
|
||||
|
||||
public var offsets: [Double]? {
|
||||
if let durations = durations {
|
||||
var currentSum = 0.0
|
||||
return durations.map {
|
||||
currentSum += $0
|
||||
return currentSum
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
public func stop() {
|
||||
let stoppedId = itemAudioProperties?.itemID
|
||||
let stoppedTimeElapsed = timeElapsed
|
||||
|
|
@ -143,26 +155,14 @@
|
|||
}
|
||||
|
||||
public func preload(itemIDs: [String], retryCount _: Int = 0) async -> Bool {
|
||||
if !preloadEnabled {
|
||||
return true
|
||||
}
|
||||
|
||||
for itemID in itemIDs {
|
||||
if let document = try? await downloadSpeechFile(itemID: itemID, priority: .low) {
|
||||
let synthesizer = SpeechSynthesizer(appEnvironment: dataService.appEnvironment, networker: dataService.networker, document: document, speechAuthHeader: speechAuthHeader)
|
||||
do {
|
||||
try await synthesizer.preload()
|
||||
return true
|
||||
} catch {
|
||||
print("error preloading audio file", error)
|
||||
}
|
||||
}
|
||||
_ = try? await downloadSpeechFile(itemID: itemID, priority: .low)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
public func downloadForOffline(itemID: String) async -> Bool {
|
||||
if let document = try? await downloadSpeechFile(itemID: itemID, priority: .low) {
|
||||
if let document = try? await getSpeechFile(itemID: itemID, priority: .low) {
|
||||
let synthesizer = SpeechSynthesizer(appEnvironment: dataService.appEnvironment, networker: dataService.networker, document: document, speechAuthHeader: speechAuthHeader)
|
||||
for item in synthesizer.createPlayerItems(from: 0) {
|
||||
do {
|
||||
|
|
@ -177,6 +177,17 @@
|
|||
return false
|
||||
}
|
||||
|
||||
public static func removeAudioFiles(itemID: String) {
|
||||
do {
|
||||
let audioDirectory = pathForAudioDirectory(itemID: itemID)
|
||||
try FileManager.default.removeItem(at: audioDirectory)
|
||||
} catch {
|
||||
// We don't need to throw here, as its likely the
|
||||
// directory just doesn't exist
|
||||
print("Error removing audio files", error)
|
||||
}
|
||||
}
|
||||
|
||||
public var scrubState: PlayerScrubState = .reset {
|
||||
didSet {
|
||||
switch scrubState {
|
||||
|
|
@ -423,7 +434,7 @@
|
|||
|
||||
if let itemID = itemAudioProperties?.itemID {
|
||||
Task {
|
||||
let document = try? await self.downloadSpeechFile(itemID: itemID, priority: .high)
|
||||
let document = try? await self.getSpeechFile(itemID: itemID, priority: .high)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
if let document = document {
|
||||
|
|
@ -531,14 +542,13 @@
|
|||
itemID + "-" + currentVoice + ".mp3"
|
||||
}
|
||||
|
||||
public func pathForAudioDirectory(itemID: String) -> URL {
|
||||
FileManager.default
|
||||
.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
public static func pathForAudioDirectory(itemID: String) -> URL {
|
||||
URL.om_documentsDirectory
|
||||
.appendingPathComponent("audio-\(itemID)/")
|
||||
}
|
||||
|
||||
public func pathForSpeechFile(itemID: String) -> URL {
|
||||
pathForAudioDirectory(itemID: itemID)
|
||||
Self.pathForAudioDirectory(itemID: itemID)
|
||||
.appendingPathComponent("speech-\(currentVoice).json")
|
||||
}
|
||||
|
||||
|
|
@ -548,7 +558,7 @@
|
|||
|
||||
if let itemID = itemAudioProperties?.itemID {
|
||||
Task {
|
||||
let document = try? await downloadSpeechFile(itemID: itemID, priority: .high)
|
||||
let document = try? await getSpeechFile(itemID: itemID, priority: .high)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.setTextItems()
|
||||
|
|
@ -868,18 +878,21 @@
|
|||
let str = String(decoding: data, as: UTF8.self)
|
||||
print("result speech file: ", str)
|
||||
|
||||
document = try? JSONDecoder().decode(SpeechDocument.self, from: data)
|
||||
|
||||
// Cache the file - if it exists
|
||||
if let document = document {
|
||||
if let document = try? JSONDecoder().decode(SpeechDocument.self, from: data) {
|
||||
do {
|
||||
try? FileManager.default.createDirectory(at: document.audioDirectory, withIntermediateDirectories: true)
|
||||
try data.write(to: speechFileUrl)
|
||||
return document
|
||||
} catch {
|
||||
print("error writing file", error)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getSpeechFile(itemID: String, priority: DownloadPriority) async throws -> SpeechDocument? {
|
||||
document = try await downloadSpeechFile(itemID: itemID, priority: priority)
|
||||
return document
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,5 +7,11 @@
|
|||
public func preload(itemIDs _: [String]) {}
|
||||
|
||||
public func downloadForOffline(itemID _: String) -> Bool { true }
|
||||
|
||||
func updateDuration(forItem _: SpeechItem, newDuration _: TimeInterval) {}
|
||||
|
||||
public func stopWithError() {}
|
||||
|
||||
public static func removeAudioFiles(itemID _: String) {}
|
||||
}
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import Models
|
|||
|
||||
// Somewhat based on: https://github.com/neekeetab/CachingPlayerItem/blob/master/CachingPlayerItem.swift
|
||||
class SpeechPlayerItem: AVPlayerItem {
|
||||
let resourceLoaderDelegate = ResourceLoaderDelegate()
|
||||
let resourceLoaderDelegate = ResourceLoaderDelegate() // swiftlint:disable:this weak_delegate
|
||||
let session: AudioController
|
||||
let speechItem: SpeechItem
|
||||
var speechMarks: [SpeechMark]?
|
||||
|
|
@ -22,6 +22,7 @@ class SpeechPlayerItem: AVPlayerItem {
|
|||
|
||||
var observer: Any?
|
||||
|
||||
// swiftlint:disable:next line_length
|
||||
init(session: AudioController, prefetchQueue: OperationQueue, speechItem: SpeechItem, completed: @escaping () -> Void) {
|
||||
self.speechItem = speechItem
|
||||
self.session = session
|
||||
|
|
@ -84,9 +85,10 @@ class SpeechPlayerItem: AVPlayerItem {
|
|||
var pendingRequests = Set<AVAssetResourceLoadingRequest>()
|
||||
weak var owner: SpeechPlayerItem?
|
||||
|
||||
func resourceLoader(_: AVAssetResourceLoader,
|
||||
shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool
|
||||
{
|
||||
func resourceLoader(
|
||||
_: AVAssetResourceLoader,
|
||||
shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest
|
||||
) -> Bool {
|
||||
if owner == nil {
|
||||
return true
|
||||
}
|
||||
|
|
@ -119,6 +121,7 @@ class SpeechPlayerItem: AVPlayerItem {
|
|||
}
|
||||
|
||||
do {
|
||||
// swiftlint:disable:next line_length
|
||||
let speechData = try await SpeechSynthesizer.download(speechItem: speechItem, session: self.session ?? URLSession.shared)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
|
|
@ -172,6 +175,7 @@ class SpeechPlayerItem: AVPlayerItem {
|
|||
_ = requestsFulfilled.map { self.pendingRequests.remove($0) }
|
||||
}
|
||||
|
||||
// swiftlint:disable:next line_length
|
||||
func fillInContentInformationRequest(_ contentInformationRequest: AVAssetResourceLoadingContentInformationRequest?) {
|
||||
contentInformationRequest?.contentType = UTType.mp3.identifier
|
||||
|
||||
|
|
|
|||
|
|
@ -52,8 +52,11 @@ struct SpeechDocument: Decodable {
|
|||
}
|
||||
|
||||
var audioDirectory: URL {
|
||||
FileManager.default
|
||||
.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
Self.audioDirectory(pageId: pageId)
|
||||
}
|
||||
|
||||
static func audioDirectory(pageId: String) -> URL {
|
||||
URL.om_documentsDirectory
|
||||
.appendingPathComponent("audio-\(pageId)")
|
||||
}
|
||||
}
|
||||
|
|
@ -141,7 +144,7 @@ struct SpeechSynthesizer {
|
|||
text: utterance.text)
|
||||
result.append(item)
|
||||
} else {
|
||||
// TODO: How do we want to handle completely skipped paragraphs?
|
||||
// How do we want to handle completely skipped paragraphs?
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -170,13 +173,13 @@ struct SpeechSynthesizer {
|
|||
|
||||
static func downloadData(session: URLSession, request: URLRequest) async throws -> Data {
|
||||
do {
|
||||
let result: (Data, URLResponse)? = try await session.data(for: request)
|
||||
guard let httpResponse = result?.1 as? HTTPURLResponse, 200 ..< 300 ~= httpResponse.statusCode else {
|
||||
print("error: ", result?.1)
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let httpResponse = response as? HTTPURLResponse, 200 ..< 300 ~= httpResponse.statusCode else {
|
||||
print("error: ", response)
|
||||
throw BasicError.message(messageText: "audioFetch failed. no response or bad status code.")
|
||||
}
|
||||
|
||||
guard let data = result?.0 else {
|
||||
guard !data.isEmpty else {
|
||||
throw BasicError.message(messageText: "audioFetch failed. no data received.")
|
||||
}
|
||||
|
||||
|
|
@ -190,10 +193,11 @@ struct SpeechSynthesizer {
|
|||
}
|
||||
}
|
||||
|
||||
static func download(speechItem: SpeechItem,
|
||||
redownloadCached: Bool = false,
|
||||
session: URLSession = URLSession.shared) async throws -> SynthesizeData?
|
||||
{
|
||||
static func download(
|
||||
speechItem: SpeechItem,
|
||||
redownloadCached: Bool = false,
|
||||
session: URLSession = URLSession.shared
|
||||
) async throws -> SynthesizeData? {
|
||||
let decoder = JSONDecoder()
|
||||
|
||||
if !redownloadCached {
|
||||
|
|
@ -208,12 +212,10 @@ struct SpeechSynthesizer {
|
|||
|
||||
let data = try await downloadData(session: session, request: speechItem.urlRequest)
|
||||
|
||||
let tempPath = FileManager.default
|
||||
.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
||||
let tempPath = URL.om_cachesDirectory
|
||||
.appendingPathComponent(UUID().uuidString + ".mp3")
|
||||
|
||||
let tempSMPath = FileManager.default
|
||||
.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
||||
let tempSMPath = URL.om_cachesDirectory
|
||||
.appendingPathComponent(UUID().uuidString + ".speechMarks")
|
||||
|
||||
do {
|
||||
|
|
|
|||
|
|
@ -31,8 +31,10 @@ public enum VoiceCategory: String, CaseIterable {
|
|||
case enUK = "English (UK)"
|
||||
case deDE = "German (Germany)"
|
||||
case hiIN = "Hindi (India)"
|
||||
case itIT = "Italian (Italy)"
|
||||
case esES = "Spanish (Spain)"
|
||||
case jaJP = "Japanese (Japan)"
|
||||
case ptBR = "Portuguese (Brazil)"
|
||||
case taIN = "Tamil (India)"
|
||||
case taLK = "Tamil (Sri Lanka)"
|
||||
case taMY = "Tamil (Malaysia)"
|
||||
|
|
@ -51,6 +53,7 @@ public struct VoicePair {
|
|||
let category: VoiceCategory
|
||||
}
|
||||
|
||||
// swiftlint:disable line_length
|
||||
public enum Voices {
|
||||
public static func isUltraRealisticVoice(_ voiceKey: String) -> Bool {
|
||||
UltraPairs.contains(where: { voice in
|
||||
|
|
@ -68,12 +71,13 @@ public enum Voices {
|
|||
VoiceLanguage(key: "zh", name: "Chinese", defaultVoice: "zh-CN-XiaochenNeural", categories: [.zhCN]),
|
||||
VoiceLanguage(key: "de", name: "German", defaultVoice: "de-CH-JanNeural", categories: [.deDE]),
|
||||
VoiceLanguage(key: "hi", name: "Hindi", defaultVoice: "hi-IN-MadhurNeural", categories: [.hiIN]),
|
||||
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: "pt", name: "Portuguese", defaultVoice: "pt-BR-AntonioNeural", categories: [.ptBR]),
|
||||
VoiceLanguage(key: "ta", name: "Tamil", defaultVoice: "ta-IN-PallaviNeural", categories: [.taIN, .taLK, .taMY, .taSG])
|
||||
]
|
||||
|
||||
// swiftlint:disable all
|
||||
public static let Pairs = [
|
||||
VoicePair(firstKey: "en-US-JennyNeural", secondKey: "en-US-BrandonNeural", firstName: "Jenny", secondName: "Brandon", language: "en-US", category: .enUS),
|
||||
VoicePair(firstKey: "en-US-CoraNeural", secondKey: "en-US-ChristopherNeural", firstName: "Cora", secondName: "Christopher", language: "en-US", category: .enUS),
|
||||
|
|
@ -92,25 +96,17 @@ public enum Voices {
|
|||
VoicePair(firstKey: "de-DE-ChristophNeural", secondKey: "de-DE-LouisaNeural", firstName: "Christoph", secondName: "Louisa", language: "de-DE", category: .deDE),
|
||||
VoicePair(firstKey: "ja-JP-NanamiNeural", secondKey: "ja-JP-KeitaNeural", firstName: "Nanami", secondName: "Keita", language: "ja-JP", category: .jaJP),
|
||||
VoicePair(firstKey: "hi-IN-MadhurNeural", secondKey: "hi-IN-SwaraNeural", firstName: "Madhur", secondName: "Swara", language: "hi-IN", category: .hiIN),
|
||||
VoicePair(firstKey: "pt-BR-AntonioNeural", secondKey: "pt-BR-BrendaNeural", firstName: "Antonio", secondName: "Brenda", language: "pt-BR", category: .ptBR),
|
||||
VoicePair(firstKey: "ta-IN-PallaviNeural", secondKey: "ta-IN-ValluvarNeural", firstName: "Pallavi", secondName: "Valluvar", language: "ta-IN", category: .taIN),
|
||||
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: "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)
|
||||
]
|
||||
|
||||
public static let UltraPairs = [
|
||||
VoicePair(firstKey: "Larry", secondKey: "Susan", firstName: "Larry", secondName: "Susan", language: "en-US", category: .enUS),
|
||||
VoicePair(firstKey: "Jordan", secondKey: "William", firstName: "Jordan", secondName: "William", language: "en-US", category: .enUS),
|
||||
VoicePair(firstKey: "Evelyn", secondKey: "Axel", firstName: "Evelyn", secondName: "Axel", language: "en-US", category: .enUS),
|
||||
VoicePair(firstKey: "Nova", secondKey: "Owen", firstName: "Nova", secondName: "Owen", language: "en-US", category: .enUS),
|
||||
VoicePair(firstKey: "Frankie", secondKey: "Natalie", firstName: "Frankie", secondName: "Natalie", language: "en-US", category: .enUS),
|
||||
|
||||
VoicePair(firstKey: "Daniel", secondKey: "Charlotte", firstName: "Daniel", secondName: "Charlotte", language: "en-CA", category: .enCA),
|
||||
VoicePair(firstKey: "Lillian", secondKey: "Aurora", firstName: "Lillian", secondName: "Aurora", language: "en-UK", category: .enUK),
|
||||
|
||||
VoicePair(firstKey: "Oliver", secondKey: "Arthur", firstName: "Oliver", secondName: "Arthur", language: "en-UK", category: .enUK),
|
||||
VoicePair(firstKey: "Frederick", secondKey: "Hunter", firstName: "Frederick", secondName: "Hunter", language: "en-UK", category: .enUK),
|
||||
VoicePair(firstKey: "Nolan", secondKey: "Phoebe", firstName: "Nolan", secondName: "Phoebe", language: "en-UK", category: .enUK),
|
||||
VoicePair(firstKey: "Daisy", secondKey: "Stella", firstName: "Daisy", secondName: "Stella", language: "en-UK", category: .enUK)
|
||||
VoicePair(firstKey: "Josh", secondKey: "Bella", firstName: "Josh", secondName: "Bella", language: "en-US", category: .enUS)
|
||||
// VoicePair(firstKey: "Arnold", secondKey: "Bella", firstName: "Arnold", secondName: "Bella", language: "en-US", category: .enUS),
|
||||
// VoicePair(firstKey: "Adam", secondKey: "Elli", firstName: "Adam", secondName: "Elli", language: "en-US", category: .enUS)
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ public final class Authenticator: ObservableObject {
|
|||
}
|
||||
|
||||
public func logout(dataService: DataService, isAccountDeletion: Bool = false) {
|
||||
dataService.resetCoreData()
|
||||
dataService.resetLocalStorage()
|
||||
clearCreds()
|
||||
Authenticator.unregisterIntercomUser?()
|
||||
isLoggedIn = false
|
||||
|
|
|
|||
|
|
@ -29,19 +29,34 @@ public final class DataService: ObservableObject {
|
|||
persistentContainer.viewContext
|
||||
}
|
||||
|
||||
@AppStorage(UserDefaultKey.lastItemSyncTime.rawValue) public var lastItemSyncTime = DateFormatter.formatterISO8601.string(
|
||||
from: Date(timeIntervalSinceReferenceDate: 0)
|
||||
)
|
||||
public var lastItemSyncTime: Date {
|
||||
get {
|
||||
guard
|
||||
let str = UserDefaults.standard.string(forKey: UserDefaultKey.lastItemSyncTime.rawValue),
|
||||
let date = DateFormatter.formatterISO8601.date(from: str)
|
||||
else {
|
||||
return Date(timeIntervalSinceReferenceDate: 0)
|
||||
}
|
||||
return date
|
||||
}
|
||||
set {
|
||||
logger.trace("last item sync updated to \(newValue)")
|
||||
let str = DateFormatter.formatterISO8601.string(from: newValue)
|
||||
UserDefaults.standard.set(str, forKey: UserDefaultKey.lastItemSyncTime.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
public init(appEnvironment: AppEnvironment, networker: Networker) {
|
||||
self.appEnvironment = appEnvironment
|
||||
self.networker = networker
|
||||
self.persistentContainer = PersistentContainer.make()
|
||||
self.backgroundContext = persistentContainer.newBackgroundContext()
|
||||
|
||||
backgroundContext.automaticallyMergesChangesFromParent = true
|
||||
backgroundContext.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
|
||||
|
||||
if isFirstTimeRunningNewAppBuild() {
|
||||
resetCoreData()
|
||||
resetLocalStorage()
|
||||
} else {
|
||||
persistentContainer.loadPersistentStores { _, error in
|
||||
if let error = error {
|
||||
|
|
@ -100,10 +115,58 @@ public final class DataService: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
public func resetCoreData() {
|
||||
lastItemSyncTime = DateFormatter.formatterISO8601.string(from: Date(timeIntervalSinceReferenceDate: 0))
|
||||
private func clearDownloadedFiles() {
|
||||
let relevantTypes = ["pdf", "mp3", "speechMarks"]
|
||||
let fileMgr = FileManager()
|
||||
logger.trace("removing cached downloads")
|
||||
|
||||
// clear the temporary files in the caches directory…
|
||||
if let cacheFileURLs = try? fileMgr.contentsOfDirectory(
|
||||
at: URL.om_cachesDirectory,
|
||||
includingPropertiesForKeys: .none,
|
||||
options: .skipsHiddenFiles
|
||||
) {
|
||||
logger.trace("\(cacheFileURLs.count) file URLs in caches directory")
|
||||
for fileURL in cacheFileURLs where relevantTypes.contains(fileURL.pathExtension) {
|
||||
logger.trace("removing \(fileURL.absoluteString)")
|
||||
try? fileMgr.removeItem(at: fileURL)
|
||||
}
|
||||
}
|
||||
|
||||
// …and also the copies written to Documents
|
||||
let resourceKeys = Set<URLResourceKey>([.nameKey, .isDirectoryKey])
|
||||
if let documentsFileURLs = try? fileMgr.contentsOfDirectory(
|
||||
at: URL.om_documentsDirectory,
|
||||
includingPropertiesForKeys: Array(resourceKeys),
|
||||
options: .skipsHiddenFiles
|
||||
) {
|
||||
logger.trace("\(documentsFileURLs.count) file URLs in documents directory")
|
||||
for fileURL in documentsFileURLs {
|
||||
guard
|
||||
let resourceValues = try? fileURL.resourceValues(forKeys: resourceKeys),
|
||||
let isDirectory = resourceValues.isDirectory,
|
||||
let name = resourceValues.name
|
||||
else {
|
||||
continue
|
||||
}
|
||||
if isDirectory {
|
||||
if name.hasPrefix("audio-") {
|
||||
logger.trace("removing \(fileURL.absoluteString)")
|
||||
try? fileMgr.removeItem(at: fileURL)
|
||||
}
|
||||
} else if relevantTypes.contains(fileURL.pathExtension) {
|
||||
logger.trace("removing \(fileURL.absoluteString)")
|
||||
try? fileMgr.removeItem(at: fileURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func resetLocalStorage() {
|
||||
lastItemSyncTime = Date(timeIntervalSinceReferenceDate: 0)
|
||||
|
||||
clearCoreData()
|
||||
clearDownloadedFiles()
|
||||
|
||||
persistentContainer = PersistentContainer.make()
|
||||
persistentContainer.loadPersistentStores { _, error in
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -12,11 +12,11 @@ extension DataService {
|
|||
linkedItem.update(inContext: self.backgroundContext, newIsArchivedValue: archived)
|
||||
|
||||
// Send update to server
|
||||
self.syncLinkArchiveStatus(itemID: linkedItem.unwrappedID, objectID: objectID, archived: archived)
|
||||
self.syncLinkArchiveStatus(itemID: linkedItem.unwrappedID, archived: archived)
|
||||
}
|
||||
}
|
||||
|
||||
func syncLinkArchiveStatus(itemID: String, objectID: NSManagedObjectID, archived: Bool) {
|
||||
func syncLinkArchiveStatus(itemID: String, archived: Bool) {
|
||||
enum MutationResult {
|
||||
case success(linkId: String)
|
||||
case error(errorCode: Enums.ArchiveLinkErrorCode)
|
||||
|
|
@ -48,7 +48,7 @@ extension DataService {
|
|||
let syncStatus: ServerSyncStatus = data == nil ? .needsUpdate : .isNSync
|
||||
|
||||
context.perform {
|
||||
guard let linkedItem = context.object(with: objectID) as? LinkedItem else { return }
|
||||
guard let linkedItem = LinkedItem.lookup(byID: itemID, inContext: context) else { return }
|
||||
linkedItem.serverSyncStatus = Int64(syncStatus.rawValue)
|
||||
|
||||
do {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import Models
|
|||
import SwiftGraphQL
|
||||
|
||||
extension DataService {
|
||||
// swiftlint:disable:next function_parameter_count
|
||||
public func createHighlight(
|
||||
shortId: String,
|
||||
highlightID: String,
|
||||
|
|
@ -16,6 +17,7 @@ extension DataService {
|
|||
) -> [String: Any]? {
|
||||
let internalHighlight = InternalHighlight(
|
||||
id: highlightID,
|
||||
type: "HIGHLIGHT",
|
||||
shortId: shortId,
|
||||
quote: quote,
|
||||
prefix: nil, suffix: nil,
|
||||
|
|
@ -60,9 +62,10 @@ extension DataService {
|
|||
articleId: articleId,
|
||||
highlightPositionAnchorIndex: OptionalArgument(highlight.positionAnchorIndex),
|
||||
highlightPositionPercent: OptionalArgument(highlight.positionPercent), id: highlight.id,
|
||||
patch: highlight.patch,
|
||||
quote: highlight.quote,
|
||||
shortId: highlight.shortId
|
||||
patch: OptionalArgument(highlight.patch),
|
||||
quote: OptionalArgument(highlight.quote),
|
||||
shortId: highlight.shortId,
|
||||
type: OptionalArgument(Enums.HighlightType.highlight)
|
||||
),
|
||||
selection: selection
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import Models
|
|||
import SwiftGraphQL
|
||||
|
||||
public extension DataService {
|
||||
// swiftlint:disable:next line_length
|
||||
func createRecommendationGroup(name: String, onlyAdminCanPost: Bool, onlyAdminCanSeeMembers: Bool) async throws -> InternalRecommendationGroup {
|
||||
enum MutationResult {
|
||||
case saved(recommendationGroup: InternalRecommendationGroup)
|
||||
|
|
|
|||
|
|
@ -5,34 +5,24 @@ import SwiftGraphQL
|
|||
|
||||
public extension DataService {
|
||||
func deleteHighlight(highlightID: String) {
|
||||
if let highlight = Highlight.lookup(byID: highlightID, inContext: viewContext) {
|
||||
deleteHighlight(objectID: highlight.objectID)
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteHighlight(objectID: NSManagedObjectID) {
|
||||
// Update CoreData
|
||||
viewContext.performAndWait {
|
||||
guard let highlight = viewContext.object(with: objectID) as? Highlight else { return }
|
||||
// Update CoreData so view updates immediately
|
||||
viewContext.perform {
|
||||
guard let highlight = Highlight.lookup(byID: highlightID, inContext: self.viewContext) else { return }
|
||||
highlight.serverSyncStatus = Int64(ServerSyncStatus.needsDeletion.rawValue)
|
||||
|
||||
do {
|
||||
try viewContext.save()
|
||||
try self.viewContext.save()
|
||||
logger.debug("Highlight succesfully marked for deletion")
|
||||
} catch {
|
||||
viewContext.rollback()
|
||||
self.viewContext.rollback()
|
||||
logger.debug("Failed to mark Highlight for deletion: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
// Send update to server
|
||||
backgroundContext.perform { [weak self] in
|
||||
guard let highlight = self?.backgroundContext.object(with: objectID) as? Highlight else { return }
|
||||
self?.syncHighlightDeletion(highlightID: highlight.unwrappedID, objectID: objectID)
|
||||
self.syncHighlightDeletion(highlightID: highlightID)
|
||||
}
|
||||
}
|
||||
|
||||
internal func syncHighlightDeletion(highlightID: String, objectID: NSManagedObjectID) {
|
||||
func syncHighlightDeletion(highlightID: String) {
|
||||
enum MutationResult {
|
||||
case saved(id: String)
|
||||
case error(errorCode: Enums.DeleteHighlightErrorCode)
|
||||
|
|
@ -63,7 +53,7 @@ public extension DataService {
|
|||
let isSyncSuccess = data != nil
|
||||
|
||||
context.perform {
|
||||
guard let highlight = context.object(with: objectID) as? Highlight else { return }
|
||||
guard let highlight = Highlight.lookup(byID: highlightID, inContext: context) else { return }
|
||||
|
||||
if isSyncSuccess {
|
||||
highlight.remove(inContext: context)
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ extension DataService {
|
|||
) -> [String: Any]? {
|
||||
let internalHighlight = InternalHighlight(
|
||||
id: highlightID,
|
||||
type: "HIGHLIGHT",
|
||||
shortId: shortId,
|
||||
quote: quote,
|
||||
prefix: nil,
|
||||
|
|
@ -39,7 +40,7 @@ extension DataService {
|
|||
)
|
||||
|
||||
// Send update to server
|
||||
syncHighlightCreation(highlight: internalHighlight, articleId: articleId)
|
||||
syncHighlightMerge(highlight: internalHighlight, articleId: articleId, overlapHighlightIdList: overlapHighlightIdList)
|
||||
|
||||
return internalHighlight.encoded()
|
||||
}
|
||||
|
|
@ -100,6 +101,7 @@ extension DataService {
|
|||
if let highlightObject = (try? context.fetch(fetchRequest))?.first {
|
||||
if isSyncSuccess {
|
||||
highlightObject.remove(inContext: context)
|
||||
return
|
||||
} else {
|
||||
highlightObject.serverSyncStatus = Int64(ServerSyncStatus.needsDeletion.rawValue)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,9 @@ public extension DataService {
|
|||
case error(errorCode: Enums.OptInFeatureErrorCode)
|
||||
}
|
||||
|
||||
let featureSelection = Selection.Feature { Feature(name: try $0.name(), token: try $0.token(), granted: try $0.grantedAt() != nil) }
|
||||
let featureSelection = Selection.Feature {
|
||||
Feature(name: try $0.name(), token: try $0.token(), granted: try $0.grantedAt() != nil)
|
||||
}
|
||||
let selection = Selection<MutationResult, Unions.OptInFeatureResult> {
|
||||
try $0.on(
|
||||
optInFeatureError: .init { .error(errorCode: try $0.errorCodes().first ?? .badRequest) },
|
||||
|
|
|
|||
|
|
@ -21,7 +21,10 @@ public extension DataService {
|
|||
|
||||
let mutation = Selection.Mutation {
|
||||
try $0.recommend(
|
||||
input: .init(groupIds: groupIDs, note: OptionalArgument(note), pageId: pageID, recommendedWithHighlights: OptionalArgument(withHighlights)),
|
||||
input: .init(groupIds: groupIDs,
|
||||
note: OptionalArgument(note),
|
||||
pageId: pageID,
|
||||
recommendedWithHighlights: OptionalArgument(withHighlights)),
|
||||
selection: selection
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,14 +57,14 @@ extension DataService {
|
|||
label.remove(inContext: context)
|
||||
} else {
|
||||
label.serverSyncStatus = Int64(ServerSyncStatus.needsDeletion.rawValue)
|
||||
}
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
logger.debug("LinkedItem deleted succesfully")
|
||||
} catch {
|
||||
context.rollback()
|
||||
logger.debug("Failed to delete LinkedItem: \(error.localizedDescription)")
|
||||
do {
|
||||
try context.save()
|
||||
logger.debug("LinkedItem deleted succesfully")
|
||||
} catch {
|
||||
context.rollback()
|
||||
logger.debug("Failed to delete LinkedItem: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,28 +5,41 @@ import SwiftGraphQL
|
|||
|
||||
public extension DataService {
|
||||
func removeLink(objectID: NSManagedObjectID) {
|
||||
// Update CoreData
|
||||
viewContext.performAndWait {
|
||||
guard let linkedItem = viewContext.object(with: objectID) as? LinkedItem else { return }
|
||||
linkedItem.serverSyncStatus = Int64(ServerSyncStatus.needsDeletion.rawValue)
|
||||
// First try to get the item synchronously, this is used later to delete files
|
||||
// Then we can async update core data and make the API call to sync the deletion
|
||||
|
||||
do {
|
||||
try viewContext.save()
|
||||
logger.debug("LinkedItem succesfully marked for deletion")
|
||||
} catch {
|
||||
viewContext.rollback()
|
||||
logger.debug("Failed to mark LinkedItem for deletion: \(error.localizedDescription)")
|
||||
}
|
||||
var linkedItemID: String?
|
||||
viewContext.performAndWait {
|
||||
guard let linkedItem = self.viewContext.object(with: objectID) as? LinkedItem else { return }
|
||||
linkedItem.serverSyncStatus = Int64(ServerSyncStatus.needsDeletion.rawValue)
|
||||
linkedItemID = linkedItem.id
|
||||
}
|
||||
|
||||
// Send update to server
|
||||
backgroundContext.perform { [weak self] in
|
||||
guard let linkedItem = self?.backgroundContext.object(with: objectID) as? LinkedItem else { return }
|
||||
self?.syncLinkDeletion(itemID: linkedItem.unwrappedID, objectID: objectID)
|
||||
viewContext.perform {
|
||||
guard let linkedItem = self.viewContext.object(with: objectID) as? LinkedItem else { return }
|
||||
linkedItem.serverSyncStatus = Int64(ServerSyncStatus.needsDeletion.rawValue)
|
||||
linkedItemID = linkedItem.id
|
||||
|
||||
do {
|
||||
try self.viewContext.save()
|
||||
logger.debug("LinkedItem succesfully marked for deletion")
|
||||
} catch {
|
||||
self.viewContext.rollback()
|
||||
logger.debug("Failed to mark LinkedItem for deletion: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
// Send update to server
|
||||
self.syncLinkDeletion(itemID: linkedItem.unwrappedID)
|
||||
}
|
||||
|
||||
if let linkedItemID = linkedItemID {
|
||||
Task {
|
||||
AudioController.removeAudioFiles(itemID: linkedItemID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func syncLinkDeletion(itemID: String, objectID: NSManagedObjectID) {
|
||||
func syncLinkDeletion(itemID: String) {
|
||||
enum MutationResult {
|
||||
case success(linkId: String)
|
||||
case error(errorCode: Enums.SetBookmarkArticleErrorCode)
|
||||
|
|
@ -64,20 +77,20 @@ public extension DataService {
|
|||
let isSyncSuccess = data != nil
|
||||
|
||||
context.perform {
|
||||
guard let linkedItem = context.object(with: objectID) as? LinkedItem else { return }
|
||||
guard let linkedItem = LinkedItem.lookup(byID: itemID, inContext: context) else { return }
|
||||
|
||||
if isSyncSuccess {
|
||||
linkedItem.remove(inContext: context)
|
||||
} else {
|
||||
linkedItem.serverSyncStatus = Int64(ServerSyncStatus.needsDeletion.rawValue)
|
||||
}
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
logger.debug("LinkedItem deleted succesfully")
|
||||
} catch {
|
||||
context.rollback()
|
||||
logger.debug("Failed to delete LinkedItem: \(error.localizedDescription)")
|
||||
do {
|
||||
try context.save()
|
||||
logger.debug("LinkedItem deleted succesfully")
|
||||
} catch {
|
||||
context.rollback()
|
||||
logger.debug("Failed to delete LinkedItem: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,11 @@ extension DataService {
|
|||
guard let self = self else { return }
|
||||
guard let linkedItem = LinkedItem.lookup(byID: itemID, inContext: self.backgroundContext) else { return }
|
||||
|
||||
if readingProgress != 0, readingProgress < linkedItem.readingProgress {
|
||||
return
|
||||
}
|
||||
|
||||
print("updating reading progress: ", readingProgress, anchorIndex)
|
||||
linkedItem.update(
|
||||
inContext: self.backgroundContext,
|
||||
newReadingProgress: readingProgress,
|
||||
|
|
@ -18,14 +23,13 @@ extension DataService {
|
|||
// Send update to server
|
||||
self.syncLinkReadingProgress(
|
||||
itemID: linkedItem.unwrappedID,
|
||||
objectID: linkedItem.objectID,
|
||||
readingProgress: readingProgress,
|
||||
anchorIndex: anchorIndex
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func syncLinkReadingProgress(itemID: String, objectID: NSManagedObjectID, readingProgress: Double, anchorIndex: Int) {
|
||||
func syncLinkReadingProgress(itemID: String, readingProgress: Double, anchorIndex: Int) {
|
||||
enum MutationResult {
|
||||
case saved(readAt: Date?)
|
||||
case error(errorCode: Enums.SaveArticleReadingProgressErrorCode)
|
||||
|
|
@ -62,7 +66,7 @@ extension DataService {
|
|||
let syncStatus: ServerSyncStatus = data == nil ? .needsUpdate : .isNSync
|
||||
|
||||
context.perform {
|
||||
guard let linkedItem = context.object(with: objectID) as? LinkedItem else { return }
|
||||
guard let linkedItem = LinkedItem.lookup(byID: itemID, inContext: context) else { return }
|
||||
linkedItem.serverSyncStatus = Int64(syncStatus.rawValue)
|
||||
if let mutationResult = data?.data, case let MutationResult.saved(readAt) = mutationResult {
|
||||
linkedItem.readAt = readAt
|
||||
|
|
|
|||
|
|
@ -4,26 +4,19 @@ import Models
|
|||
import SwiftGraphQL
|
||||
|
||||
extension DataService {
|
||||
public func updateHighlightAttributes(
|
||||
highlightID: String,
|
||||
annotation: String
|
||||
) {
|
||||
public func updateHighlightAttributes(highlightID: String, annotation: String) {
|
||||
guard let highlight = Highlight.lookup(byID: highlightID, inContext: backgroundContext) else { return }
|
||||
|
||||
backgroundContext.perform { [weak self] in
|
||||
guard let self = self else { return }
|
||||
guard let highlight = Highlight.lookup(byID: highlightID, inContext: self.backgroundContext) else { return }
|
||||
|
||||
highlight.update(inContext: self.backgroundContext, newAnnotation: annotation)
|
||||
|
||||
// Send update to server
|
||||
self.syncHighlightAttributes(
|
||||
highlightID: highlightID,
|
||||
objectID: highlight.objectID,
|
||||
annotation: annotation
|
||||
)
|
||||
}
|
||||
|
||||
// Send update to server
|
||||
syncHighlightAttributes(highlightID: highlightID, annotation: annotation)
|
||||
}
|
||||
|
||||
func syncHighlightAttributes(highlightID: String, objectID: NSManagedObjectID, annotation: String) {
|
||||
func syncHighlightAttributes(highlightID: String, annotation: String) {
|
||||
enum MutationResult {
|
||||
case saved(highlight: InternalHighlight)
|
||||
case error(errorCode: Enums.UpdateHighlightErrorCode)
|
||||
|
|
@ -58,7 +51,7 @@ extension DataService {
|
|||
let syncStatus: ServerSyncStatus = data == nil ? .needsUpdate : .isNSync
|
||||
|
||||
context.perform {
|
||||
guard let highlight = context.object(with: objectID) as? Highlight else { return }
|
||||
guard let highlight = Highlight.lookup(byID: highlightID, inContext: context) else { return }
|
||||
highlight.serverSyncStatus = Int64(syncStatus.rawValue)
|
||||
|
||||
do {
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ extension DataService {
|
|||
// Send update to server
|
||||
self.syncLinkedItemTitleAndDescription(
|
||||
itemID: itemID,
|
||||
objectID: linkedItem.objectID,
|
||||
title: title,
|
||||
author: author,
|
||||
description: description
|
||||
|
|
@ -29,7 +28,6 @@ extension DataService {
|
|||
|
||||
func syncLinkedItemTitleAndDescription(
|
||||
itemID: String,
|
||||
objectID: NSManagedObjectID,
|
||||
title: String,
|
||||
author: String?,
|
||||
description: String
|
||||
|
|
@ -50,7 +48,10 @@ extension DataService {
|
|||
|
||||
let mutation = Selection.Mutation {
|
||||
try $0.updatePage(
|
||||
input: .init(byline: OptionalArgument(author), description: OptionalArgument(description), pageId: itemID, title: OptionalArgument(title)),
|
||||
input: .init(byline: OptionalArgument(author),
|
||||
description: OptionalArgument(description),
|
||||
pageId: itemID,
|
||||
title: OptionalArgument(title)),
|
||||
selection: selection
|
||||
)
|
||||
}
|
||||
|
|
@ -64,7 +65,7 @@ extension DataService {
|
|||
let syncStatus: ServerSyncStatus = data == nil ? .needsUpdate : .isNSync
|
||||
|
||||
context.perform {
|
||||
guard let linkedItem = context.object(with: objectID) as? LinkedItem else { return }
|
||||
guard let linkedItem = LinkedItem.lookup(byID: itemID, inContext: context) else { return }
|
||||
linkedItem.serverSyncStatus = Int64(syncStatus.rawValue)
|
||||
|
||||
do {
|
||||
|
|
|
|||
|
|
@ -147,13 +147,12 @@ public extension DataService {
|
|||
break
|
||||
case .needsDeletion:
|
||||
item.serverSyncStatus = Int64(ServerSyncStatus.isSyncing.rawValue)
|
||||
syncLinkDeletion(itemID: item.unwrappedID, objectID: item.objectID)
|
||||
syncLinkDeletion(itemID: item.unwrappedID)
|
||||
case .needsUpdate:
|
||||
item.serverSyncStatus = Int64(ServerSyncStatus.isSyncing.rawValue)
|
||||
syncLinkArchiveStatus(itemID: item.unwrappedID, objectID: item.objectID, archived: item.isArchived)
|
||||
syncLinkArchiveStatus(itemID: item.unwrappedID, archived: item.isArchived)
|
||||
syncLinkReadingProgress(
|
||||
itemID: item.unwrappedID,
|
||||
objectID: item.objectID,
|
||||
readingProgress: item.readingProgress,
|
||||
anchorIndex: Int(item.readingProgressAnchor)
|
||||
)
|
||||
|
|
@ -176,15 +175,11 @@ public extension DataService {
|
|||
)
|
||||
case .needsDeletion:
|
||||
highlight.serverSyncStatus = Int64(ServerSyncStatus.isSyncing.rawValue)
|
||||
syncHighlightDeletion(highlightID: highlight.unwrappedID, objectID: highlight.objectID)
|
||||
syncHighlightDeletion(highlightID: highlight.unwrappedID)
|
||||
case .needsUpdate:
|
||||
if let annotation = highlight.annotation {
|
||||
highlight.serverSyncStatus = Int64(ServerSyncStatus.isSyncing.rawValue)
|
||||
syncHighlightAttributes(
|
||||
highlightID: highlight.unwrappedID,
|
||||
objectID: highlight.objectID,
|
||||
annotation: annotation
|
||||
)
|
||||
syncHighlightAttributes(highlightID: highlight.unwrappedID, annotation: annotation)
|
||||
} else {
|
||||
highlight.serverSyncStatus = Int64(ServerSyncStatus.isNSync.rawValue)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ public extension DataService {
|
|||
}
|
||||
}
|
||||
DispatchQueue.main.sync {
|
||||
self.lastItemSyncTime = DateFormatter.formatterISO8601.string(from: Date.now)
|
||||
self.lastItemSyncTime = Date.now
|
||||
onComplete()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,8 +21,7 @@ public extension DataService {
|
|||
|
||||
var localPdfURL: URL?
|
||||
|
||||
let tempPath = FileManager.default
|
||||
.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
||||
let tempPath = URL.om_cachesDirectory
|
||||
.appendingPathComponent(UUID().uuidString + ".pdf")
|
||||
|
||||
try await backgroundContext.perform { [weak self] in
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ extension DataService {
|
|||
contentReader: try $0.contentReader().rawValue,
|
||||
originalHtml: nil,
|
||||
language: try $0.language(),
|
||||
wordsCount: try $0.wordsCount(),
|
||||
recommendations: try $0.recommendations(selection: recommendationSelection.list.nullable) ?? [],
|
||||
labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? []
|
||||
),
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@ public extension DataService {
|
|||
|
||||
let path = appEnvironment.graphqlPath
|
||||
let headers = networker.defaultHeaders
|
||||
let context = backgroundContext
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
send(query, to: path, headers: headers) { queryResult in
|
||||
|
|
|
|||
|
|
@ -68,7 +68,8 @@ extension DataService {
|
|||
)
|
||||
}
|
||||
|
||||
let sort = InputObjects.SortParams(by: Enums.SortBy.updatedTime, order: OptionalArgument(descending ? Enums.SortOrder.descending : Enums.SortOrder.ascending))
|
||||
let sort = InputObjects.SortParams(by: .updatedTime,
|
||||
order: OptionalArgument(descending ? .descending : .ascending))
|
||||
|
||||
let query = Selection.Query {
|
||||
try $0.updatesSince(
|
||||
|
|
@ -275,6 +276,7 @@ private let libraryArticleSelection = Selection.Article {
|
|||
contentReader: try $0.contentReader().rawValue,
|
||||
originalHtml: nil,
|
||||
language: try $0.language(),
|
||||
wordsCount: try $0.wordsCount(),
|
||||
recommendations: try $0.recommendations(selection: recommendationSelection.list.nullable) ?? [],
|
||||
labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? []
|
||||
)
|
||||
|
|
@ -313,6 +315,7 @@ private let searchItemSelection = Selection.SearchItem {
|
|||
contentReader: try $0.contentReader().rawValue,
|
||||
originalHtml: nil,
|
||||
language: try $0.language(),
|
||||
wordsCount: try $0.wordsCount(),
|
||||
recommendations: try $0.recommendations(selection: recommendationSelection.list.nullable) ?? [],
|
||||
labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? []
|
||||
)
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ public struct TypeaheadSearchItem: Identifiable {
|
|||
}
|
||||
|
||||
public extension DataService {
|
||||
// swiftlint:disable:next function_body_length
|
||||
func typeaheadSearch(searchTerm: String) async throws -> [TypeaheadSearchItem] {
|
||||
enum QueryResult {
|
||||
case success(result: [TypeaheadSearchItem])
|
||||
|
|
|
|||
|
|
@ -14,11 +14,12 @@ let highlightLabelSelection = Selection.Label {
|
|||
let highlightSelection = Selection.Highlight {
|
||||
InternalHighlight(
|
||||
id: try $0.id(),
|
||||
type: (try $0.type()).rawValue,
|
||||
shortId: try $0.shortId(),
|
||||
quote: try $0.quote(),
|
||||
quote: try $0.quote() ?? "",
|
||||
prefix: try $0.prefix(),
|
||||
suffix: try $0.suffix(),
|
||||
patch: try $0.patch(),
|
||||
patch: try $0.patch() ?? "",
|
||||
annotation: try $0.annotation(),
|
||||
createdAt: try $0.createdAt().value,
|
||||
updatedAt: try $0.updatedAt().value,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import Models
|
|||
|
||||
struct InternalHighlight: Encodable {
|
||||
let id: String
|
||||
let type: String
|
||||
let shortId: String
|
||||
let quote: String
|
||||
let prefix: String?
|
||||
|
|
@ -28,6 +29,7 @@ struct InternalHighlight: Encodable {
|
|||
|
||||
highlight.markedForDeletion = false
|
||||
highlight.id = id
|
||||
highlight.type = type
|
||||
highlight.shortId = shortId
|
||||
highlight.quote = quote
|
||||
highlight.prefix = prefix
|
||||
|
|
@ -60,6 +62,7 @@ struct InternalHighlight: Encodable {
|
|||
static func make(from highlight: Highlight) -> InternalHighlight {
|
||||
InternalHighlight(
|
||||
id: highlight.id ?? "",
|
||||
type: highlight.type ?? "",
|
||||
shortId: highlight.shortId ?? "",
|
||||
quote: highlight.quote ?? "",
|
||||
prefix: highlight.prefix,
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ struct InternalLinkedItem {
|
|||
let contentReader: String?
|
||||
let originalHtml: String?
|
||||
let language: String?
|
||||
let wordsCount: Int?
|
||||
let recommendations: [InternalRecommendation]
|
||||
var labels: [InternalLinkedItemLabel]
|
||||
|
||||
|
|
@ -63,6 +64,7 @@ struct InternalLinkedItem {
|
|||
linkedItem.contentReader = contentReader
|
||||
linkedItem.originalHtml = originalHtml
|
||||
linkedItem.language = language
|
||||
linkedItem.wordsCount = Int64(wordsCount ?? 0)
|
||||
|
||||
// Remove existing labels in case a label had been deleted
|
||||
if let existingLabels = linkedItem.labels {
|
||||
|
|
@ -142,7 +144,8 @@ extension JSONArticle {
|
|||
contentReader: contentReader,
|
||||
originalHtml: nil,
|
||||
language: language,
|
||||
recommendations: [], // TODO:
|
||||
wordsCount: wordsCount,
|
||||
recommendations: [],
|
||||
labels: []
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -22,10 +22,11 @@ public struct InternalRecommendation {
|
|||
public static func make(_ recommendations: NSSet?) -> [InternalRecommendation] {
|
||||
recommendations?
|
||||
.compactMap { recommendation in
|
||||
if let recommendation = recommendation as? Recommendation,
|
||||
let groupID = recommendation.groupID,
|
||||
let name = recommendation.name,
|
||||
let recommendedAt = recommendation.recommendedAt
|
||||
if
|
||||
let recommendation = recommendation as? Recommendation,
|
||||
let groupID = recommendation.groupID,
|
||||
let name = recommendation.name,
|
||||
let recommendedAt = recommendation.recommendedAt
|
||||
{
|
||||
return InternalRecommendation(
|
||||
groupID: groupID,
|
||||
|
|
|
|||
|
|
@ -36,9 +36,10 @@ public struct InternalRecommendationGroup: Identifiable {
|
|||
}
|
||||
|
||||
public static func make(from recommendationGroup: RecommendationGroup) -> InternalRecommendationGroup? {
|
||||
if let id = recommendationGroup.id,
|
||||
let name = recommendationGroup.name,
|
||||
let inviteUrl = recommendationGroup.inviteUrl
|
||||
if
|
||||
let id = recommendationGroup.id,
|
||||
let name = recommendationGroup.name,
|
||||
let inviteUrl = recommendationGroup.inviteUrl
|
||||
{
|
||||
return InternalRecommendationGroup(
|
||||
id: id,
|
||||
|
|
|
|||
|
|
@ -43,37 +43,39 @@ public struct InternalUserProfile: Identifiable, Encodable {
|
|||
}
|
||||
|
||||
public static func makeSingle(_ user: UserProfile?) -> InternalUserProfile? {
|
||||
if let user = user,
|
||||
let userID = user.userID,
|
||||
let name = user.name,
|
||||
let username = user.username
|
||||
{
|
||||
return InternalUserProfile(
|
||||
userID: userID,
|
||||
name: name,
|
||||
username: username,
|
||||
profileImageURL: user.profileImageURL
|
||||
)
|
||||
guard
|
||||
let user = user,
|
||||
let userID = user.userID,
|
||||
let name = user.name,
|
||||
let username = user.username
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
return InternalUserProfile(
|
||||
userID: userID,
|
||||
name: name,
|
||||
username: username,
|
||||
profileImageURL: user.profileImageURL
|
||||
)
|
||||
}
|
||||
|
||||
public static func make(_ users: NSSet?) -> [InternalUserProfile] {
|
||||
users?
|
||||
.compactMap { user in
|
||||
if let user = user as? UserProfile,
|
||||
let userID = user.userID,
|
||||
let name = user.name,
|
||||
let username = user.username
|
||||
{
|
||||
return InternalUserProfile(
|
||||
userID: userID,
|
||||
name: name,
|
||||
username: username,
|
||||
profileImageURL: user.profileImageURL
|
||||
)
|
||||
guard
|
||||
let user = user as? UserProfile,
|
||||
let userID = user.userID,
|
||||
let name = user.name,
|
||||
let username = user.username
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
return InternalUserProfile(
|
||||
userID: userID,
|
||||
name: name,
|
||||
username: username,
|
||||
profileImageURL: user.profileImageURL
|
||||
)
|
||||
} ?? []
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,7 +68,6 @@ public extension Color {
|
|||
)
|
||||
}
|
||||
|
||||
// TODO: remove this?
|
||||
var isDark: Bool {
|
||||
guard let lum = luminance else { return false }
|
||||
return lum < 0.50
|
||||
|
|
@ -87,7 +86,17 @@ public extension Color {
|
|||
}
|
||||
#endif
|
||||
|
||||
return 0.2126 * Float(components[0]) + 0.7152 * Float(components[1]) + 0.0722 * Float(components[2])
|
||||
let colorR = channelColor(components[0]) * 0.2126
|
||||
let colorG = channelColor(components[1]) * 0.7152
|
||||
let colorB = channelColor(components[2]) * 0.0722
|
||||
|
||||
return Float(colorR + colorG + colorB)
|
||||
}
|
||||
|
||||
private func channelColor(_ channel: CGFloat) -> Double {
|
||||
channel <= 0.03928
|
||||
? channel / 12.92
|
||||
: pow((channel + 0.055) / 1.055, 2.4)
|
||||
}
|
||||
|
||||
static var isDarkMode: Bool {
|
||||
|
|
|
|||
|
|
@ -12,5 +12,5 @@ public enum FeatureFlag {
|
|||
public static let enableShareButton = false
|
||||
public static let enableSnooze = false
|
||||
public static let enableGridCardsOnPhone = false
|
||||
public static let enableUltraRealisticVoices = false
|
||||
public static let enableUltraRealisticVoices = true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,8 +10,7 @@ import QuickLookThumbnailing
|
|||
public enum PDFUtils {
|
||||
public static func copyToLocal(url: URL) throws -> String {
|
||||
let subPath = UUID().uuidString + ".pdf"
|
||||
let dest = FileManager.default
|
||||
.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
let dest = URL.om_documentsDirectory
|
||||
.appendingPathComponent(subPath)
|
||||
|
||||
try FileManager.default.copyItem(at: url, to: dest)
|
||||
|
|
@ -20,8 +19,7 @@ public enum PDFUtils {
|
|||
|
||||
public static func moveToLocal(url: URL) throws -> String {
|
||||
let subPath = UUID().uuidString + ".pdf"
|
||||
let dest = FileManager.default
|
||||
.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
let dest = URL.om_documentsDirectory
|
||||
.appendingPathComponent(subPath)
|
||||
|
||||
try FileManager.default.moveItem(at: url, to: dest)
|
||||
|
|
@ -29,8 +27,7 @@ public enum PDFUtils {
|
|||
}
|
||||
|
||||
public static func localPdfURL(filename: String) -> URL? {
|
||||
let url = FileManager.default
|
||||
.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
let url = URL.om_documentsDirectory
|
||||
.appendingPathComponent(filename)
|
||||
|
||||
return url
|
||||
|
|
|
|||
BIN
apple/OmnivoreKit/Sources/Utils/Resources/VoiceSamples/Adam.mp3
Normal file
BIN
apple/OmnivoreKit/Sources/Utils/Resources/VoiceSamples/Adam.mp3
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue