From 3a2d113b1bdf912aae789286cd9dd634c5815874 Mon Sep 17 00:00:00 2001 From: Admin Date: Tue, 10 Mar 2026 15:25:01 +0500 Subject: [PATCH] feat(ios): polish Home screen and reader UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../ChapterReader/ChapterReaderView.swift | 162 +++++++++++++++--- .../LibNovel/Views/Home/HomeView.swift | 101 +++++++---- 2 files changed, 206 insertions(+), 57 deletions(-) diff --git a/ios/LibNovel/LibNovel/Views/ChapterReader/ChapterReaderView.swift b/ios/LibNovel/LibNovel/Views/ChapterReader/ChapterReaderView.swift index 186e0f8..cfc8134 100644 --- a/ios/LibNovel/LibNovel/Views/ChapterReader/ChapterReaderView.swift +++ b/ios/LibNovel/LibNovel/Views/ChapterReader/ChapterReaderView.swift @@ -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

or leading text nodes. + // Strategy: strip any leading

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

tag, capture everything up to

+ let pattern = #"^(\s*]*>)(.*?)(

)"# + 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

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) diff --git a/ios/LibNovel/LibNovel/Views/Home/HomeView.swift b/ios/LibNovel/LibNovel/Views/Home/HomeView.swift index 2875c90..8a1a914 100644 --- a/ios/LibNovel/LibNovel/Views/Home/HomeView.swift +++ b/ios/LibNovel/LibNovel/Views/Home/HomeView.swift @@ -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)