Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
576afad258
commit
1f55b1b1f2
@ -1,8 +1,26 @@
|
|||||||
import Foundation
|
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.
|
/// A single insight metric displayed on the Insights tab.
|
||||||
struct InsightCard: Identifiable {
|
struct InsightCard: Identifiable {
|
||||||
let id: UUID
|
let id: UUID
|
||||||
|
let type: InsightCardType
|
||||||
let title: String
|
let title: String
|
||||||
let value: String
|
let value: String
|
||||||
let caption: String // Short description shown on card
|
let caption: String // Short description shown on card
|
||||||
@ -13,6 +31,7 @@ struct InsightCard: Identifiable {
|
|||||||
|
|
||||||
init(
|
init(
|
||||||
id: UUID = UUID(),
|
id: UUID = UUID(),
|
||||||
|
type: InsightCardType,
|
||||||
title: String,
|
title: String,
|
||||||
value: String,
|
value: String,
|
||||||
caption: String,
|
caption: String,
|
||||||
@ -22,6 +41,7 @@ struct InsightCard: Identifiable {
|
|||||||
trendData: [TrendDataPoint]? = nil
|
trendData: [TrendDataPoint]? = nil
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
|
self.type = type
|
||||||
self.title = title
|
self.title = title
|
||||||
self.value = value
|
self.value = value
|
||||||
self.caption = caption
|
self.caption = caption
|
||||||
|
|||||||
@ -479,8 +479,10 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
return best
|
return best
|
||||||
}()
|
}()
|
||||||
|
|
||||||
return [
|
// Build cards dictionary by type
|
||||||
InsightCard(
|
let cardsByType: [InsightCardType: InsightCard] = [
|
||||||
|
.active: InsightCard(
|
||||||
|
type: .active,
|
||||||
title: String(localized: "Active"),
|
title: String(localized: "Active"),
|
||||||
value: "\(activeRitualCount)",
|
value: "\(activeRitualCount)",
|
||||||
caption: String(localized: "In progress now"),
|
caption: String(localized: "In progress now"),
|
||||||
@ -488,7 +490,8 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
symbolName: "sparkles",
|
symbolName: "sparkles",
|
||||||
breakdown: currentRituals.map { BreakdownItem(label: $0.title, value: $0.theme) }
|
breakdown: currentRituals.map { BreakdownItem(label: $0.title, value: $0.theme) }
|
||||||
),
|
),
|
||||||
InsightCard(
|
.streak: InsightCard(
|
||||||
|
type: .streak,
|
||||||
title: String(localized: "Streak"),
|
title: String(localized: "Streak"),
|
||||||
value: "\(current)",
|
value: "\(current)",
|
||||||
caption: String(localized: "Consecutive perfect days"),
|
caption: String(localized: "Consecutive perfect days"),
|
||||||
@ -496,7 +499,8 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
symbolName: "flame.fill",
|
symbolName: "flame.fill",
|
||||||
breakdown: streakBreakdown
|
breakdown: streakBreakdown
|
||||||
),
|
),
|
||||||
InsightCard(
|
.habitsToday: InsightCard(
|
||||||
|
type: .habitsToday,
|
||||||
title: String(localized: "Habits today"),
|
title: String(localized: "Habits today"),
|
||||||
value: "\(completedToday)",
|
value: "\(completedToday)",
|
||||||
caption: String(localized: "Completed today"),
|
caption: String(localized: "Completed today"),
|
||||||
@ -504,7 +508,8 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
symbolName: "checkmark.circle.fill",
|
symbolName: "checkmark.circle.fill",
|
||||||
breakdown: habitsBreakdown
|
breakdown: habitsBreakdown
|
||||||
),
|
),
|
||||||
InsightCard(
|
.completion: InsightCard(
|
||||||
|
type: .completion,
|
||||||
title: String(localized: "Completion"),
|
title: String(localized: "Completion"),
|
||||||
value: "\(completionRateValue)%",
|
value: "\(completionRateValue)%",
|
||||||
caption: String(localized: "Today's progress"),
|
caption: String(localized: "Today's progress"),
|
||||||
@ -513,7 +518,8 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
breakdown: trendBreakdown,
|
breakdown: trendBreakdown,
|
||||||
trendData: trendData
|
trendData: trendData
|
||||||
),
|
),
|
||||||
InsightCard(
|
.daysActive: InsightCard(
|
||||||
|
type: .daysActive,
|
||||||
title: String(localized: "Days Active"),
|
title: String(localized: "Days Active"),
|
||||||
value: "\(daysActiveCount)",
|
value: "\(daysActiveCount)",
|
||||||
caption: String(localized: "Days you checked in"),
|
caption: String(localized: "Days you checked in"),
|
||||||
@ -521,7 +527,8 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
symbolName: "calendar",
|
symbolName: "calendar",
|
||||||
breakdown: daysActiveBreakdown()
|
breakdown: daysActiveBreakdown()
|
||||||
),
|
),
|
||||||
InsightCard(
|
.sevenDayAvg: InsightCard(
|
||||||
|
type: .sevenDayAvg,
|
||||||
title: String(localized: "7-Day Avg"),
|
title: String(localized: "7-Day Avg"),
|
||||||
value: "\(weeklyAvg)%",
|
value: "\(weeklyAvg)%",
|
||||||
caption: String(localized: "Weekly average"),
|
caption: String(localized: "Weekly average"),
|
||||||
@ -530,16 +537,18 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
breakdown: trendBreakdown,
|
breakdown: trendBreakdown,
|
||||||
trendData: trendData
|
trendData: trendData
|
||||||
),
|
),
|
||||||
InsightCard(
|
.totalCheckIns: InsightCard(
|
||||||
|
type: .totalCheckIns,
|
||||||
title: String(localized: "Total Check-ins"),
|
title: String(localized: "Total Check-ins"),
|
||||||
value: "\(totalHabitsAllTime)",
|
value: "\(totalHabitsAllTime)",
|
||||||
caption: String(localized: "All-time habits completed"),
|
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."),
|
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"
|
symbolName: "checkmark.seal.fill"
|
||||||
),
|
),
|
||||||
{
|
.bestRitual: {
|
||||||
if let best = bestRitualInfo {
|
if let best = bestRitualInfo {
|
||||||
return InsightCard(
|
return InsightCard(
|
||||||
|
type: .bestRitual,
|
||||||
title: String(localized: "Best Ritual"),
|
title: String(localized: "Best Ritual"),
|
||||||
value: "\(best.rate)%",
|
value: "\(best.rate)%",
|
||||||
caption: best.title,
|
caption: best.title,
|
||||||
@ -548,6 +557,7 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
return InsightCard(
|
return InsightCard(
|
||||||
|
type: .bestRitual,
|
||||||
title: String(localized: "Best Ritual"),
|
title: String(localized: "Best Ritual"),
|
||||||
value: "—",
|
value: "—",
|
||||||
caption: String(localized: "No active rituals"),
|
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() {
|
func createQuickRitual() {
|
||||||
|
|||||||
@ -76,7 +76,7 @@ struct HistoryDayDetailSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.presentationBackground(AppSurface.primary)
|
.presentationBackground(AppSurface.primary)
|
||||||
.presentationDetents([.medium, .large])
|
.presentationDetents([.large])
|
||||||
.presentationDragIndicator(.visible)
|
.presentationDragIndicator(.visible)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -90,7 +90,7 @@ struct HistoryView: View {
|
|||||||
ritualPicker
|
ritualPicker
|
||||||
|
|
||||||
// Month calendars - 2-column grid on iPad/landscape
|
// 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
|
ForEach(months, id: \.self) { month in
|
||||||
HistoryMonthView(
|
HistoryMonthView(
|
||||||
month: month,
|
month: month,
|
||||||
@ -103,6 +103,7 @@ struct HistoryView: View {
|
|||||||
selectedDateItem = IdentifiableDate(date: date)
|
selectedDateItem = IdentifiableDate(date: date)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
.frame(maxHeight: .infinity, alignment: .top)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.id(refreshToken)
|
.id(refreshToken)
|
||||||
|
|||||||
@ -86,6 +86,7 @@ struct InsightCardView: View {
|
|||||||
#Preview {
|
#Preview {
|
||||||
InsightCardView(
|
InsightCardView(
|
||||||
card: InsightCard(
|
card: InsightCard(
|
||||||
|
type: .completion,
|
||||||
title: "Completion",
|
title: "Completion",
|
||||||
value: "72%",
|
value: "72%",
|
||||||
caption: "Today's progress",
|
caption: "Today's progress",
|
||||||
|
|||||||
@ -74,6 +74,7 @@ struct InsightDetailSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.presentationDetents([.large])
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Header Section
|
// MARK: - Header Section
|
||||||
@ -272,6 +273,7 @@ struct InsightDetailSheet: View {
|
|||||||
#Preview {
|
#Preview {
|
||||||
InsightDetailSheet(
|
InsightDetailSheet(
|
||||||
card: InsightCard(
|
card: InsightCard(
|
||||||
|
type: .daysActive,
|
||||||
title: "Ritual days",
|
title: "Ritual days",
|
||||||
value: "16",
|
value: "16",
|
||||||
caption: "Days on your journey",
|
caption: "Days on your journey",
|
||||||
|
|||||||
@ -1,9 +1,14 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Bedrock
|
import Bedrock
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
struct InsightsView: View {
|
struct InsightsView: View {
|
||||||
@Bindable var store: RitualStore
|
@Bindable var store: RitualStore
|
||||||
@State private var refreshToken = UUID()
|
@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 = [
|
private let columns = [
|
||||||
GridItem(
|
GridItem(
|
||||||
@ -12,44 +17,205 @@ struct InsightsView: View {
|
|||||||
alignment: .top
|
alignment: .top
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/// 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 {
|
var body: some View {
|
||||||
ScrollView(.vertical, showsIndicators: false) {
|
NavigationStack {
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.large) {
|
ScrollView(.vertical, showsIndicators: false) {
|
||||||
SectionHeaderView(
|
VStack(alignment: .leading, spacing: Design.Spacing.large) {
|
||||||
title: String(localized: "Insights"),
|
SectionHeaderView(
|
||||||
subtitle: String(localized: "Momentum at a glance")
|
title: String(localized: "Insights"),
|
||||||
)
|
subtitle: String(localized: "Momentum at a glance")
|
||||||
|
)
|
||||||
|
|
||||||
LazyVGrid(columns: columns, spacing: Design.Spacing.medium) {
|
// Grid with drag-and-drop support in edit mode
|
||||||
ForEach(store.insightCards()) { card in
|
LazyVGrid(columns: columns, spacing: Design.Spacing.medium) {
|
||||||
InsightCardView(card: card, store: store)
|
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],
|
||||||
.background(LinearGradient(
|
startPoint: .topLeading,
|
||||||
colors: [AppSurface.primary, AppSurface.secondary],
|
endPoint: .bottomTrailing
|
||||||
startPoint: .topLeading,
|
))
|
||||||
endPoint: .bottomTrailing
|
.navigationTitle(String(localized: "Insights"))
|
||||||
))
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.onAppear {
|
.toolbar {
|
||||||
Task {
|
ToolbarItem(placement: .primaryAction) {
|
||||||
await Task.yield()
|
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.refreshAnalyticsIfNeeded()
|
||||||
store.refreshInsightCardsIfNeeded()
|
store.refreshInsightCardsIfNeeded()
|
||||||
|
cardOrder = store.insightCardOrder
|
||||||
|
}
|
||||||
|
.onChange(of: store.insightCardOrder) { _, newOrder in
|
||||||
|
if !isEditing {
|
||||||
|
cardOrder = newOrder
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: store.rituals) { _, _ in
|
}
|
||||||
store.refreshAnalyticsIfNeeded()
|
|
||||||
store.refreshInsightCardsIfNeeded()
|
// MARK: - Card Item with Drag Support
|
||||||
refreshToken = UUID()
|
|
||||||
|
@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 {
|
#Preview {
|
||||||
InsightsView(store: RitualStore.preview)
|
InsightsView(store: RitualStore.preview)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,10 +21,36 @@ struct ArcDetailView: View {
|
|||||||
@Bindable var store: RitualStore
|
@Bindable var store: RitualStore
|
||||||
let arc: RitualArc
|
let arc: RitualArc
|
||||||
let ritual: Ritual
|
let ritual: Ritual
|
||||||
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||||
@State private var selectedDateItem: ArcIdentifiableDate?
|
@State private var selectedDateItem: ArcIdentifiableDate?
|
||||||
|
|
||||||
private let calendar = Calendar.current
|
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 {
|
private var overallCompletionRate: Int {
|
||||||
let habits = arc.habits ?? []
|
let habits = arc.habits ?? []
|
||||||
let totalCheckIns = habits.reduce(0) { $0 + $1.completedDayIDs.count }
|
let totalCheckIns = habits.reduce(0) { $0 + $1.completedDayIDs.count }
|
||||||
@ -243,9 +269,10 @@ struct ArcDetailView: View {
|
|||||||
.font(.headline)
|
.font(.headline)
|
||||||
.foregroundStyle(AppTextColors.primary)
|
.foregroundStyle(AppTextColors.primary)
|
||||||
|
|
||||||
VStack(spacing: Design.Spacing.small) {
|
LazyVGrid(columns: habitColumns, alignment: .leading, spacing: Design.Spacing.small) {
|
||||||
ForEach(habitRates, id: \.habit.id) { item in
|
ForEach(habitRates, id: \.habit.id) { item in
|
||||||
habitRow(habit: item.habit, rate: item.rate)
|
habitRow(habit: item.habit, rate: item.rate)
|
||||||
|
.frame(maxHeight: .infinity, alignment: .top)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -307,21 +334,24 @@ struct ArcDetailView: View {
|
|||||||
.font(.headline)
|
.font(.headline)
|
||||||
.foregroundStyle(AppTextColors.primary)
|
.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
|
||||||
ForEach(monthsInArc, id: \.self) { month in
|
LazyVGrid(columns: monthColumns, alignment: .leading, spacing: Design.Spacing.large) {
|
||||||
HistoryMonthView(
|
ForEach(monthsInArc, id: \.self) { month in
|
||||||
month: month,
|
HistoryMonthView(
|
||||||
selectedRitual: ritual,
|
month: month,
|
||||||
completionRate: { date, _ in
|
selectedRitual: ritual,
|
||||||
arcCompletionRate(for: date)
|
completionRate: { date, _ in
|
||||||
},
|
arcCompletionRate(for: date)
|
||||||
onDayTapped: { date in
|
},
|
||||||
// Only allow tapping days within the arc range
|
onDayTapped: { date in
|
||||||
if arc.contains(date: date) {
|
// Only allow tapping days within the arc range
|
||||||
selectedDateItem = ArcIdentifiableDate(date: date)
|
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.
|
/// A view showing habit completion performance within a ritual.
|
||||||
struct HabitPerformanceView: View {
|
struct HabitPerformanceView: View {
|
||||||
let habitRates: [(habit: ArcHabit, rate: Double)]
|
let habitRates: [(habit: ArcHabit, rate: Double)]
|
||||||
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||||
|
|
||||||
private var sortedByRate: [(habit: ArcHabit, rate: Double)] {
|
private var sortedByRate: [(habit: ArcHabit, rate: Double)] {
|
||||||
habitRates.sorted { $0.rate > $1.rate }
|
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 {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||||
Text(String(localized: "Habit Performance"))
|
Text(String(localized: "Habit Performance"))
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.foregroundStyle(AppTextColors.primary)
|
.foregroundStyle(AppTextColors.primary)
|
||||||
|
|
||||||
VStack(spacing: Design.Spacing.xSmall) {
|
LazyVGrid(columns: habitColumns, alignment: .leading, spacing: Design.Spacing.xSmall) {
|
||||||
ForEach(sortedByRate, id: \.habit.id) { item in
|
ForEach(sortedByRate, id: \.habit.id) { item in
|
||||||
habitRow(item.habit, rate: item.rate)
|
habitRow(item.habit, rate: item.rate)
|
||||||
|
.frame(maxHeight: .infinity, alignment: .top)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ struct RitualDetailView: View {
|
|||||||
@Bindable var store: RitualStore
|
@Bindable var store: RitualStore
|
||||||
@Bindable var categoryStore: CategoryStore
|
@Bindable var categoryStore: CategoryStore
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||||
|
|
||||||
private let ritual: Ritual
|
private let ritual: Ritual
|
||||||
|
|
||||||
@ -18,6 +19,32 @@ struct RitualDetailView: View {
|
|||||||
self.categoryStore = categoryStore
|
self.categoryStore = categoryStore
|
||||||
self.ritual = ritual
|
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 {
|
private var daysRemaining: Int {
|
||||||
store.daysRemaining(for: ritual)
|
store.daysRemaining(for: ritual)
|
||||||
@ -275,7 +302,7 @@ struct RitualDetailView: View {
|
|||||||
subtitle: String(localized: "Tap to check in")
|
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
|
ForEach(store.habits(for: ritual)) { habit in
|
||||||
TodayHabitRowView(
|
TodayHabitRowView(
|
||||||
title: habit.title,
|
title: habit.title,
|
||||||
@ -283,6 +310,7 @@ struct RitualDetailView: View {
|
|||||||
isCompleted: store.isHabitCompletedToday(habit),
|
isCompleted: store.isHabitCompletedToday(habit),
|
||||||
action: { store.toggleHabitCompletion(habit) }
|
action: { store.toggleHabitCompletion(habit) }
|
||||||
)
|
)
|
||||||
|
.frame(maxHeight: .infinity, alignment: .top)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -378,9 +406,10 @@ struct RitualDetailView: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(AppTextColors.tertiary)
|
.foregroundStyle(AppTextColors.tertiary)
|
||||||
} else {
|
} else {
|
||||||
VStack(spacing: Design.Spacing.small) {
|
LazyVGrid(columns: arcColumns, alignment: .leading, spacing: Design.Spacing.small) {
|
||||||
ForEach(completedArcs) { arc in
|
ForEach(completedArcs) { arc in
|
||||||
arcHistoryRow(arc)
|
arcHistoryRow(arc)
|
||||||
|
.frame(maxHeight: .infinity, alignment: .top)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -58,6 +58,7 @@ struct ArcRenewalSheet: View {
|
|||||||
.sheet(isPresented: $showingEditSheet) {
|
.sheet(isPresented: $showingEditSheet) {
|
||||||
RitualEditSheet(store: store, categoryStore: categoryStore, ritual: ritual)
|
RitualEditSheet(store: store, categoryStore: categoryStore, ritual: ritual)
|
||||||
}
|
}
|
||||||
|
.presentationDetents([.large])
|
||||||
}
|
}
|
||||||
|
|
||||||
private var celebrationHeader: some View {
|
private var celebrationHeader: some View {
|
||||||
|
|||||||
@ -194,7 +194,7 @@ struct PresetDetailSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.presentationDetents([.medium, .large])
|
.presentationDetents([.large])
|
||||||
.presentationDragIndicator(.visible)
|
.presentationDragIndicator(.visible)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -554,7 +554,7 @@ struct IconPickerSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.presentationDetents([.medium, .large])
|
.presentationDetents([.large])
|
||||||
.presentationDragIndicator(.visible)
|
.presentationDragIndicator(.visible)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -670,7 +670,7 @@ struct HabitIconPickerSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.presentationDetents([.medium, .large])
|
.presentationDetents([.large])
|
||||||
.presentationDragIndicator(.visible)
|
.presentationDragIndicator(.visible)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -119,7 +119,7 @@ struct CategoryEditSheet: View {
|
|||||||
Text(String(localized: "Rituals using this category will be set to no category."))
|
Text(String(localized: "Rituals using this category will be set to no category."))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.presentationDetents([.medium])
|
.presentationDetents([.large])
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadCategory() {
|
private func loadCategory() {
|
||||||
|
|||||||
@ -51,7 +51,7 @@ struct TodayView: View {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Use 2-column grid on iPad/landscape when multiple rituals
|
// 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
|
ForEach(todayRituals) { ritual in
|
||||||
TodayRitualSectionView(
|
TodayRitualSectionView(
|
||||||
focusTitle: ritual.title,
|
focusTitle: ritual.title,
|
||||||
@ -63,6 +63,7 @@ struct TodayView: View {
|
|||||||
iconName: ritual.iconName,
|
iconName: ritual.iconName,
|
||||||
timeOfDay: ritual.timeOfDay
|
timeOfDay: ritual.timeOfDay
|
||||||
)
|
)
|
||||||
|
.frame(maxHeight: .infinity, alignment: .top)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user