From da0929cff4205a408779885d609670277fb86df9 Mon Sep 17 00:00:00 2001 From: Eltik Date: Sat, 7 Sep 2024 14:25:44 -0700 Subject: [PATCH] broken: Continue watching --- .../DatabaseClient/Sources/Client.swift | 9 +- .../Clients/DatabaseClient/Sources/Live.swift | 186 +++++++++++----- .../Components/ContinueWatchingCard.swift | 200 ++++++++++++++++++ .../Features/Home/Sources/HomeFeature.swift | 17 +- Targets/Features/Home/Sources/HomeView.swift | 25 ++- .../Features/Info/Sources/InfoFeature.swift | 3 + .../ViewComponents/Sources/ProgressBar.swift | 46 ++++ 7 files changed, 428 insertions(+), 58 deletions(-) create mode 100644 Targets/Features/Home/Sources/Components/ContinueWatchingCard.swift create mode 100644 Targets/Shared/ViewComponents/Sources/ProgressBar.swift diff --git a/Targets/Clients/DatabaseClient/Sources/Client.swift b/Targets/Clients/DatabaseClient/Sources/Client.swift index 9b2dc4b..e8420f9 100644 --- a/Targets/Clients/DatabaseClient/Sources/Client.swift +++ b/Targets/Clients/DatabaseClient/Sources/Client.swift @@ -13,14 +13,21 @@ import SharedModels public struct DatabaseClient: Sendable { public let initDB: @Sendable () async -> Void - public let createCollection: @Sendable (_ name: String) async -> String + public let fetchCollection: @Sendable (_ id: String) async -> CollectionData? public let fetchCollections: @Sendable () async -> [HomeSection] + public let isInCollection: @Sendable(_ collectionId: String, _ moduleId: String, _ infoData: CollectionItem) async -> Bool + + public let createCollection: @Sendable (_ name: String) async -> String public let addToCollection: @Sendable (_ collectionId: String, _ moduleId: String, _ infoData: CollectionItem) async -> Void public let updateItemInCollection: @Sendable (_ collectionId: String, _ moduleId: String, _ infoData: CollectionItem) async -> Void public let removeFromCollection: @Sendable (_ collectionId: String, _ moduleId: String, _ infoData: CollectionItem) async -> Void public let removeCollection: @Sendable (_ collectionId: String, _ moduleId: String) async -> Void + + + public let fetchContinueWatching: @Sendable () async -> HomeSection + public let addToContinueWatching: @Sendable (_ moduleId: String, _ infoData: CollectionItem) async -> Void } extension DependencyValues { diff --git a/Targets/Clients/DatabaseClient/Sources/Live.swift b/Targets/Clients/DatabaseClient/Sources/Live.swift index ec636e2..e257fb8 100644 --- a/Targets/Clients/DatabaseClient/Sources/Live.swift +++ b/Targets/Clients/DatabaseClient/Sources/Live.swift @@ -63,22 +63,6 @@ extension DatabaseClient: DependencyKey { return Self( initDB: { - do { - let dbQueue: DatabaseQueue = { - let dbPath = try! fetchDatabasePath() - return try! DatabaseQueue(path: dbPath) - }() - - // ?? - - try dbQueue.close() - } catch { - print("Error initializing database file!") - print("\(error)") - } - }, - createCollection: { name in - let randomId = UUID().uuidString; do { let dbQueue: DatabaseQueue = { let dbPath = try! fetchDatabasePath() @@ -86,49 +70,21 @@ extension DatabaseClient: DependencyKey { }() try dbQueue.write { db in - let collectionTableName = "collection-\(randomId)" - let itemsTableName = "items-\(randomId)" - - try db.create(table: collectionTableName) { t in - t.column("uuid", .text).primaryKey() - t.column("name", .text).notNull() - } - - try db.create(table: itemsTableName) { t in + try db.create(table: "continuewatching", options: .ifNotExists) { t in t.column("id", .integer).primaryKey() - t.column("collection_uuid", .text).notNull().references(collectionTableName, onDelete: .cascade) t.column("moduleId", .text).notNull() t.column("infoData", .jsonText).notNull() - t.column("flag", .text).notNull() + t.column("episodeData", .jsonText).notNull() } } - - try dbQueue.close() - } catch { - print("Error creating collection tables for \(name)!") - print("\(error)") - } - - do { - let dbQueue: DatabaseQueue = { - let dbPath = try! fetchDatabasePath() - return try! DatabaseQueue(path: dbPath) - }() - - try dbQueue.write { db in - let collectionTableName = "collection-\(randomId)" - try db.execute(sql: "INSERT INTO '\(collectionTableName)' (uuid, name) VALUES (?, ?)", arguments: [randomId, name]) - print("Successfully created collection for \(name). ID: \(randomId)") - } - + try dbQueue.close() } catch { - print("Error creating base collections for \(name)!") + print("Error initializing database file!") print("\(error)") } - - return randomId; }, + fetchCollection: { id in do { let dbQueue: DatabaseQueue = { @@ -245,6 +201,60 @@ extension DatabaseClient: DependencyKey { return false } }, + + createCollection: { name in + let randomId = UUID().uuidString; + do { + let dbQueue: DatabaseQueue = { + let dbPath = try! fetchDatabasePath() + return try! DatabaseQueue(path: dbPath) + }() + + try dbQueue.write { db in + let collectionTableName = "collection-\(randomId)" + let itemsTableName = "items-\(randomId)" + + try db.create(table: collectionTableName) { t in + t.column("uuid", .text).primaryKey() + t.column("name", .text).notNull() + } + + try db.create(table: itemsTableName) { t in + t.column("id", .integer).primaryKey() + t.column("collection_uuid", .text).notNull().references(collectionTableName, onDelete: .cascade) + t.column("moduleId", .text).notNull() + t.column("infoData", .jsonText).notNull() + t.column("flag", .text).notNull() + } + } + + try dbQueue.close() + } catch { + print("Error creating collection tables for \(name)!") + print("\(error)") + } + + do { + let dbQueue: DatabaseQueue = { + let dbPath = try! fetchDatabasePath() + return try! DatabaseQueue(path: dbPath) + }() + + try dbQueue.write { db in + let collectionTableName = "collection-\(randomId)" + try db.execute(sql: "INSERT INTO '\(collectionTableName)' (uuid, name) VALUES (?, ?)", arguments: [randomId, name]) + print("Successfully created collection for \(name). ID: \(randomId)") + } + + try dbQueue.close() + } catch { + print("Error creating base collections for \(name)!") + print("\(error)") + } + + return randomId; + }, + addToCollection: { collectionId, moduleId, infoData in do { let dbQueue: DatabaseQueue = { @@ -332,6 +342,84 @@ extension DatabaseClient: DependencyKey { print("Error deleting collection!") print("\(error)") } + }, + + fetchContinueWatching: { + do { + let dbQueue: DatabaseQueue = { + let dbPath = try! fetchDatabasePath() + return try! DatabaseQueue(path: dbPath) + }() + + let continueWatchingItems = try dbQueue.read { db in + // Fetch all rows from the "continuewatching" table + let rows = try Row.fetchAll(db, sql: "SELECT * FROM continuewatching") + + let randomId = UUID().uuidString; + var result = HomeSection(id: randomId, title: "Continue Watching", type: 3, list: []) + + for row in rows { + // Parse each row to create a ContinueWatchingItem object + let moduleId: String = row["moduleId"] + let infoDataString: String = row["infoData"] + let episodeDataString: String = row["episodeData"] + + do { + let item = try JSONDecoder().decode(CollectionItem.self, from: infoDataString.data(using: .utf8)!) + let homeData = HomeData( + url: item.url, + titles: Titles(primary: item.infoData.titles.primary, secondary: item.infoData.titles.secondary ?? ""), + description: item.infoData.description, + poster: item.infoData.poster, + label: Label(text: "Test", color: ""), + indicator: "\(item.flag.rawValue)", + status: item.flag, + current: nil, + total: nil + ) + result.list.append(homeData) + } catch { + continue + } + } + + return result + } + + + try dbQueue.close() + + return continueWatchingItems + } catch { + print("Error fetching continue watching!") + print("\(error)") + + let randomId = UUID().uuidString; + + return HomeSection(id: randomId, title: "Continue Watching", type: 0, list: []) + } + }, + + addToContinueWatching: { moduleId, infoData in + do { + let dbQueue: DatabaseQueue = { + let dbPath = try! fetchDatabasePath() + return try! DatabaseQueue(path: dbPath) + }() + + // temp need episode data + try dbQueue.write { db in + try db.execute(sql: """ + INSERT INTO 'continuewatching' (moduleId, infoData, episodeData) VALUES (?, ?, ?); + """, arguments: [moduleId, try? JSONEncoder().encode(infoData), try? JSONEncoder().encode(HomeSection(id: "", title: "", type: 0, list: []))]) + print("Successfully added item to continue watching for \(infoData.infoData.titles.primary).") + } + + try dbQueue.close() + } catch { + print("Error adding to continue watching!") + print("\(error)") + } } ) }() diff --git a/Targets/Features/Home/Sources/Components/ContinueWatchingCard.swift b/Targets/Features/Home/Sources/Components/ContinueWatchingCard.swift new file mode 100644 index 0000000..64ce0b1 --- /dev/null +++ b/Targets/Features/Home/Sources/Components/ContinueWatchingCard.swift @@ -0,0 +1,200 @@ +// +// ContinueWatchingCard.swift +// +// +// Created by Inumaki on 9/7/24. +// + +import Architecture +import SharedModels +import UIKit +import ViewComponents + +class AsyncImageView: UIImageView { + private var currentURL: URL? + + // Function to load image from a URL string + func loadImage(from urlString: String, placeholder: UIImage? = nil) { + // Set placeholder image while the actual image loads + self.image = placeholder + + // Ensure the URL is valid + guard let url = URL(string: urlString) else { + return + } + + // Keep track of the URL in case it's changed before the request finishes + currentURL = url + + // Create a URL session to download the image data asynchronously + URLSession.shared.dataTask(with: url) { [weak self] data, response, error in + // Check for errors or invalid data + if let error = error { + print("Failed to load image: \(error)") + return + } + + guard let data = data, let downloadedImage = UIImage(data: data) else { + return + } + + // Ensure we're still expecting the image from this URL (in case of reused cells, etc.) + if url == self?.currentURL { + DispatchQueue.main.async { + self?.image = downloadedImage + } + } + }.resume() + } + + // Optionally, you can cancel any ongoing request if the view is reused or deallocated + func cancelLoading() { + currentURL = nil + } +} + +public class ContinueWatchingCard: UICollectionViewCell, SelfConfiguringCell { + static var reuseIdentifier: String = "ContinueWatchingCard" + + let imageView: AsyncImageView = { + let view = AsyncImageView() + view.contentMode = .scaleAspectFill + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + let moduleImageView: AsyncImageView = { + let view = AsyncImageView() + view.contentMode = .scaleAspectFill + view.layer.cornerRadius = 8 + view.layer.borderColor = ThemeManager.shared.getColor(for: .border).cgColor + view.layer.borderWidth = 0.5 + view.clipsToBounds = true + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + let overlayView: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + let titleLabel: UILabel = { + let label = UILabel() + label.text = "Title" + label.font = .systemFont(ofSize: 16, weight: .bold) + label.textColor = ThemeManager.shared.getColor(for: .fg) + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + let subtitleLabel: UILabel = { + let label = UILabel() + label.text = "Subtitle" + label.font = .systemFont(ofSize: 12, weight: .semibold) + label.textColor = ThemeManager.shared.getColor(for: .fg) + label.alpha = 0.7 + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + let timeLabel: UILabel = { + let label = UILabel() + label.text = "12:01 / 24:02" + label.font = .systemFont(ofSize: 10) + label.textColor = ThemeManager.shared.getColor(for: .fg) + label.alpha = 0.7 + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + let progressView = ProgressBar() + + let gradientLayer = CAGradientLayer() + + override init(frame: CGRect) { + super.init(frame: frame) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setupGradient() { + gradientLayer.colors = [ + ThemeManager.shared.getColor(for: .container).withAlphaComponent(0.0).cgColor, + ThemeManager.shared.getColor(for: .container).cgColor + ] + gradientLayer.locations = [0.0, 1.0] + overlayView.layer.insertSublayer(gradientLayer, at: 0) + } + + public override func layoutSubviews() { + super.layoutSubviews() + // Update gradient frame to match the overlayView's bounds + gradientLayer.frame = overlayView.bounds + } + + func configure(with data: HomeData) { + translatesAutoresizingMaskIntoConstraints = false + backgroundColor = ThemeManager.shared.getColor(for: .container) + layer.borderColor = ThemeManager.shared.getColor(for: .border).cgColor + layer.borderWidth = 0.5 + layer.cornerRadius = 12 + clipsToBounds = true + + imageView.loadImage(from: data.poster, placeholder: UIImage(named: "placeholder")) + moduleImageView.loadImage(from: "https://www.chouten.app/Icon.png", placeholder: UIImage(named: "placeholder")) + + titleLabel.text = data.titles.primary + + addSubview(imageView) + overlayView.addSubview(titleLabel) + overlayView.addSubview(subtitleLabel) + overlayView.addSubview(timeLabel) + overlayView.addSubview(progressView) + addSubview(overlayView) + + addSubview(moduleImageView) + + setupConstraints() + setupGradient() + } + + func setupConstraints() { + NSLayoutConstraint.activate([ + widthAnchor.constraint(equalToConstant: 240), + heightAnchor.constraint(equalToConstant: 180), + + overlayView.topAnchor.constraint(equalTo: topAnchor), + overlayView.bottomAnchor.constraint(equalTo: bottomAnchor), + overlayView.leadingAnchor.constraint(equalTo: leadingAnchor), + overlayView.trailingAnchor.constraint(equalTo: trailingAnchor), + + imageView.topAnchor.constraint(equalTo: topAnchor), + imageView.bottomAnchor.constraint(equalTo: bottomAnchor), + imageView.leadingAnchor.constraint(equalTo: leadingAnchor), + imageView.trailingAnchor.constraint(equalTo: trailingAnchor), + + subtitleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12), + subtitleLabel.bottomAnchor.constraint(equalTo: progressView.topAnchor, constant: -8), + + timeLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12), + timeLabel.bottomAnchor.constraint(equalTo: progressView.topAnchor, constant: -8), + + titleLabel.bottomAnchor.constraint(equalTo: subtitleLabel.topAnchor, constant: -4), + titleLabel.leadingAnchor.constraint(equalTo: subtitleLabel.leadingAnchor), + + progressView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12), + progressView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12), + progressView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -12), + progressView.heightAnchor.constraint(equalToConstant: 4), + + moduleImageView.widthAnchor.constraint(equalToConstant: 40), + moduleImageView.heightAnchor.constraint(equalToConstant: 40), + moduleImageView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12), + moduleImageView.topAnchor.constraint(equalTo: topAnchor, constant: 12) + ]) + } +} diff --git a/Targets/Features/Home/Sources/HomeFeature.swift b/Targets/Features/Home/Sources/HomeFeature.swift index ec1acc9..e1eb88b 100644 --- a/Targets/Features/Home/Sources/HomeFeature.swift +++ b/Targets/Features/Home/Sources/HomeFeature.swift @@ -61,10 +61,15 @@ public struct HomeFeature: Reducer { .run { send in await self.databaseClient.initDB() - let data = await self.databaseClient.fetchCollections(); + var collections = await self.databaseClient.fetchCollections(); + let continueWatching = await self.databaseClient.fetchContinueWatching(); - await send(.view(.setCollections(data))) - print("Collection count: \(data.count)") + collections.append(continueWatching) + + await send(.view(.setCollections(collections))) + + print("Collections count: \(collections.count)") + print("Continue watching count: \(continueWatching.list.count)") } ) case .setCollections(let data): @@ -81,9 +86,11 @@ public struct HomeFeature: Reducer { await self.databaseClient.removeCollection(collectionId, ""); } case .createCollection(let name): - return .run { send in + return .run { _ in print("Creating collection for \(name)...") - await self.databaseClient.createCollection(name) + + let result = await self.databaseClient.createCollection(name) + print("Collection created with name \(result)!") } } } diff --git a/Targets/Features/Home/Sources/HomeView.swift b/Targets/Features/Home/Sources/HomeView.swift index 2e8e90f..57d0f0c 100644 --- a/Targets/Features/Home/Sources/HomeView.swift +++ b/Targets/Features/Home/Sources/HomeView.swift @@ -93,6 +93,7 @@ public class HomeView: UIViewController { view.addSubview(selectButton) view.addSubview(deleteButton) + collectionView.register(ContinueWatchingCard.self, forCellWithReuseIdentifier: ContinueWatchingCard.reuseIdentifier) collectionView.register(CarouselCell.self, forCellWithReuseIdentifier: CarouselCell.reuseIdentifier) collectionView.register(ListCell.self, forCellWithReuseIdentifier: ListCell.reuseIdentifier) collectionView.register( @@ -154,7 +155,10 @@ public class HomeView: UIViewController { func createDataSource() { dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, data in + switch self.store.collections[indexPath.section].type { + case 3: + return self.configure(ContinueWatchingCard.self, with: data, for: indexPath) case 0: return self.configure(CarouselCell.self, with: data, for: indexPath) default: @@ -188,10 +192,9 @@ public class HomeView: UIViewController { func reloadData() { var snapshot = NSDiffableDataSourceSnapshot() - + if !self.store.collections.isEmpty { snapshot.appendSections(self.store.collections) - for section in self.store.collections { snapshot.appendItems(section.list, toSection: section) } @@ -203,8 +206,9 @@ public class HomeView: UIViewController { func createCompositionalLayout() -> UICollectionViewLayout { let layout = UICollectionViewCompositionalLayout { sectionIndex, layoutEnvironment in let section = self.store.collections[sectionIndex] - switch section.type { + case 3: + return self.createContinueWatchingCarousel(using: section) case 0: return self.createCarouselSection(using: section) default: @@ -218,6 +222,21 @@ public class HomeView: UIViewController { return layout } + + func createContinueWatchingCarousel(using section: HomeSection) -> NSCollectionLayoutSection { + let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.8), heightDimension: .absolute(180)) + let layoutItem = NSCollectionLayoutItem(layoutSize: itemSize) + + let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.9), heightDimension: .absolute(200)) + let layoutGroup = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [layoutItem]) + + let layoutSection = NSCollectionLayoutSection(group: layoutGroup) + layoutSection.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary + layoutSection.interGroupSpacing = 12 + layoutSection.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 20, bottom: 20, trailing: 20) + + return layoutSection + } func createCarouselSection(using section: HomeSection) -> NSCollectionLayoutSection { let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1)) diff --git a/Targets/Features/Info/Sources/InfoFeature.swift b/Targets/Features/Info/Sources/InfoFeature.swift index 6e7ec53..ffff619 100644 --- a/Targets/Features/Info/Sources/InfoFeature.swift +++ b/Targets/Features/Info/Sources/InfoFeature.swift @@ -93,6 +93,9 @@ public struct InfoFeature: Reducer { let collections = await self.databaseClient.fetchCollections(); + // TEMPORARY + await self.databaseClient.addToContinueWatching("", CollectionItem(infoData: data, url: url, flag: .none)) + await send(.view(.updateIsInCollections)) await send(.view(.setInfoData(data))) diff --git a/Targets/Shared/ViewComponents/Sources/ProgressBar.swift b/Targets/Shared/ViewComponents/Sources/ProgressBar.swift new file mode 100644 index 0000000..0afdc89 --- /dev/null +++ b/Targets/Shared/ViewComponents/Sources/ProgressBar.swift @@ -0,0 +1,46 @@ +// +// ProgressBar.swift +// +// +// Created by Inumaki on 9/7/24. +// + +import Architecture +import SharedModels +import UIKit + +public class ProgressBar: UIView { + let progress: Double = 0.5 + + let progressView: UIView = { + let view = UIView() + view.backgroundColor = ThemeManager.shared.getColor(for: .accent) + view.layer.cornerRadius = 2 + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + override init(frame: CGRect) { + super.init(frame: frame) + + translatesAutoresizingMaskIntoConstraints = false + + backgroundColor = ThemeManager.shared.getColor(for: .container) + layer.cornerRadius = 2 + layer.borderColor = ThemeManager.shared.getColor(for: .border).cgColor + layer.borderWidth = 0.5 + + addSubview(progressView) + + NSLayoutConstraint.activate([ + progressView.leadingAnchor.constraint(equalTo: leadingAnchor), + progressView.topAnchor.constraint(equalTo: topAnchor), + progressView.bottomAnchor.constraint(equalTo: bottomAnchor), + progressView.widthAnchor.constraint(equalTo: widthAnchor, multiplier: progress) + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +}