Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-01-26 19:17:16 -06:00
parent 576afad258
commit 1f55b1b1f2
15 changed files with 368 additions and 58 deletions

View File

@ -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

View File

@ -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() {

View File

@ -76,7 +76,7 @@ struct HistoryDayDetailSheet: View {
}
}
.presentationBackground(AppSurface.primary)
.presentationDetents([.medium, .large])
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}

View File

@ -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)

View File

@ -86,6 +86,7 @@ struct InsightCardView: View {
#Preview {
InsightCardView(
card: InsightCard(
type: .completion,
title: "Completion",
value: "72%",
caption: "Today's progress",

View File

@ -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",

View File

@ -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,7 +18,15 @@ struct InsightsView: View {
)
]
/// 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] }
}
var body: some View {
NavigationStack {
ScrollView(.vertical, showsIndicators: false) {
VStack(alignment: .leading, spacing: Design.Spacing.large) {
SectionHeaderView(
@ -21,12 +34,19 @@ struct InsightsView: View {
subtitle: String(localized: "Momentum at a glance")
)
// Grid with drag-and-drop support in edit mode
LazyVGrid(columns: columns, spacing: Design.Spacing.medium) {
ForEach(store.insightCards()) { card in
InsightCardView(card: card, store: store)
ForEach(orderedCards) { card in
insightCardItem(card: card)
}
}
.id(refreshToken)
.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
}
}
.padding(Design.Spacing.large)
}
@ -35,7 +55,35 @@ struct InsightsView: View {
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()
@ -45,7 +93,125 @@ struct InsightsView: View {
.onChange(of: store.rituals) { _, _ in
store.refreshAnalyticsIfNeeded()
store.refreshInsightCardsIfNeeded()
refreshToken = UUID()
cardOrder = store.insightCardOrder
}
.onChange(of: store.insightCardOrder) { _, newOrder in
if !isEditing {
cardOrder = newOrder
}
}
}
}
// 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
}
}
}

View File

@ -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,7 +334,8 @@ struct ArcDetailView: View {
.font(.headline)
.foregroundStyle(AppTextColors.primary)
// Show month calendars for the arc's date range
// 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,
@ -322,6 +350,8 @@ struct ArcDetailView: View {
}
}
)
.frame(maxHeight: .infinity, alignment: .top)
}
}
}
}

View File

@ -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)
}
}
}

View File

@ -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)
}
}
}

View File

@ -58,6 +58,7 @@ struct ArcRenewalSheet: View {
.sheet(isPresented: $showingEditSheet) {
RitualEditSheet(store: store, categoryStore: categoryStore, ritual: ritual)
}
.presentationDetents([.large])
}
private var celebrationHeader: some View {

View File

@ -194,7 +194,7 @@ struct PresetDetailSheet: View {
}
}
}
.presentationDetents([.medium, .large])
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}

View File

@ -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)
}

View File

@ -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() {

View File

@ -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)
}
}
}