Add Apple platform features and UX improvements

- Add HealthKit State of Mind sync for mood entries
- Add Live Activity with streak display and rating time window
- Add App Shortcuts/Siri integration for voice mood logging
- Add TipKit hints for feature discovery
- Add centralized MoodLogger for consistent side effects
- Add reminder time setting in Settings with time picker
- Fix duplicate notifications when changing reminder time
- Fix Live Activity streak showing 0 when not yet rated today
- Fix slow tap response in entry detail mood selection
- Update widget timeline to refresh at rating time
- Sync widgets when reminder time changes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-19 17:21:55 -06:00
parent e123df1790
commit 440b04159e
27 changed files with 1577 additions and 81 deletions

View File

@@ -9,6 +9,127 @@ import WidgetKit
import SwiftUI
import Intents
import SwiftData
import ActivityKit
import AppIntents
// 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)
Text("\(context.state.currentStreak)")
.font(.title2.bold())
}
}
DynamicIslandExpandedRegion(.trailing) {
if context.state.hasLoggedToday {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
.font(.title2)
} else {
Text("Log now")
.font(.caption)
.foregroundColor(.secondary)
}
}
DynamicIslandExpandedRegion(.center) {
Text(context.state.hasLoggedToday ? "Streak: \(context.state.currentStreak) days" : "Don't break 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)
Text("Today: \(context.state.lastMoodLogged)")
.font(.subheadline)
}
}
}
} compactLeading: {
Image(systemName: "flame.fill")
.foregroundColor(.orange)
} compactTrailing: {
Text("\(context.state.currentStreak)")
.font(.caption.bold())
} minimal: {
Image(systemName: "flame.fill")
.foregroundColor(.orange)
}
}
}
}
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)
Text("\(context.state.currentStreak)")
.font(.title.bold())
Text("day streak")
.font(.caption)
.foregroundColor(.secondary)
}
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)
VStack(alignment: .leading) {
Text("Today's mood")
.font(.caption)
.foregroundColor(.secondary)
Text(context.state.lastMoodLogged)
.font(.headline)
}
}
} else {
VStack(alignment: .leading) {
Text("Don't break your streak!")
.font(.headline)
Text("Tap to log your mood")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
Spacer()
}
.padding()
.activityBackgroundTint(Color(.systemBackground).opacity(0.8))
}
}
class WatchTimelineView: Identifiable {
let id = UUID()
@@ -355,12 +476,13 @@ struct LargeWidgetView: View {
struct FeelsGraphicWidgetEntryView : View {
@Environment(\.sizeCategory) var sizeCategory
@Environment(\.widgetFamily) var family
var entry: Provider.Entry
@ViewBuilder
var body: some View {
SmallGraphicWidgetView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
}
@@ -395,12 +517,13 @@ struct SmallGraphicWidgetView: View {
struct FeelsIconWidgetEntryView : View {
@Environment(\.sizeCategory) var sizeCategory
@Environment(\.widgetFamily) var family
var entry: Provider.Entry
@ViewBuilder
var body: some View {
SmallIconView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
}
@@ -526,6 +649,31 @@ struct FeelsBundle: WidgetBundle {
FeelsGraphicWidget()
FeelsIconWidget()
FeelsVoteWidget()
FeelsMoodControlWidget()
MoodStreakLiveActivity()
}
}
// MARK: - Control Center Widget
struct FeelsMoodControlWidget: ControlWidget {
var body: some ControlWidgetConfiguration {
StaticControlConfiguration(kind: "FeelsMoodControl") {
ControlWidgetButton(action: OpenFeelsIntent()) {
Label("Log Mood", systemImage: "face.smiling")
}
}
.displayName("Log Mood")
.description("Open Feels to log your mood")
}
}
struct OpenFeelsIntent: AppIntent {
static var title: LocalizedStringResource = "Open Feels"
static var description = IntentDescription("Open the Feels app to log your mood")
static var openAppWhenRun: Bool = true
func perform() async throws -> some IntentResult {
return .result()
}
}