diff --git a/apps/ios/Rawkoon/Localizable.xcstrings b/apps/ios/Rawkoon/Localizable.xcstrings index 77db7c05..f607805e 100644 --- a/apps/ios/Rawkoon/Localizable.xcstrings +++ b/apps/ios/Rawkoon/Localizable.xcstrings @@ -15282,6 +15282,26 @@ } } } + }, + "Tomorrow": { + "localizations": { + "fr": { + "stringUnit": { + "state": "translated", + "value": "Demain" + } + } + } + }, + "Date to be announced": { + "localizations": { + "fr": { + "stringUnit": { + "state": "translated", + "value": "Date à confirmer" + } + } + } } }, "version": "1.0" diff --git a/apps/ios/Rawkoon/Models+Search.swift b/apps/ios/Rawkoon/Models+Search.swift index 7fe0d8bd..d14aab90 100644 --- a/apps/ios/Rawkoon/Models+Search.swift +++ b/apps/ios/Rawkoon/Models+Search.swift @@ -269,6 +269,12 @@ nonisolated struct UpcomingResponse: Decodable, Sendable { let items: [UpcomingItem] } +nonisolated struct UpcomingProvider: Decodable, Identifiable, Sendable { + let id: Int + let name: String + let logoUrl: String +} + nonisolated struct UpcomingItem: Decodable, Identifiable, Sendable { let id: String // "${media_type}-${tmdb_id}…" let title: String @@ -280,7 +286,12 @@ nonisolated struct UpcomingItem: Decodable, Identifiable, Sendable { let episodeNumber: Int? /// Last episode when several air the same day; nil otherwise. let episodeNumberEnd: Int? + /// Episodes this entry stands for; more than one when several air the same day. + let episodeCount: Int? let libraryId: Int? + /// TMDB score out of 10; library-only entries have none. + let voteAverage: Double? + let providers: [UpcomingProvider]? /// TMDB id parsed from the composite `id` (first run of digits). var tmdbId: Int? { diff --git a/apps/ios/Rawkoon/Views/Activity/ActivityView+Calendar.swift b/apps/ios/Rawkoon/Views/Activity/ActivityView+Calendar.swift new file mode 100644 index 00000000..b91b8238 --- /dev/null +++ b/apps/ios/Rawkoon/Views/Activity/ActivityView+Calendar.swift @@ -0,0 +1,199 @@ +import RawkoonKit +import SwiftUI + +/// The upcoming calendar: day sections and release rows. +extension ActivityView { + // MARK: Calendar + + var calendarContent: some View { + // A container, not a bare conditional, so the lane slide and the state swaps never share one transition. + ZStack(alignment: .top) { + if loadingCalendar, upcomingItems.isEmpty { + ProgressView().tint(Theme.apricot) + .frame(maxWidth: .infinity, minHeight: 420) + .transition(.rawkoonSwap) + } else if let calendarError, upcomingItems.isEmpty { + errorView(calendarError) + .transition(.rawkoonSwap) + } else if upcomingItems.isEmpty { + ContentUnavailableView( + "Nothing upcoming", + systemImage: "calendar", + description: Text("No known releases on the horizon.") + ) + .rawkoonLivingSymbol(.empty) + .frame(maxWidth: .infinity, minHeight: 420) + .transition(.rawkoonSwap) + } else { + LazyVStack(spacing: 8, pinnedViews: .sectionHeaders) { + ForEach(UpcomingCalendar.sections(upcomingItems, isoDay: \.releaseDate)) { section in + Section { + ForEach(section.items) { item in + calendarRow(item) + .rawkoonEntrance(id: item.id) + } + } header: { + calendarDayHeader(section.isoDay) + } + } + } + .padding(.horizontal, 16) + .padding(.bottom, 16) + .rawkoonEntranceScope() + .transition(.rawkoonSwap) + } + } + .rawkoonMotion( + calendarInitialLoadResolved ? RawkoonMotion.spring : nil, + value: Self.laneState( + loading: loadingCalendar, failed: calendarError != nil, isEmpty: upcomingItems.isEmpty + ) + ) + .onChange(of: Self.laneState( + loading: loadingCalendar, failed: calendarError != nil, isEmpty: upcomingItems.isEmpty + )) { _, current in + if current != .loading { + calendarInitialLoadResolved = true + } + } + } + + private func calendarRow(_ item: UpcomingItem) -> some View { + // Per row, since a show lists one row per upcoming episode day. + let zoomID: RawkoonZoom.ID = "upcoming:\(item.id)" + return NavigationLink { + MediaDetailView(tmdbId: item.tmdbId ?? 0, mediaType: item.mediaType, + title: item.title, posterPath: item.posterUrl, libraryId: item.libraryId) + .rawkoonZoomDestination(zoomID) + } label: { + calendarRowLabel(item) + .rawkoonZoomSource(zoomID) + } + .buttonStyle(.rawkoonPressable) + .disabled(item.tmdbId == nil && item.libraryId == nil) + } + + private func calendarDayHeader(_ isoDay: String?) -> some View { + let day = UpcomingCalendar.day(isoDay) + return HStack(alignment: .firstTextBaseline, spacing: 8) { + switch day?.relative { + case .today: Text("Today") + case .tomorrow: Text("Tomorrow") + case .other: Text(verbatim: day.map { UpcomingCalendar.weekday($0.date) } ?? "") + case nil: Text("Date to be announced") + } + if let day { + Text(verbatim: UpcomingCalendar.monthDay(day.date)) + .font(.subheadline) + .foregroundStyle(Theme.faint) + } + Spacer(minLength: 0) + } + .font(.headline) + .foregroundStyle(Theme.textStrong) + .padding(.top, 16) + .padding(.bottom, 4) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Theme.base) + .accessibilityElement(children: .combine) + .accessibilityAddTraits(.isHeader) + } + + private func calendarRowLabel(_ item: UpcomingItem) -> some View { + HStack(alignment: .top, spacing: 12) { + MediaThumb(url: model.absoluteURL(item.posterUrl), width: 56) + + VStack(alignment: .leading, spacing: 6) { + Text(item.title) + .font(.display(15)) + .foregroundStyle(Theme.textStrong) + .lineLimit(2) + .multilineTextAlignment(.leading) + calendarFacts(item) + if let overview = item.overview, !overview.isEmpty { + Text(overview) + .font(.caption) + .foregroundStyle(Theme.muted) + .lineLimit(2) + .multilineTextAlignment(.leading) + } + if let providers = item.providers, !providers.isEmpty { + calendarProviders(providers) + } + } + Spacer(minLength: 0) + } + .padding(12) + .activityCard(cornerRadius: 12) + } + + /// One quiet line: kind, episodes, score, and whether the title is already in the library. + private func calendarFacts(_ item: UpcomingItem) -> some View { + HStack(spacing: 6) { + if item.mediaType == "tv" { + Text("Show") + } else { + Text("Movie") + } + if let episode = item.episodeLabel { + dot + Text(verbatim: episode).monospacedDigit() + } else if let count = item.episodeCount, count > 1 { + dot + Text("\(count) episodes") + } + if let vote = item.voteAverage, vote > 0 { + dot + Text((vote / 10).formatted(.percent.precision(.fractionLength(0)))).monospacedDigit() + } + if item.libraryId != nil { + dot + Text("In library").foregroundStyle(Theme.seed) + } + } + .font(.caption) + .foregroundStyle(Theme.faint) + .lineLimit(1) + } + + private var dot: some View { + Text(verbatim: "·").accessibilityHidden(true) + } + + private func calendarProviders(_ providers: [UpcomingProvider]) -> some View { + HStack(spacing: 4) { + ForEach(providers.prefix(5)) { provider in + CachedAsyncImage( + url: URL(string: provider.logoUrl), targetSize: CGSize(width: 20, height: 20) + ) { image in + image.resizable().scaledToFit() + } placeholder: { + Theme.well + } + .frame(width: 20, height: 20) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .accessibilityLabel(Text(verbatim: provider.name)) + } + } + } + + func loadCalendar() async { + loadingCalendar = true + calendarError = nil + defer { loadingCalendar = false } + + guard let client = model.api() else { + calendarError = String(localized: "Not signed in.") + return + } + + do { + let response = try await client.upcoming() + upcomingItems = response.items + } catch let error as APIError { + calendarError = message(for: error) + } catch { + calendarError = String(localized: "Can't reach the server. Try again in a moment.") + } + } +} diff --git a/apps/ios/Rawkoon/Views/ActivityView.swift b/apps/ios/Rawkoon/Views/ActivityView.swift index 13727c7b..1d37727b 100644 --- a/apps/ios/Rawkoon/Views/ActivityView.swift +++ b/apps/ios/Rawkoon/Views/ActivityView.swift @@ -13,7 +13,15 @@ struct ActivityView: View { private let isQueueScreen: Bool init(initialLane: ActivityLane = .inbox) { - _lane = State(initialValue: initialLane) + var lane = initialLane + #if DEBUG + // Screenshot harness: open a lane without tap injection, like `RAWKOON_TAB`. + let raw = ProcessInfo.processInfo.environment["RAWKOON_ACTIVITY_LANE"] + if initialLane != .queue, let requested = raw.flatMap(ActivityLane.init(rawValue:)) { + lane = requested + } + #endif + _lane = State(initialValue: lane) isQueueScreen = initialLane == .queue } @@ -61,10 +69,10 @@ struct ActivityView: View { @State var typeFilter: String? // Calendar - @State private var upcomingItems: [UpcomingItem] = [] - @State private var loadingCalendar = true - @State private var calendarInitialLoadResolved = false - @State private var calendarError: String? + @State var upcomingItems: [UpcomingItem] = [] + @State var loadingCalendar = true + @State var calendarInitialLoadResolved = false + @State var calendarError: String? @State private var hydrated = false @@ -201,119 +209,6 @@ struct ActivityView: View { .padding(.vertical, 8) } - // MARK: Calendar - - private var calendarContent: some View { - // A container, not a bare conditional, so the lane slide and the state swaps never share one transition. - ZStack(alignment: .top) { - if loadingCalendar, upcomingItems.isEmpty { - ProgressView().tint(Theme.apricot) - .frame(maxWidth: .infinity, minHeight: 420) - .transition(.rawkoonSwap) - } else if let calendarError, upcomingItems.isEmpty { - errorView(calendarError) - .transition(.rawkoonSwap) - } else if upcomingItems.isEmpty { - ContentUnavailableView( - "Nothing upcoming", - systemImage: "calendar", - description: Text("No known releases on the horizon.") - ) - .rawkoonLivingSymbol(.empty) - .frame(maxWidth: .infinity, minHeight: 420) - .transition(.rawkoonSwap) - } else { - LazyVStack(spacing: 8) { - ForEach(upcomingItems) { item in - calendarRow(item) - .rawkoonEntrance(id: item.id) - } - } - .padding(16) - .rawkoonEntranceScope() - .transition(.rawkoonSwap) - } - } - .rawkoonMotion( - calendarInitialLoadResolved ? RawkoonMotion.spring : nil, - value: Self.laneState( - loading: loadingCalendar, failed: calendarError != nil, isEmpty: upcomingItems.isEmpty - ) - ) - .onChange(of: Self.laneState( - loading: loadingCalendar, failed: calendarError != nil, isEmpty: upcomingItems.isEmpty - )) { _, current in - if current != .loading { - calendarInitialLoadResolved = true - } - } - } - - private func calendarRow(_ item: UpcomingItem) -> some View { - // Per row, since a show lists one row per upcoming episode day. - let zoomID: RawkoonZoom.ID = "upcoming:\(item.id)" - return NavigationLink { - MediaDetailView(tmdbId: item.tmdbId ?? 0, mediaType: item.mediaType, - title: item.title, posterPath: item.posterUrl, libraryId: item.libraryId) - .rawkoonZoomDestination(zoomID) - } label: { - calendarRowLabel(item) - .rawkoonZoomSource(zoomID) - } - .buttonStyle(.rawkoonPressable) - .disabled(item.tmdbId == nil && item.libraryId == nil) - } - - private func calendarRowLabel(_ item: UpcomingItem) -> some View { - HStack(spacing: 12) { - MediaThumb(url: model.absoluteURL(item.posterUrl), width: 48) - - VStack(alignment: .leading, spacing: 4) { - Text(item.title) - .font(.display(15)) - .foregroundStyle(Theme.textStrong) - .lineLimit(2) - HStack(spacing: 8) { - if let releaseDate = item.releaseDate, !releaseDate.isEmpty { - Text(releaseDate) - } - if let season = item.seasonNumber { - if let episode = item.episodeNumber { - Text(String(format: "S%02dE%02d", season, episode)) - } else { - Text(String(format: "S%02d", season)) - } - } - } - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(Theme.faint) - } - Spacer(minLength: 0) - } - .padding(12) - .activityCard(cornerRadius: 12) - } - - private func loadCalendar() async { - loadingCalendar = true - calendarError = nil - defer { loadingCalendar = false } - - guard let client = model.api() else { - calendarError = String(localized: "Not signed in.") - return - } - - do { - let response = try await client.upcoming() - upcomingItems = response.items - } catch let error as APIError { - calendarError = message(for: error) - } catch { - calendarError = String(localized: "Can't reach the server. Try again in a moment.") - } - } - // MARK: Shared /// Paints every lane from the last saved responses, so Activity opens full — diff --git a/apps/ios/Sources/RawkoonKit/UpcomingCalendar.swift b/apps/ios/Sources/RawkoonKit/UpcomingCalendar.swift new file mode 100644 index 00000000..af221135 --- /dev/null +++ b/apps/ios/Sources/RawkoonKit/UpcomingCalendar.swift @@ -0,0 +1,78 @@ +import Foundation + +/// Day sections for the upcoming-releases calendar. +public enum UpcomingCalendar { + public enum Relative: Equatable, Sendable { + case today, tomorrow, other + } + + public struct Day: Equatable, Sendable { + public let date: Date + public let relative: Relative + } + + public struct Section: Identifiable { + /// The `yyyy-MM-dd` day, or nil for the undated tail. + public let isoDay: String? + public let items: [Item] + + public var id: String { + isoDay ?? "undated" + } + } + + /// Items bucketed by day in ascending order, server order kept within a day; undated ones last. + /// `release_date` is a calendar day, not an instant, so its first ten characters are the key. + public static func sections(_ items: [Item], isoDay: (Item) -> String?) -> [Section] { + var order: [String?] = [] + var buckets: [String?: [Item]] = [:] + for item in items { + let key = isoDay(item).map { String($0.prefix(10)) }.flatMap { $0.isEmpty ? nil : $0 } + if buckets[key] == nil { + order.append(key) + } + buckets[key, default: []].append(item) + } + let dated = order.compactMap(\.self).sorted() + var keys: [String?] = dated + if buckets[nil] != nil { + keys.append(nil) + } + return keys.map { Section(isoDay: $0, items: buckets[$0] ?? []) } + } + + /// The day an ISO date names, read in `calendar` so the weekday matches the server's calendar day. + public static func day(_ isoDay: String?, now: Date = Date(), calendar: Calendar = .current) -> Day? { + guard let isoDay else { return nil } + let parts = isoDay.prefix(10).split(separator: "-").compactMap { Int($0) } + guard parts.count == 3, + let date = calendar.date(from: DateComponents(year: parts[0], month: parts[1], day: parts[2])) + else { return nil } + let offset = calendar.dateComponents([.day], from: calendar.startOfDay(for: now), to: date).day + let relative: Relative = switch offset { + case 0?: .today + case 1?: .tomorrow + default: .other + } + return Day(date: date, relative: relative) + } + + /// "Tuesday", capitalized for a header even in languages that write weekdays lowercase. + public static func weekday(_ date: Date, locale: Locale = .current, calendar: Calendar = .current) -> String { + let name = date.formatted(style(locale: locale, calendar: calendar).weekday(.wide)) + return name.prefix(1).uppercased(with: locale) + name.dropFirst() + } + + /// "October 20", with the year only when it isn't the current one. + public static func monthDay( + _ date: Date, now: Date = Date(), locale: Locale = .current, calendar: Calendar = .current + ) -> String { + let base = style(locale: locale, calendar: calendar).month(.wide).day() + let sameYear = calendar.component(.year, from: date) == calendar.component(.year, from: now) + return date.formatted(sameYear ? base : base.year()) + } + + private static func style(locale: Locale, calendar: Calendar) -> Date.FormatStyle { + Date.FormatStyle(locale: locale, calendar: calendar, timeZone: calendar.timeZone) + } +} diff --git a/apps/ios/Tests/RawkoonKitTests/UpcomingCalendarTests.swift b/apps/ios/Tests/RawkoonKitTests/UpcomingCalendarTests.swift new file mode 100644 index 00000000..536a3e58 --- /dev/null +++ b/apps/ios/Tests/RawkoonKitTests/UpcomingCalendarTests.swift @@ -0,0 +1,58 @@ +@testable import RawkoonKit +import XCTest + +final class UpcomingCalendarTests: XCTestCase { + /// Late evening in Montréal is already the next day in UTC. + private let now = ISO8601DateFormatter().date(from: "2026-10-19T02:30:00Z")! + private let montreal: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "America/Montreal")! + return calendar + }() + + private func day(_ iso: String?) -> UpcomingCalendar.Day? { + UpcomingCalendar.day(iso, now: now, calendar: montreal) + } + + func testRelativeDayFollowsTheLocalCalendar() { + XCTAssertEqual(day("2026-10-18")?.relative, .today) + XCTAssertEqual(day("2026-10-19")?.relative, .tomorrow) + XCTAssertEqual(day("2026-10-20")?.relative, .other) + XCTAssertEqual(day("2026-10-17")?.relative, .other) + } + + func testMissingOrMalformedDayIsNil() { + XCTAssertNil(day(nil)) + XCTAssertNil(day("")) + XCTAssertNil(day("soon")) + } + + func testFullTimestampsKeepTheirCalendarDay() { + XCTAssertEqual(day("2026-10-19T00:00:00.000Z")?.relative, .tomorrow) + } + + func testHeaderLabels() throws { + let tuesday = try XCTUnwrap(day("2026-10-20")).date + let french = Locale(identifier: "fr_CA") + XCTAssertEqual(UpcomingCalendar.weekday(tuesday, locale: french, calendar: montreal), "Mardi") + let english = Locale(identifier: "en_US") + XCTAssertEqual(UpcomingCalendar.weekday(tuesday, locale: english, calendar: montreal), "Tuesday") + XCTAssertEqual(UpcomingCalendar.monthDay(tuesday, now: now, locale: french, calendar: montreal), "20 octobre") + + let nextYear = try XCTUnwrap(day("2027-01-03")).date + XCTAssertEqual( + UpcomingCalendar.monthDay(nextYear, now: now, locale: Locale(identifier: "en_US"), calendar: montreal), + "January 3, 2027" + ) + } + + func testSectionsSortDaysAndKeepUndatedLast() { + let items: [(String, String?)] = [ + ("a", "2026-10-23"), ("b", nil), ("c", "2026-10-20"), ("d", "2026-10-23"), ("e", ""), + ] + let sections = UpcomingCalendar.sections(items) { $0.1 } + XCTAssertEqual(sections.map(\.isoDay), ["2026-10-20", "2026-10-23", nil]) + XCTAssertEqual(sections.map { $0.items.map(\.0) }, [["c"], ["a", "d"], ["b", "e"]]) + XCTAssertEqual(sections.last?.id, "undated") + } +}