Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
576afad258
commit
1f55b1b1f2
@ -1,8 +1,26 @@
|
||||
import Foundation
|
||||
|
||||
/// Identifies each type of insight card for ordering and identification.
|
||||
enum InsightCardType: String, CaseIterable, Codable {
|
||||
case active
|
||||
case streak
|
||||
case habitsToday
|
||||
case completion
|
||||
case daysActive
|
||||
case sevenDayAvg
|
||||
case totalCheckIns
|
||||
case bestRitual
|
||||
|
||||
/// Default order for cards (prioritizes motivation → action → context → history)
|
||||
static var defaultOrder: [InsightCardType] {
|
||||
[.streak, .completion, .habitsToday, .sevenDayAvg, .active, .daysActive, .bestRitual, .totalCheckIns]
|
||||
}
|
||||
}
|
||||
|
||||
/// A single insight metric displayed on the Insights tab.
|
||||
struct InsightCard: Identifiable {
|
||||
let id: UUID
|
||||
let type: InsightCardType
|
||||
let title: String
|
||||
let value: String
|
||||
let caption: String // Short description shown on card
|
||||
@ -13,6 +31,7 @@ struct InsightCard: Identifiable {
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
type: InsightCardType,
|
||||
title: String,
|
||||
value: String,
|
||||
caption: String,
|
||||
@ -22,6 +41,7 @@ struct InsightCard: Identifiable {
|
||||
trendData: [TrendDataPoint]? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.type = type
|
||||
self.title = title
|
||||
self.value = value
|
||||
self.caption = caption
|
||||
|
||||
@ -479,8 +479,10 @@ final class RitualStore: RitualStoreProviding {
|
||||
return best
|
||||
}()
|
||||
|
||||
return [
|
||||
InsightCard(
|
||||
// Build cards dictionary by type
|
||||
let cardsByType: [InsightCardType: InsightCard] = [
|
||||
.active: InsightCard(
|
||||
type: .active,
|
||||
title: String(localized: "Active"),
|
||||
value: "\(activeRitualCount)",
|
||||
caption: String(localized: "In progress now"),
|
||||
@ -488,7 +490,8 @@ final class RitualStore: RitualStoreProviding {
|
||||
symbolName: "sparkles",
|
||||
breakdown: currentRituals.map { BreakdownItem(label: $0.title, value: $0.theme) }
|
||||
),
|
||||
InsightCard(
|
||||
.streak: InsightCard(
|
||||
type: .streak,
|
||||
title: String(localized: "Streak"),
|
||||
value: "\(current)",
|
||||
caption: String(localized: "Consecutive perfect days"),
|
||||
@ -496,7 +499,8 @@ final class RitualStore: RitualStoreProviding {
|
||||
symbolName: "flame.fill",
|
||||
breakdown: streakBreakdown
|
||||
),
|
||||
InsightCard(
|
||||
.habitsToday: InsightCard(
|
||||
type: .habitsToday,
|
||||
title: String(localized: "Habits today"),
|
||||
value: "\(completedToday)",
|
||||
caption: String(localized: "Completed today"),
|
||||
@ -504,7 +508,8 @@ final class RitualStore: RitualStoreProviding {
|
||||
symbolName: "checkmark.circle.fill",
|
||||
breakdown: habitsBreakdown
|
||||
),
|
||||
InsightCard(
|
||||
.completion: InsightCard(
|
||||
type: .completion,
|
||||
title: String(localized: "Completion"),
|
||||
value: "\(completionRateValue)%",
|
||||
caption: String(localized: "Today's progress"),
|
||||
@ -513,7 +518,8 @@ final class RitualStore: RitualStoreProviding {
|
||||
breakdown: trendBreakdown,
|
||||
trendData: trendData
|
||||
),
|
||||
InsightCard(
|
||||
.daysActive: InsightCard(
|
||||
type: .daysActive,
|
||||
title: String(localized: "Days Active"),
|
||||
value: "\(daysActiveCount)",
|
||||
caption: String(localized: "Days you checked in"),
|
||||
@ -521,7 +527,8 @@ final class RitualStore: RitualStoreProviding {
|
||||
symbolName: "calendar",
|
||||
breakdown: daysActiveBreakdown()
|
||||
),
|
||||
InsightCard(
|
||||
.sevenDayAvg: InsightCard(
|
||||
type: .sevenDayAvg,
|
||||
title: String(localized: "7-Day Avg"),
|
||||
value: "\(weeklyAvg)%",
|
||||
caption: String(localized: "Weekly average"),
|
||||
@ -530,16 +537,18 @@ final class RitualStore: RitualStoreProviding {
|
||||
breakdown: trendBreakdown,
|
||||
trendData: trendData
|
||||
),
|
||||
InsightCard(
|
||||
.totalCheckIns: InsightCard(
|
||||
type: .totalCheckIns,
|
||||
title: String(localized: "Total Check-ins"),
|
||||
value: "\(totalHabitsAllTime)",
|
||||
caption: String(localized: "All-time habits completed"),
|
||||
explanation: String(localized: "The total number of habit check-ins you've made since you started using Rituals. Every check-in counts toward building lasting change."),
|
||||
symbolName: "checkmark.seal.fill"
|
||||
),
|
||||
{
|
||||
.bestRitual: {
|
||||
if let best = bestRitualInfo {
|
||||
return InsightCard(
|
||||
type: .bestRitual,
|
||||
title: String(localized: "Best Ritual"),
|
||||
value: "\(best.rate)%",
|
||||
caption: best.title,
|
||||
@ -548,6 +557,7 @@ final class RitualStore: RitualStoreProviding {
|
||||
)
|
||||
} else {
|
||||
return InsightCard(
|
||||
type: .bestRitual,
|
||||
title: String(localized: "Best Ritual"),
|
||||
value: "—",
|
||||
caption: String(localized: "No active rituals"),
|
||||
@ -557,6 +567,43 @@ final class RitualStore: RitualStoreProviding {
|
||||
}
|
||||
}()
|
||||
]
|
||||
|
||||
// Return cards in user's preferred order
|
||||
let order = insightCardOrder
|
||||
return order.compactMap { cardsByType[$0] }
|
||||
}
|
||||
|
||||
// MARK: - Insight Card Order
|
||||
|
||||
private static let insightCardOrderKey = "insightCardOrder"
|
||||
|
||||
/// The user's preferred order for insight cards
|
||||
var insightCardOrder: [InsightCardType] {
|
||||
get {
|
||||
guard let data = UserDefaults.standard.data(forKey: Self.insightCardOrderKey),
|
||||
let order = try? JSONDecoder().decode([InsightCardType].self, from: data) else {
|
||||
return InsightCardType.defaultOrder
|
||||
}
|
||||
// Ensure all card types are included (in case new ones were added)
|
||||
let missingTypes = InsightCardType.allCases.filter { !order.contains($0) }
|
||||
return order + missingTypes
|
||||
}
|
||||
set {
|
||||
if let data = try? JSONEncoder().encode(newValue) {
|
||||
UserDefaults.standard.set(data, forKey: Self.insightCardOrderKey)
|
||||
}
|
||||
insightCardsNeedRefresh = true
|
||||
}
|
||||
}
|
||||
|
||||
/// Reorders insight cards by moving a card from one position to another
|
||||
func reorderInsightCards(from sourceIndex: Int, to destinationIndex: Int) {
|
||||
var order = insightCardOrder
|
||||
guard sourceIndex >= 0 && sourceIndex < order.count else { return }
|
||||
guard destinationIndex >= 0 && destinationIndex < order.count else { return }
|
||||
let item = order.remove(at: sourceIndex)
|
||||
order.insert(item, at: destinationIndex)
|
||||
insightCardOrder = order
|
||||
}
|
||||
|
||||
func createQuickRitual() {
|
||||
|
||||
@ -76,7 +76,7 @@ struct HistoryDayDetailSheet: View {
|
||||
}
|
||||
}
|
||||
.presentationBackground(AppSurface.primary)
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDetents([.large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
|
||||
|
||||
@ -90,7 +90,7 @@ struct HistoryView: View {
|
||||
ritualPicker
|
||||
|
||||
// Month calendars - 2-column grid on iPad/landscape
|
||||
LazyVGrid(columns: monthColumns, alignment: .top, spacing: Design.Spacing.large) {
|
||||
LazyVGrid(columns: monthColumns, alignment: .leading, spacing: Design.Spacing.large) {
|
||||
ForEach(months, id: \.self) { month in
|
||||
HistoryMonthView(
|
||||
month: month,
|
||||
@ -103,6 +103,7 @@ struct HistoryView: View {
|
||||
selectedDateItem = IdentifiableDate(date: date)
|
||||
}
|
||||
)
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
}
|
||||
.id(refreshToken)
|
||||
|
||||
@ -86,6 +86,7 @@ struct InsightCardView: View {
|
||||
#Preview {
|
||||
InsightCardView(
|
||||
card: InsightCard(
|
||||
type: .completion,
|
||||
title: "Completion",
|
||||
value: "72%",
|
||||
caption: "Today's progress",
|
||||
|
||||
@ -74,6 +74,7 @@ struct InsightDetailSheet: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.large])
|
||||
}
|
||||
|
||||
// MARK: - Header Section
|
||||
@ -272,6 +273,7 @@ struct InsightDetailSheet: View {
|
||||
#Preview {
|
||||
InsightDetailSheet(
|
||||
card: InsightCard(
|
||||
type: .daysActive,
|
||||
title: "Ritual days",
|
||||
value: "16",
|
||||
caption: "Days on your journey",
|
||||
|
||||
@ -1,9 +1,14 @@
|
||||
import SwiftUI
|
||||
import Bedrock
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct InsightsView: View {
|
||||
@Bindable var store: RitualStore
|
||||
@State private var refreshToken = UUID()
|
||||
@State private var isEditing = false
|
||||
@State private var draggingCard: InsightCardType?
|
||||
@State private var cardOrder: [InsightCardType] = []
|
||||
@State private var lastDroppedCard: InsightCardType?
|
||||
|
||||
private let columns = [
|
||||
GridItem(
|
||||
@ -13,43 +18,204 @@ struct InsightsView: View {
|
||||
)
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.large) {
|
||||
SectionHeaderView(
|
||||
title: String(localized: "Insights"),
|
||||
subtitle: String(localized: "Momentum at a glance")
|
||||
)
|
||||
/// Cards in the current display order (uses local state during editing)
|
||||
private var orderedCards: [InsightCard] {
|
||||
let allCards = store.insightCards()
|
||||
let cardsByType = Dictionary(uniqueKeysWithValues: allCards.map { ($0.type, $0) })
|
||||
return cardOrder.compactMap { cardsByType[$0] }
|
||||
}
|
||||
|
||||
LazyVGrid(columns: columns, spacing: Design.Spacing.medium) {
|
||||
ForEach(store.insightCards()) { card in
|
||||
InsightCardView(card: card, store: store)
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.large) {
|
||||
SectionHeaderView(
|
||||
title: String(localized: "Insights"),
|
||||
subtitle: String(localized: "Momentum at a glance")
|
||||
)
|
||||
|
||||
// Grid with drag-and-drop support in edit mode
|
||||
LazyVGrid(columns: columns, spacing: Design.Spacing.medium) {
|
||||
ForEach(orderedCards) { card in
|
||||
insightCardItem(card: card)
|
||||
}
|
||||
}
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: cardOrder)
|
||||
.onDrop(of: [.text], isTargeted: nil) { _ in
|
||||
// Fallback drop handler - clear dragging state
|
||||
lastDroppedCard = draggingCard
|
||||
draggingCard = nil
|
||||
return false
|
||||
}
|
||||
}
|
||||
.id(refreshToken)
|
||||
.padding(Design.Spacing.large)
|
||||
}
|
||||
.padding(Design.Spacing.large)
|
||||
}
|
||||
.background(LinearGradient(
|
||||
colors: [AppSurface.primary, AppSurface.secondary],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
))
|
||||
.onAppear {
|
||||
Task {
|
||||
await Task.yield()
|
||||
.background(LinearGradient(
|
||||
colors: [AppSurface.primary, AppSurface.secondary],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
))
|
||||
.navigationTitle(String(localized: "Insights"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: Design.Animation.standard)) {
|
||||
if isEditing {
|
||||
// Save order and clear all drag state when exiting edit mode
|
||||
draggingCard = nil
|
||||
lastDroppedCard = nil
|
||||
store.insightCardOrder = cardOrder
|
||||
}
|
||||
isEditing.toggle()
|
||||
}
|
||||
} label: {
|
||||
Text(isEditing ? String(localized: "Done") : String(localized: "Edit"))
|
||||
.foregroundStyle(AppAccent.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: isEditing) { _, newValue in
|
||||
// Clear all drag state when exiting edit mode
|
||||
if !newValue {
|
||||
draggingCard = nil
|
||||
lastDroppedCard = nil
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
cardOrder = store.insightCardOrder
|
||||
Task {
|
||||
await Task.yield()
|
||||
store.refreshAnalyticsIfNeeded()
|
||||
store.refreshInsightCardsIfNeeded()
|
||||
}
|
||||
}
|
||||
.onChange(of: store.rituals) { _, _ in
|
||||
store.refreshAnalyticsIfNeeded()
|
||||
store.refreshInsightCardsIfNeeded()
|
||||
cardOrder = store.insightCardOrder
|
||||
}
|
||||
.onChange(of: store.insightCardOrder) { _, newOrder in
|
||||
if !isEditing {
|
||||
cardOrder = newOrder
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: store.rituals) { _, _ in
|
||||
store.refreshAnalyticsIfNeeded()
|
||||
store.refreshInsightCardsIfNeeded()
|
||||
refreshToken = UUID()
|
||||
}
|
||||
|
||||
// MARK: - Card Item with Drag Support
|
||||
|
||||
@ViewBuilder
|
||||
private func insightCardItem(card: InsightCard) -> some View {
|
||||
let isDragging = draggingCard == card.type
|
||||
|
||||
let cardView = InsightCardView(card: card, store: store)
|
||||
.overlay(alignment: .topTrailing) {
|
||||
// Show drag handle in edit mode - sized to cover the disclosure chevron
|
||||
if isEditing {
|
||||
Image(systemName: "line.3.horizontal")
|
||||
.font(.body.weight(.medium))
|
||||
.foregroundStyle(AppTextColors.inverse)
|
||||
.padding(Design.Spacing.small)
|
||||
.background(AppAccent.primary)
|
||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.small))
|
||||
.padding(Design.Spacing.xSmall)
|
||||
}
|
||||
}
|
||||
.opacity(isDragging ? 0.3 : 1.0)
|
||||
.scaleEffect(isDragging ? 1.05 : (isEditing ? 0.98 : 1.0))
|
||||
.modifier(JiggleModifier(isEnabled: isEditing && draggingCard == nil))
|
||||
|
||||
// Only enable drag when in edit mode
|
||||
if isEditing {
|
||||
cardView
|
||||
.onDrag {
|
||||
// Prevent spurious re-trigger: if this is the same card that was just dropped
|
||||
// and we're not currently dragging, ignore the call
|
||||
let isSameAsLastDrop = lastDroppedCard == card.type
|
||||
if !isSameAsLastDrop {
|
||||
lastDroppedCard = nil
|
||||
self.draggingCard = card.type
|
||||
} else if draggingCard == nil {
|
||||
// Spurious re-trigger - don't set draggingCard
|
||||
}
|
||||
return NSItemProvider(object: card.type.rawValue as NSString)
|
||||
}
|
||||
.onDrop(of: [.text], delegate: InsightCardDropDelegate(
|
||||
card: card,
|
||||
cardOrder: $cardOrder,
|
||||
draggingCard: $draggingCard,
|
||||
lastDroppedCard: $lastDroppedCard
|
||||
))
|
||||
} else {
|
||||
cardView
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Drop Delegate
|
||||
|
||||
struct InsightCardDropDelegate: DropDelegate {
|
||||
let card: InsightCard
|
||||
@Binding var cardOrder: [InsightCardType]
|
||||
@Binding var draggingCard: InsightCardType?
|
||||
@Binding var lastDroppedCard: InsightCardType?
|
||||
|
||||
func performDrop(info: DropInfo) -> Bool {
|
||||
// Record which card was dropped BEFORE clearing draggingCard
|
||||
// This prevents spurious onDrag re-triggers from affecting state
|
||||
lastDroppedCard = draggingCard
|
||||
draggingCard = nil
|
||||
return true
|
||||
}
|
||||
|
||||
func dropEntered(info: DropInfo) {
|
||||
guard let dragging = draggingCard, dragging != card.type else { return }
|
||||
|
||||
guard let fromIndex = cardOrder.firstIndex(of: dragging),
|
||||
let toIndex = cardOrder.firstIndex(of: card.type),
|
||||
fromIndex != toIndex else {
|
||||
return
|
||||
}
|
||||
|
||||
// Move the item with animation - updates local state, persisted when Done is tapped
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
|
||||
let item = cardOrder.remove(at: fromIndex)
|
||||
cardOrder.insert(item, at: toIndex)
|
||||
}
|
||||
}
|
||||
|
||||
func dropUpdated(info: DropInfo) -> DropProposal? {
|
||||
DropProposal(operation: .move)
|
||||
}
|
||||
|
||||
func dropExited(info: DropInfo) {
|
||||
// Don't clear here - wait for performDrop
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Jiggle Animation Modifier
|
||||
|
||||
struct JiggleModifier: ViewModifier {
|
||||
let isEnabled: Bool
|
||||
@State private var isJiggling = false
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.rotationEffect(.degrees(isEnabled && isJiggling ? -1 : 0))
|
||||
.animation(
|
||||
isEnabled ? Animation.easeInOut(duration: 0.1).repeatForever(autoreverses: true) : .default,
|
||||
value: isJiggling
|
||||
)
|
||||
.onChange(of: isEnabled) { _, newValue in
|
||||
isJiggling = newValue
|
||||
}
|
||||
.onAppear {
|
||||
isJiggling = isEnabled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
InsightsView(store: RitualStore.preview)
|
||||
}
|
||||
|
||||
@ -21,10 +21,36 @@ struct ArcDetailView: View {
|
||||
@Bindable var store: RitualStore
|
||||
let arc: RitualArc
|
||||
let ritual: Ritual
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@State private var selectedDateItem: ArcIdentifiableDate?
|
||||
|
||||
private let calendar = Calendar.current
|
||||
|
||||
/// Whether to use wide layout on iPad/landscape
|
||||
private var useWideLayout: Bool {
|
||||
horizontalSizeClass == .regular
|
||||
}
|
||||
|
||||
/// Grid columns for month calendars - 2 columns on regular width when multiple months
|
||||
private var monthColumns: [GridItem] {
|
||||
AdaptiveColumns.columns(
|
||||
compactCount: 1,
|
||||
regularCount: monthsInArc.count > 1 ? 2 : 1,
|
||||
spacing: Design.Spacing.large,
|
||||
horizontalSizeClass: horizontalSizeClass
|
||||
)
|
||||
}
|
||||
|
||||
/// Grid columns for habit breakdown - 2 columns on regular width when multiple habits
|
||||
private var habitColumns: [GridItem] {
|
||||
AdaptiveColumns.columns(
|
||||
compactCount: 1,
|
||||
regularCount: habitRates.count > 1 ? 2 : 1,
|
||||
spacing: Design.Spacing.medium,
|
||||
horizontalSizeClass: horizontalSizeClass
|
||||
)
|
||||
}
|
||||
|
||||
private var overallCompletionRate: Int {
|
||||
let habits = arc.habits ?? []
|
||||
let totalCheckIns = habits.reduce(0) { $0 + $1.completedDayIDs.count }
|
||||
@ -243,9 +269,10 @@ struct ArcDetailView: View {
|
||||
.font(.headline)
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
|
||||
VStack(spacing: Design.Spacing.small) {
|
||||
LazyVGrid(columns: habitColumns, alignment: .leading, spacing: Design.Spacing.small) {
|
||||
ForEach(habitRates, id: \.habit.id) { item in
|
||||
habitRow(habit: item.habit, rate: item.rate)
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -307,21 +334,24 @@ struct ArcDetailView: View {
|
||||
.font(.headline)
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
|
||||
// Show month calendars for the arc's date range
|
||||
ForEach(monthsInArc, id: \.self) { month in
|
||||
HistoryMonthView(
|
||||
month: month,
|
||||
selectedRitual: ritual,
|
||||
completionRate: { date, _ in
|
||||
arcCompletionRate(for: date)
|
||||
},
|
||||
onDayTapped: { date in
|
||||
// Only allow tapping days within the arc range
|
||||
if arc.contains(date: date) {
|
||||
selectedDateItem = ArcIdentifiableDate(date: date)
|
||||
// Show month calendars for the arc's date range - 2 columns on iPad
|
||||
LazyVGrid(columns: monthColumns, alignment: .leading, spacing: Design.Spacing.large) {
|
||||
ForEach(monthsInArc, id: \.self) { month in
|
||||
HistoryMonthView(
|
||||
month: month,
|
||||
selectedRitual: ritual,
|
||||
completionRate: { date, _ in
|
||||
arcCompletionRate(for: date)
|
||||
},
|
||||
onDayTapped: { date in
|
||||
// Only allow tapping days within the arc range
|
||||
if arc.contains(date: date) {
|
||||
selectedDateItem = ArcIdentifiableDate(date: date)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,20 +11,32 @@ import Bedrock
|
||||
/// A view showing habit completion performance within a ritual.
|
||||
struct HabitPerformanceView: View {
|
||||
let habitRates: [(habit: ArcHabit, rate: Double)]
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
private var sortedByRate: [(habit: ArcHabit, rate: Double)] {
|
||||
habitRates.sorted { $0.rate > $1.rate }
|
||||
}
|
||||
|
||||
/// Grid columns for habits - 2 columns on regular width when multiple habits
|
||||
private var habitColumns: [GridItem] {
|
||||
AdaptiveColumns.columns(
|
||||
compactCount: 1,
|
||||
regularCount: habitRates.count > 1 ? 2 : 1,
|
||||
spacing: Design.Spacing.medium,
|
||||
horizontalSizeClass: horizontalSizeClass
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||
Text(String(localized: "Habit Performance"))
|
||||
.font(.headline)
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
|
||||
VStack(spacing: Design.Spacing.xSmall) {
|
||||
LazyVGrid(columns: habitColumns, alignment: .leading, spacing: Design.Spacing.xSmall) {
|
||||
ForEach(sortedByRate, id: \.habit.id) { item in
|
||||
habitRow(item.habit, rate: item.rate)
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ struct RitualDetailView: View {
|
||||
@Bindable var store: RitualStore
|
||||
@Bindable var categoryStore: CategoryStore
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
private let ritual: Ritual
|
||||
|
||||
@ -19,6 +20,32 @@ struct RitualDetailView: View {
|
||||
self.ritual = ritual
|
||||
}
|
||||
|
||||
/// Whether to use wide layout on iPad/landscape
|
||||
private var useWideLayout: Bool {
|
||||
horizontalSizeClass == .regular
|
||||
}
|
||||
|
||||
/// Grid columns for habits - 2 columns on regular width when multiple habits
|
||||
private var habitColumns: [GridItem] {
|
||||
let habits = store.habits(for: ritual)
|
||||
return AdaptiveColumns.columns(
|
||||
compactCount: 1,
|
||||
regularCount: habits.count > 1 ? 2 : 1,
|
||||
spacing: Design.Spacing.medium,
|
||||
horizontalSizeClass: horizontalSizeClass
|
||||
)
|
||||
}
|
||||
|
||||
/// Grid columns for arc history - 2 columns on regular width when multiple arcs
|
||||
private var arcColumns: [GridItem] {
|
||||
AdaptiveColumns.columns(
|
||||
compactCount: 1,
|
||||
regularCount: completedArcs.count > 1 ? 2 : 1,
|
||||
spacing: Design.Spacing.medium,
|
||||
horizontalSizeClass: horizontalSizeClass
|
||||
)
|
||||
}
|
||||
|
||||
private var daysRemaining: Int {
|
||||
store.daysRemaining(for: ritual)
|
||||
}
|
||||
@ -275,7 +302,7 @@ struct RitualDetailView: View {
|
||||
subtitle: String(localized: "Tap to check in")
|
||||
)
|
||||
|
||||
VStack(spacing: Design.Spacing.medium) {
|
||||
LazyVGrid(columns: habitColumns, alignment: .leading, spacing: Design.Spacing.medium) {
|
||||
ForEach(store.habits(for: ritual)) { habit in
|
||||
TodayHabitRowView(
|
||||
title: habit.title,
|
||||
@ -283,6 +310,7 @@ struct RitualDetailView: View {
|
||||
isCompleted: store.isHabitCompletedToday(habit),
|
||||
action: { store.toggleHabitCompletion(habit) }
|
||||
)
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -378,9 +406,10 @@ struct RitualDetailView: View {
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppTextColors.tertiary)
|
||||
} else {
|
||||
VStack(spacing: Design.Spacing.small) {
|
||||
LazyVGrid(columns: arcColumns, alignment: .leading, spacing: Design.Spacing.small) {
|
||||
ForEach(completedArcs) { arc in
|
||||
arcHistoryRow(arc)
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -58,6 +58,7 @@ struct ArcRenewalSheet: View {
|
||||
.sheet(isPresented: $showingEditSheet) {
|
||||
RitualEditSheet(store: store, categoryStore: categoryStore, ritual: ritual)
|
||||
}
|
||||
.presentationDetents([.large])
|
||||
}
|
||||
|
||||
private var celebrationHeader: some View {
|
||||
|
||||
@ -194,7 +194,7 @@ struct PresetDetailSheet: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDetents([.large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
|
||||
|
||||
@ -554,7 +554,7 @@ struct IconPickerSheet: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDetents([.large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
|
||||
@ -670,7 +670,7 @@ struct HabitIconPickerSheet: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDetents([.large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
|
||||
|
||||
@ -119,7 +119,7 @@ struct CategoryEditSheet: View {
|
||||
Text(String(localized: "Rituals using this category will be set to no category."))
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium])
|
||||
.presentationDetents([.large])
|
||||
}
|
||||
|
||||
private func loadCategory() {
|
||||
|
||||
@ -51,7 +51,7 @@ struct TodayView: View {
|
||||
}
|
||||
} else {
|
||||
// Use 2-column grid on iPad/landscape when multiple rituals
|
||||
LazyVGrid(columns: ritualColumns, alignment: .top, spacing: Design.Spacing.large) {
|
||||
LazyVGrid(columns: ritualColumns, alignment: .leading, spacing: Design.Spacing.large) {
|
||||
ForEach(todayRituals) { ritual in
|
||||
TodayRitualSectionView(
|
||||
focusTitle: ritual.title,
|
||||
@ -63,6 +63,7 @@ struct TodayView: View {
|
||||
iconName: ritual.iconName,
|
||||
timeOfDay: ritual.timeOfDay
|
||||
)
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user