RTSDA-iOS/Views/Components/ContentCards.swift
RTSDA 00679f927c docs: Update README for v2.0 release and fix git remote URL
- Comprehensive README update documenting v2.0 architectural changes
- Updated git remote to ssh://rockvilleav@git.rockvilletollandsda.church:10443/RTSDA/RTSDA-iOS.git
- Documented unified ChurchService and 60% code reduction
- Added new features: Home Feed, responsive reading, enhanced UI
- Corrected license information (GPL v3 with church content copyright)
- Updated build instructions and technical stack details
2025-08-16 18:41:51 -04:00

477 lines
17 KiB
Swift

import SwiftUI
#if os(iOS)
struct ShareSheet: UIViewControllerRepresentable {
let activityItems: [Any]
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: activityItems, applicationActivities: nil)
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
}
#endif
// MARK: - Sermon Card (for Watch view)
enum SermonCardStyle {
case watch // For Watch tab (compact)
case feed // For Home feed (prominent with large thumbnail)
}
struct SermonCard: View {
let sermon: Sermon
let style: SermonCardStyle
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@State private var showingScriptureSheet = false
@State private var showingShareSheet = false
@State private var scriptureText = ""
init(sermon: Sermon, style: SermonCardStyle = .watch) {
self.sermon = sermon
self.style = style
}
var body: some View {
Button {
if let videoUrl = sermon.videoUrl {
let optimalUrl = getOptimalStreamingUrl(mediaId: sermon.id)
let urlToUse = !optimalUrl.isEmpty ? optimalUrl : videoUrl
if let url = URL(string: urlToUse) {
SharedVideoManager.shared.playVideo(url: url, title: sermon.title, artworkURL: sermon.thumbnail)
}
}
} label: {
switch style {
case .watch:
watchStyleCard
case .feed:
feedStyleCard
}
}
.buttonStyle(.plain)
.contextMenu {
Button("View Scripture", systemImage: "book.fill") {
Task {
let verses = fetchScriptureVersesForSermonJson(sermonId: sermon.id)
await MainActor.run {
scriptureText = verses
showingScriptureSheet = true
}
}
}
Button("Share Sermon", systemImage: "square.and.arrow.up") {
showingShareSheet = true
}
if sermon.audioUrl != nil && sermon.videoUrl == nil {
Button("Play Audio Only", systemImage: "speaker.wave.2") {
// TODO: Implement audio-only playback
}
}
}
.sheet(isPresented: $showingScriptureSheet) {
ScriptureSheet(scriptureText: $scriptureText)
}
#if os(iOS)
.sheet(isPresented: $showingShareSheet) {
ShareSheet(activityItems: createShareItems(for: sermon))
}
#endif
.shadow(color: .black.opacity(0.1), radius: style == .feed ? 8 : 6, x: 0, y: style == .feed ? 4 : 2)
}
// MARK: - Watch Style (Compact)
private var watchStyleCard: some View {
Group {
if horizontalSizeClass == .regular {
// iPad: Horizontal layout
HStack(spacing: 16) {
thumbnailView()
.frame(width: 120, height: 90)
watchContentView
Spacer()
playButton
}
.padding(16)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
} else {
// FIXED: Proper layout with real components
VStack(alignment: .leading, spacing: 16) {
thumbnailView(durationAlignment: .bottomTrailing)
.frame(height: 160)
watchContentView
.padding(.horizontal, 16)
.padding(.bottom, 16)
}
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
}
}
}
// MARK: - Feed Style (Prominent)
private var feedStyleCard: some View {
VStack(alignment: .leading, spacing: 0) {
thumbnailView(durationAlignment: .bottomTrailing)
.frame(height: horizontalSizeClass == .regular ? 200 : 180)
// Content section
feedContentView
.padding(16)
}
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
}
private func thumbnailView(durationAlignment: Alignment = .bottomTrailing) -> some View {
// FIXED: Use .fill but force proper clipping with frame
Rectangle()
.fill(.clear)
.overlay {
CachedAsyncImage(url: URL(string: sermon.thumbnail ?? sermon.image ?? "")) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Rectangle()
.fill(.gray)
.overlay {
Text("Loading...")
.foregroundColor(.white)
}
}
}
.clipped() // Force clipping BEFORE clipShape
.clipShape(RoundedRectangle(cornerRadius: 8))
.overlay(alignment: durationAlignment) {
if let duration = sermon.durationFormatted {
Text(duration)
.font(.caption2)
.fontWeight(.medium)
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(.black.opacity(0.7), in: Capsule())
.foregroundStyle(.white)
.padding(4)
}
}
}
// MARK: - Content Views
private var watchContentView: some View {
VStack(alignment: .leading, spacing: 8) {
Text(sermon.title.trimmingCharacters(in: .whitespacesAndNewlines))
.font(.headline)
.fontWeight(.semibold)
.lineLimit(2)
.multilineTextAlignment(.leading)
if !sermon.speaker.isEmpty {
Text("by \(sermon.speaker.trimmingCharacters(in: .whitespacesAndNewlines))")
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
}
if !sermon.formattedDate.isEmpty && sermon.formattedDate != "Date unknown" {
Text(sermon.formattedDate)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
private var feedContentView: some View {
VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text(sermon.title.trimmingCharacters(in: .whitespacesAndNewlines))
.font(.headline)
.fontWeight(.semibold)
.lineLimit(2)
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
if !sermon.speaker.isEmpty {
Text("by \(sermon.speaker.trimmingCharacters(in: .whitespacesAndNewlines))")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
if !sermon.formattedDate.isEmpty && sermon.formattedDate != "Date unknown" {
Text(sermon.formattedDate)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
private var playButton: some View {
Button {
// Play sermon
} label: {
Image(systemName: "play.circle.fill")
.font(.system(size: 24))
.foregroundStyle(Color(hex: "fb8b23"))
}
}
}
// MARK: - Event Card (for Connect view)
struct EventCard: View {
let event: ChurchEvent
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
var body: some View {
NavigationLink {
EventDetailViewWrapper(event: event)
} label: {
VStack(alignment: .leading, spacing: 0) {
// Event image or gradient
Group {
if let imageUrl = event.thumbnail ?? event.image {
CachedAsyncImage(url: URL(string: imageUrl)) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
eventGradient
}
} else {
eventGradient
}
}
.frame(height: horizontalSizeClass == .regular ? 160 : 140)
.clipped()
// Content
VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text(event.title)
.font(.headline)
.fontWeight(.semibold)
.lineLimit(2)
.multilineTextAlignment(.leading)
Text(event.formattedDate)
.font(.subheadline)
.foregroundStyle(.secondary)
}
if !event.location.isEmpty {
Label {
Text(event.location)
.font(.caption)
.lineLimit(1)
} icon: {
Image(systemName: "location.fill")
.font(.caption)
}
.foregroundStyle(.secondary)
}
if !event.category.isEmpty {
Text(event.category)
.font(.caption)
.fontWeight(.medium)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(.blue.opacity(0.1), in: Capsule())
.foregroundStyle(.blue)
}
}
.padding(16)
}
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
.shadow(color: .black.opacity(0.1), radius: 6, x: 0, y: 2)
}
.buttonStyle(.plain)
}
private var eventGradient: some View {
LinearGradient(
colors: [.blue, .blue.opacity(0.7)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.overlay {
Image(systemName: "calendar")
.font(.system(size: 32))
.foregroundStyle(.white.opacity(0.7))
}
}
}
// MARK: - Bulletin Card (for Discover view)
struct BulletinCard: View {
let bulletin: ChurchBulletin
var body: some View {
NavigationLink {
BulletinDetailView(bulletin: bulletin)
} label: {
VStack(alignment: .leading, spacing: 0) {
// Header with PDF icon
HStack {
Image(systemName: "doc.text.fill")
.font(.system(size: 32))
.foregroundStyle(Color(hex: "fb8b23"))
VStack(alignment: .leading, spacing: 4) {
Text("Church Bulletin")
.font(.caption)
.fontWeight(.medium)
.foregroundStyle(.secondary)
Text(bulletin.formattedDate)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if bulletin.pdfPath != nil {
Image(systemName: "arrow.down.circle.fill")
.font(.title3)
.foregroundStyle(.green)
}
}
.padding(16)
.background(.regularMaterial)
Divider()
// Content preview
VStack(alignment: .leading, spacing: 12) {
Text(bulletin.title)
.font(.headline)
.fontWeight(.semibold)
.lineLimit(2)
.multilineTextAlignment(.leading)
VStack(alignment: .leading, spacing: 4) {
if !bulletin.sabbathSchool.isEmpty {
Text("Sabbath School: \(bulletin.sabbathSchool)")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
if !bulletin.divineWorship.isEmpty {
Text("Divine Worship: \(bulletin.divineWorship)")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
HStack {
Label("Tap to view", systemImage: "eye.fill")
.font(.caption)
.foregroundStyle(.blue)
Spacer()
if bulletin.pdfPath != nil {
Label("PDF Available", systemImage: "doc.fill")
.font(.caption)
.foregroundStyle(.green)
}
}
}
.padding(16)
}
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
.shadow(color: .black.opacity(0.1), radius: 6, x: 0, y: 2)
}
.buttonStyle(.plain)
}
}
// MARK: - Quick Action Card
struct QuickActionCard: View {
let title: String
let icon: String
let color: Color
let action: () -> Void
var body: some View {
Button(action: action) {
VStack(spacing: 12) {
Image(systemName: icon)
.font(.system(size: 24))
.foregroundStyle(color)
Text(title)
.font(.subheadline)
.fontWeight(.medium)
.foregroundStyle(.primary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(20)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
.shadow(color: .black.opacity(0.1), radius: 6, x: 0, y: 2)
}
.buttonStyle(.plain)
}
}
#Preview {
ScrollView {
VStack(spacing: 20) {
SermonCard(sermon: Sermon.sampleSermon())
EventCard(event: ChurchEvent(
id: "2",
title: "Community Potluck Dinner",
description: "Join us for fellowship",
startTime: "2025-01-15T18:00:00-05:00",
endTime: "2025-01-15T20:00:00-05:00",
formattedTime: "6:00 PM - 8:00 PM",
formattedDate: "January 15, 2025",
formattedDateTime: "January 15, 2025 at 6:00 PM",
dayOfMonth: "15",
monthAbbreviation: "JAN",
timeString: "6:00 PM - 8:00 PM",
isMultiDay: false,
detailedTimeDisplay: "6:00 PM - 8:00 PM",
location: "Fellowship Hall",
locationUrl: nil,
image: nil,
thumbnail: nil,
category: "Social",
isFeatured: false,
recurringType: nil,
createdAt: "2025-01-10T09:00:00-05:00",
updatedAt: "2025-01-10T09:00:00-05:00"
))
BulletinCard(bulletin: ChurchBulletin(
id: "3",
title: "January 11, 2025",
date: "Saturday, January 11, 2025",
sabbathSchool: "The Book of Romans",
divineWorship: "Walking in Faith",
scriptureReading: "Romans 8:28-39",
sunset: "5:47 PM",
pdfPath: "https://example.com/bulletin.pdf",
coverImage: nil,
isActive: true
))
}
.padding()
}
}