feat(app,android,ios): status, season dropdown, watched ticks

A title page now says where the show stands as a chip beside the
quality marks: Airing with the day the next episode comes out, Finished,
Upcoming, On hiatus or Cancelled. The words come from
TitleDetails::status_label, which the bridge hands the phones as
TitleRecord.status; the airing note leaves the line of facts.

A show with more than five seasons picks its season from a drop-down
rather than a row of pills that ran off the page, and a watched
episode's still carries a tick in its corner as well as the mark at the
end of its row.
This commit is contained in:
nvrl committed 2026-10-30 21:34:47 +01:00
1 parent c026f81283
commit cd9e8eea81
11 files changed
+452 -59

No files matched your search

+19
View File
@@ -401,6 +401,25 @@ private extension UIColor {
}
}
/// Where a title stands — "Airing · episode 8 Sat 1 Nov", "Finished" — as a
/// `GenreChip` with a dot in front: the accent while more is coming, muted
/// once it is all out. Android's `StatusChip`.
struct StatusChip: View {
@Environment(\.scheme) private var scheme
let text: String
let live: Bool
var body: some View {
HStack(spacing: 6) {
Circle().fill(live ? scheme.primary : scheme.onSurfaceVariant).frame(width: 6, height: 6)
Text(text).textStyle(.labelMedium).foregroundStyle(scheme.onSurface)
}
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(scheme.surfaceVariant, in: RoundedRectangle(cornerRadius: 12))
}
}
/// Something the title is filed under — a genre — as a small rounded label.
/// Android's `GenreChip`.
struct GenreChip: View {
+61 -18
View File
@@ -223,19 +223,21 @@ struct TitleView: View {
}
Text(factLine)
.textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant).padding(.top, 6)
// Who made it, and whether more is coming.
// Who made it.
let makers: [String?] = [
title.studios.isEmpty ? nil : title.studios.prefix(2).joined(separator: ", "),
title.directors.first.map { "dir. \($0)" },
airingNote(title),
]
let facts = makers.compactMap { $0 }.joined(separator: " · ")
if !facts.isEmpty {
Text(facts).textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant).padding(.top, 2)
}
// What the files are, rather than what the title is.
if !title.quality.isEmpty {
// Where the title stands, then what the files are rather than
// what the title is.
let status = statusText(title)
if !title.quality.isEmpty || status != nil {
HStack(spacing: 4) {
if let status { StatusChip(text: status.text, live: status.live).padding(.trailing, 4) }
ForEach(title.quality, id: \.self) { SpecBadge(text: $0) }
}
.padding(.top, 8)
@@ -330,11 +332,34 @@ struct TitleView: View {
private func seasonChips(season: SeasonRecord?, side: CGFloat) -> some View {
HStack(spacing: 0) {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
// By position: two seasons can share a label.
ForEach(Array(title.seasons.enumerated()), id: \.offset) { index, entry in
FilterChip(label: entry.label, selected: index == seasonIndex) { seasonIndex = index }
if title.seasons.count > seasonChipsMax {
// A long-running show's row of chips is a scroll of its own;
// past a handful it is a list to pick from.
Menu {
Picker("Season", selection: $seasonIndex) {
ForEach(Array(title.seasons.enumerated()), id: \.offset) { index, entry in
Text(entry.label).tag(index)
}
}
} label: {
HStack(spacing: 4) {
Text(title.seasons.indices.contains(seasonIndex) ? title.seasons[seasonIndex].label : "")
Image(systemName: "chevron.down").font(.system(size: 12, weight: .semibold))
}
.textStyle(.labelLarge)
.foregroundStyle(scheme.onSecondaryContainer)
.padding(.horizontal, 12)
.frame(height: 32)
.background(scheme.secondaryContainer, in: RoundedRectangle(cornerRadius: Corner.standard))
}
Spacer(minLength: 0)
} else {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
// By position: two seasons can share a label.
ForEach(Array(title.seasons.enumerated()), id: \.offset) { index, entry in
FilterChip(label: entry.label, selected: index == seasonIndex) { seasonIndex = index }
}
}
}
}
@@ -407,15 +432,20 @@ private struct FranchiseTile: View {
}
}
/// "episode 7 on Sat 11 Oct" while one is due, "airing" while it is airing
/// with no date yet, else nothing.
private func airingNote(_ title: TitleRecord) -> String? {
if let at = title.nextAiringAt, let episode = title.nextEpisode {
let format = DateFormatter()
format.setLocalizedDateFormatFromTemplate("EEE d MMM")
return "episode \(episode) on " + format.string(from: Date(timeIntervalSince1970: TimeInterval(at)))
}
return title.airing ? "airing" : nil
/// The most seasons picked from a row of chips; past it, a list. Android's
/// `SEASON_CHIPS`.
private let seasonChipsMax = 5
/// What the status chip says — "Airing · episode 7 Sat 11 Oct", "Finished" —
/// and whether more is coming. Android's `statusText`.
func statusText(_ title: TitleRecord) -> (text: String, live: Bool)? {
let next = title.nextAiringAt.flatMap { at in title.nextEpisode.map { (episode: $0, at: at) } }
guard let label = title.status ?? (next != nil || title.airing ? "Airing" : nil) else { return nil }
let live = next != nil || title.airing
guard let next else { return (label, live) }
let format = DateFormatter()
format.setLocalizedDateFormatFromTemplate("EEE d MMM")
return ("\(label) · episode \(next.episode) " + format.string(from: Date(timeIntervalSince1970: TimeInterval(next.at))), live)
}
/// One of the title's secondary actions: an icon with its name under it.
@@ -508,6 +538,19 @@ private struct EpisodeRow: View {
.overlay(alignment: .bottom) {
if let progress = episode.progress, !episode.watched { AccentProgress(progress: progress) }
}
// Seen, said on the picture as well as by the mark at the
// row's end: a season is scanned by its stills.
.overlay(alignment: .topTrailing) {
if episode.watched {
Image(systemName: "checkmark")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(scheme.onPrimary)
.frame(width: 18, height: 18)
.background(scheme.primary, in: Circle())
.padding(5)
.accessibilityHidden(true)
}
}
.clipShape(RoundedRectangle(cornerRadius: Corner.standard))
VStack(alignment: .leading, spacing: 2) {
// Seen episodes stay legible but stop competing with the
+32
View File
@@ -299,3 +299,35 @@ final class ThemeTests: XCTestCase {
XCTAssertEqual(formatRuntime(124), "2 h 4 min")
}
}
final class StatusTextTests: XCTestCase {
private func title(status: String?, airing: Bool = false, next: (UInt32, Int64)? = nil) -> TitleRecord {
TitleRecord(
key: "k", name: "k", year: nil, kind: .series, watchedCount: 0,
episodeCount: 0, canonicalName: nil, originalName: nil, overview: nil,
metadataProvider: nil, metadataId: nil, metadataYear: nil, metadataKind: nil, posterUrl: nil,
backdropUrl: nil, rating: nil, genres: [], providerEpisodeCount: nil, externalUrl: nil,
manualMatch: false, seasons: [],
displayName: "k", wideUrl: nil, formatLabel: nil, seasonLabel: nil, studios: [],
directors: [], tags: [], airing: airing, nextEpisode: next?.0, nextAiringAt: next?.1,
franchise: [], continueIndex: nil, status: status
)
}
func testTheStatusChipNamesTheNextEpisodeWhileOneIsDue() throws {
let status = try XCTUnwrap(statusText(title(status: "Airing", airing: true, next: (8, 1_800_000_000))))
XCTAssertTrue(status.text.hasPrefix("Airing · episode 8 "), status.text)
XCTAssertTrue(status.live)
}
func testAFinishedTitleIsSaidPlainlyAndIsNotLive() throws {
let status = try XCTUnwrap(statusText(title(status: "Finished")))
XCTAssertEqual(status.text, "Finished")
XCTAssertFalse(status.live)
}
func testATitleWithNoStatusAndNothingAiringHasNoChip() {
XCTAssertNil(statusText(title(status: nil)))
XCTAssertEqual(statusText(title(status: nil, airing: true))?.text, "Airing")
}
}