Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
9c5fe23488
commit
6f146fbeff
@ -79,6 +79,7 @@
|
|||||||
Assets.xcassets,
|
Assets.xcassets,
|
||||||
Shared/Configuration/AppIdentifiers.swift,
|
Shared/Configuration/AppIdentifiers.swift,
|
||||||
Shared/Services/RitualAnalytics.swift,
|
Shared/Services/RitualAnalytics.swift,
|
||||||
|
Shared/Theme/RitualsTheme.swift,
|
||||||
);
|
);
|
||||||
target = EAC04D2E2F298D9B007F87EA /* AndromidaWidgetExtension */;
|
target = EAC04D2E2F298D9B007F87EA /* AndromidaWidgetExtension */;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -171,12 +171,7 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
/// Filters based on time of day: morning (before 11am), midday (11am-2pm), afternoon (2pm-5pm),
|
/// Filters based on time of day: morning (before 11am), midday (11am-2pm), afternoon (2pm-5pm),
|
||||||
/// evening (5pm-9pm), night (after 9pm). Anytime rituals are always shown.
|
/// evening (5pm-9pm), night (after 9pm). Anytime rituals are always shown.
|
||||||
func ritualsForToday() -> [Ritual] {
|
func ritualsForToday() -> [Ritual] {
|
||||||
let currentPeriod = TimeOfDay.current()
|
RitualAnalytics.ritualsActive(on: Date(), from: currentRituals)
|
||||||
|
|
||||||
return currentRituals.filter { ritual in
|
|
||||||
guard let arc = ritual.currentArc, arc.contains(date: Date()) else { return false }
|
|
||||||
return ritual.timeOfDay == .anytime || ritual.timeOfDay == currentPeriod
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Groups current rituals by time of day for display
|
/// Groups current rituals by time of day for display
|
||||||
@ -295,25 +290,7 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
|
|
||||||
/// Calculates the current streak (consecutive perfect days ending today or yesterday)
|
/// Calculates the current streak (consecutive perfect days ending today or yesterday)
|
||||||
func currentStreak() -> Int {
|
func currentStreak() -> Int {
|
||||||
let perfect = perfectDays()
|
RitualAnalytics.calculateCurrentStreak(rituals: rituals, calendar: calendar)
|
||||||
guard !perfect.isEmpty else { return 0 }
|
|
||||||
|
|
||||||
var streak = 0
|
|
||||||
var checkDate = calendar.startOfDay(for: Date())
|
|
||||||
|
|
||||||
// Check if today is a perfect day
|
|
||||||
if perfect.contains(dayIdentifier(for: checkDate)) {
|
|
||||||
streak = 1
|
|
||||||
checkDate = calendar.date(byAdding: .day, value: -1, to: checkDate) ?? checkDate
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count consecutive days backwards
|
|
||||||
while perfect.contains(dayIdentifier(for: checkDate)) {
|
|
||||||
streak += 1
|
|
||||||
checkDate = calendar.date(byAdding: .day, value: -1, to: checkDate) ?? checkDate
|
|
||||||
}
|
|
||||||
|
|
||||||
return streak
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculates the longest streak of consecutive perfect days
|
/// Calculates the longest streak of consecutive perfect days
|
||||||
@ -917,25 +894,16 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
/// Returns the completion rate for a specific date, optionally filtered by ritual.
|
/// Returns the completion rate for a specific date, optionally filtered by ritual.
|
||||||
/// This correctly queries habits from arcs that were active on that date.
|
/// This correctly queries habits from arcs that were active on that date.
|
||||||
func completionRate(for date: Date, ritual: Ritual? = nil) -> Double {
|
func completionRate(for date: Date, ritual: Ritual? = nil) -> Double {
|
||||||
let dayID = dayIdentifier(for: date)
|
|
||||||
let habits: [ArcHabit]
|
|
||||||
|
|
||||||
if let ritual = ritual {
|
if let ritual = ritual {
|
||||||
// Get habits from the arc that was active on this date
|
let dayID = dayIdentifier(for: date)
|
||||||
if let arc = ritual.arc(for: date) {
|
guard let arc = ritual.arc(for: date) else { return 0 }
|
||||||
habits = arc.habits ?? []
|
let habits = arc.habits ?? []
|
||||||
} else {
|
guard !habits.isEmpty else { return 0 }
|
||||||
return 0
|
let completed = habits.filter { $0.completedDayIDs.contains(dayID) }.count
|
||||||
}
|
return Double(completed) / Double(habits.count)
|
||||||
} else {
|
} else {
|
||||||
// Get all habits from all arcs that were active on this date
|
return RitualAnalytics.overallCompletionRate(on: date, from: rituals)
|
||||||
habits = habitsActive(on: date)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
guard !habits.isEmpty else { return 0 }
|
|
||||||
|
|
||||||
let completed = habits.filter { $0.completedDayIDs.contains(dayID) }.count
|
|
||||||
return Double(completed) / Double(habits.count)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns all dates that have any habit activity.
|
/// Returns all dates that have any habit activity.
|
||||||
|
|||||||
@ -10,7 +10,7 @@ struct TodayNoRitualsForTimeView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var nextRituals: [Ritual] {
|
private var nextRituals: [Ritual] {
|
||||||
// Find rituals scheduled for later time periods
|
// Find rituals scheduled for later time periods TODAY
|
||||||
store.currentRituals.filter { ritual in
|
store.currentRituals.filter { ritual in
|
||||||
guard let arc = ritual.currentArc, arc.contains(date: Date()) else { return false }
|
guard let arc = ritual.currentArc, arc.contains(date: Date()) else { return false }
|
||||||
return ritual.timeOfDay != .anytime && ritual.timeOfDay > currentTimePeriod
|
return ritual.timeOfDay != .anytime && ritual.timeOfDay > currentTimePeriod
|
||||||
@ -18,18 +18,7 @@ struct TodayNoRitualsForTimeView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var nextRitualTomorrow: Ritual? {
|
private var nextRitualTomorrow: Ritual? {
|
||||||
let calendar = Calendar.current
|
RitualAnalytics.nextUpcomingRitual(from: store.currentRituals)
|
||||||
let tomorrowDate = calendar.startOfDay(
|
|
||||||
for: calendar.date(byAdding: .day, value: 1, to: Date()) ?? Date()
|
|
||||||
)
|
|
||||||
|
|
||||||
return store.currentRituals
|
|
||||||
.filter { ritual in
|
|
||||||
guard let arc = ritual.currentArc, arc.contains(date: tomorrowDate) else { return false }
|
|
||||||
return ritual.timeOfDay != .anytime
|
|
||||||
}
|
|
||||||
.sorted { $0.timeOfDay < $1.timeOfDay }
|
|
||||||
.first
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
|||||||
@ -80,4 +80,28 @@ enum RitualAnalytics {
|
|||||||
let completedCount = allTodayHabits.filter { $0.completedDayIDs.contains(dayID) }.count
|
let completedCount = allTodayHabits.filter { $0.completedDayIDs.contains(dayID) }.count
|
||||||
return Double(completedCount) / Double(allTodayHabits.count)
|
return Double(completedCount) / Double(allTodayHabits.count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Finds the next upcoming ritual (either later today or tomorrow).
|
||||||
|
static func nextUpcomingRitual(from rituals: [Ritual], currentDate: Date = Date(), calendar: Calendar = .current) -> Ritual? {
|
||||||
|
let currentTimePeriod = TimeOfDay.current(for: currentDate)
|
||||||
|
|
||||||
|
// 1. Try to find a ritual later TODAY
|
||||||
|
let laterToday = rituals.filter { ritual in
|
||||||
|
guard let _ = ritual.arcs?.first(where: { $0.isActive && $0.contains(date: currentDate) }) else { return false }
|
||||||
|
return ritual.timeOfDay != .anytime && ritual.timeOfDay > currentTimePeriod
|
||||||
|
}
|
||||||
|
.sorted { $0.timeOfDay < $1.timeOfDay }
|
||||||
|
.first
|
||||||
|
|
||||||
|
if let laterToday { return laterToday }
|
||||||
|
|
||||||
|
// 2. Try to find a ritual TOMORROW
|
||||||
|
let tomorrowDate = calendar.startOfDay(for: calendar.date(byAdding: .day, value: 1, to: currentDate) ?? currentDate)
|
||||||
|
return rituals.filter { ritual in
|
||||||
|
guard let _ = ritual.arcs?.first(where: { $0.isActive && $0.contains(date: tomorrowDate) }) else { return false }
|
||||||
|
return ritual.timeOfDay != .anytime
|
||||||
|
}
|
||||||
|
.sorted { $0.timeOfDay < $1.timeOfDay }
|
||||||
|
.first
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,4 +11,5 @@ struct WidgetEntry: TimelineEntry {
|
|||||||
let currentTimeOfDay: String
|
let currentTimeOfDay: String
|
||||||
let currentTimeOfDaySymbol: String
|
let currentTimeOfDaySymbol: String
|
||||||
let currentTimeOfDayRange: String
|
let currentTimeOfDayRange: String
|
||||||
|
let nextRitualInfo: String?
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,7 +17,8 @@ struct AndromidaWidgetProvider: AppIntentTimelineProvider {
|
|||||||
weeklyTrend: [0.5, 0.7, 0.6, 0.9, 0.8, 0.75, 0.0],
|
weeklyTrend: [0.5, 0.7, 0.6, 0.9, 0.8, 0.75, 0.0],
|
||||||
currentTimeOfDay: "Morning",
|
currentTimeOfDay: "Morning",
|
||||||
currentTimeOfDaySymbol: "sunrise.fill",
|
currentTimeOfDaySymbol: "sunrise.fill",
|
||||||
currentTimeOfDayRange: "Before 11am"
|
currentTimeOfDayRange: "Before 11am",
|
||||||
|
nextRitualInfo: "Next: Drink Water (Midday)"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -85,6 +86,27 @@ struct AndromidaWidgetProvider: AppIntentTimelineProvider {
|
|||||||
// Streak calculation
|
// Streak calculation
|
||||||
let streak = RitualAnalytics.calculateCurrentStreak(rituals: rituals)
|
let streak = RitualAnalytics.calculateCurrentStreak(rituals: rituals)
|
||||||
|
|
||||||
|
// Next ritual info
|
||||||
|
var nextRitualString: String? = nil
|
||||||
|
if let nextRitual = RitualAnalytics.nextUpcomingRitual(from: rituals, currentDate: today) {
|
||||||
|
let isTomorrow = !Calendar.current.isDate(nextRitual.startDate, inSameDayAs: today)
|
||||||
|
if isTomorrow {
|
||||||
|
let format = String(localized: "Next ritual: Tomorrow %@ (%@)")
|
||||||
|
nextRitualString = String.localizedStringWithFormat(
|
||||||
|
format,
|
||||||
|
nextRitual.timeOfDay.displayName,
|
||||||
|
nextRitual.timeOfDay.timeRange
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
let format = String(localized: "Next ritual: %@ (%@)")
|
||||||
|
nextRitualString = String.localizedStringWithFormat(
|
||||||
|
format,
|
||||||
|
nextRitual.timeOfDay.displayName,
|
||||||
|
nextRitual.timeOfDay.timeRange
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return WidgetEntry(
|
return WidgetEntry(
|
||||||
date: today,
|
date: today,
|
||||||
configuration: configuration,
|
configuration: configuration,
|
||||||
@ -94,7 +116,8 @@ struct AndromidaWidgetProvider: AppIntentTimelineProvider {
|
|||||||
weeklyTrend: [],
|
weeklyTrend: [],
|
||||||
currentTimeOfDay: timeOfDay.displayName,
|
currentTimeOfDay: timeOfDay.displayName,
|
||||||
currentTimeOfDaySymbol: timeOfDay.symbolName,
|
currentTimeOfDaySymbol: timeOfDay.symbolName,
|
||||||
currentTimeOfDayRange: timeOfDay.timeRange
|
currentTimeOfDayRange: timeOfDay.timeRange,
|
||||||
|
nextRitualInfo: nextRitualString
|
||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
// Return a default entry instead of placeholder(in: .preview)
|
// Return a default entry instead of placeholder(in: .preview)
|
||||||
@ -107,7 +130,8 @@ struct AndromidaWidgetProvider: AppIntentTimelineProvider {
|
|||||||
weeklyTrend: [],
|
weeklyTrend: [],
|
||||||
currentTimeOfDay: "Today",
|
currentTimeOfDay: "Today",
|
||||||
currentTimeOfDaySymbol: "clock.fill",
|
currentTimeOfDaySymbol: "clock.fill",
|
||||||
currentTimeOfDayRange: ""
|
currentTimeOfDayRange: "",
|
||||||
|
nextRitualInfo: nil
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,58 +10,62 @@ struct LargeWidgetView: View {
|
|||||||
HStack {
|
HStack {
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
|
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
|
||||||
Text(String(localized: "Today's Progress"))
|
Text(String(localized: "Today's Progress"))
|
||||||
.styled(.heading, emphasis: .custom(.white))
|
.styled(.heading, emphasis: .custom(AppTextColors.primary))
|
||||||
Text("\(entry.currentStreak) day streak")
|
Text("\(entry.currentStreak) day streak")
|
||||||
.styled(.subheading, emphasis: .custom(Color.brandingAccent))
|
.styled(.subheading, emphasis: .custom(AppAccent.primary))
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
ZStack {
|
ZStack {
|
||||||
Circle()
|
Circle()
|
||||||
.stroke(Color.white.opacity(0.1), lineWidth: 6)
|
.stroke(AppTextColors.primary.opacity(0.1), lineWidth: 6)
|
||||||
Circle()
|
Circle()
|
||||||
.trim(from: 0, to: entry.completionRate)
|
.trim(from: 0, to: entry.completionRate)
|
||||||
.stroke(Color.brandingAccent, style: StrokeStyle(lineWidth: 6, lineCap: .round))
|
.stroke(AppAccent.primary, style: StrokeStyle(lineWidth: 6, lineCap: .round))
|
||||||
.rotationEffect(.degrees(-90))
|
.rotationEffect(.degrees(-90))
|
||||||
Text("\(Int(entry.completionRate * 100))%")
|
Text("\(Int(entry.completionRate * 100))%")
|
||||||
.styled(.captionEmphasis, emphasis: .custom(.white))
|
.styled(.captionEmphasis, emphasis: .custom(AppTextColors.primary))
|
||||||
}
|
}
|
||||||
.frame(width: 50, height: 50)
|
.frame(width: 50, height: 50)
|
||||||
}
|
}
|
||||||
|
.padding(.top, Design.Spacing.small)
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
.background(Color.white.opacity(0.2))
|
.background(AppTextColors.primary.opacity(0.2))
|
||||||
|
|
||||||
if entry.nextHabits.isEmpty {
|
if entry.nextHabits.isEmpty {
|
||||||
Spacer()
|
Spacer()
|
||||||
WidgetEmptyStateView(
|
WidgetEmptyStateView(
|
||||||
|
iconSize: .section,
|
||||||
title: String(localized: "No rituals scheduled for \(entry.currentTimeOfDay.lowercased())."),
|
title: String(localized: "No rituals scheduled for \(entry.currentTimeOfDay.lowercased())."),
|
||||||
subtitle: entry.currentTimeOfDay,
|
subtitle: entry.currentTimeOfDay,
|
||||||
symbolName: entry.currentTimeOfDaySymbol,
|
symbolName: entry.currentTimeOfDaySymbol,
|
||||||
timeRange: entry.currentTimeOfDayRange
|
timeRange: entry.currentTimeOfDayRange,
|
||||||
|
nextRitual: entry.nextRitualInfo,
|
||||||
|
isCompact: false
|
||||||
)
|
)
|
||||||
Spacer()
|
Spacer()
|
||||||
} else {
|
} else {
|
||||||
Text(String(localized: "Habits"))
|
Text(String(localized: "Habits"))
|
||||||
.styled(.captionEmphasis, emphasis: .custom(.white.opacity(0.7)))
|
.styled(.captionEmphasis, emphasis: .custom(AppTextColors.secondary))
|
||||||
|
|
||||||
VStack(spacing: Design.Spacing.medium) {
|
VStack(spacing: Design.Spacing.medium) {
|
||||||
ForEach(entry.nextHabits) { habit in
|
ForEach(entry.nextHabits) { habit in
|
||||||
HStack(spacing: Design.Spacing.medium) {
|
HStack(spacing: Design.Spacing.medium) {
|
||||||
Image(systemName: habit.symbolName)
|
Image(systemName: habit.symbolName)
|
||||||
.foregroundColor(Color.brandingAccent)
|
.foregroundColor(AppAccent.primary)
|
||||||
.font(.system(size: 18))
|
.font(.system(size: 18))
|
||||||
.frame(width: 24)
|
.frame(width: 24)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
|
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
|
||||||
Text(habit.title)
|
Text(habit.title)
|
||||||
.styled(.subheading, emphasis: .custom(.white))
|
.styled(.subheading, emphasis: .custom(AppTextColors.primary))
|
||||||
Text(habit.ritualTitle)
|
Text(habit.ritualTitle)
|
||||||
.styled(.caption, emphasis: .custom(.white.opacity(0.5)))
|
.styled(.caption, emphasis: .custom(AppTextColors.tertiary))
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Image(systemName: habit.isCompleted ? "checkmark.circle.fill" : "circle")
|
Image(systemName: habit.isCompleted ? "checkmark.circle.fill" : "circle")
|
||||||
.foregroundColor(habit.isCompleted ? .green : .white.opacity(0.2))
|
.foregroundColor(habit.isCompleted ? .green : AppTextColors.primary.opacity(0.2))
|
||||||
.font(.system(size: 20))
|
.font(.system(size: 20))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -72,7 +76,7 @@ struct LargeWidgetView: View {
|
|||||||
}
|
}
|
||||||
.padding(Design.Spacing.large)
|
.padding(Design.Spacing.large)
|
||||||
.containerBackground(for: .widget) {
|
.containerBackground(for: .widget) {
|
||||||
Color.brandingPrimary
|
AppSurface.primary
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,27 +11,27 @@ struct MediumWidgetView: View {
|
|||||||
VStack(spacing: Design.Spacing.medium) {
|
VStack(spacing: Design.Spacing.medium) {
|
||||||
ZStack {
|
ZStack {
|
||||||
Circle()
|
Circle()
|
||||||
.stroke(Color.white.opacity(0.1), lineWidth: 8)
|
.stroke(AppTextColors.primary.opacity(0.1), lineWidth: 8)
|
||||||
Circle()
|
Circle()
|
||||||
.trim(from: 0, to: entry.completionRate)
|
.trim(from: 0, to: entry.completionRate)
|
||||||
.stroke(Color.brandingAccent, style: StrokeStyle(lineWidth: 8, lineCap: .round))
|
.stroke(AppAccent.primary, style: StrokeStyle(lineWidth: 8, lineCap: .round))
|
||||||
.rotationEffect(.degrees(-90))
|
.rotationEffect(.degrees(-90))
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Text("\(Int(entry.completionRate * 100))%")
|
Text("\(Int(entry.completionRate * 100))%")
|
||||||
.styled(.heading, emphasis: .custom(.white))
|
.styled(.heading, emphasis: .custom(AppTextColors.primary))
|
||||||
Text(String(localized: "Today"))
|
Text(String(localized: "Today"))
|
||||||
.styled(.caption, emphasis: .custom(.white.opacity(0.7)))
|
.styled(.caption, emphasis: .custom(AppTextColors.secondary))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(width: 72, height: 72)
|
.frame(width: 72, height: 72)
|
||||||
|
|
||||||
HStack(spacing: Design.Spacing.xSmall) {
|
HStack(spacing: Design.Spacing.xSmall) {
|
||||||
Image(systemName: "flame.fill")
|
Image(systemName: "flame.fill")
|
||||||
.foregroundColor(Color.brandingAccent)
|
.foregroundColor(AppAccent.primary)
|
||||||
.font(.system(size: 14))
|
.font(.system(size: 14))
|
||||||
Text("\(entry.currentStreak)d")
|
Text("\(entry.currentStreak)d")
|
||||||
.styled(.captionEmphasis, emphasis: .custom(.white))
|
.styled(.captionEmphasis, emphasis: .custom(AppTextColors.primary))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(width: 110)
|
.frame(width: 110)
|
||||||
@ -39,26 +39,29 @@ struct MediumWidgetView: View {
|
|||||||
// Right side: Habits
|
// Right side: Habits
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
||||||
if entry.nextHabits.isEmpty {
|
if entry.nextHabits.isEmpty {
|
||||||
WidgetEmptyStateView(
|
WidgetEmptyStateView(
|
||||||
title: String(localized: "No rituals now"),
|
iconSize: .card,
|
||||||
subtitle: entry.currentTimeOfDay,
|
title: String(localized: "No rituals now"),
|
||||||
symbolName: entry.currentTimeOfDaySymbol,
|
subtitle: entry.currentTimeOfDay,
|
||||||
timeRange: entry.currentTimeOfDayRange
|
symbolName: entry.currentTimeOfDaySymbol,
|
||||||
)
|
timeRange: entry.currentTimeOfDayRange,
|
||||||
|
nextRitual: entry.nextRitualInfo,
|
||||||
|
isCompact: true
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
Text(String(localized: "Next Habits"))
|
Text(String(localized: "Next Habits"))
|
||||||
.styled(.captionEmphasis, emphasis: .custom(.white.opacity(0.7)))
|
.styled(.captionEmphasis, emphasis: .custom(AppTextColors.secondary))
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||||
ForEach(entry.nextHabits.prefix(3)) { habit in
|
ForEach(entry.nextHabits.prefix(3)) { habit in
|
||||||
HStack(spacing: Design.Spacing.small) {
|
HStack(spacing: Design.Spacing.small) {
|
||||||
Image(systemName: habit.isCompleted ? "checkmark.circle.fill" : habit.symbolName)
|
Image(systemName: habit.isCompleted ? "checkmark.circle.fill" : habit.symbolName)
|
||||||
.foregroundColor(habit.isCompleted ? .green : Color.brandingAccent)
|
.foregroundColor(habit.isCompleted ? .green : AppAccent.primary)
|
||||||
.font(.system(size: 14))
|
.font(.system(size: 14))
|
||||||
.frame(width: 20)
|
.frame(width: 20)
|
||||||
|
|
||||||
Text(habit.title)
|
Text(habit.title)
|
||||||
.styled(.subheading, emphasis: .custom(.white))
|
.styled(.subheading, emphasis: .custom(AppTextColors.primary))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -73,7 +76,7 @@ struct MediumWidgetView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.containerBackground(for: .widget) {
|
.containerBackground(for: .widget) {
|
||||||
Color.brandingPrimary
|
AppSurface.primary
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,30 +9,30 @@ struct SmallWidgetView: View {
|
|||||||
VStack(spacing: Design.Spacing.small) {
|
VStack(spacing: Design.Spacing.small) {
|
||||||
ZStack {
|
ZStack {
|
||||||
Circle()
|
Circle()
|
||||||
.stroke(Color.white.opacity(0.1), lineWidth: 8)
|
.stroke(AppTextColors.primary.opacity(0.1), lineWidth: 8)
|
||||||
Circle()
|
Circle()
|
||||||
.trim(from: 0, to: entry.completionRate)
|
.trim(from: 0, to: entry.completionRate)
|
||||||
.stroke(Color.brandingAccent, style: StrokeStyle(lineWidth: 8, lineCap: .round))
|
.stroke(AppAccent.primary, style: StrokeStyle(lineWidth: 8, lineCap: .round))
|
||||||
.rotationEffect(.degrees(-90))
|
.rotationEffect(.degrees(-90))
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Text("\(Int(entry.completionRate * 100))%")
|
Text("\(Int(entry.completionRate * 100))%")
|
||||||
.styled(.heading, emphasis: .custom(.white))
|
.styled(.heading, emphasis: .custom(AppTextColors.primary))
|
||||||
Text(String(localized: "Today"))
|
Text(String(localized: "Today"))
|
||||||
.styled(.caption, emphasis: .custom(.white.opacity(0.7)))
|
.styled(.caption, emphasis: .custom(AppTextColors.secondary))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(width: 80, height: 80)
|
.frame(width: 80, height: 80)
|
||||||
|
|
||||||
HStack(spacing: Design.Spacing.xSmall) {
|
HStack(spacing: Design.Spacing.xSmall) {
|
||||||
Image(systemName: "flame.fill")
|
Image(systemName: "flame.fill")
|
||||||
.foregroundColor(Color.brandingAccent)
|
.foregroundColor(AppAccent.primary)
|
||||||
Text("\(entry.currentStreak) day streak")
|
Text("\(entry.currentStreak) day streak")
|
||||||
.styled(.captionEmphasis, emphasis: .custom(.white))
|
.styled(.captionEmphasis, emphasis: .custom(AppTextColors.primary))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.containerBackground(for: .widget) {
|
.containerBackground(for: .widget) {
|
||||||
Color.brandingPrimary
|
AppSurface.primary
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,30 +2,57 @@ import SwiftUI
|
|||||||
import Bedrock
|
import Bedrock
|
||||||
|
|
||||||
struct WidgetEmptyStateView: View {
|
struct WidgetEmptyStateView: View {
|
||||||
|
let iconSize: SymbolIcon.Size
|
||||||
let title: String
|
let title: String
|
||||||
let subtitle: String
|
let subtitle: String
|
||||||
let symbolName: String
|
let symbolName: String
|
||||||
let timeRange: String
|
let timeRange: String
|
||||||
|
let nextRitual: String?
|
||||||
|
let isCompact: Bool
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: Design.Spacing.small) {
|
VStack(spacing: Design.Spacing.small) {
|
||||||
SymbolIcon(symbolName, size: .hero, color: Color.brandingAccent.opacity(0.6))
|
if isCompact {
|
||||||
|
HStack(alignment: .center, spacing: Design.Spacing.small){
|
||||||
VStack(spacing: Design.Spacing.xSmall) {
|
iconView
|
||||||
Text(title)
|
titleView
|
||||||
.styled(.subheading, emphasis: .custom(.white))
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
|
|
||||||
if !timeRange.isEmpty {
|
|
||||||
Text(timeRange)
|
|
||||||
.styled(.caption, emphasis: .custom(.white.opacity(0.5)))
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
iconView
|
||||||
|
titleView
|
||||||
|
}
|
||||||
|
if let nextRitual {
|
||||||
|
Text(nextRitual)
|
||||||
|
.styled(.caption, emphasis: .custom(AppTextColors.secondary))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.top, Design.Spacing.small)
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(String(localized: "Enjoy this moment."))
|
Text(String(localized: "Enjoy this moment. Your next ritual will appear when it's time."))
|
||||||
.styled(.caption, emphasis: .custom(.white.opacity(0.5)))
|
.styled(.caption, emphasis: .custom(AppTextColors.tertiary))
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
|
.lineLimit(2)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.padding(.top, Design.Spacing.small)
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal, Design.Spacing.small)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var iconView: some View {
|
||||||
|
SymbolIcon(symbolName, size: iconSize, color: AppAccent.primary.opacity(0.6))
|
||||||
|
}
|
||||||
|
|
||||||
|
var titleView: some View {
|
||||||
|
VStack(spacing: Design.Spacing.xSmall) {
|
||||||
|
Text(title)
|
||||||
|
.styled(.subheading, emphasis: .custom(AppTextColors.primary))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
if !timeRange.isEmpty {
|
||||||
|
Text(timeRange)
|
||||||
|
.styled(.caption, emphasis: .custom(AppTextColors.tertiary))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user