290 lines
10 KiB
Swift
290 lines
10 KiB
Swift
//
|
|
// InsightDetailSheet.swift
|
|
// Andromida
|
|
//
|
|
// Detail sheet shown when tapping an insight card.
|
|
//
|
|
|
|
import SwiftUI
|
|
import Bedrock
|
|
import Charts
|
|
|
|
struct InsightDetailSheet: View {
|
|
let card: InsightCard
|
|
let store: RitualStore
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
private let tipsProvider: InsightTipsProviding = DefaultInsightTipsProvider()
|
|
|
|
private var weekOverWeekChange: Double {
|
|
store.weekOverWeekChange()
|
|
}
|
|
|
|
private var trendDirection: TrendDirection {
|
|
TrendDirection.from(percentageChange: weekOverWeekChange)
|
|
}
|
|
|
|
private var tips: [String] {
|
|
tipsProvider.tips(for: card, context: store.insightContext())
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ScrollView(.vertical, showsIndicators: false) {
|
|
VStack(alignment: .leading, spacing: Design.Spacing.xLarge) {
|
|
// Header with icon and value
|
|
headerSection
|
|
|
|
// Trend indicator (for cards with trend data)
|
|
if card.trendData != nil {
|
|
trendIndicatorSection
|
|
}
|
|
|
|
// Chart (if trend data available)
|
|
if let trendData = card.trendData, !trendData.isEmpty {
|
|
chartSection(trendData)
|
|
}
|
|
|
|
// Explanation
|
|
explanationSection
|
|
|
|
// Tips section (if any tips available)
|
|
if !tips.isEmpty {
|
|
tipsSection
|
|
}
|
|
|
|
// Breakdown (if available)
|
|
if let breakdown = card.breakdown, !breakdown.isEmpty {
|
|
breakdownSection(breakdown)
|
|
}
|
|
|
|
Spacer(minLength: Design.Spacing.xxxLarge)
|
|
}
|
|
.padding(Design.Spacing.large)
|
|
}
|
|
.background(AppSurface.primary)
|
|
.navigationTitle(card.title)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button(String(localized: "Done")) {
|
|
dismiss()
|
|
}
|
|
.foregroundStyle(AppAccent.primary)
|
|
}
|
|
}
|
|
}
|
|
.presentationDetents([.medium, .large])
|
|
.presentationDragIndicator(.visible)
|
|
}
|
|
|
|
// MARK: - Header Section
|
|
|
|
private var headerSection: some View {
|
|
VStack(spacing: Design.Spacing.medium) {
|
|
Image(systemName: card.symbolName)
|
|
.font(.system(size: Design.BaseFontSize.largeTitle * 2))
|
|
.foregroundStyle(AppAccent.primary)
|
|
.accessibilityHidden(true)
|
|
|
|
Text(card.value)
|
|
.font(.system(size: Design.BaseFontSize.largeTitle * 2, weight: .bold))
|
|
.foregroundStyle(AppTextColors.primary)
|
|
|
|
Text(card.caption)
|
|
.font(.subheadline)
|
|
.foregroundStyle(AppTextColors.secondary)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, Design.Spacing.large)
|
|
}
|
|
|
|
// MARK: - Trend Indicator Section
|
|
|
|
private var trendIndicatorSection: some View {
|
|
HStack(spacing: Design.Spacing.medium) {
|
|
// Trend direction badge
|
|
HStack(spacing: Design.Spacing.xSmall) {
|
|
Image(systemName: trendDirection.symbolName)
|
|
.font(.caption)
|
|
|
|
Text(trendDirection.accessibilityLabel)
|
|
.font(.caption)
|
|
}
|
|
.foregroundStyle(trendDirection.color)
|
|
.padding(.horizontal, Design.Spacing.medium)
|
|
.padding(.vertical, Design.Spacing.small)
|
|
.background(trendDirection.color.opacity(Design.Opacity.subtle))
|
|
.clipShape(.capsule)
|
|
|
|
// Week-over-week change
|
|
if abs(weekOverWeekChange) > 0.01 {
|
|
let changePercent = Int(abs(weekOverWeekChange) * 100)
|
|
let changeText = weekOverWeekChange >= 0
|
|
? String(localized: "+\(changePercent)% vs last week")
|
|
: String(localized: "-\(changePercent)% vs last week")
|
|
|
|
Text(changeText)
|
|
.font(.caption)
|
|
.foregroundStyle(AppTextColors.secondary)
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
|
|
// MARK: - Tips Section
|
|
|
|
private var tipsSection: some View {
|
|
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
|
Label(String(localized: "Tips"), systemImage: "lightbulb.fill")
|
|
.font(.headline)
|
|
.foregroundStyle(AppTextColors.primary)
|
|
|
|
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
|
ForEach(tips, id: \.self) { tip in
|
|
HStack(alignment: .top, spacing: Design.Spacing.small) {
|
|
Image(systemName: "chevron.right")
|
|
.font(.caption2)
|
|
.foregroundStyle(AppAccent.primary)
|
|
.padding(.top, Design.Spacing.xSmall)
|
|
|
|
Text(tip)
|
|
.font(.subheadline)
|
|
.foregroundStyle(AppTextColors.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(Design.Spacing.large)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(AppAccent.primary.opacity(Design.Opacity.subtle))
|
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
|
}
|
|
|
|
// MARK: - Explanation Section
|
|
|
|
private var explanationSection: some View {
|
|
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
|
Text(String(localized: "What this means"))
|
|
.font(.headline)
|
|
.foregroundStyle(AppTextColors.primary)
|
|
|
|
Text(card.explanation)
|
|
.font(.body)
|
|
.foregroundStyle(AppTextColors.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
.padding(Design.Spacing.large)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(AppSurface.card)
|
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
|
}
|
|
|
|
// MARK: - Chart Section
|
|
|
|
private func chartSection(_ data: [TrendDataPoint]) -> some View {
|
|
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
|
Text(String(localized: "7-Day Trend"))
|
|
.font(.headline)
|
|
.foregroundStyle(AppTextColors.primary)
|
|
|
|
Chart(data) { point in
|
|
BarMark(
|
|
x: .value("Day", point.label),
|
|
y: .value("Completion", point.value)
|
|
)
|
|
.foregroundStyle(
|
|
point.value >= 1.0 ? AppStatus.success.gradient :
|
|
point.value >= 0.5 ? AppAccent.primary.gradient :
|
|
AppTextColors.tertiary.gradient
|
|
)
|
|
.cornerRadius(Design.CornerRadius.small)
|
|
}
|
|
.chartYScale(domain: 0...1)
|
|
.chartYAxis {
|
|
AxisMarks(values: [0, 0.5, 1.0]) { value in
|
|
AxisValueLabel {
|
|
if let v = value.as(Double.self) {
|
|
Text("\(Int(v * 100))%")
|
|
.font(.caption2)
|
|
.foregroundStyle(AppTextColors.tertiary)
|
|
}
|
|
}
|
|
AxisGridLine()
|
|
.foregroundStyle(AppBorder.subtle)
|
|
}
|
|
}
|
|
.chartXAxis {
|
|
AxisMarks { value in
|
|
AxisValueLabel()
|
|
.font(.caption2)
|
|
.foregroundStyle(AppTextColors.secondary)
|
|
}
|
|
}
|
|
.frame(height: 180)
|
|
.accessibilityElement(children: .combine)
|
|
.accessibilityLabel(String(localized: "Weekly completion chart"))
|
|
}
|
|
.padding(Design.Spacing.large)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(AppSurface.card)
|
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
|
}
|
|
|
|
// MARK: - Breakdown Section
|
|
|
|
private func breakdownSection(_ breakdown: [BreakdownItem]) -> some View {
|
|
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
|
Text(String(localized: "Breakdown"))
|
|
.font(.headline)
|
|
.foregroundStyle(AppTextColors.primary)
|
|
|
|
VStack(spacing: Design.Spacing.xSmall) {
|
|
ForEach(breakdown) { item in
|
|
HStack {
|
|
Text(item.label)
|
|
.font(.subheadline)
|
|
.foregroundStyle(AppTextColors.primary)
|
|
|
|
Spacer()
|
|
|
|
Text(item.value)
|
|
.font(.subheadline)
|
|
.foregroundStyle(AppTextColors.secondary)
|
|
}
|
|
.padding(.vertical, Design.Spacing.small)
|
|
|
|
if item.id != breakdown.last?.id {
|
|
Divider()
|
|
.background(AppBorder.subtle)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(Design.Spacing.large)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(AppSurface.card)
|
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
InsightDetailSheet(
|
|
card: InsightCard(
|
|
title: "Ritual days",
|
|
value: "16",
|
|
caption: "Days on your journey",
|
|
explanation: "The total number of days you've been working on your rituals. This shows your progress through each arc, combining all active rituals.",
|
|
symbolName: "calendar",
|
|
breakdown: [
|
|
BreakdownItem(label: "Morning Clarity", value: "Day 1 of 28"),
|
|
BreakdownItem(label: "Evening Reset", value: "Day 15 of 28")
|
|
]
|
|
),
|
|
store: RitualStore.preview
|
|
)
|
|
}
|