diff --git a/Comic.xcodeproj/project.pbxproj b/Comic.xcodeproj/project.pbxproj index 38a4c98..e4c9dfb 100644 --- a/Comic.xcodeproj/project.pbxproj +++ b/Comic.xcodeproj/project.pbxproj @@ -23,6 +23,11 @@ 0E299BE62C020AB50083E07C /* ReaderCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E299BE52C020AB50083E07C /* ReaderCell.swift */; }; 0E299BE82C020DDB0083E07C /* UICollectionView+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E299BE72C020DDB0083E07C /* UICollectionView+Extensions.swift */; }; 0E299BEA2C0218630083E07C /* Bundle+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E299BE92C0218630083E07C /* Bundle+Extensions.swift */; }; + 0E58F7392C0F6803005936D7 /* EpisodeListVO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E58F7342C0F6803005936D7 /* EpisodeListVO.swift */; }; + 0E58F73A2C0F6803005936D7 /* EpisodeListRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E58F7352C0F6803005936D7 /* EpisodeListRouter.swift */; }; + 0E58F73B2C0F6803005936D7 /* EpisodeListVM.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E58F7362C0F6803005936D7 /* EpisodeListVM.swift */; }; + 0E58F73C2C0F6803005936D7 /* EpisodeListModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E58F7372C0F6803005936D7 /* EpisodeListModels.swift */; }; + 0E58F73D2C0F6803005936D7 /* EpisodeListVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E58F7382C0F6803005936D7 /* EpisodeListVC.swift */; }; 0E9E21A52C09FA4900E83164 /* ReaderCellIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E9E21A42C09FA4900E83164 /* ReaderCellIndicator.swift */; }; 0E9E21AC2C0C3D6000E83164 /* ParserConfiguration+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E9E21AB2C0C3D6000E83164 /* ParserConfiguration+Extensions.swift */; }; 0E9E21AE2C0C3DD900E83164 /* String+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E9E21AD2C0C3DD900E83164 /* String+Extensions.swift */; }; @@ -83,6 +88,11 @@ 0E299BE52C020AB50083E07C /* ReaderCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderCell.swift; sourceTree = ""; }; 0E299BE72C020DDB0083E07C /* UICollectionView+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UICollectionView+Extensions.swift"; sourceTree = ""; }; 0E299BE92C0218630083E07C /* Bundle+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+Extensions.swift"; sourceTree = ""; }; + 0E58F7342C0F6803005936D7 /* EpisodeListVO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeListVO.swift; sourceTree = ""; }; + 0E58F7352C0F6803005936D7 /* EpisodeListRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeListRouter.swift; sourceTree = ""; }; + 0E58F7362C0F6803005936D7 /* EpisodeListVM.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeListVM.swift; sourceTree = ""; }; + 0E58F7372C0F6803005936D7 /* EpisodeListModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeListModels.swift; sourceTree = ""; }; + 0E58F7382C0F6803005936D7 /* EpisodeListVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeListVC.swift; sourceTree = ""; }; 0E9E21A42C09FA4900E83164 /* ReaderCellIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderCellIndicator.swift; sourceTree = ""; }; 0E9E21AB2C0C3D6000E83164 /* ParserConfiguration+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ParserConfiguration+Extensions.swift"; sourceTree = ""; }; 0E9E21AD2C0C3DD900E83164 /* String+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Extensions.swift"; sourceTree = ""; }; @@ -183,6 +193,18 @@ path = Shared; sourceTree = ""; }; + 0E58F7332C0F67EA005936D7 /* EpisodeList */ = { + isa = PBXGroup; + children = ( + 0E58F7372C0F6803005936D7 /* EpisodeListModels.swift */, + 0E58F7352C0F6803005936D7 /* EpisodeListRouter.swift */, + 0E58F7382C0F6803005936D7 /* EpisodeListVC.swift */, + 0E58F7362C0F6803005936D7 /* EpisodeListVM.swift */, + 0E58F7342C0F6803005936D7 /* EpisodeListVO.swift */, + ); + path = EpisodeList; + sourceTree = ""; + }; 0EA88FA42BFCBB65002CAA75 = { isa = PBXGroup; children = ( @@ -322,6 +344,7 @@ 0EA890152BFE4777002CAA75 /* MVVVR */ = { isa = PBXGroup; children = ( + 0E58F7332C0F67EA005936D7 /* EpisodeList */, 0EA88FFE2BFD0E99002CAA75 /* Detail */, 0EA88FF02BFD01A9002CAA75 /* Favorite */, 0EE6365B2BFF93F8003F5694 /* History */, @@ -439,6 +462,11 @@ buildActionMask = 2147483647; files = ( 0EA88FCB2BFCBCBA002CAA75 /* UpdateVO.swift in Sources */, + 0E58F7392C0F6803005936D7 /* EpisodeListVO.swift in Sources */, + 0E58F73A2C0F6803005936D7 /* EpisodeListRouter.swift in Sources */, + 0E58F73B2C0F6803005936D7 /* EpisodeListVM.swift in Sources */, + 0E58F73C2C0F6803005936D7 /* EpisodeListModels.swift in Sources */, + 0E58F73D2C0F6803005936D7 /* EpisodeListVC.swift in Sources */, 0EA88FEA2BFCE59B002CAA75 /* DBWorker.swift in Sources */, 0EA88FE82BFCCF78002CAA75 /* UITableView+Extensions.swift in Sources */, 0E9E21A52C09FA4900E83164 /* ReaderCellIndicator.swift in Sources */, @@ -629,7 +657,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20240604; + CURRENT_PROJECT_VERSION = 20240605; DEVELOPMENT_TEAM = VZWPMD258L; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -640,7 +668,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.9.1; + MARKETING_VERSION = 0.9.2; PRODUCT_BUNDLE_IDENTIFIER = com.shinrenpan.Comic; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -655,7 +683,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20240604; + CURRENT_PROJECT_VERSION = 20240605; DEVELOPMENT_TEAM = VZWPMD258L; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -666,7 +694,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.9.1; + MARKETING_VERSION = 0.9.2; PRODUCT_BUNDLE_IDENTIFIER = com.shinrenpan.Comic; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; diff --git a/Sources/MVVVR/Detail/DetailVO.swift b/Sources/MVVVR/Detail/DetailVO.swift index ec08424..1409caa 100644 --- a/Sources/MVVVR/Detail/DetailVO.swift +++ b/Sources/MVVVR/Detail/DetailVO.swift @@ -31,6 +31,10 @@ extension DetailVO { header.reloadUI(comic: model.comic) list.refreshControl?.endRefreshing() list.reloadData() + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { + self.updateWatchedUI(model: model) + } } } @@ -61,4 +65,19 @@ private extension DetailVO { list.bottomAnchor.constraint(equalTo: mainView.bottomAnchor), ]) } + + // MARK: - Update Something + + func updateWatchedUI(model: DetailModels.DisplayModel) { + guard let watchedId = model.comic.watchedId else { + return + } + + guard let row = model.episodes.firstIndex(where: { $0.id == watchedId }) else { + return + } + + let indexPath = IndexPath(row: row, section: 0) + list.scrollToRow(at: indexPath, at: .top, animated: true) + } } diff --git a/Sources/MVVVR/EpisodeList/EpisodeListModels.swift b/Sources/MVVVR/EpisodeList/EpisodeListModels.swift new file mode 100644 index 0000000..1530183 --- /dev/null +++ b/Sources/MVVVR/EpisodeList/EpisodeListModels.swift @@ -0,0 +1,53 @@ +// +// EpisodeListModels.swift +// +// Created by Shinren Pan on 2024/6/4. +// + +import UIKit + +protocol EpisodeListDelegate: UIViewController { + func list(_ list: EpisodeListVC, selected episode: Comic.Episode) +} + +enum EpisodeListModels {} + +// MARK: - Action + +extension EpisodeListModels { + enum Action { + case loadData + } +} + +// MARK: - State + +extension EpisodeListModels { + enum State { + case none + case dataLoaded(episodes: [Comic.Episode], watchedId: String?) + } +} + +// MARK: - Other Model for DisplayModel + +extension EpisodeListModels { + typealias DataSource = UITableViewDiffableDataSource + typealias Snapshot = NSDiffableDataSourceSnapshot + + enum Section { + case main + } +} + +// MARK: - Display Model for ViewModel + +extension EpisodeListModels { + final class DisplayModel { + let comic: Comic + + init(comic: Comic) { + self.comic = comic + } + } +} diff --git a/Sources/MVVVR/EpisodeList/EpisodeListRouter.swift b/Sources/MVVVR/EpisodeList/EpisodeListRouter.swift new file mode 100644 index 0000000..98a5d5d --- /dev/null +++ b/Sources/MVVVR/EpisodeList/EpisodeListRouter.swift @@ -0,0 +1,11 @@ +// +// EpisodeListRouter.swift +// +// Created by Shinren Pan on 2024/6/4. +// + +import UIKit + +final class EpisodeListRouter { + weak var vc: EpisodeListVC? +} diff --git a/Sources/MVVVR/EpisodeList/EpisodeListVC.swift b/Sources/MVVVR/EpisodeList/EpisodeListVC.swift new file mode 100644 index 0000000..c8b6715 --- /dev/null +++ b/Sources/MVVVR/EpisodeList/EpisodeListVC.swift @@ -0,0 +1,121 @@ +// +// EpisodeListVC.swift +// +// Created by Shinren Pan on 2024/6/4. +// + +import Combine +import UIKit + +final class EpisodeListVC: UIViewController { + let vo = EpisodeListVO() + let vm: EpisodeListVM + let router = EpisodeListRouter() + var binding: Set = .init() + weak var delegate: EpisodeListDelegate? + lazy var dataSource = makeDataSource() + + init(comic: Comic) { + self.vm = .init(comic: comic) + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + setupSelf() + setupBinding() + setupVO() + } + + override func viewIsAppearing(_ animated: Bool) { + super.viewIsAppearing(animated) + vm.doAction(.loadData) + } +} + +// MARK: - Private + +private extension EpisodeListVC { + // MARK: Setup Something + + func setupSelf() { + view.backgroundColor = vo.mainView.backgroundColor + router.vc = self + } + + func setupBinding() { + vm.$state.receive(on: DispatchQueue.main).sink { [weak self] state in + if self?.viewIfLoaded?.window == nil { return } + + switch state { + case .none: + self?.stateNone() + case let .dataLoaded(episodes, watchId): + self?.stateDataLoaded(episodes: episodes, watchId: watchId) + } + }.store(in: &binding) + } + + func setupVO() { + view.addSubview(vo.mainView) + + NSLayoutConstraint.activate([ + vo.mainView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + vo.mainView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), + vo.mainView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), + vo.mainView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), + ]) + + vo.list.dataSource = dataSource + vo.list.delegate = self + } + + // MARK: - Handle State + + func stateNone() {} + + func stateDataLoaded(episodes: [Comic.Episode], watchId: String?) { + let row = episodes.firstIndex(where: { $0.id == watchId }) + var snapshot = EpisodeListModels.Snapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(episodes, toSection: .main) + snapshot.reloadSections([.main]) + + dataSource.apply(snapshot) { + if let row { + self.vo.list.scrollToRow(at: .init(row: row, section: 0), at: .top, animated: true) + } + } + } + + func makeDataSource() -> EpisodeListModels.DataSource { + .init(tableView: vo.list) { [weak self] tableView, indexPath, episode in + let watched = self?.vm.model.comic.watchedId == episode.id + var config = UIListContentConfiguration.cell() + config.text = episode.title + + let cell = tableView.reuseCell(UITableViewCell.self, for: indexPath) + cell.contentConfiguration = config + cell.accessoryType = watched ? .checkmark : .none + + return cell + } + } +} + +// MARK: - UITableViewDelegate + +extension EpisodeListVC: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard let epidose = dataSource.itemIdentifier(for: indexPath) else { + return + } + + delegate?.list(self, selected: epidose) + } +} diff --git a/Sources/MVVVR/EpisodeList/EpisodeListVM.swift b/Sources/MVVVR/EpisodeList/EpisodeListVM.swift new file mode 100644 index 0000000..30b541f --- /dev/null +++ b/Sources/MVVVR/EpisodeList/EpisodeListVM.swift @@ -0,0 +1,39 @@ +// +// EpisodeListVM.swift +// +// Created by Shinren Pan on 2024/6/4. +// + +import Combine +import UIKit + +final class EpisodeListVM { + @Published var state = EpisodeListModels.State.none + let model: EpisodeListModels.DisplayModel + + init(comic: Comic) { + self.model = .init(comic: comic) + } +} + +// MARK: - Public + +extension EpisodeListVM { + func doAction(_ action: EpisodeListModels.Action) { + switch action { + case .loadData: + actionLoadData() + } + } +} + +// MARK: - Private + +private extension EpisodeListVM { + // MARK: Do Action + + func actionLoadData() { + let episodes = model.comic.episodes?.sorted(by: { $0.index < $1.index }) ?? [] + state = .dataLoaded(episodes: episodes, watchedId: model.comic.watchedId) + } +} diff --git a/Sources/MVVVR/EpisodeList/EpisodeListVO.swift b/Sources/MVVVR/EpisodeList/EpisodeListVO.swift new file mode 100644 index 0000000..94cf4dc --- /dev/null +++ b/Sources/MVVVR/EpisodeList/EpisodeListVO.swift @@ -0,0 +1,38 @@ +// +// EpisodeListVO.swift +// +// Created by Shinren Pan on 2024/6/4. +// + +import UIKit + +final class EpisodeListVO { + let mainView = UIView(frame: .zero) + .setup(\.translatesAutoresizingMaskIntoConstraints, value: false) + .setup(\.backgroundColor, value: .white) + + let list = UITableView(frame: .zero, style: .plain) + .setup(\.translatesAutoresizingMaskIntoConstraints, value: false) + + init() { + addViews() + } +} + +// MARK: - Private + +private extension EpisodeListVO { + // MARK: Add Something + + func addViews() { + list.registerCell(UITableViewCell.self) + mainView.addSubview(list) + + NSLayoutConstraint.activate([ + list.topAnchor.constraint(equalTo: mainView.topAnchor), + list.leadingAnchor.constraint(equalTo: mainView.leadingAnchor), + list.trailingAnchor.constraint(equalTo: mainView.trailingAnchor), + list.bottomAnchor.constraint(equalTo: mainView.bottomAnchor), + ]) + } +} diff --git a/Sources/MVVVR/Reader/ReaderModels.swift b/Sources/MVVVR/Reader/ReaderModels.swift index 03be89a..95cda74 100644 --- a/Sources/MVVVR/Reader/ReaderModels.swift +++ b/Sources/MVVVR/Reader/ReaderModels.swift @@ -16,6 +16,7 @@ extension ReaderModels { case loadData case loadPrev case loadNext + case loadEpidoe(_ episode: Comic.Episode) } } diff --git a/Sources/MVVVR/Reader/ReaderRouter.swift b/Sources/MVVVR/Reader/ReaderRouter.swift index 031b27e..2a46b93 100644 --- a/Sources/MVVVR/Reader/ReaderRouter.swift +++ b/Sources/MVVVR/Reader/ReaderRouter.swift @@ -9,3 +9,18 @@ import UIKit final class ReaderRouter { weak var vc: ReaderVC? } + +// MARK: - Public + +extension ReaderRouter { + func showEpisodePicker(comic: Comic) { + let picker = EpisodeListVC(comic: comic) + picker.delegate = vc + + if let sheet = picker.sheetPresentationController { + sheet.detents = [.medium()] + } + + vc?.present(picker, animated: true) + } +} diff --git a/Sources/MVVVR/Reader/ReaderVC.swift b/Sources/MVVVR/Reader/ReaderVC.swift index df95e36..6527221 100644 --- a/Sources/MVVVR/Reader/ReaderVC.swift +++ b/Sources/MVVVR/Reader/ReaderVC.swift @@ -70,6 +70,7 @@ private extension ReaderVC { func setupSelf() { view.backgroundColor = vo.mainView.backgroundColor + navigationItem.rightBarButtonItem = makeEpisodePickerItem() setToolbarItems([ vo.prevItem, @@ -201,6 +202,15 @@ private extension ReaderVC { } } + func makeEpisodePickerItem() -> UIBarButtonItem { + let action = UIAction { [weak self] _ in + guard let self else { return } + router.showEpisodePicker(comic: vm.model.comic) + } + + return .init(image: .init(systemName: "list.number"), primaryAction: action) + } + // MARK: - Do Something func doLoadPrev() { @@ -214,6 +224,16 @@ private extension ReaderVC { vo.reloadDisableAll() vm.doAction(.loadNext) } + + func doLoadEpisode(_ episode: Comic.Episode) { + if episode.id == vm.model.comic.watchedId { + return + } + + showLoadingUI() + vo.reloadDisableAll() + vm.doAction(.loadEpidoe(episode)) + } } // MARK: - UICollectionViewDataSource @@ -290,3 +310,13 @@ extension ReaderVC: UICollectionViewDelegateFlowLayout { }*/ } } + +// MARK: - EpisodeListDelegate + +extension ReaderVC: EpisodeListDelegate { + func list(_ list: EpisodeListVC, selected episode: Comic.Episode) { + list.dismiss(animated: true) { + self.doLoadEpisode(episode) + } + } +} diff --git a/Sources/MVVVR/Reader/ReaderVM.swift b/Sources/MVVVR/Reader/ReaderVM.swift index ee0d8da..f6b39e4 100644 --- a/Sources/MVVVR/Reader/ReaderVM.swift +++ b/Sources/MVVVR/Reader/ReaderVM.swift @@ -31,6 +31,8 @@ extension ReaderVM { actionLoadPrev() case .loadNext: actionLoadNext() + case let .loadEpidoe(episode): + actionLoadEpisode(episode) } } } @@ -77,6 +79,13 @@ private extension ReaderVM { actionLoadData() } + func actionLoadEpisode(_ episode: Comic.Episode) { + parser.parserConfiguration.request = makeNewParserRequest(episode) + model.currentEpisode = episode + + actionLoadData() + } + // MARK: - Handle Action func handleLoadData(_ result: Any) async throws {