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

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:
Admin
2026-03-10 15:25:01 +05:00
parent 0dcfdff65b
commit 3a2d113b1b
2 changed files with 206 additions and 57 deletions

View File

@@ -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)

View File

@@ -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)