- Update App Group IDs from group.com.tt.feels to group.com.88oakapps.feels - Update iCloud container IDs from iCloud.com.tt.feels to iCloud.com.88oakapps.feels - Sync code constants with entitlements across all targets (iOS, Watch, Widget) - Update documentation in CLAUDE.md and PROJECT_OVERVIEW.md Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
279 lines
9.2 KiB
Swift
279 lines
9.2 KiB
Swift
//
|
|
// WidgetSharedViews.swift
|
|
// FeelsWidget
|
|
//
|
|
// Shared voting views used across multiple widgets
|
|
//
|
|
|
|
import WidgetKit
|
|
import SwiftUI
|
|
import AppIntents
|
|
|
|
// MARK: - Voting View
|
|
|
|
struct VotingView: View {
|
|
let family: WidgetFamily
|
|
let promptText: String
|
|
let hasSubscription: Bool
|
|
|
|
private var moodTint: MoodTintable.Type {
|
|
UserDefaultsStore.moodTintable()
|
|
}
|
|
|
|
private var moodImages: MoodImagable.Type {
|
|
UserDefaultsStore.moodMoodImagable()
|
|
}
|
|
|
|
var body: some View {
|
|
if family == .systemSmall {
|
|
smallLayout
|
|
} else {
|
|
mediumLayout
|
|
}
|
|
}
|
|
|
|
// 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
|
|
moodButton(for: mood, size: 40)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
|
|
|
// Bottom half: Bad, Horrible
|
|
HStack(spacing: 12) {
|
|
ForEach([Mood.bad, .horrible], id: \.rawValue) { mood in
|
|
moodButton(for: mood, size: 40)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
|
}
|
|
}
|
|
|
|
// MARK: - Medium Widget: Vertical split - text top, voting bottom
|
|
private var mediumLayout: some View {
|
|
VStack(spacing: 0) {
|
|
// Top: Text left-aligned, centered horizontally
|
|
Text(hasSubscription ? promptText : "Subscribe to track your mood")
|
|
.font(.headline)
|
|
.foregroundStyle(.primary)
|
|
.multilineTextAlignment(.leading)
|
|
.lineLimit(2)
|
|
.minimumScaleFactor(0.8)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
|
.padding(.horizontal, 16)
|
|
|
|
// Bottom: Voting buttons with equal spacing, centered
|
|
HStack(spacing: 0) {
|
|
ForEach([Mood.great, .good, .average, .bad, .horrible], id: \.rawValue) { mood in
|
|
moodButtonMedium(for: mood)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func moodButton(for mood: Mood, size: CGFloat) -> some View {
|
|
// Used for small widget
|
|
let touchSize = max(size, 44)
|
|
|
|
if hasSubscription {
|
|
Button(intent: VoteMoodIntent(mood: mood)) {
|
|
moodIcon(for: mood, size: size)
|
|
.frame(minWidth: touchSize, minHeight: touchSize)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel(mood.strValue)
|
|
.accessibilityHint(String(localized: "Log this mood"))
|
|
} else {
|
|
Link(destination: URL(string: "feels://subscribe")!) {
|
|
moodIcon(for: mood, size: size)
|
|
.frame(minWidth: touchSize, minHeight: touchSize)
|
|
}
|
|
.accessibilityLabel(mood.strValue)
|
|
.accessibilityHint(String(localized: "Open app to subscribe"))
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func moodButtonMedium(for mood: Mood) -> some View {
|
|
// Medium widget uses icons only (accessibility labels provide screen reader support)
|
|
let content = moodImages.icon(forMood: mood)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.frame(width: 36, height: 36)
|
|
.foregroundColor(moodTint.color(forMood: mood))
|
|
|
|
if hasSubscription {
|
|
Button(intent: VoteMoodIntent(mood: mood)) {
|
|
content
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel(mood.strValue)
|
|
.accessibilityHint(String(localized: "Log this mood"))
|
|
} else {
|
|
Link(destination: URL(string: "feels://subscribe")!) {
|
|
content
|
|
}
|
|
.accessibilityLabel(mood.strValue)
|
|
.accessibilityHint(String(localized: "Open app to subscribe"))
|
|
}
|
|
}
|
|
|
|
private func moodIcon(for mood: Mood, size: CGFloat) -> some View {
|
|
moodImages.icon(forMood: mood)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.frame(width: size, height: size)
|
|
.foregroundColor(moodTint.color(forMood: mood))
|
|
}
|
|
}
|
|
|
|
// MARK: - Large Voting View
|
|
|
|
struct LargeVotingView: View {
|
|
let promptText: String
|
|
let hasSubscription: Bool
|
|
|
|
private var moodTint: MoodTintable.Type {
|
|
UserDefaultsStore.moodTintable()
|
|
}
|
|
|
|
private var moodImages: MoodImagable.Type {
|
|
UserDefaultsStore.moodMoodImagable()
|
|
}
|
|
|
|
var body: some View {
|
|
GeometryReader { geo in
|
|
VStack(spacing: 0) {
|
|
// Top 25%: Title centered x,y
|
|
Text(hasSubscription ? promptText : "Subscribe to track your mood")
|
|
.font(.title3.weight(.semibold))
|
|
.foregroundStyle(.primary)
|
|
.multilineTextAlignment(.center)
|
|
.lineLimit(2)
|
|
.minimumScaleFactor(0.8)
|
|
.padding(.horizontal, 12)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.frame(height: geo.size.height * 0.25)
|
|
|
|
// Bottom 75%: Voting buttons in two rows
|
|
VStack(spacing: 0) {
|
|
// Top row at 33%: Great, Good, Average
|
|
HStack(spacing: 16) {
|
|
ForEach([Mood.great, .good, .average], id: \.rawValue) { mood in
|
|
moodButton(for: mood)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
|
|
// Bottom row at 66%: Bad, Horrible
|
|
HStack(spacing: 16) {
|
|
ForEach([Mood.bad, .horrible], id: \.rawValue) { mood in
|
|
moodButton(for: mood)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
.frame(height: geo.size.height * 0.75)
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func moodButton(for mood: Mood) -> some View {
|
|
if hasSubscription {
|
|
Button(intent: VoteMoodIntent(mood: mood)) {
|
|
moodButtonContent(for: mood)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel(mood.strValue)
|
|
.accessibilityHint(String(localized: "Log this mood"))
|
|
} else {
|
|
Link(destination: URL(string: "feels://subscribe")!) {
|
|
moodButtonContent(for: mood)
|
|
}
|
|
.accessibilityLabel(mood.strValue)
|
|
.accessibilityHint(String(localized: "Open app to subscribe"))
|
|
}
|
|
}
|
|
|
|
private func moodButtonContent(for mood: Mood) -> some View {
|
|
// Large widget uses icons only (accessibility labels provide screen reader support)
|
|
moodImages.icon(forMood: mood)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.frame(width: 44, height: 44)
|
|
.foregroundColor(moodTint.color(forMood: mood))
|
|
.padding(10)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.fill(moodTint.color(forMood: mood).opacity(0.15))
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Inline Voting View (compact mood buttons for timeline widget)
|
|
|
|
struct InlineVotingView: View {
|
|
let promptText: String
|
|
let hasSubscription: Bool
|
|
let moods: [Mood] = [.horrible, .bad, .average, .good, .great]
|
|
|
|
private var moodTint: MoodTintable.Type {
|
|
UserDefaultsStore.moodTintable()
|
|
}
|
|
|
|
private var moodImages: MoodImagable.Type {
|
|
UserDefaultsStore.moodMoodImagable()
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 8) {
|
|
Text(hasSubscription ? promptText : "Tap to open app")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.primary)
|
|
.multilineTextAlignment(.center)
|
|
.lineLimit(2)
|
|
.minimumScaleFactor(0.7)
|
|
|
|
HStack(spacing: 8) {
|
|
ForEach(moods, id: \.rawValue) { mood in
|
|
moodButton(for: mood)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func moodButton(for mood: Mood) -> some View {
|
|
if hasSubscription {
|
|
Button(intent: VoteMoodIntent(mood: mood)) {
|
|
moodIcon(for: mood)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel(mood.strValue)
|
|
.accessibilityHint(String(localized: "Log this mood"))
|
|
} else {
|
|
Link(destination: URL(string: "feels://subscribe")!) {
|
|
moodIcon(for: mood)
|
|
}
|
|
.accessibilityLabel(mood.strValue)
|
|
.accessibilityHint(String(localized: "Open app to subscribe"))
|
|
}
|
|
}
|
|
|
|
private func moodIcon(for mood: Mood) -> some View {
|
|
moodImages.icon(forMood: mood)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.frame(width: 44, height: 44)
|
|
.foregroundColor(moodTint.color(forMood: mood))
|
|
}
|
|
}
|