Redesign Activity tab with animated rings, monthly calendar, and global stats
Some checks failed
Some checks failed
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ActivityRingView: View {
|
||||
let moveValue: Double
|
||||
let moveGoal: Double
|
||||
let exerciseValue: Double
|
||||
let exerciseGoal: Double
|
||||
let standValue: Double
|
||||
let standGoal: Double
|
||||
|
||||
@State private var animatedProgress: Bool = false
|
||||
|
||||
private let ringWidth: CGFloat = 14
|
||||
private let spacing: CGFloat = 8
|
||||
private let size: CGFloat = 160
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
ZStack {
|
||||
moveRing
|
||||
exerciseRing
|
||||
standRing
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
.onAppear { withAnimation(.easeInOut(duration: 1.2)) { animatedProgress = true } }
|
||||
|
||||
HStack(spacing: 0) {
|
||||
ringLegend(value: Int(moveValue), unit: "kcal", label: L10n.health.move, color: .red)
|
||||
ringLegend(value: Int(exerciseValue), unit: "min", label: L10n.health.exercise, color: .green)
|
||||
ringLegend(value: Int(standValue), unit: "hrs", label: L10n.health.stand, color: .cyan)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var moveRing: some View {
|
||||
ringTrack(progress: animatedProgress ? min(moveValue / max(moveGoal, 1), 1.0) : 0,
|
||||
color: .red,
|
||||
radius: size / 2)
|
||||
}
|
||||
|
||||
private var exerciseRing: some View {
|
||||
ringTrack(progress: animatedProgress ? min(exerciseValue / max(exerciseGoal, 1), 1.0) : 0,
|
||||
color: .green,
|
||||
radius: size / 2 - ringWidth - spacing)
|
||||
}
|
||||
|
||||
private var standRing: some View {
|
||||
ringTrack(progress: animatedProgress ? min(standValue / max(standGoal, 1), 1.0) : 0,
|
||||
color: .cyan,
|
||||
radius: size / 2 - 2 * (ringWidth + spacing))
|
||||
}
|
||||
|
||||
private func ringTrack(progress: Double, color: Color, radius: CGFloat) -> some View {
|
||||
Circle()
|
||||
.trim(from: 0, to: progress)
|
||||
.stroke(color, style: StrokeStyle(lineWidth: ringWidth, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
.frame(width: radius * 2, height: radius * 2)
|
||||
.background(
|
||||
Circle()
|
||||
.stroke(color.opacity(0.15), lineWidth: ringWidth)
|
||||
.frame(width: radius * 2, height: radius * 2)
|
||||
)
|
||||
}
|
||||
|
||||
private func ringLegend(value: Int, unit: String, label: LocalizedStringResource, color: Color) -> some View {
|
||||
VStack(spacing: 2) {
|
||||
Text("\(value)")
|
||||
.font(.system(size: 18, weight: .bold, design: .rounded))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(color)
|
||||
Text(unit)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(color.opacity(0.7))
|
||||
Text(label)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import SwiftUI
|
||||
|
||||
struct GlobalStatsCard: View {
|
||||
let totalSessions: Int
|
||||
let totalMinutes: Int
|
||||
let totalCalories: Double
|
||||
let currentStreak: Int
|
||||
let longestStreak: Int
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
HStack(spacing: 0) {
|
||||
globalStat(value: "\(totalSessions)", label: L10n.activity.workouts, color: Theme.brand, icon: "flame.fill")
|
||||
globalStat(value: "\(totalMinutes)", label: L10n.activity.minutes, color: Theme.rest, icon: "clock.fill")
|
||||
globalStat(value: "\(Int(totalCalories))", label: "kcal", color: Theme.success, icon: "bolt.fill")
|
||||
}
|
||||
|
||||
Divider().opacity(0.3)
|
||||
|
||||
HStack(spacing: 0) {
|
||||
streakStat(value: currentStreak, label: L10n.activity.currentStreak, color: Theme.brand)
|
||||
streakStat(value: longestStreak, label: L10n.activity.bestStreak, color: Theme.success)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.glassCard()
|
||||
}
|
||||
|
||||
private func globalStat(value: String, label: LocalizedStringResource, color: Color, icon: String) -> some View {
|
||||
VStack(spacing: 4) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(color)
|
||||
Text(value)
|
||||
.font(.system(size: 22, weight: .bold, design: .rounded))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.primary)
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
private func streakStat(value: Int, label: LocalizedStringResource, color: Color) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "flame.fill")
|
||||
.font(.system(size: 20))
|
||||
.foregroundStyle(color)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(alignment: .lastTextBaseline, spacing: 2) {
|
||||
Text("\(value)")
|
||||
.font(.system(size: 24, weight: .black, design: .rounded))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(color)
|
||||
Text(L10n.activity.days)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(label)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import SwiftUI
|
||||
|
||||
struct WeeklySection: View {
|
||||
let snapshot: HealthSnapshot?
|
||||
let weeklyWorkouts: Int
|
||||
let weeklyMinutes: Int
|
||||
let weeklyCalories: Double
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text(L10n.home.thisWeek)
|
||||
.font(.title3.weight(.bold))
|
||||
.padding(.horizontal)
|
||||
|
||||
ActivityRingView(
|
||||
moveValue: snapshot?.activeCaloricBurn ?? weeklyCalories,
|
||||
moveGoal: 600,
|
||||
exerciseValue: snapshot?.exerciseMinutes ?? Double(weeklyMinutes),
|
||||
exerciseGoal: 30,
|
||||
standValue: Double(snapshot?.standHours ?? 0),
|
||||
standGoal: 12
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
StatBadge(label: L10n.activity.workouts, value: "\(weeklyWorkouts)", color: Theme.brand, icon: "flame.fill")
|
||||
StatBadge(label: L10n.activity.minutes, value: "\(weeklyMinutes)", color: Theme.rest, icon: "clock.fill")
|
||||
StatBadge(label: "kcal", value: "\(Int(weeklyCalories))", color: Theme.success, icon: "bolt.fill")
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import SwiftUI
|
||||
|
||||
struct WorkoutCalendarView: View {
|
||||
let activeDays: Set<DateComponents>
|
||||
|
||||
@State private var displayedMonth = Date()
|
||||
|
||||
private let calendar = Calendar.current
|
||||
private let columns = Array(repeating: GridItem(.flexible(), spacing: 4), count: 7)
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
monthHeader
|
||||
weekdayHeaders
|
||||
dayGrid
|
||||
}
|
||||
.padding(16)
|
||||
.glassCard()
|
||||
}
|
||||
|
||||
private var monthHeader: some View {
|
||||
HStack {
|
||||
Button { withAnimation { displayedMonth = calendar.date(byAdding: .month, value: -1, to: displayedMonth) ?? displayedMonth } } label: {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(monthName)
|
||||
.font(.headline)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button { withAnimation { displayedMonth = calendar.date(byAdding: .month, value: 1, to: displayedMonth) ?? displayedMonth } } label: {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var weekdayHeaders: some View {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(weekdaySymbols, id: \.self) { symbol in
|
||||
Text(symbol)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var dayGrid: some View {
|
||||
let days = daysInMonth()
|
||||
let prefix = firstWeekdayOffset()
|
||||
|
||||
return LazyVGrid(columns: columns, spacing: 4) {
|
||||
ForEach(0..<prefix, id: \.self) { _ in
|
||||
Color.clear.frame(height: 36)
|
||||
}
|
||||
ForEach(days, id: \.self) { day in
|
||||
dayCell(day)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func dayCell(_ day: Date) -> some View {
|
||||
let isToday = calendar.isDateInToday(day)
|
||||
let isActive = activeDays.contains(calendar.dateComponents([.year, .month, .day], from: day))
|
||||
let dayNum = calendar.component(.day, from: day)
|
||||
|
||||
return ZStack {
|
||||
if isActive {
|
||||
Circle()
|
||||
.fill(Theme.brand.gradient)
|
||||
.frame(width: 32, height: 32)
|
||||
} else if isToday {
|
||||
Circle()
|
||||
.stroke(Theme.brand.opacity(0.5), lineWidth: 1.5)
|
||||
.frame(width: 32, height: 32)
|
||||
}
|
||||
|
||||
Text("\(dayNum)")
|
||||
.font(.system(size: 14, weight: isActive ? .bold : .regular, design: .rounded))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(isActive ? .white : (isToday ? Theme.brand : .primary))
|
||||
}
|
||||
.frame(height: 36)
|
||||
}
|
||||
|
||||
private var monthName: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale.current
|
||||
formatter.dateFormat = "MMMM yyyy"
|
||||
let str = formatter.string(from: displayedMonth)
|
||||
return str.prefix(1).uppercased() + str.dropFirst()
|
||||
}
|
||||
|
||||
private var weekdaySymbols: [String] {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale.current
|
||||
let symbols = formatter.veryShortWeekdaySymbols ?? []
|
||||
let first = calendar.firstWeekday
|
||||
let reordered = Array(symbols[(first - 1)..<symbols.count]) + symbols[0..<(first - 1)]
|
||||
return reordered
|
||||
}
|
||||
|
||||
private func daysInMonth() -> [Date] {
|
||||
guard let interval = calendar.dateInterval(of: .month, for: displayedMonth) else { return [] }
|
||||
var days: [Date] = []
|
||||
var current = interval.start
|
||||
while current < interval.end {
|
||||
days.append(current)
|
||||
current = calendar.date(byAdding: .day, value: 1, to: current) ?? current
|
||||
}
|
||||
return days
|
||||
}
|
||||
|
||||
private func firstWeekdayOffset() -> Int {
|
||||
guard let interval = calendar.dateInterval(of: .month, for: displayedMonth),
|
||||
let firstDay = calendar.dateInterval(of: .weekOfYear, for: interval.start)?.start else { return 0 }
|
||||
let diff = calendar.dateComponents([.day], from: firstDay, to: interval.start).day ?? 0
|
||||
return max(0, diff)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
/// Activity tab — streak, workout history, HealthKit rings summary.
|
||||
struct ActivityTab: View {
|
||||
@Query(sort: \WorkoutSession.completedAt, order: .reverse)
|
||||
private var sessions: [WorkoutSession]
|
||||
@@ -12,37 +11,35 @@ struct ActivityTab: View {
|
||||
private var streak: (current: Int, longest: Int) { computeStreak(from: sessions) }
|
||||
private var snapshot: HealthSnapshot? { snapshots.first }
|
||||
private var weeklyCount: Int { countThisWeek(sessions) }
|
||||
private var activeDays: Set<DateComponents> {
|
||||
let calendar = Calendar.current
|
||||
return Set(sessions.map { calendar.dateComponents([.year, .month, .day], from: $0.completedAt) })
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
VStack(spacing: 24) {
|
||||
|
||||
// ── Streak Banner ──────────────────────────────
|
||||
StreakBanner(current: streak.current, longest: streak.longest)
|
||||
GlobalStatsCard(
|
||||
totalSessions: sessions.count,
|
||||
totalMinutes: totalMinutes,
|
||||
totalCalories: totalCalories,
|
||||
currentStreak: streak.current,
|
||||
longestStreak: streak.longest
|
||||
)
|
||||
.padding(.horizontal)
|
||||
|
||||
WeeklySection(
|
||||
snapshot: snapshot,
|
||||
weeklyWorkouts: weeklyCount,
|
||||
weeklyMinutes: weeklyMinutes,
|
||||
weeklyCalories: weeklyCalories
|
||||
)
|
||||
|
||||
WorkoutCalendarView(activeDays: activeDays)
|
||||
.padding(.horizontal)
|
||||
|
||||
// ── HealthKit Rings ────────────────────────────
|
||||
if let snap = snapshot {
|
||||
HealthRingsCard(snapshot: snap)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
// ── Weekly Summary ─────────────────────────────
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(L10n.home.thisWeek)
|
||||
.font(.title3.weight(.bold))
|
||||
.padding(.horizontal)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
StatBadge(label: L10n.activity.workouts, value: "\(weeklyCount)", color: Theme.brand, icon: "flame.fill")
|
||||
StatBadge(label: L10n.activity.minutes, value: "\(weeklyMinutes)", color: Theme.rest, icon: "clock.fill")
|
||||
StatBadge(label: L10n.activity.calories, value: "\(Int(weeklyCalories))", color: Theme.success, icon: "bolt.fill")
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
// ── Workout History ────────────────────────────
|
||||
if !sessions.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(L10n.activity.history)
|
||||
@@ -72,6 +69,14 @@ struct ActivityTab: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var totalMinutes: Int {
|
||||
sessions.reduce(0) { $0 + $1.durationSeconds / 60 }
|
||||
}
|
||||
|
||||
private var totalCalories: Double {
|
||||
sessions.reduce(0) { $0 + $1.caloriesBurned }
|
||||
}
|
||||
|
||||
private var weeklyMinutes: Int {
|
||||
countThisWeek(sessions, value: { $0.durationSeconds / 60 })
|
||||
}
|
||||
@@ -82,139 +87,13 @@ struct ActivityTab: View {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Sub-components ───────────────────────────────────────────────
|
||||
|
||||
struct StreakBanner: View {
|
||||
let current: Int
|
||||
let longest: Int
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
// Current streak
|
||||
VStack(spacing: 4) {
|
||||
HStack(alignment: .lastTextBaseline, spacing: 4) {
|
||||
Text("\(current)")
|
||||
.font(.system(size: 52, weight: .black, design: .rounded))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(Theme.brand)
|
||||
Text(L10n.activity.days)
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(L10n.activity.currentStreak)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
Divider().frame(height: 50)
|
||||
|
||||
// Longest streak
|
||||
VStack(spacing: 4) {
|
||||
HStack(alignment: .lastTextBaseline, spacing: 4) {
|
||||
Text("\(longest)")
|
||||
.font(.system(size: 52, weight: .black, design: .rounded))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(Theme.success)
|
||||
Text(L10n.activity.days)
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(L10n.activity.bestStreak)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.padding(.vertical, 16)
|
||||
.glassCard()
|
||||
}
|
||||
}
|
||||
|
||||
struct HealthRingsCard: View {
|
||||
let snapshot: HealthSnapshot
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack {
|
||||
Image(systemName: "heart.fill")
|
||||
.foregroundStyle(.red)
|
||||
Text(L10n.health.appleHealth)
|
||||
.font(.headline.weight(.semibold))
|
||||
Spacer()
|
||||
Text(L10n.health.today)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
HealthRingStat(
|
||||
label: L10n.health.move,
|
||||
value: "\(Int(snapshot.activeCaloricBurn))",
|
||||
unit: "kcal",
|
||||
color: .red
|
||||
)
|
||||
HealthRingStat(
|
||||
label: L10n.health.exercise,
|
||||
value: "\(Int(snapshot.exerciseMinutes))",
|
||||
unit: "min",
|
||||
color: .green
|
||||
)
|
||||
HealthRingStat(
|
||||
label: L10n.health.stand,
|
||||
value: "\(snapshot.standHours)",
|
||||
unit: "hrs",
|
||||
color: .cyan
|
||||
)
|
||||
}
|
||||
|
||||
if let hr = snapshot.restingHeartRate {
|
||||
Divider()
|
||||
HStack {
|
||||
Label("\(Int(hr)) bpm", systemImage: "waveform.path.ecg")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text(L10n.health.restingHR)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.glassCard()
|
||||
}
|
||||
}
|
||||
|
||||
struct HealthRingStat: View {
|
||||
let label: LocalizedStringResource
|
||||
let value: String
|
||||
let unit: String
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 4) {
|
||||
Text(value)
|
||||
.font(.system(size: 24, weight: .bold, design: .rounded))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(color)
|
||||
Text(unit)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(color.opacity(0.7))
|
||||
Text(label)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
// ─── Sub-components (kept for history list) ─────────────────────
|
||||
|
||||
struct SessionHistoryRow: View {
|
||||
let session: WorkoutSession
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 14) {
|
||||
// Zone indicator
|
||||
Circle()
|
||||
.fill(Theme.zoneColor(session.bodyZone))
|
||||
.frame(width: 10, height: 10)
|
||||
|
||||
Reference in New Issue
Block a user