From eb3305537b691df7e3d80fb1a8cd06bb1e26ffb9 Mon Sep 17 00:00:00 2001 From: Chris Brummel Date: Tue, 2 Jun 2026 09:38:33 -0700 Subject: [PATCH] Add rich calendar layout --- Ruddarr/Services/AppSettings.swift | 2 + Ruddarr/Views/Calendar/CalendarDate.swift | 105 +++++++++-- Ruddarr/Views/Calendar/CalendarMedia.swift | 177 ++++++++++++++---- Ruddarr/Views/CalendarView.swift | 177 ++++++++++++------ .../Settings/SettingsDisplaySection.swift | 9 + 5 files changed, 363 insertions(+), 107 deletions(-) diff --git a/Ruddarr/Services/AppSettings.swift b/Ruddarr/Services/AppSettings.swift index 7cbc37e1..f778b402 100644 --- a/Ruddarr/Services/AppSettings.swift +++ b/Ruddarr/Services/AppSettings.swift @@ -16,6 +16,7 @@ class AppSettings: ObservableObject { @AppStorage("theme", store: dependencies.store) var theme: Theme = .factory @AppStorage("appearance", store: dependencies.store) var appearance: Appearance = .automatic @AppStorage("grid", store: dependencies.store) var grid: GridStyle = .posters + @AppStorage("richCalendarDisplay", store: dependencies.store) var richCalendarDisplay: Bool = true @AppStorage("tab", store: dependencies.store) var tab: TabItem = .movies @AppStorage("releaseFilters", store: dependencies.store) var releaseFilters: ReleaseFilters = .reset @@ -105,6 +106,7 @@ extension AppSettings { "theme": theme.rawValue, "tab": tab.rawValue, "appearance": appearance.rawValue, + "richCalendarDisplay": richCalendarDisplay, ] for instance in configuredInstances { diff --git a/Ruddarr/Views/Calendar/CalendarDate.swift b/Ruddarr/Views/Calendar/CalendarDate.swift index 22914832..05151554 100644 --- a/Ruddarr/Views/Calendar/CalendarDate.swift +++ b/Ruddarr/Views/Calendar/CalendarDate.swift @@ -1,13 +1,79 @@ import SwiftUI +enum CalendarDateStyle { + case rich + case classic +} + struct CalendarDate: View { var date: Date + var style: CalendarDateStyle = .rich @State var isToday: Bool = false @EnvironmentObject var settings: AppSettings + @ViewBuilder var body: some View { + Group { + switch style { + case .rich: + richDate + case .classic: + classicDate + } + } + .onAppear { + isToday = Calendar.current.isDateInToday(date) + } + .onBecomeActive { + isToday = Calendar.current.isDateInToday(date) + } + .transaction { transaction in + transaction.animation = nil // disable animation + } + } + + var richDate: some View { + HStack { + dateLockup + + Spacer(minLength: 0) + } + .padding(.top, 6) + .padding(.bottom, 10) + .frame(maxWidth: .infinity, alignment: .leading) + } + + var dateLockup: some View { + HStack(alignment: .center, spacing: 7) { + VStack(alignment: .leading, spacing: 0) { + Text(CalendarDate.dayOfWeek.string(from: date).uppercased()) + .font(.caption2.weight(.semibold)) + .kerning(1.05) + .lineLimit(1) + .offset(y: 1) + + Text(CalendarDate.nameOfMonth.string(from: date).uppercased()) + .font(.caption2) + .kerning(1.05) + .lineLimit(1) + } + .foregroundStyle(isToday ? settings.theme.tint : .secondary) + + Text(CalendarDate.dayOfMonth.string(from: date)) + .font(.title.bold()) + .monospacedDigit() + .foregroundStyle(isToday ? settings.theme.tint : .primary) + } + .fixedSize() + .padding(.vertical, 4) + .padding(.horizontal, 7) + .background(.background.opacity(0.8), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .offset(x: -7) + } + + var classicDate: some View { VStack(alignment: .center, spacing: 0) { Text(CalendarDate.dayOfWeek.string(from: date).uppercased()) .font(.caption2) @@ -29,15 +95,6 @@ struct CalendarDate: View { Spacer() } .foregroundStyle(isToday ? settings.theme.tint : .primary) - .onAppear { - isToday = Calendar.current.isDateInToday(date) - } - .onBecomeActive { - isToday = Calendar.current.isDateInToday(date) - } - .transaction { transaction in - transaction.animation = nil // disable animation - } } static let dayOfWeek: DateFormatter = { @@ -61,17 +118,29 @@ struct CalendarDate: View { struct CalendarWeekRange: View { var date: Date + var style: CalendarDateStyle = .rich + @ViewBuilder var body: some View { - Spacer() - - Text(weekRange(date)) - .font(.subheadline) - .textCase(.uppercase) - .kerning(1.0) - .foregroundStyle(.secondary) - .padding(.bottom, 12) - .padding(.leading, 1) + switch style { + case .rich: + Text(weekRange(date)) + .font(.subheadline) + .textCase(.uppercase) + .kerning(1.0) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 10) + .padding(.bottom, 6) + case .classic: + Text(weekRange(date)) + .font(.subheadline) + .textCase(.uppercase) + .kerning(1.0) + .foregroundStyle(.secondary) + .padding(.bottom, 12) + .padding(.leading, 1) + } } func weekRange(_ date: Date) -> String { diff --git a/Ruddarr/Views/Calendar/CalendarMedia.swift b/Ruddarr/Views/Calendar/CalendarMedia.swift index 986059e2..8dbbee4d 100644 --- a/Ruddarr/Views/Calendar/CalendarMedia.swift +++ b/Ruddarr/Views/Calendar/CalendarMedia.swift @@ -7,6 +7,61 @@ struct CalendarMovie: View { @EnvironmentObject var settings: AppSettings var body: some View { + Group { + if settings.richCalendarDisplay { + richContent + } else { + classicContent + } + } + .padding(.vertical, 8) + .padding(.horizontal, settings.richCalendarDisplay ? 8 : 12) + .frame(maxWidth: .infinity) + .opacity(shouldFade ? 0.5 : 1) + .background(.card.opacity(shouldFade ? 0.6 : 1)) + .clipShape(RoundedRectangle(cornerRadius: 14)) + .onTapGesture { + let deeplink = String( + format: "ruddarr://movies/open/%d?instance=%@", + movie.id, + movie.instanceId!.uuidString + ) + + try? QuickActions.Deeplink(url: URL(string: deeplink)!)() + } + } + + var richContent: some View { + HStack(alignment: .center, spacing: 10) { + CalendarPoster(url: movie.remotePoster, title: movie.title) + + VStack(alignment: .leading, spacing: 3) { + HStack(alignment: .center) { + Text(movie.title) + .font(.body) + .lineLimit(1) + .foregroundStyle(shouldFade ? .secondary : .primary) + + Spacer() + + statusIcon + .font(.subheadline) + .imageScale(.small) + .foregroundStyle(.secondary) + } + + if let type = movie.releaseType(for: date) { + Text(type) + .font(.caption) + .foregroundStyle(settings.theme.tint) + } + } + + Spacer(minLength: 0) + } + } + + var classicContent: some View { HStack { VStack(alignment: .leading, spacing: 2) { HStack(alignment: .center) { @@ -30,21 +85,6 @@ struct CalendarMovie: View { } } } - .padding(.vertical, 8) - .padding(.horizontal, 12) - .frame(maxWidth: .infinity) - .opacity(shouldFade ? 0.5 : 1) - .background(.card.opacity(shouldFade ? 0.6 : 1)) - .clipShape(RoundedRectangle(cornerRadius: 14)) - .onTapGesture { - let deeplink = String( - format: "ruddarr://movies/open/%d?instance=%@", - movie.id, - movie.instanceId!.uuidString - ) - - try? QuickActions.Deeplink(url: URL(string: deeplink)!)() - } } var shouldFade: Bool { @@ -71,6 +111,80 @@ struct CalendarEpisode: View { @EnvironmentObject var settings: AppSettings var body: some View { + Group { + if settings.richCalendarDisplay { + richContent + } else { + classicContent + } + } + .padding(.vertical, 8) + .padding(.horizontal, settings.richCalendarDisplay ? 8 : 12) + .frame(maxWidth: .infinity) + .opacity(shouldFade ? 0.5 : 1) + .background(.card.opacity(shouldFade ? 0.6 : 1)) + .clipShape(RoundedRectangle(cornerRadius: 14)) + .onTapGesture { + var deeplink = String( + format: "ruddarr://series/open/%d?season=%d&instance=%@", + episode.seriesId, + episode.seasonNumber, + episode.instanceId!.uuidString + ) + + if !isGrouped { + deeplink.append("&episode=\(episode.episodeNumber)") + } + + try? QuickActions.Deeplink(url: URL(string: deeplink)!)() + } + } + + var richContent: some View { + HStack(alignment: .center, spacing: 10) { + CalendarPoster(url: episode.series?.remotePoster, title: episode.series?.title) + + VStack(alignment: .leading, spacing: 3) { + HStack { + Text(episode.series?.title ?? "Unknown") + .font(.body) + .lineLimit(1) + .foregroundStyle(shouldFade ? .secondary : .primary) + + Spacer() + + if let airDate = episode.airDateUtc { + Text(airDate.formatted(date: .omitted, time: .shortened)) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + + HStack(alignment: .center, spacing: 6) { + Text(episode.episodeLabel) + + if let title = episode.title { + Bullet() + Text(title).lineLimit(1) + } + + Spacer() + + statusIcon + .foregroundStyle(.secondary) + .imageScale(.small) + } + .foregroundStyle(.secondary) + .font(.subheadline) + + tag + } + + Spacer(minLength: 0) + } + } + + var classicContent: some View { VStack(alignment: .leading) { HStack { Text(episode.series?.title ?? "Unknown") @@ -106,26 +220,6 @@ struct CalendarEpisode: View { tag } - .padding(.vertical, 8) - .padding(.horizontal, 12) - .frame(maxWidth: .infinity) - .opacity(shouldFade ? 0.5 : 1) - .background(.card.opacity(shouldFade ? 0.6 : 1)) - .clipShape(RoundedRectangle(cornerRadius: 14)) - .onTapGesture { - var deeplink = String( - format: "ruddarr://series/open/%d?season=%d&instance=%@", - episode.seriesId, - episode.seasonNumber, - episode.instanceId!.uuidString - ) - - if !isGrouped { - deeplink.append("&episode=\(episode.episodeNumber)") - } - - try? QuickActions.Deeplink(url: URL(string: deeplink)!)() - } } var shouldFade: Bool { @@ -172,6 +266,19 @@ struct CalendarEpisode: View { } } +private struct CalendarPoster: View { + var url: String? + var title: String? + + var body: some View { + CachedAsyncImage(.poster, url, placeholder: title) + .aspectRatio(CGSize(width: 150, height: 225), contentMode: .fill) + .frame(width: 44, height: 66) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .contentShape(RoundedRectangle(cornerRadius: 8)) + } +} + enum CalendarMediaType: CaseIterable { case all case movies diff --git a/Ruddarr/Views/CalendarView.swift b/Ruddarr/Views/CalendarView.swift index 6b14e23a..ca9d59a6 100644 --- a/Ruddarr/Views/CalendarView.swift +++ b/Ruddarr/Views/CalendarView.swift @@ -1,5 +1,9 @@ import SwiftUI +private enum CalendarScrollTarget: Hashable { + case dayHeader(TimeInterval) +} + struct CalendarView: View { @State var calendar = MediaCalendar() @@ -19,57 +23,19 @@ struct CalendarView: View { @EnvironmentObject var settings: AppSettings private let firstWeekday = Calendar.current.firstWeekday - - private var gridLayout = [ + private let gridLayout = [ GridItem(.fixed(50), alignment: .center), - GridItem(.flexible()) + GridItem(.flexible()), ] var body: some View { - // swiftlint:disable:next closure_body_length NavigationStack(path: dependencies.$router.calendarPath) { - Group { - if settings.configuredInstances.isEmpty { - NoInstance() - } else { - ScrollViewReader { proxy in - ScrollView { - LazyVGrid(columns: gridLayout, alignment: .leading, spacing: 0) { - ForEach(calendar.dates, id: \.self) { timestamp in - let date = Date(timeIntervalSince1970: timestamp) - let weekday = Calendar.current.component(.weekday, from: date) - - if firstWeekday == weekday { - CalendarWeekRange(date: date) - } - - CalendarDate(date: date).offset(x: -6) - media(for: timestamp, date: date) - } - } - - Group { - if calendar.isLoadingFuture { - ProgressView().tint(.secondary) - } else if !calendar.dates.isEmpty { - Button("Load More") { - calendar.loadMoreDates() - } - .buttonStyle(.bordered) - .tint(.buttonTint) - } - }.padding(.bottom, 32) - } - .opacity(hideCalendarView ? 0 : 1) - .onAppear { - scrollView = proxy - } - .onBecomeActive { - await load() - } - } - } - } + navigationContent + } + } + + var navigationContent: some View { + content .scenePadding(.horizontal) .scrollIndicators(.never) .safeNavigationBarTitleDisplayMode(.inline) @@ -87,9 +53,7 @@ struct CalendarView: View { } } .onReceive(NotificationCenter.default.publisher(for: .scrollToToday)) { _ in - withAnimation(.smooth) { - scrollTo(calendar.today()) - } + scrollToToday() } .task { await load() @@ -111,7 +75,105 @@ struct CalendarView: View { contentUnavailable } } + } + + @ViewBuilder + var content: some View { + if settings.configuredInstances.isEmpty { + NoInstance() + } else { + scrollableCalendar + } + } + + var scrollableCalendar: some View { + ScrollViewReader { proxy in + ScrollView { + calendarSections + loadMoreButton + } + .opacity(hideCalendarView ? 0 : 1) + .onAppear { + scrollView = proxy + } + .onBecomeActive { + await load() + } + } + } + + @ViewBuilder + var calendarSections: some View { + if settings.richCalendarDisplay { + richCalendarSections + } else { + classicCalendarSections + } + } + + var richCalendarSections: some View { + LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) { + ForEach(calendar.dates, id: \.self) { timestamp in + calendarSection(for: timestamp) + } + } + } + + var classicCalendarSections: some View { + LazyVGrid(columns: gridLayout, alignment: .leading, spacing: 0) { + ForEach(calendar.dates, id: \.self) { timestamp in + classicCalendarSection(for: timestamp) + } + } + } + + @ViewBuilder + func calendarSection(for timestamp: TimeInterval) -> some View { + let date = Date(timeIntervalSince1970: timestamp) + let weekday = Calendar.current.component(.weekday, from: date) + + if firstWeekday == weekday { + CalendarWeekRange(date: date) + } + + Section { + media(for: timestamp, date: date) + } header: { + CalendarDate(date: date) + .id(CalendarScrollTarget.dayHeader(timestamp)) + } + } + + @ViewBuilder + func classicCalendarSection(for timestamp: TimeInterval) -> some View { + let date = Date(timeIntervalSince1970: timestamp) + let weekday = Calendar.current.component(.weekday, from: date) + + if firstWeekday == weekday { + Spacer() + CalendarWeekRange(date: date, style: .classic) + } + + CalendarDate(date: date, style: .classic) + .offset(x: -6) + .id(CalendarScrollTarget.dayHeader(timestamp)) + media(for: timestamp, date: date) + } + + @ViewBuilder + var loadMoreButton: some View { + Group { + if calendar.isLoadingFuture { + ProgressView().tint(.secondary) + } else if !calendar.dates.isEmpty { + Button("Load More") { + calendar.loadMoreDates() + } + .buttonStyle(.bordered) + .tint(.buttonTint) + } } + .padding(.bottom, 32) } var notConnectedToInternet: Bool { @@ -229,12 +291,19 @@ struct CalendarView: View { try? await Task.sleep(for: .milliseconds(15)) scrollTo(calendar.today()) + try? await Task.sleep(for: .milliseconds(15)) hideCalendarView = false } func scrollTo(_ timestamp: TimeInterval) { - scrollView?.scrollTo(timestamp, anchor: .center) + scrollView?.scrollTo(CalendarScrollTarget.dayHeader(timestamp), anchor: .top) + } + + func scrollToToday() { + withAnimation(.easeOut(duration: 0.45)) { + scrollTo(calendar.today()) + } } func media(for timestamp: TimeInterval, date: Date) -> some View { @@ -253,16 +322,16 @@ struct CalendarView: View { Spacer() } - .padding(.top, 4) + .frame(maxWidth: .infinity) + .padding(.top, settings.richCalendarDisplay ? 0 : 4) + .padding(.bottom, 8) } var todayButton: some ToolbarContent { ToolbarItem(placement: .primaryAction) { Button("Today", systemImage: "calendar.day.timeline.left") { Task { @MainActor in - withAnimation(.smooth) { - self.scrollTo(self.calendar.today()) - } + self.scrollToToday() } } .tint(.primary) diff --git a/Ruddarr/Views/Settings/SettingsDisplaySection.swift b/Ruddarr/Views/Settings/SettingsDisplaySection.swift index 80476f68..9119ea4e 100644 --- a/Ruddarr/Views/Settings/SettingsDisplaySection.swift +++ b/Ruddarr/Views/Settings/SettingsDisplaySection.swift @@ -13,6 +13,8 @@ struct SettingsDisplaySection: View { #if os(iOS) iconPicker #endif + + richCalendarDisplayToggle } header: { Text("Display", comment: "Preferences section title") } @@ -35,6 +37,13 @@ struct SettingsDisplaySection: View { }.tint(.secondary) } + var richCalendarDisplayToggle: some View { + Toggle(isOn: $settings.richCalendarDisplay) { + Label("Rich Display on Calendar", systemImage: TabItem.calendar.icon) + .labelStyle(SettingsIconLabelStyle()) + } + } + var themePicker: some View { Picker(selection: $settings.theme) { ForEach(Theme.allCases) { theme in