diff --git a/.swiftlint.yml b/.swiftlint.yml index 5022c867b..6354e16f6 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -10,6 +10,8 @@ disabled_rules: - switch_case_alignment - identifier_name - trailing_comma + - trailing_newline + - trailing_whitespace - private_over_fileprivate - force_try - type_body_length diff --git a/BookPlayer.xcodeproj/project.pbxproj b/BookPlayer.xcodeproj/project.pbxproj index de55e5ffc..a2e7e6a4c 100644 --- a/BookPlayer.xcodeproj/project.pbxproj +++ b/BookPlayer.xcodeproj/project.pbxproj @@ -28,8 +28,6 @@ 41188D2826ED4D5C0017124E /* ItemListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41188D2726ED4D5C0017124E /* ItemListViewController.swift */; }; 41188D2A26ED4D8E0017124E /* ItemListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41188D2926ED4D8E0017124E /* ItemListViewModel.swift */; }; 4122991D24442E7800CDB416 /* BookPlayerKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 41A1B0D2226E9BC400EA0400 /* BookPlayerKit.framework */; }; - 4124121A26CF287600B099DB /* StorageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4124121926CF287600B099DB /* StorageViewController.swift */; }; - 4124122626D1640000B099DB /* StorageTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4124122526D1640000B099DB /* StorageTableViewCell.swift */; }; 4124122826D19A8700B099DB /* StorageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4124122726D19A8600B099DB /* StorageViewModel.swift */; }; 4124122A26D19B9100B099DB /* StorageItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4124122926D19B9100B099DB /* StorageItem.swift */; }; 4124AB1725DFE07E0007C839 /* DataMigrationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4124AB1625DFE07E0007C839 /* DataMigrationManager.swift */; }; @@ -522,6 +520,8 @@ C3FA301E20E0024900393DDA /* BPArtworkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3FA301D20E0024900393DDA /* BPArtworkView.swift */; }; C3FE3F8220A090880055B9C6 /* limitPanAngle.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3FE3F8120A090880055B9C6 /* limitPanAngle.swift */; }; C3FE94792080086800BCEA37 /* BookCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3FE94782080086800BCEA37 /* BookCellView.swift */; }; + D6BA8F162A4CA94800C2BD9A /* StorageRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BA8F152A4CA94800C2BD9A /* StorageRowView.swift */; }; + D6BA8F182A4D66CD00C2BD9A /* StorageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BA8F172A4D66CD00C2BD9A /* StorageView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -690,8 +690,6 @@ 41188D2726ED4D5C0017124E /* ItemListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemListViewController.swift; sourceTree = ""; }; 41188D2926ED4D8E0017124E /* ItemListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemListViewModel.swift; sourceTree = ""; }; 41188D3026ED715D0017124E /* SimpleLibraryItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleLibraryItem.swift; sourceTree = ""; }; - 4124121926CF287600B099DB /* StorageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageViewController.swift; sourceTree = ""; }; - 4124122526D1640000B099DB /* StorageTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageTableViewCell.swift; sourceTree = ""; }; 4124122726D19A8600B099DB /* StorageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageViewModel.swift; sourceTree = ""; }; 4124122926D19B9100B099DB /* StorageItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageItem.swift; sourceTree = ""; }; 412451821D489204008AC0E5 /* Crashlytics.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; path = Crashlytics.framework; sourceTree = ""; }; @@ -1162,6 +1160,8 @@ C3FE3F8120A090880055B9C6 /* limitPanAngle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = limitPanAngle.swift; sourceTree = ""; }; C3FE94782080086800BCEA37 /* BookCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookCellView.swift; sourceTree = ""; }; D367F7671FA2A6F000FEDB37 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; + D6BA8F152A4CA94800C2BD9A /* StorageRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageRowView.swift; sourceTree = ""; }; + D6BA8F172A4D66CD00C2BD9A /* StorageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -1300,10 +1300,10 @@ 4124121B26CF289B00B099DB /* Storage */ = { isa = PBXGroup; children = ( - 4124121926CF287600B099DB /* StorageViewController.swift */, 4124122926D19B9100B099DB /* StorageItem.swift */, - 4124122526D1640000B099DB /* StorageTableViewCell.swift */, 4124122726D19A8600B099DB /* StorageViewModel.swift */, + D6BA8F152A4CA94800C2BD9A /* StorageRowView.swift */, + D6BA8F172A4D66CD00C2BD9A /* StorageView.swift */, ); path = Storage; sourceTree = ""; @@ -3027,7 +3027,6 @@ 9F2DC9DB2A008B28006CDF1F /* PricingRowView.swift in Sources */, C3FE3F8220A090880055B9C6 /* limitPanAngle.swift in Sources */, 9F1804B827A4AEC500FEDFE5 /* AccessibleSliderView.swift in Sources */, - 4124122626D1640000B099DB /* StorageTableViewCell.swift in Sources */, 9FB20EB929A479FB0021663B /* BPAlertContent.swift in Sources */, 41B68FFF2705477D00F657C3 /* StorageCoordinator.swift in Sources */, 4138CE1926E5B3FB0014F11E /* BookmarksViewController.swift in Sources */, @@ -3080,6 +3079,7 @@ C318D3AD208CF624000666F8 /* PlayerJumpIcon.swift in Sources */, 4138CE2326E66E420014F11E /* BookmarkTableViewCell.swift in Sources */, C37A6875209F13120063AEAC /* CreditsViewController.swift in Sources */, + D6BA8F162A4CA94800C2BD9A /* StorageRowView.swift in Sources */, 9F3C436A284181690066D99A /* DataInitializerCoordinator.swift in Sources */, 9F3C436B284181C70066D99A /* AlertPresenter.swift in Sources */, 9F00A6212950F44B005EA316 /* ImagePicker.swift in Sources */, @@ -3160,13 +3160,13 @@ 41E79BEB26C60DC600EA9FFF /* PlayerViewModel.swift in Sources */, 9F5FBB0A293EE0C2009F4B0E /* ItemDetailsViewModel.swift in Sources */, 9FC1A29F28C0D8CC00F25906 /* BookmarksActivityItemProvider.swift in Sources */, + D6BA8F182A4D66CD00C2BD9A /* StorageView.swift in Sources */, 9F64C6212793C31600B2493C /* PlayerControlsCoordinator.swift in Sources */, 9F7B647A2804773D00895ECC /* ThemesViewModel.swift in Sources */, 41C3395225E040FB003ED2B0 /* MappingModel_v2_to_v3.xcmappingmodel in Sources */, C3EC372E206EE0650094B4E8 /* SleepTimer.swift in Sources */, 4124122A26D19B9100B099DB /* StorageItem.swift in Sources */, 41A1B12F226FE0F900EA0400 /* Notification+BookPlayer.swift in Sources */, - 4124121A26CF287600B099DB /* StorageViewController.swift in Sources */, 9F4691FA2800F8D600A8F0E8 /* AccountCoordinator.swift in Sources */, 412AB70E2701463100969618 /* LoadingViewModel.swift in Sources */, 9F22DE42288DD20100056FCD /* AccountCardView.swift in Sources */, diff --git a/BookPlayer.xcodeproj/xcshareddata/xcschemes/BookPlayerWidgetUIExtension.xcscheme b/BookPlayer.xcodeproj/xcshareddata/xcschemes/BookPlayerWidgetUIExtension.xcscheme new file mode 100644 index 000000000..295eff2a4 --- /dev/null +++ b/BookPlayer.xcodeproj/xcshareddata/xcschemes/BookPlayerWidgetUIExtension.xcscheme @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/BookPlayer/Coordinators/StorageCoordinator.swift b/BookPlayer/Coordinators/StorageCoordinator.swift index a03e84e6f..2c8dc2239 100644 --- a/BookPlayer/Coordinators/StorageCoordinator.swift +++ b/BookPlayer/Coordinators/StorageCoordinator.swift @@ -8,9 +8,10 @@ import Foundation import BookPlayerKit +import SwiftUI -class StorageCoordinator: Coordinator { - let libraryService: LibraryServiceProtocol +final class StorageCoordinator: Coordinator { + private let libraryService: LibraryServiceProtocol init( libraryService: LibraryServiceProtocol, @@ -26,22 +27,19 @@ class StorageCoordinator: Coordinator { } override func start() { - let vc = StorageViewController.instantiate(from: .Settings) - - let viewModel = StorageViewModel(libraryService: self.libraryService, + let viewModel = StorageViewModel(libraryService: libraryService, folderURL: DataManager.getProcessedFolderURL()) viewModel.coordinator = self - vc.viewModel = viewModel - vc.navigationItem.largeTitleDisplayMode = .never - self.navigationController.viewControllers = [vc] - self.navigationController.presentationController?.delegate = self - self.presentingViewController?.present(self.navigationController, animated: true, completion: nil) + let vc = UIHostingController(rootView: StorageView(viewModel: viewModel)) +// vc.navigationItem.largeTitleDisplayMode = .never + + navigationController.viewControllers = [vc] + navigationController.presentationController?.delegate = self + presentingViewController?.present(navigationController, animated: true) } override func interactiveDidFinish(vc: UIViewController) { - guard let vc = vc as? StorageViewController else { return } - - vc.viewModel.coordinator.detach() + detach() } } diff --git a/BookPlayer/Settings/Base.lproj/Settings.storyboard b/BookPlayer/Settings/Base.lproj/Settings.storyboard index 0a04af1c7..3a3cbdf81 100644 --- a/BookPlayer/Settings/Base.lproj/Settings.storyboard +++ b/BookPlayer/Settings/Base.lproj/Settings.storyboard @@ -1,11 +1,10 @@ - + - + - @@ -528,7 +527,7 @@ - + @@ -592,7 +591,7 @@ - + @@ -667,7 +666,7 @@ - + @@ -1841,237 +1840,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -2114,7 +1882,7 @@ second line - + @@ -2455,10 +2223,8 @@ Use with caution and care for your hearing. - - @@ -2466,14 +2232,5 @@ Use with caution and care for your hearing. - - - - - - - - - diff --git a/BookPlayer/Settings/Storage/StorageItem.swift b/BookPlayer/Settings/Storage/StorageItem.swift index 4e9eab200..0a997207b 100644 --- a/BookPlayer/Settings/Storage/StorageItem.swift +++ b/BookPlayer/Settings/Storage/StorageItem.swift @@ -8,14 +8,15 @@ import Foundation -struct StorageItem { +struct StorageItem: Identifiable { + let id = UUID().uuidString let title: String let fileURL: URL let path: String let size: Int64 let showWarning: Bool - func formattedSize() -> String { - return ByteCountFormatter.string(fromByteCount: self.size, countStyle: ByteCountFormatter.CountStyle.file) + var formattedSize: String { + ByteCountFormatter.string(fromByteCount: size, countStyle: ByteCountFormatter.CountStyle.file) } } diff --git a/BookPlayer/Settings/Storage/StorageRowView.swift b/BookPlayer/Settings/Storage/StorageRowView.swift new file mode 100644 index 000000000..4c3cf4d27 --- /dev/null +++ b/BookPlayer/Settings/Storage/StorageRowView.swift @@ -0,0 +1,90 @@ +// +// StorageRowView.swift +// BookPlayer +// +// Created by Dmitrij Hojkolov on 28.06.2023. +// Copyright © 2023 Tortuga Power. All rights reserved. +// + +import SwiftUI +import BookPlayerKit + +struct StorageRowView: View { + let item: StorageItem + let onDeleteTap: (() -> Void)? + let onWarningTap: (() -> Void)? + + @EnvironmentObject var themeViewModel: ThemeViewModel + + var body: some View { + HStack { + Button { + onDeleteTap?() + } label: { + Image(systemName: "minus.circle.fill") + .resizable() + .scaledToFit() + .frame(width: 22, height: 22) + .foregroundColor(.red) + } + .padding(15) + + VStack(alignment: .leading, spacing: 2) { + Text(item.title) + .font(Font(Fonts.title)) + .multilineTextAlignment(.leading) + .foregroundColor(themeViewModel.primaryColor) + + Text(item.path) + .font(.footnote) + .multilineTextAlignment(.leading) + .foregroundColor(themeViewModel.secondaryColor) + + Text(item.formattedSize) + .font(.footnote) + .multilineTextAlignment(.leading) + .foregroundColor(themeViewModel.secondaryColor) + } + .padding(.trailing, item.showWarning ? 10 : 32) + + Spacer() + + if item.showWarning { + Button { + onWarningTap?() + } label: { + Image(systemName: "exclamationmark.triangle.fill") + .resizable() + .scaledToFit() + .frame(width: 22, height: 22) + .foregroundColor(.yellow) + } + .padding(15) + } + } + .background(themeViewModel.systemBackgroundColor) + } + + init(item: StorageItem, onDeleteTap: ( () -> Void)?, onWarningTap: ( () -> Void)?) { + self.item = item + self.onDeleteTap = onDeleteTap + self.onWarningTap = onWarningTap + } +} + +struct StorageRowView_Previews: PreviewProvider { + static var previews: some View { + StorageRowView( + item: StorageItem( + title: "Book title", + fileURL: URL(fileURLWithPath: "book.mp3"), + path: "book.mp3", + size: 124, + showWarning: true + ), + onDeleteTap: nil, + onWarningTap: nil + ) + .environmentObject(ThemeViewModel()) + } +} diff --git a/BookPlayer/Settings/Storage/StorageTableViewCell.swift b/BookPlayer/Settings/Storage/StorageTableViewCell.swift deleted file mode 100644 index 41af5e0b5..000000000 --- a/BookPlayer/Settings/Storage/StorageTableViewCell.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// StorageTableViewCell.swift -// BookPlayer -// -// Created by Gianni Carlo on 21/8/21. -// Copyright © 2021 Tortuga Power. All rights reserved. -// - -import BookPlayerKit -import Themeable -import UIKit - -final class StorageTableViewCell: UITableViewCell { - @IBOutlet weak var deleteButton: UIButton! - @IBOutlet weak var titleLabel: UILabel! - @IBOutlet weak var sizeLabel: UILabel! - @IBOutlet weak var filenameLabel: UILabel! - @IBOutlet weak var warningButton: UIButton! - - var onDeleteTap: (() -> Void)? - var onWarningTap: (() -> Void)? - - override func awakeFromNib() { - super.awakeFromNib() - setUpTheming() - } - - @IBAction func deleteTapped(_ sender: Any) { - self.onDeleteTap?() - } - - @IBAction func warningTapped(_ sender: Any) { - self.onWarningTap?() - } -} - -extension StorageTableViewCell: Themeable { - func applyTheme(_ theme: SimpleTheme) { - self.titleLabel.textColor = theme.primaryColor - self.filenameLabel?.textColor = theme.secondaryColor - self.sizeLabel.textColor = theme.secondaryColor - self.backgroundColor = theme.systemBackgroundColor - } -} diff --git a/BookPlayer/Settings/Storage/StorageView.swift b/BookPlayer/Settings/Storage/StorageView.swift new file mode 100644 index 000000000..e0ba01a81 --- /dev/null +++ b/BookPlayer/Settings/Storage/StorageView.swift @@ -0,0 +1,149 @@ +// +// StorageView.swift +// BookPlayer +// +// Created by Dmitrij Hojkolov on 29.06.2023. +// Copyright © 2023 Tortuga Power. All rights reserved. +// + +import SwiftUI +import BookPlayerKit + +struct StorageView: View { + + @StateObject var themeViewModel = ThemeViewModel() + @ObservedObject var viewModel: StorageViewModel + + var body: some View { + if viewModel.showProgressIndicator { + ProgressView() + } else { + VStack(spacing: 0) { + + // Total space + VStack { + Divider() + .background(themeViewModel.separatorColor) + + HStack(alignment: .center) { + Text("storage_total_title".localized) + .foregroundColor(themeViewModel.primaryColor) + + Spacer() + + Text(viewModel.getLibrarySize()) + .foregroundColor(themeViewModel.secondaryColor) + } + .padding(.horizontal, 16) + .padding(.top, 4) + + Divider() + .background(themeViewModel.separatorColor) + } + .background(themeViewModel.systemBackgroundColor) + .padding(.top, 14) + + HStack { + Text( + String.localizedStringWithFormat("files_title".localized, viewModel.publishedFiles.count) + .localizedUppercase + ) + .font(Font(Fonts.subheadline)) + .foregroundColor(themeViewModel.primaryColor) + + Spacer() + + if viewModel.hasFilesWithWarning { + Button("storage_fix_all_title".localized) { + viewModel.storageAlert = .fixAll + viewModel.showAlert = true + } + .foregroundColor(themeViewModel.linkColor) + } + } + .padding(.horizontal, 16) + .padding(.top, 30) + .padding(.bottom, 8) + + Divider() + .background(themeViewModel.separatorColor) + + ScrollView { + LazyVStack(spacing: 0) { + ForEach(viewModel.publishedFiles) { file in + VStack(spacing: 0) { + StorageRowView( + item: file, + onDeleteTap: { + viewModel.storageAlert = .delete(item: file) + viewModel.showAlert = true + }, + onWarningTap: { + viewModel.storageAlert = .fix(item: file) + viewModel.showAlert = true + } + ) + .padding(.vertical, 5) + + Divider() + .padding(.leading, 75) + .background(themeViewModel.separatorColor) + } + + } + } + } + .background(themeViewModel.systemBackgroundColor) + } + .background( + themeViewModel.systemGroupedBackgroundColor + .edgesIgnoringSafeArea(.bottom) + ) + .environmentObject(themeViewModel) + .navigationTitle("settings_storage_title".localized) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button( + action: viewModel.dismiss, + label: { + Image(systemName: "xmark") + .foregroundColor(themeViewModel.linkColor) + } + ) + } + + ToolbarItem(placement: .navigationBarTrailing) { + Menu { + Picker( + selection: $viewModel.sortBy, + label: Text("sort_button_title".localized)) { + // TODO: translation is missing + Text("sort_by_size_option".localized).tag(StorageViewModel.SortBy.size) + Text("title_button".localized).tag(StorageViewModel.SortBy.title) + } + } label: { + HStack { + Text("sort_button_title".localized) + Image(systemName: "chevron.down") + .resizable() + .scaledToFit() + .frame(width: 12, height: 12) + } + .foregroundColor(themeViewModel.linkColor) + } + } + } + .alert(isPresented: $viewModel.showAlert) { + viewModel.alert + } + } + } +} + +struct StorageView_Previews: PreviewProvider { + + static var previews: some View { + StorageView(viewModel: .demo) + } +} diff --git a/BookPlayer/Settings/Storage/StorageViewController.swift b/BookPlayer/Settings/Storage/StorageViewController.swift deleted file mode 100644 index 922ef1e35..000000000 --- a/BookPlayer/Settings/Storage/StorageViewController.swift +++ /dev/null @@ -1,187 +0,0 @@ -// -// StorageViewController.swift -// BookPlayer -// -// Created by Gianni Carlo on 19/8/21. -// Copyright © 2021 Tortuga Power. All rights reserved. -// - -import BookPlayerKit -import Combine -import Themeable -import UIKit - -final class StorageViewController: BaseViewController, Storyboarded { - @IBOutlet weak var filesTitleLabel: LocalizableLabel! - @IBOutlet weak var storageSpaceLabel: UILabel! - @IBOutlet weak var fixAllButton: UIButton! - @IBOutlet weak var tableView: UITableView! - @IBOutlet weak var loadingViewIndicator: UIActivityIndicatorView! - - @IBOutlet var titleLabels: [UILabel]! - @IBOutlet var containerViews: [UIView]! - @IBOutlet var separatorViews: [UIView]! - - private var disposeBag = Set() - private var items = [StorageItem]() { - didSet { - self.fixAllButton.isHidden = !self.items.contains { $0.showWarning } - } - } - - override func viewDidLoad() { - super.viewDidLoad() - - self.navigationItem.title = "settings_storage_title".localized - self.navigationItem.leftBarButtonItem = UIBarButtonItem( - image: ImageIcons.navigationBackImage, - style: .plain, - target: self, - action: #selector(self.didPressClose) - ) - self.fixAllButton.setTitle("storage_fix_all_title".localized, for: .normal) - - self.tableView.tableFooterView = UIView() - self.tableView.isScrollEnabled = true - - self.storageSpaceLabel.text = viewModel.getLibrarySize() - - self.bindObservers() - - setUpTheming() - } - - private func bindObservers() { - self.viewModel.observeFiles() - .receive(on: DispatchQueue.main) - .sink { [weak self] storageItems in - guard let loadedItems = storageItems else { return } - - self?.items = loadedItems - self?.filesTitleLabel.text = String.localizedStringWithFormat( - "files_title".localized, loadedItems.count - ).localizedUppercase - self?.tableView.reloadData() - self?.loadingViewIndicator.stopAnimating() - }.store(in: &disposeBag) - - self.fixAllButton.publisher(for: .touchUpInside) - .sink { [weak self] _ in - guard let self = self else { return } - - let brokenItems = self.viewModel.getBrokenItems() - - guard !brokenItems.isEmpty else { return } - - let alert = UIAlertController(title: nil, - message: "storage_fix_files_description".localized, - preferredStyle: .alert) - - alert.addAction(UIAlertAction(title: "cancel_button".localized, style: .cancel, handler: nil)) - - alert.addAction(UIAlertAction(title: "storage_fix_file_button".localized, style: .default, handler: { _ in - self.loadingViewIndicator.startAnimating() - do { - try self.viewModel.handleFix(for: brokenItems) { - self.loadingViewIndicator.stopAnimating() - } - } catch { - self.loadingViewIndicator.stopAnimating() - self.showAlert("error_title".localized, message: error.localizedDescription) - } - })) - - self.present(alert, animated: true, completion: nil) - }.store(in: &disposeBag) - } - - @objc func didPressClose() { - self.viewModel.dismiss() - } -} - -extension StorageViewController: UITableViewDataSource { - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return items.count - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - // swiftlint:disable force_cast - let cell = tableView.dequeueReusableCell(withIdentifier: "StorageTableViewCell", for: indexPath) as! StorageTableViewCell - let item = self.items[indexPath.row] - - cell.titleLabel.text = item.title - cell.sizeLabel.text = item.formattedSize() - cell.filenameLabel.text = item.path - cell.warningButton.isHidden = !item.showWarning - - cell.onWarningTap = { [weak self] in - let alert = UIAlertController(title: nil, - message: "storage_fix_file_description".localized, - preferredStyle: .alert) - - alert.addAction(UIAlertAction(title: "cancel_button".localized, style: .cancel, handler: nil)) - - alert.addAction(UIAlertAction(title: "storage_fix_file_button".localized, style: .default, handler: { [weak self] _ in - do { - try self?.viewModel.handleFix(for: item) - } catch { - self?.showAlert("error_title".localized, message: error.localizedDescription) - } - })) - - self?.present(alert, animated: true, completion: nil) - } - - cell.onDeleteTap = { [weak self] in - let alert = UIAlertController(title: nil, - message: String(format: "delete_single_item_title".localized, item.title), - preferredStyle: .alert) - - alert.addAction(UIAlertAction(title: "cancel_button".localized, style: .cancel, handler: nil)) - - alert.addAction(UIAlertAction(title: "delete_button".localized, style: .destructive, handler: { _ in - do { - try self?.viewModel.handleDelete(for: item) - } catch { - self?.showAlert("error_title".localized, message: error.localizedDescription) - } - })) - - self?.present(alert, animated: true, completion: nil) - } - - return cell - } - // swiftlint:enable force_cast -} - -extension StorageViewController: UITableViewDelegate { - func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - return UITableView.automaticDimension - } -} - -extension StorageViewController: Themeable { - func applyTheme(_ theme: SimpleTheme) { - self.view.backgroundColor = theme.systemGroupedBackgroundColor - self.fixAllButton.tintColor = theme.linkColor - - self.tableView.backgroundColor = theme.systemBackgroundColor - self.tableView.separatorColor = theme.separatorColor - - self.storageSpaceLabel.textColor = theme.secondaryColor - - self.separatorViews.forEach { $0.backgroundColor = theme.separatorColor } - - self.containerViews.forEach { $0.backgroundColor = theme.systemBackgroundColor } - - self.titleLabels.forEach { $0.textColor = theme.primaryColor } - - self.tableView.reloadData() - - self.overrideUserInterfaceStyle = theme.useDarkVariant - ? UIUserInterfaceStyle.dark - : UIUserInterfaceStyle.light - } -} diff --git a/BookPlayer/Settings/Storage/StorageViewModel.swift b/BookPlayer/Settings/Storage/StorageViewModel.swift index 225297a89..2be4a9bf3 100644 --- a/BookPlayer/Settings/Storage/StorageViewModel.swift +++ b/BookPlayer/Settings/Storage/StorageViewModel.swift @@ -9,29 +9,190 @@ import BookPlayerKit import Combine import DirectoryWatcher -import Foundation +import SwiftUI final class StorageViewModel: BaseViewModel, ObservableObject { - private var files = CurrentValueSubject<[StorageItem]?, Never>(nil) - private var disposeBag = Set() + + // MARK: - Properties + let libraryService: LibraryServiceProtocol private let folderURL: URL + + @Published var publishedFiles = [StorageItem]() { + didSet { + self.hasFilesWithWarning = self.publishedFiles.contains { $0.showWarning } + } + } + @Published var hasFilesWithWarning = false + @Published var showAlert = false + @Published var showProgressIndicator = false + + enum SortBy: Int { + case size, title + } + + enum StorageAlert { + case error(errorMessage: String) + case delete(item: StorageItem) + case fix(item: StorageItem) + case fixAll + case none // to avoid optional + } + + @Published var sortBy: SortBy { + didSet { + publishedFiles = sortedItems(items: publishedFiles) + UserDefaults.standard.set(sortBy.rawValue, forKey: Constants.UserDefaults.storageFilesSortOrder) + } + } + + var storageAlert: StorageAlert = .none + lazy var library: Library = { - return libraryService.getLibrary() + libraryService.getLibrary() }() init(libraryService: LibraryServiceProtocol, folderURL: URL) { self.libraryService = libraryService self.folderURL = folderURL + + self.sortBy = SortBy(rawValue: UserDefaults.standard.integer(forKey: Constants.UserDefaults.storageFilesSortOrder)) ?? .size super.init() self.loadItems() } + // MARK: - Public interface + + func getLibrarySize() -> String { + var folderSize: Int64 = 0 + + let enumerator = FileManager.default.enumerator( + at: self.folderURL, + includingPropertiesForKeys: [], + options: [.skipsHiddenFiles], errorHandler: { (url, error) -> Bool in + print("directoryEnumerator error at \(url): ", error) + return true + })! + + for case let fileURL as URL in enumerator { + guard let fileAttributes = try? FileManager.default.attributesOfItem(atPath: fileURL.path) else { continue } + folderSize += fileAttributes[FileAttributeKey.size] as? Int64 ?? 0 + } + + return ByteCountFormatter.string(fromByteCount: folderSize, countStyle: ByteCountFormatter.CountStyle.file) + } + + func shouldShowWarning(for relativePath: String) -> Bool { + // Fetch may fail with unicode characters, this is a last resort to double check it's not really linked + !bookExists(relativePath, library: self.library) + } + + func getBrokenItems() -> [StorageItem] { + publishedFiles.filter({ $0.showWarning }) + } + + func handleFix(for item: StorageItem, shouldReloadItems: Bool = true) throws { + guard let fetchedBook = self.libraryService.findBooks(containing: item.fileURL)?.first else { + // create a new book + try self.createBook(from: item) + if shouldReloadItems { + self.loadItems() + } + return + } + + // Relink book object if it's orphaned + if fetchedBook.getLibrary() == nil { + try libraryService.moveItems([fetchedBook.relativePath], inside: nil) + reloadLibraryItems() + } + + let fetchedBookURL = self.folderURL.appendingPathComponent(fetchedBook.relativePath) + + // Check if existing book already has its file, and this one is a duplicate + if FileManager.default.fileExists(atPath: fetchedBookURL.path) { + try FileManager.default.removeItem(at: item.fileURL) + self.coordinator.showAlert("storage_duplicate_item_title".localized, message: String.localizedStringWithFormat("storage_duplicate_item_description".localized, fetchedBook.relativePath!)) + if shouldReloadItems { + self.loadItems() + } + return + } + + try self.moveBookFile(from: item, with: fetchedBook) + + if shouldReloadItems { + self.loadItems() + } + } + + func deleteSelectedItem(_ item: StorageItem) { + do { + try handleDelete(for: item) + } catch { + storageAlert = .error(errorMessage: error.localizedDescription) + showAlert = true + } + } + + func fixSelectedItem(_ item: StorageItem) { + do { + try handleFix(for: item) + } catch { + storageAlert = .error(errorMessage: error.localizedDescription) + showAlert = true + } + } + + func fixAllBrokenItems() { + let brokenItems = getBrokenItems() + + guard !brokenItems.isEmpty else { return } + + showProgressIndicator = true + DispatchQueue.global().async { + do { + try self.handleFix(for: brokenItems) { + DispatchQueue.main.async { [weak self] in + self?.showProgressIndicator = false + } + } + } catch { + DispatchQueue.main.async { [weak self] in + self?.showProgressIndicator = false + self?.storageAlert = .error(errorMessage: error.localizedDescription) + self?.showAlert = true + } + } + } + } + + var alert: Alert { + + switch storageAlert { + case .error(let errorMessage): + return errorAlert(errorMessage) + case .delete(let item): + return deleteAlert(for: item) + case .fix(let item): + return fixAlert(for: item) + case .fixAll: + return fixAllAlert + case .none: + // processing this case to use non-optional var for storageAlert. + // This case should not happen + return Alert(title: Text("")) + } + } + + // MARK: - Private functions + private func loadItems() { - DispatchQueue.global(qos: .userInteractive).async { + showProgressIndicator = true + Task { @MainActor in let processedFolder = self.folderURL - + let enumerator = FileManager.default.enumerator( at: self.folderURL, includingPropertiesForKeys: [.isDirectoryKey], @@ -39,21 +200,21 @@ final class StorageViewModel: BaseViewModel, ObservableObjec print("directoryEnumerator error at \(url): ", error) return true })! - + var items = [StorageItem]() - + for case let fileURL as URL in enumerator { guard !fileURL.isDirectoryFolder, let fileAttributes = try? FileManager.default.attributesOfItem(atPath: fileURL.path) else { continue } - + let currentRelativePath = self.getRelativePath(of: fileURL, baseURL: processedFolder) let fetchedTitle = self.libraryService.getItemProperty( - #keyPath(LibraryItem.title), + #keyPath(BookPlayerKit.LibraryItem.title), relativePath: currentRelativePath ) as? String - + let bookTitle = fetchedTitle ?? Book.getBookTitle(from: fileURL) - + let storageItem = StorageItem( title: bookTitle, fileURL: fileURL, @@ -61,25 +222,21 @@ final class StorageViewModel: BaseViewModel, ObservableObjec size: fileAttributes[FileAttributeKey.size] as? Int64 ?? 0, showWarning: fetchedTitle == nil && self.shouldShowWarning(for: currentRelativePath) ) - + items.append(storageItem) } - - self.files.value = items + + showProgressIndicator = false + self.publishedFiles = self.sortedItems(items: items) } } - - func getRelativePath(of fileURL: URL, baseURL: URL) -> String { - return fileURL.relativePath(to: baseURL) - } - - func shouldShowWarning(for relativePath: String) -> Bool { - // Fetch may fail with unicode characters, this is a last resort to double check it's not really linked - return !bookExists(relativePath, library: self.library) + + private func getRelativePath(of fileURL: URL, baseURL: URL) -> String { + fileURL.relativePath(to: baseURL) } - func bookExists(_ relativePath: String, library: Library) -> Bool { - guard let items = library.items?.allObjects as? [LibraryItem] else { + private func bookExists(_ relativePath: String, library: Library) -> Bool { + guard let items = library.items?.allObjects as? [BookPlayerKit.LibraryItem] else { return false } @@ -94,7 +251,7 @@ final class StorageViewModel: BaseViewModel, ObservableObjec } } - func getItem(with relativePath: String, from item: LibraryItem) -> LibraryItem? { + private func getItem(with relativePath: String, from item: BookPlayerKit.LibraryItem) -> BookPlayerKit.LibraryItem? { switch item { case let folder as Folder: return getItem(with: relativePath, from: folder) @@ -105,12 +262,12 @@ final class StorageViewModel: BaseViewModel, ObservableObjec } } - func getItem(with relativePath: String, from folder: Folder) -> LibraryItem? { - guard let items = folder.items?.allObjects as? [LibraryItem] else { + private func getItem(with relativePath: String, from folder: Folder) -> BookPlayerKit.LibraryItem? { + guard let items = folder.items?.allObjects as? [BookPlayerKit.LibraryItem] else { return nil } - var itemFound: LibraryItem? + var itemFound: BookPlayerKit.LibraryItem? for item in items { if let libraryItem = getItem(with: relativePath, from: item) { @@ -122,12 +279,12 @@ final class StorageViewModel: BaseViewModel, ObservableObjec return itemFound } - func reloadLibraryItems() { + private func reloadLibraryItems() { AppDelegate.shared?.activeSceneDelegate?.coordinator.getMainCoordinator()? .getLibraryCoordinator()?.reloadItemsWithPadding() } - func createBook(from item: StorageItem) throws { + private func createBook(from item: StorageItem) throws { let book = self.libraryService.createBook(from: item.fileURL) try moveBookFile(from: item, with: book) try libraryService.moveItems([book.relativePath], inside: nil) @@ -154,91 +311,112 @@ final class StorageViewModel: BaseViewModel, ObservableObjec try FileManager.default.moveItem(at: item.fileURL, to: destinationURL) } - public func getLibrarySize() -> String { - var folderSize: Int64 = 0 - - let enumerator = FileManager.default.enumerator( - at: self.folderURL, - includingPropertiesForKeys: [], - options: [.skipsHiddenFiles], errorHandler: { (url, error) -> Bool in - print("directoryEnumerator error at \(url): ", error) - return true - })! - - for case let fileURL as URL in enumerator { - guard let fileAttributes = try? FileManager.default.attributesOfItem(atPath: fileURL.path) else { continue } - folderSize += fileAttributes[FileAttributeKey.size] as? Int64 ?? 0 - } - - return ByteCountFormatter.string(fromByteCount: folderSize, countStyle: ByteCountFormatter.CountStyle.file) - } - - public func observeFiles() -> AnyPublisher<[StorageItem]?, Never> { - self.files.map({ items in - return items?.sorted { $0.size > $1.size } - }).map({ items in - return items?.sorted { $0.showWarning && !$1.showWarning } - }).eraseToAnyPublisher() + private func sortedItems(items: [StorageItem]) -> [StorageItem] { + return items + .sorted { sortBy == .size ? $0.size > $1.size : $0.title < $1.title } + .sorted { $0.showWarning && !$1.showWarning } } - - public func handleDelete(for item: StorageItem) throws { - self.files.value = self.files.value?.filter { $0.fileURL != item.fileURL } - + + private func handleDelete(for item: StorageItem) throws { + let filteredFiles = publishedFiles.filter { $0.fileURL != item.fileURL } + publishedFiles = self.sortedItems(items: filteredFiles) try FileManager.default.removeItem(at: item.fileURL) } - - public func getBrokenItems() -> [StorageItem] { - return self.files.value?.filter({ $0.showWarning }) ?? [] - } - - public func handleFix(for items: [StorageItem], completion: (() -> Void)) throws { + + private func handleFix(for items: [StorageItem], completion: @escaping () -> Void) throws { guard !items.isEmpty else { - self.loadItems() + loadItems() completion() return } - + var mutableItems = items - + let currentItem = mutableItems.removeFirst() - - try self.handleFix(for: currentItem, shouldReloadItems: false) - - try self.handleFix(for: mutableItems, completion: completion) + + try handleFix(for: currentItem, shouldReloadItems: false) + + try handleFix(for: mutableItems, completion: completion) } - - public func handleFix(for item: StorageItem, shouldReloadItems: Bool = true) throws { - guard let fetchedBook = self.libraryService.findBooks(containing: item.fileURL)?.first else { - // create a new book - try self.createBook(from: item) - if shouldReloadItems { - self.loadItems() - } - return - } - - // Relink book object if it's orphaned - if fetchedBook.getLibrary() == nil { - try libraryService.moveItems([fetchedBook.relativePath], inside: nil) - reloadLibraryItems() - } - - let fetchedBookURL = self.folderURL.appendingPathComponent(fetchedBook.relativePath) - - // Check if existing book already has its file, and this one is a duplicate - if FileManager.default.fileExists(atPath: fetchedBookURL.path) { - try FileManager.default.removeItem(at: item.fileURL) - self.coordinator.showAlert("storage_duplicate_item_title".localized, message: String.localizedStringWithFormat("storage_duplicate_item_description".localized, fetchedBook.relativePath!)) - if shouldReloadItems { - self.loadItems() - } - return - } - - try self.moveBookFile(from: item, with: fetchedBook) - - if shouldReloadItems { - self.loadItems() - } + + private var fixAllAlert: Alert { + Alert( + title: Text(""), + message: Text("storage_fix_files_description".localized), + primaryButton: .cancel( + Text("cancel_button".localized) + ), + secondaryButton: .default( + Text("storage_fix_file_button".localized), + action: fixAllBrokenItems + ) + ) + } + + private func deleteAlert(for item: StorageItem) -> Alert { + Alert( + title: Text(""), + message: Text(String(format: "delete_single_item_title".localized, item.title)), + primaryButton: .cancel( + Text("cancel_button".localized) + ), + secondaryButton: .destructive( + Text("delete_button".localized), + action: { [weak self] in + self?.deleteSelectedItem(item) + } + ) + ) + } + + private func fixAlert(for item: StorageItem) -> Alert { + Alert( + title: Text(""), + message: Text("storage_fix_file_description".localized), + primaryButton: .cancel( + Text("cancel_button".localized) + ), + secondaryButton: .default( + Text("storage_fix_file_button".localized), + action: { [weak self] in + self?.fixSelectedItem(item) + } + ) + ) } + + private func errorAlert(_ message: String) -> Alert { + Alert( + title: Text("error_title".localized), + message: Text(message), + dismissButton: .default(Text("ok_button".localized)) + ) + } +} + +// MARK: - Preview data + +extension StorageViewModel { + static var demo: StorageViewModel = { + let bookContents = "bookcontents".data(using: .utf8)! + let filename = "book volume.mp3" + let documentsURL = DataManager.getDocumentsFolderURL() + let testPath = "/dev/null" + + let directoryURL = try! FileManager.default.url( + for: .itemReplacementDirectory, + in: .userDomainMask, + appropriateFor: documentsURL, + create: true + ) + + let destination = directoryURL.appendingPathComponent(filename) + try! bookContents.write(to: destination) + + let dataManager = DataManager(coreDataStack: CoreDataStack(testPath: testPath)) + let libraryService = LibraryService(dataManager: dataManager) + _ = libraryService.getLibrary() + return StorageViewModel(libraryService: libraryService, + folderURL: directoryURL) + }() } diff --git a/BookPlayerTests/StorageViewModelTests.swift b/BookPlayerTests/StorageViewModelTests.swift index 33e9ccafd..999ee9170 100644 --- a/BookPlayerTests/StorageViewModelTests.swift +++ b/BookPlayerTests/StorageViewModelTests.swift @@ -13,11 +13,10 @@ import Foundation import Combine import XCTest -class StorageViewModelMissingFileTests: XCTestCase { - var viewModel: StorageViewModel! - var subscription: AnyCancellable? - var directoryURL: URL! - let testPath = "/dev/null" +final class StorageViewModelMissingFileTests: XCTestCase { + private var viewModel: StorageViewModel! + private var directoryURL: URL! + private let testPath = "/dev/null" func testSetupItem(in folder: String, filename: String) { let bookContents = "bookcontents".data(using: .utf8)! @@ -69,19 +68,12 @@ class StorageViewModelMissingFileTests: XCTestCase { func testGetBrokenItems() { self.testSetup(with: "file-storage1.txt") - self.subscription?.cancel() let expectation = XCTestExpectation(description: "Items load expectation") - - var loadedItems: [StorageItem]! - self.subscription = self.viewModel.observeFiles() - .sink { optionalItems in - guard let items = optionalItems else { return } - loadedItems = items - expectation.fulfill() - } - + expectation.isInverted = true wait(for: [expectation], timeout: 5.0) + + let loadedItems: [StorageItem] = self.viewModel.publishedFiles XCTAssert(loadedItems.count == 1) let brokenItems = self.viewModel.getBrokenItems() @@ -90,7 +82,6 @@ class StorageViewModelMissingFileTests: XCTestCase { func testHandleFixItem() throws { self.testSetup(with: "file-storage2.txt") - self.subscription?.cancel() let item = StorageItem(title: "item", fileURL: self.directoryURL.appendingPathComponent("file-storage2.txt"), path: self.directoryURL.path, @@ -101,17 +92,10 @@ class StorageViewModelMissingFileTests: XCTestCase { try self.viewModel.handleFix(for: item) let expectation = XCTestExpectation(description: "Items load expectation") - - var loadedItems: [StorageItem]! - self.subscription = self.viewModel.observeFiles() - .sink { optionalItems in - guard let items = optionalItems, - items.contains(where: { !$0.showWarning }) else { return } - loadedItems = items - expectation.fulfill() - } - + expectation.isInverted = true wait(for: [expectation], timeout: 5.0) + + let loadedItems: [StorageItem] = viewModel.publishedFiles XCTAssert(loadedItems.count == 1) let brokenItems = self.viewModel.getBrokenItems() @@ -123,19 +107,15 @@ class StorageViewModelMissingFileTests: XCTestCase { let bookName = "idyllica_04_herrick_64kb.mp3" self.testSetupItem(in: folderName, filename: bookName) - self.subscription?.cancel() let expectation = XCTestExpectation(description: "Items load expectation") - - var loadedFileURL: URL! - self.subscription = self.viewModel.observeFiles() - .sink { optionalItems in - guard let item = optionalItems?.first else { return } - loadedFileURL = item.fileURL - expectation.fulfill() - } - + expectation.isInverted = true wait(for: [expectation], timeout: 5.0) + + guard let item = viewModel.publishedFiles.first else { + return + } + let loadedFileURL: URL = item.fileURL // Manual recreation of folder and book inside library let folder = try self.viewModel.libraryService.createFolder(with: folderName, inside: nil) diff --git a/Shared/Constants.swift b/Shared/Constants.swift index 2c35227ad..25b3d639c 100644 --- a/Shared/Constants.swift +++ b/Shared/Constants.swift @@ -30,6 +30,7 @@ public enum Constants { public static let autolockDisabled = "userSettingsDisableAutolock" public static let autolockDisabledOnlyWhenPowered = "userSettingsAutolockOnlyWhenPowered" public static let playerListPrefersBookmarks = "userSettingsPlayerListPrefersBookmarks" + public static let storageFilesSortOrder = "userSettingsStorageFilesSortOrder" public static let rewindInterval = "userSettingsRewindInterval" public static let forwardInterval = "userSettingsForwardInterval"