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>
This commit is contained in:
318
SportsTime/Export/Views/SharePreviewView.swift
Normal file
318
SportsTime/Export/Views/SharePreviewView.swift
Normal file
@@ -0,0 +1,318 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user