Files
Reflect/Shared/Services/ExportableWidgetViews.swift
Trey t 0442eab1f8 Rebrand entire project from Feels to Reflect
Complete rename across all bundle IDs, App Groups, CloudKit containers,
StoreKit product IDs, data store filenames, URL schemes, logger subsystems,
Swift identifiers, user-facing strings (7 languages), file names, directory
names, Xcode project, schemes, assets, and documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 11:47:16 -06:00

695 lines
24 KiB
Swift

//
// ExportableWidgetViews.swift
// Reflect
//
// Exportable widget views that match the real WidgetKit widgets pixel-for-pixel.
// These views accept tint/icon configuration as parameters for batch export.
//
#if DEBUG
import SwiftUI
// MARK: - Widget Theme Configuration
/// Configuration for widget export styling
struct WidgetExportConfig {
let moodTint: MoodTintable.Type
let moodImages: MoodImagable.Type
/// Creates sample timeline data for export
func createTimelineData(count: Int) -> [ExportTimelineItem] {
let moods: [Mood] = [.great, .good, .average, .good, .great, .average, .bad, .good, .great, .good]
return (0..<count).map { index in
let mood = moods[index % moods.count]
let date = Calendar.current.date(byAdding: .day, value: -index, to: Date())!
return ExportTimelineItem(
mood: mood,
date: date,
color: moodTint.color(forMood: mood),
image: moodImages.icon(forMood: mood)
)
}
}
}
/// Timeline data item for export
struct ExportTimelineItem: Identifiable {
let id = UUID()
let mood: Mood
let date: Date
let color: Color
let image: Image
}
// MARK: - Exportable Voting View (matches VotingView from WidgetSharedViews.swift)
struct ExportableVotingView: View {
enum Size {
case small
case medium
case large
}
let size: Size
let config: WidgetExportConfig
let promptText: String
var body: some View {
switch size {
case .small:
smallLayout
case .medium:
mediumLayout
case .large:
largeLayout
}
}
// MARK: - Small Widget: 3 over 2 grid centered in 50%|50% vertical split
private var smallLayout: some View {
VStack(spacing: 0) {
// Top half: Great, Good, Average
HStack(spacing: 12) {
ForEach([Mood.great, .good, .average], id: \.rawValue) { mood in
moodIcon(for: mood, size: 40)
.frame(minWidth: 44, minHeight: 44)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
// Bottom half: Bad, Horrible
HStack(spacing: 12) {
ForEach([Mood.bad, .horrible], id: \.rawValue) { mood in
moodIcon(for: mood, size: 40)
.frame(minWidth: 44, minHeight: 44)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
}
}
// MARK: - Medium Widget: 50/50 split, both centered
private var mediumLayout: some View {
VStack(spacing: 0) {
// Top 50%: Text left-aligned, vertically centered
HStack {
Text(promptText)
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(.primary)
.multilineTextAlignment(.leading)
.lineLimit(2)
.minimumScaleFactor(0.8)
Spacer()
}
.padding(.horizontal, 16)
.frame(maxWidth: .infinity, maxHeight: .infinity)
// Bottom 50%: Voting buttons centered
HStack(spacing: 0) {
ForEach([Mood.great, .good, .average, .bad, .horrible], id: \.rawValue) { mood in
config.moodImages.icon(forMood: mood)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 54, height: 54)
.foregroundColor(config.moodTint.color(forMood: mood))
.frame(maxWidth: .infinity)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
// MARK: - Large Widget Layout: 33% header, 66% voting (2 rows)
private var largeLayout: some View {
Color.clear.overlay(
GeometryReader { geo in
VStack(spacing: 0) {
// Top 33%: Title centered
Text(promptText)
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(.primary)
.multilineTextAlignment(.center)
.lineLimit(2)
.minimumScaleFactor(0.8)
.padding(.horizontal, 12)
.frame(width: geo.size.width, height: geo.size.height * 0.33)
// Bottom 66%: Voting buttons in two rows
VStack(spacing: 0) {
// Top row: Great, Good, Average
HStack(spacing: 16) {
ForEach([Mood.great, .good, .average], id: \.rawValue) { mood in
largeMoodButton(for: mood)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
// Bottom row: Bad, Horrible
HStack(spacing: 16) {
ForEach([Mood.bad, .horrible], id: \.rawValue) { mood in
largeMoodButton(for: mood)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.frame(width: geo.size.width, height: geo.size.height * 0.67)
}
}
)
}
private func largeMoodButton(for mood: Mood) -> some View {
config.moodImages.icon(forMood: mood)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 53, height: 53)
.foregroundColor(config.moodTint.color(forMood: mood))
.padding(12)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(config.moodTint.color(forMood: mood).opacity(0.15))
)
}
private func moodIcon(for mood: Mood, size: CGFloat) -> some View {
config.moodImages.icon(forMood: mood)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: size, height: size)
.foregroundColor(config.moodTint.color(forMood: mood))
}
}
// MARK: - Exportable Voted Stats View (matches VotedStatsView from ReflectVoteWidget.swift)
struct ExportableVotedStatsView: View {
enum Size {
case small
case medium
}
let size: Size
let config: WidgetExportConfig
let mood: Mood
let totalEntries: Int
let moodCounts: [Mood: Int]
/// Returns "Today" for display
private var votingDateString: String {
return String(localized: "Today")
}
var body: some View {
if size == .small {
smallLayout
} else {
mediumLayout
}
}
// MARK: - Small: Centered mood with checkmark and date
private var smallLayout: some View {
VStack(spacing: 8) {
// Large centered mood icon
ZStack(alignment: .bottomTrailing) {
config.moodImages.icon(forMood: mood)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 56, height: 56)
.foregroundColor(config.moodTint.color(forMood: mood))
// Checkmark badge
Image(systemName: "checkmark.circle.fill")
.font(.headline)
.foregroundColor(.green)
.background(Circle().fill(.white).frame(width: 14, height: 14))
.offset(x: 4, y: 4)
}
Text(votingDateString)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Text("\(totalEntries) entries")
.font(.caption2)
.foregroundStyle(.tertiary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(12)
}
// MARK: - Medium: Mood + stats bar
private var mediumLayout: some View {
HStack(alignment: .top, spacing: 20) {
// Left: Mood display
VStack(spacing: 6) {
config.moodImages.icon(forMood: mood)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 48, height: 48)
.foregroundColor(config.moodTint.color(forMood: mood))
Text(mood.widgetDisplayName)
.font(.subheadline.weight(.semibold))
.foregroundColor(config.moodTint.color(forMood: mood))
Text(votingDateString)
.font(.caption2)
.foregroundStyle(.secondary)
}
// Right: Stats with progress bar
VStack(alignment: .leading, spacing: 10) {
Text("\(totalEntries) entries")
.font(.headline.weight(.semibold))
.foregroundStyle(.primary)
// Mini mood breakdown
HStack(spacing: 6) {
ForEach([Mood.great, .good, .average, .bad, .horrible], id: \.rawValue) { m in
let count = moodCounts[m, default: 0]
if count > 0 {
HStack(spacing: 2) {
Circle()
.fill(config.moodTint.color(forMood: m))
.frame(width: 8, height: 8)
Text("\(count)")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
}
// Progress bar
GeometryReader { geo in
HStack(spacing: 1) {
ForEach([Mood.great, .good, .average, .bad, .horrible], id: \.rawValue) { m in
let percentage = Double(moodCounts[m, default: 0]) / Double(totalEntries) * 100
if percentage > 0 {
RoundedRectangle(cornerRadius: 2)
.fill(config.moodTint.color(forMood: m))
.frame(width: max(4, geo.size.width * CGFloat(percentage) / 100))
}
}
}
}
.frame(height: 10)
.clipShape(RoundedRectangle(cornerRadius: 5))
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding()
}
}
// MARK: - Exportable Timeline Small View (matches SmallWidgetView from ReflectTimelineWidget.swift)
struct ExportableTimelineSmallView: View {
let config: WidgetExportConfig
let timelineData: ExportTimelineItem?
let hasVoted: Bool
let promptText: String
private var dayFormatter: DateFormatter {
let f = DateFormatter()
f.dateFormat = "EEEE"
return f
}
private var dateFormatter: DateFormatter {
let f = DateFormatter()
f.dateFormat = "MMM d"
return f
}
var body: some View {
if !hasVoted {
ExportableVotingView(size: .small, config: config, promptText: promptText)
} else if let today = timelineData {
VStack(spacing: 0) {
Spacer()
// Large mood icon
today.image
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 70, height: 70)
.foregroundColor(today.color)
Spacer()
.frame(height: 12)
// Date info
VStack(spacing: 2) {
Text(dayFormatter.string(from: today.date))
.font(.caption2.weight(.medium))
.foregroundStyle(.secondary)
.textCase(.uppercase)
Text(dateFormatter.string(from: today.date))
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
}
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
// MARK: - Exportable Timeline Medium View (matches MediumWidgetView from ReflectTimelineWidget.swift)
struct ExportableTimelineMediumView: View {
let config: WidgetExportConfig
let timelineData: [ExportTimelineItem]
let hasVoted: Bool
let promptText: String
private var dayFormatter: DateFormatter {
let f = DateFormatter()
f.dateFormat = "EEE"
return f
}
private var dateFormatter: DateFormatter {
let f = DateFormatter()
f.dateFormat = "d"
return f
}
private var headerDateRange: String {
guard let first = timelineData.first, let last = timelineData.last else { return "" }
let formatter = DateFormatter()
formatter.dateFormat = "MMM d"
return "\(formatter.string(from: last.date)) - \(formatter.string(from: first.date))"
}
var body: some View {
if !hasVoted {
ExportableVotingView(size: .medium, config: config, promptText: promptText)
} else {
GeometryReader { geo in
let cellHeight = geo.size.height - 36
VStack(spacing: 4) {
// Header
HStack {
Text("Last 5 Days")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
Text("·")
.foregroundStyle(.secondary)
Text(headerDateRange)
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
}
.padding(.horizontal, 14)
.padding(.top, 10)
// Single row of 5 days
HStack(spacing: 8) {
ForEach(Array(timelineData.enumerated()), id: \.element.id) { index, item in
ExportableMediumDayCell(
dayLabel: dayFormatter.string(from: item.date),
dateLabel: dateFormatter.string(from: item.date),
image: item.image,
color: item.color,
isToday: index == 0,
height: cellHeight,
mood: item.mood
)
}
}
.padding(.horizontal, 10)
.padding(.bottom, 10)
}
}
}
}
}
// MARK: - Exportable Medium Day Cell
struct ExportableMediumDayCell: View {
let dayLabel: String
let dateLabel: String
let image: Image
let color: Color
let isToday: Bool
let height: CGFloat
let mood: Mood
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 14)
.fill(color.opacity(isToday ? 0.25 : 0.12))
.frame(height: height)
VStack(spacing: 4) {
Text(dayLabel)
.font(.caption2.weight(isToday ? .bold : .medium))
.foregroundStyle(isToday ? .primary : .secondary)
.textCase(.uppercase)
image
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 36, height: 36)
.foregroundColor(color)
Text(dateLabel)
.font(.caption.weight(isToday ? .bold : .semibold))
.foregroundStyle(isToday ? color : .secondary)
}
}
.frame(maxWidth: .infinity)
}
}
// MARK: - Exportable Timeline Large View (matches LargeWidgetView from ReflectTimelineWidget.swift)
struct ExportableTimelineLargeView: View {
let config: WidgetExportConfig
let timelineData: [ExportTimelineItem]
let hasVoted: Bool
let promptText: String
private var dayFormatter: DateFormatter {
let f = DateFormatter()
f.dateFormat = "EEE"
return f
}
private var dateFormatter: DateFormatter {
let f = DateFormatter()
f.dateFormat = "d"
return f
}
private var headerDateRange: String {
guard let first = timelineData.first, let last = timelineData.last else { return "" }
let formatter = DateFormatter()
formatter.dateFormat = "MMM d"
return "\(formatter.string(from: last.date)) - \(formatter.string(from: first.date))"
}
var body: some View {
if !hasVoted {
ExportableVotingView(size: .large, config: config, promptText: promptText)
} else {
GeometryReader { geo in
let cellHeight = (geo.size.height - 70) / 2
VStack(spacing: 6) {
// Header
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Last 10 Days")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
Text(headerDateRange)
.font(.caption2)
.foregroundStyle(.secondary)
}
Spacer()
}
.padding(.horizontal, 12)
.padding(.top, 8)
// Calendar grid - 2 rows of 5
VStack(spacing: 6) {
// First row (most recent 5)
HStack(spacing: 6) {
ForEach(Array(timelineData.prefix(5).enumerated()), id: \.element.id) { index, item in
ExportableDayCell(
dayLabel: dayFormatter.string(from: item.date),
dateLabel: dateFormatter.string(from: item.date),
image: item.image,
color: item.color,
isToday: index == 0,
height: cellHeight,
mood: item.mood
)
}
}
// Second row (older 5)
HStack(spacing: 6) {
ForEach(Array(timelineData.suffix(5).enumerated()), id: \.element.id) { _, item in
ExportableDayCell(
dayLabel: dayFormatter.string(from: item.date),
dateLabel: dateFormatter.string(from: item.date),
image: item.image,
color: item.color,
isToday: false,
height: cellHeight,
mood: item.mood
)
}
}
}
.padding(.horizontal, 10)
.padding(.bottom, 8)
}
}
}
}
}
// MARK: - Exportable Day Cell (for Large Widget)
struct ExportableDayCell: View {
let dayLabel: String
let dateLabel: String
let image: Image
let color: Color
let isToday: Bool
let height: CGFloat
let mood: Mood
var body: some View {
VStack(spacing: 2) {
Text(dayLabel)
.font(.caption2.weight(isToday ? .bold : .medium))
.foregroundStyle(isToday ? .primary : .secondary)
.textCase(.uppercase)
ZStack {
RoundedRectangle(cornerRadius: 14)
.fill(color.opacity(isToday ? 0.25 : 0.12))
.frame(height: height - 16)
VStack(spacing: 6) {
image
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 38, height: 38)
.foregroundColor(color)
Text(dateLabel)
.font(.caption.weight(isToday ? .bold : .semibold))
.foregroundStyle(isToday ? color : .secondary)
}
}
}
.frame(maxWidth: .infinity)
}
}
// MARK: - Exportable Live Activity View (matches lock screen Live Activity)
struct ExportableLiveActivityView: View {
let config: WidgetExportConfig
let streak: Int
let hasLoggedToday: Bool
let mood: Mood?
var body: some View {
HStack(spacing: 16) {
// Streak indicator
VStack(spacing: 4) {
Image(systemName: "flame.fill")
.font(.title)
.foregroundColor(.orange)
Text("\(streak)")
.font(.title.bold())
Text("day streak")
.font(.caption)
.foregroundColor(.secondary)
}
Divider()
.frame(height: 50)
// Status
VStack(alignment: .leading, spacing: 8) {
if hasLoggedToday, let mood = mood {
HStack(spacing: 8) {
Circle()
.fill(config.moodTint.color(forMood: mood))
.frame(width: 24, height: 24)
VStack(alignment: .leading) {
Text("Today's mood")
.font(.caption)
.foregroundColor(.secondary)
Text(mood.widgetDisplayName)
.font(.headline)
}
}
} else {
VStack(alignment: .leading) {
Text(streak > 0 ? "Don't break your streak!" : "Start your streak!")
.font(.headline)
Text("Tap to log your mood")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
Spacer()
}
.padding()
}
}
// MARK: - Widget Container for Export
struct ExportableWidgetContainer<Content: View>: View {
let width: CGFloat
let height: CGFloat
let colorScheme: ColorScheme
let useSystemBackground: Bool
let content: Content
init(width: CGFloat, height: CGFloat, colorScheme: ColorScheme, useSystemBackground: Bool = false, @ViewBuilder content: () -> Content) {
self.width = width
self.height = height
self.colorScheme = colorScheme
self.useSystemBackground = useSystemBackground
self.content = content()
}
/// Opaque background color based on color scheme (no transparency)
private var backgroundColor: Color {
if useSystemBackground {
return colorScheme == .dark ? Color(red: 0.11, green: 0.11, blue: 0.12) : Color.white
} else {
// Tertiary fill equivalent - opaque
return colorScheme == .dark ? Color(red: 0.17, green: 0.17, blue: 0.18) : Color(red: 0.95, green: 0.95, blue: 0.97)
}
}
var body: some View {
content
.environment(\.colorScheme, colorScheme)
.frame(width: width, height: height)
.background(backgroundColor)
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
}
}
#endif