Andromida/Andromida/App/Views/Insights/Components/InsightDetailSheet.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
)
}