Some checks failed
CI / Scraper / Lint (pull_request) Successful in 12s
CI / Scraper / Test (pull_request) Successful in 15s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 25s
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Build (push) Successful in 1m39s
iOS CI / Build (pull_request) Successful in 1m29s
iOS CI / Test (push) Has been cancelled
iOS CI / Test (pull_request) Successful in 5m14s
- Reader: list.bullet ToC button in top chrome opens ChaptersListSheet - Reader: swipe right on title page → prev chapter, swipe left on end page → next chapter - Reader: scroll mode toggle in settings panel (ReaderSettings.scrollMode); ScrollReaderContent for continuous layout - Library: segmented All/In Progress/Completed filter - Library: genre filter chips derived from book.genres, amber fill when active - Library: completed books show checkmark badge + Finished label - Library: context-aware empty state messages per filter combination - Player: prev/next chapter buttons in mini + full player, ±15s skip, sleep timer countdown, Chapter N of M label - Player: ChaptersListSheet with 100-chapter blocks, jump bar, search filter - Home/BookDetail/Browse: Apple Books-style redesign, zoom transitions
289 lines
7.6 KiB
Swift
289 lines
7.6 KiB
Swift
import Foundation
|
|
import SwiftUI
|
|
|
|
// MARK: - Book
|
|
|
|
struct Book: Identifiable, Codable, Hashable {
|
|
let id: String
|
|
let slug: String
|
|
let title: String
|
|
let author: String
|
|
let cover: String
|
|
let status: String
|
|
let genres: [String]
|
|
let summary: String
|
|
let totalChapters: Int
|
|
let sourceURL: String
|
|
let ranking: Int
|
|
let metaUpdated: String
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case id, slug, title, author, cover, status, genres, summary
|
|
case totalChapters = "total_chapters"
|
|
case sourceURL = "source_url"
|
|
case ranking
|
|
case metaUpdated = "meta_updated"
|
|
}
|
|
|
|
// PocketBase returns genres as either a JSON string array or a real array
|
|
init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
id = try container.decode(String.self, forKey: .id)
|
|
slug = try container.decode(String.self, forKey: .slug)
|
|
title = try container.decode(String.self, forKey: .title)
|
|
author = try container.decode(String.self, forKey: .author)
|
|
cover = try container.decodeIfPresent(String.self, forKey: .cover) ?? ""
|
|
status = try container.decodeIfPresent(String.self, forKey: .status) ?? ""
|
|
totalChapters = try container.decodeIfPresent(Int.self, forKey: .totalChapters) ?? 0
|
|
sourceURL = try container.decodeIfPresent(String.self, forKey: .sourceURL) ?? ""
|
|
ranking = try container.decodeIfPresent(Int.self, forKey: .ranking) ?? 0
|
|
metaUpdated = try container.decodeIfPresent(String.self, forKey: .metaUpdated) ?? ""
|
|
summary = try container.decodeIfPresent(String.self, forKey: .summary) ?? ""
|
|
|
|
// genres is sometimes a JSON-encoded string, sometimes a real array
|
|
if let arr = try? container.decode([String].self, forKey: .genres) {
|
|
genres = arr
|
|
} else if let str = try? container.decode(String.self, forKey: .genres),
|
|
let data = str.data(using: .utf8),
|
|
let arr = try? JSONDecoder().decode([String].self, from: data) {
|
|
genres = arr
|
|
} else {
|
|
genres = []
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - ChapterIndex
|
|
|
|
struct ChapterIndex: Identifiable, Codable, Hashable {
|
|
let id: String
|
|
let slug: String
|
|
let number: Int
|
|
let title: String
|
|
let dateLabel: String
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case id, slug, number, title
|
|
case dateLabel = "date_label"
|
|
}
|
|
}
|
|
|
|
struct ChapterIndexBrief: Codable, Hashable {
|
|
let number: Int
|
|
let title: String
|
|
}
|
|
|
|
// MARK: - Progress
|
|
|
|
struct ReadingProgress: Codable {
|
|
var id: String?
|
|
let sessionId: String
|
|
var userId: String?
|
|
let slug: String
|
|
var chapter: Int
|
|
var audioTime: Double?
|
|
let updated: String
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case id
|
|
case sessionId = "session_id"
|
|
case userId = "user_id"
|
|
case slug, chapter
|
|
case audioTime = "audio_time"
|
|
case updated
|
|
}
|
|
}
|
|
|
|
// MARK: - User Settings
|
|
|
|
struct UserSettings: Codable {
|
|
var id: String?
|
|
var autoNext: Bool
|
|
var voice: String
|
|
var speed: Double
|
|
|
|
// Server sends/expects camelCase: { autoNext, voice, speed }
|
|
// (No CodingKeys needed — Swift synthesises the same names by default)
|
|
|
|
static let `default` = UserSettings(id: nil, autoNext: false, voice: "af_bella", speed: 1.0)
|
|
}
|
|
|
|
// MARK: - Reading Display Settings (local only — stored in UserDefaults)
|
|
|
|
enum ReaderTheme: String, CaseIterable, Codable {
|
|
case white, sepia, night
|
|
|
|
var backgroundColor: Color {
|
|
switch self {
|
|
case .white: return Color(.sRGB, white: 1.0, opacity: 1)
|
|
case .sepia: return Color(red: 0.97, green: 0.93, blue: 0.82)
|
|
case .night: return Color(red: 0.10, green: 0.10, blue: 0.12)
|
|
}
|
|
}
|
|
|
|
var textColor: Color {
|
|
switch self {
|
|
case .white: return Color(.sRGB, white: 0.1, opacity: 1)
|
|
case .sepia: return Color(red: 0.25, green: 0.18, blue: 0.08)
|
|
case .night: return Color(red: 0.85, green: 0.85, blue: 0.87)
|
|
}
|
|
}
|
|
|
|
var colorScheme: ColorScheme? {
|
|
switch self {
|
|
case .white: return nil // follows system
|
|
case .sepia: return .light
|
|
case .night: return .dark
|
|
}
|
|
}
|
|
}
|
|
|
|
enum ReaderFont: String, CaseIterable, Codable {
|
|
case system = "System"
|
|
case georgia = "Georgia"
|
|
case newYork = "New York"
|
|
|
|
var fontName: String? {
|
|
switch self {
|
|
case .system: return nil
|
|
case .georgia: return "Georgia"
|
|
case .newYork: return "NewYorkMedium-Regular"
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ReaderSettings: Codable, Equatable {
|
|
var fontSize: CGFloat
|
|
var lineSpacing: CGFloat
|
|
var font: ReaderFont
|
|
var theme: ReaderTheme
|
|
var scrollMode: Bool
|
|
|
|
static let `default` = ReaderSettings(
|
|
fontSize: 17,
|
|
lineSpacing: 1.7,
|
|
font: .system,
|
|
theme: .white,
|
|
scrollMode: false
|
|
)
|
|
|
|
static let userDefaultsKey = "readerSettings"
|
|
|
|
static func load() -> ReaderSettings {
|
|
guard let data = UserDefaults.standard.data(forKey: userDefaultsKey),
|
|
let decoded = try? JSONDecoder().decode(ReaderSettings.self, from: data)
|
|
else { return .default }
|
|
return decoded
|
|
}
|
|
|
|
func save() {
|
|
if let data = try? JSONEncoder().encode(self) {
|
|
UserDefaults.standard.set(data, forKey: ReaderSettings.userDefaultsKey)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - User
|
|
|
|
struct AppUser: Codable, Identifiable {
|
|
let id: String
|
|
let username: String
|
|
let role: String
|
|
let created: String
|
|
|
|
var isAdmin: Bool { role == "admin" }
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
id = try c.decode(String.self, forKey: .id)
|
|
username = try c.decode(String.self, forKey: .username)
|
|
role = try c.decodeIfPresent(String.self, forKey: .role) ?? "user"
|
|
created = try c.decodeIfPresent(String.self, forKey: .created) ?? ""
|
|
}
|
|
}
|
|
|
|
// MARK: - Ranking
|
|
|
|
struct RankingItem: Codable, Identifiable {
|
|
var id: String { slug }
|
|
let rank: Int
|
|
let slug: String
|
|
let title: String
|
|
let author: String
|
|
let cover: String
|
|
let status: String
|
|
let genres: [String]
|
|
let sourceURL: String
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case rank, slug, title, author, cover, status, genres
|
|
case sourceURL = "source_url"
|
|
}
|
|
}
|
|
|
|
// MARK: - Home
|
|
|
|
struct HomeData {
|
|
let continueReading: [ContinueReadingItem]
|
|
let recentlyUpdated: [Book]
|
|
let stats: HomeStats
|
|
}
|
|
|
|
struct ContinueReadingItem: Identifiable {
|
|
var id: String { book.id }
|
|
let book: Book
|
|
let chapter: Int
|
|
}
|
|
|
|
struct HomeStats: Codable {
|
|
let totalBooks: Int
|
|
let totalChapters: Int
|
|
let booksInProgress: Int
|
|
}
|
|
|
|
// MARK: - Session
|
|
|
|
struct UserSession: Codable, Identifiable {
|
|
let id: String
|
|
let userAgent: String
|
|
let ip: String
|
|
let createdAt: String
|
|
let lastSeen: String
|
|
var isCurrent: Bool
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case id
|
|
case userAgent = "user_agent"
|
|
case ip
|
|
case createdAt = "created_at"
|
|
case lastSeen = "last_seen"
|
|
case isCurrent = "is_current"
|
|
}
|
|
}
|
|
|
|
struct PreviewChapter: Codable, Identifiable {
|
|
var id: Int { number }
|
|
let number: Int
|
|
let title: String
|
|
let url: String
|
|
}
|
|
|
|
struct BookBrief: Codable {
|
|
let slug: String
|
|
let title: String
|
|
let cover: String
|
|
}
|
|
|
|
// MARK: - Audio
|
|
|
|
enum NextPrefetchStatus {
|
|
case none, prefetching, prefetched, failed
|
|
}
|
|
|
|
// MARK: - PocketBase list response
|
|
|
|
struct PBList<T: Codable>: Codable {
|
|
let items: [T]
|
|
let totalItems: Int
|
|
}
|