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