mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
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:
1 parent
c026f81283
commit
cd9e8eea81
11 files changed
+452
-59
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user