From bde6cf0319235552027dd9b3bae2009bf548d861 Mon Sep 17 00:00:00 2001 From: Daniil Vinogradov Date: Mon, 16 Sep 2024 17:06:34 +0200 Subject: [PATCH] WIP: Remote trackers list --- .../ProgressWidgetLiveActivity.swift | 13 --- Submodules/MVVMFoundation | 2 +- iTorrent.xcodeproj/project.pbxproj | 31 +++++ iTorrent/Core/Assets/Localizable.xcstrings | 86 ++++++++++++++ .../Core/SceneDelegate/SceneDelegate.swift | 3 + .../Root/PreferencesViewModel.swift | 3 + .../TrackersListDetailsPreferencesView.swift | 44 +++++++ .../TrackersListPreferencesView.swift | 110 ++++++++++++++++++ .../TorrentTrackersViewController.swift | 35 ++++-- .../TorrentTrackersViewModel.swift | 34 ++++-- .../TrackersListService.swift | 87 ++++++++++++++ .../Extensions/Combine/Publisher+UI.swift | 2 +- .../MvvmFoundation/MvvmViewModel+Alert.swift | 4 + .../MvvmViewModelProtocol+Alert.swift | 1 + 14 files changed, 421 insertions(+), 34 deletions(-) create mode 100644 iTorrent/Screens/Preferences/TrackersList/TrackersListDetailsPreferencesView.swift create mode 100644 iTorrent/Screens/Preferences/TrackersList/TrackersListPreferencesView.swift create mode 100644 iTorrent/Services/TrackersListService/TrackersListService.swift diff --git a/ProgressWidget/ProgressWidgetLiveActivity.swift b/ProgressWidget/ProgressWidgetLiveActivity.swift index 4a681a7a..0b5962be 100644 --- a/ProgressWidget/ProgressWidgetLiveActivity.swift +++ b/ProgressWidget/ProgressWidgetLiveActivity.swift @@ -38,16 +38,9 @@ struct ProgressWidgetLiveActivity: Widget { // Lock screen/banner UI goes here if #available(iOS 18, *) { -#if XCODE16 ProgressWidgetLiveActivityWatchSupportContent(context: context) .tint(Color(uiColor: context.tintColor)) .padding() -#else - ProgressWidgetLiveActivityContent(context: context) - .tint(Color(uiColor: context.tintColor)) - .padding() -#endif - } else { ProgressWidgetLiveActivityContent(context: context) .tint(Color(uiColor: context.tintColor)) @@ -105,18 +98,13 @@ struct ProgressWidgetLiveActivity: Widget { } if #available(iOS 18.0, *) { -#if XCODE16 return config.supplementalActivityFamilies([.small]) -#else - return config -#endif } else { return config } } } -#if XCODE16 @available(iOS 18.0, *) struct ProgressWidgetLiveActivityWatchSupportContent: View { @Environment(\.activityFamily) var activityFamily @@ -180,7 +168,6 @@ struct ProgressWidgetLiveActivityWatchSupportContent: View { } } } -#endif struct ProgressWidgetLiveActivityContent: View { @State var context: ActivityViewContext diff --git a/Submodules/MVVMFoundation b/Submodules/MVVMFoundation index 91ae3e81..8aa6ba93 160000 --- a/Submodules/MVVMFoundation +++ b/Submodules/MVVMFoundation @@ -1 +1 @@ -Subproject commit 91ae3e8165b347697afe2e62a51b39b90aedafa9 +Subproject commit 8aa6ba9344da30f21b334bed17d83f1a34378e7f diff --git a/iTorrent.xcodeproj/project.pbxproj b/iTorrent.xcodeproj/project.pbxproj index 00abf062..0d83523a 100644 --- a/iTorrent.xcodeproj/project.pbxproj +++ b/iTorrent.xcodeproj/project.pbxproj @@ -186,6 +186,9 @@ D1B99D932BEE631B00F51514 /* Benefit.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1B99D8F2BEE631B00F51514 /* Benefit.swift */; }; D1B99D942BEE631B00F51514 /* Tier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1B99D902BEE631B00F51514 /* Tier.swift */; }; D1B99D962BEE657F00F51514 /* API.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1B99D952BEE657F00F51514 /* API.swift */; }; + D1BB073B2C98524B00981D5F /* TrackersListService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1BB073A2C98524A00981D5F /* TrackersListService.swift */; }; + D1BB07432C985EB800981D5F /* TrackersListPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1BB07422C985EAE00981D5F /* TrackersListPreferencesView.swift */; }; + D1BB07452C9869C300981D5F /* TrackersListDetailsPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1BB07442C9869B900981D5F /* TrackersListDetailsPreferencesView.swift */; }; D1CAB8852AF3B51E00EB6AFF /* ToggleCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1CAB8842AF3B51E00EB6AFF /* ToggleCellView.swift */; }; D1CAB8872AF3B52E00EB6AFF /* ToggleCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1CAB8862AF3B52E00EB6AFF /* ToggleCellViewModel.swift */; }; D1D1279B2BC7CA7600C04533 /* SwiftUILayoutGuides.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1D1279A2BC7CA7600C04533 /* SwiftUILayoutGuides.swift */; }; @@ -418,6 +421,9 @@ D1B99D8F2BEE631B00F51514 /* Benefit.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Benefit.swift; sourceTree = ""; }; D1B99D902BEE631B00F51514 /* Tier.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Tier.swift; sourceTree = ""; }; D1B99D952BEE657F00F51514 /* API.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = API.swift; sourceTree = ""; }; + D1BB073A2C98524A00981D5F /* TrackersListService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackersListService.swift; sourceTree = ""; }; + D1BB07422C985EAE00981D5F /* TrackersListPreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackersListPreferencesView.swift; sourceTree = ""; }; + D1BB07442C9869B900981D5F /* TrackersListDetailsPreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackersListDetailsPreferencesView.swift; sourceTree = ""; }; D1CAB8842AF3B51E00EB6AFF /* ToggleCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleCellView.swift; sourceTree = ""; }; D1CAB8862AF3B52E00EB6AFF /* ToggleCellViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleCellViewModel.swift; sourceTree = ""; }; D1D1279A2BC7CA7600C04533 /* SwiftUILayoutGuides.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwiftUILayoutGuides.swift; sourceTree = ""; }; @@ -793,6 +799,7 @@ D111384C2AF9663F008907F7 /* Preferences */ = { isa = PBXGroup; children = ( + D1BB07412C985E9700981D5F /* TrackersList */, 7C95B7A32C34B554000EC50F /* Storage */, D1DB718E2BD92206007F9267 /* Patreon */, 7CB6F6CC2BD82B8A00D0813B /* FileSharing */, @@ -1118,6 +1125,7 @@ D1A226F02AEF018500669D6D /* Services */ = { isa = PBXGroup; children = ( + D1BB07392C98522D00981D5F /* TrackersListService */, 7C1C08AE2C31FEF700569B45 /* IntentsService */, 7C3142D42C31ED4600397E82 /* LiveActivityService */, D1B99D842BEE5E4100F51514 /* Patreon */, @@ -1256,6 +1264,23 @@ path = Models; sourceTree = ""; }; + D1BB07392C98522D00981D5F /* TrackersListService */ = { + isa = PBXGroup; + children = ( + D1BB073A2C98524A00981D5F /* TrackersListService.swift */, + ); + path = TrackersListService; + sourceTree = ""; + }; + D1BB07412C985E9700981D5F /* TrackersList */ = { + isa = PBXGroup; + children = ( + D1BB07442C9869B900981D5F /* TrackersListDetailsPreferencesView.swift */, + D1BB07422C985EAE00981D5F /* TrackersListPreferencesView.swift */, + ); + path = TrackersList; + sourceTree = ""; + }; D1CAB8832AF3B50C00EB6AFF /* ToggleCell */ = { isa = PBXGroup; children = ( @@ -1539,6 +1564,7 @@ D11138552AF97511008907F7 /* TorrentAddFileItemViewModel.swift in Sources */, D11333B52AF19C4900FA017E /* TorrentHandle+Extension.swift in Sources */, D19E00202AEFFA1B000A17A2 /* DetailCellView.swift in Sources */, + D1BB07452C9869C300981D5F /* TrackersListDetailsPreferencesView.swift in Sources */, D1ACFDC62AF6DB9F0098FF56 /* TorrentFilesFileListCell.swift in Sources */, D1A227072AEF0B2C00669D6D /* TorrentListItem.swift in Sources */, D111384B2AF965F1008907F7 /* TorrentAddViewModel.swift in Sources */, @@ -1602,6 +1628,7 @@ 7CFEBE7C2BC4318E0013233F /* RssChannelViewController.swift in Sources */, 7C5FBE2C2BBDD6B40069E5A0 /* UIView+LayerColors.swift in Sources */, 7C4ED08D2BE646E40034B62C /* AdView+Meta.swift in Sources */, + D1BB07432C985EB800981D5F /* TrackersListPreferencesView.swift in Sources */, D1B538572AF1171900694AFD /* TorrentDetailProgressCellView.swift in Sources */, 7C5FBE742BC2F0A30069E5A0 /* MvvmViewModelProtocol+Alert.swift in Sources */, D1EFCD122AF572E400D33A7A /* TorrentFilesFileItemViewModel.swift in Sources */, @@ -1672,6 +1699,7 @@ D1DB71802BD6773E007F9267 /* RssSearchViewModel.swift in Sources */, D17733EE2BBC2C5F006FC81A /* ProxyPreferencesViewModel.swift in Sources */, 7CF6DA3E2C0F9DC40033D03F /* LiveActivityService.swift in Sources */, + D1BB073B2C98524B00981D5F /* TrackersListService.swift in Sources */, 7C4ED0982BEF8B8E0034B62C /* PatreonCredentials.swift in Sources */, D173D9E12BC0286800E4F9EB /* UIMenu+Priority.swift in Sources */, D1AA00CE2AFA8B1000B74629 /* PreferencesStorage.swift in Sources */, @@ -1744,6 +1772,7 @@ "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "iTorrent2 ProgressWidget Dev"; SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$inherited XCODE16"; SWIFT_EMIT_LOC_STRINGS = YES; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -1891,6 +1920,7 @@ "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "iTorrent2 ProgressWidget Dev"; SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$inherited XCODE16"; SWIFT_EMIT_LOC_STRINGS = YES; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -2033,6 +2063,7 @@ "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "iTorrent2 ProgressWidget Distrib"; SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$inherited XCODE16"; SWIFT_EMIT_LOC_STRINGS = YES; TARGETED_DEVICE_FAMILY = "1,2"; }; diff --git a/iTorrent/Core/Assets/Localizable.xcstrings b/iTorrent/Core/Assets/Localizable.xcstrings index 11a2bee0..c0268314 100644 --- a/iTorrent/Core/Assets/Localizable.xcstrings +++ b/iTorrent/Core/Assets/Localizable.xcstrings @@ -4912,6 +4912,22 @@ } } }, + "preferences.network.trackers" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trackers sources" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Источники трекеров" + } + } + } + }, "preferences.notifications" : { "localizations" : { "en" : { @@ -5780,6 +5796,76 @@ } } }, + "preferences.trackers.add.title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add trackers list" + } + } + } + }, + "preferences.trackers.add.titlePlaceholder" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Title" + } + } + } + }, + "preferences.trackers.add.urlPlaceholder" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL to trackers source file" + } + } + } + }, + "preferences.trackers.exists.title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This tracker list already added" + } + } + } + }, + "preferences.trackers.remove.message" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Do you want to remove this trackers source?" + } + } + } + }, + "preferences.trackers.remove.title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remove source" + } + } + } + }, + "preferences.trackers.rename.title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rename source" + } + } + } + }, "preferences.version" : { "localizations" : { "en" : { diff --git a/iTorrent/Core/SceneDelegate/SceneDelegate.swift b/iTorrent/Core/SceneDelegate/SceneDelegate.swift index 6b14ec19..14eede22 100644 --- a/iTorrent/Core/SceneDelegate/SceneDelegate.swift +++ b/iTorrent/Core/SceneDelegate/SceneDelegate.swift @@ -24,6 +24,7 @@ class SceneDelegate: MvvmSceneDelegate { container.registerSingleton(factory: { BackgroundService.shared }) container.registerSingleton(factory: NetworkMonitoringService.init) container.registerSingleton(factory: ImageLoader.init) + container.registerSingleton(factory: TrackersListService.init) container.registerDaemon(factory: PatreonService.init) container.registerDaemon(factory: TorrentMonitoringService.init) container.registerDaemon(factory: RssFeedProvider.init) @@ -62,6 +63,8 @@ class SceneDelegate: MvvmSceneDelegate { router.register(BasePreferencesViewController.self) router.register(BasePreferencesViewController.self) + router.register(TrackersListPreferencesViewController.self) + router.register(TrackersListDetailsPreferencesViewController.self) router.register(BasePreferencesViewController.self) router.register(BasePreferencesViewController.self) router.register(PreferencesSectionGroupingViewController.self) diff --git a/iTorrent/Screens/Preferences/Root/PreferencesViewModel.swift b/iTorrent/Screens/Preferences/Root/PreferencesViewModel.swift index 230145b9..31f7a4cf 100644 --- a/iTorrent/Screens/Preferences/Root/PreferencesViewModel.swift +++ b/iTorrent/Screens/Preferences/Root/PreferencesViewModel.swift @@ -143,6 +143,9 @@ private extension PreferencesViewModel { PRButtonViewModel(with: .init(title: %"preferences.network.proxy", accessories: [.disclosureIndicator()]) { [unowned self] in navigate(to: ProxyPreferencesViewModel.self, by: .show) }) + PRButtonViewModel(with: .init(title: %"preferences.network.trackers", accessories: [.disclosureIndicator()]) { [unowned self] in + navigate(to: TrackersListPreferencesViewModel.self, by: .show) + }) PRButtonViewModel(with: .init(title: %"preferences.network.connection", accessories: [.disclosureIndicator()]) { [unowned self] in navigate(to: ConnectionPreferencesViewModel.self, by: .show) }) diff --git a/iTorrent/Screens/Preferences/TrackersList/TrackersListDetailsPreferencesView.swift b/iTorrent/Screens/Preferences/TrackersList/TrackersListDetailsPreferencesView.swift new file mode 100644 index 00000000..75083894 --- /dev/null +++ b/iTorrent/Screens/Preferences/TrackersList/TrackersListDetailsPreferencesView.swift @@ -0,0 +1,44 @@ +// +// TrackersListDetailsPreferencesView.swift +// iTorrent +// +// Created by Daniil Vinogradov on 16/09/2024. +// + +import LibTorrent +import MvvmFoundation +import SwiftUI + +class TrackersListDetailsPreferencesViewModel: BaseViewModelWith, ObservableObject { + @Published var trackers: [String] = [] + @Published var title: String = "" + + override func prepare(with model: TrackersListService.ListState) { + trackers = model.trackers + title = model.title + } +} + +struct TrackersListDetailsPreferencesView: MvvmSwiftUIViewProtocol { + @ObservedObject var viewModel: VM + var title: String + + init(viewModel: VM) { + self.viewModel = viewModel + title = viewModel.title + } + + var body: some View { + List { + Section { + ForEach(viewModel.trackers, id: \.self) { tracker in + Text(tracker) + } + } + } + } +} + +class TrackersListDetailsPreferencesViewController: BaseHostingViewController> { + +} diff --git a/iTorrent/Screens/Preferences/TrackersList/TrackersListPreferencesView.swift b/iTorrent/Screens/Preferences/TrackersList/TrackersListPreferencesView.swift new file mode 100644 index 00000000..448eeed9 --- /dev/null +++ b/iTorrent/Screens/Preferences/TrackersList/TrackersListPreferencesView.swift @@ -0,0 +1,110 @@ +// +// TrackersListPreferencesView.swift +// iTorrent +// +// Created by Daniil Vinogradov on 16/09/2024. +// + +import LibTorrent +import MvvmFoundation +import SwiftUI + +class TrackersListPreferencesViewModel: BaseViewModel, ObservableObject { + @Published var sorces: [TrackersListService.ListState] = [] + + required init() { + super.init() + trackersListService.trackerSources.uiSink { [unowned self] values in + sorces = Array(values.values) + }.store(in: disposeBag) + } + + func addTracker() { + textInputs(title: %"preferences.trackers.add.title", textInputs: [ + .init(placeholder: %"preferences.trackers.add.titlePlaceholder"), + .init(placeholder: %"preferences.trackers.add.urlPlaceholder") + ]) { [weak self] path in + guard let self, + let path, + let url = URL(string: path[1]) + else { return } + + Task { + guard !self.trackersListService.trackerSources.value.keys.contains(url) else { + self.alert(title: %"preferences.trackers.exists.title", actions: [.init(title: %"common.ok", style: .cancel)]) + return + } + try await self.trackersListService.addTrackersSource(url, title: path[0]) + } + } + } + + func showDetails(_ model: TrackersListService.ListState) { + navigate(to: TrackersListDetailsPreferencesViewModel.self, with: model, by: .show) + } + + func renameDetails(_ model: TrackersListService.ListState) { + textInput(title: %"preferences.trackers.rename.title", placeholder: model.title, defaultValue: model.title) { [weak self] result in + guard let self, let result, !result.isEmpty else { return } + trackersListService.trackerSources.value[model.url]?.title = result + } + } + + @Injected var trackersListService: TrackersListService +} + +struct TrackersListPreferencesView: MvvmSwiftUIViewProtocol { + @ObservedObject var viewModel: VM + var title: String = %"preferences.network.trackers" + + init(viewModel: VM) { + self.viewModel = viewModel + } + + var body: some View { + List { + Section { + ForEach(viewModel.sorces) { state in + Button { + viewModel.showDetails(state) + } label: { + NavigationLink(state.title, destination: EmptyView()) + } + .foregroundColor(Color(uiColor: .label)) + .swipeActions { + Button { + viewModel.alert(title: %"preferences.trackers.remove.title", message: %"preferences.trackers.remove.message", actions: [ + .init(title: %"common.cancel", style: .cancel), + .init(title: %"common.remove", style: .destructive, action: { + withAnimation { + viewModel.trackersListService.trackerSources.value[state.url] = nil + } + }) + ]) + } label: { + Image(systemName: "trash") + }.tint(.red) + + Button { + viewModel.renameDetails(state) + } label: { + Image(systemName: "character.textbox") + }.tint(.init(uiColor: PreferencesStorage.shared.tintColor)) + } + } + } + } + } +} + +class TrackersListPreferencesViewController: BaseHostingViewController> { + private lazy var addButtonItem = UIBarButtonItem(systemItem: .add, primaryAction: .init { [unowned self] _ in + viewModel.addTracker() + }) + + override func viewDidLoad() { + super.viewDidLoad() + + navigationItem.trailingItemGroups.append(.fixedGroup(items: [addButtonItem])) + } +} diff --git a/iTorrent/Screens/TorrentTrackers/TorrentTrackersViewController.swift b/iTorrent/Screens/TorrentTrackers/TorrentTrackersViewController.swift index 9cd795eb..28849f48 100644 --- a/iTorrent/Screens/TorrentTrackers/TorrentTrackersViewController.swift +++ b/iTorrent/Screens/TorrentTrackers/TorrentTrackersViewController.swift @@ -10,7 +10,7 @@ import UIKit class TorrentTrackersViewController: BaseViewController { @IBOutlet private var collectionView: MvvmCollectionView! - private let addButton = UIBarButtonItem() + private let addButton = UIBarButtonItem(systemItem: .add) private let removeButton = UIBarButtonItem() private let reannounceButton = UIBarButtonItem() @@ -36,8 +36,8 @@ class TorrentTrackersViewController: BaseViewContr if #available(iOS 17.0, *) { var config = UIContentUnavailableConfiguration.empty() config.image = .init(systemName: "externaldrive.fill.badge.questionmark") - config.text = %"trackers.empty.title" //"No trackers" - config.secondaryText = %"trackers.empty.subtitle" //"You can add trackers manually by editing this page" + config.text = %"trackers.empty.title" // "No trackers" + config.secondaryText = %"trackers.empty.subtitle" // "You can add trackers manually by editing this page" contentUnavailableConfiguration = isEmpty ? config : nil } } @@ -51,9 +51,22 @@ class TorrentTrackersViewController: BaseViewContr // collectionView.delegate = delegates - addButton.primaryAction = .init(title: "Add Trackers", image: .init(systemName: "plus"), handler: { [unowned self] _ in - viewModel.addTrackers() - }) + let importActions: [UIAction] = viewModel.trackersListService.trackerSources.value.values.map { [unowned self] source in + .init(title: source.title) { [unowned self] _ in + viewModel.addTrackers(from: source) + } + } + + addButton.menu = .init(title: "Add trackers", children: [ + UIAction(title: "Manually") { [unowned self] _ in + viewModel.addTrackers() + }, + UIMenu(title: "From sources list", children: importActions + [ + UIAction(title: "Import all") { [unowned self] _ in + viewModel.addAllTrackersFromSourcesList() + } + ]) + ]) removeButton.primaryAction = .init(title: "Remove Trackers", image: .init(systemName: "trash"), handler: { [unowned self] _ in viewModel.removeSelected() }) @@ -81,15 +94,13 @@ class TorrentTrackersViewController: BaseViewContr private lazy var editToolbar: [UIBarButtonItem] = { [ - .init(systemItem: .flexibleSpace), + // .init(systemItem: .flexibleSpace), addButton, .init(systemItem: .flexibleSpace), - removeButton, - .init(systemItem: .flexibleSpace) + removeButton +// .init(systemItem: .flexibleSpace) ] }() } -private extension TorrentTrackersViewController { - -} +private extension TorrentTrackersViewController {} diff --git a/iTorrent/Screens/TorrentTrackers/TorrentTrackersViewModel.swift b/iTorrent/Screens/TorrentTrackers/TorrentTrackersViewModel.swift index 2014ef2b..143bfd1d 100644 --- a/iTorrent/Screens/TorrentTrackers/TorrentTrackersViewModel.swift +++ b/iTorrent/Screens/TorrentTrackers/TorrentTrackersViewModel.swift @@ -17,6 +17,8 @@ class TorrentTrackersViewModel: BaseViewModelWith { @Published var sections: [MvvmCollectionSectionModel] = [] @Published var selectedIndexPaths: [IndexPath] = [] + @Injected var trackersListService: TrackersListService + var isRemoveAvailable: AnyPublisher { $selectedIndexPaths.map { !$0.isEmpty } .eraseToAnyPublisher() @@ -35,24 +37,42 @@ class TorrentTrackersViewModel: BaseViewModelWith { extension TorrentTrackersViewModel { func addTrackers() { - #if !os(visionOS) + #if os(visionOS) + textInput(title: %"trackers.add.title.single", message: %"trackers.add.message.single", placeholder: "http://x.x.x.x:8080/announce", cancel: %"common.cancel", accept: %"common.add") { [unowned self] result in + guard let url = URL(string: result ?? "") else { return } + torrentHandle.addTracker(url.absoluteString) + reload() + } + #else textMultilineInput(title: %"trackers.add.title", message: %"trackers.add.message", placeholder: "http://x.x.x.x:8080/announce", accept: %"common.add") { [unowned self] result in guard let result else { return } result.components(separatedBy: .newlines).forEach { urlString in guard let url = URL(string: urlString) else { return } torrentHandle.addTracker(url.absoluteString) - reload() } - } - #else - textInput(title: %"trackers.add.title.single", message: %"trackers.add.message.single", placeholder: "http://x.x.x.x:8080/announce", cancel: %"common.cancel", accept: %"common.add") { [unowned self] result in - guard let url = URL(string: result ?? "") else { return } - torrentHandle.addTracker(url.absoluteString) reload() } #endif } + func addTrackers(from list: TrackersListService.ListState) { + list.trackers.forEach { urlString in + guard let url = URL(string: urlString) else { return } + torrentHandle.addTracker(url.absoluteString) + } + reload() + } + + func addAllTrackersFromSourcesList() { + trackersListService.trackerSources.value.values.forEach { state in + state.trackers.forEach { urlString in + guard let url = URL(string: urlString) else { return } + torrentHandle.addTracker(url.absoluteString) + } + } + reload() + } + func removeSelected() { alert(title: %"trackers.remove.title", actions: [ .init(title: %"common.delete", style: .destructive, action: { [unowned self] in diff --git a/iTorrent/Services/TrackersListService/TrackersListService.swift b/iTorrent/Services/TrackersListService/TrackersListService.swift new file mode 100644 index 00000000..a694a657 --- /dev/null +++ b/iTorrent/Services/TrackersListService/TrackersListService.swift @@ -0,0 +1,87 @@ +// +// TrackersListService.swift +// iTorrent +// +// Created by Daniil Vinogradov on 16/09/2024. +// + +import Foundation +import MvvmFoundation +import Combine + +extension TrackersListService.ListState { + enum Status: Codable { + case updated + case error + } +} + +extension TrackersListService { + struct ListState: Identifiable, Codable { + var id: URL { url } + + var url: URL + var title: String + var status: Status + var trackers: [String] + } +} + +class TrackersListService { + let trackerSources: CurrentValueSubject<[URL: ListState], Never> + + init() { + trackerSources = CurrentValueSubject<[URL: ListState], Never>((try? Self.load()) ?? [:]) + + trackerSources.sink { urls in + try? Self.safe(urls) + }.store(in: disposeBag) + + Task { await Self.refresh(trackerSources.value) } + } + + func addTrackersSource(_ url: URL, title: String) async throws { + let (data, _) = try await URLSession.shared.data(from: url) + let trackers = String(data: data, encoding: .utf8)?.components(separatedBy: "\n").filter { !$0.isEmpty } ?? [] + let listState = ListState(url: url, title: title, status: .updated, trackers: trackers) + trackerSources.value[url] = listState + } + + private let disposeBag = DisposeBag() + private static let key = "TrackersListServiceDataKey" +} + +private extension TrackersListService { + static func refresh(_ oldValues: [URL: ListState]) async -> [URL: ListState] { + await withTaskGroup(of: ListState.self, returning: [URL: ListState].self) { taskGroup in + oldValues.values.forEach { value in + taskGroup.addTask { + do { + let (data, _) = try await URLSession.shared.data(from: value.url) + let trackers = String(data: data, encoding: .utf8)?.components(separatedBy: "\n").filter { !$0.isEmpty } ?? [] + return ListState(url: value.url, title: value.title, status: .updated, trackers: trackers) + } catch { + return .init(url: value.url, title: value.title, status: .error, trackers: value.trackers) + } + } + } + + return await taskGroup.reduce(into: [URL: ListState]()) { partialResult, state in + partialResult[state.url] = state + } + } + } +} + +private extension TrackersListService { + static func load() throws -> [URL: ListState] { + guard let data = UserDefaults.standard.data(forKey: key) + else { return [:] } + return try JSONDecoder().decode([URL: ListState].self, from: data) + } + + static func safe(_ urls: [URL: ListState]) throws { + let data = try JSONEncoder().encode(urls) + UserDefaults.standard.set(data, forKey: key) + } +} diff --git a/iTorrent/Utils/Extensions/Combine/Publisher+UI.swift b/iTorrent/Utils/Extensions/Combine/Publisher+UI.swift index 6bbcc6ce..04091647 100644 --- a/iTorrent/Utils/Extensions/Combine/Publisher+UI.swift +++ b/iTorrent/Utils/Extensions/Combine/Publisher+UI.swift @@ -8,7 +8,7 @@ import Combine import Foundation -@MainActor +//@MainActor extension Publisher where Self.Failure == Never { /// Attaches a subscriber with closure-based behavior to a publisher that never fails and receives on Main Thread if needed. diff --git a/iTorrent/Utils/Extensions/MvvmFoundation/MvvmViewModel+Alert.swift b/iTorrent/Utils/Extensions/MvvmFoundation/MvvmViewModel+Alert.swift index f804e7be..a4410b98 100644 --- a/iTorrent/Utils/Extensions/MvvmFoundation/MvvmViewModel+Alert.swift +++ b/iTorrent/Utils/Extensions/MvvmFoundation/MvvmViewModel+Alert.swift @@ -12,4 +12,8 @@ extension MvvmViewModelProtocol { func textInput(title: String?, message: String? = nil, placeholder: String?, defaultValue: String? = nil, type: UIKeyboardType = .default, secured: Bool = false, accept: String = String(localized: "common.ok"), result: @escaping (String?) -> Void) { textInput(title: title, message: message, placeholder: placeholder, defaultValue: defaultValue, type: type, secured: secured, cancel: String(localized: "common.cancel"), accept: accept, result: result) } + + func textInputs(title: String?, message: String? = nil, textInputs: [MvvmTextInputModel], accept: String = String(localized: "common.ok"), result: @escaping ([String]?) -> Void) { + self.textInputs(title: title, message: message, textInputs: textInputs, cancel: String(localized: "common.cancel"), accept: accept, result: result) + } } diff --git a/iTorrent/Utils/Extensions/MvvmFoundation/MvvmViewModelProtocol+Alert.swift b/iTorrent/Utils/Extensions/MvvmFoundation/MvvmViewModelProtocol+Alert.swift index 133c7315..2bc62438 100644 --- a/iTorrent/Utils/Extensions/MvvmFoundation/MvvmViewModelProtocol+Alert.swift +++ b/iTorrent/Utils/Extensions/MvvmFoundation/MvvmViewModelProtocol+Alert.swift @@ -9,6 +9,7 @@ import MvvmFoundation import UIKit extension MvvmViewModelProtocol { + @available(visionOS, unavailable) func textMultilineInput(title: String?, message: String? = nil, placeholder: String? = nil,