Add guided reflection flow with mood-adaptive CBT/ACT questions
Walks users through 3-4 guided questions based on mood category: positive (great/good) gets gratitude-oriented questions, neutral (average) gets exploratory questions, and negative (bad/horrible) gets empathetic questions. Stored as JSON in MoodEntryModel, integrated into PDF reports, AI summaries, and CSV export. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -58,6 +58,20 @@ enum AccessibilityID {
|
||||
static let cancelButton = "note_editor_cancel"
|
||||
}
|
||||
|
||||
// MARK: - Guided Reflection
|
||||
enum GuidedReflection {
|
||||
static let sheet = "guided_reflection_sheet"
|
||||
static let progressDots = "guided_reflection_progress"
|
||||
static let textEditor = "guided_reflection_text_editor"
|
||||
static let nextButton = "guided_reflection_next"
|
||||
static let backButton = "guided_reflection_back"
|
||||
static let saveButton = "guided_reflection_save"
|
||||
static let cancelButton = "guided_reflection_cancel"
|
||||
static func questionLabel(step: Int) -> String {
|
||||
"guided_reflection_question_\(step)"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Settings
|
||||
enum Settings {
|
||||
static let header = "settings_header"
|
||||
|
||||
@@ -350,6 +350,7 @@ extension AnalyticsManager {
|
||||
case paywall = "paywall"
|
||||
case entryDetail = "entry_detail"
|
||||
case noteEditor = "note_editor"
|
||||
case guidedReflection = "guided_reflection"
|
||||
case lockScreen = "lock_screen"
|
||||
case sharing = "sharing"
|
||||
case themePicker = "theme_picker"
|
||||
@@ -382,6 +383,8 @@ extension AnalyticsManager {
|
||||
case noteUpdated(characterCount: Int)
|
||||
case photoAdded
|
||||
case photoDeleted
|
||||
case reflectionStarted
|
||||
case reflectionCompleted(answeredCount: Int)
|
||||
case missingEntriesFilled(count: Int)
|
||||
case entryDeleted(mood: Int)
|
||||
case allDataCleared
|
||||
@@ -491,6 +494,10 @@ extension AnalyticsManager {
|
||||
return ("photo_added", nil)
|
||||
case .photoDeleted:
|
||||
return ("photo_deleted", nil)
|
||||
case .reflectionStarted:
|
||||
return ("reflection_started", nil)
|
||||
case .reflectionCompleted(let count):
|
||||
return ("reflection_completed", ["answered_count": count])
|
||||
case .missingEntriesFilled(let count):
|
||||
return ("missing_entries_filled", ["count": count])
|
||||
case .entryDeleted(let mood):
|
||||
|
||||
@@ -36,12 +36,14 @@ struct ReportEntry {
|
||||
let mood: Mood
|
||||
let notes: String?
|
||||
let weather: WeatherData?
|
||||
let reflection: GuidedReflection?
|
||||
|
||||
init(from model: MoodEntryModel) {
|
||||
self.date = model.forDate
|
||||
self.mood = model.mood
|
||||
self.notes = model.notes
|
||||
self.weather = model.weatherJSON.flatMap { WeatherData.decode(from: $0) }
|
||||
self.reflection = model.reflectionJSON.flatMap { GuidedReflection.decode(from: $0) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
110
Shared/Models/GuidedReflection.swift
Normal file
110
Shared/Models/GuidedReflection.swift
Normal file
@@ -0,0 +1,110 @@
|
||||
//
|
||||
// GuidedReflection.swift
|
||||
// Reflect
|
||||
//
|
||||
// Codable model for guided reflection responses, stored as JSON in MoodEntryModel.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - Mood Category
|
||||
|
||||
enum MoodCategory: String, Codable {
|
||||
case positive // great, good → 3 questions
|
||||
case neutral // average → 4 questions
|
||||
case negative // bad, horrible → 4 questions
|
||||
|
||||
init(from mood: Mood) {
|
||||
switch mood {
|
||||
case .great, .good: self = .positive
|
||||
case .average: self = .neutral
|
||||
case .horrible, .bad: self = .negative
|
||||
default: self = .neutral
|
||||
}
|
||||
}
|
||||
|
||||
var questionCount: Int {
|
||||
switch self {
|
||||
case .positive: return 3
|
||||
case .neutral, .negative: return 4
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Guided Reflection
|
||||
|
||||
struct GuidedReflection: Codable, Equatable {
|
||||
|
||||
struct Response: Codable, Equatable, Identifiable {
|
||||
var id: Int // question index (0-based)
|
||||
let question: String
|
||||
var answer: String
|
||||
}
|
||||
|
||||
let moodCategory: MoodCategory
|
||||
var responses: [Response]
|
||||
var completedAt: Date?
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
var isComplete: Bool {
|
||||
responses.count == moodCategory.questionCount &&
|
||||
responses.allSatisfy { !$0.answer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||||
}
|
||||
|
||||
var answeredCount: Int {
|
||||
responses.filter { !$0.answer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }.count
|
||||
}
|
||||
|
||||
var totalQuestions: Int {
|
||||
moodCategory.questionCount
|
||||
}
|
||||
|
||||
// MARK: - Factory
|
||||
|
||||
static func createNew(for mood: Mood) -> GuidedReflection {
|
||||
let category = MoodCategory(from: mood)
|
||||
let questionTexts = questions(for: category)
|
||||
let responses = questionTexts.enumerated().map { index, question in
|
||||
Response(id: index, question: question, answer: "")
|
||||
}
|
||||
return GuidedReflection(moodCategory: category, responses: responses, completedAt: nil)
|
||||
}
|
||||
|
||||
static func questions(for category: MoodCategory) -> [String] {
|
||||
switch category {
|
||||
case .positive:
|
||||
return [
|
||||
String(localized: "guided_reflection_positive_q1"),
|
||||
String(localized: "guided_reflection_positive_q2"),
|
||||
String(localized: "guided_reflection_positive_q3"),
|
||||
]
|
||||
case .neutral:
|
||||
return [
|
||||
String(localized: "guided_reflection_neutral_q1"),
|
||||
String(localized: "guided_reflection_neutral_q2"),
|
||||
String(localized: "guided_reflection_neutral_q3"),
|
||||
String(localized: "guided_reflection_neutral_q4"),
|
||||
]
|
||||
case .negative:
|
||||
return [
|
||||
String(localized: "guided_reflection_negative_q1"),
|
||||
String(localized: "guided_reflection_negative_q2"),
|
||||
String(localized: "guided_reflection_negative_q3"),
|
||||
String(localized: "guided_reflection_negative_q4"),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - JSON Helpers
|
||||
|
||||
func encode() -> String? {
|
||||
guard let data = try? JSONEncoder().encode(self) else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
static func decode(from json: String) -> GuidedReflection? {
|
||||
guard let data = json.data(using: .utf8) else { return nil }
|
||||
return try? JSONDecoder().decode(GuidedReflection.self, from: data)
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,9 @@ final class MoodEntryModel {
|
||||
// Weather
|
||||
var weatherJSON: String?
|
||||
|
||||
// Guided Reflection
|
||||
var reflectionJSON: String?
|
||||
|
||||
// Computed properties
|
||||
var mood: Mood {
|
||||
Mood(rawValue: moodValue) ?? .missing
|
||||
@@ -62,7 +65,8 @@ final class MoodEntryModel {
|
||||
canDelete: Bool = true,
|
||||
notes: String? = nil,
|
||||
photoID: UUID? = nil,
|
||||
weatherJSON: String? = nil
|
||||
weatherJSON: String? = nil,
|
||||
reflectionJSON: String? = nil
|
||||
) {
|
||||
self.forDate = forDate
|
||||
self.moodValue = mood.rawValue
|
||||
@@ -74,6 +78,7 @@ final class MoodEntryModel {
|
||||
self.notes = notes
|
||||
self.photoID = photoID
|
||||
self.weatherJSON = weatherJSON
|
||||
self.reflectionJSON = reflectionJSON
|
||||
}
|
||||
|
||||
// Convenience initializer for raw values
|
||||
@@ -87,7 +92,8 @@ final class MoodEntryModel {
|
||||
canDelete: Bool = true,
|
||||
notes: String? = nil,
|
||||
photoID: UUID? = nil,
|
||||
weatherJSON: String? = nil
|
||||
weatherJSON: String? = nil,
|
||||
reflectionJSON: String? = nil
|
||||
) {
|
||||
self.forDate = forDate
|
||||
self.moodValue = moodValue
|
||||
@@ -99,5 +105,6 @@ final class MoodEntryModel {
|
||||
self.notes = notes
|
||||
self.photoID = photoID
|
||||
self.weatherJSON = weatherJSON
|
||||
self.reflectionJSON = reflectionJSON
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,18 @@ extension DataController {
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Guided Reflection
|
||||
|
||||
@discardableResult
|
||||
func updateReflection(forDate date: Date, reflectionJSON: String?) -> Bool {
|
||||
guard let entry = getEntry(byDate: date) else { return false }
|
||||
entry.reflectionJSON = reflectionJSON
|
||||
saveAndRunDataListeners()
|
||||
let count = reflectionJSON.flatMap { GuidedReflection.decode(from: $0) }?.answeredCount ?? 0
|
||||
AnalyticsManager.shared.track(.reflectionCompleted(answeredCount: count))
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Photo
|
||||
|
||||
@discardableResult
|
||||
|
||||
@@ -54,7 +54,7 @@ class ExportService {
|
||||
// MARK: - CSV Export
|
||||
|
||||
func generateCSV(entries: [MoodEntryModel]) -> String {
|
||||
var csv = "Date,Mood,Mood Value,Notes,Weekday,Entry Type,Timestamp\n"
|
||||
var csv = "Date,Mood,Mood Value,Notes,Reflection,Weekday,Entry Type,Timestamp\n"
|
||||
|
||||
let sortedEntries = entries.sorted { $0.forDate > $1.forDate }
|
||||
|
||||
@@ -63,11 +63,14 @@ class ExportService {
|
||||
let mood = entry.mood.widgetDisplayName
|
||||
let moodValue = entry.moodValue + 1 // 1-5 scale
|
||||
let notes = escapeCSV(entry.notes ?? "")
|
||||
let reflectionText = entry.reflectionJSON
|
||||
.flatMap { GuidedReflection.decode(from: $0) }
|
||||
.map { $0.responses.filter { !$0.answer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }.map { "Q: \($0.question) A: \($0.answer)" }.joined(separator: " | ") } ?? ""
|
||||
let weekday = weekdayName(from: entry.weekDay)
|
||||
let entryType = EntryType(rawValue: entry.entryType)?.description ?? "Unknown"
|
||||
let timestamp = isoFormatter.string(from: entry.timestamp)
|
||||
|
||||
csv += "\(date),\(mood),\(moodValue),\(notes),\(weekday),\(entryType),\(timestamp)\n"
|
||||
csv += "\(date),\(mood),\(moodValue),\(notes),\(escapeCSV(reflectionText)),\(weekday),\(entryType),\(timestamp)\n"
|
||||
}
|
||||
|
||||
return csv
|
||||
|
||||
@@ -213,6 +213,18 @@ final class ReportPDFGenerator {
|
||||
<td>\(escapeHTML(weatherStr))</td>
|
||||
</tr>
|
||||
"""
|
||||
|
||||
if let reflection = entry.reflection, reflection.answeredCount > 0 {
|
||||
rows += """
|
||||
<tr class="reflection-row">
|
||||
<td colspan="4">
|
||||
<div class="reflection-block">
|
||||
\(formatReflectionHTML(reflection))
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
return """
|
||||
@@ -261,6 +273,13 @@ final class ReportPDFGenerator {
|
||||
}
|
||||
}
|
||||
|
||||
private func formatReflectionHTML(_ reflection: GuidedReflection) -> String {
|
||||
reflection.responses
|
||||
.filter { !$0.answer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||||
.map { "<p class=\"reflection-q\">\(escapeHTML($0.question))</p><p class=\"reflection-a\">\(escapeHTML($0.answer))</p>" }
|
||||
.joined()
|
||||
}
|
||||
|
||||
private func escapeHTML(_ string: String) -> String {
|
||||
string
|
||||
.replacingOccurrences(of: "&", with: "&")
|
||||
@@ -396,6 +415,10 @@ final class ReportPDFGenerator {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
.stats { font-size: 10pt; color: #666; margin-bottom: 6px; }
|
||||
.reflection-row td { border-bottom: none; padding-top: 0; }
|
||||
.reflection-block { background: #f9f7ff; padding: 8px 12px; margin: 4px 0 8px 0; border-radius: 4px; }
|
||||
.reflection-q { font-style: italic; font-size: 9pt; color: #666; margin-bottom: 2px; }
|
||||
.reflection-a { font-size: 10pt; color: #333; margin-bottom: 6px; }
|
||||
.page-break { page-break-before: always; }
|
||||
.no-page-break { page-break-inside: avoid; }
|
||||
.footer {
|
||||
|
||||
297
Shared/Views/GuidedReflectionView.swift
Normal file
297
Shared/Views/GuidedReflectionView.swift
Normal file
@@ -0,0 +1,297 @@
|
||||
//
|
||||
// GuidedReflectionView.swift
|
||||
// Reflect
|
||||
//
|
||||
// Card-step guided reflection sheet — one question at a time.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct GuidedReflectionView: View {
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
|
||||
@AppStorage(UserDefaultsStore.Keys.moodImages.rawValue, store: GroupUserDefaults.groupDefaults) private var imagePack: MoodImages = .FontAwesome
|
||||
@AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default
|
||||
|
||||
let entry: MoodEntryModel
|
||||
|
||||
@State private var reflection: GuidedReflection
|
||||
@State private var currentStep: Int = 0
|
||||
@State private var isSaving = false
|
||||
@State private var showDiscardAlert = false
|
||||
@FocusState private var isTextFieldFocused: Bool
|
||||
|
||||
/// Snapshot of the initial state to detect unsaved changes
|
||||
private let initialReflection: GuidedReflection
|
||||
|
||||
private let maxCharacters = 500
|
||||
|
||||
private var textColor: Color { theme.currentTheme.labelColor }
|
||||
|
||||
private var totalSteps: Int { reflection.totalQuestions }
|
||||
|
||||
private var hasUnsavedChanges: Bool {
|
||||
reflection != initialReflection
|
||||
}
|
||||
|
||||
init(entry: MoodEntryModel) {
|
||||
self.entry = entry
|
||||
let existing = entry.reflectionJSON.flatMap { GuidedReflection.decode(from: $0) }
|
||||
?? GuidedReflection.createNew(for: entry.mood)
|
||||
self._reflection = State(initialValue: existing)
|
||||
self.initialReflection = existing
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
// Entry header
|
||||
entryHeader
|
||||
.padding()
|
||||
.background(Color(.systemGray6))
|
||||
|
||||
Divider()
|
||||
|
||||
// Progress dots
|
||||
progressDots
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
// Question + answer area
|
||||
questionContent
|
||||
.padding(.horizontal)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Navigation buttons
|
||||
navigationButtons
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle(String(localized: "guided_reflection_title"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.accessibilityIdentifier(AccessibilityID.GuidedReflection.sheet)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(String(localized: "Cancel")) {
|
||||
if hasUnsavedChanges {
|
||||
showDiscardAlert = true
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityID.GuidedReflection.cancelButton)
|
||||
}
|
||||
|
||||
ToolbarItemGroup(placement: .keyboard) {
|
||||
Spacer()
|
||||
Button("Done") {
|
||||
isTextFieldFocused = false
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert(
|
||||
String(localized: "guided_reflection_unsaved_title"),
|
||||
isPresented: $showDiscardAlert
|
||||
) {
|
||||
Button(String(localized: "guided_reflection_discard"), role: .destructive) {
|
||||
dismiss()
|
||||
}
|
||||
Button(String(localized: "Cancel"), role: .cancel) { }
|
||||
} message: {
|
||||
Text(String(localized: "guided_reflection_unsaved_message"))
|
||||
}
|
||||
.trackScreen(.guidedReflection)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Entry Header
|
||||
|
||||
private var entryHeader: some View {
|
||||
HStack(spacing: 12) {
|
||||
Circle()
|
||||
.fill(moodTint.color(forMood: entry.mood).opacity(0.2))
|
||||
.frame(width: 50, height: 50)
|
||||
.overlay(
|
||||
imagePack.icon(forMood: entry.mood)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 28, height: 28)
|
||||
.foregroundColor(moodTint.color(forMood: entry.mood))
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(entry.forDate, format: .dateTime.weekday(.wide).month().day().year())
|
||||
.font(.headline)
|
||||
.foregroundColor(textColor)
|
||||
|
||||
Text(entry.moodString)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(moodTint.color(forMood: entry.mood))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Progress Dots
|
||||
|
||||
private var progressDots: some View {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(0..<totalSteps, id: \.self) { index in
|
||||
Circle()
|
||||
.fill(index == currentStep ? moodTint.color(forMood: entry.mood) : Color(.systemGray4))
|
||||
.frame(width: 10, height: 10)
|
||||
}
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityID.GuidedReflection.progressDots)
|
||||
}
|
||||
|
||||
// MARK: - Question Content
|
||||
|
||||
private var questionContent: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if currentStep < reflection.responses.count {
|
||||
let response = reflection.responses[currentStep]
|
||||
|
||||
Text(response.question)
|
||||
.font(.title3)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(textColor)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.accessibilityIdentifier(AccessibilityID.GuidedReflection.questionLabel(step: currentStep))
|
||||
.id("question_\(currentStep)")
|
||||
|
||||
TextEditor(text: $reflection.responses[currentStep].answer)
|
||||
.focused($isTextFieldFocused)
|
||||
.frame(minHeight: 120, maxHeight: 200)
|
||||
.scrollContentBackground(.hidden)
|
||||
.padding(12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color(.systemBackground))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(Color(.systemGray4), lineWidth: 1)
|
||||
)
|
||||
.onChange(of: reflection.responses[currentStep].answer) { _, newValue in
|
||||
if newValue.count > maxCharacters {
|
||||
reflection.responses[currentStep].answer = String(newValue.prefix(maxCharacters))
|
||||
}
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityID.GuidedReflection.textEditor)
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
Text("\(reflection.responses[currentStep].answer.count)/\(maxCharacters)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(
|
||||
reflection.responses[currentStep].answer.count >= maxCharacters ? .red : .secondary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Navigation Buttons
|
||||
|
||||
private var navigationButtons: some View {
|
||||
HStack {
|
||||
// Back button
|
||||
if currentStep > 0 {
|
||||
Button {
|
||||
navigateBack()
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "chevron.left")
|
||||
Text(String(localized: "guided_reflection_back"))
|
||||
}
|
||||
.font(.body)
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityID.GuidedReflection.backButton)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Next / Save button
|
||||
if currentStep < totalSteps - 1 {
|
||||
Button {
|
||||
navigateForward()
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Text(String(localized: "guided_reflection_next"))
|
||||
Image(systemName: "chevron.right")
|
||||
}
|
||||
.font(.body)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityID.GuidedReflection.nextButton)
|
||||
} else {
|
||||
Button {
|
||||
saveReflection()
|
||||
} label: {
|
||||
Text(String(localized: "guided_reflection_save"))
|
||||
.font(.body)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
.disabled(isSaving)
|
||||
.accessibilityIdentifier(AccessibilityID.GuidedReflection.saveButton)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Navigation
|
||||
|
||||
private func navigateForward() {
|
||||
isTextFieldFocused = false
|
||||
let animate = !UIAccessibility.isReduceMotionEnabled
|
||||
if animate {
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
currentStep += 1
|
||||
}
|
||||
} else {
|
||||
currentStep += 1
|
||||
}
|
||||
focusTextFieldDelayed()
|
||||
}
|
||||
|
||||
private func navigateBack() {
|
||||
isTextFieldFocused = false
|
||||
let animate = !UIAccessibility.isReduceMotionEnabled
|
||||
if animate {
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
currentStep -= 1
|
||||
}
|
||||
} else {
|
||||
currentStep -= 1
|
||||
}
|
||||
focusTextFieldDelayed()
|
||||
}
|
||||
|
||||
private func focusTextFieldDelayed() {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
isTextFieldFocused = true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Save
|
||||
|
||||
private func saveReflection() {
|
||||
isSaving = true
|
||||
|
||||
if reflection.isComplete {
|
||||
reflection.completedAt = Date()
|
||||
}
|
||||
|
||||
let json = reflection.encode()
|
||||
let success = DataController.shared.updateReflection(forDate: entry.forDate, reflectionJSON: json)
|
||||
|
||||
if success {
|
||||
dismiss()
|
||||
} else {
|
||||
isSaving = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -322,7 +322,12 @@ class ReportsViewModel: ObservableObject {
|
||||
let day = entry.date.formatted(.dateTime.weekday(.abbreviated))
|
||||
let mood = entry.mood.widgetDisplayName
|
||||
let notes = entry.notes ?? "no notes"
|
||||
return "\(day): \(mood) (\(notes))"
|
||||
let reflectionSummary = entry.reflection?.responses
|
||||
.filter { !$0.answer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||||
.map { "\($0.question): \(String($0.answer.prefix(150)))" }
|
||||
.joined(separator: " | ") ?? ""
|
||||
let reflectionStr = reflectionSummary.isEmpty ? "" : " [reflection: \(reflectionSummary)]"
|
||||
return "\(day): \(mood) (\(notes))\(reflectionStr)"
|
||||
}.joined(separator: "\n")
|
||||
|
||||
let prompt = """
|
||||
|
||||
@@ -158,6 +158,7 @@ struct EntryDetailView: View {
|
||||
@State private var showDeleteConfirmation = false
|
||||
@State private var showFullScreenPhoto = false
|
||||
@State private var selectedPhotoItem: PhotosPickerItem?
|
||||
@State private var showReflectionFlow = false
|
||||
@State private var selectedMood: Mood?
|
||||
|
||||
private var currentMood: Mood {
|
||||
@@ -184,6 +185,11 @@ struct EntryDetailView: View {
|
||||
// Mood section
|
||||
moodSection
|
||||
|
||||
// Guided reflection section
|
||||
if currentMood != .missing && currentMood != .placeholder {
|
||||
reflectionSection
|
||||
}
|
||||
|
||||
// Notes section
|
||||
notesSection
|
||||
|
||||
@@ -218,6 +224,9 @@ struct EntryDetailView: View {
|
||||
.sheet(isPresented: $showNoteEditor) {
|
||||
NoteEditorView(entry: entry)
|
||||
}
|
||||
.sheet(isPresented: $showReflectionFlow) {
|
||||
GuidedReflectionView(entry: entry)
|
||||
}
|
||||
.alert("Delete Entry", isPresented: $showDeleteConfirmation) {
|
||||
Button("Delete", role: .destructive) {
|
||||
onDelete()
|
||||
@@ -417,6 +426,74 @@ struct EntryDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var reflectionSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text(String(localized: "guided_reflection_title"))
|
||||
.font(.headline)
|
||||
.foregroundColor(textColor)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
AnalyticsManager.shared.track(.reflectionStarted)
|
||||
showReflectionFlow = true
|
||||
} label: {
|
||||
Text(entry.reflectionJSON != nil
|
||||
? String(localized: "guided_reflection_edit")
|
||||
: String(localized: "guided_reflection_begin"))
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
AnalyticsManager.shared.track(.reflectionStarted)
|
||||
showReflectionFlow = true
|
||||
} label: {
|
||||
HStack {
|
||||
if let json = entry.reflectionJSON,
|
||||
let reflection = GuidedReflection.decode(from: json),
|
||||
reflection.answeredCount > 0 {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(reflection.responses.first(where: {
|
||||
!$0.answer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
})?.answer ?? "")
|
||||
.font(.body)
|
||||
.foregroundColor(textColor)
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineLimit(3)
|
||||
|
||||
Text(String(localized: "guided_reflection_answered_count \(reflection.answeredCount) \(reflection.totalQuestions)"))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "sparkles")
|
||||
.foregroundStyle(.secondary)
|
||||
Text(String(localized: "guided_reflection_empty_prompt"))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color(.systemBackground))
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
private func weatherSection(_ weatherData: WeatherData) -> some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Weather")
|
||||
|
||||
Reference in New Issue
Block a user