Files
Reflect/ReflectWidget/ReflectLiveActivity.swift
Trey T 1f040ab676 v1.1 polish: accessibility, error logging, localization, and code quality sweep
- Wrap 30+ production print() statements in #if DEBUG guards across 18 files
- Add VoiceOver labels, hints, and traits to Watch app, Live Activities, widgets
- Add .accessibilityAddTraits(.isButton) to 15+ onTapGesture views
- Add text alternatives for color-only indicators (progress dots, mood circles)
- Localize raw string literals in NoteEditorView, EntryDetailView, widgets
- Replace 25+ silent try? with do/catch + AppLogger error logging
- Replace hardcoded font sizes with semantic Dynamic Type fonts
- Fix FIXME in IconPickerView (log icon change errors)
- Extract magic animation delays to named constants across 8 files
- Add widget empty state "Log your first mood!" messaging
- Hide decorative images from VoiceOver, add labels to ColorPickers
- Remove stale TODO in Color+Codable (alpha change deferred for migration)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:09:14 -05:00

280 lines
10 KiB
Swift

//
// ReflectLiveActivity.swift
// ReflectWidget
//
// Live Activity for mood streak tracking (Dynamic Island + Lock Screen)
//
import WidgetKit
import SwiftUI
import ActivityKit
// MARK: - Live Activity Widget
// Note: MoodStreakAttributes is defined in MoodStreakActivity.swift (Shared folder)
struct MoodStreakLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: MoodStreakAttributes.self) { context in
// Lock Screen / StandBy view
MoodStreakLockScreenView(context: context)
} dynamicIsland: { context in
DynamicIsland {
// Expanded view
DynamicIslandExpandedRegion(.leading) {
HStack(spacing: 8) {
Image(systemName: "flame.fill")
.foregroundColor(.orange)
.accessibilityHidden(true)
Text("\(context.state.currentStreak)")
.font(.title2.bold())
}
.accessibilityElement(children: .combine)
.accessibilityLabel(String(localized: "\(context.state.currentStreak) day streak"))
}
DynamicIslandExpandedRegion(.trailing) {
if context.state.hasLoggedToday {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
.font(.title2)
.accessibilityLabel(String(localized: "Mood logged today"))
} else {
Text("Log now")
.font(.caption)
.foregroundColor(.secondary)
}
}
DynamicIslandExpandedRegion(.center) {
Text(context.state.hasLoggedToday ? "Streak: \(context.state.currentStreak) days" : (context.state.currentStreak > 0 ? "Don't break your streak!" : "Start your streak!"))
.font(.headline)
}
DynamicIslandExpandedRegion(.bottom) {
if !context.state.hasLoggedToday {
Text("Voting closes at midnight")
.font(.caption)
.foregroundColor(.secondary)
} else {
HStack {
Circle()
.fill(Color(hex: context.state.lastMoodColor))
.frame(width: 20, height: 20)
.accessibilityHidden(true)
Text("Today: \(context.state.lastMoodLogged)")
.font(.subheadline)
}
.accessibilityElement(children: .combine)
}
}
} compactLeading: {
Image(systemName: "flame.fill")
.foregroundColor(.orange)
.accessibilityLabel(String(localized: "Streak"))
} compactTrailing: {
Text("\(context.state.currentStreak)")
.font(.caption.bold())
.accessibilityLabel(String(localized: "\(context.state.currentStreak) days"))
} minimal: {
Image(systemName: "flame.fill")
.foregroundColor(.orange)
.accessibilityLabel(String(localized: "Mood streak"))
}
}
}
}
// MARK: - Lock Screen View
struct MoodStreakLockScreenView: View {
let context: ActivityViewContext<MoodStreakAttributes>
var body: some View {
HStack(spacing: 16) {
// Streak indicator
VStack(spacing: 4) {
Image(systemName: "flame.fill")
.font(.title)
.foregroundColor(.orange)
.accessibilityHidden(true)
Text("\(context.state.currentStreak)")
.font(.title.bold())
Text("day streak")
.font(.caption)
.foregroundColor(.secondary)
}
.accessibilityElement(children: .combine)
.accessibilityLabel(String(localized: "\(context.state.currentStreak) day streak"))
Divider()
.frame(height: 50)
// Status
VStack(alignment: .leading, spacing: 8) {
if context.state.hasLoggedToday {
HStack(spacing: 8) {
Circle()
.fill(Color(hex: context.state.lastMoodColor))
.frame(width: 24, height: 24)
.accessibilityHidden(true)
VStack(alignment: .leading) {
Text("Today's mood")
.font(.caption)
.foregroundColor(.secondary)
Text(context.state.lastMoodLogged)
.font(.headline)
}
}
.accessibilityElement(children: .combine)
} else {
VStack(alignment: .leading) {
Text(context.state.currentStreak > 0 ? "Don't break your streak!" : "Start your streak!")
.font(.headline)
Text("Tap to log your mood")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
Spacer()
}
.padding()
.activityBackgroundTint(Color(.systemBackground).opacity(0.8))
}
}
// MARK: - Preview Sample Data
extension MoodStreakAttributes {
static var preview: MoodStreakAttributes {
MoodStreakAttributes(startDate: Date())
}
}
extension MoodStreakAttributes.ContentState {
static var notLogged: MoodStreakAttributes.ContentState {
MoodStreakAttributes.ContentState(
currentStreak: 7,
lastMoodLogged: "None",
lastMoodColor: "#888888",
hasLoggedToday: false,
votingWindowEnd: Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: Date()) ?? Date()
)
}
static var loggedGreat: MoodStreakAttributes.ContentState {
MoodStreakAttributes.ContentState(
currentStreak: 15,
lastMoodLogged: "Great",
lastMoodColor: MoodTints.Default.color(forMood: .great).toHex() ?? "#4CAF50",
hasLoggedToday: true,
votingWindowEnd: Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: Date()) ?? Date()
)
}
static var loggedGood: MoodStreakAttributes.ContentState {
MoodStreakAttributes.ContentState(
currentStreak: 30,
lastMoodLogged: "Good",
lastMoodColor: MoodTints.Default.color(forMood: .good).toHex() ?? "#8BC34A",
hasLoggedToday: true,
votingWindowEnd: Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: Date()) ?? Date()
)
}
static var loggedAverage: MoodStreakAttributes.ContentState {
MoodStreakAttributes.ContentState(
currentStreak: 10,
lastMoodLogged: "Average",
lastMoodColor: MoodTints.Default.color(forMood: .average).toHex() ?? "#FFC107",
hasLoggedToday: true,
votingWindowEnd: Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: Date()) ?? Date()
)
}
static var loggedBad: MoodStreakAttributes.ContentState {
MoodStreakAttributes.ContentState(
currentStreak: 5,
lastMoodLogged: "Bad",
lastMoodColor: MoodTints.Default.color(forMood: .bad).toHex() ?? "#FF9800",
hasLoggedToday: true,
votingWindowEnd: Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: Date()) ?? Date()
)
}
static var loggedHorrible: MoodStreakAttributes.ContentState {
MoodStreakAttributes.ContentState(
currentStreak: 3,
lastMoodLogged: "Horrible",
lastMoodColor: MoodTints.Default.color(forMood: .horrible).toHex() ?? "#F44336",
hasLoggedToday: true,
votingWindowEnd: Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: Date()) ?? Date()
)
}
}
// MARK: - Live Activity Previews
#Preview("Lock Screen - Not Logged", as: .content, using: MoodStreakAttributes.preview) {
MoodStreakLiveActivity()
} contentStates: {
MoodStreakAttributes.ContentState.notLogged
}
#Preview("Lock Screen - Great", as: .content, using: MoodStreakAttributes.preview) {
MoodStreakLiveActivity()
} contentStates: {
MoodStreakAttributes.ContentState.loggedGreat
}
#Preview("Lock Screen - Good", as: .content, using: MoodStreakAttributes.preview) {
MoodStreakLiveActivity()
} contentStates: {
MoodStreakAttributes.ContentState.loggedGood
}
#Preview("Lock Screen - Average", as: .content, using: MoodStreakAttributes.preview) {
MoodStreakLiveActivity()
} contentStates: {
MoodStreakAttributes.ContentState.loggedAverage
}
#Preview("Lock Screen - Bad", as: .content, using: MoodStreakAttributes.preview) {
MoodStreakLiveActivity()
} contentStates: {
MoodStreakAttributes.ContentState.loggedBad
}
#Preview("Lock Screen - Horrible", as: .content, using: MoodStreakAttributes.preview) {
MoodStreakLiveActivity()
} contentStates: {
MoodStreakAttributes.ContentState.loggedHorrible
}
// MARK: - Dynamic Island Previews
#Preview("Dynamic Island Expanded - Not Logged", as: .dynamicIsland(.expanded), using: MoodStreakAttributes.preview) {
MoodStreakLiveActivity()
} contentStates: {
MoodStreakAttributes.ContentState.notLogged
}
#Preview("Dynamic Island Expanded - Logged", as: .dynamicIsland(.expanded), using: MoodStreakAttributes.preview) {
MoodStreakLiveActivity()
} contentStates: {
MoodStreakAttributes.ContentState.loggedGreat
}
#Preview("Dynamic Island Compact", as: .dynamicIsland(.compact), using: MoodStreakAttributes.preview) {
MoodStreakLiveActivity()
} contentStates: {
MoodStreakAttributes.ContentState.loggedGreat
}
#Preview("Dynamic Island Minimal", as: .dynamicIsland(.minimal), using: MoodStreakAttributes.preview) {
MoodStreakLiveActivity()
} contentStates: {
MoodStreakAttributes.ContentState.loggedGreat
}