feat(ios): polish Home screen and reader UI
All checks were successful
CI / UI / Build (pull_request) Successful in 16s
CI / UI / Docker Push (pull_request) Has been skipped
CI / Scraper / Lint (pull_request) Successful in 20s
CI / Scraper / Test (pull_request) Successful in 19s
CI / Scraper / Docker Push (pull_request) Has been skipped
iOS CI / Build (push) Successful in 2m14s
iOS CI / Build (pull_request) Successful in 2m1s
iOS CI / Test (push) Successful in 5m27s
iOS CI / Test (pull_request) Successful in 8m54s
All checks were successful
CI / UI / Build (pull_request) Successful in 16s
CI / UI / Docker Push (pull_request) Has been skipped
CI / Scraper / Lint (pull_request) Successful in 20s
CI / Scraper / Test (pull_request) Successful in 19s
CI / Scraper / Docker Push (pull_request) Has been skipped
iOS CI / Build (push) Successful in 2m14s
iOS CI / Build (pull_request) Successful in 2m1s
iOS CI / Test (push) Successful in 5m27s
iOS CI / Test (pull_request) Successful in 8m54s
Home: - Hero card redesign: deeper amber-tinted gradient, taller cover (96×138), inline progress bar + completion % text above CTA - Continue Reading shelf: replace chapter badge with circular progress arc ring (amber) containing chapter number - Stats strip: add SF Symbol icons (books, alignleft, bookmark) above each stat value Reader: - Strip duplicate chapter-number/date/title prefix that novelfire embeds at top of HTML body - Bottom chrome: pill-shaped prev/next chapter buttons (amber fill for next, muted for prev), placeholder spacers keep Listen button centered - Chapter title page: show 'N% through' alongside date label, animated swipe-hint arrow that pulses once on appear - Progress bar: 2px → 1px for a more refined look
This commit is contained in:
@@ -199,21 +199,32 @@ struct ChapterReaderView: View {
|
||||
|
||||
ZStack {
|
||||
readerSettings.settings.theme.backgroundColor
|
||||
.opacity(0.92)
|
||||
.opacity(0.95)
|
||||
|
||||
HStack(spacing: 16) {
|
||||
// Prev chapter
|
||||
HStack(spacing: 0) {
|
||||
// Prev chapter pill
|
||||
if let prev = content.prev {
|
||||
Button {
|
||||
navigateToChapter(prev)
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
Text("Ch.\(prev)")
|
||||
.font(.caption.weight(.semibold))
|
||||
}
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(readerSettings.settings.theme.textColor.opacity(0.7))
|
||||
.foregroundStyle(readerSettings.settings.theme.textColor.opacity(0.55))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 7)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(readerSettings.settings.theme.textColor.opacity(0.07))
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
// Placeholder to keep center stable
|
||||
Color.clear.frame(width: 60, height: 32)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
@@ -228,24 +239,34 @@ struct ChapterReaderView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
// Next chapter
|
||||
// Next chapter pill
|
||||
if let next = content.next {
|
||||
Button {
|
||||
navigateToChapter(next)
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
HStack(spacing: 5) {
|
||||
Text("Ch.\(next)")
|
||||
.font(.caption.weight(.semibold))
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
}
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(.amber)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 7)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(Color.amber.opacity(0.12))
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
Color.clear.frame(width: 60, height: 32)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 12)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
.frame(height: 56)
|
||||
.frame(height: 60)
|
||||
|
||||
// Mini player spacer if active
|
||||
if audioPlayer.isActive {
|
||||
@@ -579,6 +600,18 @@ private struct ChapterTitlePage: View {
|
||||
let content: ChapterResponse
|
||||
let readerSettings: ReaderSettingsStore
|
||||
|
||||
@State private var arrowOpacity: Double = 0.25
|
||||
@State private var arrowOffset: CGFloat = 0
|
||||
|
||||
private var totalChapters: Int {
|
||||
content.chapters.last?.number ?? content.chapter.number
|
||||
}
|
||||
|
||||
private var progressPercent: Int {
|
||||
guard totalChapters > 1 else { return 100 }
|
||||
return Int((Double(content.chapter.number) / Double(totalChapters)) * 100)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let settings = readerSettings.settings
|
||||
VStack(spacing: 0) {
|
||||
@@ -601,10 +634,26 @@ private struct ChapterTitlePage: View {
|
||||
.foregroundStyle(settings.theme.textColor)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
if !content.chapter.dateLabel.isEmpty {
|
||||
Text(content.chapter.dateLabel)
|
||||
.font(.caption)
|
||||
.foregroundStyle(settings.theme.textColor.opacity(0.4))
|
||||
HStack(spacing: 8) {
|
||||
if !content.chapter.dateLabel.isEmpty {
|
||||
Text(content.chapter.dateLabel)
|
||||
.font(.caption)
|
||||
.foregroundStyle(settings.theme.textColor.opacity(0.4))
|
||||
}
|
||||
if totalChapters > 1 {
|
||||
if !content.chapter.dateLabel.isEmpty {
|
||||
Text("·")
|
||||
.font(.caption)
|
||||
.foregroundStyle(settings.theme.textColor.opacity(0.25))
|
||||
}
|
||||
Text("\(progressPercent)% through")
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(
|
||||
settings.theme == .sepia
|
||||
? Color(red: 0.65, green: 0.45, blue: 0.15).opacity(0.75)
|
||||
: Color.amber.opacity(0.75)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
@@ -612,15 +661,26 @@ private struct ChapterTitlePage: View {
|
||||
Spacer()
|
||||
Spacer()
|
||||
|
||||
// Swipe hint
|
||||
HStack(spacing: 6) {
|
||||
// Animated swipe hint
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: "arrow.right")
|
||||
.font(.caption2)
|
||||
.offset(x: arrowOffset)
|
||||
Text("Swipe to read")
|
||||
.font(.caption2)
|
||||
}
|
||||
.foregroundStyle(settings.theme.textColor.opacity(0.25))
|
||||
.foregroundStyle(settings.theme.textColor.opacity(arrowOpacity))
|
||||
.padding(.bottom, 100)
|
||||
.onAppear {
|
||||
withAnimation(.easeOut(duration: 0.5).delay(0.5)) {
|
||||
arrowOpacity = 0.6
|
||||
arrowOffset = 5
|
||||
}
|
||||
withAnimation(.easeIn(duration: 0.7).delay(1.4)) {
|
||||
arrowOpacity = 0.18
|
||||
arrowOffset = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(settings.theme.backgroundColor)
|
||||
@@ -703,7 +763,7 @@ private struct ChapterProgressBar: View {
|
||||
.animation(.easeInOut(duration: 0.4), value: progress)
|
||||
}
|
||||
}
|
||||
.frame(height: 2)
|
||||
.frame(height: 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -952,8 +1012,59 @@ final class ReaderSettingsStore: ObservableObject {
|
||||
// MARK: - HTML → AttributedString parser
|
||||
|
||||
enum HTMLParser {
|
||||
/// Converts HTML string to AttributedString with the given display settings.
|
||||
/// Falls back to plain text if parsing fails.
|
||||
/// Strips the duplicated chapter-header block that novelfire embeds at the
|
||||
/// top of the HTML body. The pattern looks like:
|
||||
/// "538 Chapter 538: Title.1 day ago\nChapter 538: Title\n"
|
||||
/// We detect it by looking for a leading paragraph whose text starts with
|
||||
/// a digit followed by " Chapter \d+:" and strip up through the end of
|
||||
/// the next paragraph if it also starts with "Chapter \d+:".
|
||||
static func stripLeadingChapterHeader(from html: String) -> String {
|
||||
// Work on the plain-text representation of the first ~400 chars to avoid
|
||||
// running full HTML parse twice; we strip matching <p> or leading text nodes.
|
||||
// Strategy: strip any leading <p> tags whose inner text matches the pattern.
|
||||
var result = html
|
||||
|
||||
// Repeat up to 3 times in case there are multiple such paragraphs.
|
||||
for _ in 0..<3 {
|
||||
// Match an opening <p …> tag, capture everything up to </p>
|
||||
let pattern = #"^(\s*<p[^>]*>)(.*?)(</p>)"#
|
||||
guard let regex = try? NSRegularExpression(
|
||||
pattern: pattern,
|
||||
options: [.dotMatchesLineSeparators, .caseInsensitive]
|
||||
) else { break }
|
||||
|
||||
let nsResult = result as NSString
|
||||
guard let match = regex.firstMatch(
|
||||
in: result,
|
||||
range: NSRange(result.startIndex..., in: result)
|
||||
) else { break }
|
||||
|
||||
let innerRange = match.range(at: 2)
|
||||
guard innerRange.location != NSNotFound,
|
||||
let swiftRange = Range(innerRange, in: result) else { break }
|
||||
|
||||
let inner = String(result[swiftRange])
|
||||
// Strip HTML tags from inner to get plain text for pattern check
|
||||
let plain = inner.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
// Match: "538 Chapter 538: ..." or "Chapter 538: ..."
|
||||
let isHeaderLine = plain.range(
|
||||
of: #"^\d*\s*[Cc]hapter\s+\d+"#,
|
||||
options: .regularExpression
|
||||
) != nil
|
||||
|
||||
guard isHeaderLine else { break }
|
||||
|
||||
// Remove the entire matched <p>…</p> block
|
||||
let fullMatchRange = match.range(at: 0)
|
||||
guard let swiftFullRange = Range(fullMatchRange, in: result) else { break }
|
||||
result.removeSubrange(swiftFullRange)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
static func toAttributedString(
|
||||
html: String,
|
||||
fontSize: CGFloat,
|
||||
@@ -974,7 +1085,8 @@ enum HTMLParser {
|
||||
paragraphStyle.lineSpacing = (lineSpacing - 1.0) * fontSize
|
||||
paragraphStyle.paragraphSpacing = fontSize * 0.7
|
||||
|
||||
let htmlData = Data(html.utf8)
|
||||
let cleanedHtml = stripLeadingChapterHeader(from: html)
|
||||
let htmlData = Data(cleanedHtml.utf8)
|
||||
let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [
|
||||
.documentType: NSAttributedString.DocumentType.html,
|
||||
.characterEncoding: String.Encoding.utf8.rawValue
|
||||
@@ -984,12 +1096,10 @@ enum HTMLParser {
|
||||
if let parsed = try? NSMutableAttributedString(data: htmlData, options: options, documentAttributes: nil) {
|
||||
nsAttr = parsed
|
||||
} else {
|
||||
// Fallback: strip tags manually
|
||||
let plain = html.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression)
|
||||
let plain = cleanedHtml.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression)
|
||||
nsAttr = NSMutableAttributedString(string: plain)
|
||||
}
|
||||
|
||||
// Apply our display settings over the full range
|
||||
let fullRange = NSRange(location: 0, length: nsAttr.length)
|
||||
nsAttr.addAttribute(.font, value: uiFont, range: fullRange)
|
||||
nsAttr.addAttribute(.foregroundColor, value: uiColor, range: fullRange)
|
||||
|
||||
@@ -98,40 +98,61 @@ private struct HeroContinueCard: View {
|
||||
|
||||
var body: some View {
|
||||
NavigationLink(value: NavDestination.chapter(item.book.slug, item.chapter)) {
|
||||
ZStack(alignment: .bottom) {
|
||||
ZStack(alignment: .bottomLeading) {
|
||||
// Blurred background
|
||||
KFImage(URL(string: item.book.cover))
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 220)
|
||||
.blur(radius: 18)
|
||||
.blur(radius: 22)
|
||||
.clipped()
|
||||
// Depth gradient: subtle amber tint at top, deep shadow at bottom
|
||||
.overlay(
|
||||
LinearGradient(
|
||||
colors: [.black.opacity(0.2), .black.opacity(0.72)],
|
||||
stops: [
|
||||
.init(color: Color(red: 0.18, green: 0.12, blue: 0.02).opacity(0.55), location: 0),
|
||||
.init(color: .black.opacity(0.15), location: 0.35),
|
||||
.init(color: .black.opacity(0.78), location: 1)
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
|
||||
// Content row
|
||||
// Content: cover on left, info stacked on right
|
||||
HStack(alignment: .bottom, spacing: 14) {
|
||||
// Cover
|
||||
KFImage(URL(string: item.book.cover))
|
||||
.resizable()
|
||||
.placeholder {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(Color(.systemGray5))
|
||||
}
|
||||
.scaledToFill()
|
||||
.frame(width: 90, height: 128)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.shadow(color: .black.opacity(0.5), radius: 10, y: 4)
|
||||
.frame(width: 96, height: 138)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.shadow(color: .black.opacity(0.55), radius: 12, y: 6)
|
||||
.bookCoverZoomSource(slug: item.book.slug)
|
||||
|
||||
// Text + CTA
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
// Progress indicator
|
||||
if item.book.totalChapters > 0 {
|
||||
let pct = min(1.0, Double(item.chapter) / Double(item.book.totalChapters))
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule().fill(Color.white.opacity(0.2))
|
||||
Capsule().fill(Color.amber.opacity(0.85))
|
||||
.frame(width: geo.size.width * pct)
|
||||
}
|
||||
}
|
||||
.frame(height: 3)
|
||||
.frame(maxWidth: 140)
|
||||
|
||||
Text("\(Int(pct * 100))% complete")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.white.opacity(0.55))
|
||||
}
|
||||
|
||||
Text(item.book.title)
|
||||
.font(.headline)
|
||||
.foregroundStyle(.white)
|
||||
@@ -139,12 +160,11 @@ private struct HeroContinueCard: View {
|
||||
|
||||
Text(item.book.author)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
.foregroundStyle(.white.opacity(0.65))
|
||||
.lineLimit(1)
|
||||
|
||||
Spacer(minLength: 6)
|
||||
Spacer(minLength: 8)
|
||||
|
||||
// Continue pill
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "play.fill")
|
||||
.font(.caption.bold())
|
||||
@@ -153,7 +173,7 @@ private struct HeroContinueCard: View {
|
||||
}
|
||||
.foregroundStyle(.black.opacity(0.85))
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.padding(.vertical, 9)
|
||||
.background(Capsule().fill(Color.amber))
|
||||
}
|
||||
|
||||
@@ -162,8 +182,8 @@ private struct HeroContinueCard: View {
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 18)
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
.shadow(color: .black.opacity(0.2), radius: 12, y: 4)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.shadow(color: .black.opacity(0.25), radius: 14, y: 5)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
@@ -187,6 +207,11 @@ private struct ShelfHeader: View {
|
||||
private struct ContinueReadingCard: View {
|
||||
let item: ContinueReadingItem
|
||||
|
||||
private var progressFraction: Double {
|
||||
guard item.book.totalChapters > 0 else { return 0 }
|
||||
return min(1.0, Double(item.chapter) / Double(item.book.totalChapters))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
@@ -201,12 +226,22 @@ private struct ContinueReadingCard: View {
|
||||
.frame(width: 110, height: 158)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
|
||||
Text("Ch.\(item.chapter)")
|
||||
.font(.caption2.bold())
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
.background(.ultraThinMaterial, in: Capsule())
|
||||
.padding(6)
|
||||
// Progress arc ring + chapter badge
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(Color.white.opacity(0.18), lineWidth: 2.5)
|
||||
Circle()
|
||||
.trim(from: 0, to: progressFraction)
|
||||
.stroke(Color.amber, style: StrokeStyle(lineWidth: 2.5, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
Text("Ch.\(item.chapter)")
|
||||
.font(.system(size: 8, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.minimumScaleFactor(0.6)
|
||||
}
|
||||
.frame(width: 36, height: 36)
|
||||
.background(.ultraThinMaterial, in: Circle())
|
||||
.padding(5)
|
||||
}
|
||||
Text(item.book.title)
|
||||
.font(.caption.bold())
|
||||
@@ -257,24 +292,28 @@ private struct StatsStrip: View {
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
StatPill(value: "\(stats.totalBooks)", label: "Books")
|
||||
Divider().frame(height: 24)
|
||||
StatPill(value: "\(stats.totalChapters)", label: "Chapters")
|
||||
Divider().frame(height: 24)
|
||||
StatPill(value: "\(stats.booksInProgress)", label: "In Progress")
|
||||
StatPill(icon: "books.vertical.fill", value: "\(stats.totalBooks)", label: "Books")
|
||||
Divider().frame(height: 28)
|
||||
StatPill(icon: "text.alignleft", value: "\(stats.totalChapters)", label: "Chapters")
|
||||
Divider().frame(height: 28)
|
||||
StatPill(icon: "bookmark.fill", value: "\(stats.booksInProgress)", label: "In Progress")
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12))
|
||||
.padding(.vertical, 14)
|
||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
}
|
||||
|
||||
private struct StatPill: View {
|
||||
let icon: String
|
||||
let value: String
|
||||
let label: String
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 2) {
|
||||
VStack(spacing: 4) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(Color.amber.opacity(0.8))
|
||||
Text(value)
|
||||
.font(.subheadline.bold().monospacedDigit())
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Reference in New Issue
Block a user