Compare commits
10 Commits
e7648ddd8a
...
5fd50e1a84
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fd50e1a84 | ||
|
|
b0cd4be8d7 | ||
|
|
7a6c4056d8 | ||
|
|
70400b7790 | ||
|
|
329fb7c671 | ||
|
|
ab8d8fbdc0 | ||
|
|
43ff239781 | ||
|
|
1f040ab676 | ||
|
|
4d9e906c4d | ||
|
|
ed8205cd88 |
@@ -22,8 +22,9 @@ struct ContentView: View {
|
|||||||
// Show voting UI
|
// Show voting UI
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 8) {
|
||||||
Text("How do you feel?")
|
Text("How do you feel?")
|
||||||
.font(.system(size: 16, weight: .medium))
|
.font(.headline)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
.accessibilityAddTraits(.isHeader)
|
||||||
|
|
||||||
// Top row: Great, Good, Average
|
// Top row: Great, Good, Average
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
@@ -87,11 +88,14 @@ struct AlreadyRatedView: View {
|
|||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
Text(mood.watchEmoji)
|
Text(mood.watchEmoji)
|
||||||
.font(.system(size: 50))
|
.font(.system(size: 50))
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
|
||||||
Text("Logged!")
|
Text("Logged!")
|
||||||
.font(.system(size: 18, weight: .semibold))
|
.font(.title3.weight(.semibold))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
.accessibilityLabel(String(localized: "\(mood.strValue) mood logged"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,13 +108,16 @@ struct MoodButton: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: action) {
|
Button(action: action) {
|
||||||
Text(mood.watchEmoji)
|
Text(mood.watchEmoji)
|
||||||
.font(.system(size: 28))
|
.font(.title2)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.frame(height: 50)
|
.frame(height: 50)
|
||||||
.background(mood.watchColor.opacity(0.3))
|
.background(mood.watchColor.opacity(0.3))
|
||||||
.cornerRadius(12)
|
.cornerRadius(12)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Watch.moodButton(mood.strValue))
|
||||||
|
.accessibilityLabel(String(localized: "Log \(mood.strValue) mood"))
|
||||||
|
.accessibilityHint(String(localized: "Double tap to log your mood as \(mood.strValue)"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<array>
|
<array>
|
||||||
<string>com.88oakapps.reflect.dbUpdateMissing</string>
|
<string>com.88oakapps.reflect.dbUpdateMissing</string>
|
||||||
<string>com.88oakapps.reflect.weatherRetry</string>
|
<string>com.88oakapps.reflect.weatherRetry</string>
|
||||||
|
<string>com.88oakapps.reflect.weeklyDigest</string>
|
||||||
</array>
|
</array>
|
||||||
<key>NSLocationWhenInUseUsageDescription</key>
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
<string>Reflect uses your location to show weather details for your mood entries.</string>
|
<string>Reflect uses your location to show weather details for your mood entries.</string>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -24,9 +24,12 @@ struct MoodStreakLiveActivity: Widget {
|
|||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Image(systemName: "flame.fill")
|
Image(systemName: "flame.fill")
|
||||||
.foregroundColor(.orange)
|
.foregroundColor(.orange)
|
||||||
|
.accessibilityHidden(true)
|
||||||
Text("\(context.state.currentStreak)")
|
Text("\(context.state.currentStreak)")
|
||||||
.font(.title2.bold())
|
.font(.title2.bold())
|
||||||
}
|
}
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
.accessibilityLabel(String(localized: "\(context.state.currentStreak) day streak"))
|
||||||
}
|
}
|
||||||
|
|
||||||
DynamicIslandExpandedRegion(.trailing) {
|
DynamicIslandExpandedRegion(.trailing) {
|
||||||
@@ -34,6 +37,7 @@ struct MoodStreakLiveActivity: Widget {
|
|||||||
Image(systemName: "checkmark.circle.fill")
|
Image(systemName: "checkmark.circle.fill")
|
||||||
.foregroundColor(.green)
|
.foregroundColor(.green)
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
|
.accessibilityLabel(String(localized: "Mood logged today"))
|
||||||
} else {
|
} else {
|
||||||
Text("Log now")
|
Text("Log now")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
@@ -56,20 +60,25 @@ struct MoodStreakLiveActivity: Widget {
|
|||||||
Circle()
|
Circle()
|
||||||
.fill(Color(hex: context.state.lastMoodColor))
|
.fill(Color(hex: context.state.lastMoodColor))
|
||||||
.frame(width: 20, height: 20)
|
.frame(width: 20, height: 20)
|
||||||
|
.accessibilityHidden(true)
|
||||||
Text("Today: \(context.state.lastMoodLogged)")
|
Text("Today: \(context.state.lastMoodLogged)")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
}
|
}
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} compactLeading: {
|
} compactLeading: {
|
||||||
Image(systemName: "flame.fill")
|
Image(systemName: "flame.fill")
|
||||||
.foregroundColor(.orange)
|
.foregroundColor(.orange)
|
||||||
|
.accessibilityLabel(String(localized: "Streak"))
|
||||||
} compactTrailing: {
|
} compactTrailing: {
|
||||||
Text("\(context.state.currentStreak)")
|
Text("\(context.state.currentStreak)")
|
||||||
.font(.caption.bold())
|
.font(.caption.bold())
|
||||||
|
.accessibilityLabel(String(localized: "\(context.state.currentStreak) days"))
|
||||||
} minimal: {
|
} minimal: {
|
||||||
Image(systemName: "flame.fill")
|
Image(systemName: "flame.fill")
|
||||||
.foregroundColor(.orange)
|
.foregroundColor(.orange)
|
||||||
|
.accessibilityLabel(String(localized: "Mood streak"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,12 +96,15 @@ struct MoodStreakLockScreenView: View {
|
|||||||
Image(systemName: "flame.fill")
|
Image(systemName: "flame.fill")
|
||||||
.font(.title)
|
.font(.title)
|
||||||
.foregroundColor(.orange)
|
.foregroundColor(.orange)
|
||||||
|
.accessibilityHidden(true)
|
||||||
Text("\(context.state.currentStreak)")
|
Text("\(context.state.currentStreak)")
|
||||||
.font(.title.bold())
|
.font(.title.bold())
|
||||||
Text("day streak")
|
Text("day streak")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
.accessibilityLabel(String(localized: "\(context.state.currentStreak) day streak"))
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
.frame(height: 50)
|
.frame(height: 50)
|
||||||
@@ -104,6 +116,7 @@ struct MoodStreakLockScreenView: View {
|
|||||||
Circle()
|
Circle()
|
||||||
.fill(Color(hex: context.state.lastMoodColor))
|
.fill(Color(hex: context.state.lastMoodColor))
|
||||||
.frame(width: 24, height: 24)
|
.frame(width: 24, height: 24)
|
||||||
|
.accessibilityHidden(true)
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
Text("Today's mood")
|
Text("Today's mood")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
@@ -112,6 +125,7 @@ struct MoodStreakLockScreenView: View {
|
|||||||
.font(.headline)
|
.font(.headline)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
} else {
|
} else {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
Text(context.state.currentStreak > 0 ? "Don't break your streak!" : "Start your streak!")
|
Text(context.state.currentStreak > 0 ? "Don't break your streak!" : "Start your streak!")
|
||||||
|
|||||||
@@ -82,6 +82,8 @@ struct SmallWidgetView: View {
|
|||||||
return f
|
return f
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var isSampleData: Bool
|
||||||
|
|
||||||
init(entry: Provider.Entry) {
|
init(entry: Provider.Entry) {
|
||||||
self.entry = entry
|
self.entry = entry
|
||||||
let realData = TimeLineCreator.createViews(daysBack: 2)
|
let realData = TimeLineCreator.createViews(daysBack: 2)
|
||||||
@@ -89,6 +91,7 @@ struct SmallWidgetView: View {
|
|||||||
let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable()
|
let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable()
|
||||||
return view.color != moodTint.color(forMood: .missing)
|
return view.color != moodTint.color(forMood: .missing)
|
||||||
}
|
}
|
||||||
|
isSampleData = !hasRealData
|
||||||
todayView = hasRealData ? realData.first : TimeLineCreator.createSampleViews(count: 1).first
|
todayView = hasRealData ? realData.first : TimeLineCreator.createSampleViews(count: 1).first
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +101,13 @@ struct SmallWidgetView: View {
|
|||||||
VotingView(family: .systemSmall, promptText: entry.promptText, hasSubscription: entry.hasSubscription)
|
VotingView(family: .systemSmall, promptText: entry.promptText, hasSubscription: entry.hasSubscription)
|
||||||
} else if let today = todayView {
|
} else if let today = todayView {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
|
if isSampleData {
|
||||||
|
Text(String(localized: "Log your first mood!"))
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
// Large mood icon
|
// Large mood icon
|
||||||
@@ -152,6 +162,8 @@ struct MediumWidgetView: View {
|
|||||||
return f
|
return f
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var isSampleData: Bool
|
||||||
|
|
||||||
init(entry: Provider.Entry) {
|
init(entry: Provider.Entry) {
|
||||||
self.entry = entry
|
self.entry = entry
|
||||||
let realData = Array(TimeLineCreator.createViews(daysBack: 6).prefix(5))
|
let realData = Array(TimeLineCreator.createViews(daysBack: 6).prefix(5))
|
||||||
@@ -159,6 +171,7 @@ struct MediumWidgetView: View {
|
|||||||
let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable()
|
let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable()
|
||||||
return view.color != moodTint.color(forMood: .missing)
|
return view.color != moodTint.color(forMood: .missing)
|
||||||
}
|
}
|
||||||
|
isSampleData = !hasRealData
|
||||||
timeLineView = hasRealData ? realData : TimeLineCreator.createSampleViews(count: 5)
|
timeLineView = hasRealData ? realData : TimeLineCreator.createSampleViews(count: 5)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,11 +196,19 @@ struct MediumWidgetView: View {
|
|||||||
Text("Last 5 Days")
|
Text("Last 5 Days")
|
||||||
.font(.subheadline.weight(.semibold))
|
.font(.subheadline.weight(.semibold))
|
||||||
.foregroundStyle(.primary)
|
.foregroundStyle(.primary)
|
||||||
Text("·")
|
if isSampleData {
|
||||||
.foregroundStyle(.secondary)
|
Text("·")
|
||||||
Text(headerDateRange)
|
.foregroundStyle(.secondary)
|
||||||
.font(.caption)
|
Text(String(localized: "Log your first mood!"))
|
||||||
.foregroundStyle(.secondary)
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
Text("·")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(headerDateRange)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
@@ -264,6 +285,8 @@ struct LargeWidgetView: View {
|
|||||||
!entry.hasVotedToday
|
!entry.hasVotedToday
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var isSampleData: Bool
|
||||||
|
|
||||||
init(entry: Provider.Entry) {
|
init(entry: Provider.Entry) {
|
||||||
self.entry = entry
|
self.entry = entry
|
||||||
let realData = Array(TimeLineCreator.createViews(daysBack: 11).prefix(10))
|
let realData = Array(TimeLineCreator.createViews(daysBack: 11).prefix(10))
|
||||||
@@ -271,6 +294,7 @@ struct LargeWidgetView: View {
|
|||||||
let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable()
|
let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable()
|
||||||
return view.color != moodTint.color(forMood: .missing)
|
return view.color != moodTint.color(forMood: .missing)
|
||||||
}
|
}
|
||||||
|
isSampleData = !hasRealData
|
||||||
timeLineView = hasRealData ? realData : TimeLineCreator.createSampleViews(count: 10)
|
timeLineView = hasRealData ? realData : TimeLineCreator.createSampleViews(count: 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,7 +325,7 @@ struct LargeWidgetView: View {
|
|||||||
Text("Last 10 Days")
|
Text("Last 10 Days")
|
||||||
.font(.subheadline.weight(.semibold))
|
.font(.subheadline.weight(.semibold))
|
||||||
.foregroundStyle(.primary)
|
.foregroundStyle(.primary)
|
||||||
Text(headerDateRange)
|
Text(isSampleData ? String(localized: "Log your first mood!") : headerDateRange)
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,10 +158,13 @@ struct VotedStatsView: View {
|
|||||||
Circle()
|
Circle()
|
||||||
.fill(moodTint.color(forMood: mood))
|
.fill(moodTint.color(forMood: mood))
|
||||||
.frame(width: 8, height: 8)
|
.frame(width: 8, height: 8)
|
||||||
|
.accessibilityHidden(true)
|
||||||
Text("\(count)")
|
Text("\(count)")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
.accessibilityLabel("\(count) \(mood.strValue)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -214,6 +217,7 @@ struct NonSubscriberView: View {
|
|||||||
}
|
}
|
||||||
.accessibilityLabel(String(localized: "Track Your Mood"))
|
.accessibilityLabel(String(localized: "Track Your Mood"))
|
||||||
.accessibilityHint(String(localized: "Tap to open app and subscribe"))
|
.accessibilityHint(String(localized: "Tap to open app and subscribe"))
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Widget.subscribeLink)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,8 +58,8 @@ struct VotingView: View {
|
|||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Top 50%: Text left-aligned, vertically centered
|
// Top 50%: Text left-aligned, vertically centered
|
||||||
HStack {
|
HStack {
|
||||||
Text(hasSubscription ? promptText : "Subscribe to track your mood")
|
Text(hasSubscription ? promptText : String(localized: "Subscribe to track your mood"))
|
||||||
.font(.system(size: 20, weight: .semibold))
|
.font(.title3.weight(.semibold))
|
||||||
.foregroundStyle(.primary)
|
.foregroundStyle(.primary)
|
||||||
.multilineTextAlignment(.leading)
|
.multilineTextAlignment(.leading)
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
@@ -93,6 +93,7 @@ struct VotingView: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel(mood.strValue)
|
.accessibilityLabel(mood.strValue)
|
||||||
.accessibilityHint(String(localized: "Log this mood"))
|
.accessibilityHint(String(localized: "Log this mood"))
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Widget.voteMoodButton(mood.strValue))
|
||||||
} else {
|
} else {
|
||||||
Link(destination: URL(string: "reflect://subscribe")!) {
|
Link(destination: URL(string: "reflect://subscribe")!) {
|
||||||
moodIcon(for: mood, size: size)
|
moodIcon(for: mood, size: size)
|
||||||
@@ -100,6 +101,7 @@ struct VotingView: View {
|
|||||||
}
|
}
|
||||||
.accessibilityLabel(mood.strValue)
|
.accessibilityLabel(mood.strValue)
|
||||||
.accessibilityHint(String(localized: "Open app to subscribe"))
|
.accessibilityHint(String(localized: "Open app to subscribe"))
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Widget.subscribeLink)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,12 +121,14 @@ struct VotingView: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel(mood.strValue)
|
.accessibilityLabel(mood.strValue)
|
||||||
.accessibilityHint(String(localized: "Log this mood"))
|
.accessibilityHint(String(localized: "Log this mood"))
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Widget.voteMoodButton(mood.strValue))
|
||||||
} else {
|
} else {
|
||||||
Link(destination: URL(string: "reflect://subscribe")!) {
|
Link(destination: URL(string: "reflect://subscribe")!) {
|
||||||
content
|
content
|
||||||
}
|
}
|
||||||
.accessibilityLabel(mood.strValue)
|
.accessibilityLabel(mood.strValue)
|
||||||
.accessibilityHint(String(localized: "Open app to subscribe"))
|
.accessibilityHint(String(localized: "Open app to subscribe"))
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Widget.subscribeLink)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,8 +159,8 @@ struct LargeVotingView: View {
|
|||||||
GeometryReader { geo in
|
GeometryReader { geo in
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Top 33%: Title centered
|
// Top 33%: Title centered
|
||||||
Text(hasSubscription ? promptText : "Subscribe to track your mood")
|
Text(hasSubscription ? promptText : String(localized: "Subscribe to track your mood"))
|
||||||
.font(.system(size: 24, weight: .semibold))
|
.font(.title2.weight(.semibold))
|
||||||
.foregroundStyle(.primary)
|
.foregroundStyle(.primary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
@@ -196,12 +200,14 @@ struct LargeVotingView: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel(mood.strValue)
|
.accessibilityLabel(mood.strValue)
|
||||||
.accessibilityHint(String(localized: "Log this mood"))
|
.accessibilityHint(String(localized: "Log this mood"))
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Widget.voteMoodButton(mood.strValue))
|
||||||
} else {
|
} else {
|
||||||
Link(destination: URL(string: "reflect://subscribe")!) {
|
Link(destination: URL(string: "reflect://subscribe")!) {
|
||||||
moodButtonContent(for: mood)
|
moodButtonContent(for: mood)
|
||||||
}
|
}
|
||||||
.accessibilityLabel(mood.strValue)
|
.accessibilityLabel(mood.strValue)
|
||||||
.accessibilityHint(String(localized: "Open app to subscribe"))
|
.accessibilityHint(String(localized: "Open app to subscribe"))
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Widget.subscribeLink)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,12 +267,14 @@ struct InlineVotingView: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel(mood.strValue)
|
.accessibilityLabel(mood.strValue)
|
||||||
.accessibilityHint(String(localized: "Log this mood"))
|
.accessibilityHint(String(localized: "Log this mood"))
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Widget.voteMoodButton(mood.strValue))
|
||||||
} else {
|
} else {
|
||||||
Link(destination: URL(string: "reflect://subscribe")!) {
|
Link(destination: URL(string: "reflect://subscribe")!) {
|
||||||
moodIcon(for: mood)
|
moodIcon(for: mood)
|
||||||
}
|
}
|
||||||
.accessibilityLabel(mood.strValue)
|
.accessibilityLabel(mood.strValue)
|
||||||
.accessibilityHint(String(localized: "Open app to subscribe"))
|
.accessibilityHint(String(localized: "Open app to subscribe"))
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Widget.subscribeLink)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -97,6 +97,21 @@ enum AccessibilityID {
|
|||||||
static let baLearnMoreLink = "guided_reflection_ba_learn_more"
|
static let baLearnMoreLink = "guided_reflection_ba_learn_more"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Reflection Feedback
|
||||||
|
enum ReflectionFeedback {
|
||||||
|
static let container = "reflection_feedback_container"
|
||||||
|
static let loading = "reflection_feedback_loading"
|
||||||
|
static let content = "reflection_feedback_content"
|
||||||
|
static let fallback = "reflection_feedback_fallback"
|
||||||
|
static let doneButton = "reflection_feedback_done"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Weekly Digest
|
||||||
|
enum WeeklyDigest {
|
||||||
|
static let card = "weekly_digest_card"
|
||||||
|
static let dismissButton = "weekly_digest_dismiss"
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Settings
|
// MARK: - Settings
|
||||||
enum Settings {
|
enum Settings {
|
||||||
static let header = "settings_header"
|
static let header = "settings_header"
|
||||||
@@ -120,6 +135,45 @@ enum AccessibilityID {
|
|||||||
static let reminderTimePicker = "settings_reminder_time_picker"
|
static let reminderTimePicker = "settings_reminder_time_picker"
|
||||||
static let reminderSaveButton = "settings_reminder_save"
|
static let reminderSaveButton = "settings_reminder_save"
|
||||||
static let reminderCancelButton = "settings_reminder_cancel"
|
static let reminderCancelButton = "settings_reminder_cancel"
|
||||||
|
static let reminderTimeButton = "settings_reminder_time"
|
||||||
|
static let changeTrialDateButton = "settings_change_trial_date"
|
||||||
|
static let trialDatePickerDoneButton = "settings_trial_date_done"
|
||||||
|
static let trialDatePicker = "settings_trial_date_picker"
|
||||||
|
static let paywallPreviewButton = "settings_paywall_preview"
|
||||||
|
static let tipsPreviewButton = "settings_tips_preview"
|
||||||
|
static let testNotificationsButton = "settings_test_notifications"
|
||||||
|
static let exportWidgetsButton = "settings_export_widgets"
|
||||||
|
static let exportVotingLayoutsButton = "settings_export_voting_layouts"
|
||||||
|
static let exportWatchViewsButton = "settings_export_watch_views"
|
||||||
|
static let exportInsightsButton = "settings_export_insights"
|
||||||
|
static let generateScreenshotsButton = "settings_generate_screenshots"
|
||||||
|
static let addTestDataButton = "settings_add_test_data"
|
||||||
|
static let deleteHealthKitButton = "settings_delete_health_kit"
|
||||||
|
static let locationAlertOpenSettingsButton = "settings_location_open_settings"
|
||||||
|
static let locationAlertCancelButton = "settings_location_cancel"
|
||||||
|
static let fontAwesomeLink = "settings_font_awesome_link"
|
||||||
|
static let chartsLink = "settings_charts_link"
|
||||||
|
static let exportDataButton = "settings_export_data"
|
||||||
|
static let closeButton = "settings_close"
|
||||||
|
static let resetLaunchDateButton = "settings_reset_launch_date"
|
||||||
|
static let fixWeekdayButton = "settings_fix_weekday"
|
||||||
|
static let whyBackgroundModeButton = "settings_why_bg_mode"
|
||||||
|
static let exportLegacyButton = "settings_export_legacy"
|
||||||
|
static let importButton = "settings_import"
|
||||||
|
static let randomIconsButton = "settings_random_icons"
|
||||||
|
static let doneButton = "settings_done"
|
||||||
|
static let specialThanksButton = "settings_special_thanks"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - TipModal
|
||||||
|
enum TipModal {
|
||||||
|
static let dismissButton = "tip_modal_dismiss"
|
||||||
|
static let resetTipsButton = "tip_modal_reset_tips"
|
||||||
|
static let tipsEnabledToggle = "tip_modal_tips_enabled"
|
||||||
|
static let doneButton = "tip_modal_done"
|
||||||
|
static func tipPreviewButton(_ index: Int) -> String {
|
||||||
|
"tip_modal_preview_\(index)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Customize
|
// MARK: - Customize
|
||||||
@@ -129,6 +183,7 @@ enum AccessibilityID {
|
|||||||
static let appThemePickerDoneButton = "apptheme_picker_done"
|
static let appThemePickerDoneButton = "apptheme_picker_done"
|
||||||
static let appThemePreviewCancelButton = "apptheme_preview_cancel"
|
static let appThemePreviewCancelButton = "apptheme_preview_cancel"
|
||||||
static let appThemePreviewApplyButton = "apptheme_preview_apply"
|
static let appThemePreviewApplyButton = "apptheme_preview_apply"
|
||||||
|
static let widgetHowToLink = "customize_widget_how_to_link"
|
||||||
static func themeButton(_ name: String) -> String {
|
static func themeButton(_ name: String) -> String {
|
||||||
"customize_theme_\(name.lowercased())"
|
"customize_theme_\(name.lowercased())"
|
||||||
}
|
}
|
||||||
@@ -147,6 +202,31 @@ enum AccessibilityID {
|
|||||||
static func appThemeCard(_ name: String) -> String {
|
static func appThemeCard(_ name: String) -> String {
|
||||||
"apptheme_card_\(name.lowercased())"
|
"apptheme_card_\(name.lowercased())"
|
||||||
}
|
}
|
||||||
|
static func customWidget(_ index: Int) -> String {
|
||||||
|
"customize_widget_\(index)"
|
||||||
|
}
|
||||||
|
static let customWidgetAdd = "customize_widget_add"
|
||||||
|
static func shapeOption(_ name: String) -> String {
|
||||||
|
"customize_shape_\(name.lowercased())"
|
||||||
|
}
|
||||||
|
static let shapeRefresh = "customize_shape_refresh"
|
||||||
|
static func imagePackOption(_ name: String) -> String {
|
||||||
|
"customize_imagepack_option_\(name.lowercased())"
|
||||||
|
}
|
||||||
|
static func personalityPackOption(_ name: String) -> String {
|
||||||
|
"customize_personalitypack_option_\(name.lowercased())"
|
||||||
|
}
|
||||||
|
static func celebrationAnimationButton(_ name: String) -> String {
|
||||||
|
"customize_celebration_\(name.lowercased())"
|
||||||
|
}
|
||||||
|
static let manageSubscriptionButton = "customize_manage_subscription"
|
||||||
|
static let unlockPremiumButton = "customize_unlock_premium"
|
||||||
|
static func dayFilterButton(_ day: String) -> String {
|
||||||
|
"customize_day_filter_\(day.lowercased())"
|
||||||
|
}
|
||||||
|
static func iconButton(_ name: String) -> String {
|
||||||
|
"customize_icon_\(name.lowercased())"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Paywall
|
// MARK: - Paywall
|
||||||
@@ -173,21 +253,32 @@ enum AccessibilityID {
|
|||||||
static let monthSection = "insights_month_section"
|
static let monthSection = "insights_month_section"
|
||||||
static let yearSection = "insights_year_section"
|
static let yearSection = "insights_year_section"
|
||||||
static let allTimeSection = "insights_all_time_section"
|
static let allTimeSection = "insights_all_time_section"
|
||||||
|
static let expandCollapseButton = "insights_expand_collapse"
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Month View
|
// MARK: - Month View
|
||||||
enum MonthView {
|
enum MonthView {
|
||||||
static let grid = "month_grid"
|
static let grid = "month_grid"
|
||||||
static let shareButton = "month_share_button"
|
static let shareButton = "month_share_button"
|
||||||
|
static let statsToggleButton = "month_stats_toggle"
|
||||||
|
static let settingsButton = "month_settings_button"
|
||||||
|
static func dayCell(dateString: String) -> String {
|
||||||
|
"month_day_cell_\(dateString)"
|
||||||
|
}
|
||||||
|
static let debugDemoToggle = "month_debug_demo_toggle"
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Month Detail
|
// MARK: - Month Detail
|
||||||
enum MonthDetail {
|
enum MonthDetail {
|
||||||
static let shareButton = "month_detail_share"
|
static let shareButton = "month_detail_share"
|
||||||
static let deleteButton = "month_detail_delete"
|
static let deleteButton = "month_detail_delete"
|
||||||
|
static let cancelButton = "month_detail_cancel"
|
||||||
static func moodButton(_ mood: String) -> String {
|
static func moodButton(_ mood: String) -> String {
|
||||||
"month_detail_mood_\(mood.lowercased())"
|
"month_detail_mood_\(mood.lowercased())"
|
||||||
}
|
}
|
||||||
|
static func entryCell(_ dateString: String) -> String {
|
||||||
|
"month_detail_entry_\(dateString)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Year View
|
// MARK: - Year View
|
||||||
@@ -198,6 +289,7 @@ enum AccessibilityID {
|
|||||||
static let statsSection = "year_stats_section"
|
static let statsSection = "year_stats_section"
|
||||||
static func cardHeader(year: Int) -> String { "year_card_header_\(year)" }
|
static func cardHeader(year: Int) -> String { "year_card_header_\(year)" }
|
||||||
static let shareButton = "year_share_button"
|
static let shareButton = "year_share_button"
|
||||||
|
static let debugDemoToggle = "year_debug_demo_toggle"
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Onboarding
|
// MARK: - Onboarding
|
||||||
@@ -213,12 +305,23 @@ enum AccessibilityID {
|
|||||||
static let subscribeButton = "onboarding_subscribe_button"
|
static let subscribeButton = "onboarding_subscribe_button"
|
||||||
static let skipButton = "onboarding_skip_button"
|
static let skipButton = "onboarding_skip_button"
|
||||||
static let nextButton = "onboarding_next_button"
|
static let nextButton = "onboarding_next_button"
|
||||||
|
static let timePicker = "onboarding_time_picker"
|
||||||
|
static let wrapupContinue = "onboarding_wrapup_continue"
|
||||||
|
static let titleOptionButton = "onboarding_title_option"
|
||||||
|
static func styleThemeButton(_ name: String) -> String {
|
||||||
|
"onboarding_style_theme_\(name.lowercased())"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Reports
|
// MARK: - Reports
|
||||||
enum Reports {
|
enum Reports {
|
||||||
static let segmentedPicker = "reports_segmented_picker"
|
static let segmentedPicker = "reports_segmented_picker"
|
||||||
static let dateRangePicker = "reports_date_range_picker"
|
static let dateRangePicker = "reports_date_range_picker"
|
||||||
|
static let previousMonthButton = "reports_previous_month"
|
||||||
|
static let nextMonthButton = "reports_next_month"
|
||||||
|
static func dayCell(dateString: String) -> String {
|
||||||
|
"reports_day_cell_\(dateString)"
|
||||||
|
}
|
||||||
static let quickSummaryButton = "reports_quick_summary_button"
|
static let quickSummaryButton = "reports_quick_summary_button"
|
||||||
static let detailedReportButton = "reports_detailed_report_button"
|
static let detailedReportButton = "reports_detailed_report_button"
|
||||||
static let generateButton = "reports_generate_button"
|
static let generateButton = "reports_generate_button"
|
||||||
@@ -229,6 +332,8 @@ enum AccessibilityID {
|
|||||||
static let minimumEntriesWarning = "reports_minimum_entries_warning"
|
static let minimumEntriesWarning = "reports_minimum_entries_warning"
|
||||||
static let exportDataButton = "reports_export_data_button"
|
static let exportDataButton = "reports_export_data_button"
|
||||||
static let retryButton = "reports_retry_button"
|
static let retryButton = "reports_retry_button"
|
||||||
|
static let privacyShareButton = "reports_privacy_share"
|
||||||
|
static let privacyCancelButton = "reports_privacy_cancel"
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Purchase / Subscription
|
// MARK: - Purchase / Subscription
|
||||||
@@ -239,6 +344,11 @@ enum AccessibilityID {
|
|||||||
static let subscribeButton = "purchase_subscribe"
|
static let subscribeButton = "purchase_subscribe"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Subscription Store
|
||||||
|
enum SubscriptionStore {
|
||||||
|
static let closeButton = "subscription_store_close"
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - IAP Warning
|
// MARK: - IAP Warning
|
||||||
enum IAPWarning {
|
enum IAPWarning {
|
||||||
static let subscribeButton = "iap_warning_subscribe"
|
static let subscribeButton = "iap_warning_subscribe"
|
||||||
@@ -249,6 +359,7 @@ enum AccessibilityID {
|
|||||||
static let unlockButton = "lock_screen_unlock"
|
static let unlockButton = "lock_screen_unlock"
|
||||||
static let tryAgainButton = "lock_screen_try_again"
|
static let tryAgainButton = "lock_screen_try_again"
|
||||||
static let cancelButton = "lock_screen_cancel"
|
static let cancelButton = "lock_screen_cancel"
|
||||||
|
static let passcodeUnlockButton = "lock_screen_passcode_unlock"
|
||||||
static func passcodeButton(_ digit: Int) -> String {
|
static func passcodeButton(_ digit: Int) -> String {
|
||||||
"lock_screen_passcode_\(digit)"
|
"lock_screen_passcode_\(digit)"
|
||||||
}
|
}
|
||||||
@@ -260,6 +371,135 @@ enum AccessibilityID {
|
|||||||
static let dismissArea = "full_screen_photo_dismiss"
|
static let dismissArea = "full_screen_photo_dismiss"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Export
|
||||||
|
enum Export {
|
||||||
|
static let cancelButton = "export_cancel"
|
||||||
|
static let exportButton = "export_export"
|
||||||
|
static let alertOKButton = "export_alert_ok"
|
||||||
|
static func formatButton(_ format: String) -> String {
|
||||||
|
"export_format_\(format.lowercased())"
|
||||||
|
}
|
||||||
|
static func rangeButton(_ range: String) -> String {
|
||||||
|
"export_range_\(range.lowercased())"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Photo Picker
|
||||||
|
enum PhotoPicker {
|
||||||
|
static let cameraButton = "photo_picker_camera"
|
||||||
|
static let cancelButton = "photo_picker_cancel"
|
||||||
|
static let closeButton = "photo_picker_close"
|
||||||
|
static let shareButton = "photo_picker_share"
|
||||||
|
static let deleteButton = "photo_picker_delete"
|
||||||
|
static let deleteConfirmButton = "photo_picker_delete_confirm"
|
||||||
|
static let deleteCancelButton = "photo_picker_delete_cancel"
|
||||||
|
static let photosPicker = "photo_picker_library"
|
||||||
|
static let photoImage = "photo_picker_image"
|
||||||
|
static let menuButton = "photo_picker_menu"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sharing
|
||||||
|
enum Sharing {
|
||||||
|
static let exitButton = "sharing_exit"
|
||||||
|
static let shareButton = "sharing_share"
|
||||||
|
static func moodMenuButton(_ mood: String) -> String {
|
||||||
|
"sharing_mood_menu_\(mood.lowercased())"
|
||||||
|
}
|
||||||
|
static let moodMenu = "sharing_mood_menu"
|
||||||
|
static func templateButton(_ description: String) -> String {
|
||||||
|
"sharing_template_\(description.lowercased().replacingOccurrences(of: " ", with: "_"))"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sharing Templates
|
||||||
|
enum SharingTemplate {
|
||||||
|
static let dismissButton = "sharing_template_dismiss"
|
||||||
|
static let shareButton = "sharing_template_share"
|
||||||
|
static let moodMenu = "sharing_template_mood_menu"
|
||||||
|
static func moodMenuButton(_ mood: String) -> String {
|
||||||
|
"sharing_template_mood_menu_\(mood.lowercased())"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Custom Widget
|
||||||
|
enum CustomWidget {
|
||||||
|
static func colorPicker(_ name: String) -> String {
|
||||||
|
"custom_widget_color_\(name.lowercased())"
|
||||||
|
}
|
||||||
|
static let leftEyeButton = "custom_widget_left_eye"
|
||||||
|
static let rightEyeButton = "custom_widget_right_eye"
|
||||||
|
static let mouthButton = "custom_widget_mouth"
|
||||||
|
static func backgroundOption(_ index: Int) -> String {
|
||||||
|
"custom_widget_bg_\(index)"
|
||||||
|
}
|
||||||
|
static let randomBackgroundButton = "custom_widget_random_bg"
|
||||||
|
static let shuffleButton = "custom_widget_shuffle"
|
||||||
|
static let saveButton = "custom_widget_save"
|
||||||
|
static let useButton = "custom_widget_use"
|
||||||
|
static let deleteButton = "custom_widget_delete"
|
||||||
|
static func imageOption(_ name: String) -> String {
|
||||||
|
"custom_widget_image_\(name.lowercased())"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Debug / Preview
|
||||||
|
enum Debug {
|
||||||
|
static let animationDoneButton = "debug_animation_done"
|
||||||
|
static func animationCard(_ name: String) -> String {
|
||||||
|
"debug_animation_\(name.lowercased())"
|
||||||
|
}
|
||||||
|
static func debugMoodButton(_ mood: String) -> String {
|
||||||
|
"debug_mood_\(mood.lowercased())"
|
||||||
|
}
|
||||||
|
static let paywallPreviewDoneButton = "debug_paywall_done"
|
||||||
|
static let viewFullPaywallButton = "debug_view_full_paywall"
|
||||||
|
static func paywallStyleOption(_ name: String) -> String {
|
||||||
|
"debug_paywall_style_\(name.lowercased())"
|
||||||
|
}
|
||||||
|
static let liveActivityResetButton = "debug_live_activity_reset"
|
||||||
|
static let liveActivityToggleButton = "debug_live_activity_toggle"
|
||||||
|
static let liveActivityRecordButton = "debug_live_activity_record"
|
||||||
|
static let liveActivityDismissButton = "debug_live_activity_dismiss"
|
||||||
|
static let liveActivityExportButton = "debug_live_activity_export"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sample Entry
|
||||||
|
enum SampleEntry {
|
||||||
|
static let refreshButton = "sample_entry_refresh"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Switchable View
|
||||||
|
enum SwitchableView {
|
||||||
|
static let headerToggle = "switchable_view_header_toggle"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Neon Mood Button (voting layout)
|
||||||
|
enum NeonMoodButton {
|
||||||
|
static func id(for mood: String) -> String {
|
||||||
|
"neon_mood_button_\(mood.lowercased())"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - App Alerts
|
||||||
|
enum AppAlert {
|
||||||
|
static let storageUnavailableOK = "app_alert_storage_ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Watch
|
||||||
|
enum Watch {
|
||||||
|
static func moodButton(_ mood: String) -> String {
|
||||||
|
"watch_mood_button_\(mood.lowercased())"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Widget
|
||||||
|
enum Widget {
|
||||||
|
static func voteMoodButton(_ mood: String) -> String {
|
||||||
|
"widget_vote_mood_\(mood.lowercased())"
|
||||||
|
}
|
||||||
|
static let subscribeLink = "widget_subscribe_link"
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Common
|
// MARK: - Common
|
||||||
enum Common {
|
enum Common {
|
||||||
static let lockScreen = "lock_screen"
|
static let lockScreen = "lock_screen"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import BackgroundTasks
|
|||||||
class BGTask {
|
class BGTask {
|
||||||
static let updateDBMissingID = "com.88oakapps.reflect.dbUpdateMissing"
|
static let updateDBMissingID = "com.88oakapps.reflect.dbUpdateMissing"
|
||||||
static let weatherRetryID = "com.88oakapps.reflect.weatherRetry"
|
static let weatherRetryID = "com.88oakapps.reflect.weatherRetry"
|
||||||
|
static let weeklyDigestID = "com.88oakapps.reflect.weeklyDigest"
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
class func runFillInMissingDatesTask(task: BGProcessingTask) {
|
class func runFillInMissingDatesTask(task: BGProcessingTask) {
|
||||||
@@ -51,7 +52,68 @@ class BGTask {
|
|||||||
do {
|
do {
|
||||||
try BGTaskScheduler.shared.submit(request)
|
try BGTaskScheduler.shared.submit(request)
|
||||||
} catch {
|
} catch {
|
||||||
|
#if DEBUG
|
||||||
print("Could not schedule weather retry: \(error)")
|
print("Could not schedule weather retry: \(error)")
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class func runWeeklyDigestTask(task: BGProcessingTask) {
|
||||||
|
BGTask.scheduleWeeklyDigest()
|
||||||
|
|
||||||
|
task.expirationHandler = {
|
||||||
|
task.setTaskCompleted(success: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if #available(iOS 26, *) {
|
||||||
|
Task {
|
||||||
|
guard !IAPManager.shared.shouldShowPaywall else {
|
||||||
|
task.setTaskCompleted(success: true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let digest = try await FoundationModelsDigestService.shared.generateWeeklyDigest()
|
||||||
|
|
||||||
|
// Send local notification with the headline
|
||||||
|
let personalityPack = UserDefaultsStore.personalityPackable()
|
||||||
|
LocalNotification.scheduleDigestNotification(headline: digest.headline, personalityPack: personalityPack)
|
||||||
|
|
||||||
|
task.setTaskCompleted(success: true)
|
||||||
|
} catch {
|
||||||
|
print("Weekly digest generation failed: \(error)")
|
||||||
|
task.setTaskCompleted(success: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
task.setTaskCompleted(success: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class func scheduleWeeklyDigest() {
|
||||||
|
let request = BGProcessingTaskRequest(identifier: BGTask.weeklyDigestID)
|
||||||
|
request.requiresNetworkConnectivity = false
|
||||||
|
request.requiresExternalPower = false
|
||||||
|
|
||||||
|
// Schedule for next Sunday at 7 PM
|
||||||
|
let calendar = Calendar.current
|
||||||
|
var components = calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: Date())
|
||||||
|
components.weekday = 1 // Sunday
|
||||||
|
components.hour = 19
|
||||||
|
components.minute = 0
|
||||||
|
|
||||||
|
var nextSunday = calendar.date(from: components) ?? Date()
|
||||||
|
if nextSunday <= Date() {
|
||||||
|
nextSunday = calendar.date(byAdding: .weekOfYear, value: 1, to: nextSunday)!
|
||||||
|
}
|
||||||
|
|
||||||
|
request.earliestBeginDate = nextSunday
|
||||||
|
|
||||||
|
do {
|
||||||
|
try BGTaskScheduler.shared.submit(request)
|
||||||
|
} catch {
|
||||||
|
print("Could not schedule weekly digest: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +129,9 @@ class BGTask {
|
|||||||
do {
|
do {
|
||||||
try BGTaskScheduler.shared.submit(request)
|
try BGTaskScheduler.shared.submit(request)
|
||||||
} catch {
|
} catch {
|
||||||
|
#if DEBUG
|
||||||
print("Could not schedule image fetch: \(error)")
|
print("Could not schedule image fetch: \(error)")
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,7 +134,6 @@ extension Color {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension Color: @retroactive RawRepresentable {
|
extension Color: @retroactive RawRepresentable {
|
||||||
// TODO: Sort out alpha
|
|
||||||
public init?(rawValue: Int) {
|
public init?(rawValue: Int) {
|
||||||
let red = Double((rawValue & 0xFF0000) >> 16) / 0xFF
|
let red = Double((rawValue & 0xFF0000) >> 16) / 0xFF
|
||||||
let green = Double((rawValue & 0x00FF00) >> 8) / 0xFF
|
let green = Double((rawValue & 0x00FF00) >> 8) / 0xFF
|
||||||
|
|||||||
@@ -37,13 +37,9 @@ class IAPManager: ObservableObject {
|
|||||||
|
|
||||||
/// Set to `true` to bypass all subscription checks and grant full access (for development only)
|
/// Set to `true` to bypass all subscription checks and grant full access (for development only)
|
||||||
/// Togglable at runtime in DEBUG builds via Settings > Debug > Bypass Subscription
|
/// Togglable at runtime in DEBUG builds via Settings > Debug > Bypass Subscription
|
||||||
#if DEBUG
|
|
||||||
@Published var bypassSubscription: Bool {
|
@Published var bypassSubscription: Bool {
|
||||||
didSet { UserDefaults.standard.set(bypassSubscription, forKey: "debug_bypassSubscription") }
|
didSet { UserDefaults.standard.set(bypassSubscription, forKey: "debug_bypassSubscription") }
|
||||||
}
|
}
|
||||||
#else
|
|
||||||
let bypassSubscription = false
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// MARK: - Constants
|
// MARK: - Constants
|
||||||
|
|
||||||
@@ -140,9 +136,7 @@ class IAPManager: ObservableObject {
|
|||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
#if DEBUG
|
|
||||||
self.bypassSubscription = UserDefaults.standard.bool(forKey: "debug_bypassSubscription")
|
self.bypassSubscription = UserDefaults.standard.bool(forKey: "debug_bypassSubscription")
|
||||||
#endif
|
|
||||||
restoreCachedSubscriptionState()
|
restoreCachedSubscriptionState()
|
||||||
updateListenerTask = listenForTransactions()
|
updateListenerTask = listenForTransactions()
|
||||||
|
|
||||||
@@ -307,8 +301,16 @@ class IAPManager: ObservableObject {
|
|||||||
|
|
||||||
// Get renewal info
|
// Get renewal info
|
||||||
if let product = currentProduct,
|
if let product = currentProduct,
|
||||||
let subscription = product.subscription,
|
let subscription = product.subscription {
|
||||||
let statuses = try? await subscription.status {
|
let statuses: [Product.SubscriptionInfo.Status]
|
||||||
|
do {
|
||||||
|
statuses = try await subscription.status
|
||||||
|
} catch {
|
||||||
|
AppLogger.iap.error("Failed to fetch subscription status for \(product.id): \(error)")
|
||||||
|
// Fallback handled below
|
||||||
|
state = .subscribed(expirationDate: transaction.expirationDate, willAutoRenew: false)
|
||||||
|
return true
|
||||||
|
}
|
||||||
var hadVerifiedStatus = false
|
var hadVerifiedStatus = false
|
||||||
|
|
||||||
for status in statuses {
|
for status in statuses {
|
||||||
@@ -365,7 +367,6 @@ class IAPManager: ObservableObject {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
/// Reset subscription state for UI testing. Called after group defaults are cleared
|
/// Reset subscription state for UI testing. Called after group defaults are cleared
|
||||||
/// so that stale cached state from previous test runs is discarded.
|
/// so that stale cached state from previous test runs is discarded.
|
||||||
func resetForTesting() {
|
func resetForTesting() {
|
||||||
@@ -382,7 +383,6 @@ class IAPManager: ObservableObject {
|
|||||||
|
|
||||||
updateTrialState()
|
updateTrialState()
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
|
|
||||||
private func updateTrialState() {
|
private func updateTrialState() {
|
||||||
let daysSinceInstall = Calendar.current.dateComponents([.day], from: firstLaunchDate, to: Date()).day ?? 0
|
let daysSinceInstall = Calendar.current.dateComponents([.day], from: firstLaunchDate, to: Date()).day ?? 0
|
||||||
|
|||||||
@@ -69,11 +69,15 @@ class LocalNotification {
|
|||||||
let request = UNNotificationRequest(identifier: UUID().uuidString, content: notificationContent, trigger: trigger)
|
let request = UNNotificationRequest(identifier: UUID().uuidString, content: notificationContent, trigger: trigger)
|
||||||
UNUserNotificationCenter.current().add(request) { (error : Error?) in
|
UNUserNotificationCenter.current().add(request) { (error : Error?) in
|
||||||
if let theError = error {
|
if let theError = error {
|
||||||
|
#if DEBUG
|
||||||
print(theError.localizedDescription)
|
print(theError.localizedDescription)
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
|
#if DEBUG
|
||||||
print(error)
|
print(error)
|
||||||
|
#endif
|
||||||
// Todo: show enable this
|
// Todo: show enable this
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -135,7 +139,28 @@ class LocalNotification {
|
|||||||
|
|
||||||
// MARK: - Debug: Send All Personality Pack Notifications
|
// MARK: - Debug: Send All Personality Pack Notifications
|
||||||
|
|
||||||
#if DEBUG
|
// MARK: - Weekly Digest Notification
|
||||||
|
|
||||||
|
public class func scheduleDigestNotification(headline: String, personalityPack: PersonalityPack) {
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = String(localized: "Your Weekly Digest")
|
||||||
|
content.body = headline
|
||||||
|
content.sound = .default
|
||||||
|
|
||||||
|
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
|
||||||
|
let request = UNNotificationRequest(
|
||||||
|
identifier: "weekly-digest-\(UUID().uuidString)",
|
||||||
|
content: content,
|
||||||
|
trigger: trigger
|
||||||
|
)
|
||||||
|
|
||||||
|
UNUserNotificationCenter.current().add(request) { error in
|
||||||
|
if let error = error {
|
||||||
|
print("Failed to schedule digest notification: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Sends one notification from each personality pack, staggered over 10 seconds for screenshot
|
/// Sends one notification from each personality pack, staggered over 10 seconds for screenshot
|
||||||
public class func sendAllPersonalityNotificationsForScreenshot() {
|
public class func sendAllPersonalityNotificationsForScreenshot() {
|
||||||
let _ = createNotificationCategory()
|
let _ = createNotificationCategory()
|
||||||
@@ -173,5 +198,4 @@ class LocalNotification {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|||||||
28
Shared/Models/AIEntryTags.swift
Normal file
28
Shared/Models/AIEntryTags.swift
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
//
|
||||||
|
// AIEntryTags.swift
|
||||||
|
// Reflect
|
||||||
|
//
|
||||||
|
// @Generable model for AI-extracted theme tags from mood entry notes and reflections.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import FoundationModels
|
||||||
|
|
||||||
|
/// A single AI-extracted theme tag
|
||||||
|
@available(iOS 26, *)
|
||||||
|
@Generable
|
||||||
|
struct AITag: Equatable {
|
||||||
|
@Guide(description: "Theme label — one of: work, family, social, health, sleep, exercise, stress, gratitude, growth, creative, nature, self-care, finances, relationships, loneliness, motivation")
|
||||||
|
var label: String
|
||||||
|
|
||||||
|
@Guide(description: "Confidence level: high or medium")
|
||||||
|
var confidence: String
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Container for extracted tags from a single entry
|
||||||
|
@available(iOS 26, *)
|
||||||
|
@Generable
|
||||||
|
struct AIEntryTags: Equatable {
|
||||||
|
@Guide(description: "Array of 1-4 theme tags extracted from the text", .maximumCount(4))
|
||||||
|
var tags: [AITag]
|
||||||
|
}
|
||||||
26
Shared/Models/AIReflectionFeedback.swift
Normal file
26
Shared/Models/AIReflectionFeedback.swift
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
//
|
||||||
|
// AIReflectionFeedback.swift
|
||||||
|
// Reflect
|
||||||
|
//
|
||||||
|
// @Generable model for AI-powered reflection feedback after guided reflection completion.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import FoundationModels
|
||||||
|
|
||||||
|
/// AI-generated personalized feedback after completing a guided reflection
|
||||||
|
@available(iOS 26, *)
|
||||||
|
@Generable
|
||||||
|
struct AIReflectionFeedback: Equatable {
|
||||||
|
@Guide(description: "A warm, specific affirmation of what the user did well in their reflection (1 sentence)")
|
||||||
|
var affirmation: String
|
||||||
|
|
||||||
|
@Guide(description: "An observation connecting something the user wrote to a meaningful pattern or insight (1 sentence)")
|
||||||
|
var observation: String
|
||||||
|
|
||||||
|
@Guide(description: "A brief, actionable takeaway the user can carry forward (1 sentence)")
|
||||||
|
var takeaway: String
|
||||||
|
|
||||||
|
@Guide(description: "SF Symbol name for the feedback icon (e.g., sparkles, heart.fill, leaf.fill, star.fill)")
|
||||||
|
var iconName: String
|
||||||
|
}
|
||||||
64
Shared/Models/AIWeeklyDigest.swift
Normal file
64
Shared/Models/AIWeeklyDigest.swift
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
//
|
||||||
|
// AIWeeklyDigest.swift
|
||||||
|
// Reflect
|
||||||
|
//
|
||||||
|
// @Generable model and storage for AI-generated weekly emotional digest.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import FoundationModels
|
||||||
|
|
||||||
|
/// AI-generated weekly mood digest
|
||||||
|
@available(iOS 26, *)
|
||||||
|
@Generable
|
||||||
|
struct AIWeeklyDigestResponse: Equatable {
|
||||||
|
@Guide(description: "An engaging headline summarizing the week's emotional arc (3-7 words)")
|
||||||
|
var headline: String
|
||||||
|
|
||||||
|
@Guide(description: "A warm 2-3 sentence summary of the week's mood patterns and notable moments")
|
||||||
|
var summary: String
|
||||||
|
|
||||||
|
@Guide(description: "The best moment or strongest positive pattern from the week (1 sentence)")
|
||||||
|
var highlight: String
|
||||||
|
|
||||||
|
@Guide(description: "A gentle, actionable intention or suggestion for the coming week (1 sentence)")
|
||||||
|
var intention: String
|
||||||
|
|
||||||
|
@Guide(description: "SF Symbol name for the digest icon (e.g., sun.max.fill, leaf.fill, heart.fill)")
|
||||||
|
var iconName: String
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Storable weekly digest (Codable for UserDefaults persistence)
|
||||||
|
struct WeeklyDigest: Codable, Equatable {
|
||||||
|
let headline: String
|
||||||
|
let summary: String
|
||||||
|
let highlight: String
|
||||||
|
let intention: String
|
||||||
|
let iconName: String
|
||||||
|
let generatedAt: Date
|
||||||
|
let weekStartDate: Date
|
||||||
|
let weekEndDate: Date
|
||||||
|
|
||||||
|
var isFromCurrentWeek: Bool {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let now = Date()
|
||||||
|
let currentWeekStart = calendar.dateInterval(of: .weekOfYear, for: now)?.start ?? now
|
||||||
|
let digestWeekStart = calendar.dateInterval(of: .weekOfYear, for: weekStartDate)?.start ?? weekStartDate
|
||||||
|
return calendar.isDate(currentWeekStart, inSameDayAs: digestWeekStart) ||
|
||||||
|
calendar.isDate(digestWeekStart, inSameDayAs: calendar.date(byAdding: .weekOfYear, value: -1, to: currentWeekStart)!)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the digest was dismissed by the user
|
||||||
|
static var isDismissedKey: String { "weeklyDigestDismissedDate" }
|
||||||
|
|
||||||
|
static func markDismissed() {
|
||||||
|
GroupUserDefaults.groupDefaults.set(Date(), forKey: isDismissedKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isDismissed(for digest: WeeklyDigest) -> Bool {
|
||||||
|
guard let dismissedDate = GroupUserDefaults.groupDefaults.object(forKey: isDismissedKey) as? Date else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return dismissedDate >= digest.generatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -80,24 +80,24 @@ struct QuestionChips {
|
|||||||
// MARK: Positive (Great/Good) — Behavioral Activation
|
// MARK: Positive (Great/Good) — Behavioral Activation
|
||||||
|
|
||||||
// Q1: "What did you do today…?" — no chips (situational)
|
// Q1: "What did you do today…?" — no chips (situational)
|
||||||
// Q2: "What thought or moment stands out?" — positive feelings to savor
|
// Q2: "What thought or moment stands out?" — memorable moments to savor
|
||||||
case (.positive, 1):
|
case (.positive, 1):
|
||||||
return QuestionChips(
|
return QuestionChips(
|
||||||
topRow: [
|
topRow: [
|
||||||
String(localized: "guided_chip_pos_joy"),
|
String(localized: "guided_chip_pos_moment_conversation"),
|
||||||
String(localized: "guided_chip_pos_gratitude"),
|
String(localized: "guided_chip_pos_moment_accomplished"),
|
||||||
String(localized: "guided_chip_pos_pride"),
|
String(localized: "guided_chip_pos_moment_calm"),
|
||||||
String(localized: "guided_chip_pos_contentment"),
|
String(localized: "guided_chip_pos_moment_laugh"),
|
||||||
String(localized: "guided_chip_pos_love"),
|
String(localized: "guided_chip_pos_moment_grateful_person"),
|
||||||
String(localized: "guided_chip_pos_excitement"),
|
String(localized: "guided_chip_pos_moment_small_win"),
|
||||||
],
|
],
|
||||||
expanded: [
|
expanded: [
|
||||||
String(localized: "guided_chip_pos_inspiration"),
|
String(localized: "guided_chip_pos_moment_beauty"),
|
||||||
String(localized: "guided_chip_pos_amusement"),
|
String(localized: "guided_chip_pos_moment_connected"),
|
||||||
String(localized: "guided_chip_pos_serenity"),
|
String(localized: "guided_chip_pos_moment_progress"),
|
||||||
String(localized: "guided_chip_pos_relief"),
|
String(localized: "guided_chip_pos_moment_like_myself"),
|
||||||
String(localized: "guided_chip_pos_connection"),
|
String(localized: "guided_chip_pos_moment_kindness"),
|
||||||
String(localized: "guided_chip_pos_hope"),
|
String(localized: "guided_chip_pos_moment_time_well_spent"),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -221,22 +221,24 @@ struct QuestionChips {
|
|||||||
expanded: []
|
expanded: []
|
||||||
)
|
)
|
||||||
|
|
||||||
// Q4: "More balanced way to see it?" — grounding actions + cognitive shifts
|
// Q4: "More balanced way to see it?" — cognitive reframes first, grounding actions expanded
|
||||||
case (.negative, 3):
|
case (.negative, 3):
|
||||||
return QuestionChips(
|
return QuestionChips(
|
||||||
topRow: [
|
topRow: [
|
||||||
|
String(localized: "guided_chip_neg_act_worst_case"),
|
||||||
|
String(localized: "guided_chip_neg_act_facts_feelings"),
|
||||||
|
String(localized: "guided_chip_neg_act_matter_in_week"),
|
||||||
|
String(localized: "guided_chip_neg_act_got_through"),
|
||||||
|
String(localized: "guided_chip_neg_ref_one_chapter"),
|
||||||
|
String(localized: "guided_chip_neg_ref_doing_my_best"),
|
||||||
|
],
|
||||||
|
expanded: [
|
||||||
String(localized: "guided_chip_neg_act_talk_someone"),
|
String(localized: "guided_chip_neg_act_talk_someone"),
|
||||||
String(localized: "guided_chip_neg_act_write_it_out"),
|
String(localized: "guided_chip_neg_act_write_it_out"),
|
||||||
String(localized: "guided_chip_neg_act_take_walk"),
|
String(localized: "guided_chip_neg_act_take_walk"),
|
||||||
String(localized: "guided_chip_neg_act_step_away"),
|
String(localized: "guided_chip_neg_act_step_away"),
|
||||||
String(localized: "guided_chip_neg_act_get_rest"),
|
String(localized: "guided_chip_neg_act_get_rest"),
|
||||||
String(localized: "guided_chip_neg_act_one_small_thing"),
|
String(localized: "guided_chip_neg_act_one_small_thing"),
|
||||||
],
|
|
||||||
expanded: [
|
|
||||||
String(localized: "guided_chip_neg_act_worst_case"),
|
|
||||||
String(localized: "guided_chip_neg_act_got_through"),
|
|
||||||
String(localized: "guided_chip_neg_act_facts_feelings"),
|
|
||||||
String(localized: "guided_chip_neg_act_matter_in_week"),
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -48,11 +48,28 @@ final class MoodEntryModel {
|
|||||||
// Guided Reflection
|
// Guided Reflection
|
||||||
var reflectionJSON: String?
|
var reflectionJSON: String?
|
||||||
|
|
||||||
|
// AI-extracted theme tags (JSON array of strings)
|
||||||
|
var tagsJSON: String?
|
||||||
|
|
||||||
// Computed properties
|
// Computed properties
|
||||||
var mood: Mood {
|
var mood: Mood {
|
||||||
Mood(rawValue: moodValue) ?? .missing
|
Mood(rawValue: moodValue) ?? .missing
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Decoded tags from tagsJSON, or empty array if none
|
||||||
|
var tags: [String] {
|
||||||
|
guard let json = tagsJSON, let data = json.data(using: .utf8),
|
||||||
|
let decoded = try? JSONDecoder().decode([String].self, from: data) else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this entry has AI-extracted tags
|
||||||
|
var hasTags: Bool {
|
||||||
|
!tags.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
var moodString: String {
|
var moodString: String {
|
||||||
mood.strValue
|
mood.strValue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import os.log
|
||||||
|
|
||||||
enum VotingLayoutStyle: Int, CaseIterable {
|
enum VotingLayoutStyle: Int, CaseIterable {
|
||||||
case horizontal = 0 // Current: 5 buttons in a row
|
case horizontal = 0 // Current: 5 buttons in a row
|
||||||
@@ -177,6 +178,8 @@ enum DayViewStyle: Int, CaseIterable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private let userDefaultsLogger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.88oakapps.reflect", category: "UserDefaults")
|
||||||
|
|
||||||
class UserDefaultsStore {
|
class UserDefaultsStore {
|
||||||
enum Keys: String {
|
enum Keys: String {
|
||||||
case savedOnboardingData
|
case savedOnboardingData
|
||||||
@@ -226,15 +229,18 @@ class UserDefaultsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Decode and cache
|
// Decode and cache
|
||||||
if let data = GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.savedOnboardingData.rawValue) as? Data,
|
if let data = GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.savedOnboardingData.rawValue) as? Data {
|
||||||
let model = try? JSONDecoder().decode(OnboardingData.self, from: data) {
|
do {
|
||||||
cachedOnboardingData = model
|
let model = try JSONDecoder().decode(OnboardingData.self, from: data)
|
||||||
return model
|
cachedOnboardingData = model
|
||||||
} else {
|
return model
|
||||||
let defaultData = OnboardingData()
|
} catch {
|
||||||
cachedOnboardingData = defaultData
|
userDefaultsLogger.error("Failed to decode onboarding data: \(error)")
|
||||||
return defaultData
|
}
|
||||||
}
|
}
|
||||||
|
let defaultData = OnboardingData()
|
||||||
|
cachedOnboardingData = defaultData
|
||||||
|
return defaultData
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Invalidate cached onboarding data (call when data might have changed externally)
|
/// Invalidate cached onboarding data (call when data might have changed externally)
|
||||||
@@ -251,7 +257,7 @@ class UserDefaultsStore {
|
|||||||
let data = try JSONEncoder().encode(onboardingData)
|
let data = try JSONEncoder().encode(onboardingData)
|
||||||
GroupUserDefaults.groupDefaults.set(data, forKey: UserDefaultsStore.Keys.savedOnboardingData.rawValue)
|
GroupUserDefaults.groupDefaults.set(data, forKey: UserDefaultsStore.Keys.savedOnboardingData.rawValue)
|
||||||
} catch {
|
} catch {
|
||||||
print("Error saving onboarding: \(error)")
|
userDefaultsLogger.error("Failed to encode onboarding data: \(error)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-cache the saved data
|
// Re-cache the saved data
|
||||||
@@ -314,28 +320,38 @@ class UserDefaultsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func getCustomWidgets() -> [CustomWidgetModel] {
|
static func getCustomWidgets() -> [CustomWidgetModel] {
|
||||||
if let data = GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.customWidget.rawValue) as? Data,
|
if let data = GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.customWidget.rawValue) as? Data {
|
||||||
let model = try? JSONDecoder().decode([CustomWidgetModel].self, from: data) {
|
do {
|
||||||
return model
|
let model = try JSONDecoder().decode([CustomWidgetModel].self, from: data)
|
||||||
} else {
|
return model
|
||||||
GroupUserDefaults.groupDefaults.removeObject(forKey: UserDefaultsStore.Keys.customWidget.rawValue)
|
} catch {
|
||||||
|
userDefaultsLogger.error("Failed to decode custom widgets: \(error)")
|
||||||
let widget = CustomWidgetModel.randomWidget
|
|
||||||
widget.isSaved = true
|
|
||||||
let widgets = [widget]
|
|
||||||
|
|
||||||
guard let data = try? JSONEncoder().encode(widgets) else {
|
|
||||||
return widgets
|
|
||||||
}
|
|
||||||
GroupUserDefaults.groupDefaults.set(data, forKey: UserDefaultsStore.Keys.customWidget.rawValue)
|
|
||||||
|
|
||||||
if let savedData = GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.customWidget.rawValue) as? Data,
|
|
||||||
let models = try? JSONDecoder().decode([CustomWidgetModel].self, from: savedData) {
|
|
||||||
return models.sorted { $0.createdDate < $1.createdDate }
|
|
||||||
} else {
|
|
||||||
return widgets
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
GroupUserDefaults.groupDefaults.removeObject(forKey: UserDefaultsStore.Keys.customWidget.rawValue)
|
||||||
|
|
||||||
|
let widget = CustomWidgetModel.randomWidget
|
||||||
|
widget.isSaved = true
|
||||||
|
let widgets = [widget]
|
||||||
|
|
||||||
|
do {
|
||||||
|
let data = try JSONEncoder().encode(widgets)
|
||||||
|
GroupUserDefaults.groupDefaults.set(data, forKey: UserDefaultsStore.Keys.customWidget.rawValue)
|
||||||
|
} catch {
|
||||||
|
userDefaultsLogger.error("Failed to encode default custom widgets: \(error)")
|
||||||
|
return widgets
|
||||||
|
}
|
||||||
|
|
||||||
|
if let savedData = GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.customWidget.rawValue) as? Data {
|
||||||
|
do {
|
||||||
|
let models = try JSONDecoder().decode([CustomWidgetModel].self, from: savedData)
|
||||||
|
return models.sorted { $0.createdDate < $1.createdDate }
|
||||||
|
} catch {
|
||||||
|
userDefaultsLogger.error("Failed to decode saved custom widgets: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return widgets
|
||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
@@ -366,7 +382,7 @@ class UserDefaultsStore {
|
|||||||
let data = try JSONEncoder().encode(existingWidgets)
|
let data = try JSONEncoder().encode(existingWidgets)
|
||||||
GroupUserDefaults.groupDefaults.set(data, forKey: UserDefaultsStore.Keys.customWidget.rawValue)
|
GroupUserDefaults.groupDefaults.set(data, forKey: UserDefaultsStore.Keys.customWidget.rawValue)
|
||||||
} catch {
|
} catch {
|
||||||
print("Error saving custom widget: \(error)")
|
userDefaultsLogger.error("Failed to encode custom widget for save: \(error)")
|
||||||
}
|
}
|
||||||
return UserDefaultsStore.getCustomWidgets()
|
return UserDefaultsStore.getCustomWidgets()
|
||||||
}
|
}
|
||||||
@@ -396,7 +412,7 @@ class UserDefaultsStore {
|
|||||||
let data = try JSONEncoder().encode(existingWidgets)
|
let data = try JSONEncoder().encode(existingWidgets)
|
||||||
GroupUserDefaults.groupDefaults.set(data, forKey: UserDefaultsStore.Keys.customWidget.rawValue)
|
GroupUserDefaults.groupDefaults.set(data, forKey: UserDefaultsStore.Keys.customWidget.rawValue)
|
||||||
} catch {
|
} catch {
|
||||||
print("Error deleting custom widget: \(error)")
|
userDefaultsLogger.error("Failed to encode custom widgets for delete: \(error)")
|
||||||
}
|
}
|
||||||
return UserDefaultsStore.getCustomWidgets()
|
return UserDefaultsStore.getCustomWidgets()
|
||||||
}
|
}
|
||||||
@@ -407,7 +423,7 @@ class UserDefaultsStore {
|
|||||||
let model = try JSONDecoder().decode(SavedMoodTint.self, from: data)
|
let model = try JSONDecoder().decode(SavedMoodTint.self, from: data)
|
||||||
return model
|
return model
|
||||||
} catch {
|
} catch {
|
||||||
print(error)
|
userDefaultsLogger.error("Failed to decode custom mood tint: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return SavedMoodTint()
|
return SavedMoodTint()
|
||||||
@@ -428,7 +444,7 @@ class UserDefaultsStore {
|
|||||||
let data = try JSONEncoder().encode(customTint)
|
let data = try JSONEncoder().encode(customTint)
|
||||||
GroupUserDefaults.groupDefaults.set(data, forKey: UserDefaultsStore.Keys.customMoodTint.rawValue)
|
GroupUserDefaults.groupDefaults.set(data, forKey: UserDefaultsStore.Keys.customMoodTint.rawValue)
|
||||||
} catch {
|
} catch {
|
||||||
print("Error saving custom mood tint: \(error)")
|
userDefaultsLogger.error("Failed to encode custom mood tint: \(error)")
|
||||||
}
|
}
|
||||||
return UserDefaultsStore.getCustomMoodTint()
|
return UserDefaultsStore.getCustomMoodTint()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,9 @@ class LiveActivityManager: ObservableObject {
|
|||||||
// Start a mood streak Live Activity
|
// Start a mood streak Live Activity
|
||||||
func startStreakActivity(streak: Int, lastMood: Mood?, hasLoggedToday: Bool) {
|
func startStreakActivity(streak: Int, lastMood: Mood?, hasLoggedToday: Bool) {
|
||||||
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
|
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
|
||||||
|
#if DEBUG
|
||||||
print("Live Activities not enabled")
|
print("Live Activities not enabled")
|
||||||
|
#endif
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +78,9 @@ class LiveActivityManager: ObservableObject {
|
|||||||
)
|
)
|
||||||
currentActivity = activity
|
currentActivity = activity
|
||||||
} catch {
|
} catch {
|
||||||
|
#if DEBUG
|
||||||
print("Error starting Live Activity: \(error)")
|
print("Error starting Live Activity: \(error)")
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,23 +261,31 @@ class LiveActivityScheduler: ObservableObject {
|
|||||||
invalidateTimers()
|
invalidateTimers()
|
||||||
|
|
||||||
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
|
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
|
||||||
|
#if DEBUG
|
||||||
print("[LiveActivity] Live Activities not enabled by user")
|
print("[LiveActivity] Live Activities not enabled by user")
|
||||||
|
#endif
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let now = Date()
|
let now = Date()
|
||||||
guard let startTime = getStartTime(),
|
guard let startTime = getStartTime(),
|
||||||
let endTime = getEndTime() else {
|
let endTime = getEndTime() else {
|
||||||
|
#if DEBUG
|
||||||
print("[LiveActivity] No rating time configured - skipping")
|
print("[LiveActivity] No rating time configured - skipping")
|
||||||
|
#endif
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let hasRated = hasRatedToday()
|
let hasRated = hasRatedToday()
|
||||||
|
#if DEBUG
|
||||||
print("[LiveActivity] Schedule check - now: \(now), start: \(startTime), end: \(endTime), hasRated: \(hasRated)")
|
print("[LiveActivity] Schedule check - now: \(now), start: \(startTime), end: \(endTime), hasRated: \(hasRated)")
|
||||||
|
#endif
|
||||||
|
|
||||||
// If user has already rated today, don't show activity - schedule for next day
|
// If user has already rated today, don't show activity - schedule for next day
|
||||||
if hasRated {
|
if hasRated {
|
||||||
|
#if DEBUG
|
||||||
print("[LiveActivity] User already rated today - scheduling for next day")
|
print("[LiveActivity] User already rated today - scheduling for next day")
|
||||||
|
#endif
|
||||||
scheduleForNextDay()
|
scheduleForNextDay()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -281,7 +293,9 @@ class LiveActivityScheduler: ObservableObject {
|
|||||||
// Check if we're within the activity window (rating time to 5 hrs after)
|
// Check if we're within the activity window (rating time to 5 hrs after)
|
||||||
if now >= startTime && now <= endTime {
|
if now >= startTime && now <= endTime {
|
||||||
// Start activity immediately
|
// Start activity immediately
|
||||||
|
#if DEBUG
|
||||||
print("[LiveActivity] Within window - starting activity now")
|
print("[LiveActivity] Within window - starting activity now")
|
||||||
|
#endif
|
||||||
let streak = calculateStreak()
|
let streak = calculateStreak()
|
||||||
LiveActivityManager.shared.startStreakActivity(streak: streak, lastMood: getTodaysMood(), hasLoggedToday: false)
|
LiveActivityManager.shared.startStreakActivity(streak: streak, lastMood: getTodaysMood(), hasLoggedToday: false)
|
||||||
|
|
||||||
@@ -289,12 +303,16 @@ class LiveActivityScheduler: ObservableObject {
|
|||||||
scheduleEnd(at: endTime)
|
scheduleEnd(at: endTime)
|
||||||
} else if now < startTime {
|
} else if now < startTime {
|
||||||
// Schedule start for later today
|
// Schedule start for later today
|
||||||
|
#if DEBUG
|
||||||
print("[LiveActivity] Before window - scheduling start for \(startTime)")
|
print("[LiveActivity] Before window - scheduling start for \(startTime)")
|
||||||
|
#endif
|
||||||
scheduleStart(at: startTime)
|
scheduleStart(at: startTime)
|
||||||
scheduleEnd(at: endTime)
|
scheduleEnd(at: endTime)
|
||||||
} else {
|
} else {
|
||||||
// Past the window for today, schedule for tomorrow
|
// Past the window for today, schedule for tomorrow
|
||||||
|
#if DEBUG
|
||||||
print("[LiveActivity] Past window - scheduling for tomorrow")
|
print("[LiveActivity] Past window - scheduling for tomorrow")
|
||||||
|
#endif
|
||||||
scheduleForNextDay()
|
scheduleForNextDay()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -195,6 +195,7 @@ struct OnboardingThemeCard: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Onboarding.styleThemeButton(theme.name))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ struct OnboardingTime: View {
|
|||||||
.datePickerStyle(.wheel)
|
.datePickerStyle(.wheel)
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
.colorScheme(.light)
|
.colorScheme(.light)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Onboarding.timePicker)
|
||||||
.accessibilityLabel(String(localized: "Reminder time"))
|
.accessibilityLabel(String(localized: "Reminder time"))
|
||||||
.accessibilityHint(String(localized: "Select when you want to be reminded"))
|
.accessibilityHint(String(localized: "Select when you want to be reminded"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ struct OnboardingTitle: View {
|
|||||||
.cornerRadius(10)
|
.cornerRadius(10)
|
||||||
})
|
})
|
||||||
.buttonStyle(PlainButtonStyle())
|
.buttonStyle(PlainButtonStyle())
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Onboarding.titleOptionButton)
|
||||||
.padding([.top], 10)
|
.padding([.top], 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ struct OnboardingWrapup: View {
|
|||||||
.background(RoundedRectangle(cornerRadius: 10).fill().foregroundColor(Color.white))
|
.background(RoundedRectangle(cornerRadius: 10).fill().foregroundColor(Color.white))
|
||||||
.cornerRadius(10)
|
.cornerRadius(10)
|
||||||
})
|
})
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Onboarding.wrapupContinue)
|
||||||
.padding([.top], 65)
|
.padding([.top], 65)
|
||||||
}
|
}
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ extension DataController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func populateMemory() {
|
func populateMemory() {
|
||||||
#if DEBUG
|
|
||||||
for idx in 1..<255 {
|
for idx in 1..<255 {
|
||||||
let date = Calendar.current.date(byAdding: .day, value: -idx, to: Date())!
|
let date = Calendar.current.date(byAdding: .day, value: -idx, to: Date())!
|
||||||
var moodValue = Int.random(in: 2...4)
|
var moodValue = Int.random(in: 2...4)
|
||||||
@@ -43,7 +42,6 @@ extension DataController {
|
|||||||
modelContext.insert(entry)
|
modelContext.insert(entry)
|
||||||
}
|
}
|
||||||
save()
|
save()
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates an entry that is NOT inserted into the context - used for UI placeholders
|
/// Creates an entry that is NOT inserted into the context - used for UI placeholders
|
||||||
@@ -79,7 +77,6 @@ extension DataController {
|
|||||||
saveAndRunDataListeners()
|
saveAndRunDataListeners()
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
func populate2YearsData() {
|
func populate2YearsData() {
|
||||||
clearDB()
|
clearDB()
|
||||||
|
|
||||||
@@ -100,7 +97,6 @@ extension DataController {
|
|||||||
|
|
||||||
saveAndRunDataListeners()
|
saveAndRunDataListeners()
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
|
|
||||||
private static func randomMood() -> Mood {
|
private static func randomMood() -> Mood {
|
||||||
var moodValue = Int.random(in: 3...4)
|
var moodValue = Int.random(in: 3...4)
|
||||||
|
|||||||
@@ -63,6 +63,16 @@ extension DataController {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Tags
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func updateTags(forDate date: Date, tagsJSON: String?) -> Bool {
|
||||||
|
guard let entry = getEntry(byDate: date) else { return false }
|
||||||
|
entry.tagsJSON = tagsJSON
|
||||||
|
saveAndRunDataListeners()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Photo
|
// MARK: - Photo
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ import WidgetKit
|
|||||||
|
|
||||||
@main
|
@main
|
||||||
struct ReflectApp: App {
|
struct ReflectApp: App {
|
||||||
|
private enum AnimationConstants {
|
||||||
|
static let deepLinkHandlingDelay: TimeInterval = 0.3
|
||||||
|
}
|
||||||
|
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||||
|
|
||||||
@@ -40,6 +44,10 @@ struct ReflectApp: App {
|
|||||||
guard let processingTask = task as? BGProcessingTask else { return }
|
guard let processingTask = task as? BGProcessingTask else { return }
|
||||||
BGTask.runWeatherRetryTask(task: processingTask)
|
BGTask.runWeatherRetryTask(task: processingTask)
|
||||||
}
|
}
|
||||||
|
BGTaskScheduler.shared.register(forTaskWithIdentifier: BGTask.weeklyDigestID, using: nil) { task in
|
||||||
|
guard let processingTask = task as? BGProcessingTask else { return }
|
||||||
|
BGTask.runWeeklyDigestTask(task: processingTask)
|
||||||
|
}
|
||||||
UNUserNotificationCenter.current().setBadgeCount(0)
|
UNUserNotificationCenter.current().setBadgeCount(0)
|
||||||
|
|
||||||
// Reset tips session on app launch
|
// Reset tips session on app launch
|
||||||
@@ -73,6 +81,7 @@ struct ReflectApp: App {
|
|||||||
.alert("Data Storage Unavailable",
|
.alert("Data Storage Unavailable",
|
||||||
isPresented: $showStorageFallbackAlert) {
|
isPresented: $showStorageFallbackAlert) {
|
||||||
Button("OK", role: .cancel) { }
|
Button("OK", role: .cancel) { }
|
||||||
|
.accessibilityIdentifier(AccessibilityID.AppAlert.storageUnavailableOK)
|
||||||
} message: {
|
} message: {
|
||||||
Text("Your mood data cannot be saved permanently. Please restart the app. If the problem persists, reinstall the app.")
|
Text("Your mood data cannot be saved permanently. Please restart the app. If the problem persists, reinstall the app.")
|
||||||
}
|
}
|
||||||
@@ -82,7 +91,7 @@ struct ReflectApp: App {
|
|||||||
}
|
}
|
||||||
if let url = AppDelegate.pendingDeepLinkURL {
|
if let url = AppDelegate.pendingDeepLinkURL {
|
||||||
AppDelegate.pendingDeepLinkURL = nil
|
AppDelegate.pendingDeepLinkURL = nil
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + AnimationConstants.deepLinkHandlingDelay) {
|
||||||
handleDeepLink(url)
|
handleDeepLink(url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -97,6 +106,7 @@ struct ReflectApp: App {
|
|||||||
}.onChange(of: scenePhase) { _, newPhase in
|
}.onChange(of: scenePhase) { _, newPhase in
|
||||||
if newPhase == .background {
|
if newPhase == .background {
|
||||||
BGTask.scheduleBackgroundProcessing()
|
BGTask.scheduleBackgroundProcessing()
|
||||||
|
BGTask.scheduleWeeklyDigest()
|
||||||
WidgetCenter.shared.reloadAllTimelines()
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
// Flush pending analytics events
|
// Flush pending analytics events
|
||||||
AnalyticsManager.shared.flush()
|
AnalyticsManager.shared.flush()
|
||||||
|
|||||||
@@ -238,6 +238,10 @@ class ReflectTipsManager: ObservableObject {
|
|||||||
// MARK: - View Modifier for Easy Integration
|
// MARK: - View Modifier for Easy Integration
|
||||||
|
|
||||||
struct ReflectTipModifier: ViewModifier {
|
struct ReflectTipModifier: ViewModifier {
|
||||||
|
private enum AnimationConstants {
|
||||||
|
static let tipPresentationDelay: TimeInterval = 0.5
|
||||||
|
}
|
||||||
|
|
||||||
let tip: any ReflectTip
|
let tip: any ReflectTip
|
||||||
let gradientColors: [Color]
|
let gradientColors: [Color]
|
||||||
|
|
||||||
@@ -254,7 +258,7 @@ struct ReflectTipModifier: ViewModifier {
|
|||||||
|
|
||||||
// Delay tip presentation to ensure view hierarchy is fully established
|
// Delay tip presentation to ensure view hierarchy is fully established
|
||||||
// This prevents "presenting from detached view controller" errors
|
// This prevents "presenting from detached view controller" errors
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + AnimationConstants.tipPresentationDelay) {
|
||||||
if ReflectTipsManager.shared.shouldShowTip(tip) {
|
if ReflectTipsManager.shared.shouldShowTip(tip) {
|
||||||
showSheet = true
|
showSheet = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,7 +102,9 @@ class BiometricAuthManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
return success
|
return success
|
||||||
} catch {
|
} catch {
|
||||||
|
#if DEBUG
|
||||||
print("Authentication failed: \(error.localizedDescription)")
|
print("Authentication failed: \(error.localizedDescription)")
|
||||||
|
#endif
|
||||||
AnalyticsManager.shared.track(.biometricUnlockFailed(error: error.localizedDescription))
|
AnalyticsManager.shared.track(.biometricUnlockFailed(error: error.localizedDescription))
|
||||||
|
|
||||||
// If biometrics failed, try device passcode as fallback
|
// If biometrics failed, try device passcode as fallback
|
||||||
@@ -126,7 +128,9 @@ class BiometricAuthManager: ObservableObject {
|
|||||||
isUnlocked = success
|
isUnlocked = success
|
||||||
return success
|
return success
|
||||||
} catch {
|
} catch {
|
||||||
|
#if DEBUG
|
||||||
print("Passcode authentication failed: \(error.localizedDescription)")
|
print("Passcode authentication failed: \(error.localizedDescription)")
|
||||||
|
#endif
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -146,7 +150,9 @@ class BiometricAuthManager: ObservableObject {
|
|||||||
|
|
||||||
// Only allow enabling if biometrics are available
|
// Only allow enabling if biometrics are available
|
||||||
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
|
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
|
||||||
|
#if DEBUG
|
||||||
print("Biometric authentication not available: \(error?.localizedDescription ?? "Unknown")")
|
print("Biometric authentication not available: \(error?.localizedDescription ?? "Unknown")")
|
||||||
|
#endif
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +170,9 @@ class BiometricAuthManager: ObservableObject {
|
|||||||
|
|
||||||
return success
|
return success
|
||||||
} catch {
|
} catch {
|
||||||
|
#if DEBUG
|
||||||
print("Failed to enable lock: \(error.localizedDescription)")
|
print("Failed to enable lock: \(error.localizedDescription)")
|
||||||
|
#endif
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,7 +87,9 @@ class ExportService {
|
|||||||
trackDataExported(format: "csv", count: entries.count)
|
trackDataExported(format: "csv", count: entries.count)
|
||||||
return tempURL
|
return tempURL
|
||||||
} catch {
|
} catch {
|
||||||
|
#if DEBUG
|
||||||
print("ExportService: Failed to write CSV: \(error)")
|
print("ExportService: Failed to write CSV: \(error)")
|
||||||
|
#endif
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,7 +179,9 @@ class ExportService {
|
|||||||
try data.write(to: tempURL)
|
try data.write(to: tempURL)
|
||||||
return tempURL
|
return tempURL
|
||||||
} catch {
|
} catch {
|
||||||
|
#if DEBUG
|
||||||
print("ExportService: Failed to write PDF: \(error)")
|
print("ExportService: Failed to write PDF: \(error)")
|
||||||
|
#endif
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,6 @@
|
|||||||
//
|
//
|
||||||
// Exportable insights views with sample AI-generated insights for screenshots.
|
// Exportable insights views with sample AI-generated insights for screenshots.
|
||||||
//
|
//
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
// MARK: - Sample Insights Data
|
// MARK: - Sample Insights Data
|
||||||
@@ -377,4 +375,3 @@ struct ExportableInsightsContainer<Content: View>: View {
|
|||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
|
|||||||
@@ -5,8 +5,6 @@
|
|||||||
// Exportable watch views that match the real watchOS layouts.
|
// Exportable watch views that match the real watchOS layouts.
|
||||||
// These views accept tint/icon configuration as parameters for batch export.
|
// These views accept tint/icon configuration as parameters for batch export.
|
||||||
//
|
//
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
// MARK: - Watch Export Configuration
|
// MARK: - Watch Export Configuration
|
||||||
@@ -362,4 +360,3 @@ struct ExportableComplicationContainer<Content: View>: View {
|
|||||||
.clipShape(isCircular ? AnyShape(Circle()) : AnyShape(RoundedRectangle(cornerRadius: 12, style: .continuous)))
|
.clipShape(isCircular ? AnyShape(Circle()) : AnyShape(RoundedRectangle(cornerRadius: 12, style: .continuous)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
|
|||||||
@@ -5,8 +5,6 @@
|
|||||||
// Exportable widget views that match the real WidgetKit widgets pixel-for-pixel.
|
// Exportable widget views that match the real WidgetKit widgets pixel-for-pixel.
|
||||||
// These views accept tint/icon configuration as parameters for batch export.
|
// These views accept tint/icon configuration as parameters for batch export.
|
||||||
//
|
//
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
// MARK: - Widget Theme Configuration
|
// MARK: - Widget Theme Configuration
|
||||||
@@ -691,4 +689,3 @@ struct ExportableWidgetContainer<Content: View>: View {
|
|||||||
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
|
|||||||
156
Shared/Services/FoundationModelsDigestService.swift
Normal file
156
Shared/Services/FoundationModelsDigestService.swift
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
//
|
||||||
|
// FoundationModelsDigestService.swift
|
||||||
|
// Reflect
|
||||||
|
//
|
||||||
|
// Generates weekly emotional digests using Foundation Models.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import FoundationModels
|
||||||
|
|
||||||
|
@available(iOS 26, *)
|
||||||
|
@MainActor
|
||||||
|
class FoundationModelsDigestService {
|
||||||
|
|
||||||
|
// MARK: - Singleton
|
||||||
|
|
||||||
|
static let shared = FoundationModelsDigestService()
|
||||||
|
|
||||||
|
private let summarizer = MoodDataSummarizer()
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
// MARK: - Storage Keys
|
||||||
|
|
||||||
|
private static let digestStorageKey = "latestWeeklyDigest"
|
||||||
|
|
||||||
|
// MARK: - Digest Generation
|
||||||
|
|
||||||
|
/// Generate a weekly digest from the past 7 days of mood data
|
||||||
|
func generateWeeklyDigest() async throws -> WeeklyDigest {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let now = Date()
|
||||||
|
let weekStart = calendar.date(byAdding: .day, value: -7, to: now)!
|
||||||
|
|
||||||
|
let entries = DataController.shared.getData(
|
||||||
|
startDate: weekStart,
|
||||||
|
endDate: now,
|
||||||
|
includedDays: [1, 2, 3, 4, 5, 6, 7]
|
||||||
|
)
|
||||||
|
|
||||||
|
let validEntries = entries.filter { ![.missing, .placeholder].contains($0.mood) }
|
||||||
|
|
||||||
|
guard validEntries.count >= 3 else {
|
||||||
|
throw InsightGenerationError.insufficientData
|
||||||
|
}
|
||||||
|
|
||||||
|
let session = LanguageModelSession(instructions: systemInstructions)
|
||||||
|
let prompt = buildPrompt(entries: validEntries, weekStart: weekStart, weekEnd: now)
|
||||||
|
|
||||||
|
let response = try await session.respond(to: prompt, generating: AIWeeklyDigestResponse.self, options: GenerationOptions(maximumResponseTokens: 300))
|
||||||
|
|
||||||
|
let digest = WeeklyDigest(
|
||||||
|
headline: response.content.headline,
|
||||||
|
summary: response.content.summary,
|
||||||
|
highlight: response.content.highlight,
|
||||||
|
intention: response.content.intention,
|
||||||
|
iconName: response.content.iconName,
|
||||||
|
generatedAt: Date(),
|
||||||
|
weekStartDate: weekStart,
|
||||||
|
weekEndDate: now
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store the digest
|
||||||
|
saveDigest(digest)
|
||||||
|
|
||||||
|
return digest
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the latest stored digest
|
||||||
|
func loadLatestDigest() -> WeeklyDigest? {
|
||||||
|
guard let data = GroupUserDefaults.groupDefaults.data(forKey: Self.digestStorageKey),
|
||||||
|
let digest = try? JSONDecoder().decode(WeeklyDigest.self, from: data) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return digest
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Storage
|
||||||
|
|
||||||
|
private func saveDigest(_ digest: WeeklyDigest) {
|
||||||
|
if let data = try? JSONEncoder().encode(digest) {
|
||||||
|
GroupUserDefaults.groupDefaults.set(data, forKey: Self.digestStorageKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - System Instructions
|
||||||
|
|
||||||
|
private var systemInstructions: String {
|
||||||
|
let personalityPack = UserDefaultsStore.personalityPackable()
|
||||||
|
|
||||||
|
switch personalityPack {
|
||||||
|
case .Default:
|
||||||
|
return """
|
||||||
|
You are a warm, supportive mood companion writing a weekly emotional digest. \
|
||||||
|
Summarize the week's mood journey with encouragement and specificity. \
|
||||||
|
Be personal, brief, and uplifting. Reference specific patterns from the data. \
|
||||||
|
SF Symbols: sun.max.fill, heart.fill, star.fill, leaf.fill, sparkles
|
||||||
|
"""
|
||||||
|
case .MotivationalCoach:
|
||||||
|
return """
|
||||||
|
You are a HIGH ENERGY motivational coach delivering a weekly performance review! \
|
||||||
|
Celebrate wins, frame challenges as growth opportunities, and fire them up for next week! \
|
||||||
|
Use exclamations and power language! \
|
||||||
|
SF Symbols: trophy.fill, flame.fill, bolt.fill, figure.run, star.fill
|
||||||
|
"""
|
||||||
|
case .ZenMaster:
|
||||||
|
return """
|
||||||
|
You are a calm Zen master offering a weekly reflection on the emotional journey. \
|
||||||
|
Use nature metaphors, gentle wisdom, and serene observations. Find meaning in all moods. \
|
||||||
|
SF Symbols: leaf.fill, moon.fill, drop.fill, sunrise.fill, wind
|
||||||
|
"""
|
||||||
|
case .BestFriend:
|
||||||
|
return """
|
||||||
|
You are their best friend doing a weekly check-in on how they've been. \
|
||||||
|
Be warm, casual, validating, and conversational. Celebrate with them, commiserate together. \
|
||||||
|
SF Symbols: heart.fill, hand.thumbsup.fill, sparkles, face.smiling.fill, balloon.fill
|
||||||
|
"""
|
||||||
|
case .DataAnalyst:
|
||||||
|
return """
|
||||||
|
You are a clinical data analyst delivering a weekly mood metrics report. \
|
||||||
|
Reference exact numbers, percentages, and observed trends. Be objective but constructive. \
|
||||||
|
SF Symbols: chart.bar.fill, chart.line.uptrend.xyaxis, number, percent, doc.text.magnifyingglass
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Prompt Construction
|
||||||
|
|
||||||
|
private func buildPrompt(entries: [MoodEntryModel], weekStart: Date, weekEnd: Date) -> String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateStyle = .medium
|
||||||
|
|
||||||
|
let moodList = entries.sorted { $0.forDate < $1.forDate }.map { entry in
|
||||||
|
let day = entry.forDate.formatted(.dateTime.weekday(.abbreviated))
|
||||||
|
let mood = entry.mood.widgetDisplayName
|
||||||
|
let hasNotes = entry.notes != nil && !entry.notes!.isEmpty
|
||||||
|
let noteSnippet = hasNotes ? " (\(String(entry.notes!.prefix(50))))" : ""
|
||||||
|
return "\(day): \(mood)\(noteSnippet)"
|
||||||
|
}.joined(separator: "\n")
|
||||||
|
|
||||||
|
let summary = summarizer.summarize(entries: entries, periodName: "this week")
|
||||||
|
let avgMood = String(format: "%.1f", summary.averageMoodScore)
|
||||||
|
|
||||||
|
return """
|
||||||
|
Generate a weekly emotional digest for \(formatter.string(from: weekStart)) - \(formatter.string(from: weekEnd)):
|
||||||
|
|
||||||
|
\(moodList)
|
||||||
|
|
||||||
|
Average mood: \(avgMood)/5, Trend: \(summary.recentTrend), Stability: \(String(format: "%.0f", summary.moodStabilityScore * 100))%
|
||||||
|
Current streak: \(summary.currentLoggingStreak) days
|
||||||
|
|
||||||
|
Write a warm, personalized weekly digest.
|
||||||
|
Keep summary to 2 sentences. Keep highlight and intention to 1 sentence each.
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import FoundationModels
|
import FoundationModels
|
||||||
|
import os.log
|
||||||
|
|
||||||
/// Error types for insight generation
|
/// Error types for insight generation
|
||||||
enum InsightGenerationError: Error, LocalizedError {
|
enum InsightGenerationError: Error, LocalizedError {
|
||||||
@@ -29,6 +30,15 @@ enum InsightGenerationError: Error, LocalizedError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Why Apple Intelligence is unavailable
|
||||||
|
enum AIUnavailableReason {
|
||||||
|
case deviceNotEligible
|
||||||
|
case notEnabled
|
||||||
|
case modelDownloading
|
||||||
|
case unknown
|
||||||
|
case preiOS26
|
||||||
|
}
|
||||||
|
|
||||||
/// Service responsible for generating AI-powered mood insights using Apple's Foundation Models
|
/// Service responsible for generating AI-powered mood insights using Apple's Foundation Models
|
||||||
@available(iOS 26, *)
|
@available(iOS 26, *)
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -39,6 +49,7 @@ class FoundationModelsInsightService: ObservableObject {
|
|||||||
@Published private(set) var isAvailable: Bool = false
|
@Published private(set) var isAvailable: Bool = false
|
||||||
@Published private(set) var isGenerating: Bool = false
|
@Published private(set) var isGenerating: Bool = false
|
||||||
@Published private(set) var lastError: InsightGenerationError?
|
@Published private(set) var lastError: InsightGenerationError?
|
||||||
|
@Published private(set) var unavailableReason: AIUnavailableReason = .unknown
|
||||||
|
|
||||||
// MARK: - Dependencies
|
// MARK: - Dependencies
|
||||||
|
|
||||||
@@ -62,15 +73,27 @@ class FoundationModelsInsightService: ObservableObject {
|
|||||||
switch model.availability {
|
switch model.availability {
|
||||||
case .available:
|
case .available:
|
||||||
isAvailable = true
|
isAvailable = true
|
||||||
|
unavailableReason = .unknown
|
||||||
case .unavailable(let reason):
|
case .unavailable(let reason):
|
||||||
isAvailable = false
|
isAvailable = false
|
||||||
|
unavailableReason = mapUnavailableReason(reason)
|
||||||
lastError = .modelUnavailable(reason: describeUnavailability(reason))
|
lastError = .modelUnavailable(reason: describeUnavailability(reason))
|
||||||
@unknown default:
|
@unknown default:
|
||||||
isAvailable = false
|
isAvailable = false
|
||||||
|
unavailableReason = .unknown
|
||||||
lastError = .modelUnavailable(reason: "Unknown availability status")
|
lastError = .modelUnavailable(reason: "Unknown availability status")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func mapUnavailableReason(_ reason: SystemLanguageModel.Availability.UnavailableReason) -> AIUnavailableReason {
|
||||||
|
switch reason {
|
||||||
|
case .deviceNotEligible: return .deviceNotEligible
|
||||||
|
case .appleIntelligenceNotEnabled: return .notEnabled
|
||||||
|
case .modelNotReady: return .modelDownloading
|
||||||
|
@unknown default: return .unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func describeUnavailability(_ reason: SystemLanguageModel.Availability.UnavailableReason) -> String {
|
private func describeUnavailability(_ reason: SystemLanguageModel.Availability.UnavailableReason) -> String {
|
||||||
switch reason {
|
switch reason {
|
||||||
case .deviceNotEligible:
|
case .deviceNotEligible:
|
||||||
@@ -84,7 +107,13 @@ class FoundationModelsInsightService: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a new session for each request to allow concurrent generation
|
/// Prewarm the language model to reduce first-generation latency
|
||||||
|
func prewarm() {
|
||||||
|
let session = LanguageModelSession(instructions: systemInstructions)
|
||||||
|
session.prewarm()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a fresh session per request (sessions accumulate transcript, so reuse causes context overflow)
|
||||||
private func createSession() -> LanguageModelSession {
|
private func createSession() -> LanguageModelSession {
|
||||||
LanguageModelSession(instructions: systemInstructions)
|
LanguageModelSession(instructions: systemInstructions)
|
||||||
}
|
}
|
||||||
@@ -213,8 +242,7 @@ class FoundationModelsInsightService: ObservableObject {
|
|||||||
throw InsightGenerationError.modelUnavailable(reason: lastError?.localizedDescription ?? "Model not available")
|
throw InsightGenerationError.modelUnavailable(reason: lastError?.localizedDescription ?? "Model not available")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a new session for this request to allow concurrent generation
|
let activeSession = createSession()
|
||||||
let session = createSession()
|
|
||||||
|
|
||||||
// Filter valid entries
|
// Filter valid entries
|
||||||
let validEntries = entries.filter { ![.missing, .placeholder].contains($0.mood) }
|
let validEntries = entries.filter { ![.missing, .placeholder].contains($0.mood) }
|
||||||
@@ -231,9 +259,10 @@ class FoundationModelsInsightService: ObservableObject {
|
|||||||
let prompt = buildPrompt(from: summary, count: count)
|
let prompt = buildPrompt(from: summary, count: count)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let response = try await session.respond(
|
let response = try await activeSession.respond(
|
||||||
to: prompt,
|
to: prompt,
|
||||||
generating: AIInsightsResponse.self
|
generating: AIInsightsResponse.self,
|
||||||
|
options: GenerationOptions(maximumResponseTokens: 600)
|
||||||
)
|
)
|
||||||
|
|
||||||
let insights = response.content.insights.map { $0.toInsight() }
|
let insights = response.content.insights.map { $0.toInsight() }
|
||||||
@@ -244,9 +273,7 @@ class FoundationModelsInsightService: ObservableObject {
|
|||||||
return insights
|
return insights
|
||||||
} catch {
|
} catch {
|
||||||
// Log detailed error for debugging
|
// Log detailed error for debugging
|
||||||
print("❌ AI Insight generation failed for '\(periodName)': \(error)")
|
AppLogger.ai.error("AI Insight generation failed for '\(periodName)': \(error)")
|
||||||
print(" Error type: \(type(of: error))")
|
|
||||||
print(" Localized: \(error.localizedDescription)")
|
|
||||||
|
|
||||||
lastError = .generationFailed(underlying: error)
|
lastError = .generationFailed(underlying: error)
|
||||||
throw lastError!
|
throw lastError!
|
||||||
@@ -263,7 +290,7 @@ class FoundationModelsInsightService: ObservableObject {
|
|||||||
|
|
||||||
\(dataSection)
|
\(dataSection)
|
||||||
|
|
||||||
Include: 1 pattern, 1 advice, 1 prediction, and other varied insights. Reference specific data points.
|
Include: 1 pattern, 1 advice, 1 prediction, and other varied insights. Reference specific data points. Keep each insight to 1-2 sentences. If theme tags are available, identify what good days and bad days have in common. If weather data is available, note weather-mood correlations. If logging gaps exist, comment on what happens around breaks in tracking.
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
126
Shared/Services/FoundationModelsReflectionService.swift
Normal file
126
Shared/Services/FoundationModelsReflectionService.swift
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
//
|
||||||
|
// FoundationModelsReflectionService.swift
|
||||||
|
// Reflect
|
||||||
|
//
|
||||||
|
// Generates personalized AI feedback after a user completes a guided reflection.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import FoundationModels
|
||||||
|
|
||||||
|
@available(iOS 26, *)
|
||||||
|
@MainActor
|
||||||
|
class FoundationModelsReflectionService {
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
init() {}
|
||||||
|
|
||||||
|
// MARK: - Feedback Generation
|
||||||
|
|
||||||
|
/// Generate personalized feedback based on a completed guided reflection
|
||||||
|
/// - Parameters:
|
||||||
|
/// - reflection: The completed guided reflection with Q&A responses
|
||||||
|
/// - mood: The mood associated with this entry
|
||||||
|
/// - Returns: AI-generated reflection feedback
|
||||||
|
func generateFeedback(
|
||||||
|
for reflection: GuidedReflection,
|
||||||
|
mood: Mood
|
||||||
|
) async throws -> AIReflectionFeedback {
|
||||||
|
let session = LanguageModelSession(instructions: systemInstructions)
|
||||||
|
let prompt = buildPrompt(from: reflection, mood: mood)
|
||||||
|
let response = try await session.respond(
|
||||||
|
to: prompt,
|
||||||
|
generating: AIReflectionFeedback.self,
|
||||||
|
options: GenerationOptions(maximumResponseTokens: 200)
|
||||||
|
)
|
||||||
|
return response.content
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - System Instructions
|
||||||
|
|
||||||
|
private var systemInstructions: String {
|
||||||
|
let personalityPack = UserDefaultsStore.personalityPackable()
|
||||||
|
|
||||||
|
switch personalityPack {
|
||||||
|
case .Default:
|
||||||
|
return defaultInstructions
|
||||||
|
case .MotivationalCoach:
|
||||||
|
return coachInstructions
|
||||||
|
case .ZenMaster:
|
||||||
|
return zenInstructions
|
||||||
|
case .BestFriend:
|
||||||
|
return bestFriendInstructions
|
||||||
|
case .DataAnalyst:
|
||||||
|
return analystInstructions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var defaultInstructions: String {
|
||||||
|
"""
|
||||||
|
You are a warm, supportive companion responding to someone who just completed a guided mood reflection. \
|
||||||
|
Validate their effort, reflect their own words back to them, and offer a gentle takeaway. \
|
||||||
|
Be specific — reference what they actually wrote. Keep each field to 1 sentence. \
|
||||||
|
SF Symbols: sparkles, heart.fill, star.fill, sun.max.fill, leaf.fill
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
private var coachInstructions: String {
|
||||||
|
"""
|
||||||
|
You are a HIGH ENERGY motivational coach responding to someone who just completed a guided mood reflection! \
|
||||||
|
Celebrate their self-awareness, pump them up about the growth they showed, and give them a power move for tomorrow. \
|
||||||
|
Reference what they actually wrote. Keep each field to 1 sentence. Use exclamations! \
|
||||||
|
SF Symbols: trophy.fill, flame.fill, bolt.fill, star.fill, figure.run
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
private var zenInstructions: String {
|
||||||
|
"""
|
||||||
|
You are a calm, mindful guide responding to someone who just completed a guided mood reflection. \
|
||||||
|
Acknowledge their practice of self-awareness with gentle wisdom. Use nature metaphors. \
|
||||||
|
Reference what they actually wrote. Keep each field to 1 sentence. Speak with serene clarity. \
|
||||||
|
SF Symbols: leaf.fill, moon.fill, drop.fill, sunrise.fill, wind
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
private var bestFriendInstructions: String {
|
||||||
|
"""
|
||||||
|
You are their supportive best friend responding after they completed a guided mood reflection. \
|
||||||
|
Be warm, casual, and validating. Use conversational tone. \
|
||||||
|
Reference what they actually wrote. Keep each field to 1 sentence. \
|
||||||
|
SF Symbols: heart.fill, hand.thumbsup.fill, sparkles, star.fill, face.smiling.fill
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
private var analystInstructions: String {
|
||||||
|
"""
|
||||||
|
You are a clinical data analyst providing feedback on a completed mood reflection. \
|
||||||
|
Note the cognitive patterns observed, the technique application quality, and a data-informed recommendation. \
|
||||||
|
Reference what they actually wrote. Keep each field to 1 sentence. Be objective but encouraging. \
|
||||||
|
SF Symbols: chart.bar.fill, brain.head.profile, doc.text.magnifyingglass, chart.line.uptrend.xyaxis
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Prompt Construction
|
||||||
|
|
||||||
|
private func buildPrompt(from reflection: GuidedReflection, mood: Mood) -> String {
|
||||||
|
let moodName = mood.widgetDisplayName
|
||||||
|
let technique = reflection.moodCategory.techniqueName
|
||||||
|
|
||||||
|
let qaPairs = reflection.responses
|
||||||
|
.filter { !$0.answer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||||||
|
.map { response in
|
||||||
|
let chips = response.selectedChips.isEmpty ? "" : " [themes: \(response.selectedChips.joined(separator: ", "))]"
|
||||||
|
return "Q: \(response.question)\nA: \(response.answer)\(chips)"
|
||||||
|
}
|
||||||
|
.joined(separator: "\n\n")
|
||||||
|
|
||||||
|
return """
|
||||||
|
The user logged their mood as "\(moodName)" and completed a \(technique) reflection:
|
||||||
|
|
||||||
|
\(qaPairs)
|
||||||
|
|
||||||
|
Respond with personalized feedback that references their specific answers.
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
99
Shared/Services/FoundationModelsTagService.swift
Normal file
99
Shared/Services/FoundationModelsTagService.swift
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
//
|
||||||
|
// FoundationModelsTagService.swift
|
||||||
|
// Reflect
|
||||||
|
//
|
||||||
|
// Extracts theme tags from mood entry notes and guided reflections using Foundation Models.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import FoundationModels
|
||||||
|
|
||||||
|
@available(iOS 26, *)
|
||||||
|
@MainActor
|
||||||
|
class FoundationModelsTagService {
|
||||||
|
|
||||||
|
// MARK: - Singleton
|
||||||
|
|
||||||
|
static let shared = FoundationModelsTagService()
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
// MARK: - Tag Extraction
|
||||||
|
|
||||||
|
/// Extract theme tags from an entry's note and/or reflection content
|
||||||
|
/// - Parameters:
|
||||||
|
/// - entry: The mood entry to extract tags from
|
||||||
|
/// - Returns: Array of tag label strings, or nil if extraction fails
|
||||||
|
func extractTags(for entry: MoodEntryModel) async -> [String]? {
|
||||||
|
// Need at least some text content to extract from
|
||||||
|
let noteText = entry.notes?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
let reflectionText = extractReflectionText(from: entry)
|
||||||
|
|
||||||
|
guard !noteText.isEmpty || !reflectionText.isEmpty else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let session = LanguageModelSession(instructions: systemInstructions)
|
||||||
|
let prompt = buildPrompt(noteText: noteText, reflectionText: reflectionText, mood: entry.mood)
|
||||||
|
|
||||||
|
do {
|
||||||
|
let response = try await session.respond(to: prompt, generating: AIEntryTags.self, options: GenerationOptions(maximumResponseTokens: 100))
|
||||||
|
return response.content.tags.map { $0.label.lowercased() }
|
||||||
|
} catch {
|
||||||
|
print("Tag extraction failed: \(error.localizedDescription)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract tags and save them to the entry via DataController
|
||||||
|
func extractAndSaveTags(for entry: MoodEntryModel) async {
|
||||||
|
guard let tags = await extractTags(for: entry), !tags.isEmpty else { return }
|
||||||
|
|
||||||
|
if let data = try? JSONEncoder().encode(tags),
|
||||||
|
let json = String(data: data, encoding: .utf8) {
|
||||||
|
DataController.shared.updateTags(forDate: entry.forDate, tagsJSON: json)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - System Instructions
|
||||||
|
|
||||||
|
private var systemInstructions: String {
|
||||||
|
"""
|
||||||
|
You are a theme extractor for a mood journal. Extract 1-4 theme tags from the user's journal text. \
|
||||||
|
Only use tags from this list: work, family, social, health, sleep, exercise, stress, gratitude, \
|
||||||
|
growth, creative, nature, self-care, finances, relationships, loneliness, motivation. \
|
||||||
|
Only extract tags clearly present in the text. Do not guess or infer themes not mentioned.
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Prompt Construction
|
||||||
|
|
||||||
|
private func buildPrompt(noteText: String, reflectionText: String, mood: Mood) -> String {
|
||||||
|
var content = "Mood: \(mood.widgetDisplayName)\n"
|
||||||
|
|
||||||
|
if !noteText.isEmpty {
|
||||||
|
content += "\nJournal note:\n\(String(noteText.prefix(500)))\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
if !reflectionText.isEmpty {
|
||||||
|
content += "\nReflection responses:\n\(String(reflectionText.prefix(800)))\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
content += "\nExtract theme tags from the text above."
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func extractReflectionText(from entry: MoodEntryModel) -> String {
|
||||||
|
guard let json = entry.reflectionJSON,
|
||||||
|
let reflection = GuidedReflection.decode(from: json) else {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return reflection.responses
|
||||||
|
.filter { !$0.answer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||||||
|
.map { "Q: \($0.question)\nA: \($0.answer)" }
|
||||||
|
.joined(separator: "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,7 +71,9 @@ class HealthService: ObservableObject {
|
|||||||
|
|
||||||
func requestAuthorization() async -> Bool {
|
func requestAuthorization() async -> Bool {
|
||||||
guard isAvailable else {
|
guard isAvailable else {
|
||||||
|
#if DEBUG
|
||||||
print("HealthService: HealthKit not available on this device")
|
print("HealthService: HealthKit not available on this device")
|
||||||
|
#endif
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,7 +84,9 @@ class HealthService: ObservableObject {
|
|||||||
AnalyticsManager.shared.track(.healthKitAuthorized)
|
AnalyticsManager.shared.track(.healthKitAuthorized)
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
|
#if DEBUG
|
||||||
print("HealthService: Authorization failed: \(error.localizedDescription)")
|
print("HealthService: Authorization failed: \(error.localizedDescription)")
|
||||||
|
#endif
|
||||||
AnalyticsManager.shared.track(.healthKitAuthFailed(error: error.localizedDescription))
|
AnalyticsManager.shared.track(.healthKitAuthFailed(error: error.localizedDescription))
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
// Debug utility to export insights view screenshots with sample AI data.
|
// Debug utility to export insights view screenshots with sample AI data.
|
||||||
//
|
//
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import os.log
|
||||||
|
|
||||||
/// Exports insights view screenshots for App Store marketing
|
/// Exports insights view screenshots for App Store marketing
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -28,7 +28,12 @@ class InsightsExporter {
|
|||||||
|
|
||||||
// Clean and create export directory
|
// Clean and create export directory
|
||||||
try? FileManager.default.removeItem(at: exportPath)
|
try? FileManager.default.removeItem(at: exportPath)
|
||||||
try? FileManager.default.createDirectory(at: exportPath, withIntermediateDirectories: true)
|
do {
|
||||||
|
try FileManager.default.createDirectory(at: exportPath, withIntermediateDirectories: true)
|
||||||
|
} catch {
|
||||||
|
AppLogger.export.error("Failed to create insights export directory: \(error)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
var totalExported = 0
|
var totalExported = 0
|
||||||
|
|
||||||
@@ -95,9 +100,12 @@ class InsightsExporter {
|
|||||||
if let image = renderer.uiImage {
|
if let image = renderer.uiImage {
|
||||||
let url = folder.appendingPathComponent("\(name).png")
|
let url = folder.appendingPathComponent("\(name).png")
|
||||||
if let data = image.pngData() {
|
if let data = image.pngData() {
|
||||||
try? data.write(to: url)
|
do {
|
||||||
|
try data.write(to: url)
|
||||||
|
} catch {
|
||||||
|
AppLogger.export.error("Failed to write insights image '\(name)': \(error)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
|
|||||||
@@ -49,6 +49,23 @@ struct MoodDataSummary {
|
|||||||
|
|
||||||
// Health data for AI analysis (optional)
|
// Health data for AI analysis (optional)
|
||||||
let healthAverages: HealthService.HealthAverages?
|
let healthAverages: HealthService.HealthAverages?
|
||||||
|
|
||||||
|
// Tag-mood correlations
|
||||||
|
let tagFrequencies: [String: Int]
|
||||||
|
let goodDayTags: [String: Int] // tag counts for entries with mood good/great
|
||||||
|
let badDayTags: [String: Int] // tag counts for entries with mood bad/horrible
|
||||||
|
|
||||||
|
// Weather-mood correlation
|
||||||
|
let weatherMoodAverages: [String: Double] // condition -> avg mood (1-5 scale)
|
||||||
|
let tempRangeMoodAverages: [String: Double] // "Cold"/"Mild"/"Warm"/"Hot" -> avg mood
|
||||||
|
|
||||||
|
// Absence patterns
|
||||||
|
let loggingGapCount: Int // number of 2+ day gaps
|
||||||
|
let preGapMoodAverage: Double // avg mood in 3 days before a gap
|
||||||
|
let postGapMoodAverage: Double // avg mood in 3 days after returning
|
||||||
|
|
||||||
|
// Entry source breakdown
|
||||||
|
let entrySourceBreakdown: [String: Int] // source name -> count
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Transforms raw MoodEntryModel data into AI-optimized summaries
|
/// Transforms raw MoodEntryModel data into AI-optimized summaries
|
||||||
@@ -83,6 +100,11 @@ class MoodDataSummarizer {
|
|||||||
// Format date range
|
// Format date range
|
||||||
let dateRange = formatDateRange(entries: sortedEntries)
|
let dateRange = formatDateRange(entries: sortedEntries)
|
||||||
|
|
||||||
|
let tagAnalysis = calculateTagAnalysis(entries: validEntries)
|
||||||
|
let weatherAnalysis = calculateWeatherAnalysis(entries: validEntries)
|
||||||
|
let absencePatterns = calculateAbsencePatterns(entries: sortedEntries)
|
||||||
|
let sourceBreakdown = calculateEntrySourceBreakdown(entries: validEntries)
|
||||||
|
|
||||||
return MoodDataSummary(
|
return MoodDataSummary(
|
||||||
periodName: periodName,
|
periodName: periodName,
|
||||||
totalEntries: validEntries.count,
|
totalEntries: validEntries.count,
|
||||||
@@ -107,7 +129,16 @@ class MoodDataSummarizer {
|
|||||||
last7DaysMoods: recentContext.moods,
|
last7DaysMoods: recentContext.moods,
|
||||||
hasAllMoodTypes: moodTypes.hasAll,
|
hasAllMoodTypes: moodTypes.hasAll,
|
||||||
missingMoodTypes: moodTypes.missing,
|
missingMoodTypes: moodTypes.missing,
|
||||||
healthAverages: healthAverages
|
healthAverages: healthAverages,
|
||||||
|
tagFrequencies: tagAnalysis.frequencies,
|
||||||
|
goodDayTags: tagAnalysis.goodDayTags,
|
||||||
|
badDayTags: tagAnalysis.badDayTags,
|
||||||
|
weatherMoodAverages: weatherAnalysis.conditionAverages,
|
||||||
|
tempRangeMoodAverages: weatherAnalysis.tempRangeAverages,
|
||||||
|
loggingGapCount: absencePatterns.gapCount,
|
||||||
|
preGapMoodAverage: absencePatterns.preGapAverage,
|
||||||
|
postGapMoodAverage: absencePatterns.postGapAverage,
|
||||||
|
entrySourceBreakdown: sourceBreakdown
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,6 +377,139 @@ class MoodDataSummarizer {
|
|||||||
return (hasAll, missing)
|
return (hasAll, missing)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Tag Analysis
|
||||||
|
|
||||||
|
private func calculateTagAnalysis(entries: [MoodEntryModel]) -> (frequencies: [String: Int], goodDayTags: [String: Int], badDayTags: [String: Int]) {
|
||||||
|
var frequencies: [String: Int] = [:]
|
||||||
|
var goodDayTags: [String: Int] = [:]
|
||||||
|
var badDayTags: [String: Int] = [:]
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
let entryTags = entry.tags
|
||||||
|
guard !entryTags.isEmpty else { continue }
|
||||||
|
|
||||||
|
for tag in entryTags {
|
||||||
|
let normalizedTag = tag.lowercased()
|
||||||
|
frequencies[normalizedTag, default: 0] += 1
|
||||||
|
|
||||||
|
if [.good, .great].contains(entry.mood) {
|
||||||
|
goodDayTags[normalizedTag, default: 0] += 1
|
||||||
|
} else if [.bad, .horrible].contains(entry.mood) {
|
||||||
|
badDayTags[normalizedTag, default: 0] += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (frequencies, goodDayTags, badDayTags)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Weather Analysis
|
||||||
|
|
||||||
|
private func calculateWeatherAnalysis(entries: [MoodEntryModel]) -> (conditionAverages: [String: Double], tempRangeAverages: [String: Double]) {
|
||||||
|
var conditionTotals: [String: (total: Int, count: Int)] = [:]
|
||||||
|
var tempRangeTotals: [String: (total: Int, count: Int)] = [:]
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
guard let json = entry.weatherJSON, let weather = WeatherData.decode(from: json) else { continue }
|
||||||
|
|
||||||
|
let moodScore = Int(entry.moodValue) + 1 // 1-5 scale
|
||||||
|
|
||||||
|
// Group by weather condition
|
||||||
|
let condition = weather.condition
|
||||||
|
let current = conditionTotals[condition, default: (0, 0)]
|
||||||
|
conditionTotals[condition] = (current.total + moodScore, current.count + 1)
|
||||||
|
|
||||||
|
// Group by temperature range (convert Celsius to Fahrenheit)
|
||||||
|
let tempF = weather.temperature * 9.0 / 5.0 + 32.0
|
||||||
|
let tempRange: String
|
||||||
|
if tempF < 50 {
|
||||||
|
tempRange = "Cold"
|
||||||
|
} else if tempF <= 70 {
|
||||||
|
tempRange = "Mild"
|
||||||
|
} else if tempF <= 85 {
|
||||||
|
tempRange = "Warm"
|
||||||
|
} else {
|
||||||
|
tempRange = "Hot"
|
||||||
|
}
|
||||||
|
let currentTemp = tempRangeTotals[tempRange, default: (0, 0)]
|
||||||
|
tempRangeTotals[tempRange] = (currentTemp.total + moodScore, currentTemp.count + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
var conditionAverages: [String: Double] = [:]
|
||||||
|
for (condition, data) in conditionTotals {
|
||||||
|
conditionAverages[condition] = Double(data.total) / Double(data.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
var tempRangeAverages: [String: Double] = [:]
|
||||||
|
for (range, data) in tempRangeTotals {
|
||||||
|
tempRangeAverages[range] = Double(data.total) / Double(data.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (conditionAverages, tempRangeAverages)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Absence Patterns
|
||||||
|
|
||||||
|
private func calculateAbsencePatterns(entries: [MoodEntryModel]) -> (gapCount: Int, preGapAverage: Double, postGapAverage: Double) {
|
||||||
|
guard entries.count >= 2 else {
|
||||||
|
return (0, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
var gapCount = 0
|
||||||
|
var preGapScores: [Int] = []
|
||||||
|
var postGapScores: [Int] = []
|
||||||
|
|
||||||
|
for i in 1..<entries.count {
|
||||||
|
let dayDiff = calendar.dateComponents([.day], from: entries[i-1].forDate, to: entries[i].forDate).day ?? 0
|
||||||
|
guard dayDiff >= 2 else { continue }
|
||||||
|
|
||||||
|
gapCount += 1
|
||||||
|
|
||||||
|
// Collect up to 3 entries before the gap
|
||||||
|
let preStart = max(0, i - 3)
|
||||||
|
for j in preStart..<i {
|
||||||
|
preGapScores.append(Int(entries[j].moodValue) + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect up to 3 entries after the gap
|
||||||
|
let postEnd = min(entries.count, i + 3)
|
||||||
|
for j in i..<postEnd {
|
||||||
|
postGapScores.append(Int(entries[j].moodValue) + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let preAvg = preGapScores.isEmpty ? 0.0 : Double(preGapScores.reduce(0, +)) / Double(preGapScores.count)
|
||||||
|
let postAvg = postGapScores.isEmpty ? 0.0 : Double(postGapScores.reduce(0, +)) / Double(postGapScores.count)
|
||||||
|
|
||||||
|
return (gapCount, preAvg, postAvg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Entry Source Breakdown
|
||||||
|
|
||||||
|
private func calculateEntrySourceBreakdown(entries: [MoodEntryModel]) -> [String: Int] {
|
||||||
|
var breakdown: [String: Int] = [:]
|
||||||
|
|
||||||
|
let sourceNames: [Int: String] = [
|
||||||
|
0: "App",
|
||||||
|
1: "Widget",
|
||||||
|
2: "Watch",
|
||||||
|
3: "Shortcut",
|
||||||
|
4: "Auto-fill",
|
||||||
|
5: "Notification",
|
||||||
|
6: "Header",
|
||||||
|
7: "Siri",
|
||||||
|
8: "Control Center",
|
||||||
|
9: "Live Activity"
|
||||||
|
]
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
let name = sourceNames[entry.entryType] ?? "Other"
|
||||||
|
breakdown[name, default: 0] += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return breakdown
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|
||||||
private func formatDateRange(entries: [MoodEntryModel]) -> String {
|
private func formatDateRange(entries: [MoodEntryModel]) -> String {
|
||||||
@@ -384,7 +548,16 @@ class MoodDataSummarizer {
|
|||||||
last7DaysMoods: [],
|
last7DaysMoods: [],
|
||||||
hasAllMoodTypes: false,
|
hasAllMoodTypes: false,
|
||||||
missingMoodTypes: ["great", "good", "average", "bad", "horrible"],
|
missingMoodTypes: ["great", "good", "average", "bad", "horrible"],
|
||||||
healthAverages: nil
|
healthAverages: nil,
|
||||||
|
tagFrequencies: [:],
|
||||||
|
goodDayTags: [:],
|
||||||
|
badDayTags: [:],
|
||||||
|
weatherMoodAverages: [:],
|
||||||
|
tempRangeMoodAverages: [:],
|
||||||
|
loggingGapCount: 0,
|
||||||
|
preGapMoodAverage: 0,
|
||||||
|
postGapMoodAverage: 0,
|
||||||
|
entrySourceBreakdown: [:]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,6 +642,53 @@ class MoodDataSummarizer {
|
|||||||
lines.append("Analyze how these health metrics may correlate with mood patterns.")
|
lines.append("Analyze how these health metrics may correlate with mood patterns.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tag-mood correlations (only if tags exist)
|
||||||
|
if !summary.tagFrequencies.isEmpty {
|
||||||
|
let topTags = summary.tagFrequencies.sorted { $0.value > $1.value }.prefix(8)
|
||||||
|
.map { "\($0.key)(\($0.value))" }.joined(separator: ", ")
|
||||||
|
lines.append("Themes: \(topTags)")
|
||||||
|
|
||||||
|
if !summary.goodDayTags.isEmpty {
|
||||||
|
let goodTags = summary.goodDayTags.sorted { $0.value > $1.value }.prefix(5)
|
||||||
|
.map { "\($0.key)(\($0.value))" }.joined(separator: ", ")
|
||||||
|
lines.append("Good day themes: \(goodTags)")
|
||||||
|
}
|
||||||
|
if !summary.badDayTags.isEmpty {
|
||||||
|
let badTags = summary.badDayTags.sorted { $0.value > $1.value }.prefix(5)
|
||||||
|
.map { "\($0.key)(\($0.value))" }.joined(separator: ", ")
|
||||||
|
lines.append("Bad day themes: \(badTags)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weather-mood (only if weather data exists)
|
||||||
|
if !summary.weatherMoodAverages.isEmpty {
|
||||||
|
let weatherMood = summary.weatherMoodAverages.sorted { $0.value > $1.value }
|
||||||
|
.map { "\($0.key) avg \(String(format: "%.1f", $0.value))" }.joined(separator: ", ")
|
||||||
|
lines.append("Weather-mood: \(weatherMood)")
|
||||||
|
}
|
||||||
|
if !summary.tempRangeMoodAverages.isEmpty {
|
||||||
|
let tempMood = ["Cold", "Mild", "Warm", "Hot"].compactMap { range -> String? in
|
||||||
|
guard let avg = summary.tempRangeMoodAverages[range] else { return nil }
|
||||||
|
return "\(range) avg \(String(format: "%.1f", avg))"
|
||||||
|
}.joined(separator: ", ")
|
||||||
|
if !tempMood.isEmpty {
|
||||||
|
lines.append("Temp-mood: \(tempMood)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gaps (only if gaps exist)
|
||||||
|
if summary.loggingGapCount > 0 {
|
||||||
|
lines.append("Logging gaps: \(summary.loggingGapCount) breaks of 2+ days. Pre-gap avg: \(String(format: "%.1f", summary.preGapMoodAverage))/5, Post-return avg: \(String(format: "%.1f", summary.postGapMoodAverage))/5")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sources (only if multiple sources)
|
||||||
|
if summary.entrySourceBreakdown.count > 1 {
|
||||||
|
let total = Double(summary.entrySourceBreakdown.values.reduce(0, +))
|
||||||
|
let sources = summary.entrySourceBreakdown.sorted { $0.value > $1.value }
|
||||||
|
.map { "\($0.key) \(Int(Double($0.value) / total * 100))%" }.joined(separator: ", ")
|
||||||
|
lines.append("Entry sources: \(sources)")
|
||||||
|
}
|
||||||
|
|
||||||
return lines.joined(separator: "\n")
|
return lines.joined(separator: "\n")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,7 +92,11 @@ class PhotoManager: ObservableObject {
|
|||||||
let thumbnailURL = thumbnailsDir.appendingPathComponent(filename)
|
let thumbnailURL = thumbnailsDir.appendingPathComponent(filename)
|
||||||
if let thumbnail = createThumbnail(from: image),
|
if let thumbnail = createThumbnail(from: image),
|
||||||
let thumbnailData = thumbnail.jpegData(compressionQuality: 0.6) {
|
let thumbnailData = thumbnail.jpegData(compressionQuality: 0.6) {
|
||||||
try? thumbnailData.write(to: thumbnailURL)
|
do {
|
||||||
|
try thumbnailData.write(to: thumbnailURL)
|
||||||
|
} catch {
|
||||||
|
AppLogger.photos.error("Failed to save thumbnail: \(error)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
AnalyticsManager.shared.track(.photoAdded)
|
AnalyticsManager.shared.track(.photoAdded)
|
||||||
@@ -107,13 +111,21 @@ class PhotoManager: ObservableObject {
|
|||||||
let filename = "\(id.uuidString).jpg"
|
let filename = "\(id.uuidString).jpg"
|
||||||
let fullURL = photosDir.appendingPathComponent(filename)
|
let fullURL = photosDir.appendingPathComponent(filename)
|
||||||
|
|
||||||
guard FileManager.default.fileExists(atPath: fullURL.path),
|
guard FileManager.default.fileExists(atPath: fullURL.path) else {
|
||||||
let data = try? Data(contentsOf: fullURL),
|
|
||||||
let image = UIImage(data: data) else {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return image
|
do {
|
||||||
|
let data = try Data(contentsOf: fullURL)
|
||||||
|
guard let image = UIImage(data: data) else {
|
||||||
|
AppLogger.photos.error("Failed to create UIImage from photo data: \(id)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return image
|
||||||
|
} catch {
|
||||||
|
AppLogger.photos.error("Failed to read photo data for \(id): \(error)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadThumbnail(id: UUID) -> UIImage? {
|
func loadThumbnail(id: UUID) -> UIImage? {
|
||||||
@@ -123,10 +135,15 @@ class PhotoManager: ObservableObject {
|
|||||||
let thumbnailURL = thumbnailsDir.appendingPathComponent(filename)
|
let thumbnailURL = thumbnailsDir.appendingPathComponent(filename)
|
||||||
|
|
||||||
// Try thumbnail first
|
// Try thumbnail first
|
||||||
if FileManager.default.fileExists(atPath: thumbnailURL.path),
|
if FileManager.default.fileExists(atPath: thumbnailURL.path) {
|
||||||
let data = try? Data(contentsOf: thumbnailURL),
|
do {
|
||||||
let image = UIImage(data: data) {
|
let data = try Data(contentsOf: thumbnailURL)
|
||||||
return image
|
if let image = UIImage(data: data) {
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
AppLogger.photos.error("Failed to read thumbnail data for \(id): \(error)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to full image if thumbnail doesn't exist
|
// Fall back to full image if thumbnail doesn't exist
|
||||||
@@ -159,7 +176,11 @@ class PhotoManager: ObservableObject {
|
|||||||
|
|
||||||
// Delete thumbnail
|
// Delete thumbnail
|
||||||
if FileManager.default.fileExists(atPath: thumbnailURL.path) {
|
if FileManager.default.fileExists(atPath: thumbnailURL.path) {
|
||||||
try? FileManager.default.removeItem(at: thumbnailURL)
|
do {
|
||||||
|
try FileManager.default.removeItem(at: thumbnailURL)
|
||||||
|
} catch {
|
||||||
|
AppLogger.photos.error("Failed to delete thumbnail: \(error)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if success {
|
if success {
|
||||||
@@ -197,8 +218,13 @@ class PhotoManager: ObservableObject {
|
|||||||
var totalPhotoCount: Int {
|
var totalPhotoCount: Int {
|
||||||
guard let photosDir = photosDirectory else { return 0 }
|
guard let photosDir = photosDirectory else { return 0 }
|
||||||
|
|
||||||
let files = try? FileManager.default.contentsOfDirectory(atPath: photosDir.path)
|
do {
|
||||||
return files?.filter { $0.hasSuffix(".jpg") }.count ?? 0
|
let files = try FileManager.default.contentsOfDirectory(atPath: photosDir.path)
|
||||||
|
return files.filter { $0.hasSuffix(".jpg") }.count
|
||||||
|
} catch {
|
||||||
|
AppLogger.photos.error("Failed to list photos directory: \(error)")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var totalStorageUsed: Int64 {
|
var totalStorageUsed: Int64 {
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
// Debug utility to export sharing template screenshots.
|
// Debug utility to export sharing template screenshots.
|
||||||
//
|
//
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import os.log
|
||||||
|
|
||||||
/// Exports sharing template screenshots for App Store marketing
|
/// Exports sharing template screenshots for App Store marketing
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -21,13 +21,23 @@ class SharingScreenshotExporter {
|
|||||||
|
|
||||||
// Clean and create export directory
|
// Clean and create export directory
|
||||||
try? FileManager.default.removeItem(at: exportPath)
|
try? FileManager.default.removeItem(at: exportPath)
|
||||||
try? FileManager.default.createDirectory(at: exportPath, withIntermediateDirectories: true)
|
do {
|
||||||
|
try FileManager.default.createDirectory(at: exportPath, withIntermediateDirectories: true)
|
||||||
|
} catch {
|
||||||
|
AppLogger.export.error("Failed to create sharing export directory: \(error)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Create subdirectories
|
// Create subdirectories
|
||||||
let origDir = exportPath.appendingPathComponent("originals", isDirectory: true)
|
let origDir = exportPath.appendingPathComponent("originals", isDirectory: true)
|
||||||
let varDir = exportPath.appendingPathComponent("variations", isDirectory: true)
|
let varDir = exportPath.appendingPathComponent("variations", isDirectory: true)
|
||||||
try? FileManager.default.createDirectory(at: origDir, withIntermediateDirectories: true)
|
do {
|
||||||
try? FileManager.default.createDirectory(at: varDir, withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(at: origDir, withIntermediateDirectories: true)
|
||||||
|
try FileManager.default.createDirectory(at: varDir, withIntermediateDirectories: true)
|
||||||
|
} catch {
|
||||||
|
AppLogger.export.error("Failed to create sharing subdirectories: \(error)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
var totalExported = 0
|
var totalExported = 0
|
||||||
let distantPast = Date(timeIntervalSince1970: 0)
|
let distantPast = Date(timeIntervalSince1970: 0)
|
||||||
@@ -167,10 +177,9 @@ class SharingScreenshotExporter {
|
|||||||
try data.write(to: url)
|
try data.write(to: url)
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
print("Failed to save \(name): \(error)")
|
AppLogger.export.error("Failed to save sharing screenshot '\(name)': \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
|
|||||||
@@ -6,9 +6,9 @@
|
|||||||
// Uses the exportable watch views from ExportableWatchViews.swift.
|
// Uses the exportable watch views from ExportableWatchViews.swift.
|
||||||
//
|
//
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import os.log
|
||||||
|
|
||||||
/// Exports watch view previews to PNG files for App Store screenshots
|
/// Exports watch view previews to PNG files for App Store screenshots
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -76,7 +76,12 @@ class WatchExporter {
|
|||||||
|
|
||||||
// Clean and create export directory
|
// Clean and create export directory
|
||||||
try? FileManager.default.removeItem(at: exportPath)
|
try? FileManager.default.removeItem(at: exportPath)
|
||||||
try? FileManager.default.createDirectory(at: exportPath, withIntermediateDirectories: true)
|
do {
|
||||||
|
try FileManager.default.createDirectory(at: exportPath, withIntermediateDirectories: true)
|
||||||
|
} catch {
|
||||||
|
AppLogger.export.error("Failed to create watch export directory: \(error)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
var totalExported = 0
|
var totalExported = 0
|
||||||
|
|
||||||
@@ -85,7 +90,12 @@ class WatchExporter {
|
|||||||
for iconOption in allIcons {
|
for iconOption in allIcons {
|
||||||
let folderName = "\(tintOption.name)_\(iconOption.name)"
|
let folderName = "\(tintOption.name)_\(iconOption.name)"
|
||||||
let variantPath = exportPath.appendingPathComponent(folderName, isDirectory: true)
|
let variantPath = exportPath.appendingPathComponent(folderName, isDirectory: true)
|
||||||
try? FileManager.default.createDirectory(at: variantPath, withIntermediateDirectories: true)
|
do {
|
||||||
|
try FileManager.default.createDirectory(at: variantPath, withIntermediateDirectories: true)
|
||||||
|
} catch {
|
||||||
|
AppLogger.export.error("Failed to create watch variant directory '\(folderName)': \(error)")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
let config = WatchExportConfig(
|
let config = WatchExportConfig(
|
||||||
moodTint: tintOption.tint,
|
moodTint: tintOption.tint,
|
||||||
@@ -242,9 +252,12 @@ class WatchExporter {
|
|||||||
if let image = renderer.uiImage {
|
if let image = renderer.uiImage {
|
||||||
let url = folder.appendingPathComponent("\(name).png")
|
let url = folder.appendingPathComponent("\(name).png")
|
||||||
if let data = image.pngData() {
|
if let data = image.pngData() {
|
||||||
try? data.write(to: url)
|
do {
|
||||||
|
try data.write(to: url)
|
||||||
|
} catch {
|
||||||
|
AppLogger.export.error("Failed to write watch image '\(name)': \(error)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
|
|||||||
@@ -6,9 +6,9 @@
|
|||||||
// Uses the real widget view layouts from ExportableWidgetViews.swift.
|
// Uses the real widget view layouts from ExportableWidgetViews.swift.
|
||||||
//
|
//
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import os.log
|
||||||
|
|
||||||
/// Exports widget previews to PNG files for App Store screenshots
|
/// Exports widget previews to PNG files for App Store screenshots
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -76,7 +76,12 @@ class WidgetExporter {
|
|||||||
|
|
||||||
// Clean and create export directory
|
// Clean and create export directory
|
||||||
try? FileManager.default.removeItem(at: exportPath)
|
try? FileManager.default.removeItem(at: exportPath)
|
||||||
try? FileManager.default.createDirectory(at: exportPath, withIntermediateDirectories: true)
|
do {
|
||||||
|
try FileManager.default.createDirectory(at: exportPath, withIntermediateDirectories: true)
|
||||||
|
} catch {
|
||||||
|
AppLogger.export.error("Failed to create widget export directory: \(error)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
var totalExported = 0
|
var totalExported = 0
|
||||||
|
|
||||||
@@ -85,7 +90,12 @@ class WidgetExporter {
|
|||||||
for iconOption in allIcons {
|
for iconOption in allIcons {
|
||||||
let folderName = "\(tintOption.name)_\(iconOption.name)"
|
let folderName = "\(tintOption.name)_\(iconOption.name)"
|
||||||
let variantPath = exportPath.appendingPathComponent(folderName, isDirectory: true)
|
let variantPath = exportPath.appendingPathComponent(folderName, isDirectory: true)
|
||||||
try? FileManager.default.createDirectory(at: variantPath, withIntermediateDirectories: true)
|
do {
|
||||||
|
try FileManager.default.createDirectory(at: variantPath, withIntermediateDirectories: true)
|
||||||
|
} catch {
|
||||||
|
AppLogger.export.error("Failed to create variant directory '\(folderName)': \(error)")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
let config = WidgetExportConfig(
|
let config = WidgetExportConfig(
|
||||||
moodTint: tintOption.tint,
|
moodTint: tintOption.tint,
|
||||||
@@ -155,7 +165,12 @@ class WidgetExporter {
|
|||||||
let exportPath = documentsPath.appendingPathComponent("WidgetExports_Current", isDirectory: true)
|
let exportPath = documentsPath.appendingPathComponent("WidgetExports_Current", isDirectory: true)
|
||||||
|
|
||||||
try? FileManager.default.removeItem(at: exportPath)
|
try? FileManager.default.removeItem(at: exportPath)
|
||||||
try? FileManager.default.createDirectory(at: exportPath, withIntermediateDirectories: true)
|
do {
|
||||||
|
try FileManager.default.createDirectory(at: exportPath, withIntermediateDirectories: true)
|
||||||
|
} catch {
|
||||||
|
AppLogger.export.error("Failed to create current config export directory: \(error)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
let config = WidgetExportConfig(
|
let config = WidgetExportConfig(
|
||||||
moodTint: UserDefaultsStore.moodTintable(),
|
moodTint: UserDefaultsStore.moodTintable(),
|
||||||
@@ -177,7 +192,12 @@ class WidgetExporter {
|
|||||||
|
|
||||||
// Clean and create export directory
|
// Clean and create export directory
|
||||||
try? FileManager.default.removeItem(at: exportPath)
|
try? FileManager.default.removeItem(at: exportPath)
|
||||||
try? FileManager.default.createDirectory(at: exportPath, withIntermediateDirectories: true)
|
do {
|
||||||
|
try FileManager.default.createDirectory(at: exportPath, withIntermediateDirectories: true)
|
||||||
|
} catch {
|
||||||
|
AppLogger.export.error("Failed to create voting layout export directory: \(error)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
var totalExported = 0
|
var totalExported = 0
|
||||||
|
|
||||||
@@ -186,7 +206,12 @@ class WidgetExporter {
|
|||||||
for iconOption in allIcons {
|
for iconOption in allIcons {
|
||||||
let folderName = "\(tintOption.name)_\(iconOption.name)"
|
let folderName = "\(tintOption.name)_\(iconOption.name)"
|
||||||
let variantPath = exportPath.appendingPathComponent(folderName, isDirectory: true)
|
let variantPath = exportPath.appendingPathComponent(folderName, isDirectory: true)
|
||||||
try? FileManager.default.createDirectory(at: variantPath, withIntermediateDirectories: true)
|
do {
|
||||||
|
try FileManager.default.createDirectory(at: variantPath, withIntermediateDirectories: true)
|
||||||
|
} catch {
|
||||||
|
AppLogger.export.error("Failed to create voting variant directory '\(folderName)': \(error)")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
let config = WidgetExportConfig(
|
let config = WidgetExportConfig(
|
||||||
moodTint: tintOption.tint,
|
moodTint: tintOption.tint,
|
||||||
@@ -372,7 +397,11 @@ class WidgetExporter {
|
|||||||
if let image = renderer.uiImage {
|
if let image = renderer.uiImage {
|
||||||
let url = folder.appendingPathComponent("\(name).png")
|
let url = folder.appendingPathComponent("\(name).png")
|
||||||
if let data = image.pngData() {
|
if let data = image.pngData() {
|
||||||
try? data.write(to: url)
|
do {
|
||||||
|
try data.write(to: url)
|
||||||
|
} catch {
|
||||||
|
AppLogger.export.error("Failed to write widget image '\(name)': \(error)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -384,9 +413,12 @@ class WidgetExporter {
|
|||||||
if let image = renderer.uiImage {
|
if let image = renderer.uiImage {
|
||||||
let url = folder.appendingPathComponent("\(name).png")
|
let url = folder.appendingPathComponent("\(name).png")
|
||||||
if let data = image.pngData() {
|
if let data = image.pngData() {
|
||||||
try? data.write(to: url)
|
do {
|
||||||
|
try data.write(to: url)
|
||||||
|
} catch {
|
||||||
|
AppLogger.export.error("Failed to write live activity image '\(name)': \(error)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
|
|||||||
@@ -69,7 +69,6 @@ struct AddMoodHeaderView: View {
|
|||||||
.background(theme.currentTheme.secondaryBGColor)
|
.background(theme.currentTheme.secondaryBGColor)
|
||||||
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.accessibilityIdentifier(AccessibilityID.DayView.moodHeader)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -125,13 +124,13 @@ struct HorizontalVotingView: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(MoodButtonStyle())
|
.buttonStyle(MoodButtonStyle())
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
.accessibilityElement(children: .ignore)
|
||||||
|
.accessibilityAddTraits(.isButton)
|
||||||
.accessibilityIdentifier(AccessibilityID.MoodButton.id(for: mood.widgetDisplayName))
|
.accessibilityIdentifier(AccessibilityID.MoodButton.id(for: mood.widgetDisplayName))
|
||||||
.accessibilityLabel(mood.strValue)
|
.accessibilityLabel(mood.strValue)
|
||||||
.accessibilityHint(String(localized: "Select this mood"))
|
.accessibilityHint(String(localized: "Select this mood"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.accessibilityElement(children: .contain)
|
|
||||||
.accessibilityLabel(String(localized: "Mood selection"))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,8 +166,6 @@ struct CardVotingView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: 190)
|
.frame(height: 190)
|
||||||
.accessibilityElement(children: .contain)
|
|
||||||
.accessibilityLabel(String(localized: "Mood selection"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func cardButton(for mood: Mood, width: CGFloat) -> some View {
|
private func cardButton(for mood: Mood, width: CGFloat) -> some View {
|
||||||
@@ -190,6 +187,8 @@ struct CardVotingView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.buttonStyle(CardButtonStyle())
|
.buttonStyle(CardButtonStyle())
|
||||||
|
.accessibilityElement(children: .ignore)
|
||||||
|
.accessibilityAddTraits(.isButton)
|
||||||
.accessibilityIdentifier(AccessibilityID.MoodButton.id(for: mood.widgetDisplayName))
|
.accessibilityIdentifier(AccessibilityID.MoodButton.id(for: mood.widgetDisplayName))
|
||||||
.accessibilityLabel(mood.strValue)
|
.accessibilityLabel(mood.strValue)
|
||||||
.accessibilityHint(String(localized: "Select this mood"))
|
.accessibilityHint(String(localized: "Select this mood"))
|
||||||
@@ -230,13 +229,13 @@ struct StackedVotingView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.buttonStyle(CardButtonStyle())
|
.buttonStyle(CardButtonStyle())
|
||||||
|
.accessibilityElement(children: .ignore)
|
||||||
|
.accessibilityAddTraits(.isButton)
|
||||||
.accessibilityIdentifier(AccessibilityID.MoodButton.id(for: mood.widgetDisplayName))
|
.accessibilityIdentifier(AccessibilityID.MoodButton.id(for: mood.widgetDisplayName))
|
||||||
.accessibilityLabel(mood.strValue)
|
.accessibilityLabel(mood.strValue)
|
||||||
.accessibilityHint(String(localized: "Select this mood"))
|
.accessibilityHint(String(localized: "Select this mood"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.accessibilityElement(children: .contain)
|
|
||||||
.accessibilityLabel(String(localized: "Mood selection"))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,6 +316,8 @@ struct AuraVotingView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(AuraButtonStyle(color: color))
|
.buttonStyle(AuraButtonStyle(color: color))
|
||||||
|
.accessibilityElement(children: .ignore)
|
||||||
|
.accessibilityAddTraits(.isButton)
|
||||||
.accessibilityIdentifier(AccessibilityID.MoodButton.id(for: mood.widgetDisplayName))
|
.accessibilityIdentifier(AccessibilityID.MoodButton.id(for: mood.widgetDisplayName))
|
||||||
.accessibilityLabel(mood.strValue)
|
.accessibilityLabel(mood.strValue)
|
||||||
.accessibilityHint(String(localized: "Select this mood"))
|
.accessibilityHint(String(localized: "Select this mood"))
|
||||||
@@ -355,8 +356,6 @@ struct OrbitVotingView: View {
|
|||||||
.onDisappear {
|
.onDisappear {
|
||||||
centerPulse = 1.0
|
centerPulse = 1.0
|
||||||
}
|
}
|
||||||
.accessibilityElement(children: .contain)
|
|
||||||
.accessibilityLabel(String(localized: "Mood selection"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func orbitalRing(radius: CGFloat, centerX: CGFloat, centerY: CGFloat) -> some View {
|
private func orbitalRing(radius: CGFloat, centerX: CGFloat, centerY: CGFloat) -> some View {
|
||||||
@@ -408,6 +407,8 @@ struct OrbitVotingView: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(OrbitButtonStyle(color: color))
|
.buttonStyle(OrbitButtonStyle(color: color))
|
||||||
.position(x: posX, y: posY)
|
.position(x: posX, y: posY)
|
||||||
|
.accessibilityElement(children: .ignore)
|
||||||
|
.accessibilityAddTraits(.isButton)
|
||||||
.accessibilityIdentifier(AccessibilityID.MoodButton.id(for: mood.widgetDisplayName))
|
.accessibilityIdentifier(AccessibilityID.MoodButton.id(for: mood.widgetDisplayName))
|
||||||
.accessibilityLabel(mood.strValue)
|
.accessibilityLabel(mood.strValue)
|
||||||
.accessibilityHint(String(localized: "Select this mood"))
|
.accessibilityHint(String(localized: "Select this mood"))
|
||||||
@@ -696,7 +697,9 @@ struct NeonEqualizerBar: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(NeonBarButtonStyle(isPressed: $isPressed))
|
.buttonStyle(NeonBarButtonStyle(isPressed: $isPressed))
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.accessibilityIdentifier(AccessibilityID.MoodButton.id(for: mood.widgetDisplayName))
|
.accessibilityElement(children: .ignore)
|
||||||
|
.accessibilityAddTraits(.isButton)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.NeonMoodButton.id(for: mood.widgetDisplayName))
|
||||||
.accessibilityLabel(mood.strValue)
|
.accessibilityLabel(mood.strValue)
|
||||||
.accessibilityHint(String(localized: "Select this mood"))
|
.accessibilityHint(String(localized: "Select this mood"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -305,6 +305,12 @@ struct FlipRevealAnimation: View {
|
|||||||
struct ShatterReformAnimation: View {
|
struct ShatterReformAnimation: View {
|
||||||
let mood: Mood
|
let mood: Mood
|
||||||
|
|
||||||
|
private enum AnimationConstants {
|
||||||
|
static let shatterPhaseDuration: TimeInterval = 0.6
|
||||||
|
static let checkmarkAppearDelay: TimeInterval = 1.1
|
||||||
|
static let fadeOutDelay: TimeInterval = 1.8
|
||||||
|
}
|
||||||
|
|
||||||
@State private var shardOffsets: [CGSize] = []
|
@State private var shardOffsets: [CGSize] = []
|
||||||
@State private var shardRotations: [Double] = []
|
@State private var shardRotations: [Double] = []
|
||||||
@State private var shardOpacities: [Double] = []
|
@State private var shardOpacities: [Double] = []
|
||||||
@@ -354,7 +360,7 @@ struct ShatterReformAnimation: View {
|
|||||||
|
|
||||||
// Phase 2: Converge to center and fade
|
// Phase 2: Converge to center and fade
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
try? await Task.sleep(for: .seconds(0.6))
|
try? await Task.sleep(for: .seconds(AnimationConstants.shatterPhaseDuration))
|
||||||
phase = .reform
|
phase = .reform
|
||||||
withAnimation(.easeInOut(duration: 0.5)) {
|
withAnimation(.easeInOut(duration: 0.5)) {
|
||||||
for i in 0..<shardCount {
|
for i in 0..<shardCount {
|
||||||
@@ -367,7 +373,7 @@ struct ShatterReformAnimation: View {
|
|||||||
|
|
||||||
// Phase 3: Show checkmark
|
// Phase 3: Show checkmark
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
try? await Task.sleep(for: .seconds(1.1))
|
try? await Task.sleep(for: .seconds(AnimationConstants.checkmarkAppearDelay))
|
||||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) {
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) {
|
||||||
checkmarkOpacity = 1
|
checkmarkOpacity = 1
|
||||||
}
|
}
|
||||||
@@ -375,7 +381,7 @@ struct ShatterReformAnimation: View {
|
|||||||
|
|
||||||
// Phase 4: Fade out
|
// Phase 4: Fade out
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
try? await Task.sleep(for: .seconds(1.8))
|
try? await Task.sleep(for: .seconds(AnimationConstants.fadeOutDelay))
|
||||||
withAnimation(.easeOut(duration: 0.3)) {
|
withAnimation(.easeOut(duration: 0.3)) {
|
||||||
checkmarkOpacity = 0
|
checkmarkOpacity = 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,7 +109,8 @@ struct CreateWidgetView: View {
|
|||||||
.frame(minWidth: 0, maxWidth: .infinity)
|
.frame(minWidth: 0, maxWidth: .infinity)
|
||||||
.frame(minHeight: 40, maxHeight: .infinity)
|
.frame(minHeight: 40, maxHeight: .infinity)
|
||||||
.background(.blue)
|
.background(.blue)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.CustomWidget.shuffleButton)
|
||||||
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
AnalyticsManager.shared.track(.widgetCreated)
|
AnalyticsManager.shared.track(.widgetCreated)
|
||||||
UserDefaultsStore.saveCustomWidget(widgetModel: customWidget, inUse: false)
|
UserDefaultsStore.saveCustomWidget(widgetModel: customWidget, inUse: false)
|
||||||
@@ -127,7 +128,8 @@ struct CreateWidgetView: View {
|
|||||||
.frame(minWidth: 0, maxWidth: .infinity)
|
.frame(minWidth: 0, maxWidth: .infinity)
|
||||||
.frame(minHeight: 40, maxHeight: .infinity)
|
.frame(minHeight: 40, maxHeight: .infinity)
|
||||||
.background(.green)
|
.background(.green)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.CustomWidget.saveButton)
|
||||||
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
AnalyticsManager.shared.track(.widgetUsed)
|
AnalyticsManager.shared.track(.widgetUsed)
|
||||||
UserDefaultsStore.saveCustomWidget(widgetModel: customWidget, inUse: true)
|
UserDefaultsStore.saveCustomWidget(widgetModel: customWidget, inUse: true)
|
||||||
@@ -145,7 +147,8 @@ struct CreateWidgetView: View {
|
|||||||
.frame(minWidth: 0, maxWidth: .infinity)
|
.frame(minWidth: 0, maxWidth: .infinity)
|
||||||
.frame(minHeight: 40, maxHeight: .infinity)
|
.frame(minHeight: 40, maxHeight: .infinity)
|
||||||
.background(.pink)
|
.background(.pink)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.CustomWidget.useButton)
|
||||||
|
|
||||||
if customWidget.isSaved {
|
if customWidget.isSaved {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
AnalyticsManager.shared.track(.widgetDeleted)
|
AnalyticsManager.shared.track(.widgetDeleted)
|
||||||
@@ -163,6 +166,7 @@ struct CreateWidgetView: View {
|
|||||||
.frame(minWidth: 0, maxWidth: .infinity)
|
.frame(minWidth: 0, maxWidth: .infinity)
|
||||||
.frame(minHeight: 40, maxHeight: .infinity)
|
.frame(minHeight: 40, maxHeight: .infinity)
|
||||||
.background(.orange)
|
.background(.orange)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.CustomWidget.deleteButton)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(minHeight: 40, maxHeight: .infinity)
|
.frame(minHeight: 40, maxHeight: .infinity)
|
||||||
@@ -178,6 +182,8 @@ struct CreateWidgetView: View {
|
|||||||
AnalyticsManager.shared.track(.widgetColorUpdated(part: "background"))
|
AnalyticsManager.shared.track(.widgetColorUpdated(part: "background"))
|
||||||
}
|
}
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
|
.accessibilityLabel(String(localized: "create_widget_background_color"))
|
||||||
|
.accessibilityIdentifier(AccessibilityID.CustomWidget.colorPicker("bg"))
|
||||||
}
|
}
|
||||||
.frame(minWidth: 0, maxWidth: .infinity)
|
.frame(minWidth: 0, maxWidth: .infinity)
|
||||||
|
|
||||||
@@ -188,6 +194,8 @@ struct CreateWidgetView: View {
|
|||||||
AnalyticsManager.shared.track(.widgetColorUpdated(part: "inner"))
|
AnalyticsManager.shared.track(.widgetColorUpdated(part: "inner"))
|
||||||
}
|
}
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
|
.accessibilityLabel(String(localized: "create_widget_inner_color"))
|
||||||
|
.accessibilityIdentifier(AccessibilityID.CustomWidget.colorPicker("inner"))
|
||||||
}
|
}
|
||||||
.frame(minWidth: 0, maxWidth: .infinity)
|
.frame(minWidth: 0, maxWidth: .infinity)
|
||||||
|
|
||||||
@@ -198,6 +206,8 @@ struct CreateWidgetView: View {
|
|||||||
AnalyticsManager.shared.track(.widgetColorUpdated(part: "outline"))
|
AnalyticsManager.shared.track(.widgetColorUpdated(part: "outline"))
|
||||||
}
|
}
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
|
.accessibilityLabel(String(localized: "create_widget_face_outline_color"))
|
||||||
|
.accessibilityIdentifier(AccessibilityID.CustomWidget.colorPicker("stroke"))
|
||||||
}
|
}
|
||||||
.frame(minWidth: 0, maxWidth: .infinity)
|
.frame(minWidth: 0, maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
@@ -210,6 +220,8 @@ struct CreateWidgetView: View {
|
|||||||
AnalyticsManager.shared.track(.widgetColorUpdated(part: "left_eye"))
|
AnalyticsManager.shared.track(.widgetColorUpdated(part: "left_eye"))
|
||||||
}
|
}
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
|
.accessibilityLabel(String(localized: "create_widget_view_left_eye_color"))
|
||||||
|
.accessibilityIdentifier(AccessibilityID.CustomWidget.colorPicker("leftEye"))
|
||||||
}
|
}
|
||||||
.frame(minWidth: 0, maxWidth: .infinity)
|
.frame(minWidth: 0, maxWidth: .infinity)
|
||||||
|
|
||||||
@@ -220,6 +232,8 @@ struct CreateWidgetView: View {
|
|||||||
AnalyticsManager.shared.track(.widgetColorUpdated(part: "right_eye"))
|
AnalyticsManager.shared.track(.widgetColorUpdated(part: "right_eye"))
|
||||||
}
|
}
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
|
.accessibilityLabel(String(localized: "create_widget_view_right_eye_color"))
|
||||||
|
.accessibilityIdentifier(AccessibilityID.CustomWidget.colorPicker("rightEye"))
|
||||||
}
|
}
|
||||||
.frame(minWidth: 0, maxWidth: .infinity)
|
.frame(minWidth: 0, maxWidth: .infinity)
|
||||||
|
|
||||||
@@ -230,6 +244,8 @@ struct CreateWidgetView: View {
|
|||||||
AnalyticsManager.shared.track(.widgetColorUpdated(part: "mouth"))
|
AnalyticsManager.shared.track(.widgetColorUpdated(part: "mouth"))
|
||||||
}
|
}
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
|
.accessibilityLabel(String(localized: "create_widget_view_mouth_color"))
|
||||||
|
.accessibilityIdentifier(AccessibilityID.CustomWidget.colorPicker("mouth"))
|
||||||
}
|
}
|
||||||
.frame(minWidth: 0, maxWidth: .infinity)
|
.frame(minWidth: 0, maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
@@ -250,16 +266,25 @@ struct CreateWidgetView: View {
|
|||||||
.frame(minWidth: 10, idealWidth: 40, maxWidth: 40,
|
.frame(minWidth: 10, idealWidth: 40, maxWidth: 40,
|
||||||
minHeight: 10, idealHeight: 40, maxHeight: 40,
|
minHeight: 10, idealHeight: 40, maxHeight: 40,
|
||||||
alignment: .center)
|
alignment: .center)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.CustomWidget.backgroundOption(CustomWidgetBackGroundOptions.selectable.firstIndex(of: bg) ?? 0))
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
update(background: bg)
|
update(background: bg)
|
||||||
}
|
}
|
||||||
|
.accessibilityAddTraits(.isButton)
|
||||||
|
.accessibilityLabel(String(localized: "Select background \(bg.rawValue)"))
|
||||||
}
|
}
|
||||||
mixBG
|
mixBG
|
||||||
|
.accessibilityIdentifier(AccessibilityID.CustomWidget.randomBackgroundButton)
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
update(background: .random)
|
update(background: .random)
|
||||||
}
|
}
|
||||||
|
.accessibilityAddTraits(.isButton)
|
||||||
|
.accessibilityLabel(String(localized: "Random background"))
|
||||||
Divider()
|
Divider()
|
||||||
ColorPicker("", selection: $customWidget.bgOverlayColor)
|
ColorPicker("", selection: $customWidget.bgOverlayColor)
|
||||||
|
.labelsHidden()
|
||||||
|
.accessibilityLabel(String(localized: "Background overlay color"))
|
||||||
|
.accessibilityIdentifier(AccessibilityID.CustomWidget.colorPicker("bgOverlay"))
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.background(
|
.background(
|
||||||
@@ -270,24 +295,30 @@ struct CreateWidgetView: View {
|
|||||||
var faceImageOptions: some View {
|
var faceImageOptions: some View {
|
||||||
HStack(alignment: .center) {
|
HStack(alignment: .center) {
|
||||||
Text(String(localized: "create_widget_view_left_eye"))
|
Text(String(localized: "create_widget_view_left_eye"))
|
||||||
|
.accessibilityIdentifier(AccessibilityID.CustomWidget.leftEyeButton)
|
||||||
.onTapGesture(perform: {
|
.onTapGesture(perform: {
|
||||||
showLeftEyeImagePicker.toggle()
|
showLeftEyeImagePicker.toggle()
|
||||||
})
|
})
|
||||||
|
.accessibilityAddTraits(.isButton)
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.frame(minWidth: 0, maxWidth: .infinity)
|
.frame(minWidth: 0, maxWidth: .infinity)
|
||||||
Divider()
|
Divider()
|
||||||
Text(String(localized: "create_widget_view_right_eye"))
|
Text(String(localized: "create_widget_view_right_eye"))
|
||||||
|
.accessibilityIdentifier(AccessibilityID.CustomWidget.rightEyeButton)
|
||||||
.onTapGesture(perform: {
|
.onTapGesture(perform: {
|
||||||
showRightEyeImagePicker.toggle()
|
showRightEyeImagePicker.toggle()
|
||||||
})
|
})
|
||||||
|
.accessibilityAddTraits(.isButton)
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.frame(minWidth: 0, maxWidth: .infinity)
|
.frame(minWidth: 0, maxWidth: .infinity)
|
||||||
Divider()
|
Divider()
|
||||||
Text(String(localized: "create_widget_view_mouth"))
|
Text(String(localized: "create_widget_view_mouth"))
|
||||||
|
.accessibilityIdentifier(AccessibilityID.CustomWidget.mouthButton)
|
||||||
.onTapGesture(perform: {
|
.onTapGesture(perform: {
|
||||||
showMuthImagePicker.toggle()
|
showMuthImagePicker.toggle()
|
||||||
})
|
})
|
||||||
|
.accessibilityAddTraits(.isButton)
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.frame(minWidth: 0, maxWidth: .infinity)
|
.frame(minWidth: 0, maxWidth: .infinity)
|
||||||
|
|||||||
@@ -492,6 +492,11 @@ struct VotingLayoutPickerCompact: View {
|
|||||||
|
|
||||||
// MARK: - Celebration Animation Picker
|
// MARK: - Celebration Animation Picker
|
||||||
struct CelebrationAnimationPickerCompact: View {
|
struct CelebrationAnimationPickerCompact: View {
|
||||||
|
private enum AnimationConstants {
|
||||||
|
static let previewTriggerDelay: TimeInterval = 0.5
|
||||||
|
static let dismissTransitionDelay: TimeInterval = 0.35
|
||||||
|
}
|
||||||
|
|
||||||
@AppStorage(UserDefaultsStore.Keys.celebrationAnimation.rawValue, store: GroupUserDefaults.groupDefaults) private var celebrationAnimationIndex: Int = 0
|
@AppStorage(UserDefaultsStore.Keys.celebrationAnimation.rawValue, store: GroupUserDefaults.groupDefaults) private var celebrationAnimationIndex: Int = 0
|
||||||
@AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default
|
@AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default
|
||||||
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
|
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
|
||||||
@@ -538,6 +543,7 @@ struct CelebrationAnimationPickerCompact: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Customize.celebrationAnimationButton(animation.rawValue))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 4)
|
.padding(.horizontal, 4)
|
||||||
@@ -585,7 +591,7 @@ struct CelebrationAnimationPickerCompact: View {
|
|||||||
|
|
||||||
// Auto-trigger the celebration after a brief pause
|
// Auto-trigger the celebration after a brief pause
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
try? await Task.sleep(for: .seconds(0.5))
|
try? await Task.sleep(for: .seconds(AnimationConstants.previewTriggerDelay))
|
||||||
guard previewAnimation == animation else { return }
|
guard previewAnimation == animation else { return }
|
||||||
if hapticFeedbackEnabled {
|
if hapticFeedbackEnabled {
|
||||||
HapticFeedbackManager.shared.play(for: animation)
|
HapticFeedbackManager.shared.play(for: animation)
|
||||||
@@ -602,7 +608,7 @@ struct CelebrationAnimationPickerCompact: View {
|
|||||||
previewOpacity = 0
|
previewOpacity = 0
|
||||||
}
|
}
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
try? await Task.sleep(for: .seconds(0.35))
|
try? await Task.sleep(for: .seconds(AnimationConstants.dismissTransitionDelay))
|
||||||
withAnimation(.easeOut(duration: 0.15)) {
|
withAnimation(.easeOut(duration: 0.15)) {
|
||||||
previewAnimation = nil
|
previewAnimation = nil
|
||||||
}
|
}
|
||||||
@@ -666,6 +672,7 @@ struct CustomWidgetSection: View {
|
|||||||
CustomWidgetView(customWidgetModel: widget)
|
CustomWidgetView(customWidgetModel: widget)
|
||||||
.frame(width: 60, height: 60)
|
.frame(width: 60, height: 60)
|
||||||
.cornerRadius(12)
|
.cornerRadius(12)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Customize.customWidget(UserDefaultsStore.getCustomWidgets().firstIndex(where: { $0.uuid == widget.uuid }) ?? 0))
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
AnalyticsManager.shared.track(.widgetViewed)
|
AnalyticsManager.shared.track(.widgetViewed)
|
||||||
selectedWidget.selectedItem = widget.copy() as? CustomWidgetModel
|
selectedWidget.selectedItem = widget.copy() as? CustomWidgetModel
|
||||||
@@ -689,6 +696,7 @@ struct CustomWidgetSection: View {
|
|||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Customize.customWidgetAdd)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -701,6 +709,7 @@ struct CustomWidgetSection: View {
|
|||||||
}
|
}
|
||||||
.foregroundColor(.accentColor)
|
.foregroundColor(.accentColor)
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Customize.widgetHowToLink)
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $selectedWidget.showSheet) {
|
.sheet(isPresented: $selectedWidget.showSheet) {
|
||||||
if let selectedItem = selectedWidget.selectedItem {
|
if let selectedItem = selectedWidget.selectedItem {
|
||||||
@@ -822,6 +831,7 @@ struct SubscriptionBannerView: View {
|
|||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
.background(Capsule().fill(Color.green.opacity(0.15)))
|
.background(Capsule().fill(Color.green.opacity(0.15)))
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Customize.manageSubscriptionButton)
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(16)
|
||||||
}
|
}
|
||||||
@@ -866,6 +876,7 @@ struct SubscriptionBannerView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Customize.unlockPremiumButton)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func openSubscriptionManagement() async {
|
private func openSubscriptionManagement() async {
|
||||||
@@ -873,7 +884,9 @@ struct SubscriptionBannerView: View {
|
|||||||
do {
|
do {
|
||||||
try await AppStore.showManageSubscriptions(in: windowScene)
|
try await AppStore.showManageSubscriptions(in: windowScene)
|
||||||
} catch {
|
} catch {
|
||||||
|
#if DEBUG
|
||||||
print("Failed to open subscription management: \(error)")
|
print("Failed to open subscription management: \(error)")
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ struct CustomWigetView: View {
|
|||||||
CustomWidgetView(customWidgetModel: widget)
|
CustomWidgetView(customWidgetModel: widget)
|
||||||
.frame(width: 50, height: 50)
|
.frame(width: 50, height: 50)
|
||||||
.cornerRadius(10)
|
.cornerRadius(10)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Customize.customWidget(UserDefaultsStore.getCustomWidgets().firstIndex(where: { $0.uuid == widget.uuid }) ?? 0))
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
AnalyticsManager.shared.track(.widgetViewed)
|
AnalyticsManager.shared.track(.widgetViewed)
|
||||||
selectedWidget.selectedItem = widget.copy() as? CustomWidgetModel
|
selectedWidget.selectedItem = widget.copy() as? CustomWidgetModel
|
||||||
@@ -34,6 +35,7 @@ struct CustomWigetView: View {
|
|||||||
.overlay(
|
.overlay(
|
||||||
Image(systemName: "plus")
|
Image(systemName: "plus")
|
||||||
)
|
)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Customize.customWidgetAdd)
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
AnalyticsManager.shared.track(.widgetCreateTapped)
|
AnalyticsManager.shared.track(.widgetCreateTapped)
|
||||||
selectedWidget.selectedItem = CustomWidgetModel.randomWidget
|
selectedWidget.selectedItem = CustomWidgetModel.randomWidget
|
||||||
@@ -47,6 +49,7 @@ struct CustomWigetView: View {
|
|||||||
.cornerRadius(10)
|
.cornerRadius(10)
|
||||||
|
|
||||||
Text("[\(String(localized: "how_to_add_widget"))](https://support.apple.com/guide/iphone/add-widgets-iphb8f1bf206/ios)")
|
Text("[\(String(localized: "how_to_add_widget"))](https://support.apple.com/guide/iphone/add-widgets-iphb8f1bf206/ios)")
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Customize.widgetHowToLink)
|
||||||
.accentColor(textColor)
|
.accentColor(textColor)
|
||||||
.padding(.bottom)
|
.padding(.bottom)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ struct DayFilterPickerView: View {
|
|||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Customize.dayFilterButton(day))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text(String(localized: "day_picker_view_text"))
|
Text(String(localized: "day_picker_view_text"))
|
||||||
|
|||||||
@@ -64,12 +64,15 @@ struct IconPickerView: View {
|
|||||||
})
|
})
|
||||||
.accessibilityLabel(String(localized: "Default app icon"))
|
.accessibilityLabel(String(localized: "Default app icon"))
|
||||||
.accessibilityHint(String(localized: "Double tap to select"))
|
.accessibilityHint(String(localized: "Double tap to select"))
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Customize.iconButton("default"))
|
||||||
|
|
||||||
|
|
||||||
ForEach(iconSets, id: \.self.0){ iconSet in
|
ForEach(iconSets, id: \.self.0){ iconSet in
|
||||||
Button(action: {
|
Button(action: {
|
||||||
UIApplication.shared.setAlternateIconName(iconSet.1) { (error) in
|
UIApplication.shared.setAlternateIconName(iconSet.1) { error in
|
||||||
// FIXME: Handle error
|
if let error {
|
||||||
|
AppLogger.settings.error("Failed to set app icon '\(iconSet.1)': \(error.localizedDescription)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
AnalyticsManager.shared.track(.appIconChanged(iconTitle: iconSet.1))
|
AnalyticsManager.shared.track(.appIconChanged(iconTitle: iconSet.1))
|
||||||
}, label: {
|
}, label: {
|
||||||
@@ -80,6 +83,7 @@ struct IconPickerView: View {
|
|||||||
})
|
})
|
||||||
.accessibilityLabel(String(localized: "App icon style \(iconSet.1.replacingOccurrences(of: "AppIcon", with: "").replacingOccurrences(of: "Image", with: ""))"))
|
.accessibilityLabel(String(localized: "App icon style \(iconSet.1.replacingOccurrences(of: "AppIcon", with: "").replacingOccurrences(of: "Image", with: ""))"))
|
||||||
.accessibilityHint(String(localized: "Double tap to select"))
|
.accessibilityHint(String(localized: "Double tap to select"))
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Customize.iconButton(iconSet.1))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
|
|||||||
@@ -41,12 +41,15 @@ struct ImagePackPickerView: View {
|
|||||||
.fill(imagePack == images ? theme.currentTheme.bgColor : .clear)
|
.fill(imagePack == images ? theme.currentTheme.bgColor : .clear)
|
||||||
.padding([.top, .bottom], -3)
|
.padding([.top, .bottom], -3)
|
||||||
)
|
)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Customize.imagePackOption(String(describing: images)))
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
let impactMed = UIImpactFeedbackGenerator(style: .heavy)
|
let impactMed = UIImpactFeedbackGenerator(style: .heavy)
|
||||||
impactMed.impactOccurred()
|
impactMed.impactOccurred()
|
||||||
imagePack = images
|
imagePack = images
|
||||||
AnalyticsManager.shared.track(.iconPackChanged(packId: images.rawValue))
|
AnalyticsManager.shared.track(.iconPackChanged(packId: images.rawValue))
|
||||||
}
|
}
|
||||||
|
.accessibilityAddTraits(.isButton)
|
||||||
|
.accessibilityLabel(String(localized: "Select \(String(describing: images)) icon pack"))
|
||||||
if images.rawValue != (MoodImages.allCases.sorted(by: { $0.rawValue > $1.rawValue }).first?.rawValue) ?? 0 {
|
if images.rawValue != (MoodImages.allCases.sorted(by: { $0.rawValue > $1.rawValue }).first?.rawValue) ?? 0 {
|
||||||
Divider()
|
Divider()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ struct PersonalityPackPickerView: View {
|
|||||||
.fill(personalityPack == aPack ? theme.currentTheme.bgColor : .clear)
|
.fill(personalityPack == aPack ? theme.currentTheme.bgColor : .clear)
|
||||||
.padding(5)
|
.padding(5)
|
||||||
)
|
)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Customize.personalityPackOption(aPack.title()))
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
let impactMed = UIImpactFeedbackGenerator(style: .heavy)
|
let impactMed = UIImpactFeedbackGenerator(style: .heavy)
|
||||||
impactMed.impactOccurred()
|
impactMed.impactOccurred()
|
||||||
@@ -46,6 +47,8 @@ struct PersonalityPackPickerView: View {
|
|||||||
LocalNotification.rescheduleNotifiations()
|
LocalNotification.rescheduleNotifiations()
|
||||||
// }
|
// }
|
||||||
}
|
}
|
||||||
|
.accessibilityAddTraits(.isButton)
|
||||||
|
.accessibilityLabel(String(localized: "Select \(aPack.title()) personality pack"))
|
||||||
// .blur(radius: aPack.rawValue == PersonalityPack.Rude.rawValue && !showNSFW ? 5 : 0)
|
// .blur(radius: aPack.rawValue == PersonalityPack.Rude.rawValue && !showNSFW ? 5 : 0)
|
||||||
.alert(isPresented: $showOver18Alert) {
|
.alert(isPresented: $showOver18Alert) {
|
||||||
let primaryButton = Alert.Button.default(Text(String(localized: "customize_view_over18alert_ok"))) {
|
let primaryButton = Alert.Button.default(Text(String(localized: "customize_view_over18alert_ok"))) {
|
||||||
|
|||||||
@@ -31,9 +31,12 @@ struct ShapePickerView: View {
|
|||||||
.resizable()
|
.resizable()
|
||||||
.frame(width: 20, height: 20, alignment: .trailing)
|
.frame(width: 20, height: 20, alignment: .trailing)
|
||||||
.foregroundColor(Color(UIColor.systemGray))
|
.foregroundColor(Color(UIColor.systemGray))
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Customize.shapeRefresh)
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
shapeRefreshToggleThing.toggle()
|
shapeRefreshToggleThing.toggle()
|
||||||
}
|
}
|
||||||
|
.accessibilityAddTraits(.isButton)
|
||||||
|
.accessibilityLabel(String(localized: "Refresh shapes"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,12 +46,15 @@ struct ShapePickerView: View {
|
|||||||
bgColor: moodTint.color(forMood: Mood.allValues.randomElement()!), textColor: textColor)
|
bgColor: moodTint.color(forMood: Mood.allValues.randomElement()!), textColor: textColor)
|
||||||
.frame(height: 50)
|
.frame(height: 50)
|
||||||
.frame(minWidth: 0, maxWidth: .infinity)
|
.frame(minWidth: 0, maxWidth: .infinity)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Customize.shapeOption(String(describing: ashape)))
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
let impactMed = UIImpactFeedbackGenerator(style: .heavy)
|
let impactMed = UIImpactFeedbackGenerator(style: .heavy)
|
||||||
impactMed.impactOccurred()
|
impactMed.impactOccurred()
|
||||||
shape = ashape
|
shape = ashape
|
||||||
AnalyticsManager.shared.track(.moodShapeChanged(shapeId: shape.rawValue))
|
AnalyticsManager.shared.track(.moodShapeChanged(shapeId: shape.rawValue))
|
||||||
}
|
}
|
||||||
|
.accessibilityAddTraits(.isButton)
|
||||||
|
.accessibilityLabel(String(localized: "Select \(String(describing: ashape)) shape"))
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ struct ThemePickerView: View {
|
|||||||
.fill(selectedTheme == theme ? selectedTheme.currentTheme.bgColor : .clear)
|
.fill(selectedTheme == theme ? selectedTheme.currentTheme.bgColor : .clear)
|
||||||
.padding(-5)
|
.padding(-5)
|
||||||
)
|
)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Customize.themeButton(theme.title))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func selectTheme(_ theme: Theme) {
|
private func selectTheme(_ theme: Theme) {
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ struct VotingLayoutPickerView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Customize.votingLayoutButton(layout.displayName))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
|
|||||||
@@ -72,7 +72,9 @@ class DayViewViewModel: ObservableObject {
|
|||||||
|
|
||||||
public func update(entry: MoodEntryModel, toMood mood: Mood) {
|
public func update(entry: MoodEntryModel, toMood mood: Mood) {
|
||||||
if !MoodLogger.shared.updateMood(entryDate: entry.forDate, withMood: mood) {
|
if !MoodLogger.shared.updateMood(entryDate: entry.forDate, withMood: mood) {
|
||||||
|
#if DEBUG
|
||||||
print("Failed to update mood entry")
|
print("Failed to update mood entry")
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -110,10 +110,12 @@ struct EntryListView: View {
|
|||||||
if hasNotes {
|
if hasNotes {
|
||||||
Image(systemName: "note.text")
|
Image(systemName: "note.text")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
|
.accessibilityHidden(true)
|
||||||
}
|
}
|
||||||
if hasReflection {
|
if hasReflection {
|
||||||
Image(systemName: "sparkles")
|
Image(systemName: "sparkles")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
|
.accessibilityHidden(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
@@ -134,7 +136,10 @@ struct EntryListView: View {
|
|||||||
if isMissing {
|
if isMissing {
|
||||||
return String(localized: "\(dateString), no mood logged")
|
return String(localized: "\(dateString), no mood logged")
|
||||||
} else {
|
} else {
|
||||||
return "\(dateString), \(entry.mood.strValue)"
|
var description = "\(dateString), \(entry.mood.strValue)"
|
||||||
|
if hasNotes { description += String(localized: ", has notes") }
|
||||||
|
if hasReflection { description += String(localized: ", has reflection") }
|
||||||
|
return description
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ struct ExportView: View {
|
|||||||
Button("Cancel") {
|
Button("Cancel") {
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Export.cancelButton)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showShareSheet) {
|
.sheet(isPresented: $showShareSheet) {
|
||||||
@@ -113,6 +114,7 @@ struct ExportView: View {
|
|||||||
}
|
}
|
||||||
.alert("Export Failed", isPresented: $showError) {
|
.alert("Export Failed", isPresented: $showError) {
|
||||||
Button("OK", role: .cancel) { }
|
Button("OK", role: .cancel) { }
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Export.alertOKButton)
|
||||||
} message: {
|
} message: {
|
||||||
Text(errorMessage)
|
Text(errorMessage)
|
||||||
}
|
}
|
||||||
@@ -230,6 +232,7 @@ struct ExportView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Export.formatButton(format.rawValue))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -260,6 +263,7 @@ struct ExportView: View {
|
|||||||
.background(Color(.systemBackground))
|
.background(Color(.systemBackground))
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Export.rangeButton(range.rawValue))
|
||||||
|
|
||||||
if range != DateRange.allCases.last {
|
if range != DateRange.allCases.last {
|
||||||
Divider()
|
Divider()
|
||||||
@@ -293,6 +297,7 @@ struct ExportView: View {
|
|||||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
}
|
}
|
||||||
.disabled(isExporting || validEntries.isEmpty)
|
.disabled(isExporting || validEntries.isEmpty)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Export.exportButton)
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ struct GuidedReflectionView: View {
|
|||||||
@State private var isSaving = false
|
@State private var isSaving = false
|
||||||
@State private var showDiscardAlert = false
|
@State private var showDiscardAlert = false
|
||||||
@State private var showInfoSheet = false
|
@State private var showInfoSheet = false
|
||||||
|
@State private var showFeedback = false
|
||||||
|
@State private var savedReflection: GuidedReflection?
|
||||||
|
|
||||||
private let initialDraft: GuidedReflectionDraft
|
private let initialDraft: GuidedReflectionDraft
|
||||||
|
|
||||||
@@ -77,8 +79,24 @@ struct GuidedReflectionView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ScrollViewReader { proxy in
|
ZStack {
|
||||||
reflectionSheetContent(with: proxy)
|
ScrollViewReader { proxy in
|
||||||
|
reflectionSheetContent(with: proxy)
|
||||||
|
}
|
||||||
|
.blur(radius: showFeedback ? 6 : 0)
|
||||||
|
.allowsHitTesting(!showFeedback)
|
||||||
|
|
||||||
|
if showFeedback, let savedReflection {
|
||||||
|
Color.black.opacity(0.3)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.onTapGesture { }
|
||||||
|
|
||||||
|
ReflectionFeedbackView(
|
||||||
|
mood: entry.mood,
|
||||||
|
reflection: savedReflection,
|
||||||
|
onDismiss: { dismiss() }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -236,6 +254,8 @@ struct GuidedReflectionView: View {
|
|||||||
.frame(height: 10)
|
.frame(height: 10)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.accessibilityElement(children: .ignore)
|
||||||
|
.accessibilityLabel(String(localized: "\(draft.steps.filter(\.hasAnswer).count) of \(draft.steps.count) steps completed"))
|
||||||
}
|
}
|
||||||
.accessibilityIdentifier(AccessibilityID.GuidedReflection.progressDots)
|
.accessibilityIdentifier(AccessibilityID.GuidedReflection.progressDots)
|
||||||
}
|
}
|
||||||
@@ -454,7 +474,22 @@ struct GuidedReflectionView: View {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if success {
|
if success {
|
||||||
dismiss()
|
// Fire-and-forget tag extraction
|
||||||
|
if #available(iOS 26, *), !IAPManager.shared.shouldShowPaywall {
|
||||||
|
Task {
|
||||||
|
await FoundationModelsTagService.shared.extractAndSaveTags(for: entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show AI feedback if reflection is complete and AI is potentially available
|
||||||
|
if reflection.isComplete {
|
||||||
|
savedReflection = reflection
|
||||||
|
withAnimation(.easeInOut(duration: 0.3)) {
|
||||||
|
showFeedback = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
isSaving = false
|
isSaving = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ struct ImagePickerGridView: View {
|
|||||||
.scaledToFit()
|
.scaledToFit()
|
||||||
.frame(width: 40, height: 40)
|
.frame(width: 40, height: 40)
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.CustomWidget.imageOption(item.rawValue))
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
pickedImageClosure(item)
|
pickedImageClosure(item)
|
||||||
presentationMode.wrappedValue.dismiss()
|
presentationMode.wrappedValue.dismiss()
|
||||||
|
|||||||
@@ -13,10 +13,15 @@ enum InsightsTab: String, CaseIterable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct InsightsView: View {
|
struct InsightsView: View {
|
||||||
|
private enum AnimationConstants {
|
||||||
|
static let refreshDelay: UInt64 = 500_000_000 // 0.5 seconds in nanoseconds
|
||||||
|
}
|
||||||
|
|
||||||
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
|
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
|
||||||
@AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default
|
@AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default
|
||||||
@AppStorage(UserDefaultsStore.Keys.moodImages.rawValue, store: GroupUserDefaults.groupDefaults) private var imagePack: MoodImages = .FontAwesome
|
@AppStorage(UserDefaultsStore.Keys.moodImages.rawValue, store: GroupUserDefaults.groupDefaults) private var imagePack: MoodImages = .FontAwesome
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
private var textColor: Color { theme.currentTheme.labelColor }
|
private var textColor: Color { theme.currentTheme.labelColor }
|
||||||
|
|
||||||
@@ -24,6 +29,8 @@ struct InsightsView: View {
|
|||||||
@EnvironmentObject var iapManager: IAPManager
|
@EnvironmentObject var iapManager: IAPManager
|
||||||
@State private var showSubscriptionStore = false
|
@State private var showSubscriptionStore = false
|
||||||
@State private var selectedTab: InsightsTab = .insights
|
@State private var selectedTab: InsightsTab = .insights
|
||||||
|
@State private var weeklyDigest: WeeklyDigest?
|
||||||
|
@State private var showDigest = true
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -40,6 +47,7 @@ struct InsightsView: View {
|
|||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Image(systemName: "sparkles")
|
Image(systemName: "sparkles")
|
||||||
.font(.caption.weight(.medium))
|
.font(.caption.weight(.medium))
|
||||||
|
.accessibilityHidden(true)
|
||||||
Text("AI")
|
Text("AI")
|
||||||
.font(.caption.weight(.semibold))
|
.font(.caption.weight(.semibold))
|
||||||
}
|
}
|
||||||
@@ -82,6 +90,10 @@ struct InsightsView: View {
|
|||||||
if iapManager.shouldShowPaywall {
|
if iapManager.shouldShowPaywall {
|
||||||
paywallOverlay
|
paywallOverlay
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if selectedTab == .insights && isGeneratingInsights && !iapManager.shouldShowPaywall {
|
||||||
|
generatingOverlay
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showSubscriptionStore) {
|
.sheet(isPresented: $showSubscriptionStore) {
|
||||||
@@ -94,15 +106,52 @@ struct InsightsView: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
AnalyticsManager.shared.trackScreen(.insights)
|
AnalyticsManager.shared.trackScreen(.insights)
|
||||||
viewModel.generateInsights()
|
viewModel.generateInsights()
|
||||||
|
loadWeeklyDigest()
|
||||||
|
}
|
||||||
|
.onChange(of: scenePhase) { _, newPhase in
|
||||||
|
if newPhase == .active {
|
||||||
|
viewModel.recheckAvailability()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(.top)
|
.padding(.top)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Insights Content
|
// MARK: - Insights Content
|
||||||
|
|
||||||
|
private func loadWeeklyDigest() {
|
||||||
|
guard #available(iOS 26, *), !iapManager.shouldShowPaywall else { return }
|
||||||
|
|
||||||
|
// Try cached digest first
|
||||||
|
if let digest = FoundationModelsDigestService.shared.loadLatestDigest(),
|
||||||
|
digest.isFromCurrentWeek {
|
||||||
|
weeklyDigest = digest
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// No digest for this week — generate one on-demand
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let digest = try await FoundationModelsDigestService.shared.generateWeeklyDigest()
|
||||||
|
weeklyDigest = digest
|
||||||
|
} catch {
|
||||||
|
// Not enough data or AI unavailable — just don't show the card
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var insightsContent: some View {
|
private var insightsContent: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
|
// AI enablement guidance when not available
|
||||||
|
if !viewModel.isAIAvailable && !iapManager.shouldShowPaywall {
|
||||||
|
aiEnablementCard
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weekly Digest Card
|
||||||
|
if let digest = weeklyDigest {
|
||||||
|
WeeklyDigestCardView(digest: digest)
|
||||||
|
}
|
||||||
|
|
||||||
// This Month Section
|
// This Month Section
|
||||||
InsightsSectionView(
|
InsightsSectionView(
|
||||||
title: "This Month",
|
title: "This Month",
|
||||||
@@ -145,14 +194,145 @@ struct InsightsView: View {
|
|||||||
.padding(.vertical)
|
.padding(.vertical)
|
||||||
.padding(.bottom, 100)
|
.padding(.bottom, 100)
|
||||||
}
|
}
|
||||||
|
.opacity(isGeneratingInsights && !iapManager.shouldShowPaywall ? 0.2 : 1.0)
|
||||||
|
.animation(.easeInOut(duration: 0.3), value: isGeneratingInsights)
|
||||||
.refreshable {
|
.refreshable {
|
||||||
viewModel.refreshInsights()
|
viewModel.refreshInsights()
|
||||||
// Small delay to show refresh animation
|
// Small delay to show refresh animation
|
||||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
try? await Task.sleep(nanoseconds: AnimationConstants.refreshDelay)
|
||||||
}
|
}
|
||||||
.disabled(iapManager.shouldShowPaywall)
|
.disabled(iapManager.shouldShowPaywall)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - AI Enablement Card
|
||||||
|
|
||||||
|
private var aiEnablementCard: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: aiEnablementIcon)
|
||||||
|
.font(.system(size: 36))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Text(aiEnablementTitle)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
|
||||||
|
Text(aiEnablementDescription)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
if viewModel.aiUnavailableReason == .notEnabled {
|
||||||
|
Button {
|
||||||
|
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label(String(localized: "Open Settings"), systemImage: "gear")
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(.purple)
|
||||||
|
}
|
||||||
|
|
||||||
|
if viewModel.aiUnavailableReason == .modelDownloading {
|
||||||
|
Button {
|
||||||
|
viewModel.recheckAvailability()
|
||||||
|
} label: {
|
||||||
|
Label(String(localized: "Try Again"), systemImage: "arrow.clockwise")
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(24)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 20)
|
||||||
|
.fill(Color(.secondarySystemBackground))
|
||||||
|
)
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var aiEnablementIcon: String {
|
||||||
|
switch viewModel.aiUnavailableReason {
|
||||||
|
case .deviceNotEligible: return "iphone.slash"
|
||||||
|
case .notEnabled: return "gearshape.fill"
|
||||||
|
case .modelDownloading: return "arrow.down.circle"
|
||||||
|
case .preiOS26: return "arrow.up.circle"
|
||||||
|
case .unknown: return "brain.head.profile"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var aiEnablementTitle: String {
|
||||||
|
switch viewModel.aiUnavailableReason {
|
||||||
|
case .deviceNotEligible: return String(localized: "Device Not Supported")
|
||||||
|
case .notEnabled: return String(localized: "Enable Apple Intelligence")
|
||||||
|
case .modelDownloading: return String(localized: "AI Model Downloading")
|
||||||
|
case .preiOS26: return String(localized: "Update Required")
|
||||||
|
case .unknown: return String(localized: "AI Unavailable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var aiEnablementDescription: String {
|
||||||
|
switch viewModel.aiUnavailableReason {
|
||||||
|
case .deviceNotEligible:
|
||||||
|
return String(localized: "AI insights require iPhone 15 Pro or later with Apple Intelligence.")
|
||||||
|
case .notEnabled:
|
||||||
|
return String(localized: "Turn on Apple Intelligence to unlock personalized mood insights.\n\nSettings → Apple Intelligence & Siri → Apple Intelligence")
|
||||||
|
case .modelDownloading:
|
||||||
|
return String(localized: "The AI model is still downloading. This may take a few minutes.")
|
||||||
|
case .preiOS26:
|
||||||
|
return String(localized: "AI insights require iOS 26 or later with Apple Intelligence.")
|
||||||
|
case .unknown:
|
||||||
|
return String(localized: "Apple Intelligence is required for personalized insights.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Generating State
|
||||||
|
|
||||||
|
private var isGeneratingInsights: Bool {
|
||||||
|
let states = [viewModel.monthLoadingState, viewModel.yearLoadingState, viewModel.allTimeLoadingState]
|
||||||
|
return states.contains(where: { $0 == .loading || $0 == .idle })
|
||||||
|
}
|
||||||
|
|
||||||
|
private var generatingOverlay: some View {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "sparkles")
|
||||||
|
.font(.system(size: 36))
|
||||||
|
.foregroundStyle(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [.purple, .blue],
|
||||||
|
startPoint: .leading,
|
||||||
|
endPoint: .trailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.symbolEffect(.pulse, options: .repeating)
|
||||||
|
|
||||||
|
Text(String(localized: "Generating Insights"))
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
|
||||||
|
Text(String(localized: "Apple Intelligence is analyzing your mood data..."))
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
.padding(32)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 24)
|
||||||
|
.fill(.regularMaterial)
|
||||||
|
)
|
||||||
|
.padding(.horizontal, 40)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Paywall Overlay
|
// MARK: - Paywall Overlay
|
||||||
|
|
||||||
private var paywallOverlay: some View {
|
private var paywallOverlay: some View {
|
||||||
@@ -173,6 +353,7 @@ struct InsightsView: View {
|
|||||||
|
|
||||||
Image(systemName: "sparkles")
|
Image(systemName: "sparkles")
|
||||||
.font(.largeTitle)
|
.font(.largeTitle)
|
||||||
|
.accessibilityHidden(true)
|
||||||
.foregroundStyle(
|
.foregroundStyle(
|
||||||
LinearGradient(
|
LinearGradient(
|
||||||
colors: [.purple, .blue],
|
colors: [.purple, .blue],
|
||||||
@@ -202,6 +383,7 @@ struct InsightsView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "sparkles")
|
Image(systemName: "sparkles")
|
||||||
|
.accessibilityHidden(true)
|
||||||
Text("Get Personal Insights")
|
Text("Get Personal Insights")
|
||||||
}
|
}
|
||||||
.font(.headline.weight(.bold))
|
.font(.headline.weight(.bold))
|
||||||
@@ -277,6 +459,7 @@ struct InsightsSectionView: View {
|
|||||||
.padding(.vertical, 14)
|
.padding(.vertical, 14)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Insights.expandCollapseButton)
|
||||||
.accessibilityAddTraits(.isHeader)
|
.accessibilityAddTraits(.isHeader)
|
||||||
|
|
||||||
// Insights List (collapsible)
|
// Insights List (collapsible)
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ class InsightsViewModel: ObservableObject {
|
|||||||
@Published var allTimeLoadingState: InsightLoadingState = .idle
|
@Published var allTimeLoadingState: InsightLoadingState = .idle
|
||||||
|
|
||||||
@Published var isAIAvailable: Bool = false
|
@Published var isAIAvailable: Bool = false
|
||||||
|
@Published var aiUnavailableReason: AIUnavailableReason = .preiOS26
|
||||||
|
|
||||||
// MARK: - Dependencies
|
// MARK: - Dependencies
|
||||||
|
|
||||||
@@ -57,9 +58,12 @@ class InsightsViewModel: ObservableObject {
|
|||||||
let service = FoundationModelsInsightService()
|
let service = FoundationModelsInsightService()
|
||||||
insightService = service
|
insightService = service
|
||||||
isAIAvailable = service.isAvailable
|
isAIAvailable = service.isAvailable
|
||||||
|
aiUnavailableReason = service.unavailableReason
|
||||||
|
service.prewarm()
|
||||||
} else {
|
} else {
|
||||||
insightService = nil
|
insightService = nil
|
||||||
isAIAvailable = false
|
isAIAvailable = false
|
||||||
|
aiUnavailableReason = .preiOS26
|
||||||
}
|
}
|
||||||
|
|
||||||
dataListenerToken = DataController.shared.addNewDataListener { [weak self] in
|
dataListenerToken = DataController.shared.addNewDataListener { [weak self] in
|
||||||
@@ -118,12 +122,23 @@ class InsightsViewModel: ObservableObject {
|
|||||||
let yearEntries = DataController.shared.getData(startDate: yearStart, endDate: now, includedDays: [1, 2, 3, 4, 5, 6, 7])
|
let yearEntries = DataController.shared.getData(startDate: yearStart, endDate: now, includedDays: [1, 2, 3, 4, 5, 6, 7])
|
||||||
let allTimeEntries = DataController.shared.getData(startDate: allTimeStart, endDate: now, includedDays: [1, 2, 3, 4, 5, 6, 7])
|
let allTimeEntries = DataController.shared.getData(startDate: allTimeStart, endDate: now, includedDays: [1, 2, 3, 4, 5, 6, 7])
|
||||||
|
|
||||||
|
// Pre-fetch health data once (instead of 3x per period)
|
||||||
|
var sharedHealthAverages: HealthService.HealthAverages?
|
||||||
|
if healthService.isEnabled && healthService.isAuthorized {
|
||||||
|
let allValidEntries = allTimeEntries.filter { ![.missing, .placeholder].contains($0.mood) }
|
||||||
|
if !allValidEntries.isEmpty {
|
||||||
|
let healthData = await healthService.fetchHealthData(for: allValidEntries)
|
||||||
|
sharedHealthAverages = healthService.computeHealthAverages(entries: allValidEntries, healthData: healthData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Generate insights concurrently for all three periods
|
// Generate insights concurrently for all three periods
|
||||||
await withTaskGroup(of: Void.self) { group in
|
await withTaskGroup(of: Void.self) { group in
|
||||||
group.addTask { @MainActor in
|
group.addTask { @MainActor in
|
||||||
await self.generatePeriodInsights(
|
await self.generatePeriodInsights(
|
||||||
entries: monthEntries,
|
entries: monthEntries,
|
||||||
periodName: "this month",
|
periodName: "this month",
|
||||||
|
healthAverages: sharedHealthAverages,
|
||||||
updateState: { self.monthLoadingState = $0 },
|
updateState: { self.monthLoadingState = $0 },
|
||||||
updateInsights: { self.monthInsights = $0 }
|
updateInsights: { self.monthInsights = $0 }
|
||||||
)
|
)
|
||||||
@@ -133,6 +148,7 @@ class InsightsViewModel: ObservableObject {
|
|||||||
await self.generatePeriodInsights(
|
await self.generatePeriodInsights(
|
||||||
entries: yearEntries,
|
entries: yearEntries,
|
||||||
periodName: "this year",
|
periodName: "this year",
|
||||||
|
healthAverages: sharedHealthAverages,
|
||||||
updateState: { self.yearLoadingState = $0 },
|
updateState: { self.yearLoadingState = $0 },
|
||||||
updateInsights: { self.yearInsights = $0 }
|
updateInsights: { self.yearInsights = $0 }
|
||||||
)
|
)
|
||||||
@@ -142,6 +158,7 @@ class InsightsViewModel: ObservableObject {
|
|||||||
await self.generatePeriodInsights(
|
await self.generatePeriodInsights(
|
||||||
entries: allTimeEntries,
|
entries: allTimeEntries,
|
||||||
periodName: "all time",
|
periodName: "all time",
|
||||||
|
healthAverages: sharedHealthAverages,
|
||||||
updateState: { self.allTimeLoadingState = $0 },
|
updateState: { self.allTimeLoadingState = $0 },
|
||||||
updateInsights: { self.allTimeInsights = $0 }
|
updateInsights: { self.allTimeInsights = $0 }
|
||||||
)
|
)
|
||||||
@@ -152,6 +169,7 @@ class InsightsViewModel: ObservableObject {
|
|||||||
private func generatePeriodInsights(
|
private func generatePeriodInsights(
|
||||||
entries: [MoodEntryModel],
|
entries: [MoodEntryModel],
|
||||||
periodName: String,
|
periodName: String,
|
||||||
|
healthAverages: HealthService.HealthAverages?,
|
||||||
updateState: @escaping (InsightLoadingState) -> Void,
|
updateState: @escaping (InsightLoadingState) -> Void,
|
||||||
updateInsights: @escaping ([Insight]) -> Void
|
updateInsights: @escaping ([Insight]) -> Void
|
||||||
) async {
|
) async {
|
||||||
@@ -170,27 +188,16 @@ class InsightsViewModel: ObservableObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if AI is available
|
// Check if AI is available — show reason-specific guidance
|
||||||
guard isAIAvailable else {
|
guard isAIAvailable else {
|
||||||
updateInsights([Insight(
|
let (icon, title, description) = unavailableMessage()
|
||||||
icon: "brain.head.profile",
|
updateInsights([Insight(icon: icon, title: title, description: description, mood: nil)])
|
||||||
title: "AI Unavailable",
|
|
||||||
description: "Apple Intelligence is required for personalized insights. Please enable it in Settings.",
|
|
||||||
mood: nil
|
|
||||||
)])
|
|
||||||
updateState(.error("AI not available"))
|
updateState(.error("AI not available"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
updateState(.loading)
|
updateState(.loading)
|
||||||
|
|
||||||
// Fetch health data if enabled - pass raw averages to AI for correlation analysis
|
|
||||||
var healthAverages: HealthService.HealthAverages?
|
|
||||||
if healthService.isEnabled && healthService.isAuthorized {
|
|
||||||
let healthData = await healthService.fetchHealthData(for: validEntries)
|
|
||||||
healthAverages = healthService.computeHealthAverages(entries: validEntries, healthData: healthData)
|
|
||||||
}
|
|
||||||
|
|
||||||
if #available(iOS 26, *), let service = insightService as? FoundationModelsInsightService {
|
if #available(iOS 26, *), let service = insightService as? FoundationModelsInsightService {
|
||||||
do {
|
do {
|
||||||
let insights = try await service.generateInsights(
|
let insights = try await service.generateInsights(
|
||||||
@@ -212,13 +219,47 @@ class InsightsViewModel: ObservableObject {
|
|||||||
updateState(.error(error.localizedDescription))
|
updateState(.error(error.localizedDescription))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
updateInsights([Insight(
|
let (icon, title, description) = unavailableMessage()
|
||||||
icon: "brain.head.profile",
|
updateInsights([Insight(icon: icon, title: title, description: description, mood: nil)])
|
||||||
title: "AI Unavailable",
|
|
||||||
description: "Apple Intelligence is required for personalized insights. Please enable it in Settings.",
|
|
||||||
mood: nil
|
|
||||||
)])
|
|
||||||
updateState(.error("AI not available"))
|
updateState(.error("AI not available"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Unavailable Messages
|
||||||
|
|
||||||
|
private func unavailableMessage() -> (icon: String, title: String, description: String) {
|
||||||
|
switch aiUnavailableReason {
|
||||||
|
case .deviceNotEligible:
|
||||||
|
return ("iphone.slash", "Device Not Supported",
|
||||||
|
String(localized: "AI insights require iPhone 15 Pro or later with Apple Intelligence."))
|
||||||
|
case .notEnabled:
|
||||||
|
return ("gearshape.fill", "Apple Intelligence Disabled",
|
||||||
|
String(localized: "Turn on Apple Intelligence in Settings → Apple Intelligence & Siri to unlock AI insights."))
|
||||||
|
case .modelDownloading:
|
||||||
|
return ("arrow.down.circle", "AI Model Downloading",
|
||||||
|
String(localized: "The AI model is still downloading. Please wait a few minutes and try again."))
|
||||||
|
case .preiOS26:
|
||||||
|
return ("arrow.up.circle", "Update Required",
|
||||||
|
String(localized: "AI insights require iOS 26 or later with Apple Intelligence."))
|
||||||
|
case .unknown:
|
||||||
|
return ("brain.head.profile", "AI Unavailable",
|
||||||
|
String(localized: "Apple Intelligence is required for personalized insights."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-check AI availability (e.g., after returning from Settings)
|
||||||
|
func recheckAvailability() {
|
||||||
|
if #available(iOS 26, *), let service = insightService as? FoundationModelsInsightService {
|
||||||
|
service.checkAvailability()
|
||||||
|
let wasAvailable = isAIAvailable
|
||||||
|
isAIAvailable = service.isAvailable
|
||||||
|
aiUnavailableReason = service.unavailableReason
|
||||||
|
|
||||||
|
// If just became available, generate insights
|
||||||
|
if !wasAvailable && isAIAvailable {
|
||||||
|
service.prewarm()
|
||||||
|
generateInsights()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ struct ReportDateRangePicker: View {
|
|||||||
.background(Color.accentColor.opacity(0.15))
|
.background(Color.accentColor.opacity(0.15))
|
||||||
.clipShape(Circle())
|
.clipShape(Circle())
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Reports.previousMonthButton)
|
||||||
.accessibilityLabel("Previous month")
|
.accessibilityLabel("Previous month")
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -172,6 +173,7 @@ struct ReportDateRangePicker: View {
|
|||||||
.background(Color.accentColor.opacity(0.15))
|
.background(Color.accentColor.opacity(0.15))
|
||||||
.clipShape(Circle())
|
.clipShape(Circle())
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Reports.nextMonthButton)
|
||||||
.accessibilityLabel("Next month")
|
.accessibilityLabel("Next month")
|
||||||
.disabled(isDisplayingCurrentMonth)
|
.disabled(isDisplayingCurrentMonth)
|
||||||
}
|
}
|
||||||
@@ -341,6 +343,7 @@ private struct ReportDayCell: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.disabled(isFuture)
|
.disabled(isFuture)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Reports.dayCell(dateString: dayNumber))
|
||||||
.frame(height: 40)
|
.frame(height: 40)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,7 +95,9 @@ struct ReportsView: View {
|
|||||||
viewModel.exportDataPDF()
|
viewModel.exportDataPDF()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Reports.privacyShareButton)
|
||||||
Button(String(localized: "Cancel"), role: .cancel) {}
|
Button(String(localized: "Cancel"), role: .cancel) {}
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Reports.privacyCancelButton)
|
||||||
} message: {
|
} message: {
|
||||||
Text("This report contains your personal mood data and journal notes. Only share it with people you trust.")
|
Text("This report contains your personal mood data and journal notes. Only share it with people you trust.")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,10 @@ class ReportsViewModel: ObservableObject {
|
|||||||
let service = FoundationModelsInsightService()
|
let service = FoundationModelsInsightService()
|
||||||
insightService = service
|
insightService = service
|
||||||
isAIAvailable = service.isAvailable
|
isAIAvailable = service.isAvailable
|
||||||
|
service.prewarm()
|
||||||
|
// Also prewarm the clinical session used for reports
|
||||||
|
let clinicalSession = LanguageModelSession(instructions: clinicalSystemInstructions)
|
||||||
|
clinicalSession.prewarm()
|
||||||
} else {
|
} else {
|
||||||
insightService = nil
|
insightService = nil
|
||||||
isAIAvailable = false
|
isAIAvailable = false
|
||||||
@@ -205,7 +209,7 @@ class ReportsViewModel: ObservableObject {
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let response = try await session.respond(to: prompt, generating: AIQuickSummaryResponse.self)
|
let response = try await session.respond(to: prompt, generating: AIQuickSummaryResponse.self, options: GenerationOptions(maximumResponseTokens: 400))
|
||||||
|
|
||||||
guard !Task.isCancelled else { throw CancellationError() }
|
guard !Task.isCancelled else { throw CancellationError() }
|
||||||
|
|
||||||
@@ -251,10 +255,11 @@ class ReportsViewModel: ObservableObject {
|
|||||||
let totalSections = weeks.count + monthlySummaries.count + yearlySummaries.count
|
let totalSections = weeks.count + monthlySummaries.count + yearlySummaries.count
|
||||||
var completedSections = 0
|
var completedSections = 0
|
||||||
|
|
||||||
// Generate weekly AI summaries — batched at 4 concurrent
|
// Generate AI summaries — fresh session per call, batched at 4 concurrent
|
||||||
if #available(iOS 26, *) {
|
if #available(iOS 26, *) {
|
||||||
let batchSize = 4
|
let batchSize = 2
|
||||||
|
|
||||||
|
// Weekly summaries — batched at 4 concurrent
|
||||||
for batchStart in stride(from: 0, to: weeks.count, by: batchSize) {
|
for batchStart in stride(from: 0, to: weeks.count, by: batchSize) {
|
||||||
guard !Task.isCancelled else { throw CancellationError() }
|
guard !Task.isCancelled else { throw CancellationError() }
|
||||||
|
|
||||||
@@ -279,46 +284,60 @@ class ReportsViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate monthly AI summaries — concurrent
|
// Monthly summaries — batched at 4 concurrent
|
||||||
guard !Task.isCancelled else { throw CancellationError() }
|
guard !Task.isCancelled else { throw CancellationError() }
|
||||||
progressMessage = String(localized: "Generating monthly summaries...")
|
progressMessage = String(localized: "Generating monthly summaries...")
|
||||||
|
|
||||||
await withTaskGroup(of: (Int, String?).self) { group in
|
for batchStart in stride(from: 0, to: monthlySummaries.count, by: batchSize) {
|
||||||
for (index, monthSummary) in monthlySummaries.enumerated() {
|
guard !Task.isCancelled else { throw CancellationError() }
|
||||||
group.addTask { @MainActor in
|
|
||||||
let summary = await self.generateMonthlySummary(month: monthSummary, allEntries: reportEntries)
|
|
||||||
return (index, summary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for await (index, summary) in group {
|
let batchEnd = min(batchStart + batchSize, monthlySummaries.count)
|
||||||
monthlySummaries[index].aiSummary = summary
|
let batchIndices = batchStart..<batchEnd
|
||||||
completedSections += 1
|
|
||||||
progressValue = Double(completedSections) / Double(totalSections)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate yearly AI summaries — concurrent
|
|
||||||
guard !Task.isCancelled else { throw CancellationError() }
|
|
||||||
|
|
||||||
if !yearlySummaries.isEmpty {
|
|
||||||
progressMessage = String(localized: "Generating yearly summaries...")
|
|
||||||
|
|
||||||
await withTaskGroup(of: (Int, String?).self) { group in
|
await withTaskGroup(of: (Int, String?).self) { group in
|
||||||
for (index, yearSummary) in yearlySummaries.enumerated() {
|
for index in batchIndices {
|
||||||
group.addTask { @MainActor in
|
group.addTask { @MainActor in
|
||||||
let summary = await self.generateYearlySummary(year: yearSummary, allEntries: reportEntries)
|
let summary = await self.generateMonthlySummary(month: monthlySummaries[index], allEntries: reportEntries)
|
||||||
return (index, summary)
|
return (index, summary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for await (index, summary) in group {
|
for await (index, summary) in group {
|
||||||
yearlySummaries[index].aiSummary = summary
|
monthlySummaries[index].aiSummary = summary
|
||||||
completedSections += 1
|
completedSections += 1
|
||||||
progressValue = Double(completedSections) / Double(totalSections)
|
progressValue = Double(completedSections) / Double(totalSections)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Yearly summaries — batched at 4 concurrent
|
||||||
|
guard !Task.isCancelled else { throw CancellationError() }
|
||||||
|
|
||||||
|
if !yearlySummaries.isEmpty {
|
||||||
|
progressMessage = String(localized: "Generating yearly summaries...")
|
||||||
|
|
||||||
|
for batchStart in stride(from: 0, to: yearlySummaries.count, by: batchSize) {
|
||||||
|
guard !Task.isCancelled else { throw CancellationError() }
|
||||||
|
|
||||||
|
let batchEnd = min(batchStart + batchSize, yearlySummaries.count)
|
||||||
|
let batchIndices = batchStart..<batchEnd
|
||||||
|
|
||||||
|
await withTaskGroup(of: (Int, String?).self) { group in
|
||||||
|
for index in batchIndices {
|
||||||
|
group.addTask { @MainActor in
|
||||||
|
let summary = await self.generateYearlySummary(year: yearlySummaries[index], allEntries: reportEntries)
|
||||||
|
return (index, summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for await (index, summary) in group {
|
||||||
|
yearlySummaries[index].aiSummary = summary
|
||||||
|
completedSections += 1
|
||||||
|
progressValue = Double(completedSections) / Double(totalSections)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return MoodReport(
|
return MoodReport(
|
||||||
@@ -337,7 +356,6 @@ class ReportsViewModel: ObservableObject {
|
|||||||
@available(iOS 26, *)
|
@available(iOS 26, *)
|
||||||
private func generateWeeklySummary(week: ReportWeek) async -> String? {
|
private func generateWeeklySummary(week: ReportWeek) async -> String? {
|
||||||
let session = LanguageModelSession(instructions: clinicalSystemInstructions)
|
let session = LanguageModelSession(instructions: clinicalSystemInstructions)
|
||||||
|
|
||||||
let moodList = week.entries.sorted(by: { $0.date < $1.date }).map { entry in
|
let moodList = week.entries.sorted(by: { $0.date < $1.date }).map { entry in
|
||||||
let day = entry.date.formatted(.dateTime.weekday(.abbreviated))
|
let day = entry.date.formatted(.dateTime.weekday(.abbreviated))
|
||||||
let mood = entry.mood.widgetDisplayName
|
let mood = entry.mood.widgetDisplayName
|
||||||
@@ -358,7 +376,7 @@ class ReportsViewModel: ObservableObject {
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let response = try await session.respond(to: prompt, generating: AIWeeklySummary.self)
|
let response = try await session.respond(to: prompt, generating: AIWeeklySummary.self, options: GenerationOptions(maximumResponseTokens: 150))
|
||||||
return response.content.summary
|
return response.content.summary
|
||||||
} catch {
|
} catch {
|
||||||
return "Summary unavailable"
|
return "Summary unavailable"
|
||||||
@@ -368,7 +386,6 @@ class ReportsViewModel: ObservableObject {
|
|||||||
@available(iOS 26, *)
|
@available(iOS 26, *)
|
||||||
private func generateMonthlySummary(month: ReportMonthSummary, allEntries: [ReportEntry]) async -> String? {
|
private func generateMonthlySummary(month: ReportMonthSummary, allEntries: [ReportEntry]) async -> String? {
|
||||||
let session = LanguageModelSession(instructions: clinicalSystemInstructions)
|
let session = LanguageModelSession(instructions: clinicalSystemInstructions)
|
||||||
|
|
||||||
let monthEntries = allEntries.filter {
|
let monthEntries = allEntries.filter {
|
||||||
calendar.component(.month, from: $0.date) == month.month &&
|
calendar.component(.month, from: $0.date) == month.month &&
|
||||||
calendar.component(.year, from: $0.date) == month.year
|
calendar.component(.year, from: $0.date) == month.year
|
||||||
@@ -387,7 +404,7 @@ class ReportsViewModel: ObservableObject {
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let response = try await session.respond(to: prompt, generating: AIMonthSummary.self)
|
let response = try await session.respond(to: prompt, generating: AIMonthSummary.self, options: GenerationOptions(maximumResponseTokens: 150))
|
||||||
return response.content.summary
|
return response.content.summary
|
||||||
} catch {
|
} catch {
|
||||||
return "Summary unavailable"
|
return "Summary unavailable"
|
||||||
@@ -397,7 +414,6 @@ class ReportsViewModel: ObservableObject {
|
|||||||
@available(iOS 26, *)
|
@available(iOS 26, *)
|
||||||
private func generateYearlySummary(year: ReportYearSummary, allEntries: [ReportEntry]) async -> String? {
|
private func generateYearlySummary(year: ReportYearSummary, allEntries: [ReportEntry]) async -> String? {
|
||||||
let session = LanguageModelSession(instructions: clinicalSystemInstructions)
|
let session = LanguageModelSession(instructions: clinicalSystemInstructions)
|
||||||
|
|
||||||
let yearEntries = allEntries.filter { calendar.component(.year, from: $0.date) == year.year }
|
let yearEntries = allEntries.filter { calendar.component(.year, from: $0.date) == year.year }
|
||||||
|
|
||||||
let monthlyAvgs = Dictionary(grouping: yearEntries) { calendar.component(.month, from: $0.date) }
|
let monthlyAvgs = Dictionary(grouping: yearEntries) { calendar.component(.month, from: $0.date) }
|
||||||
@@ -420,7 +436,7 @@ class ReportsViewModel: ObservableObject {
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let response = try await session.respond(to: prompt, generating: AIYearSummary.self)
|
let response = try await session.respond(to: prompt, generating: AIYearSummary.self, options: GenerationOptions(maximumResponseTokens: 150))
|
||||||
return response.content.summary
|
return response.content.summary
|
||||||
} catch {
|
} catch {
|
||||||
return "Summary unavailable"
|
return "Summary unavailable"
|
||||||
|
|||||||
138
Shared/Views/InsightsView/WeeklyDigestCardView.swift
Normal file
138
Shared/Views/InsightsView/WeeklyDigestCardView.swift
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
//
|
||||||
|
// WeeklyDigestCardView.swift
|
||||||
|
// Reflect
|
||||||
|
//
|
||||||
|
// Displays the AI-generated weekly emotional digest card in the Insights tab.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct WeeklyDigestCardView: View {
|
||||||
|
|
||||||
|
let digest: WeeklyDigest
|
||||||
|
|
||||||
|
@AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default
|
||||||
|
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
|
||||||
|
|
||||||
|
private var textColor: Color { theme.currentTheme.labelColor }
|
||||||
|
private var accentColor: Color { moodTint.color(forMood: .good) }
|
||||||
|
|
||||||
|
@State private var isExpanded = true
|
||||||
|
@State private var appeared = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
// Header — always visible, tappable to toggle
|
||||||
|
Button {
|
||||||
|
withAnimation(.easeInOut(duration: 0.25)) {
|
||||||
|
isExpanded.toggle()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: digest.iconName)
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(accentColor)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(String(localized: "Weekly Digest"))
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.textCase(.uppercase)
|
||||||
|
|
||||||
|
Text(digest.headline)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "chevron.up")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.rotationEffect(.degrees(isExpanded ? 0 : 180))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.WeeklyDigest.dismissButton)
|
||||||
|
|
||||||
|
// Expandable content
|
||||||
|
if isExpanded {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
// Summary
|
||||||
|
Text(digest.summary)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Highlight
|
||||||
|
HStack(alignment: .top, spacing: 10) {
|
||||||
|
Image(systemName: "star.fill")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.yellow)
|
||||||
|
.padding(.top, 2)
|
||||||
|
|
||||||
|
Text(digest.highlight)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intention
|
||||||
|
HStack(alignment: .top, spacing: 10) {
|
||||||
|
Image(systemName: "arrow.right.circle.fill")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(accentColor)
|
||||||
|
.padding(.top, 2)
|
||||||
|
|
||||||
|
Text(digest.intention)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date range
|
||||||
|
Text(dateRangeString)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
.padding(.top, 16)
|
||||||
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 20)
|
||||||
|
.fill(Color(.secondarySystemBackground))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 20)
|
||||||
|
.stroke(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [accentColor.opacity(0.3), .purple.opacity(0.2)],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
),
|
||||||
|
lineWidth: 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.padding(.horizontal)
|
||||||
|
.opacity(appeared ? 1 : 0)
|
||||||
|
.offset(y: appeared ? 0 : 10)
|
||||||
|
.onAppear {
|
||||||
|
withAnimation(.easeOut(duration: 0.4)) {
|
||||||
|
appeared = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityID.WeeklyDigest.card)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var dateRangeString: String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateStyle = .medium
|
||||||
|
return "\(formatter.string(from: digest.weekStartDate)) - \(formatter.string(from: digest.weekEndDate))"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1465,6 +1465,12 @@ struct GlassButton: View {
|
|||||||
// MARK: - Main Lock Screen View
|
// MARK: - Main Lock Screen View
|
||||||
|
|
||||||
struct LockScreenView: View {
|
struct LockScreenView: View {
|
||||||
|
private enum AnimationConstants {
|
||||||
|
static let contentAppearDuration: TimeInterval = 0.8
|
||||||
|
static let contentAppearDelay: TimeInterval = 0.2
|
||||||
|
static let authenticationDelay: Int = 800 // milliseconds
|
||||||
|
}
|
||||||
|
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
@ObservedObject var authManager: BiometricAuthManager
|
@ObservedObject var authManager: BiometricAuthManager
|
||||||
@State private var showError = false
|
@State private var showError = false
|
||||||
@@ -1691,6 +1697,7 @@ struct LockScreenView: View {
|
|||||||
.disabled(authManager.isAuthenticating)
|
.disabled(authManager.isAuthenticating)
|
||||||
.padding(.top, 16)
|
.padding(.top, 16)
|
||||||
.opacity(showContent ? 1 : 0)
|
.opacity(showContent ? 1 : 0)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.LockScreen.passcodeUnlockButton)
|
||||||
.accessibilityLabel("Use device passcode")
|
.accessibilityLabel("Use device passcode")
|
||||||
.accessibilityHint("Double tap to authenticate with your device passcode")
|
.accessibilityHint("Double tap to authenticate with your device passcode")
|
||||||
}
|
}
|
||||||
@@ -1713,13 +1720,13 @@ struct LockScreenView: View {
|
|||||||
Text("Unable to verify your identity. Please try again.")
|
Text("Unable to verify your identity. Please try again.")
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
withAnimation(.easeOut(duration: 0.8).delay(0.2)) {
|
withAnimation(.easeOut(duration: AnimationConstants.contentAppearDuration).delay(AnimationConstants.contentAppearDelay)) {
|
||||||
showContent = true
|
showContent = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if !authManager.isUnlocked && !authManager.isAuthenticating {
|
if !authManager.isUnlocked && !authManager.isAuthenticating {
|
||||||
Task {
|
Task {
|
||||||
try? await Task.sleep(for: .milliseconds(800))
|
try? await Task.sleep(for: .milliseconds(AnimationConstants.authenticationDelay))
|
||||||
await authManager.authenticate()
|
await authManager.authenticate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,11 +58,13 @@ struct MonthDetailView: View {
|
|||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
let impactMed = UIImpactFeedbackGenerator(style: .heavy)
|
let impactMed = UIImpactFeedbackGenerator(style: .heavy)
|
||||||
impactMed.impactOccurred()
|
impactMed.impactOccurred()
|
||||||
|
|
||||||
let _image = self.image
|
let _image = self.image
|
||||||
self.shareImage.showSheet = true
|
self.shareImage.showSheet = true
|
||||||
self.shareImage.selectedShareImage = _image
|
self.shareImage.selectedShareImage = _image
|
||||||
}
|
}
|
||||||
|
.accessibilityAddTraits(.isButton)
|
||||||
|
.accessibilityLabel(String(localized: "Share month"))
|
||||||
}
|
}
|
||||||
.background(
|
.background(
|
||||||
theme.currentTheme.secondaryBGColor
|
theme.currentTheme.secondaryBGColor
|
||||||
@@ -117,6 +119,7 @@ struct MonthDetailView: View {
|
|||||||
selectedEntry = nil
|
selectedEntry = nil
|
||||||
showUpdateEntryAlert = false
|
showUpdateEntryAlert = false
|
||||||
})
|
})
|
||||||
|
.accessibilityIdentifier(AccessibilityID.MonthDetail.cancelButton)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,12 +156,14 @@ struct MonthDetailView: View {
|
|||||||
LazyVGrid(columns: columns, spacing: 25) {
|
LazyVGrid(columns: columns, spacing: 25) {
|
||||||
ForEach(entries, id: \.self) { entry in
|
ForEach(entries, id: \.self) { entry in
|
||||||
listViewEntry(forEntry: entry)
|
listViewEntry(forEntry: entry)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.MonthDetail.entryCell(DateFormattingCache.shared.string(for: entry.forDate, format: .dateMedium)))
|
||||||
.onTapGesture(perform: {
|
.onTapGesture(perform: {
|
||||||
if entry.canEdit {
|
if entry.canEdit {
|
||||||
selectedEntry = entry
|
selectedEntry = entry
|
||||||
showUpdateEntryAlert = true
|
showUpdateEntryAlert = true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
.accessibilityAddTraits(.isButton)
|
||||||
.frame(minWidth: 0, maxWidth: .infinity)
|
.frame(minWidth: 0, maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -378,6 +378,7 @@ struct MonthView: View {
|
|||||||
.preferredColorScheme(theme.preferredColorScheme)
|
.preferredColorScheme(theme.preferredColorScheme)
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
// Triple-tap to toggle demo mode for video recording
|
// Triple-tap to toggle demo mode for video recording
|
||||||
|
.accessibilityIdentifier(AccessibilityID.MonthView.debugDemoToggle)
|
||||||
.onTapGesture(count: 3) {
|
.onTapGesture(count: 3) {
|
||||||
if demoManager.isDemoMode {
|
if demoManager.isDemoMode {
|
||||||
demoManager.stopDemoMode()
|
demoManager.stopDemoMode()
|
||||||
@@ -591,6 +592,7 @@ struct MonthCard: View, Equatable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.MonthView.statsToggleButton)
|
||||||
.accessibilityLabel("\(Random.monthName(fromMonthInt: month)) \(String(year)), \(showStats ? "expanded" : "collapsed")")
|
.accessibilityLabel("\(Random.monthName(fromMonthInt: month)) \(String(year)), \(showStats ? "expanded" : "collapsed")")
|
||||||
.accessibilityHint("Double tap to toggle statistics")
|
.accessibilityHint("Double tap to toggle statistics")
|
||||||
|
|
||||||
@@ -661,6 +663,7 @@ struct MonthCard: View, Equatable {
|
|||||||
.fill(theme.currentTheme.secondaryBGColor)
|
.fill(theme.currentTheme.secondaryBGColor)
|
||||||
)
|
)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
|
.accessibilityIdentifier(AccessibilityID.MonthView.dayCell(dateString: "\(month)_\(year)"))
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
onTap()
|
onTap()
|
||||||
}
|
}
|
||||||
@@ -867,6 +870,7 @@ extension MonthView {
|
|||||||
}
|
}
|
||||||
.padding(.top, 60)
|
.padding(.top, 60)
|
||||||
.padding(.trailing)
|
.padding(.trailing)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.MonthView.settingsButton)
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import PhotosUI
|
|||||||
|
|
||||||
struct NoteEditorView: View {
|
struct NoteEditorView: View {
|
||||||
|
|
||||||
|
private enum AnimationConstants {
|
||||||
|
static let keyboardAppearDelay: TimeInterval = 0.5
|
||||||
|
}
|
||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
|
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
|
||||||
|
|
||||||
@@ -57,18 +61,18 @@ struct NoteEditorView: View {
|
|||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
.navigationTitle("Journal Note")
|
.navigationTitle(String(localized: "Journal Note"))
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
Button("Cancel") {
|
Button(String(localized: "Cancel")) {
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
.accessibilityIdentifier(AccessibilityID.NoteEditor.cancelButton)
|
.accessibilityIdentifier(AccessibilityID.NoteEditor.cancelButton)
|
||||||
}
|
}
|
||||||
|
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
Button("Save") {
|
Button(String(localized: "Save")) {
|
||||||
saveNote()
|
saveNote()
|
||||||
}
|
}
|
||||||
.disabled(isSaving || noteText.count > maxCharacters)
|
.disabled(isSaving || noteText.count > maxCharacters)
|
||||||
@@ -78,14 +82,14 @@ struct NoteEditorView: View {
|
|||||||
|
|
||||||
ToolbarItemGroup(placement: .keyboard) {
|
ToolbarItemGroup(placement: .keyboard) {
|
||||||
Spacer()
|
Spacer()
|
||||||
Button("Done") {
|
Button(String(localized: "Done")) {
|
||||||
isTextFieldFocused = false
|
isTextFieldFocused = false
|
||||||
}
|
}
|
||||||
.accessibilityIdentifier(AccessibilityID.NoteEditor.keyboardDoneButton)
|
.accessibilityIdentifier(AccessibilityID.NoteEditor.keyboardDoneButton)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + AnimationConstants.keyboardAppearDelay) {
|
||||||
isTextFieldFocused = true
|
isTextFieldFocused = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,6 +133,12 @@ struct NoteEditorView: View {
|
|||||||
let success = DataController.shared.updateNotes(forDate: entry.forDate, notes: noteToSave)
|
let success = DataController.shared.updateNotes(forDate: entry.forDate, notes: noteToSave)
|
||||||
|
|
||||||
if success {
|
if success {
|
||||||
|
// Fire-and-forget tag extraction after saving a note
|
||||||
|
if #available(iOS 26, *), !IAPManager.shared.shouldShowPaywall, noteToSave != nil {
|
||||||
|
Task {
|
||||||
|
await FoundationModelsTagService.shared.extractAndSaveTags(for: entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
dismiss()
|
dismiss()
|
||||||
} else {
|
} else {
|
||||||
isSaving = false
|
isSaving = false
|
||||||
@@ -186,6 +196,11 @@ struct EntryDetailView: View {
|
|||||||
// Mood section
|
// Mood section
|
||||||
moodSection
|
moodSection
|
||||||
|
|
||||||
|
// Tags section
|
||||||
|
if entry.hasTags {
|
||||||
|
tagsSection
|
||||||
|
}
|
||||||
|
|
||||||
// Guided reflection section
|
// Guided reflection section
|
||||||
if currentMood != .missing && currentMood != .placeholder {
|
if currentMood != .missing && currentMood != .placeholder {
|
||||||
reflectionSection
|
reflectionSection
|
||||||
@@ -205,12 +220,12 @@ struct EntryDetailView: View {
|
|||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
.background(Color(.systemGroupedBackground))
|
.background(Color(.systemGroupedBackground))
|
||||||
.navigationTitle("Entry Details")
|
.navigationTitle(String(localized: "Entry Details"))
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.accessibilityIdentifier(AccessibilityID.EntryDetail.sheet)
|
.accessibilityIdentifier(AccessibilityID.EntryDetail.sheet)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
Button("Done") {
|
Button(String(localized: "Done")) {
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
.accessibilityIdentifier(AccessibilityID.EntryDetail.doneButton)
|
.accessibilityIdentifier(AccessibilityID.EntryDetail.doneButton)
|
||||||
@@ -222,16 +237,16 @@ struct EntryDetailView: View {
|
|||||||
.sheet(isPresented: $showReflectionFlow) {
|
.sheet(isPresented: $showReflectionFlow) {
|
||||||
GuidedReflectionView(entry: entry)
|
GuidedReflectionView(entry: entry)
|
||||||
}
|
}
|
||||||
.alert("Delete Entry", isPresented: $showDeleteConfirmation) {
|
.alert(String(localized: "Delete Entry"), isPresented: $showDeleteConfirmation) {
|
||||||
Button("Delete", role: .destructive) {
|
Button(String(localized: "Delete"), role: .destructive) {
|
||||||
onDelete()
|
onDelete()
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
.accessibilityIdentifier(AccessibilityID.EntryDetail.deleteConfirmButton)
|
.accessibilityIdentifier(AccessibilityID.EntryDetail.deleteConfirmButton)
|
||||||
Button("Cancel", role: .cancel) { }
|
Button(String(localized: "Cancel"), role: .cancel) { }
|
||||||
.accessibilityIdentifier(AccessibilityID.EntryDetail.deleteCancelButton)
|
.accessibilityIdentifier(AccessibilityID.EntryDetail.deleteCancelButton)
|
||||||
} message: {
|
} message: {
|
||||||
Text("Are you sure you want to delete this mood entry? This cannot be undone.")
|
Text(String(localized: "Are you sure you want to delete this mood entry? This cannot be undone."))
|
||||||
}
|
}
|
||||||
.photosPicker(isPresented: $showPhotoPicker, selection: $selectedPhotoItem, matching: .images)
|
.photosPicker(isPresented: $showPhotoPicker, selection: $selectedPhotoItem, matching: .images)
|
||||||
.onChange(of: selectedPhotoItem) { _, newItem in
|
.onChange(of: selectedPhotoItem) { _, newItem in
|
||||||
@@ -389,6 +404,35 @@ struct EntryDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var tagsSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text(String(localized: "Themes"))
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
|
||||||
|
FlowLayout(spacing: 8) {
|
||||||
|
ForEach(entry.tags, id: \.self) { tag in
|
||||||
|
Text(tag.capitalized)
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(
|
||||||
|
Capsule()
|
||||||
|
.fill(moodColor.opacity(0.15))
|
||||||
|
)
|
||||||
|
.foregroundColor(moodColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 16)
|
||||||
|
.fill(Color(.systemBackground))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var notesSection: some View {
|
private var notesSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
HStack {
|
HStack {
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ struct PhotoPickerView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.disabled(isProcessing)
|
.disabled(isProcessing)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.PhotoPicker.photosPicker)
|
||||||
|
|
||||||
// Camera
|
// Camera
|
||||||
Button {
|
Button {
|
||||||
@@ -111,6 +112,7 @@ struct PhotoPickerView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.disabled(isProcessing)
|
.disabled(isProcessing)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.PhotoPicker.cameraButton)
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
|
|
||||||
@@ -130,6 +132,7 @@ struct PhotoPickerView: View {
|
|||||||
Button("Cancel") {
|
Button("Cancel") {
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityID.PhotoPicker.cancelButton)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: selectedItem) { _, newItem in
|
.onChange(of: selectedItem) { _, newItem in
|
||||||
@@ -157,7 +160,9 @@ struct PhotoPickerView: View {
|
|||||||
handleSelectedImage(image)
|
handleSelectedImage(image)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
#if DEBUG
|
||||||
print("PhotoPickerView: Failed to load image: \(error)")
|
print("PhotoPickerView: Failed to load image: \(error)")
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,6 +283,7 @@ struct PhotoGalleryView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityID.PhotoPicker.photoImage)
|
||||||
} else {
|
} else {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
Image(systemName: "photo.badge.exclamationmark")
|
Image(systemName: "photo.badge.exclamationmark")
|
||||||
@@ -301,6 +307,7 @@ struct PhotoGalleryView: View {
|
|||||||
.font(.title2)
|
.font(.title2)
|
||||||
.foregroundStyle(.white.opacity(0.7))
|
.foregroundStyle(.white.opacity(0.7))
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityID.PhotoPicker.closeButton)
|
||||||
}
|
}
|
||||||
|
|
||||||
ToolbarItem(placement: .primaryAction) {
|
ToolbarItem(placement: .primaryAction) {
|
||||||
@@ -310,17 +317,20 @@ struct PhotoGalleryView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Label("Share", systemImage: "square.and.arrow.up")
|
Label("Share", systemImage: "square.and.arrow.up")
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityID.PhotoPicker.shareButton)
|
||||||
|
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
showDeleteConfirmation = true
|
showDeleteConfirmation = true
|
||||||
} label: {
|
} label: {
|
||||||
Label("Delete", systemImage: "trash")
|
Label("Delete", systemImage: "trash")
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityID.PhotoPicker.deleteButton)
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "ellipsis.circle.fill")
|
Image(systemName: "ellipsis.circle.fill")
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
.foregroundStyle(.white.opacity(0.7))
|
.foregroundStyle(.white.opacity(0.7))
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityID.PhotoPicker.menuButton)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.confirmationDialog("Delete Photo", isPresented: $showDeleteConfirmation, titleVisibility: .visible) {
|
.confirmationDialog("Delete Photo", isPresented: $showDeleteConfirmation, titleVisibility: .visible) {
|
||||||
@@ -328,7 +338,9 @@ struct PhotoGalleryView: View {
|
|||||||
onDelete()
|
onDelete()
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityID.PhotoPicker.deleteConfirmButton)
|
||||||
Button("Cancel", role: .cancel) { }
|
Button("Cancel", role: .cancel) { }
|
||||||
|
.accessibilityIdentifier(AccessibilityID.PhotoPicker.deleteCancelButton)
|
||||||
} message: {
|
} message: {
|
||||||
Text("Are you sure you want to delete this photo?")
|
Text("Are you sure you want to delete this photo?")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ struct PurchaseButtonView: View {
|
|||||||
.background(Color.pink)
|
.background(Color.pink)
|
||||||
.cornerRadius(10)
|
.cornerRadius(10)
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Purchase.subscribeButton)
|
||||||
|
|
||||||
// Restore purchases
|
// Restore purchases
|
||||||
Button {
|
Button {
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ struct ReflectSubscriptionStoreView: View {
|
|||||||
}
|
}
|
||||||
.padding(16)
|
.padding(16)
|
||||||
.accessibilityLabel("Close")
|
.accessibilityLabel("Close")
|
||||||
|
.accessibilityIdentifier(AccessibilityID.SubscriptionStore.closeButton)
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
AppLogger.iap.info("SubscriptionStoreView appeared — source: \(source), productIDs: \(IAPManager.productIdentifiers.sorted().joined(separator: ", ")), groupID: \(IAPManager.subscriptionGroupID)")
|
AppLogger.iap.info("SubscriptionStoreView appeared — source: \(source), productIDs: \(IAPManager.productIdentifiers.sorted().joined(separator: ", ")), groupID: \(IAPManager.subscriptionGroupID)")
|
||||||
|
|||||||
219
Shared/Views/ReflectionFeedbackView.swift
Normal file
219
Shared/Views/ReflectionFeedbackView.swift
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
//
|
||||||
|
// ReflectionFeedbackView.swift
|
||||||
|
// Reflect
|
||||||
|
//
|
||||||
|
// Displays AI-generated personalized feedback after completing a guided reflection.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ReflectionFeedbackView: View {
|
||||||
|
|
||||||
|
let mood: Mood
|
||||||
|
let reflection: GuidedReflection
|
||||||
|
let onDismiss: () -> Void
|
||||||
|
|
||||||
|
@AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default
|
||||||
|
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
|
||||||
|
|
||||||
|
@State private var feedback: ReflectionFeedbackState = .loading
|
||||||
|
@State private var appeared = false
|
||||||
|
|
||||||
|
private var accentColor: Color { moodTint.color(forMood: mood) }
|
||||||
|
private var textColor: Color { theme.currentTheme.labelColor }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 24) {
|
||||||
|
headerIcon
|
||||||
|
|
||||||
|
switch feedback {
|
||||||
|
case .loading:
|
||||||
|
loadingContent
|
||||||
|
case .loaded(let affirmation, let observation, let takeaway, let iconName):
|
||||||
|
feedbackContent(affirmation: affirmation, observation: observation, takeaway: takeaway, iconName: iconName)
|
||||||
|
case .error:
|
||||||
|
fallbackContent
|
||||||
|
case .unavailable:
|
||||||
|
fallbackContent
|
||||||
|
}
|
||||||
|
|
||||||
|
dismissButton
|
||||||
|
}
|
||||||
|
.padding(24)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 24)
|
||||||
|
.fill(Color(.secondarySystemBackground))
|
||||||
|
)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.opacity(appeared ? 1 : 0)
|
||||||
|
.scaleEffect(appeared ? 1 : 0.95)
|
||||||
|
.task {
|
||||||
|
await generateFeedback()
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
withAnimation(.easeOut(duration: 0.3)) {
|
||||||
|
appeared = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityID.ReflectionFeedback.container)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Header
|
||||||
|
|
||||||
|
private var headerIcon: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Image(systemName: "sparkles")
|
||||||
|
.font(.system(size: 32))
|
||||||
|
.foregroundStyle(accentColor)
|
||||||
|
.symbolEffect(.pulse, options: .repeating, isActive: feedback.isLoading)
|
||||||
|
|
||||||
|
Text(String(localized: "Your Reflection"))
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Loading
|
||||||
|
|
||||||
|
private var loadingContent: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
ForEach(0..<3, id: \.self) { _ in
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.fill(Color(.systemGray5))
|
||||||
|
.frame(height: 16)
|
||||||
|
.shimmering()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.ReflectionFeedback.loading)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Feedback Content
|
||||||
|
|
||||||
|
private func feedbackContent(affirmation: String, observation: String, takeaway: String, iconName: String) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
feedbackRow(icon: iconName, text: affirmation)
|
||||||
|
feedbackRow(icon: "eye.fill", text: observation)
|
||||||
|
feedbackRow(icon: "arrow.right.circle.fill", text: takeaway)
|
||||||
|
}
|
||||||
|
.transition(.opacity.combined(with: .move(edge: .bottom)))
|
||||||
|
.accessibilityIdentifier(AccessibilityID.ReflectionFeedback.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func feedbackRow(icon: String, text: String) -> some View {
|
||||||
|
HStack(alignment: .top, spacing: 12) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(accentColor)
|
||||||
|
.frame(width: 24, height: 24)
|
||||||
|
|
||||||
|
Text(text)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Fallback (no AI available)
|
||||||
|
|
||||||
|
private var fallbackContent: some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Text(String(localized: "Great job completing your reflection. Taking time to check in with yourself is a powerful habit."))
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityID.ReflectionFeedback.fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Dismiss
|
||||||
|
|
||||||
|
private var dismissButton: some View {
|
||||||
|
Button {
|
||||||
|
onDismiss()
|
||||||
|
} label: {
|
||||||
|
Text(String(localized: "Done"))
|
||||||
|
.font(.headline)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(accentColor)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.ReflectionFeedback.doneButton)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Generation
|
||||||
|
|
||||||
|
private func generateFeedback() async {
|
||||||
|
// Check premium access
|
||||||
|
guard !IAPManager.shared.shouldShowPaywall else {
|
||||||
|
feedback = .unavailable
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if #available(iOS 26, *) {
|
||||||
|
let service = FoundationModelsReflectionService()
|
||||||
|
do {
|
||||||
|
let result = try await service.generateFeedback(for: reflection, mood: mood)
|
||||||
|
withAnimation(.easeInOut(duration: 0.3)) {
|
||||||
|
feedback = .loaded(
|
||||||
|
affirmation: result.affirmation,
|
||||||
|
observation: result.observation,
|
||||||
|
takeaway: result.takeaway,
|
||||||
|
iconName: result.iconName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
withAnimation {
|
||||||
|
feedback = .error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
feedback = .unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - State
|
||||||
|
|
||||||
|
private enum ReflectionFeedbackState {
|
||||||
|
case loading
|
||||||
|
case loaded(affirmation: String, observation: String, takeaway: String, iconName: String)
|
||||||
|
case error
|
||||||
|
case unavailable
|
||||||
|
|
||||||
|
var isLoading: Bool {
|
||||||
|
if case .loading = self { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Shimmer Effect
|
||||||
|
|
||||||
|
private struct ShimmerModifier: ViewModifier {
|
||||||
|
@State private var phase: CGFloat = 0
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.overlay(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [.clear, Color.white.opacity(0.3), .clear],
|
||||||
|
startPoint: .leading,
|
||||||
|
endPoint: .trailing
|
||||||
|
)
|
||||||
|
.offset(x: phase)
|
||||||
|
.onAppear {
|
||||||
|
withAnimation(.linear(duration: 1.5).repeatForever(autoreverses: false)) {
|
||||||
|
phase = 300
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.mask(content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension View {
|
||||||
|
func shimmering() -> some View {
|
||||||
|
modifier(ShimmerModifier())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,9 +22,12 @@ struct SampleEntryView: View {
|
|||||||
.resizable()
|
.resizable()
|
||||||
.frame(width: 20, height: 20, alignment: .trailing)
|
.frame(width: 20, height: 20, alignment: .trailing)
|
||||||
.foregroundColor(Color(UIColor.systemGray))
|
.foregroundColor(Color(UIColor.systemGray))
|
||||||
|
.accessibilityIdentifier(AccessibilityID.SampleEntry.refreshButton)
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
sampleListEntry = DataController.shared.generateObjectNotInArray(forDate: Date(), withMood: sampleListEntry.mood.next)
|
sampleListEntry = DataController.shared.generateObjectNotInArray(forDate: Date(), withMood: sampleListEntry.mood.next)
|
||||||
}
|
}
|
||||||
|
.accessibilityAddTraits(.isButton)
|
||||||
|
.accessibilityLabel(String(localized: "Refresh sample entry"))
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
}.padding()
|
}.padding()
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ struct DebugAnimationSettingsView: View {
|
|||||||
Button("Done") {
|
Button("Done") {
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Debug.animationDoneButton)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -217,6 +218,7 @@ struct AnimationCard: View {
|
|||||||
)
|
)
|
||||||
.scaleEffect(isPressed ? 0.95 : (isSelected ? 1.02 : 1.0))
|
.scaleEffect(isPressed ? 0.95 : (isSelected ? 1.02 : 1.0))
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Debug.animationCard(type.rawValue))
|
||||||
.buttonStyle(PlainButtonStyle())
|
.buttonStyle(PlainButtonStyle())
|
||||||
.onLongPressGesture(minimumDuration: .infinity, pressing: { pressing in
|
.onLongPressGesture(minimumDuration: .infinity, pressing: { pressing in
|
||||||
withAnimation(.easeInOut(duration: 0.15)) {
|
withAnimation(.easeInOut(duration: 0.15)) {
|
||||||
@@ -336,6 +338,7 @@ struct DebugVotingContentView: View {
|
|||||||
.fill(mood.color.opacity(0.15))
|
.fill(mood.color.opacity(0.15))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Debug.debugMoodButton(mood.strValue))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ struct LiveActivityPreviewView: View {
|
|||||||
.background(Color.gray.opacity(0.2))
|
.background(Color.gray.opacity(0.2))
|
||||||
.cornerRadius(12)
|
.cornerRadius(12)
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Debug.liveActivityResetButton)
|
||||||
|
|
||||||
Button(action: toggleAnimation) {
|
Button(action: toggleAnimation) {
|
||||||
Label(isAnimating ? "Pause" : "Start", systemImage: isAnimating ? "pause.fill" : "play.fill")
|
Label(isAnimating ? "Pause" : "Start", systemImage: isAnimating ? "pause.fill" : "play.fill")
|
||||||
@@ -68,6 +69,7 @@ struct LiveActivityPreviewView: View {
|
|||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.cornerRadius(12)
|
.cornerRadius(12)
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Debug.liveActivityToggleButton)
|
||||||
}
|
}
|
||||||
|
|
||||||
Button(action: { showRecordingMode = true }) {
|
Button(action: { showRecordingMode = true }) {
|
||||||
@@ -79,6 +81,7 @@ struct LiveActivityPreviewView: View {
|
|||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.cornerRadius(12)
|
.cornerRadius(12)
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Debug.liveActivityRecordButton)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.padding(.bottom, 40)
|
.padding(.bottom, 40)
|
||||||
@@ -264,6 +267,7 @@ struct LiveActivityRecordingView: View {
|
|||||||
.background(Color.orange)
|
.background(Color.orange)
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.cornerRadius(12)
|
.cornerRadius(12)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Debug.liveActivityDismissButton)
|
||||||
} else if isExporting {
|
} else if isExporting {
|
||||||
Text("Exporting frames...")
|
Text("Exporting frames...")
|
||||||
.font(.title2.bold())
|
.font(.title2.bold())
|
||||||
@@ -282,6 +286,7 @@ struct LiveActivityRecordingView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Debug.liveActivityExportButton)
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
if !isExporting && !exportComplete {
|
if !isExporting && !exportComplete {
|
||||||
startExport()
|
startExport()
|
||||||
@@ -319,7 +324,9 @@ struct LiveActivityRecordingView: View {
|
|||||||
try? FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true)
|
try? FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true)
|
||||||
|
|
||||||
exportPath = outputDir.path
|
exportPath = outputDir.path
|
||||||
|
#if DEBUG
|
||||||
print("📁 Exporting frames to: \(exportPath)")
|
print("📁 Exporting frames to: \(exportPath)")
|
||||||
|
#endif
|
||||||
|
|
||||||
let target = targetStreak
|
let target = targetStreak
|
||||||
let outDir = outputDir
|
let outDir = outputDir
|
||||||
@@ -354,7 +361,9 @@ struct LiveActivityRecordingView: View {
|
|||||||
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
exportComplete = true
|
exportComplete = true
|
||||||
|
#if DEBUG
|
||||||
print("✅ Export complete! \(target) frames saved to: \(outPath)")
|
print("✅ Export complete! \(target) frames saved to: \(outPath)")
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
struct PaywallPreviewSettingsView: View {
|
struct PaywallPreviewSettingsView: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@State private var selectedStyle: PaywallStyle = .celestial
|
@State private var selectedStyle: PaywallStyle = .celestial
|
||||||
@@ -35,6 +34,7 @@ struct PaywallPreviewSettingsView: View {
|
|||||||
Button("Done") {
|
Button("Done") {
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Debug.paywallPreviewDoneButton)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showFullPreview) {
|
.sheet(isPresented: $showFullPreview) {
|
||||||
@@ -160,6 +160,7 @@ struct PaywallPreviewSettingsView: View {
|
|||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Debug.viewFullPaywallButton)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var gradientColors: [Color] {
|
private var gradientColors: [Color] {
|
||||||
@@ -241,6 +242,7 @@ struct StyleOptionRow: View {
|
|||||||
.stroke(isSelected ? Color.accentColor : Color.clear, lineWidth: 2)
|
.stroke(isSelected ? Color.accentColor : Color.clear, lineWidth: 2)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Debug.paywallStyleOption(style.displayName))
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -925,4 +927,3 @@ struct JournalMiniPreview: View {
|
|||||||
.environmentObject(IAPManager())
|
.environmentObject(IAPManager())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
|
|||||||
@@ -255,6 +255,7 @@ struct WhyUpgradeView: View {
|
|||||||
Button("Done") {
|
Button("Done") {
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Settings.doneButton)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ import SwiftUI
|
|||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
import StoreKit
|
import StoreKit
|
||||||
|
|
||||||
|
private enum SettingsAnimationConstants {
|
||||||
|
static let locationPermissionCheckDelay: TimeInterval = 1.0
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Settings Content View (for use in SettingsTabView)
|
// MARK: - Settings Content View (for use in SettingsTabView)
|
||||||
struct SettingsContentView: View {
|
struct SettingsContentView: View {
|
||||||
@EnvironmentObject var authManager: BiometricAuthManager
|
@EnvironmentObject var authManager: BiometricAuthManager
|
||||||
@@ -68,7 +72,6 @@ struct SettingsContentView: View {
|
|||||||
privacyButton
|
privacyButton
|
||||||
analyticsToggle
|
analyticsToggle
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
// Debug section
|
// Debug section
|
||||||
debugSectionHeader
|
debugSectionHeader
|
||||||
addTestDataButton
|
addTestDataButton
|
||||||
@@ -83,9 +86,9 @@ struct SettingsContentView: View {
|
|||||||
exportInsightsButton
|
exportInsightsButton
|
||||||
generateAndExportButton
|
generateAndExportButton
|
||||||
deleteHealthKitDataButton
|
deleteHealthKitDataButton
|
||||||
|
generateWeeklyDigestButton
|
||||||
|
|
||||||
clearDataButton
|
clearDataButton
|
||||||
#endif
|
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
.frame(height: 20)
|
.frame(height: 20)
|
||||||
@@ -149,6 +152,7 @@ struct SettingsContentView: View {
|
|||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
})
|
})
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Settings.reminderTimeButton)
|
||||||
.accessibilityLabel(String(localized: "Reminder Time"))
|
.accessibilityLabel(String(localized: "Reminder Time"))
|
||||||
.accessibilityValue(formattedReminderTime)
|
.accessibilityValue(formattedReminderTime)
|
||||||
.accessibilityHint(String(localized: "Opens time picker to change reminder time"))
|
.accessibilityHint(String(localized: "Opens time picker to change reminder time"))
|
||||||
@@ -206,7 +210,6 @@ struct SettingsContentView: View {
|
|||||||
|
|
||||||
// MARK: - Debug Section
|
// MARK: - Debug Section
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
private var debugSectionHeader: some View {
|
private var debugSectionHeader: some View {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Debug")
|
Text("Debug")
|
||||||
@@ -269,6 +272,7 @@ struct SettingsContentView: View {
|
|||||||
showTrialDatePicker = true
|
showTrialDatePicker = true
|
||||||
}
|
}
|
||||||
.font(.subheadline.weight(.medium))
|
.font(.subheadline.weight(.medium))
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Settings.changeTrialDateButton)
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
@@ -283,6 +287,7 @@ struct SettingsContentView: View {
|
|||||||
displayedComponents: .date
|
displayedComponents: .date
|
||||||
)
|
)
|
||||||
.datePickerStyle(.graphical)
|
.datePickerStyle(.graphical)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Settings.trialDatePicker)
|
||||||
.padding()
|
.padding()
|
||||||
.navigationTitle("Set Trial Start Date")
|
.navigationTitle("Set Trial Start Date")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
@@ -295,6 +300,7 @@ struct SettingsContentView: View {
|
|||||||
await iapManager.checkSubscriptionStatus()
|
await iapManager.checkSubscriptionStatus()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Settings.trialDatePickerDoneButton)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -338,6 +344,7 @@ struct SettingsContentView: View {
|
|||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Settings.paywallPreviewButton)
|
||||||
.background(theme.currentTheme.secondaryBGColor)
|
.background(theme.currentTheme.secondaryBGColor)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
||||||
@@ -381,6 +388,7 @@ struct SettingsContentView: View {
|
|||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Settings.tipsPreviewButton)
|
||||||
.background(theme.currentTheme.secondaryBGColor)
|
.background(theme.currentTheme.secondaryBGColor)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
||||||
@@ -418,6 +426,7 @@ struct SettingsContentView: View {
|
|||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Settings.testNotificationsButton)
|
||||||
.background(theme.currentTheme.secondaryBGColor)
|
.background(theme.currentTheme.secondaryBGColor)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
||||||
@@ -430,7 +439,9 @@ struct SettingsContentView: View {
|
|||||||
widgetExportPath = await WidgetExporter.exportAllWidgets()
|
widgetExportPath = await WidgetExporter.exportAllWidgets()
|
||||||
isExportingWidgets = false
|
isExportingWidgets = false
|
||||||
if let path = widgetExportPath {
|
if let path = widgetExportPath {
|
||||||
|
#if DEBUG
|
||||||
print("📸 Widgets exported to: \(path.path)")
|
print("📸 Widgets exported to: \(path.path)")
|
||||||
|
#endif
|
||||||
openInFilesApp(path)
|
openInFilesApp(path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -470,6 +481,7 @@ struct SettingsContentView: View {
|
|||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
.disabled(isExportingWidgets)
|
.disabled(isExportingWidgets)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Settings.exportWidgetsButton)
|
||||||
.background(theme.currentTheme.secondaryBGColor)
|
.background(theme.currentTheme.secondaryBGColor)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
||||||
@@ -482,7 +494,9 @@ struct SettingsContentView: View {
|
|||||||
votingLayoutExportPath = await WidgetExporter.exportAllVotingLayouts()
|
votingLayoutExportPath = await WidgetExporter.exportAllVotingLayouts()
|
||||||
isExportingVotingLayouts = false
|
isExportingVotingLayouts = false
|
||||||
if let path = votingLayoutExportPath {
|
if let path = votingLayoutExportPath {
|
||||||
|
#if DEBUG
|
||||||
print("📸 Voting layouts exported to: \(path.path)")
|
print("📸 Voting layouts exported to: \(path.path)")
|
||||||
|
#endif
|
||||||
openInFilesApp(path)
|
openInFilesApp(path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -522,6 +536,7 @@ struct SettingsContentView: View {
|
|||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
.disabled(isExportingVotingLayouts)
|
.disabled(isExportingVotingLayouts)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Settings.exportVotingLayoutsButton)
|
||||||
.background(theme.currentTheme.secondaryBGColor)
|
.background(theme.currentTheme.secondaryBGColor)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
||||||
@@ -534,7 +549,9 @@ struct SettingsContentView: View {
|
|||||||
watchExportPath = await WatchExporter.exportAllWatchViews()
|
watchExportPath = await WatchExporter.exportAllWatchViews()
|
||||||
isExportingWatchViews = false
|
isExportingWatchViews = false
|
||||||
if let path = watchExportPath {
|
if let path = watchExportPath {
|
||||||
|
#if DEBUG
|
||||||
print("⌚ Watch views exported to: \(path.path)")
|
print("⌚ Watch views exported to: \(path.path)")
|
||||||
|
#endif
|
||||||
openInFilesApp(path)
|
openInFilesApp(path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -574,6 +591,7 @@ struct SettingsContentView: View {
|
|||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
.disabled(isExportingWatchViews)
|
.disabled(isExportingWatchViews)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Settings.exportWatchViewsButton)
|
||||||
.background(theme.currentTheme.secondaryBGColor)
|
.background(theme.currentTheme.secondaryBGColor)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
||||||
@@ -586,7 +604,9 @@ struct SettingsContentView: View {
|
|||||||
insightsExportPath = await InsightsExporter.exportInsightsScreenshots()
|
insightsExportPath = await InsightsExporter.exportInsightsScreenshots()
|
||||||
isExportingInsights = false
|
isExportingInsights = false
|
||||||
if let path = insightsExportPath {
|
if let path = insightsExportPath {
|
||||||
|
#if DEBUG
|
||||||
print("✨ Insights exported to: \(path.path)")
|
print("✨ Insights exported to: \(path.path)")
|
||||||
|
#endif
|
||||||
openInFilesApp(path)
|
openInFilesApp(path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -632,6 +652,7 @@ struct SettingsContentView: View {
|
|||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
.disabled(isExportingInsights)
|
.disabled(isExportingInsights)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Settings.exportInsightsButton)
|
||||||
.background(theme.currentTheme.secondaryBGColor)
|
.background(theme.currentTheme.secondaryBGColor)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
||||||
@@ -645,7 +666,9 @@ struct SettingsContentView: View {
|
|||||||
sharingExportPath = await SharingScreenshotExporter.exportAllSharingScreenshots()
|
sharingExportPath = await SharingScreenshotExporter.exportAllSharingScreenshots()
|
||||||
isGeneratingScreenshots = false
|
isGeneratingScreenshots = false
|
||||||
if let path = sharingExportPath {
|
if let path = sharingExportPath {
|
||||||
|
#if DEBUG
|
||||||
print("📸 Sharing screenshots exported to: \(path.path)")
|
print("📸 Sharing screenshots exported to: \(path.path)")
|
||||||
|
#endif
|
||||||
openInFilesApp(path)
|
openInFilesApp(path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -691,6 +714,7 @@ struct SettingsContentView: View {
|
|||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
.disabled(isGeneratingScreenshots)
|
.disabled(isGeneratingScreenshots)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Settings.generateScreenshotsButton)
|
||||||
.background(theme.currentTheme.secondaryBGColor)
|
.background(theme.currentTheme.secondaryBGColor)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
||||||
@@ -741,6 +765,64 @@ struct SettingsContentView: View {
|
|||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
.disabled(isDeletingHealthKitData)
|
.disabled(isDeletingHealthKitData)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Settings.deleteHealthKitButton)
|
||||||
|
.background(theme.currentTheme.secondaryBGColor)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
||||||
|
}
|
||||||
|
|
||||||
|
@State private var isGeneratingDigest = false
|
||||||
|
@State private var digestResult: String?
|
||||||
|
|
||||||
|
private var generateWeeklyDigestButton: some View {
|
||||||
|
Button {
|
||||||
|
isGeneratingDigest = true
|
||||||
|
digestResult = nil
|
||||||
|
Task {
|
||||||
|
if #available(iOS 26, *) {
|
||||||
|
do {
|
||||||
|
let digest = try await FoundationModelsDigestService.shared.generateWeeklyDigest()
|
||||||
|
digestResult = "✓ \(digest.headline)"
|
||||||
|
} catch {
|
||||||
|
digestResult = "✗ \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
digestResult = "✗ Requires iOS 26+"
|
||||||
|
}
|
||||||
|
isGeneratingDigest = false
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
if isGeneratingDigest {
|
||||||
|
ProgressView()
|
||||||
|
.frame(width: 32)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "sparkles.rectangle.stack")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundColor(.purple)
|
||||||
|
.frame(width: 32)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Generate Weekly Digest")
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
|
||||||
|
if let result = digestResult {
|
||||||
|
Text(result)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(result.contains("✓") ? .green : .red)
|
||||||
|
} else {
|
||||||
|
Text("Create AI digest now (shows in Insights tab)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.disabled(isGeneratingDigest)
|
||||||
.background(theme.currentTheme.secondaryBGColor)
|
.background(theme.currentTheme.secondaryBGColor)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
||||||
@@ -774,7 +856,6 @@ struct SettingsContentView: View {
|
|||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
|
|
||||||
// MARK: - Privacy Lock Toggle
|
// MARK: - Privacy Lock Toggle
|
||||||
|
|
||||||
@@ -852,11 +933,12 @@ struct SettingsContentView: View {
|
|||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Settings.addTestDataButton)
|
||||||
.background(theme.currentTheme.secondaryBGColor)
|
.background(theme.currentTheme.secondaryBGColor)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
||||||
}
|
}
|
||||||
|
|
||||||
private var healthKitToggle: some View {
|
private var healthKitToggle: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
@@ -902,7 +984,9 @@ struct SettingsContentView: View {
|
|||||||
AnalyticsManager.shared.track(.healthKitNotAuthorized)
|
AnalyticsManager.shared.track(.healthKitNotAuthorized)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
#if DEBUG
|
||||||
print("HealthKit authorization failed: \(error)")
|
print("HealthKit authorization failed: \(error)")
|
||||||
|
#endif
|
||||||
AnalyticsManager.shared.track(.healthKitEnableFailed)
|
AnalyticsManager.shared.track(.healthKitEnableFailed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1000,7 +1084,7 @@ struct SettingsContentView: View {
|
|||||||
LocationManager.shared.requestAuthorization()
|
LocationManager.shared.requestAuthorization()
|
||||||
// Check if permission was denied after a brief delay
|
// Check if permission was denied after a brief delay
|
||||||
Task {
|
Task {
|
||||||
try? await Task.sleep(for: .seconds(1))
|
try? await Task.sleep(for: .seconds(SettingsAnimationConstants.locationPermissionCheckDelay))
|
||||||
let status = LocationManager.shared.authorizationStatus
|
let status = LocationManager.shared.authorizationStatus
|
||||||
if status == .denied || status == .restricted {
|
if status == .denied || status == .restricted {
|
||||||
weatherEnabled = false
|
weatherEnabled = false
|
||||||
@@ -1048,7 +1132,9 @@ struct SettingsContentView: View {
|
|||||||
UIApplication.shared.open(url)
|
UIApplication.shared.open(url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Settings.locationAlertOpenSettingsButton)
|
||||||
Button(String(localized: "Cancel"), role: .cancel) {}
|
Button(String(localized: "Cancel"), role: .cancel) {}
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Settings.locationAlertCancelButton)
|
||||||
} message: {
|
} message: {
|
||||||
Text("Reflect needs location access to show weather. You can enable it in Settings.")
|
Text("Reflect needs location access to show weather. You can enable it in Settings.")
|
||||||
}
|
}
|
||||||
@@ -1084,6 +1170,7 @@ struct SettingsContentView: View {
|
|||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
})
|
})
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Settings.exportDataButton)
|
||||||
.accessibilityLabel(String(localized: "Export Data"))
|
.accessibilityLabel(String(localized: "Export Data"))
|
||||||
.accessibilityHint(String(localized: "Export your mood data as CSV or PDF"))
|
.accessibilityHint(String(localized: "Export your mood data as CSV or PDF"))
|
||||||
.background(theme.currentTheme.secondaryBGColor)
|
.background(theme.currentTheme.secondaryBGColor)
|
||||||
@@ -1377,11 +1464,11 @@ struct SettingsView: View {
|
|||||||
// specialThanksCell
|
// specialThanksCell
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
Group {
|
Group {
|
||||||
Divider()
|
Divider()
|
||||||
Text("Test builds only")
|
Text("Test builds only")
|
||||||
Toggle("Bypass Subscription", isOn: $iapManager.bypassSubscription)
|
Toggle("Bypass Subscription", isOn: $iapManager.bypassSubscription)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Settings.bypassSubscriptionToggle)
|
||||||
addTestDataCell
|
addTestDataCell
|
||||||
clearDB
|
clearDB
|
||||||
// fixWeekday
|
// fixWeekday
|
||||||
@@ -1392,7 +1479,6 @@ struct SettingsView: View {
|
|||||||
Divider()
|
Divider()
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
#endif
|
|
||||||
Text("\(Bundle.main.appName) v \(Bundle.main.versionNumber) (Build \(Bundle.main.buildNumber))")
|
Text("\(Bundle.main.appName) v \(Bundle.main.versionNumber) (Build \(Bundle.main.buildNumber))")
|
||||||
.font(.body)
|
.font(.body)
|
||||||
}
|
}
|
||||||
@@ -1427,9 +1513,13 @@ struct SettingsView: View {
|
|||||||
switch result {
|
switch result {
|
||||||
case .success(let url):
|
case .success(let url):
|
||||||
AnalyticsManager.shared.track(.dataExported(format: "file", count: 0))
|
AnalyticsManager.shared.track(.dataExported(format: "file", count: 0))
|
||||||
|
#if DEBUG
|
||||||
print("Saved to \(url)")
|
print("Saved to \(url)")
|
||||||
|
#endif
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
|
#if DEBUG
|
||||||
print(error.localizedDescription)
|
print(error.localizedDescription)
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.fileImporter(isPresented: $showingImporter, allowedContentTypes: [.text],
|
.fileImporter(isPresented: $showingImporter, allowedContentTypes: [.text],
|
||||||
@@ -1470,8 +1560,10 @@ struct SettingsView: View {
|
|||||||
} catch {
|
} catch {
|
||||||
// Handle failure.
|
// Handle failure.
|
||||||
AnalyticsManager.shared.track(.importFailed(error: error.localizedDescription))
|
AnalyticsManager.shared.track(.importFailed(error: error.localizedDescription))
|
||||||
|
#if DEBUG
|
||||||
print("Unable to read file contents")
|
print("Unable to read file contents")
|
||||||
print(error.localizedDescription)
|
print(error.localizedDescription)
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1611,7 +1703,9 @@ struct SettingsView: View {
|
|||||||
AnalyticsManager.shared.track(.healthKitNotAuthorized)
|
AnalyticsManager.shared.track(.healthKitNotAuthorized)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
#if DEBUG
|
||||||
print("HealthKit authorization failed: \(error)")
|
print("HealthKit authorization failed: \(error)")
|
||||||
|
#endif
|
||||||
AnalyticsManager.shared.track(.healthKitEnableFailed)
|
AnalyticsManager.shared.track(.healthKitEnableFailed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1701,7 +1795,7 @@ struct SettingsView: View {
|
|||||||
LocationManager.shared.requestAuthorization()
|
LocationManager.shared.requestAuthorization()
|
||||||
// Check if permission was denied after a brief delay
|
// Check if permission was denied after a brief delay
|
||||||
Task {
|
Task {
|
||||||
try? await Task.sleep(for: .seconds(1))
|
try? await Task.sleep(for: .seconds(SettingsAnimationConstants.locationPermissionCheckDelay))
|
||||||
let status = LocationManager.shared.authorizationStatus
|
let status = LocationManager.shared.authorizationStatus
|
||||||
if status == .denied || status == .restricted {
|
if status == .denied || status == .restricted {
|
||||||
weatherEnabled = false
|
weatherEnabled = false
|
||||||
@@ -1745,7 +1839,9 @@ struct SettingsView: View {
|
|||||||
UIApplication.shared.open(url)
|
UIApplication.shared.open(url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Settings.locationAlertOpenSettingsButton)
|
||||||
Button(String(localized: "Cancel"), role: .cancel) {}
|
Button(String(localized: "Cancel"), role: .cancel) {}
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Settings.locationAlertCancelButton)
|
||||||
} message: {
|
} message: {
|
||||||
Text("Reflect needs location access to show weather. You can enable it in Settings.")
|
Text("Reflect needs location access to show weather. You can enable it in Settings.")
|
||||||
}
|
}
|
||||||
@@ -1781,6 +1877,7 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
})
|
})
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Settings.exportDataButton)
|
||||||
.background(theme.currentTheme.secondaryBGColor)
|
.background(theme.currentTheme.secondaryBGColor)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
||||||
@@ -1797,6 +1894,7 @@ struct SettingsView: View {
|
|||||||
.font(.body)
|
.font(.body)
|
||||||
.foregroundColor(Color(UIColor.systemBlue))
|
.foregroundColor(Color(UIColor.systemBlue))
|
||||||
})
|
})
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Settings.closeButton)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1811,17 +1909,20 @@ struct SettingsView: View {
|
|||||||
Text(String(localized: "settings_view_special_thanks_to_title"))
|
Text(String(localized: "settings_view_special_thanks_to_title"))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
})
|
})
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Settings.specialThanksButton)
|
||||||
.padding()
|
.padding()
|
||||||
|
|
||||||
if showSpecialThanks {
|
if showSpecialThanks {
|
||||||
Divider()
|
Divider()
|
||||||
Link("Font Awesome", destination: URL(string: "https://fontawesome.com")!)
|
Link("Font Awesome", destination: URL(string: "https://fontawesome.com")!)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Settings.fontAwesomeLink)
|
||||||
.accentColor(textColor)
|
.accentColor(textColor)
|
||||||
.padding(.bottom)
|
.padding(.bottom)
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
Link("Charts", destination: URL(string: "https://github.com/danielgindi/Charts")!)
|
Link("Charts", destination: URL(string: "https://github.com/danielgindi/Charts")!)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Settings.chartsLink)
|
||||||
.accentColor(textColor)
|
.accentColor(textColor)
|
||||||
.padding(.bottom)
|
.padding(.bottom)
|
||||||
}
|
}
|
||||||
@@ -1838,6 +1939,7 @@ struct SettingsView: View {
|
|||||||
Text("Add test data")
|
Text("Add test data")
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
})
|
})
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Settings.addTestDataButton)
|
||||||
.padding()
|
.padding()
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.background(theme.currentTheme.secondaryBGColor)
|
.background(theme.currentTheme.secondaryBGColor)
|
||||||
@@ -1867,6 +1969,7 @@ struct SettingsView: View {
|
|||||||
showTrialDatePicker = true
|
showTrialDatePicker = true
|
||||||
}
|
}
|
||||||
.font(.subheadline.weight(.medium))
|
.font(.subheadline.weight(.medium))
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Settings.changeTrialDateButton)
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.background(theme.currentTheme.secondaryBGColor)
|
.background(theme.currentTheme.secondaryBGColor)
|
||||||
@@ -1880,6 +1983,7 @@ struct SettingsView: View {
|
|||||||
displayedComponents: .date
|
displayedComponents: .date
|
||||||
)
|
)
|
||||||
.datePickerStyle(.graphical)
|
.datePickerStyle(.graphical)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Settings.trialDatePicker)
|
||||||
.padding()
|
.padding()
|
||||||
.navigationTitle("Set Trial Start Date")
|
.navigationTitle("Set Trial Start Date")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
@@ -1892,6 +1996,7 @@ struct SettingsView: View {
|
|||||||
await iapManager.checkSubscriptionStatus()
|
await iapManager.checkSubscriptionStatus()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Settings.trialDatePickerDoneButton)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1909,6 +2014,7 @@ struct SettingsView: View {
|
|||||||
Text("Reset luanch date to current date")
|
Text("Reset luanch date to current date")
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
})
|
})
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Settings.resetLaunchDateButton)
|
||||||
.padding()
|
.padding()
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.background(theme.currentTheme.secondaryBGColor)
|
.background(theme.currentTheme.secondaryBGColor)
|
||||||
@@ -1923,6 +2029,7 @@ struct SettingsView: View {
|
|||||||
Text("Clear DB")
|
Text("Clear DB")
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
})
|
})
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Settings.clearDataButton)
|
||||||
.padding()
|
.padding()
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.background(theme.currentTheme.secondaryBGColor)
|
.background(theme.currentTheme.secondaryBGColor)
|
||||||
@@ -1937,6 +2044,7 @@ struct SettingsView: View {
|
|||||||
Text("Fix Weekday")
|
Text("Fix Weekday")
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
})
|
})
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Settings.fixWeekdayButton)
|
||||||
.padding()
|
.padding()
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.background(theme.currentTheme.secondaryBGColor)
|
.background(theme.currentTheme.secondaryBGColor)
|
||||||
@@ -1954,6 +2062,7 @@ struct SettingsView: View {
|
|||||||
Text(String(localized: "settings_view_why_bg_mode_title"))
|
Text(String(localized: "settings_view_why_bg_mode_title"))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
})
|
})
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Settings.whyBackgroundModeButton)
|
||||||
.padding()
|
.padding()
|
||||||
if showWhyBGMode {
|
if showWhyBGMode {
|
||||||
Text(String(localized: "settings_view_why_bg_mode_body"))
|
Text(String(localized: "settings_view_why_bg_mode_body"))
|
||||||
@@ -2110,13 +2219,14 @@ struct SettingsView: View {
|
|||||||
Text("Export")
|
Text("Export")
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
})
|
})
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Settings.exportLegacyButton)
|
||||||
.padding()
|
.padding()
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.background(theme.currentTheme.secondaryBGColor)
|
.background(theme.currentTheme.secondaryBGColor)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
||||||
}
|
}
|
||||||
|
|
||||||
private var importData: some View {
|
private var importData: some View {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
showingImporter.toggle()
|
showingImporter.toggle()
|
||||||
@@ -2125,13 +2235,14 @@ struct SettingsView: View {
|
|||||||
Text("Import")
|
Text("Import")
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
})
|
})
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Settings.importButton)
|
||||||
.padding()
|
.padding()
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.background(theme.currentTheme.secondaryBGColor)
|
.background(theme.currentTheme.secondaryBGColor)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
||||||
}
|
}
|
||||||
|
|
||||||
private var randomIcons: some View {
|
private var randomIcons: some View {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
var iconViews = [UIImage]()
|
var iconViews = [UIImage]()
|
||||||
@@ -2245,9 +2356,13 @@ struct SettingsView: View {
|
|||||||
let url = URL(fileURLWithPath: path)
|
let url = URL(fileURLWithPath: path)
|
||||||
do {
|
do {
|
||||||
try image.jpegData(compressionQuality: 1.0)?.write(to: url, options: .atomic)
|
try image.jpegData(compressionQuality: 1.0)?.write(to: url, options: .atomic)
|
||||||
|
#if DEBUG
|
||||||
print(url)
|
print(url)
|
||||||
|
#endif
|
||||||
} catch {
|
} catch {
|
||||||
|
#if DEBUG
|
||||||
print(error.localizedDescription)
|
print(error.localizedDescription)
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2255,6 +2370,7 @@ struct SettingsView: View {
|
|||||||
Text("Create random icons")
|
Text("Create random icons")
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
})
|
})
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Settings.randomIconsButton)
|
||||||
.padding()
|
.padding()
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.background(theme.currentTheme.secondaryBGColor)
|
.background(theme.currentTheme.secondaryBGColor)
|
||||||
|
|||||||
@@ -154,10 +154,10 @@ struct SharingListView: View {
|
|||||||
}, label: {
|
}, label: {
|
||||||
ZStack {
|
ZStack {
|
||||||
theme.currentTheme.secondaryBGColor
|
theme.currentTheme.secondaryBGColor
|
||||||
|
|
||||||
item.preview
|
item.preview
|
||||||
.frame(height: 88)
|
.frame(height: 88)
|
||||||
|
|
||||||
VStack {
|
VStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(item.description)
|
Text(item.description)
|
||||||
@@ -179,6 +179,7 @@ struct SharingListView: View {
|
|||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.padding([.leading, .trailing])
|
.padding([.leading, .trailing])
|
||||||
})
|
})
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Sharing.templateButton(item.description))
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ struct SharingStylePickerView: View {
|
|||||||
.font(.headline)
|
.font(.headline)
|
||||||
.foregroundColor(.red)
|
.foregroundColor(.red)
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Sharing.exitButton)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
@@ -104,6 +105,7 @@ struct SharingStylePickerView: View {
|
|||||||
.background(Color.green)
|
.background(Color.green)
|
||||||
.cornerRadius(14)
|
.cornerRadius(14)
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Sharing.shareButton)
|
||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
.padding(.top, 12)
|
.padding(.top, 12)
|
||||||
.padding(.bottom, 24)
|
.padding(.bottom, 24)
|
||||||
@@ -160,6 +162,7 @@ struct LongestStreakPickerView: View {
|
|||||||
.font(.headline)
|
.font(.headline)
|
||||||
.foregroundColor(.red)
|
.foregroundColor(.red)
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Sharing.exitButton)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
@@ -169,6 +172,7 @@ struct LongestStreakPickerView: View {
|
|||||||
selectedMood = mood
|
selectedMood = mood
|
||||||
recomputeStreak()
|
recomputeStreak()
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Sharing.moodMenuButton(mood.strValue))
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
@@ -180,6 +184,7 @@ struct LongestStreakPickerView: View {
|
|||||||
.foregroundColor(textColor.opacity(0.6))
|
.foregroundColor(textColor.opacity(0.6))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Sharing.moodMenu)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
@@ -225,6 +230,7 @@ struct LongestStreakPickerView: View {
|
|||||||
.background(Color.green)
|
.background(Color.green)
|
||||||
.cornerRadius(14)
|
.cornerRadius(14)
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityID.Sharing.shareButton)
|
||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
.padding(.top, 12)
|
.padding(.top, 12)
|
||||||
.padding(.bottom, 24)
|
.padding(.bottom, 24)
|
||||||
|
|||||||
@@ -176,12 +176,13 @@ struct AllMoodsTotalTemplate: View, SharingTemplate {
|
|||||||
.foregroundColor(Color.white)
|
.foregroundColor(Color.white)
|
||||||
.padding(.top, 20)
|
.padding(.top, 20)
|
||||||
})
|
})
|
||||||
|
.accessibilityIdentifier(AccessibilityID.SharingTemplate.shareButton)
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
.background(
|
.background(
|
||||||
Color.green
|
Color.green
|
||||||
)
|
)
|
||||||
.padding(.trailing, -5)
|
.padding(.trailing, -5)
|
||||||
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
presentationMode.wrappedValue.dismiss()
|
presentationMode.wrappedValue.dismiss()
|
||||||
}, label: {
|
}, label: {
|
||||||
@@ -191,6 +192,7 @@ struct AllMoodsTotalTemplate: View, SharingTemplate {
|
|||||||
.foregroundColor(Color.white)
|
.foregroundColor(Color.white)
|
||||||
.padding(.top, 20)
|
.padding(.top, 20)
|
||||||
})
|
})
|
||||||
|
.accessibilityIdentifier(AccessibilityID.SharingTemplate.dismissButton)
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
.background(
|
.background(
|
||||||
Color.red
|
Color.red
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ struct CurrentStreakTemplate: View, SharingTemplate {
|
|||||||
.foregroundColor(Color.white)
|
.foregroundColor(Color.white)
|
||||||
.padding(.top, 20)
|
.padding(.top, 20)
|
||||||
})
|
})
|
||||||
|
.accessibilityIdentifier(AccessibilityID.SharingTemplate.shareButton)
|
||||||
.sheet(isPresented: self.$shareImage.showSheet) {
|
.sheet(isPresented: self.$shareImage.showSheet) {
|
||||||
if let uiImage = self.shareImage.selectedShareImage {
|
if let uiImage = self.shareImage.selectedShareImage {
|
||||||
ShareSheet(photo: uiImage)
|
ShareSheet(photo: uiImage)
|
||||||
@@ -129,7 +130,7 @@ struct CurrentStreakTemplate: View, SharingTemplate {
|
|||||||
Color.green
|
Color.green
|
||||||
)
|
)
|
||||||
.padding(.trailing, -5)
|
.padding(.trailing, -5)
|
||||||
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
presentationMode.wrappedValue.dismiss()
|
presentationMode.wrappedValue.dismiss()
|
||||||
}, label: {
|
}, label: {
|
||||||
@@ -139,6 +140,7 @@ struct CurrentStreakTemplate: View, SharingTemplate {
|
|||||||
.foregroundColor(Color.white)
|
.foregroundColor(Color.white)
|
||||||
.padding(.top, 20)
|
.padding(.top, 20)
|
||||||
})
|
})
|
||||||
|
.accessibilityIdentifier(AccessibilityID.SharingTemplate.dismissButton)
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
.background(
|
.background(
|
||||||
Color.red
|
Color.red
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ struct LongestStreakTemplate: View, SharingTemplate {
|
|||||||
selectedMood = mood
|
selectedMood = mood
|
||||||
configureData(fakeData: self.fakeData, mood: self.selectedMood)
|
configureData(fakeData: self.fakeData, mood: self.selectedMood)
|
||||||
})
|
})
|
||||||
|
.accessibilityIdentifier(AccessibilityID.SharingTemplate.moodMenuButton(mood.strValue))
|
||||||
}
|
}
|
||||||
}, label: {
|
}, label: {
|
||||||
Text("Pick Mood")
|
Text("Pick Mood")
|
||||||
@@ -174,6 +175,7 @@ struct LongestStreakTemplate: View, SharingTemplate {
|
|||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.padding()
|
.padding()
|
||||||
})
|
})
|
||||||
|
.accessibilityIdentifier(AccessibilityID.SharingTemplate.moodMenu)
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
.background(
|
.background(
|
||||||
theme.currentTheme.secondaryBGColor
|
theme.currentTheme.secondaryBGColor
|
||||||
@@ -194,6 +196,7 @@ struct LongestStreakTemplate: View, SharingTemplate {
|
|||||||
.foregroundColor(Color.white)
|
.foregroundColor(Color.white)
|
||||||
.padding(.top, 20)
|
.padding(.top, 20)
|
||||||
})
|
})
|
||||||
|
.accessibilityIdentifier(AccessibilityID.SharingTemplate.shareButton)
|
||||||
.sheet(isPresented: self.$shareImage.showSheet) {
|
.sheet(isPresented: self.$shareImage.showSheet) {
|
||||||
if let uiImage = self.shareImage.selectedShareImage {
|
if let uiImage = self.shareImage.selectedShareImage {
|
||||||
ShareSheet(photo: uiImage)
|
ShareSheet(photo: uiImage)
|
||||||
@@ -204,7 +207,7 @@ struct LongestStreakTemplate: View, SharingTemplate {
|
|||||||
Color.green
|
Color.green
|
||||||
)
|
)
|
||||||
.padding(.trailing, -5)
|
.padding(.trailing, -5)
|
||||||
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
presentationMode.wrappedValue.dismiss()
|
presentationMode.wrappedValue.dismiss()
|
||||||
}, label: {
|
}, label: {
|
||||||
@@ -214,6 +217,7 @@ struct LongestStreakTemplate: View, SharingTemplate {
|
|||||||
.foregroundColor(Color.white)
|
.foregroundColor(Color.white)
|
||||||
.padding(.top, 20)
|
.padding(.top, 20)
|
||||||
})
|
})
|
||||||
|
.accessibilityIdentifier(AccessibilityID.SharingTemplate.dismissButton)
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
.background(
|
.background(
|
||||||
Color.red
|
Color.red
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ struct MonthTotalTemplate: View, SharingTemplate {
|
|||||||
.foregroundColor(Color.white)
|
.foregroundColor(Color.white)
|
||||||
.padding(.top, 20)
|
.padding(.top, 20)
|
||||||
})
|
})
|
||||||
|
.accessibilityIdentifier(AccessibilityID.SharingTemplate.shareButton)
|
||||||
.sheet(isPresented: self.$shareImage.showSheet) {
|
.sheet(isPresented: self.$shareImage.showSheet) {
|
||||||
if let uiImage = self.shareImage.selectedShareImage {
|
if let uiImage = self.shareImage.selectedShareImage {
|
||||||
ShareSheet(photo: uiImage)
|
ShareSheet(photo: uiImage)
|
||||||
@@ -169,7 +170,7 @@ struct MonthTotalTemplate: View, SharingTemplate {
|
|||||||
Color.green
|
Color.green
|
||||||
)
|
)
|
||||||
.padding(.trailing, -5)
|
.padding(.trailing, -5)
|
||||||
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
presentationMode.wrappedValue.dismiss()
|
presentationMode.wrappedValue.dismiss()
|
||||||
}, label: {
|
}, label: {
|
||||||
@@ -179,6 +180,7 @@ struct MonthTotalTemplate: View, SharingTemplate {
|
|||||||
.foregroundColor(Color.white)
|
.foregroundColor(Color.white)
|
||||||
.padding(.top, 20)
|
.padding(.top, 20)
|
||||||
})
|
})
|
||||||
|
.accessibilityIdentifier(AccessibilityID.SharingTemplate.dismissButton)
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
.background(
|
.background(
|
||||||
Color.red
|
Color.red
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ struct SwitchableView: View {
|
|||||||
theme.currentTheme.secondaryBGColor
|
theme.currentTheme.secondaryBGColor
|
||||||
)
|
)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
|
.accessibilityIdentifier(AccessibilityID.SwitchableView.headerToggle)
|
||||||
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
||||||
.padding(.bottom, 30)
|
.padding(.bottom, 30)
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
@@ -100,6 +101,8 @@ struct SwitchableView: View {
|
|||||||
self.headerTypeChanged(viewType)
|
self.headerTypeChanged(viewType)
|
||||||
AnalyticsManager.shared.track(.viewHeaderChanged(header: String(describing: viewType)))
|
AnalyticsManager.shared.track(.viewHeaderChanged(header: String(describing: viewType)))
|
||||||
}
|
}
|
||||||
|
.accessibilityAddTraits(.isButton)
|
||||||
|
.accessibilityLabel(String(localized: "Switch header view"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ struct TipModalView: View {
|
|||||||
y: 6
|
y: 6
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityID.TipModal.dismissButton)
|
||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
.padding(.bottom, 24)
|
.padding(.bottom, 24)
|
||||||
.opacity(appeared ? 1 : 0)
|
.opacity(appeared ? 1 : 0)
|
||||||
@@ -245,7 +246,6 @@ extension View {
|
|||||||
|
|
||||||
// MARK: - Tips Preview View (Debug)
|
// MARK: - Tips Preview View (Debug)
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
struct TipsPreviewView: View {
|
struct TipsPreviewView: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@State private var selectedTipIndex: Int?
|
@State private var selectedTipIndex: Int?
|
||||||
@@ -308,6 +308,7 @@ struct TipsPreviewView: View {
|
|||||||
}
|
}
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityID.TipModal.tipPreviewButton(index))
|
||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
Text("Tap to preview")
|
Text("Tap to preview")
|
||||||
@@ -320,11 +321,13 @@ struct TipsPreviewView: View {
|
|||||||
ReflectTipsManager.shared.resetAllTips()
|
ReflectTipsManager.shared.resetAllTips()
|
||||||
}
|
}
|
||||||
.foregroundColor(.red)
|
.foregroundColor(.red)
|
||||||
|
.accessibilityIdentifier(AccessibilityID.TipModal.resetTipsButton)
|
||||||
|
|
||||||
Toggle("Tips Enabled", isOn: Binding(
|
Toggle("Tips Enabled", isOn: Binding(
|
||||||
get: { ReflectTipsManager.shared.tipsEnabled },
|
get: { ReflectTipsManager.shared.tipsEnabled },
|
||||||
set: { ReflectTipsManager.shared.tipsEnabled = $0 }
|
set: { ReflectTipsManager.shared.tipsEnabled = $0 }
|
||||||
))
|
))
|
||||||
|
.accessibilityIdentifier(AccessibilityID.TipModal.tipsEnabledToggle)
|
||||||
} header: {
|
} header: {
|
||||||
Text("Settings")
|
Text("Settings")
|
||||||
}
|
}
|
||||||
@@ -346,6 +349,7 @@ struct TipsPreviewView: View {
|
|||||||
Button("Done") {
|
Button("Done") {
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityID.TipModal.doneButton)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(item: Binding(
|
.sheet(item: Binding(
|
||||||
@@ -379,4 +383,3 @@ private struct TipIndexWrapper: Identifiable {
|
|||||||
TipsPreviewView()
|
TipsPreviewView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
|
|||||||
@@ -308,6 +308,7 @@ struct YearView: View {
|
|||||||
.preferredColorScheme(theme.preferredColorScheme)
|
.preferredColorScheme(theme.preferredColorScheme)
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
// Triple-tap to toggle demo mode for video recording
|
// Triple-tap to toggle demo mode for video recording
|
||||||
|
.accessibilityIdentifier(AccessibilityID.YearView.debugDemoToggle)
|
||||||
.onTapGesture(count: 3) {
|
.onTapGesture(count: 3) {
|
||||||
if demoManager.isDemoMode {
|
if demoManager.isDemoMode {
|
||||||
demoManager.stopDemoMode()
|
demoManager.stopDemoMode()
|
||||||
|
|||||||
Reference in New Issue
Block a user