209 lines
7.6 KiB
Swift
209 lines
7.6 KiB
Swift
// ── Weekly Summary Card ───────────────────────────────────────
|
|
// Shareable image card showing weekly stats — designed for social sharing
|
|
// Like Spotify Wrapped but for productivity
|
|
|
|
import SwiftUI
|
|
|
|
struct WeeklySummaryCard: View {
|
|
let summary: GamificationEngine.WeeklySummary
|
|
@State private var shareImage: UIImage?
|
|
@State private var showShareSheet = false
|
|
|
|
var body: some View {
|
|
VStack(spacing: CMSpacing.lg) {
|
|
// The card itself
|
|
cardContent
|
|
.background(
|
|
GeometryReader { _ in Color.clear }
|
|
)
|
|
|
|
// Share button
|
|
Button {
|
|
HapticEngine.tap()
|
|
shareImage = renderCardAsImage()
|
|
if shareImage != nil {
|
|
showShareSheet = true
|
|
}
|
|
} label: {
|
|
HStack(spacing: CMSpacing.sm) {
|
|
Image(systemName: "square.and.arrow.up")
|
|
Text("Share Summary")
|
|
}
|
|
.font(CMFonts.body(size: 15, weight: .semibold))
|
|
.foregroundStyle(.white)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, CMSpacing.md)
|
|
.background(CMColors.accent)
|
|
.clipShape(RoundedRectangle(cornerRadius: CMRadius.md))
|
|
}
|
|
}
|
|
.sheet(isPresented: $showShareSheet) {
|
|
if let image = shareImage {
|
|
ShareSheet(items: [image])
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Card Content (rendered as image for sharing)
|
|
|
|
@ViewBuilder
|
|
private var cardContent: some View {
|
|
VStack(spacing: CMSpacing.lg) {
|
|
// Header
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: CMSpacing.xxs) {
|
|
Text("ChronoMind")
|
|
.font(CMFonts.body(size: 12, weight: .medium))
|
|
.foregroundStyle(CMColors.textMuted)
|
|
Text("Weekly Focus Report")
|
|
.font(CMFonts.display(size: 20))
|
|
.foregroundStyle(CMColors.text)
|
|
}
|
|
Spacer()
|
|
Text(summary.focusScore.weekLabel)
|
|
.font(CMFonts.body(size: 12))
|
|
.foregroundStyle(CMColors.textMuted)
|
|
}
|
|
|
|
// Score ring (large)
|
|
HStack(spacing: CMSpacing.xxl) {
|
|
ZStack {
|
|
Circle()
|
|
.stroke(CMColors.border, lineWidth: 8)
|
|
.frame(width: 100, height: 100)
|
|
|
|
Circle()
|
|
.trim(from: 0, to: CGFloat(summary.focusScore.score) / 100)
|
|
.stroke(gradeColor, style: StrokeStyle(lineWidth: 8, lineCap: .round))
|
|
.frame(width: 100, height: 100)
|
|
.rotationEffect(.degrees(-90))
|
|
|
|
VStack(spacing: 0) {
|
|
Text("\(summary.focusScore.score)")
|
|
.font(CMFonts.mono(size: 32, weight: .bold))
|
|
.foregroundStyle(CMColors.text)
|
|
}
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: CMSpacing.md) {
|
|
SummaryMetric(label: "On-time rate", value: String(format: "%.0f%%", summary.focusScore.onTimeRate * 100))
|
|
SummaryMetric(label: "Focus hours", value: String(format: "%.1fh", summary.focusScore.focusHours))
|
|
SummaryMetric(label: "Timers completed", value: "\(summary.focusScore.completedCount)")
|
|
SummaryMetric(label: "Pomodoro sessions", value: "\(summary.pomodoroSessions)")
|
|
}
|
|
}
|
|
|
|
Divider()
|
|
.background(CMColors.border)
|
|
|
|
// Streak + badges
|
|
HStack {
|
|
// Streak
|
|
HStack(spacing: CMSpacing.sm) {
|
|
Image(systemName: summary.streak.currentStreak > 0 ? "flame.fill" : "flame")
|
|
.font(.title3)
|
|
.foregroundStyle(summary.streak.currentStreak > 0 ? CMColors.important : CMColors.textMuted)
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
Text("\(summary.streak.currentStreak)-day streak")
|
|
.font(CMFonts.body(size: 14, weight: .semibold))
|
|
.foregroundStyle(CMColors.text)
|
|
Text("Best: \(summary.streak.longestStreak) days")
|
|
.font(CMFonts.body(size: 11))
|
|
.foregroundStyle(CMColors.textMuted)
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Earned badges count
|
|
let earnedCount = summary.badges.count
|
|
HStack(spacing: CMSpacing.sm) {
|
|
Image(systemName: "trophy.fill")
|
|
.font(.title3)
|
|
.foregroundStyle(Color(red: 1, green: 0.84, blue: 0))
|
|
Text("\(earnedCount) badge\(earnedCount == 1 ? "" : "s")")
|
|
.font(CMFonts.body(size: 14, weight: .semibold))
|
|
.foregroundStyle(CMColors.text)
|
|
}
|
|
}
|
|
|
|
// Grade label
|
|
Text(summary.focusScore.grade.rawValue)
|
|
.font(CMFonts.display(size: 16))
|
|
.foregroundStyle(gradeColor)
|
|
.padding(.horizontal, CMSpacing.lg)
|
|
.padding(.vertical, CMSpacing.sm)
|
|
.background(gradeColor.opacity(0.15))
|
|
.clipShape(Capsule())
|
|
}
|
|
.padding(CMSpacing.xl)
|
|
.background(
|
|
LinearGradient(
|
|
colors: [CMColors.surface, CMColors.bg],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
)
|
|
.clipShape(RoundedRectangle(cornerRadius: CMRadius.xl))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: CMRadius.xl)
|
|
.stroke(CMColors.border, lineWidth: 1)
|
|
)
|
|
}
|
|
|
|
// MARK: - Render as Image
|
|
|
|
@MainActor
|
|
private func renderCardAsImage() -> UIImage? {
|
|
let renderer = ImageRenderer(content:
|
|
cardContent
|
|
.frame(width: 360)
|
|
.padding(CMSpacing.lg)
|
|
.background(CMColors.bg)
|
|
)
|
|
renderer.scale = 3.0
|
|
return renderer.uiImage
|
|
}
|
|
|
|
private var gradeColor: Color {
|
|
switch summary.focusScore.grade {
|
|
case .excellent: return CMColors.success
|
|
case .great: return CMColors.accent
|
|
case .good: return CMColors.standard
|
|
case .fair: return CMColors.important
|
|
case .needsWork: return CMColors.critical
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Summary Metric Row
|
|
|
|
private struct SummaryMetric: View {
|
|
let label: String
|
|
let value: String
|
|
|
|
var body: some View {
|
|
HStack {
|
|
Text(label)
|
|
.font(CMFonts.body(size: 12))
|
|
.foregroundStyle(CMColors.textSecondary)
|
|
Spacer()
|
|
Text(value)
|
|
.font(CMFonts.mono(size: 13, weight: .bold))
|
|
.foregroundStyle(CMColors.text)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Share Sheet (UIKit wrapper)
|
|
|
|
struct ShareSheet: UIViewControllerRepresentable {
|
|
let items: [Any]
|
|
|
|
func makeUIViewController(context: Context) -> UIActivityViewController {
|
|
UIActivityViewController(activityItems: items, applicationActivities: nil)
|
|
}
|
|
|
|
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
|
|
}
|