Files
Sportstime/SportsTime/Export/Views/SharePreviewView.swift
Trey t fe36f99bca feat(sharing): implement unified sharing system for social media
Replace old ProgressCardGenerator with protocol-based sharing architecture
supporting trips, achievements, and stadium progress. Features 8 color
themes, Instagram Stories optimization (1080x1920), and reusable card
components with map snapshots.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 08:54:37 -06:00

319 lines
10 KiB
Swift

//
// SharePreviewView.swift
// SportsTime
//
// Unified preview and customization UI for all shareable content.
//
import SwiftUI
struct SharePreviewView<Content: ShareableContent>: View {
let content: Content
@Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var colorScheme
@State private var selectedTheme: ShareTheme
@State private var generatedImage: UIImage?
@State private var isGenerating = false
@State private var error: String?
@State private var showCopiedToast = false
// Progress-specific options
@State private var includeUsername = true
@State private var username = ""
init(content: Content) {
self.content = content
_selectedTheme = State(initialValue: ShareThemePreferences.theme(for: content.cardType))
}
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: Theme.Spacing.lg) {
// Preview
previewSection
// Theme selector
themeSelector
// Username toggle (progress cards only)
if content.cardType == .stadiumProgress {
usernameSection
}
// Action buttons
actionButtons
}
.padding(Theme.Spacing.md)
}
.themedBackground()
.navigationTitle("Share")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
}
.alert("Error", isPresented: .constant(error != nil)) {
Button("OK") { error = nil }
} message: {
Text(error ?? "")
}
.overlay {
if showCopiedToast {
copiedToast
}
}
.task {
await generatePreview()
}
.onChange(of: selectedTheme) { _, _ in
Task { await generatePreview() }
}
}
}
// MARK: - Preview Section
private var previewSection: some View {
VStack(spacing: Theme.Spacing.sm) {
Text("Preview")
.font(.subheadline)
.foregroundStyle(Theme.textMuted(colorScheme))
if let image = generatedImage {
Image(uiImage: image)
.resizable()
.aspectRatio(9/16, contentMode: .fit)
.frame(maxHeight: 400)
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
.shadow(color: .black.opacity(0.2), radius: 10, x: 0, y: 5)
} else if isGenerating {
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
.fill(Theme.cardBackground(colorScheme))
.aspectRatio(9/16, contentMode: .fit)
.frame(maxHeight: 400)
.overlay {
ProgressView()
}
}
}
}
// MARK: - Theme Selector
private var themeSelector: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
Text("Theme")
.font(.subheadline)
.foregroundStyle(Theme.textMuted(colorScheme))
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: Theme.Spacing.sm) {
ForEach(ShareTheme.all) { theme in
themeButton(theme)
}
}
}
}
}
private func themeButton(_ theme: ShareTheme) -> some View {
Button {
withAnimation {
selectedTheme = theme
ShareThemePreferences.setTheme(theme, for: content.cardType)
}
} label: {
VStack(spacing: 4) {
ZStack {
RoundedRectangle(cornerRadius: 8)
.fill(
LinearGradient(
colors: theme.gradientColors,
startPoint: .top,
endPoint: .bottom
)
)
.frame(width: 50, height: 50)
Circle()
.fill(theme.accentColor)
.frame(width: 16, height: 16)
}
.overlay {
if selectedTheme.id == theme.id {
RoundedRectangle(cornerRadius: 8)
.stroke(Theme.warmOrange, lineWidth: 3)
}
}
Text(theme.name)
.font(.caption2)
.foregroundStyle(Theme.textSecondary(colorScheme))
}
}
.buttonStyle(.plain)
}
// MARK: - Username Section
private var usernameSection: some View {
VStack(spacing: Theme.Spacing.sm) {
Toggle(isOn: $includeUsername) {
Text("Include username")
}
.onChange(of: includeUsername) { _, _ in
Task { await generatePreview() }
}
if includeUsername {
TextField("@username", text: $username)
.textFieldStyle(.roundedBorder)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.onChange(of: username) { _, _ in
Task { await generatePreview() }
}
}
}
.padding()
.background(Theme.cardBackground(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
}
// MARK: - Action Buttons
private var actionButtons: some View {
VStack(spacing: Theme.Spacing.sm) {
// Primary: Share to Instagram
Button {
shareToInstagram()
} label: {
HStack {
Image(systemName: "camera.fill")
Text("Share to Instagram")
}
.frame(maxWidth: .infinity)
.padding(Theme.Spacing.md)
.background(Theme.warmOrange)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
}
.disabled(generatedImage == nil)
HStack(spacing: Theme.Spacing.sm) {
// Copy Image
Button {
copyImage()
} label: {
HStack {
Image(systemName: "doc.on.doc")
Text("Copy")
}
.frame(maxWidth: .infinity)
.padding(Theme.Spacing.md)
.background(Theme.cardBackgroundElevated(colorScheme))
.foregroundStyle(Theme.textPrimary(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
}
.disabled(generatedImage == nil)
// More Options
Button {
showSystemShare()
} label: {
HStack {
Image(systemName: "ellipsis.circle")
Text("More")
}
.frame(maxWidth: .infinity)
.padding(Theme.Spacing.md)
.background(Theme.cardBackgroundElevated(colorScheme))
.foregroundStyle(Theme.textPrimary(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
}
.disabled(generatedImage == nil)
}
}
}
// MARK: - Copied Toast
private var copiedToast: some View {
VStack {
Spacer()
HStack {
Image(systemName: "checkmark.circle.fill")
Text("Copied to clipboard")
}
.padding()
.background(.ultraThinMaterial)
.clipShape(Capsule())
.padding(.bottom, 100)
}
.transition(.move(edge: .bottom).combined(with: .opacity))
}
// MARK: - Actions
private func generatePreview() async {
isGenerating = true
do {
// For progress content, we may need to inject username
if let progressContent = content as? ProgressShareContent {
// This is a workaround - ideally we'd have a more elegant solution
let modifiedContent = ProgressShareContent(
progress: progressContent.progress,
tripCount: progressContent.tripCount,
username: includeUsername ? (username.isEmpty ? nil : username) : nil
)
generatedImage = try await modifiedContent.render(theme: selectedTheme)
} else {
generatedImage = try await content.render(theme: selectedTheme)
}
} catch {
self.error = error.localizedDescription
}
isGenerating = false
}
private func shareToInstagram() {
guard let image = generatedImage else { return }
if !ShareService.shared.shareToInstagram(image: image) {
// Fallback to system share
showSystemShare()
}
}
private func copyImage() {
guard let image = generatedImage else { return }
ShareService.shared.copyToClipboard(image: image)
withAnimation {
showCopiedToast = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
withAnimation {
showCopiedToast = false
}
}
}
private func showSystemShare() {
guard let image = generatedImage,
let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let rootVC = windowScene.windows.first?.rootViewController else { return }
ShareService.shared.presentShareSheet(image: image, from: rootVC)
}
}