Storage containers implemented

WIP: Select download path

Storage containers implemented

Rework torrents Array to Dictionary
This commit is contained in:
Daniil Vinogradov
2024-07-02 00:51:31 +02:00
parent 299b516fb3
commit b5d619ed5b
41 changed files with 1138 additions and 112 deletions
@@ -18,6 +18,7 @@ extension ProgressWidgetAttributes {
case seeding
case checkingResumeData
case paused
case storageError
}
}
@@ -38,6 +39,8 @@ extension ProgressWidgetAttributes.State {
return %"torrent.state.resuming"
case .paused:
return %"torrent.state.paused"
case .storageError:
return %"torrent.state.storageError"
}
}
}
@@ -149,6 +149,8 @@ struct ProgressWidgetLiveActivityWatchSupportContent: View {
case .paused:
Text(context.state.state.name)
Spacer()
case .storageError:
EmptyView()
}
}
.font(.caption2)
@@ -208,6 +210,8 @@ struct ProgressWidgetLiveActivityContent: View {
Text(context.state.state.name)
case .paused:
Text(context.state.state.name)
case .storageError:
EmptyView()
}
Spacer()
@@ -253,6 +257,8 @@ struct LeadingView: View {
Image(systemName: "arrow.triangle.2.circlepath")
case .paused:
Image(systemName: "pause.fill")
case .storageError:
EmptyView()
}
}
}
+50 -10
View File
@@ -51,6 +51,8 @@
7C5FBE702BC2EF8B0069E5A0 /* EditTextViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C5FBE6D2BC2EF8B0069E5A0 /* EditTextViewController.swift */; };
7C5FBE742BC2F0A30069E5A0 /* MvvmViewModelProtocol+Alert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C5FBE732BC2F0A30069E5A0 /* MvvmViewModelProtocol+Alert.swift */; };
7C609A5B2C1F5D9E00586635 /* SceneDelegate+AVPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C609A5A2C1F5D9E00586635 /* SceneDelegate+AVPlayer.swift */; };
7C95B7B42C35DE5C000EC50F /* StoragePreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C95B7B32C35DE49000EC50F /* StoragePreferencesView.swift */; };
7C95B7B72C385B99000EC50F /* UICellAccessory+Image.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C95B7B62C385B97000EC50F /* UICellAccessory+Image.swift */; };
7CAD30192BC3455800592990 /* SWXMLHash in Frameworks */ = {isa = PBXBuildFile; productRef = 7CAD30182BC3455800592990 /* SWXMLHash */; };
7CAD301C2BC3457900592990 /* RssModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CAD301B2BC3457900592990 /* RssModel.swift */; };
7CAD301E2BC347D900592990 /* Published+Codable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CAD301D2BC347D900592990 /* Published+Codable.swift */; };
@@ -205,6 +207,8 @@
D1EFCD172AF6AE1600D33A7A /* TorrentFilesDictionaryItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1EFCD162AF6AE1600D33A7A /* TorrentFilesDictionaryItemView.swift */; };
D1EFCD192AF6AEC700D33A7A /* TorrentFilesDictionaryItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1EFCD182AF6AEC700D33A7A /* TorrentFilesDictionaryItemViewModel.swift */; };
D1F8BC852AFC405A00A6258C /* MvvmViewModel+Alert.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1F8BC842AFC405A00A6258C /* MvvmViewModel+Alert.swift */; };
D1FFC9652C38136600233C2F /* URL+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1FFC9642C38135F00233C2F /* URL+Extensions.swift */; };
D1FFC96C2C382FC900233C2F /* StorageModel+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1FFC96B2C382FBE00233C2F /* StorageModel+Extensions.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -287,6 +291,8 @@
7C609A572C1F1D6400586635 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
7C609A592C1F2A2700586635 /* iTorrent.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = iTorrent.xcconfig; sourceTree = "<group>"; };
7C609A5A2C1F5D9E00586635 /* SceneDelegate+AVPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SceneDelegate+AVPlayer.swift"; sourceTree = "<group>"; };
7C95B7B32C35DE49000EC50F /* StoragePreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoragePreferencesView.swift; sourceTree = "<group>"; };
7C95B7B62C385B97000EC50F /* UICellAccessory+Image.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UICellAccessory+Image.swift"; sourceTree = "<group>"; };
7CAD301B2BC3457900592990 /* RssModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RssModel.swift; sourceTree = "<group>"; };
7CAD301D2BC347D900592990 /* Published+Codable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Published+Codable.swift"; sourceTree = "<group>"; };
7CAD301F2BC34BCE00592990 /* RssFeedProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RssFeedProvider.swift; sourceTree = "<group>"; };
@@ -437,6 +443,8 @@
D1EFCD162AF6AE1600D33A7A /* TorrentFilesDictionaryItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TorrentFilesDictionaryItemView.swift; sourceTree = "<group>"; };
D1EFCD182AF6AEC700D33A7A /* TorrentFilesDictionaryItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TorrentFilesDictionaryItemViewModel.swift; sourceTree = "<group>"; };
D1F8BC842AFC405A00A6258C /* MvvmViewModel+Alert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MvvmViewModel+Alert.swift"; sourceTree = "<group>"; };
D1FFC9642C38135F00233C2F /* URL+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Extensions.swift"; sourceTree = "<group>"; };
D1FFC96B2C382FBE00233C2F /* StorageModel+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StorageModel+Extensions.swift"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -597,6 +605,22 @@
path = MvvmFoundation;
sourceTree = "<group>";
};
7C95B7A32C34B554000EC50F /* Storage */ = {
isa = PBXGroup;
children = (
7C95B7B32C35DE49000EC50F /* StoragePreferencesView.swift */,
);
path = Storage;
sourceTree = "<group>";
};
7C95B7B52C385B8E000EC50F /* UIKit */ = {
isa = PBXGroup;
children = (
7C95B7B62C385B97000EC50F /* UICellAccessory+Image.swift */,
);
path = UIKit;
sourceTree = "<group>";
};
7CAD301A2BC3456700592990 /* RssFeed */ = {
isa = PBXGroup;
children = (
@@ -764,6 +788,7 @@
D111384C2AF9663F008907F7 /* Preferences */ = {
isa = PBXGroup;
children = (
7C95B7A32C34B554000EC50F /* Storage */,
D1DB718E2BD92206007F9267 /* Patreon */,
7CB6F6CC2BD82B8A00D0813B /* FileSharing */,
D1352D1F2BBC338300104E7B /* Base */,
@@ -786,8 +811,8 @@
D11333B32AF19C3400FA017E /* TorrentService */ = {
isa = PBXGroup;
children = (
D1FFC9632C37F44800233C2F /* Extensions */,
D1A226F12AEF019400669D6D /* TorrentService.swift */,
D11333B42AF19C4900FA017E /* TorrentHandle+Extension.swift */,
);
path = TorrentService;
sourceTree = "<group>";
@@ -999,6 +1024,7 @@
D19E00242AF045DF000A17A2 /* Extensions */ = {
isa = PBXGroup;
children = (
7C95B7B52C385B8E000EC50F /* UIKit */,
7C5FBE722BC2F0940069E5A0 /* MvvmFoundation */,
D173D9DF2BC0285800E4F9EB /* UIMenu */,
7C5FBE2F2BBF5C990069E5A0 /* SwiftUI */,
@@ -1006,6 +1032,7 @@
D1048D872BBEA23C0027EF2F /* Combine */,
D1352D282BBD418200104E7B /* UIViewController */,
D1D1279A2BC7CA7600C04533 /* SwiftUILayoutGuides.swift */,
D1FFC9642C38135F00233C2F /* URL+Extensions.swift */,
D19E00252AF045F9000A17A2 /* SpeedFormat.swift */,
D1ACFDC82AF6ED200098FF56 /* UIImage+File.swift */,
D1AA00D12AFAC95500B74629 /* String+Localized.swift */,
@@ -1305,6 +1332,15 @@
path = TorrentFilesDictionaryItem;
sourceTree = "<group>";
};
D1FFC9632C37F44800233C2F /* Extensions */ = {
isa = PBXGroup;
children = (
D11333B42AF19C4900FA017E /* TorrentHandle+Extension.swift */,
D1FFC96B2C382FBE00233C2F /* StorageModel+Extensions.swift */,
);
path = Extensions;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -1497,6 +1533,7 @@
buildActionMask = 2147483647;
files = (
7CF6DA352C0F3DB90033D03F /* BCHostingConfiguration.swift in Sources */,
D1FFC96C2C382FC900233C2F /* StorageModel+Extensions.swift in Sources */,
D1F8BC852AFC405A00A6258C /* MvvmViewModel+Alert.swift in Sources */,
D17871FF2AF2C0A700F11F9F /* UIKitSwiftUIInarop.swift in Sources */,
D135C59C2AEFB96F00440680 /* TorrentDetailsViewModel.swift in Sources */,
@@ -1528,6 +1565,7 @@
7C5FBE642BC158BC0069E5A0 /* SceneDelegate+URLProcessing.swift in Sources */,
D1A2269E2AEEEFCC00669D6D /* AppDelegate.swift in Sources */,
7C5FBE702BC2EF8B0069E5A0 /* EditTextViewController.swift in Sources */,
7C95B7B42C35DE5C000EC50F /* StoragePreferencesView.swift in Sources */,
7CFEBEA02BC6F3CD0013233F /* Date+Extensions.swift in Sources */,
D1DB718B2BD91606007F9267 /* ImageCache.swift in Sources */,
7CBDBAAD2C31EF0C008C986B /* UserDefaults+AppGroup.swift in Sources */,
@@ -1572,6 +1610,7 @@
7CAD301C2BC3457900592990 /* RssModel.swift in Sources */,
7CFEBE882BC439C30013233F /* RssChannelItemCell.swift in Sources */,
7C5FBE312BBF5CAB0069E5A0 /* MinHeightModifier.swift in Sources */,
7C95B7B72C385B99000EC50F /* UICellAccessory+Image.swift in Sources */,
D1EFCD192AF6AEC700D33A7A /* TorrentFilesDictionaryItemViewModel.swift in Sources */,
D1B99D892BEE613B00F51514 /* PatreonWebServer.swift in Sources */,
7CFEBE762BC3ED550013233F /* RssFeedCellViewModel.swift in Sources */,
@@ -1586,6 +1625,7 @@
D135C5992AEFB96100440680 /* TorrentDetailsViewController.swift in Sources */,
D1048D8B2BBEB6DE0027EF2F /* CombineLatest.swift in Sources */,
D11138572AF976CE008907F7 /* TorrentAddDirectoryItemViewModel.swift in Sources */,
D1FFC9652C38136600233C2F /* URL+Extensions.swift in Sources */,
7C1C08AB2C31F31900569B45 /* PauseTorrentIntent.swift in Sources */,
D1CAB8872AF3B52E00EB6AFF /* ToggleCellViewModel.swift in Sources */,
D11BE5492AFBA03D00780C1B /* PRButtonView.swift in Sources */,
@@ -1706,7 +1746,6 @@
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "iTorrent2 ProgressWidget Dev";
SKIP_INSTALL = YES;
SUPPORTS_MACCATALYST = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited)";
SWIFT_EMIT_LOC_STRINGS = YES;
TARGETED_DEVICE_FAMILY = "1,2";
};
@@ -1748,7 +1787,7 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 2;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
@@ -1769,7 +1808,7 @@
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.1;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 2.0.5;
MARKETING_VERSION = 2.0.6;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
@@ -1778,6 +1817,7 @@
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_STRICT_CONCURRENCY = minimal;
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = RelWithDebInfo;
};
@@ -1851,7 +1891,6 @@
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "iTorrent2 ProgressWidget Dev";
SKIP_INSTALL = YES;
SUPPORTS_MACCATALYST = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited)";
SWIFT_EMIT_LOC_STRINGS = YES;
TARGETED_DEVICE_FAMILY = "1,2";
};
@@ -1893,7 +1932,7 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 2;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
@@ -1909,7 +1948,7 @@
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.1;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 2.0.5;
MARKETING_VERSION = 2.0.6;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
@@ -1918,6 +1957,7 @@
SWIFT_STRICT_CONCURRENCY = minimal;
SWIFT_VERSION = 5.0;
VALIDATE_PRODUCT = YES;
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
};
@@ -1991,7 +2031,6 @@
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "iTorrent2 ProgressWidget Distrib";
SKIP_INSTALL = YES;
SUPPORTS_MACCATALYST = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited)";
SWIFT_EMIT_LOC_STRINGS = YES;
TARGETED_DEVICE_FAMILY = "1,2";
};
@@ -2033,7 +2072,7 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 2;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
@@ -2054,7 +2093,7 @@
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.1;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 2.0.5;
MARKETING_VERSION = 2.0.6;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
@@ -2063,6 +2102,7 @@
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_STRICT_CONCURRENCY = minimal;
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
};
@@ -0,0 +1,168 @@
{
"originHash" : "11bb8e91059647586820412f0c343b58cc80736343ec98a17b3c6d40900620dd",
"pins" : [
{
"identity" : "abseil-cpp-binary",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/abseil-cpp-binary.git",
"state" : {
"revision" : "748c7837511d0e6a507737353af268484e1745e2",
"version" : "1.2024011601.1"
}
},
{
"identity" : "app-check",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/app-check.git",
"state" : {
"revision" : "3b62f154d00019ae29a71e9738800bb6f18b236d",
"version" : "10.19.2"
}
},
{
"identity" : "combinecocoa",
"kind" : "remoteSourceControl",
"location" : "https://github.com/XITRIX/CombineCocoa.git",
"state" : {
"branch" : "main",
"revision" : "c1bd62b53589e053c56958beebb6d9aee1367d5d"
}
},
{
"identity" : "firebase-ios-sdk",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/firebase-ios-sdk",
"state" : {
"revision" : "e57841b296d04370ea23580f908881b0ccab17b9",
"version" : "10.28.1"
}
},
{
"identity" : "googleappmeasurement",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleAppMeasurement.git",
"state" : {
"revision" : "fe727587518729046fc1465625b9afd80b5ab361",
"version" : "10.28.0"
}
},
{
"identity" : "googledatatransport",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleDataTransport.git",
"state" : {
"revision" : "a637d318ae7ae246b02d7305121275bc75ed5565",
"version" : "9.4.0"
}
},
{
"identity" : "googleutilities",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleUtilities.git",
"state" : {
"revision" : "57a1d307f42df690fdef2637f3e5b776da02aad6",
"version" : "7.13.3"
}
},
{
"identity" : "grpc-binary",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/grpc-binary.git",
"state" : {
"revision" : "e9fad491d0673bdda7063a0341fb6b47a30c5359",
"version" : "1.62.2"
}
},
{
"identity" : "gtm-session-fetcher",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/gtm-session-fetcher.git",
"state" : {
"revision" : "a2ab612cb980066ee56d90d60d8462992c07f24b",
"version" : "3.5.0"
}
},
{
"identity" : "interop-ios-for-google-sdks",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/interop-ios-for-google-sdks.git",
"state" : {
"revision" : "2d12673670417654f08f5f90fdd62926dc3a2648",
"version" : "100.0.0"
}
},
{
"identity" : "leveldb",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/leveldb.git",
"state" : {
"revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1",
"version" : "1.22.5"
}
},
{
"identity" : "marqueelabel",
"kind" : "remoteSourceControl",
"location" : "https://github.com/cbpowell/MarqueeLabel.git",
"state" : {
"revision" : "877e810534cda9afabb8143ae319b7c3341b121b",
"version" : "4.5.0"
}
},
{
"identity" : "marqueetext",
"kind" : "remoteSourceControl",
"location" : "https://github.com/joekndy/MarqueeText.git",
"state" : {
"branch" : "master",
"revision" : "e18d514455563ad00581e0d825146c6f15b1c6c4"
}
},
{
"identity" : "nanopb",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/nanopb.git",
"state" : {
"revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1",
"version" : "2.30910.0"
}
},
{
"identity" : "openssl",
"kind" : "remoteSourceControl",
"location" : "https://github.com/krzyzanowskim/OpenSSL.git",
"state" : {
"revision" : "bb0cbdf1e01c6a9fde4077f041abc766ea48bd7f",
"version" : "3.1.5006"
}
},
{
"identity" : "promises",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/promises.git",
"state" : {
"revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac",
"version" : "2.4.0"
}
},
{
"identity" : "swift-protobuf",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-protobuf.git",
"state" : {
"revision" : "9f0c76544701845ad98716f3f6a774a892152bcb",
"version" : "1.26.0"
}
},
{
"identity" : "swxmlhash",
"kind" : "remoteSourceControl",
"location" : "https://github.com/drmohundro/SWXMLHash.git",
"state" : {
"revision" : "a853604c9e9a83ad9954c7e3d2a565273982471f",
"version" : "7.0.2"
}
}
],
"version" : 3
}
+56
View File
@@ -58,3 +58,59 @@ class BaseViewController<ViewModel: MvvmViewModelProtocol>: SAViewController<Vie
return titleLabel
}()
}
class BaseHostingViewController<View: MvvmSwiftUIViewProtocol>: SAHostingViewController<View>, ToolbarHidingProtocol {
var isToolbarItemsHidden: Bool { toolbarItems?.isEmpty ?? true }
var useMarqueeLabel: Bool { true }
// override var title: String? {
// get { super.title }
// set {
// super.title = newValue
// titleLabel.text = newValue
// titleLabel.sizeToFit()
// }
// }
override func viewDidLoad() {
super.viewDidLoad()
#if os(visionOS)
view.backgroundColor = nil
#endif
// #if !os(visionOS) // Not renders properly on VisionOS
// if useMarqueeLabel {
// navigationItem.titleView = titleLabel
// titleLabel.sizeToFit()
// }
// #endif
title = rootView.title
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
navigationController?.setToolbarHidden(isToolbarItemsHidden, animated: false)
if navigationController?.viewControllers.count == 1 {
navigationItem.trailingItemGroups = [.fixedGroup(items: [
UIBarButtonItem(systemItem: .close, primaryAction: .init { [unowned self] _ in
dismiss(animated: true)
})
])]
}
}
private let titleLabel: MarqueeLabel = {
let titleLabel = MarqueeLabel()
#if !os(visionOS)
titleLabel.font = .preferredFont(forTextStyle: .headline)
#else
titleLabel.font = .preferredFont(forTextStyle: .title1)
#endif
titleLabel.fadeLength = 16
titleLabel.adjustsFontForContentSizeCategory = true
titleLabel.textAlignment = .center
return titleLabel
}()
}
@@ -17,7 +17,8 @@ struct DetailCellView: MvvmSwiftUICellProtocol {
if isHorizontal {
HStack {
Text(viewModel.title)
.fontWeight(.semibold)
.fontWeight(viewModel.isBold ? .semibold : .regular)
.foregroundStyle(viewModel.isEnabled ? Color.primary : Color.secondary)
Spacer(minLength: viewModel.spacer)
Text(LocalizedStringKey(viewModel.detail))
.foregroundStyle(Color.accentColor)
@@ -43,6 +44,7 @@ struct DetailCellView: MvvmSwiftUICellProtocol {
cell.contentConfiguration = UIHostingConfiguration {
Self(viewModel: itemIdentifier)
}
cell.isUserInteractionEnabled = itemIdentifier.isEnabled
cell.accessories = itemIdentifier.selectAction == nil ? [] : [.disclosureIndicator(displayed: .always)]
}
}
@@ -22,12 +22,16 @@ class DetailCellViewModel: BaseViewModel, ObservableObject, MvvmSelectableProtoc
@Published var title: String = ""
@Published var detail: String = ""
@Published var spacer: Double = 0
@Published var isBold: Bool = true
@Published var isEnabled: Bool = true
init(title: String = "", detail: String = "", spacer: Double = 24, selectAction: (() -> Void)? = nil) {
init(title: String = "", detail: String = "", spacer: Double = 24, isBold: Bool = true, isEnabled: Bool = true, selectAction: (() -> Void)? = nil) {
self.title = title
self.detail = detail
self.spacer = spacer
self.selectAction = selectAction
self.isEnabled = isEnabled
self.isBold = isBold
}
required init() {
@@ -26,7 +26,7 @@ extension AppDelegate: UNUserNotificationCenterDelegate {
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async {
guard let hash = response.notification.request.content.userInfo["hash"] as? String,
let torrentHandle = TorrentService.shared.torrents.first(where: { $0.snapshot.infoHashes.best.hex == hash })
let torrentHandle = TorrentService.shared.torrents.values.first(where: { $0.snapshot.infoHashes.best.hex == hash })
else { return }
Self.showTorrentDetailScreen(with: torrentHandle)
+273
View File
@@ -102,6 +102,38 @@
}
}
},
"addTorrent.storage.manage" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Manage..."
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "Настроить..."
}
}
}
},
"addTorrent.storage.selected" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Select storage"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "Выберите хранилище"
}
}
}
},
"common.add" : {
"localizations" : {
"en" : {
@@ -150,6 +182,22 @@
}
}
},
"common.continue" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Continue"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "Продолжить"
}
}
}
},
"common.delete" : {
"localizations" : {
"en" : {
@@ -647,6 +695,55 @@
}
}
},
"details.path" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Storage"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "Хранилище"
}
}
}
},
"details.path.browse" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Current"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "Текущее"
}
}
}
},
"details.path.migrate" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Migrate storage"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "Мигрировать хранилище"
}
}
}
},
"details.pause" : {
"localizations" : {
"en" : {
@@ -663,6 +760,54 @@
}
}
},
"details.refreshStorage.fail.message" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Storage not found"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "Хранилище не найдено"
}
}
}
},
"details.refreshStorage.message" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "iTorrent will try to find missing storage"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "iTorrent попробует найти утерянное хранилище"
}
}
}
},
"details.refreshStorage.title" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Refresh storage"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "Перезагрузить хранилище"
}
}
}
},
"details.rehash" : {
"localizations" : {
"en" : {
@@ -2975,6 +3120,38 @@
}
}
},
"preferences.storage.add" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Add more..."
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "Добавить..."
}
}
}
},
"preferences.storage.add.error.notDirectory" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Selected path is not a directory"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "Выбранный путь не является директорией"
}
}
}
},
"preferences.storage.allocate" : {
"localizations" : {
"en" : {
@@ -3007,6 +3184,86 @@
}
}
},
"preferences.storage.storages" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Storages"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "Хранилища"
}
}
}
},
"preferences.storage.storages.available%lld/%lld" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Available: %1$lld/%2$lld"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "Доступно: %1$lld/%2$lld"
}
}
}
},
"preferences.storage.warning.accept" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "I understood, continue"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "Я понял, продолжить"
}
}
}
},
"preferences.storage.warning.message" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "In case of using external drives, you should eject it only after closing iTorrent app. Otherwise iTorrent cannot garantee safety storing of your downloaded files and could potentially corrups current states of torrents from ejected drive."
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "В случае использования внешних дисков извлекать их следует только после закрытия приложения iTorrent. В противном случае iTorrent не может гарантировать безопасное хранение загруженных вами файлов и потенциально может испортить текущее состояние торрентов с извлеченного диска."
}
}
}
},
"preferences.storage.warning.title" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "WARNING!"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "ВНИМАНИЕ!"
}
}
}
},
"preferences.version" : {
"localizations" : {
"en" : {
@@ -3775,6 +4032,22 @@
}
}
},
"torrent.state.storageError" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Storage error"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "Ошибка хранилища"
}
}
}
},
"tracker.leeches" : {
"localizations" : {
"en" : {
@@ -27,7 +27,7 @@ private extension SceneDelegate {
guard url.absoluteString.hasPrefix(prefix) else { return false }
let hash = url.absoluteString.replacingOccurrences(of: prefix, with: "")
guard let torrent = TorrentService.shared.torrents.first(where: { $0.snapshot.infoHashes.best.hex == hash })
guard let torrent = TorrentService.shared.torrents.values.first(where: { $0.snapshot.infoHashes.best.hex == hash })
else { return false }
AppDelegate.showTorrentDetailScreen(with: torrent)
@@ -52,6 +52,8 @@ class SceneDelegate: MvvmSceneDelegate {
router.register(PRColorPickerCell.self)
// MARK: Controllers
router.register(BaseHostingViewController<StoragePreferencesView>.self)
router.register(TorrentListViewController<TorrentListViewModel>.self)
router.register(TorrentDetailsViewController<TorrentDetailsViewModel>.self)
router.register(TorrentFilesViewController<TorrentFilesViewModel>.self)
@@ -14,6 +14,8 @@ struct PRButtonView: MvvmSwiftUICellProtocol {
var body: some View {
HStack {
Text(viewModel.title)
.fontWeight(viewModel.isBold ? .semibold : .regular)
.foregroundStyle(viewModel.tintedTitle ? Color.accentColor : Color.primary)
Spacer()
Text(viewModel.value)
.foregroundStyle(viewModel.tinted ? Color.accentColor : Color.secondary)
@@ -21,6 +23,15 @@ struct PRButtonView: MvvmSwiftUICellProtocol {
.multilineTextAlignment(.trailing)
}
.systemMinimumHeight()
.swipeActions {
if let removeAction = viewModel.removeAction {
Button(role: .destructive) {
removeAction()
} label: {
Image(systemName: "trash")
}
}
}
}
static var registration: UICollectionView.CellRegistration<MvvmCollectionViewListCell<PRButtonViewModel>, PRButtonViewModel> = .init { cell, indexPath, itemIdentifier in
@@ -12,13 +12,16 @@ import SwiftUI
extension PRButtonViewModel {
struct Config {
var id: String?
var removeAction: (() -> Void)? = nil
var title: String
var value: AnyPublisher<String, Never>?// = Just("").eraseToAnyPublisher()
var tintedTitle: Bool = false
var isBold: Bool = false
var value: AnyPublisher<String, Never>?
var canReorder: Bool = false
var tinted: Bool = true
var singleLine: Bool = false
var accessories: [UICellAccessory] = []
var selectAction: () -> Void = {}
var selectAction: (() -> Void)? = {}
}
}
@@ -27,17 +30,22 @@ class PRButtonViewModel: BaseViewModelWith<PRButtonViewModel.Config>, Observable
var id: String?
@Published var title = ""
@Published var tintedTitle: Bool = false
@Published var isBold: Bool = false
@Published var value: String = ""
@Published var tinted: Bool = true
@Published var singleLine: Bool = false
@Published var canReorder: Bool = false
@Published var accessories: [UICellAccessory] = []
@Published var removeAction: (() -> Void)?
var metadata: Any?
override func prepare(with model: Config) {
id = model.id
title = model.title
tintedTitle = model.tintedTitle
isBold = model.isBold
if let value = model.value {
value.assign(to: &self.$value)
}
@@ -46,15 +54,21 @@ class PRButtonViewModel: BaseViewModelWith<PRButtonViewModel.Config>, Observable
canReorder = model.canReorder
tinted = model.tinted
singleLine = model.singleLine
removeAction = model.removeAction
}
override func isEqual(to other: MvvmViewModel) -> Bool {
guard let other = other as? Self else { return false }
return id == other.id && title == other.title
return id == other.id &&
title == other.title &&
isBold == other.isBold &&
accessories.count == other.accessories.count
}
override func hash(into hasher: inout Hasher) {
hasher.combine(id)
hasher.combine(title)
hasher.combine(isBold)
hasher.combine(accessories.count)
}
}
@@ -60,7 +60,13 @@ private extension PreferencesViewModel {
sections.append(.init(id: "memory", header: %"preferences.storage") {
storageVM
PRSwitchViewModel(with: .init(title: %"preferences.storage.allocate", value: preferences.$allocateMemory.binding))
PRButtonViewModel(with: .init(title: %"preferences", accessories: [.disclosureIndicator()]) { [unowned self] in
navigate(to: StoragePreferencesViewModel.self, by: .show)
})
// PRButtonViewModel(with: .init(title: "preferences2", accessories: [.disclosureIndicator()]) { [unowned self] in
// navigate(to: StoragePreferencesViewModel.self, by: .show)
//// navigate(to: SUIStoragePreferencesViewModel.self, by: .show)
// })
})
#if IS_SUPPORT_LOCATION_BG
@@ -0,0 +1,187 @@
//
// StoragePreferencesView.swift
// iTorrent
//
// Created by Даниил Виноградов on 03.07.2024.
//
import LibTorrent
import MvvmFoundation
import SwiftUI
import UniformTypeIdentifiers
class StoragePreferencesViewModel: BaseViewModel, ObservableObject {
@Published var allocateMemory: Bool = false
@Published var customStoragesVM: [UUID: StorageModel] = [:]
@Published var currentStorage: UUID?
@Published var isStorageRulesAccepted: Bool = false
required init() {
super.init()
allocateMemory = preferences.allocateMemory
preferences.$storageScopes.assign(to: &$customStoragesVM)
isStorageRulesAccepted = preferences.isStorageRulesAccepted
preferences.$isStorageRulesAccepted.assign(to: &$isStorageRulesAccepted)
preferences.$defaultStorage.assign(to: &$currentStorage)
disposeBag.bind {
$allocateMemory.sink { [unowned self] in preferences.allocateMemory = $0 }
}
}
@Injected var preferences: PreferencesStorage
}
struct StoragePreferencesView<VM: StoragePreferencesViewModel>: MvvmSwiftUIViewProtocol {
@ObservedObject var viewModel: VM
@State var filePickerPresented: Bool = false
let storagesLimit = 5
var title: String = %"preferences.storage"
init(viewModel: VM) {
self.viewModel = viewModel
}
var body: some View {
List {
Section {
Toggle("preferences.storage.allocate", isOn: $viewModel.allocateMemory)
}
if !viewModel.isStorageRulesAccepted {
Section {
VStack(spacing: 16) {
VStack(spacing: 8) {
Text("preferences.storage.warning.title")
.fontWeight(.semibold)
Text("preferences.storage.warning.message")
}
Button {
withAnimation {
viewModel.preferences.isStorageRulesAccepted = true
}
} label: {
Spacer()
Text("preferences.storage.warning.accept")
.fontWeight(.semibold)
Spacer()
}
.frame(maxWidth: .infinity)
.buttonStyle(.bordered)
.buttonBorderShape(.capsule)
.controlSize(.large)
.accentColor(.red)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
}
.listRowBackground(Color.red.opacity(0.1))
.listRowSeparator(.hidden)
}
Section {
Button {
viewModel.preferences.defaultStorage = nil
} label: {
HStack {
Text(StorageModel.defaultName)
.foregroundStyle(Color.primary)
Spacer()
if viewModel.preferences.defaultStorage == nil {
Image(systemName: "checkmark")
.foregroundColor(.accentColor)
.fontWeight(.medium)
}
}
}
ForEach(Array(viewModel.customStoragesVM.values.sorted(by: { $0.name.localizedStandardCompare($1.name) == .orderedAscending }))) { scope in
Button {
if scope.allowed {
viewModel.preferences.defaultStorage = scope.uuid
}
} label: {
HStack {
Text(scope.name)
.foregroundStyle(scope.allowed ? Color.primary : Color.secondary)
Spacer()
if viewModel.preferences.defaultStorage == scope.uuid {
Image(systemName: "checkmark")
.foregroundColor(.accentColor)
.fontWeight(.medium)
} else if !scope.allowed {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.red)
}
}
}.swipeActions {
Button(role: .destructive) {
viewModel.preferences.storageScopes[scope.uuid] = nil
if viewModel.preferences.defaultStorage == scope.uuid {
viewModel.preferences.defaultStorage = nil
}
} label: {
Image(systemName: "trash")
}
}
}
if viewModel.customStoragesVM.count < storagesLimit - 1 {
Button("preferences.storage.add") {
filePickerPresented = true
}.disabled(!viewModel.isStorageRulesAccepted)
}
} header: {
HStack {
Text("preferences.storage.storages")
Spacer()
Text("preferences.storage.storages.available\(viewModel.customStoragesVM.count + 1)/\(storagesLimit)")
}
}
}.fileImporter(isPresented: $filePickerPresented, allowedContentTypes: [.folder]) { result in
guard let url = try? result.get() else { return }
guard url.isDirectory else {
return viewModel.alert(title: %"common.error", message: %"preferences.storage.add.error.notDirectory", actions: [
.init(title: %"common.close", style: .cancel)
])
}
let allowed = url.startAccessingSecurityScopedResource()
print("Path - \(url) | write permissions - \(allowed)")
guard let bookmark = try? url.bookmarkData(options: [.minimalBookmark])
else { return }
if let storage = viewModel.preferences.storageScopes.values.first(where: {
$0.url == url || $0.url == TorrentService.downloadPath
}) {
storage.pathBookmark = bookmark
return
}
let storage = StorageModel()
storage.uuid = UUID()
storage.name = url.lastPathComponent
storage.url = url
storage.allowed = allowed
storage.resolved = true
do {
let name = try url.resourceValues(forKeys: [.localizedNameKey])
if let name = name.allValues[.localizedNameKey] as? String {
storage.name = name
}
} catch {}
storage.pathBookmark = bookmark
withAnimation {
viewModel.preferences.storageScopes[storage.uuid] = storage
}
}
}
}
@@ -11,18 +11,18 @@ import UIKit
class TorrentAddViewController<VM: TorrentAddViewModel>: BaseViewController<VM> {
@IBOutlet private var collectionView: UICollectionView!
private lazy var delegates = Deletates(parent: self)
private lazy var dataPickerDelegate = DataPickerDelegate(parent: self)
private let cancelButton = UIBarButtonItem(systemItem: .close)
private let downloadButton = UIModernBarButtonItem(image: .init(systemName: "arrow.down"))
private let diskLabel = makeDiskLabel()
private let moreButton = UIBarButtonItem(title: "More", image: .init(systemName: "ellipsis.circle"))
private let priorityButton = UIBarButtonItem(title: %"prioriry.change.title", image: .init(resource: .icSort))
private let storageButton = UIBarButtonItem(title: %"addTorrent.storage.selected", image: .init(systemName: "externaldrive"))
override func viewDidLoad() {
super.viewDidLoad()
title = viewModel.title
moreButton.menu = .makeForChangePriority { [unowned self] priority in
viewModel.setAllFilesPriority(priority)
}
updateMenu()
collectionView.register(TorrentFilesDictionaryItemViewCell<TorrentAddDirectoryItemViewModel>.self, forCellWithReuseIdentifier: TorrentFilesDictionaryItemViewCell<TorrentAddDirectoryItemViewModel>.reusableId)
collectionView.register(type: TorrentFilesFileListCell<TorrentAddFileItemViewModel>.self, hasXib: false)
@@ -45,7 +45,9 @@ class TorrentAddViewController<VM: TorrentAddViewModel>: BaseViewController<VM>
toolbarItems = [
.init(customView: diskLabel),
.init(systemItem: .flexibleSpace),
moreButton
storageButton,
.fixedSpace(16),
priorityButton
]
disposeBag.bind {
@@ -75,6 +77,36 @@ private extension TorrentAddViewController {
label.font = .preferredFont(forTextStyle: .callout)
return label
}
func updateMenu() {
priorityButton.menu = UIMenu.makeForChangePriority { [unowned self] priority in
viewModel.setAllFilesPriority(priority)
}
storageButton.menu = UIMenu(
title: %"addTorrent.storage.selected",
image: .init(systemName: "externaldrive"),
children:
[
UIAction(title: %"addTorrent.storage.manage") { [unowned self] _ in
viewModel.navigate(to: StoragePreferencesViewModel.self, by: .present(wrapInNavigation: true))
}
] +
viewModel.storages.map { storage in
var attributes: UIMenuElement.Attributes = []
var image: UIImage?
if !storage.allowed {
attributes = .disabled
image = .init(systemName: "exclamationmark.triangle.fill")?.withTintColor(.systemRed, renderingMode: .alwaysOriginal)
}
return UIAction(title: storage.name, image: image, attributes: attributes, state: storage.selected ? .on : .off) { [unowned self] _ in
viewModel.downloadStorage.value = storage.uuid
updateMenu()
}
})
}
}
private extension TorrentAddViewController {
@@ -129,3 +161,11 @@ private extension TorrentAddViewController {
}
}
}
private extension TorrentAddViewController {
class DataPickerDelegate: DelegateObject<TorrentAddViewController>, UIDocumentPickerDelegate {
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
print(urls)
}
}
}
@@ -24,11 +24,15 @@ class TorrentAddViewModel: BaseViewModelWith<TorrentAddViewModel.Config> {
private(set) var isRoot: Bool = false
let updatePublisher = CurrentValueRelay<Void>(())
let downloadStorage = CurrentValueRelay<UUID?>(nil)
let downloadStorages = CurrentValueRelay<[StorageModel]>([])
override func prepare(with model: Config) {
torrentFile = model.torrentFile
isRoot = model.rootDirectory == nil
rootDirectory = model.rootDirectory ?? generateRoot()
downloadStorage.value = preferences.defaultStorage
keys = rootDirectory.storage
.sorted(by: { first, second in
let f = first.value.name
@@ -49,6 +53,8 @@ class TorrentAddViewModel: BaseViewModelWith<TorrentAddViewModel.Config> {
override func willAppear() {
updatePublisher.send()
}
@Injected private var preferences: PreferencesStorage
}
extension TorrentAddViewModel {
@@ -91,7 +97,7 @@ extension TorrentAddViewModel {
}
func download() {
TorrentService.shared.addTorrent(by: torrentFile)
TorrentService.shared.addTorrent(by: torrentFile, at: downloadStorage.value)
dismiss()
}
@@ -113,6 +119,12 @@ extension TorrentAddViewModel {
return "\(selected.bitrateToHumanReadable) / \(total.bitrateToHumanReadable)"
}.eraseToAnyPublisher()
}
var storages: [(name: String, selected: Bool, uuid: UUID?, allowed: Bool)] {
[(StorageModel.defaultName, downloadStorage.value == nil, nil, true)] +
preferences.storageScopes.values.sorted(by: { $0.name.localizedStandardCompare($1.name) == .orderedAscending })
.map { ($0.name, downloadStorage.value == $0.uuid, $0.uuid, $0.allowed ) }
}
}
private extension TorrentAddViewModel {
@@ -152,7 +164,7 @@ extension TorrentAddViewModel {
}
private static func presentAlert(from navigationContext: NavigationProtocol, ifTorrentExists torrentFile: TorrentFile) -> Bool {
guard TorrentService.shared.torrents.contains(where: { $0.snapshot.infoHashes == torrentFile.infoHashes })
guard TorrentService.shared.torrents[torrentFile.infoHashes] != nil
else { return false }
let alert = UIAlertController(title: %"addTorrent.exists", message: %"addTorrent.\(torrentFile.infoHashes.best.hex)_exists", preferredStyle: .alert)
@@ -41,9 +41,12 @@ class TorrentDetailsViewController<VM: TorrentDetailsViewModel>: BaseViewControl
shareButton.isEnabled = available
}
viewModel.$isPaused.sink { [unowned self] isPaused in
playButton.isEnabled = isPaused
pauseButton.isEnabled = !isPaused
viewModel.$canResume.sink { [unowned self] canResume in
playButton.isEnabled = canResume
}
viewModel.$canPause.sink { [unowned self] canPause in
pauseButton.isEnabled = canPause
}
}
@@ -16,6 +16,10 @@ class TorrentDetailsViewModel: BaseViewModelWith<TorrentHandle> {
@Published var sections: [MvvmCollectionSectionModel] = []
@Published var title: String = ""
@Published var isPaused: Bool = false
@Published var canResume: Bool = false
@Published var canPause: Bool = false
@Published private var storageError: Bool = false
let dismissSignal = PassthroughSubject<Void, Never>()
@@ -46,6 +50,25 @@ class TorrentDetailsViewModel: BaseViewModelWith<TorrentHandle> {
sequentialModel.$isOn.sink { [unowned self] value in
torrentHandle.setSequentialDownload(value)
}
$storageError.removeDuplicates().sink { [unowned self] error in
runOnMainThreadIfNeeded { [self] in
downloadPathModel.accessories = error ?
[
.image(.init(systemName: "exclamationmark.triangle.fill"), options: .init(tintColor: .systemRed))
] :
[
// .popUpMenu(
// .init(title: %"details.path.migrate", children: [
// UIAction(title: "Default", state: .off) { _ in },
// UIAction(title: "Browse", state: .off) { _ in },
// ]), options: .init(tintColor: .tintColor)
// )
]
downloadPathModel.selectAction = nil //error ? nil : {}
}
}
}
hashModel.longPressAction = { [unowned self] in
@@ -84,6 +107,7 @@ class TorrentDetailsViewModel: BaseViewModelWith<TorrentHandle> {
private let commentModel = DetailCellViewModel(title: %"details.info.comment", spacer: 80)
private let createdModel = DetailCellViewModel(title: %"details.info.created")
private let addedModel = DetailCellViewModel(title: %"details.info.added")
private let downloadPath2Model = DetailCellViewModel(title: "Download Path")
private let selectedModel = DetailCellViewModel(title: %"details.transfer.selectedTotal")
private let completedModel = DetailCellViewModel(title: %"details.transfer.completed")
@@ -93,12 +117,17 @@ class TorrentDetailsViewModel: BaseViewModelWith<TorrentHandle> {
private let seedersModel = DetailCellViewModel(title: %"details.transfer.seeders")
private let leechersModel = DetailCellViewModel(title: %"details.transfer.leechers")
private lazy var downloadPathModel = PRButtonViewModel(with: .init(title: %"details.path.browse", isBold: true, value: nil))
private lazy var trackersModel = DetailCellViewModel(title: %"details.actions.trackers") { [unowned self] in
navigate(to: TorrentTrackersViewModel.self, with: torrentHandle, by: .show)
}
private lazy var filesModel = DetailCellViewModel(title: %"details.actions.files") { [unowned self] in
navigate(to: TorrentFilesViewModel.self, with: .init(torrentHandle: torrentHandle), by: .show)
}
@Injected private var torrentService: TorrentService
@Injected private var preferences: PreferencesStorage
}
extension TorrentDetailsViewModel {
@@ -117,10 +146,30 @@ extension TorrentDetailsViewModel {
}
func rehash() {
// If Storage is not available, try to reconnect storage
if torrentHandle.snapshot.friendlyState == .storageError {
return refreshStorage()
}
alert(title: %"details.rehash.title", message: %"details.rehash.message", actions: [
.init(title: %"common.cancel", style: .cancel),
.init(title: %"details.rehash.action", style: .destructive, action: { [unowned self] in
torrentHandle.rehash()
}),
])
}
func refreshStorage() {
guard let storage = torrentHandle.storage else { return }
alert(title: %"details.refreshStorage.title", message: %"details.refreshStorage.message", actions: [
.init(title: %"common.cancel", style: .cancel),
.init(title: %"common.continue", style: .default, action: { [self] in
Task {
guard !torrentService.refreshStorage(storage) else { return }
alert(title: %"common.error", message: %"details.refreshStorage.fail.message", actions: [
.init(title: %"common.close", style: .cancel)
])
}
})
])
}
@@ -133,7 +182,7 @@ extension TorrentDetailsViewModel {
.init(title: %"torrent.remove.action.keepData", style: .default, action: { [unowned self] in
TorrentService.shared.removeTorrent(by: torrentHandle.snapshot.infoHashes, deleteFiles: false)
}),
.init(title: %"common.cancel", style: .cancel)
.init(title: %"common.cancel", style: .cancel),
])
}
@@ -154,7 +203,11 @@ extension TorrentDetailsViewModel {
private extension TorrentDetailsViewModel {
func dataUpdate() {
isPaused = torrentHandle.snapshot.isPaused
stateModel.detail = "\(torrentHandle.snapshot.friendlyState.name)" // "\(torrentHandle.snapshot.state.rawValue) | \(torrentHandle.snapshot.isPaused ? "Paused" : "Running")"
canResume = torrentHandle.snapshot.canResume
canPause = torrentHandle.snapshot.canPause
storageError = torrentHandle.snapshot.friendlyState == .storageError
stateModel.detail = torrentHandle.snapshot.friendlyState.name
downloadModel.detail = "\(torrentHandle.snapshot.downloadRate.bitrateToHumanReadable)/s"
uploadModel.detail = "\(torrentHandle.snapshot.uploadRate.bitrateToHumanReadable)/s"
@@ -191,6 +244,11 @@ private extension TorrentDetailsViewModel {
uploadedModel.detail = "\(torrentHandle.snapshot.totalUpload.bitrateToHumanReadable)"
seedersModel.detail = "\(torrentHandle.snapshot.numberOfSeeds)(\(torrentHandle.snapshot.numberOfTotalSeeds))"
leechersModel.detail = "\(torrentHandle.snapshot.numberOfLeechers)(\(torrentHandle.snapshot.numberOfTotalLeechers))"
downloadPath2Model.detail = torrentHandle.snapshot.downloadPath.path()
downloadPathModel.value = torrentHandle.snapshot.storage.name
filesModel.isEnabled = torrentHandle.snapshot.friendlyState != .storageError
}
func reload() {
@@ -253,6 +311,13 @@ private extension TorrentDetailsViewModel {
leechersModel
})
if !preferences.storageScopes.isEmpty {
sections.append(.init(id: "path", header: %"details.path") {
// downloadPath2Model
downloadPathModel
})
}
sections.append(.init(id: "actions", header: %"details.actions") {
trackersModel
filesModel
@@ -56,7 +56,7 @@ class TorrentFilesFileItemViewModel: BaseViewModelWith<(TorrentHandle, Int)>, Mv
}
var path: URL {
TorrentService.downloadPath.appending(path: file.path)
torrentHandle.snapshot.downloadPath.appending(path: file.path)
}
func setPriority(_ priority: FileEntry.Priority) {
@@ -57,17 +57,17 @@ class TorrentFilesFileListCell<VM: FileItemViewModelProtocol>: MvvmCollectionVie
disposeBag.bind {
viewModel.updatePublisher
.receive(on: DispatchQueue.global(qos: .userInitiated))
.receive(on: .global(qos: .userInitiated))
.sink
{ [weak self] _ in
self?.reload()
{ [weak self, viewModel] _ in
self?.reload(with: viewModel)
}
viewModel.selected.sink { [unowned self] _ in
switchView.setOn(!switchView.isOn, animated: true)
switcher(switchView)
}
}
reload()
reload(with: viewModel)
}
func shareAction() {
@@ -96,7 +96,7 @@ class TorrentFilesFileListCell<VM: FileItemViewModelProtocol>: MvvmCollectionVie
}
private extension TorrentFilesFileListCell {
func reload() {
func reload(with viewModel: VM) {
let file = viewModel.file
let percent = "\(String(format: "%.2f", file.progress * 100))%"
@@ -139,7 +139,8 @@ private extension TorrentFilesViewController {
// @MainActor
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
let path = parent.viewModel.filesForPreview[index].path
return TorrentService.downloadPath.appending(path: path) as NSURL
let res = parent.viewModel.downloadPath.appending(path: path).standardized as NSURL
return res
}
}
@@ -148,7 +149,7 @@ private extension TorrentFilesViewController {
else { return }
let path = viewModel.filesForPreview[startIndex].path
let url = TorrentService.downloadPath.appending(path: path)
let url = viewModel.downloadPath.appending(path: path)
Task {
// Allow to choose be
@@ -183,7 +184,7 @@ private extension TorrentFilesViewController {
} catch {}
let path = viewModel.filesForPreview[startIndex].path
let url = TorrentService.downloadPath.appending(path: path)
let url = viewModel.downloadPath.appending(path: path)
let player = AVPlayer(url: url)
let playerController = AVPlayerViewController.resolve()
playerController.canStartPictureInPictureAutomaticallyFromInline = true
@@ -102,6 +102,10 @@ extension TorrentFilesViewModel {
keys.count
}
var downloadPath: URL {
torrentHandle.snapshot.downloadPath
}
var filesForPreview: [FileEntry] {
keys.flatMap {
switch rootDirectory.storage[$0] {
@@ -157,10 +157,10 @@ class TorrentListViewController<VM: TorrentListViewModel>: BaseViewController<VM
return UIContextMenuConfiguration {
TorrentDetailsViewModel.resolveVC(with: torrentHandle)
} actionProvider: { _ in
let start = UIAction(title: %"details.start", image: .init(systemName: "play.fill"), attributes: torrentHandle.snapshot.isPaused ? [] : .hidden, handler: { _ in
let start = UIAction(title: %"details.start", image: .init(systemName: "play.fill"), attributes: torrentHandle.snapshot.canResume ? [] : .hidden, handler: { _ in
torrentHandle.resume()
})
let pause = UIAction(title: %"details.pause", image: .init(systemName: "pause.fill"), attributes: !torrentHandle.snapshot.isPaused ? [] : .hidden, handler: { _ in
let pause = UIAction(title: %"details.pause", image: .init(systemName: "pause.fill"), attributes: torrentHandle.snapshot.canPause ? [] : .hidden, handler: { _ in
torrentHandle.pause()
})
let delete = UIAction(title: %"common.delete", image: UIImage(systemName: "trash.fill"), attributes: .destructive) { [unowned self] _ in
@@ -317,14 +317,7 @@ extension TorrentListViewController {
return
}
guard !TorrentService.shared.checkTorrentExists(with: torrentFile.infoHashes) else {
let alert = UIAlertController(title: %"addTorrent.exists", message: %"addTorrent.\(torrentFile.infoHashes.best.hex)_exists", preferredStyle: .alert)
alert.addAction(.init(title: %"common.close", style: .cancel))
present(alert, animated: true)
return
}
TorrentService.shared.addTorrent(by: torrentFile)
TorrentAddViewModel.present(with: torrentFile, from: self)
}
})
return alert
@@ -68,7 +68,7 @@ class TorrentListViewModel: BaseViewModel {
Publishers.combineLatest(
torrentSectionChanged,
TorrentService.shared.$torrents,
TorrentService.shared.$torrents.map { Array($0.values) },
$searchQuery,
sortingType,
sortingReverced,
@@ -124,7 +124,10 @@ extension TorrentListViewModel {
func resumeAllSelected(at indexPaths: [IndexPath]) {
let torrentModels = indexPaths.compactMap { sections[$0.section].items[$0.item] as? TorrentListItemViewModel }
torrentModels.forEach { $0.torrentHandle.resume() }
torrentModels.forEach {
guard $0.torrentHandle.snapshot.canResume else { return }
$0.torrentHandle.resume()
}
}
func pauseAllSelected(at indexPaths: [IndexPath]) {
@@ -63,7 +63,7 @@ class BackgroundService: BackgroundServiceProtocol {
// MARK: Backgroud requirements
extension BackgroundService {
static var isBackgroundNeeded: Bool {
TorrentService.shared.torrents.contains(where: { $0.snapshot.needBackground })
TorrentService.shared.torrents.values.contains(where: { $0.snapshot.needBackground })
}
}
@@ -13,7 +13,7 @@ actor IntentsService {
disposeBag.bind {
NotificationCenter.default.publisher(for: .pauseTorrent).sink { notification in
guard let hash = notification.object as? String,
let torrentHandle = TorrentService.shared.torrents.first(where: { $0.snapshot.infoHashes.best.hex == hash })
let torrentHandle = TorrentService.shared.torrents.values.first(where: { $0.snapshot.infoHashes.best.hex == hash })
else { return }
torrentHandle.pause()
@@ -99,13 +99,15 @@ private extension TorrentHandle.State {
return .checkingResumeData
case .paused:
return .paused
case .storageError:
return .storageError
@unknown default:
fatalError("\(ProgressWidgetAttributes.State.self) has no such case \(self)")
}
}
var shouldShowLiveActivity: Bool {
let notShow: [Self] = [.finished, .paused]
let notShow: [Self] = [.finished, .paused, .storageError]
return !notShow.contains(self)
}
}
@@ -18,6 +18,17 @@ class PreferencesStorage: Resolvable {
// Location mode is not allowed by Apple policy
backgroundMode = .audio
#endif
// Sanity check for defaultStorage if something went wrong
if defaultStorage != nil, !storageScopes.contains(where: { $0.key == defaultStorage }) { defaultStorage = nil }
// Fix sorting array in case new status appeared
if torrentListGroupsSortingArray.count != Self.defaultTorrentListGroupsSortingArray.count {
torrentListGroupsSortingArray = Self.defaultTorrentListGroupsSortingArray
}
// TODO: REMOVE LATER!!!
isStorageRulesAccepted = false
}
private var disposeBag: [AnyCancellable] = []
@@ -27,12 +38,17 @@ class PreferencesStorage: Resolvable {
.checkingFiles,
.downloadingMetadata,
.downloading,
.finished,
.seeding,
.finished,
.checkingResumeData,
.paused
.paused,
.storageError
]
@UserDefaultItem("torrentDefaultStorage", nil) var defaultStorage: UUID?
@UserDefaultItem("torrentIsStorageRulesAccepted", false) var isStorageRulesAccepted: Bool
@UserDefaultItem("torrentStorageScopes", [:]) var storageScopes: [UUID: StorageModel]
@UserDefaultItem("torrentListSortType", .alphabetically) var torrentListSortType: TorrentListViewModel.Sort
@UserDefaultItem("torrentListSortReverced", false) var torrentListSortReverced: Bool
@UserDefaultItem("torrentListIsGroubedByState", false) var torrentListGroupedByState: Bool
@@ -99,7 +115,6 @@ class PreferencesStorage: Resolvable {
var settingsUpdatePublisher: AnyPublisher<Void, Never> {
Just<Void>(())
.combineLatest($allocateMemory)
.combineLatest($maxActiveTorrents)
.combineLatest($maxDownloadingTorrents)
.combineLatest($maxUploadingTorrents)
@@ -17,12 +17,22 @@ class TorrentMonitoringService {
disposeBag.bind {
torrentService.updateNotifier.sink { [unowned self] updateModel in
checkDoneNotification(with: updateModel)
checkStorageAvailability(with: updateModel)
}
}
}
}
private extension TorrentMonitoringService {
func checkStorageAvailability(with model: TorrentService.TorrentUpdateModel) {
guard let storage = model.handle.storage,
!storage.allowed,
!model.handle.snapshot.isPaused
else { return }
model.handle.pause()
}
func checkDoneNotification(with model: TorrentService.TorrentUpdateModel) {
guard PreferencesStorage.shared.isDownloadNotificationsEnabled,
model.oldSnapshot.state != .checkingFiles,
@@ -0,0 +1,23 @@
//
// StorageModel+Extensions.swift
// iTorrent
//
// Created by Daniil Vinogradov on 05/07/2024.
//
import LibTorrent
extension StorageModel {
static var defaultName: String { "iTorrent Default" }
}
extension Optional where Wrapped: StorageModel {
var name: String {
switch self {
case .none:
return StorageModel.defaultName
case .some(let wrapped):
return wrapped.name
}
}
}
@@ -66,6 +66,10 @@ extension TorrentHandle {
extension TorrentHandle.Snapshot {
var friendlyState: TorrentHandle.State {
if isStorageMissing {
return .storageError
}
switch state {
case .downloading:
if isPaused { return .paused }
@@ -80,6 +84,14 @@ extension TorrentHandle.Snapshot {
return state
}
}
var canResume: Bool {
isPaused && friendlyState != .storageError
}
var canPause: Bool {
!isPaused
}
}
extension TorrentHandle.State {
@@ -99,6 +111,8 @@ extension TorrentHandle.State {
return String(localized: "torrent.state.resuming")
case .paused:
return String(localized: "torrent.state.paused")
case .storageError:
return String(localized: "torrent.state.storageError")
@unknown default:
assertionFailure("Unregistered \(Self.self) enum value is not allowed: \(self)")
return ""
@@ -106,6 +120,14 @@ extension TorrentHandle.State {
}
}
// MARK: - Storage
extension TorrentHandle.Snapshot {
var storage: StorageModel? {
guard let storageUUID else { return nil }
return TorrentService.shared.storages[storageUUID]
}
}
// MARK: - Metadata
extension TorrentHandle {
struct Metadata: Codable {
@@ -8,6 +8,7 @@
import Combine
import LibTorrent
import MvvmFoundation
import UIKit
extension TorrentService {
struct TorrentUpdateModel {
@@ -17,7 +18,7 @@ extension TorrentService {
}
class TorrentService {
@Published var torrents: [TorrentHandle] = []
@Published var torrents: [TorrentHashes: TorrentHandle] = [:]
var updateNotifier = PassthroughSubject<TorrentUpdateModel, Never>()
static let shared = TorrentService()
@@ -30,10 +31,10 @@ class TorrentService {
static var fastResumePath: URL { downloadPath.appending(path: "config") }
static var metadataPath: URL { downloadPath.appending(path: "config") }
private let session: Session = {
private lazy var session: Session = {
var settings = Session.Settings()
print("Working directory: \(downloadPath.path())")
return .init(downloadPath.path(), torrentsPath: torrentPath.path(), fastResumePath: fastResumePath.path(), settings: .fromPreferences(with: []))
print("Working directory: \(Self.downloadPath.path())")
return .init(Self.downloadPath.path(), torrentsPath: Self.torrentPath.path(), fastResumePath: Self.fastResumePath.path(), settings: .fromPreferences(with: []), storages: PreferencesStorage.shared.storageScopes)
}()
private let disposeBag = DisposeBag()
@@ -43,35 +44,26 @@ class TorrentService {
}
extension TorrentService {
var storages: Dictionary<UUID, StorageModel> {
get { session.storages }
set { session.storages = newValue }
}
func checkTorrentExists(with hash: TorrentHashes) -> Bool {
torrents.contains(where: { $0.snapshot.infoHashes == hash })
session.torrentsMap[hash] != nil
}
@discardableResult
func addTorrent(by file: Downloadable) -> Bool {
guard !torrents.contains(where: { file.infoHashes == $0.snapshot.infoHashes })
func addTorrent(by file: Downloadable, at storage: UUID? = nil) -> Bool {
guard session.torrentsMap[file.infoHashes] == nil
else { return false }
session.addTorrent(file)
return true
}
@discardableResult
func addTorrent(by path: URL) -> Bool {
defer { path.stopAccessingSecurityScopedResource() }
guard path.startAccessingSecurityScopedResource(),
let file = TorrentFile(with: path)
else { return false }
guard !torrents.contains(where: { file.infoHashes == $0.snapshot.infoHashes })
else { return false }
session.addTorrent(file)
session.addTorrent(file, to: storage)
return true
}
func removeTorrent(by infoHashes: TorrentHashes, deleteFiles: Bool) {
guard let handle = torrents.first(where: { $0.snapshot.infoHashes == infoHashes })
guard let handle = session.torrentsMap[infoHashes]
else { return }
handle.deleteMetadata()
@@ -81,35 +73,33 @@ extension TorrentService {
func updateSettings(_ settings: Session.Settings) {
session.settings = settings
}
func refreshStorage(_ storage: StorageModel) -> Bool {
guard storage.resolveSequrityScopes() else { return false }
let handles = torrents.values.filter { $0.snapshot.storageUUID == storage.uuid }
handles.forEach { $0.reload() }
return true
}
}
// MARK: - SessionDelegate
extension TorrentService: SessionDelegate {
func torrentManager(_ manager: Session, didAddTorrent torrent: TorrentHandle) {
// Do not use snapshot for 'torrent' because it is not generated yet
guard torrents.firstIndex(where: { $0.snapshot.infoHashes == torrent.infoHashes }) == nil
else { return }
torrent.prepareToAdd(into: self)
torrents.append(torrent)
torrents[torrent.snapshot.infoHashes] = torrent
}
func torrentManager(_ manager: Session, didRemoveTorrentWithHash hashesData: TorrentHashes) {
// Already on Main thread
guard let index = torrents.firstIndex(where: { $0.snapshot.infoHashes == hashesData })
guard let torrent = torrents[hashesData]
else { return }
let torrent = torrents[index]
torrent.removePublisher.send(torrent)
torrents.remove(at: index)
torrents[hashesData] = nil
}
func torrentManager(_ manager: Session, didReceiveUpdateForTorrent torrent: TorrentHandle) {
// Do not use snapshot for 'torrent' because it could be not generated yet
guard let existingTorrent = torrents.first(where: { $0.snapshot.infoHashes == torrent.infoHashes })
else { return }
existingTorrent.__unthrottledUpdatePublisher.send()
torrent.__unthrottledUpdatePublisher.send()
}
func torrentManager(_ manager: Session, didErrorOccur error: Error) {}
@@ -117,9 +107,12 @@ extension TorrentService: SessionDelegate {
private extension TorrentService {
func setup() {
// Resolve storage scopes, so Core will be able to restore states
resolveStorageScopes()
// Pause the core, so it will not deadlock app on making first snapshots
session.pause();
torrents = session.torrents.map { torrent in
torrents = session.torrentsMap.mapValues { torrent in
torrent.prepareToAdd(into: self)
return torrent
}
@@ -139,6 +132,21 @@ private extension TorrentService {
session.settings = Session.Settings.fromPreferences(with: interfaces)
}
}
preferences.$storageScopes.sink { [unowned self] storages in
session.storages = storages
}
}
}
func resolveStorageScopes() {
preferences.storageScopes.values.forEach { scope in
scope.resolveSequrityScopes()
// If storage is not allowed and it used as default, reset default
if !scope.allowed, preferences.defaultStorage == scope.uuid {
preferences.defaultStorage = nil
}
}
}
}
@@ -35,13 +35,19 @@ extension Publisher where Self.Failure == Never {
/// - Returns: A cancellable instance, which you use when you end assignment of the received value. Deallocation of the result will tear down the subscription stream.
public func uiSink(receiveValue: @escaping ((Self.Output) -> Void)) -> AnyCancellable {
sink { value in
if Thread.isMainThread {
runOnMainThreadIfNeeded {
receiveValue(value)
} else {
DispatchQueue.main.async {
receiveValue(value)
}
}
}
}
}
func runOnMainThreadIfNeeded(_ action: @escaping () -> Void) {
if Thread.isMainThread {
action()
} else {
DispatchQueue.main.async {
action()
}
}
}
@@ -0,0 +1,25 @@
//
// UICellAccessory+Image.swift
// iTorrent
//
// Created by Даниил Виноградов on 05.07.2024.
//
import UIKit
extension UICellAccessory {
public struct ImageOptions {
public var isHidden: Bool = false
public var tintColor: UIColor? = nil
}
static func image(_ image: UIImage?, displayed: UICellAccessory.DisplayedState = .always, options: ImageOptions = .init()) -> UICellAccessory {
.customView(configuration: .init(customView: {
if let tintColor = options.tintColor {
return UIImageView(image: image?.withTintColor(tintColor, renderingMode: .alwaysOriginal))
} else {
return UIImageView(image: image)
}
}(), placement: .trailing(displayed: displayed, at: { _ in 0 })))
}
}
@@ -11,7 +11,7 @@ import UIKit
extension UIMenu {
static func makeForChangePriority( options: UIMenu.Options = [], _ setPriority: @escaping (FileEntry.Priority) -> ()) -> UIMenu {
.init(title: %"prioriry.change.title", options: options, children: [
.init(title: %"prioriry.change.title", image: .init(resource: .icSort), options: options, children: [
UIAction(title: String(localized: "prioriry.top"), image: .init(systemName: "gauge.with.dots.needle.100percent"), handler: { _ in
setPriority(.topPriority)
}),
@@ -0,0 +1,14 @@
//
// URL+Normalization.swift
// iTorrent
//
// Created by Daniil Vinogradov on 05/07/2024.
//
import Foundation
extension URL {
var isDirectory: Bool {
(try? resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true
}
}
@@ -20,3 +20,14 @@ class SAViewController<ViewModel: MvvmViewModelProtocol>: MvvmViewController<Vie
}
}
}
class SAHostingViewController<View: MvvmSwiftUIViewProtocol>: MvvmHostingViewController<View> {
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if let nav = navigationController as? SANavigationController,
nav.viewControllers.last == self
{
nav.locker = false
}
}
}
+6 -9
View File
@@ -13,25 +13,22 @@ import MvvmFoundation
struct UserDefaultItem<T: Codable> {
private let key: String
private let defaultValue: T
private let value: CurrentValueSubject<T, Never>
private let disposeBag = DisposeBag()
var wrappedValue: T {
get { value.value }
set { value.value = newValue }
}
let projectedValue: CurrentValueSubject<T, Never>
var projectedValue: CurrentValueSubject<T, Never> {
value
var wrappedValue: T {
get { projectedValue.value }
set { projectedValue.value = newValue }
}
init(_ key: String, _ defaultValue: T) {
self.key = key
self.defaultValue = defaultValue
value = CurrentValueSubject(Self.get(by: key) ?? defaultValue)
projectedValue = CurrentValueSubject(Self.get(by: key) ?? defaultValue)
disposeBag.bind {
value.sink { value in
projectedValue.sink { value in
Self.set(by: key, value)
}
}