feat(gamification): add streak card, focus score card, badge grid, confetti, weekly summary card with share

This commit is contained in:
saravanakumardb1 2026-02-27 22:13:44 -08:00
parent f6208e65d1
commit 58dde35cc9
5 changed files with 721 additions and 0 deletions

View 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)
}
}
}

View 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
)
}
}

View 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)
}
}
}

View 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)
}
}
}

View 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) {}
}