Files
Reflect/FeelsWidget2/FeelsWidget.swift
Trey t aaaf04f05e Migrate from Core Data to SwiftData
- Replace Core Data with SwiftData for iOS 18+
- Create MoodEntryModel as @Model class replacing MoodEntry entity
- Create SharedModelContainer for App Group container sharing
- Create DataController with CRUD extensions replacing PersistenceController
- Update all views and view models to use MoodEntryModel
- Update widget extension to use SwiftData
- Remove old Core Data files (Persistence*.swift, .xcdatamodeld)
- Add EntryType enum with all entry type cases
- Fix widget label truncation with proper spacing and text scaling

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 15:08:05 -06:00

509 lines
20 KiB
Swift

//
// FeelsWidget.swift
// FeelsWidget
//
// Created by Trey Tartt on 1/7/22.
//
import WidgetKit
import SwiftUI
import Intents
import SwiftData
class WatchTimelineView: Identifiable {
let id = UUID()
let image: Image
let graphic: Image
let date: Date
let color: Color
let secondaryColor: Color
init(image: Image, graphic: Image, date: Date, color: Color, secondaryColor: Color) {
self.image = image
self.date = date
self.color = color
self.graphic = graphic
self.secondaryColor = secondaryColor
}
}
struct TimeLineCreator {
@MainActor static func createViews(daysBack: Int) -> [WatchTimelineView] {
var timeLineView = [WatchTimelineView]()
let latestDayToShow = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding())
let dates = Array(0...daysBack).map({
Calendar.current.date(byAdding: .day, value: -$0, to: latestDayToShow)!
})
for date in dates {
let dayStart = Calendar.current.startOfDay(for: date)
let dayEnd = Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: dayStart)!
let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable()
let moodImages: MoodImagable.Type = UserDefaultsStore.moodMoodImagable()
if let todayEntry = DataController.shared.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first {
timeLineView.append(WatchTimelineView(image: moodImages.icon(forMood: todayEntry.mood),
graphic: moodImages.icon(forMood: todayEntry.mood),
date: dayStart,
color: moodTint.color(forMood: todayEntry.mood),
secondaryColor: moodTint.secondary(forMood: todayEntry.mood)))
} else {
timeLineView.append(WatchTimelineView(image: moodImages.icon(forMood: .missing),
graphic: moodImages.icon(forMood: .missing),
date: dayStart,
color: moodTint.color(forMood: .missing),
secondaryColor: moodTint.secondary(forMood: .missing)))
}
}
timeLineView = timeLineView.sorted(by: { $0.date > $1.date })
return timeLineView
}
/// Creates sample preview data for widget picker - shows what widget looks like with mood data
static func createSampleViews(count: Int) -> [WatchTimelineView] {
var timeLineView = [WatchTimelineView]()
let sampleMoods: [Mood] = [.great, .good, .average, .good, .great, .average, .bad, .good, .great, .good, .average]
let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable()
let moodImages: MoodImagable.Type = UserDefaultsStore.moodMoodImagable()
for i in 0..<count {
let date = Calendar.current.date(byAdding: .day, value: -i, to: Date())!
let dayStart = Calendar.current.startOfDay(for: date)
let mood = sampleMoods[i % sampleMoods.count]
timeLineView.append(WatchTimelineView(
image: moodImages.icon(forMood: mood),
graphic: moodImages.icon(forMood: mood),
date: dayStart,
color: moodTint.color(forMood: mood),
secondaryColor: moodTint.secondary(forMood: mood)
))
}
return timeLineView
}
}
struct Provider: IntentTimelineProvider {
let timeLineCreator = TimeLineCreator()
/*
placeholder for widget, no data
gets redacted auto - uses sample data for widget picker preview
*/
func placeholder(in context: Context) -> SimpleEntry {
return SimpleEntry(date: Date(),
configuration: ConfigurationIntent(),
timeLineViews: TimeLineCreator.createSampleViews(count: 10))
}
@MainActor func getSnapshot(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (SimpleEntry) -> ()) {
// Use sample data for widget picker preview, real data otherwise
let timeLineViews: [WatchTimelineView]
if context.isPreview {
timeLineViews = TimeLineCreator.createSampleViews(count: 10)
} else {
timeLineViews = Array(TimeLineCreator.createViews(daysBack: 11).prefix(10))
}
let entry = SimpleEntry(date: Date(),
configuration: ConfigurationIntent(),
timeLineViews: timeLineViews)
completion(entry)
}
func getTimeline(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
let entry = SimpleEntry(date: Calendar.current.date(byAdding: .second, value: 15, to: Date())!,
configuration: ConfigurationIntent(),
timeLineViews: nil)
let midNightEntry = SimpleEntry(date: Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: Date())!,
configuration: ConfigurationIntent(),
timeLineViews: nil)
let date = Calendar.current.date(byAdding: .second, value: 10, to: Date())!
let timeline = Timeline(entries: [entry, midNightEntry], policy: .after(date))
completion(timeline)
}
}
struct SimpleEntry: TimelineEntry {
let date: Date
let configuration: ConfigurationIntent
let timeLineViews: [WatchTimelineView]?
let showStats: Bool
init(date: Date, configuration: ConfigurationIntent, timeLineViews: [WatchTimelineView]?, showStats: Bool = false) {
self.date = date
self.configuration = configuration
self.timeLineViews = timeLineViews
self.showStats = showStats
}
}
/**********************************************************/
struct FeelsWidgetEntryView : View {
@Environment(\.sizeCategory) var sizeCategory
@Environment(\.widgetFamily) var family
var entry: Provider.Entry
@ViewBuilder
var body: some View {
ZStack {
Color(UIColor.systemBackground)
switch family {
case .systemSmall:
SmallWidgetView(entry: entry)
case .systemMedium:
MediumWidgetView(entry: entry)
case .systemLarge:
LargeWidgetView(entry: entry)
case .systemExtraLarge:
LargeWidgetView(entry: entry)
@unknown default:
fatalError()
}
}
}
}
struct SmallWidgetView: View {
var entry: Provider.Entry
var timeLineView = [WatchTimelineView]()
init(entry: Provider.Entry) {
self.entry = entry
let realData = TimeLineCreator.createViews(daysBack: 2)
// Check if we have any real mood data (not all missing)
let hasRealData = realData.contains { view in
let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable()
return view.color != moodTint.color(forMood: .missing)
}
timeLineView = hasRealData ? [realData.first!] : [TimeLineCreator.createSampleViews(count: 1).first!]
}
var body: some View {
ZStack {
Color(UIColor.secondarySystemBackground)
HStack {
ForEach(self.timeLineView) { watchView in
EntryCard(timeLineView: watchView)
}
}
.padding()
}
.clipShape(RoundedRectangle(cornerRadius: 25, style: .continuous))
.frame(minHeight: 0, maxHeight: 55)
.padding()
}
}
struct MediumWidgetView: View {
var entry: Provider.Entry
var timeLineView = [WatchTimelineView]()
init(entry: Provider.Entry) {
self.entry = entry
let realData = Array(TimeLineCreator.createViews(daysBack: 6).prefix(5))
// Check if we have any real mood data (not all missing)
let hasRealData = realData.contains { view in
let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable()
return view.color != moodTint.color(forMood: .missing)
}
timeLineView = hasRealData ? realData : TimeLineCreator.createSampleViews(count: 5)
}
var body: some View {
VStack {
Spacer()
TimeHeaderView(startDate: timeLineView.first!.date, endDate: timeLineView.last!.date)
.frame(minWidth: 0, maxWidth: .infinity)
.multilineTextAlignment(.leading)
TimeBodyView(group: timeLineView)
.clipShape(RoundedRectangle(cornerRadius: 25, style: .continuous))
.frame(minHeight: 0, maxHeight: 55)
.padding()
Spacer()
}
}
}
struct LargeWidgetView: View {
var entry: Provider.Entry
var timeLineView = [WatchTimelineView]()
init(entry: Provider.Entry) {
self.entry = entry
let realData = Array(TimeLineCreator.createViews(daysBack: 11).prefix(10))
// Check if we have any real mood data (not all missing)
let hasRealData = realData.contains { view in
let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable()
return view.color != moodTint.color(forMood: .missing)
}
timeLineView = hasRealData ? realData : TimeLineCreator.createSampleViews(count: 10)
}
var firstGroup: ([WatchTimelineView], String) {
return (Array(self.timeLineView.prefix(5)), UUID().uuidString)
}
var secondGroup: ([WatchTimelineView], String) {
return (Array(self.timeLineView.suffix(5)), UUID().uuidString)
}
var body: some View {
VStack {
Spacer()
ForEach([firstGroup, secondGroup], id: \.1) { group in
VStack {
Spacer()
TimeHeaderView(startDate: group.0.first!.date, endDate: group.0.last!.date)
.frame(minWidth: 0, maxWidth: .infinity)
.multilineTextAlignment(.leading)
TimeBodyView(group: group.0)
.clipShape(RoundedRectangle(cornerRadius: 25, style: .continuous))
.frame(minHeight: 0, maxHeight: 55)
.padding()
Spacer()
}
}
Spacer()
}
}
}
/**********************************************************/
struct FeelsGraphicWidgetEntryView : View {
@Environment(\.sizeCategory) var sizeCategory
@Environment(\.widgetFamily) var family
var entry: Provider.Entry
@ViewBuilder
var body: some View {
SmallGraphicWidgetView(entry: entry)
}
}
struct SmallGraphicWidgetView: View {
var entry: Provider.Entry
var timeLineView: [WatchTimelineView]
init(entry: Provider.Entry) {
self.entry = entry
let realData = TimeLineCreator.createViews(daysBack: 2)
// Check if we have any real mood data (not all missing)
let hasRealData = realData.contains { view in
let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable()
return view.color != moodTint.color(forMood: .missing)
}
timeLineView = hasRealData ? realData : TimeLineCreator.createSampleViews(count: 2)
}
var body: some View {
if let first = timeLineView.first {
IconView(iconViewModel: IconViewModel(backgroundImage: first.graphic,
bgColor: first.color,
bgOverlayColor: first.secondaryColor,
centerImage: first.graphic,
innerColor: first.color))
} else {
IconView(iconViewModel: IconViewModel.great)
}
}
}
/**********************************************************/
struct FeelsIconWidgetEntryView : View {
@Environment(\.sizeCategory) var sizeCategory
@Environment(\.widgetFamily) var family
var entry: Provider.Entry
@ViewBuilder
var body: some View {
SmallIconView(entry: entry)
}
}
struct SmallIconView: View {
var entry: Provider.Entry
var body: some View {
GeometryReader { geo in
if let inUseWidget = UserDefaultsStore.getCustomWidgets().first(where: {
$0.inUse == true
}) {
CustomWidgetView(customWidgetModel: inUseWidget)
.frame(width: geo.size.width, height: geo.size.height)
} else {
CustomWidgetView(customWidgetModel: CustomWidgetModel.randomWidget)
.frame(width: geo.size.width, height: geo.size.height)
}
}
}
}
/**********************************************************/
struct TimeHeaderView: View {
let startDate: Date
let endDate: Date
var formatter: DateFormatter {
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .medium
return dateFormatter
}
var body: some View {
HStack {
Text(startDate, formatter: formatter)
.font(.system(.footnote))
Text(" - ")
.font(.system(.footnote))
Text(endDate, formatter: formatter)
.font(.system(.footnote))
}
}
}
struct TimeBodyView: View {
let group: [WatchTimelineView]
var body: some View {
ZStack {
Color(UIColor.secondarySystemBackground)
HStack {
ForEach(group) { watchView in
EntryCard(timeLineView: watchView)
}
}
.padding()
}
}
}
struct EntryCard: View {
var timeLineView: WatchTimelineView
var body: some View {
timeLineView.image
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 50, height: 50, alignment: .center)
.foregroundColor(timeLineView.color)
}
}
@main
struct FeelsBundle: WidgetBundle {
var body: some Widget {
FeelsWidget()
FeelsGraphicWidget()
FeelsIconWidget()
FeelsVoteWidget()
}
}
struct FeelsWidget: Widget {
let kind: String = "FeelsWidget"
var body: some WidgetConfiguration {
IntentConfiguration(kind: kind,
intent: ConfigurationIntent.self,
provider: Provider()) { entry in
FeelsWidgetEntryView(entry: entry)
}
.configurationDisplayName("Feels")
.description("")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
}
}
struct FeelsIconWidget: Widget {
let kind: String = "FeelsIconWidget"
var body: some WidgetConfiguration {
IntentConfiguration(kind: kind,
intent: ConfigurationIntent.self,
provider: Provider()) { entry in
FeelsIconWidgetEntryView(entry: entry)
}
.configurationDisplayName("Feels Icon")
.description("")
.supportedFamilies([.systemSmall])
}
}
struct FeelsGraphicWidget: Widget {
let kind: String = "FeelsGraphicWidget"
var body: some WidgetConfiguration {
IntentConfiguration(kind: kind,
intent: ConfigurationIntent.self,
provider: Provider()) { entry in
FeelsGraphicWidgetEntryView(entry: entry)
}
.configurationDisplayName("Mood Graphic")
.description("")
.supportedFamilies([.systemSmall])
}
}
struct FeelsWidget_Previews: PreviewProvider {
static var previews: some View {
Group {
FeelsGraphicWidgetEntryView(entry: SimpleEntry(date: Date(),
configuration: ConfigurationIntent(),
timeLineViews: [WatchTimelineView(image: HandEmojiMoodImages.icon(forMood: .great),
graphic: HandEmojiMoodImages.icon(forMood: .great),
date: Date(),
color: MoodTints.Neon.color(forMood: .great),
secondaryColor: .white),
WatchTimelineView(image: HandEmojiMoodImages.icon(forMood: .great),
graphic: HandEmojiMoodImages.icon(forMood: .great),
date: Date(),
color: MoodTints.Neon.color(forMood: .great),
secondaryColor: .white)]))
.previewContext(WidgetPreviewContext(family: .systemSmall))
FeelsGraphicWidgetEntryView(entry: SimpleEntry(date: Date(),
configuration: ConfigurationIntent(),
timeLineViews: [WatchTimelineView(image: HandEmojiMoodImages.icon(forMood: .horrible),
graphic: HandEmojiMoodImages.icon(forMood: .horrible),
date: Date(),
color: MoodTints.Neon.color(forMood: .horrible),
secondaryColor: .white),
WatchTimelineView(image: HandEmojiMoodImages.icon(forMood: .horrible),
graphic: HandEmojiMoodImages.icon(forMood: .horrible),
date: Date(),
color: MoodTints.Neon.color(forMood: .horrible),
secondaryColor: .white)]))
.previewContext(WidgetPreviewContext(family: .systemSmall))
// FeelsWidgetEntryView(entry: SimpleEntry(date: Date(),
// configuration: ConfigurationIntent(),
// timeLineViews: FeelsWidget_Previews.data))
// .previewContext(WidgetPreviewContext(family: .systemMedium))
// .environment(\.sizeCategory, .medium)
//
// FeelsWidgetEntryView(entry: SimpleEntry(date: Date(),
// configuration: ConfigurationIntent(),
// timeLineViews: FeelsWidget_Previews.data))
// .previewContext(WidgetPreviewContext(family: .systemLarge))
// .environment(\.sizeCategory, .large)
}
}
}