Fixes ~95 issues from deep audit across 12 categories in 82 files: - Crash prevention: double-resume in PhotoMetadataExtractor, force unwraps in DateRangePicker, array bounds checks in polls/achievements, ProGate hit-test bypass, Dictionary(uniqueKeysWithValues:) → uniquingKeysWith in 4 files - Silent failure elimination: all 34 try? sites replaced with do/try/catch + logging (SavedTrip, TripDetailView, CanonicalSyncService, BootstrapService, CanonicalModels, CKModels, SportsTimeApp, and more) - Performance: cached DateFormatters (7 files), O(1) team lookups via AppDataProvider, achievement definition dictionary, AnimatedBackground consolidated from 19 Tasks to 1, task cancellation in SharePreviewView - Concurrency: UIKit drawing → MainActor.run, background fetch timeout guard, @MainActor on ThemeManager/AppearanceManager, SyncLogger read/write race fix - Planning engine: game end time in travel feasibility, state-aware city normalization, exact city matching, DrivingConstraints parameter propagation - IAP: unknown subscription states → expired, unverified transaction logging, entitlements updated before paywall dismiss, restore visible to all users - Security: API key to Info.plist lookup, filename sanitization in PDF export, honest User-Agent, removed stale "Feels" analytics super properties - Navigation: consolidated competing navigationDestination, boolean → value-based - Testing: 8 sleep() → waitForExistence, duplicates extracted, Swift 6 compat - Service bugs: infinite retry cap, duplicate achievement prevention, TOCTOU vote fix, PollVote.odg → voterId rename, deterministic placeholder IDs, parallel MKDirections, Sendable-safe POI struct Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
267 lines
8.4 KiB
Swift
267 lines
8.4 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
|
|
@State private var loadTask: Task<Void, Never>?
|
|
|
|
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
|
|
|
|
// 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
|
|
loadTask?.cancel()
|
|
loadTask = Task {
|
|
guard !Task.isCancelled else { return }
|
|
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)
|
|
.accessibilityLabel("Share card preview")
|
|
} 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)
|
|
.accessibilityLabel("Select \(theme.name) theme")
|
|
.accessibilityHint(selectedTheme.id == theme.id ? "Currently selected" : "Double-tap to select this theme")
|
|
.accessibilityAddTraits(selectedTheme.id == theme.id ? .isSelected : [])
|
|
}
|
|
|
|
// 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")
|
|
.accessibilityHidden(true)
|
|
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)
|
|
|
|
// Copy Image
|
|
Button {
|
|
copyImage()
|
|
} label: {
|
|
HStack {
|
|
Image(systemName: "doc.on.doc")
|
|
.accessibilityHidden(true)
|
|
Text("Copy to Clipboard")
|
|
}
|
|
.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")
|
|
.accessibilityHidden(true)
|
|
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 {
|
|
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)
|
|
}
|
|
}
|