
- 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
477 lines
17 KiB
Swift
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()
|
|
}
|
|
}
|