feat(gamification): add streak card, focus score card, badge grid, confetti, weekly summary card with share
This commit is contained in:
parent
f6208e65d1
commit
58dde35cc9
87
ios/ChronoMind/Views/Gamification/BadgeGridView.swift
Normal file
87
ios/ChronoMind/Views/Gamification/BadgeGridView.swift
Normal file
@ -0,0 +1,87 @@
|
||||
// ── Badge Grid View ───────────────────────────────────────────
|
||||
// Shows all badges with earned/locked states
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct BadgeGridView: View {
|
||||
@ObservedObject var gamification = GamificationStore.shared
|
||||
|
||||
private var badges: [Badge] {
|
||||
BadgeDefinitions.earnedBadges(for: gamification.streak.currentStreak)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: CMSpacing.md) {
|
||||
HStack {
|
||||
Text("BADGES")
|
||||
.font(CMFonts.body(size: 11, weight: .bold))
|
||||
.foregroundStyle(CMColors.textMuted)
|
||||
.tracking(1.5)
|
||||
Spacer()
|
||||
let earned = badges.filter(\.isEarned).count
|
||||
Text("\(earned)/\(badges.count)")
|
||||
.font(CMFonts.mono(size: 12))
|
||||
.foregroundStyle(CMColors.textMuted)
|
||||
}
|
||||
|
||||
LazyVGrid(columns: [
|
||||
GridItem(.flexible(), spacing: CMSpacing.md),
|
||||
GridItem(.flexible(), spacing: CMSpacing.md),
|
||||
GridItem(.flexible(), spacing: CMSpacing.md),
|
||||
GridItem(.flexible(), spacing: CMSpacing.md),
|
||||
], spacing: CMSpacing.md) {
|
||||
ForEach(badges) { badge in
|
||||
BadgeCell(badge: badge)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(CMSpacing.lg)
|
||||
.background(CMColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: CMRadius.lg))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: CMRadius.lg)
|
||||
.stroke(CMColors.border, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Badge Cell
|
||||
|
||||
struct BadgeCell: View {
|
||||
let badge: Badge
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: CMSpacing.xs) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(badge.isEarned ? tierColor(badge.tier).opacity(0.2) : CMColors.border.opacity(0.3))
|
||||
.frame(width: 48, height: 48)
|
||||
|
||||
Image(systemName: badge.icon)
|
||||
.font(.system(size: 20))
|
||||
.foregroundStyle(badge.isEarned ? tierColor(badge.tier) : CMColors.textMuted.opacity(0.4))
|
||||
}
|
||||
|
||||
Text(badge.title)
|
||||
.font(CMFonts.body(size: 9, weight: .medium))
|
||||
.foregroundStyle(badge.isEarned ? CMColors.text : CMColors.textMuted)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
|
||||
Text("\(badge.requirement)d")
|
||||
.font(CMFonts.mono(size: 8))
|
||||
.foregroundStyle(CMColors.textMuted)
|
||||
}
|
||||
.opacity(badge.isEarned ? 1.0 : 0.5)
|
||||
}
|
||||
|
||||
private func tierColor(_ tier: BadgeTier) -> Color {
|
||||
switch tier {
|
||||
case .bronze: return Color(red: 0.8, green: 0.5, blue: 0.2)
|
||||
case .silver: return Color(red: 0.75, green: 0.75, blue: 0.8)
|
||||
case .gold: return Color(red: 1.0, green: 0.84, blue: 0.0)
|
||||
case .platinum: return Color(red: 0.7, green: 0.85, blue: 0.95)
|
||||
case .diamond: return Color(red: 0.6, green: 0.8, blue: 1.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
192
ios/ChronoMind/Views/Gamification/ConfettiView.swift
Normal file
192
ios/ChronoMind/Views/Gamification/ConfettiView.swift
Normal file
@ -0,0 +1,192 @@
|
||||
// ── Confetti Celebration ───────────────────────────────────────
|
||||
// Gentle confetti animation for streak milestones and badge unlocks
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ConfettiView: View {
|
||||
let colors: [Color]
|
||||
@State private var particles: [ConfettiParticle] = []
|
||||
@State private var isActive = false
|
||||
|
||||
init(colors: [Color] = [
|
||||
CMColors.accent, CMColors.success, CMColors.important,
|
||||
CMColors.standard, CMColors.critical, CMColors.gentle,
|
||||
]) {
|
||||
self.colors = colors
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
ZStack {
|
||||
ForEach(particles) { particle in
|
||||
Circle()
|
||||
.fill(particle.color)
|
||||
.frame(width: particle.size, height: particle.size)
|
||||
.position(particle.position)
|
||||
.opacity(particle.opacity)
|
||||
.rotationEffect(.degrees(particle.rotation))
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
generateParticles(in: geo.size)
|
||||
withAnimation(.easeOut(duration: 2.5)) {
|
||||
isActive = true
|
||||
animateParticles(in: geo.size)
|
||||
}
|
||||
// Fade out after animation
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
||||
withAnimation(.easeOut(duration: 0.5)) {
|
||||
for i in particles.indices {
|
||||
particles[i].opacity = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
|
||||
private func generateParticles(in size: CGSize) {
|
||||
particles = (0..<40).map { _ in
|
||||
ConfettiParticle(
|
||||
id: UUID().uuidString,
|
||||
color: colors.randomElement() ?? .white,
|
||||
size: CGFloat.random(in: 4...10),
|
||||
position: CGPoint(
|
||||
x: CGFloat.random(in: 0...size.width),
|
||||
y: -20
|
||||
),
|
||||
targetY: CGFloat.random(in: size.height * 0.3...size.height),
|
||||
rotation: Double.random(in: 0...360),
|
||||
opacity: 1.0,
|
||||
delay: Double.random(in: 0...0.5)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func animateParticles(in size: CGSize) {
|
||||
for i in particles.indices {
|
||||
let targetX = particles[i].position.x + CGFloat.random(in: -60...60)
|
||||
let targetY = particles[i].targetY
|
||||
|
||||
withAnimation(
|
||||
.interpolatingSpring(stiffness: 20, damping: 8)
|
||||
.delay(particles[i].delay)
|
||||
) {
|
||||
particles[i].position = CGPoint(x: targetX, y: targetY)
|
||||
particles[i].rotation += Double.random(in: 180...720)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ConfettiParticle: Identifiable {
|
||||
let id: String
|
||||
let color: Color
|
||||
let size: CGFloat
|
||||
var position: CGPoint
|
||||
let targetY: CGFloat
|
||||
var rotation: Double
|
||||
var opacity: Double
|
||||
let delay: Double
|
||||
}
|
||||
|
||||
// MARK: - Badge Celebration Overlay
|
||||
|
||||
struct BadgeCelebrationOverlay: View {
|
||||
let badge: Badge
|
||||
let onDismiss: () -> Void
|
||||
@State private var scale: CGFloat = 0.5
|
||||
@State private var opacity: Double = 0
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Dimmed background
|
||||
Color.black.opacity(0.6)
|
||||
.ignoresSafeArea()
|
||||
.onTapGesture { onDismiss() }
|
||||
|
||||
// Confetti
|
||||
ConfettiView()
|
||||
|
||||
// Badge card
|
||||
VStack(spacing: CMSpacing.xl) {
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: CMSpacing.lg) {
|
||||
// Badge icon
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(tierGradient(badge.tier))
|
||||
.frame(width: 100, height: 100)
|
||||
.shadow(color: tierColor(badge.tier).opacity(0.5), radius: 20)
|
||||
|
||||
Image(systemName: badge.icon)
|
||||
.font(.system(size: 44))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
|
||||
Text("Badge Unlocked!")
|
||||
.font(CMFonts.body(size: 14, weight: .medium))
|
||||
.foregroundStyle(CMColors.textSecondary)
|
||||
|
||||
Text(badge.title)
|
||||
.font(CMFonts.display(size: 28))
|
||||
.foregroundStyle(CMColors.text)
|
||||
|
||||
Text(badge.description)
|
||||
.font(CMFonts.body(size: 16))
|
||||
.foregroundStyle(CMColors.textSecondary)
|
||||
|
||||
Button {
|
||||
HapticEngine.tap()
|
||||
onDismiss()
|
||||
} label: {
|
||||
Text("Awesome!")
|
||||
.font(CMFonts.body(size: 16, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, CMSpacing.md)
|
||||
.background(tierColor(badge.tier))
|
||||
.clipShape(RoundedRectangle(cornerRadius: CMRadius.md))
|
||||
}
|
||||
.padding(.horizontal, CMSpacing.xxl)
|
||||
}
|
||||
.padding(CMSpacing.xxl)
|
||||
.background(CMColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: CMRadius.xl))
|
||||
.padding(.horizontal, CMSpacing.xl)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.scaleEffect(scale)
|
||||
.opacity(opacity)
|
||||
}
|
||||
.onAppear {
|
||||
HapticEngine.fire(urgency: .standard)
|
||||
withAnimation(.spring(response: 0.5, dampingFraction: 0.7)) {
|
||||
scale = 1.0
|
||||
opacity = 1.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func tierColor(_ tier: BadgeTier) -> Color {
|
||||
switch tier {
|
||||
case .bronze: return Color(red: 0.8, green: 0.5, blue: 0.2)
|
||||
case .silver: return Color(red: 0.75, green: 0.75, blue: 0.8)
|
||||
case .gold: return Color(red: 1.0, green: 0.84, blue: 0.0)
|
||||
case .platinum: return Color(red: 0.7, green: 0.85, blue: 0.95)
|
||||
case .diamond: return Color(red: 0.6, green: 0.8, blue: 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
private func tierGradient(_ tier: BadgeTier) -> LinearGradient {
|
||||
let color = tierColor(tier)
|
||||
return LinearGradient(
|
||||
colors: [color, color.opacity(0.7)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
}
|
||||
}
|
||||
117
ios/ChronoMind/Views/Gamification/FocusScoreCard.swift
Normal file
117
ios/ChronoMind/Views/Gamification/FocusScoreCard.swift
Normal file
@ -0,0 +1,117 @@
|
||||
// ── Focus Score Card ──────────────────────────────────────────
|
||||
// Weekly focus score with breakdown ring and metrics
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct FocusScoreCard: View {
|
||||
let score: FocusScore
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: CMSpacing.md) {
|
||||
HStack {
|
||||
Text("FOCUS SCORE")
|
||||
.font(CMFonts.body(size: 11, weight: .bold))
|
||||
.foregroundStyle(CMColors.textMuted)
|
||||
.tracking(1.5)
|
||||
Spacer()
|
||||
Text(score.weekLabel)
|
||||
.font(CMFonts.body(size: 11))
|
||||
.foregroundStyle(CMColors.textMuted)
|
||||
}
|
||||
|
||||
HStack(spacing: CMSpacing.xl) {
|
||||
// Score ring
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(CMColors.border, lineWidth: 6)
|
||||
.frame(width: 80, height: 80)
|
||||
|
||||
Circle()
|
||||
.trim(from: 0, to: CGFloat(score.score) / 100)
|
||||
.stroke(gradeColor, style: StrokeStyle(lineWidth: 6, lineCap: .round))
|
||||
.frame(width: 80, height: 80)
|
||||
.rotationEffect(.degrees(-90))
|
||||
|
||||
VStack(spacing: 0) {
|
||||
Text("\(score.score)")
|
||||
.font(CMFonts.mono(size: 24, weight: .bold))
|
||||
.foregroundStyle(CMColors.text)
|
||||
Text(score.grade.rawValue)
|
||||
.font(CMFonts.body(size: 10, weight: .medium))
|
||||
.foregroundStyle(gradeColor)
|
||||
}
|
||||
}
|
||||
|
||||
// Metrics breakdown
|
||||
VStack(alignment: .leading, spacing: CMSpacing.sm) {
|
||||
MetricRow(
|
||||
icon: "checkmark.circle.fill",
|
||||
label: "On-time",
|
||||
value: String(format: "%.0f%%", score.onTimeRate * 100),
|
||||
color: CMColors.success
|
||||
)
|
||||
MetricRow(
|
||||
icon: "target",
|
||||
label: "Focus",
|
||||
value: String(format: "%.1fh", score.focusHours),
|
||||
color: CMColors.accent
|
||||
)
|
||||
MetricRow(
|
||||
icon: "timer",
|
||||
label: "Completed",
|
||||
value: "\(score.completedCount)",
|
||||
color: CMColors.text
|
||||
)
|
||||
MetricRow(
|
||||
icon: "moon.zzz.fill",
|
||||
label: "Avg snooze",
|
||||
value: String(format: "%.1f", score.avgSnoozeCount),
|
||||
color: CMColors.important
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(CMSpacing.lg)
|
||||
.background(CMColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: CMRadius.lg))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: CMRadius.lg)
|
||||
.stroke(CMColors.border, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
private var gradeColor: Color {
|
||||
switch score.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: - Metric Row
|
||||
|
||||
private struct MetricRow: View {
|
||||
let icon: String
|
||||
let label: String
|
||||
let value: String
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: CMSpacing.sm) {
|
||||
Image(systemName: icon)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(color)
|
||||
.frame(width: 14)
|
||||
Text(label)
|
||||
.font(CMFonts.body(size: 12))
|
||||
.foregroundStyle(CMColors.textSecondary)
|
||||
Spacer()
|
||||
Text(value)
|
||||
.font(CMFonts.mono(size: 12, weight: .semibold))
|
||||
.foregroundStyle(CMColors.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
117
ios/ChronoMind/Views/Gamification/StreakCard.swift
Normal file
117
ios/ChronoMind/Views/Gamification/StreakCard.swift
Normal file
@ -0,0 +1,117 @@
|
||||
// ── Streak Card ───────────────────────────────────────────────
|
||||
// Shows current streak with flame animation and next badge progress
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct StreakCard: View {
|
||||
@ObservedObject var gamification = GamificationStore.shared
|
||||
@State private var flameScale: CGFloat = 1.0
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: CMSpacing.md) {
|
||||
HStack {
|
||||
Text("STREAK")
|
||||
.font(CMFonts.body(size: 11, weight: .bold))
|
||||
.foregroundStyle(CMColors.textMuted)
|
||||
.tracking(1.5)
|
||||
Spacer()
|
||||
if gamification.streak.currentStreak > 0 {
|
||||
Text("\(gamification.streak.longestStreak) best")
|
||||
.font(CMFonts.body(size: 11))
|
||||
.foregroundStyle(CMColors.textMuted)
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: CMSpacing.lg) {
|
||||
// Flame + count
|
||||
VStack(spacing: CMSpacing.xs) {
|
||||
Image(systemName: gamification.streak.currentStreak > 0 ? "flame.fill" : "flame")
|
||||
.font(.system(size: 36))
|
||||
.foregroundStyle(flameColor)
|
||||
.scaleEffect(flameScale)
|
||||
.animation(
|
||||
gamification.streak.currentStreak >= 7
|
||||
? .easeInOut(duration: 1.2).repeatForever(autoreverses: true)
|
||||
: .default,
|
||||
value: flameScale
|
||||
)
|
||||
.onAppear {
|
||||
if gamification.streak.currentStreak >= 7 {
|
||||
flameScale = 1.15
|
||||
}
|
||||
}
|
||||
|
||||
Text("\(gamification.streak.currentStreak)")
|
||||
.font(CMFonts.mono(size: 32, weight: .bold))
|
||||
.foregroundStyle(CMColors.text)
|
||||
|
||||
Text(gamification.streak.currentStreak == 1 ? "day" : "days")
|
||||
.font(CMFonts.body(size: 12))
|
||||
.foregroundStyle(CMColors.textSecondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Next badge progress
|
||||
if let nextBadge = BadgeDefinitions.nextBadge(for: gamification.streak.currentStreak) {
|
||||
VStack(alignment: .trailing, spacing: CMSpacing.sm) {
|
||||
HStack(spacing: CMSpacing.xs) {
|
||||
Image(systemName: nextBadge.icon)
|
||||
.font(.caption)
|
||||
.foregroundStyle(tierColor(nextBadge.tier))
|
||||
Text(nextBadge.title)
|
||||
.font(CMFonts.body(size: 12, weight: .medium))
|
||||
.foregroundStyle(CMColors.textSecondary)
|
||||
}
|
||||
|
||||
let progress = Double(gamification.streak.currentStreak) / Double(nextBadge.requirement)
|
||||
ProgressView(value: progress)
|
||||
.tint(tierColor(nextBadge.tier))
|
||||
.frame(width: 120)
|
||||
|
||||
Text("\(nextBadge.requirement - gamification.streak.currentStreak) days to go")
|
||||
.font(CMFonts.body(size: 11))
|
||||
.foregroundStyle(CMColors.textMuted)
|
||||
}
|
||||
} else {
|
||||
// All badges earned
|
||||
VStack(spacing: CMSpacing.xs) {
|
||||
Image(systemName: "crown.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(Color(red: 1, green: 0.84, blue: 0))
|
||||
Text("All badges earned!")
|
||||
.font(CMFonts.body(size: 12, weight: .medium))
|
||||
.foregroundStyle(CMColors.textSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(CMSpacing.lg)
|
||||
.background(CMColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: CMRadius.lg))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: CMRadius.lg)
|
||||
.stroke(CMColors.border, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
private var flameColor: Color {
|
||||
switch gamification.streak.currentStreak {
|
||||
case 0: return CMColors.textMuted
|
||||
case 1...6: return CMColors.important
|
||||
case 7...29: return Color(red: 1.0, green: 0.6, blue: 0.0)
|
||||
case 30...99: return CMColors.critical
|
||||
default: return Color(red: 1.0, green: 0.2, blue: 0.6) // hot pink for 100+
|
||||
}
|
||||
}
|
||||
|
||||
private func tierColor(_ tier: BadgeTier) -> Color {
|
||||
switch tier {
|
||||
case .bronze: return Color(red: 0.8, green: 0.5, blue: 0.2)
|
||||
case .silver: return Color(red: 0.75, green: 0.75, blue: 0.8)
|
||||
case .gold: return Color(red: 1.0, green: 0.84, blue: 0.0)
|
||||
case .platinum: return Color(red: 0.7, green: 0.85, blue: 0.95)
|
||||
case .diamond: return Color(red: 0.6, green: 0.8, blue: 1.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
208
ios/ChronoMind/Views/Gamification/WeeklySummaryCard.swift
Normal file
208
ios/ChronoMind/Views/Gamification/WeeklySummaryCard.swift
Normal file
@ -0,0 +1,208 @@
|
||||
// ── 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) {}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user