From 58dde35cc99346311422bb93c9632e73737d0986 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Fri, 27 Feb 2026 22:13:44 -0800 Subject: [PATCH] feat(gamification): add streak card, focus score card, badge grid, confetti, weekly summary card with share --- .../Views/Gamification/BadgeGridView.swift | 87 ++++++++ .../Views/Gamification/ConfettiView.swift | 192 ++++++++++++++++ .../Views/Gamification/FocusScoreCard.swift | 117 ++++++++++ .../Views/Gamification/StreakCard.swift | 117 ++++++++++ .../Gamification/WeeklySummaryCard.swift | 208 ++++++++++++++++++ 5 files changed, 721 insertions(+) create mode 100644 ios/ChronoMind/Views/Gamification/BadgeGridView.swift create mode 100644 ios/ChronoMind/Views/Gamification/ConfettiView.swift create mode 100644 ios/ChronoMind/Views/Gamification/FocusScoreCard.swift create mode 100644 ios/ChronoMind/Views/Gamification/StreakCard.swift create mode 100644 ios/ChronoMind/Views/Gamification/WeeklySummaryCard.swift diff --git a/ios/ChronoMind/Views/Gamification/BadgeGridView.swift b/ios/ChronoMind/Views/Gamification/BadgeGridView.swift new file mode 100644 index 0000000..ec1e0a6 --- /dev/null +++ b/ios/ChronoMind/Views/Gamification/BadgeGridView.swift @@ -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) + } + } +} diff --git a/ios/ChronoMind/Views/Gamification/ConfettiView.swift b/ios/ChronoMind/Views/Gamification/ConfettiView.swift new file mode 100644 index 0000000..9cb4b44 --- /dev/null +++ b/ios/ChronoMind/Views/Gamification/ConfettiView.swift @@ -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 + ) + } +} diff --git a/ios/ChronoMind/Views/Gamification/FocusScoreCard.swift b/ios/ChronoMind/Views/Gamification/FocusScoreCard.swift new file mode 100644 index 0000000..c5f1756 --- /dev/null +++ b/ios/ChronoMind/Views/Gamification/FocusScoreCard.swift @@ -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) + } + } +} diff --git a/ios/ChronoMind/Views/Gamification/StreakCard.swift b/ios/ChronoMind/Views/Gamification/StreakCard.swift new file mode 100644 index 0000000..7571b73 --- /dev/null +++ b/ios/ChronoMind/Views/Gamification/StreakCard.swift @@ -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) + } + } +} diff --git a/ios/ChronoMind/Views/Gamification/WeeklySummaryCard.swift b/ios/ChronoMind/Views/Gamification/WeeklySummaryCard.swift new file mode 100644 index 0000000..d2444cf --- /dev/null +++ b/ios/ChronoMind/Views/Gamification/WeeklySummaryCard.swift @@ -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) {} +}