1.0.5: Eating out slots, rule violations panel, Crashlytics, search bar fix

- Eating out: tap any empty slot → "Mark as eating out"; shows teal indicator,
  skipped by auto-assign, counts as complete, tap again to unmark
- Rule violations panel: access via wand long-press context menu when conflicts exist;
  shows all isRuleOverridden slots with Fix (clear) or Ignore (acknowledge) actions
- Firebase Crashlytics integrated: CrashlyticsService + dSYM upload build phase,
  isPremium property tracked per session
- PremiumSyncService: extracted premium state machine, StoreKit Transaction.updates
  listener, isPremium no longer synced via iCloud to avoid stale state
- StoreManager: analytics on purchase/restore, bundle ID fallback for product ID lookup
- Search bar contrast bug fixed: TextField now has explicit foreground color for dark mode
- WelcomeStepView: redesigned onboarding welcome screen with week preview
- Version bump: 1.0.5 build 22

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
alexandrev-tibco
2026-04-27 09:48:20 +02:00
parent 6c7e12b41f
commit 110807d239
111 changed files with 3116 additions and 154 deletions
+28
View File
@@ -43,3 +43,31 @@ struct EmptySlotView: View {
return valid ? .mealMoodSuccess : .mealMoodError
}
}
struct EatingOutSlotView: View {
let mealType: MealType
var body: some View {
VStack(spacing: 6) {
Image(systemName: "fork.knife.circle")
.font(.system(size: 20))
.foregroundColor(Color(hex: "#A0C4B8"))
Text("slot_eating_out")
.font(.mealMoodCaption)
.foregroundColor(Color(hex: "#6B9E90"))
}
.frame(maxWidth: .infinity, minHeight: 80)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color(hex: "#EEF8F5"))
.overlay(
RoundedRectangle(cornerRadius: 12)
.strokeBorder(
style: StrokeStyle(lineWidth: 2, dash: [5])
)
.foregroundColor(Color(hex: "#A0C4B8"))
)
)
}
}
+46 -25
View File
@@ -1,22 +1,20 @@
import SwiftUI
import SwiftData
import StoreKit
struct ContentView: View {
@Environment(\.modelContext) private var context
@Environment(\.scenePhase) private var scenePhase
@Query private var allSettings: [AppSettings]
@State private var isReady = false
@State private var hasCompletedColdLaunch = false
private let premiumSyncService = PremiumSyncService()
private var settings: AppSettings? { allSettings.first }
private var isTestFlightBuild: Bool {
guard let receiptURL = Bundle.main.appStoreReceiptURL else { return false }
return receiptURL.lastPathComponent == "sandboxReceipt"
}
var body: some View {
Group {
if !isReady {
// Splash screen
ZStack {
Color.mealMoodBackground.ignoresSafeArea()
AppIconPlaceholder(size: 120)
@@ -30,13 +28,39 @@ struct ContentView: View {
}
}
.environment(\.locale, Locale(identifier: settings?.languageEnum.localeIdentifier ?? Locale.current.identifier))
// MARK: Transaction.updates listener (StoreKit 2 recommended pattern)
// Fires for new purchases, renewals and revocations while the app is running.
// This is the most reliable way to catch a purchase even if the app was
// interrupted during the payment flow.
.task {
for await result in Transaction.updates {
guard case .verified(let transaction) = result else { continue }
guard let s = settings else {
await transaction.finish()
continue
}
let isActive = transaction.revocationDate == nil &&
(transaction.expirationDate.map { $0 > Date() } ?? true)
if isActive && !s.isPremium {
s.isPremium = true
try? context.save()
} else if transaction.revocationDate != nil && s.isPremium {
// Explicitly revoked subscription downgrade
s.isPremium = false
try? context.save()
}
await transaction.finish()
}
}
.onAppear {
initializeAppIfNeeded()
Task {
if settings?.iCloudSyncEnabledResolved ?? true {
await ICloudSyncService.shared.pullRemoteIfNeeded(context: context)
}
await syncPremiumStatus()
await syncPremiumStatus(isColdLaunch: true)
hasCompletedColdLaunch = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
withAnimation(.easeInOut) {
@@ -48,7 +72,11 @@ struct ContentView: View {
Task {
switch phase {
case .active:
await syncPremiumStatus()
// Only run foreground sync after cold launch is complete.
// The first activation is covered by .onAppear above.
if hasCompletedColdLaunch {
await syncPremiumStatus(isColdLaunch: false)
}
case .inactive, .background:
if settings?.iCloudSyncEnabledResolved ?? true {
await ICloudSyncService.shared.pushLocalSnapshot(context: context)
@@ -60,35 +88,28 @@ struct ContentView: View {
}
}
// MARK: - Private
private func initializeAppIfNeeded() {
// Create default settings if none exist
if allSettings.isEmpty {
DefaultDataService.createDefaultSettings(context: context)
}
// Create default tags if none exist
let tagDescriptor = FetchDescriptor<Tag>()
if (try? context.fetch(tagDescriptor))?.isEmpty ?? true {
DefaultDataService.createDefaultTags(context: context)
}
}
private func syncPremiumStatus() async {
let descriptor = FetchDescriptor<AppSettings>()
guard let settings = try? context.fetch(descriptor).first else { return }
if isTestFlightBuild {
if settings.isPremium != true {
settings.isPremium = true
try? context.save()
}
return
}
let premium = await StoreManager.hasActiveSubscription()
if settings.isPremium != premium {
settings.isPremium = premium
private func syncPremiumStatus(isColdLaunch: Bool) async {
guard let settings else { return }
let newPremium = await premiumSyncService.sync(
currentIsPremium: settings.isPremium,
isColdLaunch: isColdLaunch
)
if settings.isPremium != newPremium {
settings.isPremium = newPremium
try? context.save()
}
CrashlyticsService.setUserProperties(isPremium: settings.isPremium)
}
}
+3
View File
@@ -1,5 +1,7 @@
import SwiftUI
import SwiftData
import FirebaseCore
import FirebaseCrashlytics
#if canImport(GoogleMobileAds)
import GoogleMobileAds
#endif
@@ -19,6 +21,7 @@ struct MealMoodApp: App {
}()
init() {
FirebaseApp.configure()
#if canImport(GoogleMobileAds)
GADMobileAds.sharedInstance().start(completionHandler: nil)
#endif
+4 -1
View File
@@ -9,6 +9,7 @@ final class MealSlot {
var dishId: UUID?
var calendarEventId: String?
var isRuleOverridden: Bool
var isEatingOut: Bool
var weekPlan: WeekPlan?
@@ -18,7 +19,8 @@ final class MealSlot {
mealType: String,
dishId: UUID? = nil,
calendarEventId: String? = nil,
isRuleOverridden: Bool = false
isRuleOverridden: Bool = false,
isEatingOut: Bool = false
) {
self.id = id
self.dayOfWeek = dayOfWeek
@@ -26,6 +28,7 @@ final class MealSlot {
self.dishId = dishId
self.calendarEventId = calendarEventId
self.isRuleOverridden = isRuleOverridden
self.isEatingOut = isEatingOut
}
var mealTypeEnum: MealType {
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>API_KEY</key>
<string>AIzaSyBkDgBKsgudAAwsWErzLviIWm3dir4bHCc</string>
<key>GCM_SENDER_ID</key>
<string>442954975324</string>
<key>PLIST_VERSION</key>
<string>1</string>
<key>BUNDLE_ID</key>
<string>com.alexandrevazquez.mealmood</string>
<key>PROJECT_ID</key>
<string>mealmood</string>
<key>STORAGE_BUCKET</key>
<string>mealmood.firebasestorage.app</string>
<key>IS_ADS_ENABLED</key>
<true></true>
<key>IS_ANALYTICS_ENABLED</key>
<true></true>
<key>IS_APPINVITE_ENABLED</key>
<true></true>
<key>IS_GCM_ENABLED</key>
<true></true>
<key>IS_SIGNIN_ENABLED</key>
<true></true>
<key>GOOGLE_APP_ID</key>
<string>1:442954975324:ios:f6fd3a3bf28b6f3d59be99</string>
</dict>
</plist>
+2 -2
View File
@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0.4</string>
<string>1.0.5</string>
<key>CFBundleVersion</key>
<string>21</string>
<string>22</string>
<key>GADApplicationIdentifier</key>
<string>ca-app-pub-1549720748100858~9985112590</string>
<key>LSRequiresIPhoneOS</key>
+4
View File
@@ -8,5 +8,9 @@
</array>
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.alexandrevazquez.mealmood</string>
</array>
</dict>
</plist>
@@ -298,3 +298,15 @@
// Onboarding preview
"onboarding_welcome_preview_label" = "So könnte deine Woche aussehen ✨";
// Auswärts essen
"slot_eating_out" = "Auswärts";
"home_mark_eating_out" = "Auswärts essen";
"toast_eating_out_marked" = "Als auswärts markiert";
// Regelkonflikte
"violations_panel_title" = "Regelkonflikte";
"violations_panel_empty" = "Keine Konflikte diese Woche";
"violations_fix" = "Slot leeren";
"violations_ignore" = "Ignorieren";
"violations_badge" = "%d Konflikt(e)";
@@ -298,3 +298,15 @@
// Onboarding preview
"onboarding_welcome_preview_label" = "Your week could look like this ✨";
// Eating out
"slot_eating_out" = "Eating out";
"home_mark_eating_out" = "Mark as eating out";
"toast_eating_out_marked" = "Marked as eating out";
// Rule violations panel
"violations_panel_title" = "Rule conflicts";
"violations_panel_empty" = "No rule conflicts this week";
"violations_fix" = "Clear slot";
"violations_ignore" = "Ignore";
"violations_badge" = "%d conflict(s)";
@@ -298,3 +298,15 @@
// Onboarding preview
"onboarding_welcome_preview_label" = "Tu semana puede quedar así ✨";
// Comer fuera
"slot_eating_out" = "Comiendo fuera";
"home_mark_eating_out" = "Marcar como comida fuera";
"toast_eating_out_marked" = "Marcado como comida fuera";
// Panel de conflictos
"violations_panel_title" = "Conflictos de reglas";
"violations_panel_empty" = "Sin conflictos esta semana";
"violations_fix" = "Vaciar hueco";
"violations_ignore" = "Ignorar";
"violations_badge" = "%d conflicto(s)";
@@ -298,3 +298,15 @@
// Onboarding preview
"onboarding_welcome_preview_label" = "Votre semaine pourrait ressembler à ça ✨";
// Repas à l'extérieur
"slot_eating_out" = "Repas dehors";
"home_mark_eating_out" = "Manger à l'extérieur";
"toast_eating_out_marked" = "Repas marqué à l'extérieur";
// Panneau de conflits
"violations_panel_title" = "Conflits de règles";
"violations_panel_empty" = "Aucun conflit cette semaine";
"violations_fix" = "Vider le créneau";
"violations_ignore" = "Ignorer";
"violations_badge" = "%d conflit(s)";
@@ -298,3 +298,15 @@
// Onboarding preview
"onboarding_welcome_preview_label" = "La tua settimana potrebbe essere così ✨";
// Mangiare fuori
"slot_eating_out" = "Fuori casa";
"home_mark_eating_out" = "Mangiare fuori";
"toast_eating_out_marked" = "Segnato come fuori casa";
// Pannello conflitti
"violations_panel_title" = "Conflitti di regole";
"violations_panel_empty" = "Nessun conflitto questa settimana";
"violations_fix" = "Svuota slot";
"violations_ignore" = "Ignora";
"violations_badge" = "%d conflitto/i";
@@ -298,3 +298,15 @@
// Onboarding preview
"onboarding_welcome_preview_label" = "Sua semana pode ficar assim ✨";
// Comer fora
"slot_eating_out" = "Comendo fora";
"home_mark_eating_out" = "Comer fora";
"toast_eating_out_marked" = "Marcado como comer fora";
// Painel de conflitos
"violations_panel_title" = "Conflitos de regras";
"violations_panel_empty" = "Sem conflitos esta semana";
"violations_fix" = "Limpar slot";
"violations_ignore" = "Ignorar";
"violations_badge" = "%d conflito(s)";
+29 -3
View File
@@ -7,6 +7,14 @@ struct AutocompleteEngine {
let unfilledCount: Int
}
struct RuleViolation {
let slotId: UUID
let dayOfWeek: Int
let mealType: String
let dishId: UUID
let dishName: String
}
static func autocomplete(
emptySlots: [MealSlot],
currentPlan: WeekPlan,
@@ -18,9 +26,9 @@ struct AutocompleteEngine {
var filledSlots: [(slotId: UUID, dishId: UUID)] = []
var unfilledCount = 0
let sortedEmpty = emptySlots.sorted {
($0.dayOfWeek, $0.mealType) < ($1.dayOfWeek, $1.mealType)
}
let sortedEmpty = emptySlots
.filter { !$0.isEatingOut }
.sorted { ($0.dayOfWeek, $0.mealType) < ($1.dayOfWeek, $1.mealType) }
for slot in sortedEmpty {
let strictCandidates = allDishes.filter { dish in
@@ -66,6 +74,24 @@ struct AutocompleteEngine {
return AutocompleteResult(filledSlots: filledSlots, unfilledCount: unfilledCount)
}
static func findViolations(plan: WeekPlan, allDishes: [Dish], allTags: [Tag]) -> [RuleViolation] {
let tagMap = Dictionary(uniqueKeysWithValues: allTags.map { ($0.id, $0) })
let dishMap = Dictionary(uniqueKeysWithValues: allDishes.map { ($0.id, $0) })
return plan.slots.compactMap { slot in
guard let dishId = slot.dishId,
let dish = dishMap[dishId],
slot.isRuleOverridden,
!slot.isEatingOut else { return nil }
return RuleViolation(
slotId: slot.id,
dayOfWeek: slot.dayOfWeek,
mealType: slot.mealType,
dishId: dishId,
dishName: dish.name
)
}
}
static func validateDrop(
dish: Dish,
slot: MealSlot,
@@ -0,0 +1,26 @@
import FirebaseCrashlytics
enum CrashlyticsService {
static func setUserProperties(isPremium: Bool) {
Crashlytics.crashlytics().setCustomValue(isPremium, forKey: "is_premium")
}
static func log(_ message: String) {
Crashlytics.crashlytics().log(message)
}
static func record(_ error: Error, context: String? = nil) {
var userInfo: [String: Any] = [:]
if let context { userInfo["context"] = context }
Crashlytics.crashlytics().record(error: error, userInfo: userInfo)
}
static func record(message: String, context: String? = nil) {
let userInfo: [String: Any] = context.map { ["context": $0] } ?? [:]
Crashlytics.crashlytics().record(
exceptionModel: ExceptionModel(name: "AppError", reason: message),
userInfo: userInfo
)
}
}
+5 -3
View File
@@ -71,7 +71,6 @@ final class ICloudSyncService {
reminderMinutesBefore: $0.reminderMinutesBefore,
iCloudSyncEnabled: $0.iCloudSyncEnabled,
weekExportStyle: $0.weekExportStyle,
isPremium: $0.isPremium,
onboardingCompleted: $0.onboardingCompleted
)
},
@@ -153,7 +152,9 @@ final class ICloudSyncService {
settings.reminderMinutesBefore = settingsPayload.reminderMinutesBefore
settings.iCloudSyncEnabled = settingsPayload.iCloudSyncEnabled
settings.weekExportStyle = settingsPayload.weekExportStyle
settings.isPremium = settingsPayload.isPremium
// isPremium is intentionally NOT synced via iCloud.
// It is determined exclusively by StoreKit to avoid stale state
// overwriting a freshly completed purchase.
settings.onboardingCompleted = settingsPayload.onboardingCompleted
context.insert(settings)
}
@@ -236,8 +237,9 @@ private struct SettingsPayload: Codable {
let reminderMinutesBefore: Int?
let iCloudSyncEnabled: Bool?
let weekExportStyle: String?
let isPremium: Bool
let onboardingCompleted: Bool
// isPremium is intentionally omitted determined by StoreKit only, never by sync.
// Old payloads in iCloud may contain this key; it is safely ignored by the decoder.
}
private struct TagPayload: Codable {
@@ -0,0 +1,36 @@
import Foundation
/// Encapsulates the premium status sync state machine.
/// Extracted from ContentView to be independently testable.
@MainActor
final class PremiumSyncService {
/// Injectable for testing defaults to real StoreKit check.
var hasActiveSubscription: () async -> Bool = {
await StoreManager.hasActiveSubscription()
}
/// Determine whether the user should be premium after a sync.
///
/// Rules:
/// - Active subscription found always return `true` (upgrade).
/// - No subscription, currently free return `false`.
/// - No subscription, currently premium, **cold launch** return `false`
/// (subscription likely expired between sessions).
/// - No subscription, currently premium, **foreground activation** return `true`
/// (StoreKit `currentEntitlements` can return empty for a few seconds immediately
/// after a purchase while the receipt propagates; never downgrade mid-session).
func sync(currentIsPremium: Bool, isColdLaunch: Bool) async -> Bool {
let active = await hasActiveSubscription()
switch (active, currentIsPremium, isColdLaunch) {
case (true, _, _):
return true
case (false, false, _):
return false
case (false, true, true):
return false // Subscription expired downgrade on cold launch
case (false, true, false):
return true // Keep premium on foreground (timing protection)
}
}
}
+16 -5
View File
@@ -77,6 +77,7 @@ final class StoreManager: ObservableObject {
if case .verified(let transaction) = verification {
await transaction.finish()
isPremium = true
AnalyticsService.logPremiumPurchased()
return .success
}
return .failed
@@ -99,14 +100,18 @@ final class StoreManager: ObservableObject {
do {
try await AppStore.sync()
let wasNotPremium = !isPremium
await checkPremiumStatus()
if wasNotPremium && isPremium {
AnalyticsService.logPremiumRestored()
}
} catch {
print("Restore failed: \(error)")
}
}
private func checkPremiumStatus() async {
isPremium = await Self.hasActiveSubscription(productIds: productIds)
isPremium = await Self.hasActiveSubscription()
}
var monthlyProduct: Product? {
@@ -133,12 +138,18 @@ final class StoreManager: ObservableObject {
var debugProductIds: [String] { productIds }
static func hasActiveSubscription(productIds: [String] = [
StoreManager.monthlyProductId
]) async -> Bool {
static func hasActiveSubscription() async -> Bool {
// Check all known product ID variants to handle both the canonical ID
// and any bundle-prefixed IDs that may have been used at purchase time.
var knownIds: [String] = [monthlyProductId]
if let bundleId = Bundle.main.bundleIdentifier {
knownIds.append("\(bundleId).premium.monthly")
}
let ids = Set(knownIds)
for await result in Transaction.currentEntitlements {
if case .verified(let transaction) = result,
productIds.contains(transaction.productID),
ids.contains(transaction.productID),
transaction.revocationDate == nil {
if let expirationDate = transaction.expirationDate, expirationDate < Date() {
continue
+55
View File
@@ -0,0 +1,55 @@
import Foundation
import WidgetKit
struct TodayMealData: Codable {
let lunch: String?
let dinner: String?
let lunchLabel: String
let dinnerLabel: String
let weekdayName: String
let updatedAt: Date
}
enum WidgetDataStore {
static let appGroupID = "group.com.alexandrevazquez.mealmood"
private static let key = "mealmood.today_meals"
static func update(plan: WeekPlan?, dishes: [Dish], settings: AppSettings) {
let today = Date()
let weekday = Calendar.current.component(.weekday, from: today)
// weekday: 1=Sun,2=Mon..7=Sat appDayOfWeek: 0=Mon..6=Sun
let appDayOfWeek = (weekday + 5) % 7
let lunchId = plan?.slots.first { $0.dayOfWeek == appDayOfWeek && $0.mealType == MealType.lunch.rawValue }?.dishId
let dinnerId = plan?.slots.first { $0.dayOfWeek == appDayOfWeek && $0.mealType == MealType.dinner.rawValue }?.dishId
let lunch = lunchId.flatMap { id in dishes.first { $0.id == id }?.name }
let dinner = dinnerId.flatMap { id in dishes.first { $0.id == id }?.name }
let locale = Locale(identifier: settings.languageEnum.resolved().localeIdentifier)
let formatter = DateFormatter()
formatter.locale = locale
formatter.dateFormat = "EEEE"
let weekdayName = formatter.string(from: today).capitalized(with: locale)
let data = TodayMealData(
lunch: lunch,
dinner: dinner,
lunchLabel: String(localized: "lunch"),
dinnerLabel: String(localized: "dinner"),
weekdayName: weekdayName,
updatedAt: today
)
guard let defaults = UserDefaults(suiteName: appGroupID),
let encoded = try? JSONEncoder().encode(data) else { return }
defaults.set(encoded, forKey: key)
WidgetCenter.shared.reloadAllTimelines()
}
static func read() -> TodayMealData? {
guard let defaults = UserDefaults(suiteName: appGroupID),
let data = defaults.data(forKey: key) else { return nil }
return try? JSONDecoder().decode(TodayMealData.self, from: data)
}
}
+35 -4
View File
@@ -12,6 +12,7 @@ final class HomeViewModel: ObservableObject {
private struct SlotSnapshot {
let dishId: UUID?
let isRuleOverridden: Bool
let isEatingOut: Bool
}
@Published var currentWeekStart: Date
@@ -116,7 +117,7 @@ final class HomeViewModel: ObservableObject {
captureUndoSnapshot(plan: plan)
isAutoCompleting = true
let emptySlots = plan.slots.filter { $0.dishId == nil }
let emptySlots = plan.slots.filter { $0.dishId == nil && !$0.isEatingOut }
let result = AutocompleteEngine.autocomplete(
emptySlots: emptySlots,
currentPlan: plan,
@@ -160,6 +161,7 @@ final class HomeViewModel: ObservableObject {
}
slot.dishId = nil
slot.isRuleOverridden = false
slot.isEatingOut = false
}
plan.updatedAt = Date()
AnalyticsService.logWeekReset()
@@ -297,9 +299,11 @@ final class HomeViewModel: ObservableObject {
if let snap = lastSlotsSnapshot[slot.id] {
slot.dishId = snap.dishId
slot.isRuleOverridden = snap.isRuleOverridden
slot.isEatingOut = snap.isEatingOut
} else {
slot.dishId = nil
slot.isRuleOverridden = false
slot.isEatingOut = false
}
}
@@ -323,7 +327,7 @@ final class HomeViewModel: ObservableObject {
guard !isApplyingUndo else { return }
lastWeekStartSnapshot = plan.weekStartDate
lastSlotsSnapshot = Dictionary(uniqueKeysWithValues: plan.slots.map { slot in
(slot.id, SlotSnapshot(dishId: slot.dishId, isRuleOverridden: slot.isRuleOverridden))
(slot.id, SlotSnapshot(dishId: slot.dishId, isRuleOverridden: slot.isRuleOverridden, isEatingOut: slot.isEatingOut))
})
hasUndoSnapshot = true
}
@@ -336,7 +340,7 @@ final class HomeViewModel: ObservableObject {
private func firstFreeSlot(in plan: WeekPlan) -> MealSlot? {
plan.slots
.filter { $0.dishId == nil }
.filter { $0.dishId == nil && !$0.isEatingOut }
.sorted { lhs, rhs in
if lhs.dayOfWeek != rhs.dayOfWeek {
return lhs.dayOfWeek < rhs.dayOfWeek
@@ -355,7 +359,7 @@ final class HomeViewModel: ObservableObject {
switch settings.syncModeEnum {
case .weekComplete:
if plan.slots.allSatisfy({ $0.dishId != nil }) {
if plan.slots.allSatisfy({ $0.dishId != nil || $0.isEatingOut }) {
let descriptor = FetchDescriptor<Dish>()
let dishes = (try? plan.modelContext?.fetch(descriptor)) ?? []
syncAllAssignedSlotsToCalendar(plan: plan, dishes: dishes, settings: settings)
@@ -426,6 +430,33 @@ final class HomeViewModel: ObservableObject {
slot.isRuleOverridden = !valid
}
func markEatingOut(slot: MealSlot, plan: WeekPlan, settings: AppSettings) {
captureUndoSnapshot(plan: plan)
if let eventId = slot.calendarEventId {
CalendarService.shared.deleteEvent(eventId: eventId)
slot.calendarEventId = nil
}
slot.dishId = nil
slot.isRuleOverridden = false
slot.isEatingOut = true
plan.updatedAt = Date()
applyCalendarSyncPolicy(plan: plan, settings: settings, shouldNotify: false)
HapticManager.shared.impact(style: .medium)
showToastMessage(localizedString("toast_eating_out_marked", language: settings.languageEnum.resolved()))
}
func unmarkEatingOut(slot: MealSlot, plan: WeekPlan, settings: AppSettings) {
captureUndoSnapshot(plan: plan)
slot.isEatingOut = false
plan.updatedAt = Date()
HapticManager.shared.impact(style: .light)
}
func acknowledgeViolation(slot: MealSlot, plan: WeekPlan) {
slot.isRuleOverridden = false
plan.updatedAt = Date()
}
func confirmInvalidDrop(_ dish: Dish, to slot: MealSlot, plan: WeekPlan, settings: AppSettings) {
invalidDropContext = InvalidDropContext(dish: dish, slot: slot)
HapticManager.shared.notification(type: .warning)
+2
View File
@@ -62,6 +62,8 @@ struct DishDrawerView: View {
.foregroundColor(.mealMoodTextSecondary)
TextField("home_my_dishes_search", text: $searchText)
.font(.mealMoodBody)
.foregroundColor(.mealMoodTextPrimary)
.tint(.mealMoodCoral)
if !searchText.isEmpty {
Button {
searchText = ""
+193 -6
View File
@@ -29,8 +29,11 @@ struct HomeView: View {
@State private var pendingExportPlan: WeekPlan?
@State private var shareImageURL: URL?
@State private var showShareSheet: Bool = false
@State private var showViolationsPanel: Bool = false
private var settings: AppSettings? { allSettings.first }
private var isRunningOnMac: Bool { ProcessInfo.processInfo.isiOSAppOnMac }
private var contentHorizontalPadding: CGFloat { (isRunningOnMac || horizontalSizeClass == .regular) ? 14 : 0 }
var body: some View {
NavigationStack {
@@ -140,6 +143,18 @@ struct HomeView: View {
Label("home_copy_previous_week", systemImage: "doc.on.doc")
}
let violationCount = AutocompleteEngine.findViolations(plan: plan, allDishes: dishes, allTags: tags).count
if violationCount > 0 {
Button {
showViolationsPanel = true
} label: {
Label(
String(format: String(localized: "violations_badge"), violationCount),
systemImage: "exclamationmark.triangle.fill"
)
}
}
Button(role: .destructive) {
viewModel.showResetAlert = true
} label: {
@@ -165,6 +180,7 @@ struct HomeView: View {
}
}
.onAppear {
AnalyticsService.logScreenView("Home")
guard let settings = settings,
let plan = ensureCurrentWeekPlanExists(settings: settings) else { return }
viewModel.reconcileSlotsIfNeeded(plan: plan, settings: settings, context: context)
@@ -222,14 +238,14 @@ struct HomeView: View {
}
.frame(maxHeight: .infinity)
}
.frame(maxWidth: horizontalSizeClass == .regular ? 1160 : .infinity)
.frame(maxWidth: .infinity)
.frame(maxWidth: .infinity, alignment: .top)
.padding(.horizontal, horizontalSizeClass == .regular ? 14 : 0)
.padding(.horizontal, contentHorizontalPadding)
.padding(.bottom, 0)
}
.frame(maxHeight: .infinity, alignment: .top)
.safeAreaInset(edge: .bottom, spacing: 0) {
if !settings.isPremium {
if !settings.isPremium && !isRunningOnMac {
AdBannerView()
}
}
@@ -366,7 +382,6 @@ struct HomeView: View {
if isValid {
viewModel.assignDish(dish, to: freshSlot, plan: plan, settings: settings)
} else {
// In picker flow, assign anyway and mark as override so the action is never lost.
viewModel.assignDish(dish, to: freshSlot, plan: plan, settings: settings, isOverride: true)
}
selectedEmptySlotId = nil
@@ -376,6 +391,14 @@ struct HomeView: View {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
viewModel.showDishForm = true
}
},
onEatingOut: {
guard let freshSlot = plan.slots.first(where: { $0.id == slotId }) else {
selectedEmptySlotId = nil
return
}
selectedEmptySlotId = nil
viewModel.markEatingOut(slot: freshSlot, plan: plan, settings: settings)
}
)
}
@@ -405,9 +428,23 @@ struct HomeView: View {
}
.sheet(isPresented: $showShareSheet) {
if let shareImageURL {
ShareSheet(activityItems: [shareImageURL])
SocialShareSheet(imageURL: shareImageURL)
}
}
.sheet(isPresented: $showViolationsPanel) {
RuleViolationsPanelSheet(
plan: plan,
dishes: dishes,
tags: tags,
settings: settings,
onFix: { slot in
viewModel.removeDish(from: slot, plan: plan, settings: settings)
},
onIgnore: { slot in
viewModel.acknowledgeViolation(slot: slot, plan: plan)
}
)
}
.sheet(isPresented: $showMonthlyHistory) {
MonthlyHistoryView(
weekPlans: weekPlans,
@@ -427,6 +464,7 @@ struct HomeView: View {
)
wasWeekComplete = isWeekComplete(plan: plan)
evaluatePostOnboardingPromptsIfNeeded(plan: plan, settings: settings)
updateWidget(settings: settings)
}
.onChange(of: plan.updatedAt) { _, _ in
let nowComplete = isWeekComplete(plan: plan)
@@ -434,6 +472,7 @@ struct HomeView: View {
evaluateReviewPrompt()
}
wasWeekComplete = nowComplete
updateWidget(settings: settings)
}
}
@@ -442,6 +481,7 @@ struct HomeView: View {
let tags: [Tag]
let onPickDish: (Dish) -> Void
let onCreateDish: () -> Void
var onEatingOut: (() -> Void)? = nil
@Environment(\.dismiss) private var dismiss
@State private var searchText: String = ""
@@ -477,6 +517,18 @@ struct HomeView: View {
.padding(24)
} else {
List {
if let onEatingOut, searchText.isEmpty {
Button {
dismiss()
onEatingOut()
} label: {
Label("home_mark_eating_out", systemImage: "fork.knife.circle")
.font(.mealMoodBody)
.foregroundColor(Color(hex: "#6B9E90"))
}
.listRowBackground(Color(hex: "#EEF8F5"))
}
ForEach(filteredDishes, id: \.id) { dish in
Button {
onPickDish(dish)
@@ -596,7 +648,12 @@ struct HomeView: View {
}
private func isWeekComplete(plan: WeekPlan) -> Bool {
plan.slots.allSatisfy { $0.dishId != nil }
plan.slots.allSatisfy { $0.dishId != nil || $0.isEatingOut }
}
private func updateWidget(settings: AppSettings) {
let todayPlan = fetchWeekPlan(for: Date().startOfWeek())
WidgetDataStore.update(plan: todayPlan, dishes: dishes, settings: settings)
}
@ViewBuilder
@@ -678,6 +735,7 @@ struct HomeView: View {
}
shareImageURL = url
showShareSheet = true
AnalyticsService.logWeekPlanShared(format: settings.weekExportStyleEnum.rawValue)
}
private func renderWeekShareImage(plan: WeekPlan, settings: AppSettings) -> UIImage? {
@@ -777,6 +835,135 @@ struct HomeView: View {
}
}
private struct RuleViolationsPanelSheet: View {
let plan: WeekPlan
let dishes: [Dish]
let tags: [Tag]
let settings: AppSettings
let onFix: (MealSlot) -> Void
let onIgnore: (MealSlot) -> Void
@Environment(\.dismiss) private var dismiss
private var violations: [AutocompleteEngine.RuleViolation] {
AutocompleteEngine.findViolations(plan: plan, allDishes: dishes, allTags: tags)
}
var body: some View {
NavigationStack {
Group {
if violations.isEmpty {
VStack(spacing: 12) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 44))
.foregroundColor(.mealMoodSuccess)
Text("violations_panel_empty")
.font(.mealMoodBody)
.foregroundColor(.mealMoodTextSecondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(24)
} else {
List {
ForEach(violations, id: \.slotId) { violation in
if let slot = plan.slots.first(where: { $0.id == violation.slotId }) {
ViolationRow(
violation: violation,
settings: settings,
onFix: {
onFix(slot)
if violations.count <= 1 { dismiss() }
},
onIgnore: {
onIgnore(slot)
if violations.count <= 1 { dismiss() }
}
)
.listRowBackground(Color.mealMoodSurface)
}
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
}
}
.background(Color.mealMoodBackground.ignoresSafeArea())
.navigationTitle("violations_panel_title")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("tag_selector_done") { dismiss() }
}
}
}
}
private struct ViolationRow: View {
let violation: AutocompleteEngine.RuleViolation
let settings: AppSettings
let onFix: () -> Void
let onIgnore: () -> Void
private var dayLabel: String {
let language = settings.languageEnum.resolved()
return localizedString(dayKey(for: violation.dayOfWeek), language: language)
}
private var mealLabel: String {
let language = settings.languageEnum.resolved()
return localizedString(violation.mealType, language: language)
}
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.mealMoodWarning)
.font(.system(size: 14))
Text("\(dayLabel) · \(mealLabel)")
.font(.mealMoodSmall)
.foregroundColor(.mealMoodTextSecondary)
}
Text(violation.dishName)
.font(.mealMoodBodyBold)
.foregroundColor(.mealMoodTextPrimary)
HStack(spacing: 10) {
Button(action: onFix) {
Text("violations_fix")
.font(.mealMoodSmall)
.foregroundColor(.white)
.padding(.horizontal, 14)
.padding(.vertical, 6)
.background(Color.mealMoodCoral)
.clipShape(Capsule())
}
.buttonStyle(.plain)
Button(action: onIgnore) {
Text("violations_ignore")
.font(.mealMoodSmall)
.foregroundColor(.mealMoodTextSecondary)
.padding(.horizontal, 14)
.padding(.vertical, 6)
.background(Color(hex: "#F0F0F0"))
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
}
.padding(.vertical, 8)
}
private func dayKey(for day: Int) -> String {
let keys = ["day_mon","day_tue","day_wed","day_thu","day_fri","day_sat","day_sun"]
guard day >= 0 && day < keys.count else { return "" }
return keys[day]
}
}
}
private struct ShareSheet: UIViewControllerRepresentable {
let activityItems: [Any]
+138
View File
@@ -0,0 +1,138 @@
import SwiftUI
import UIKit
struct SocialShareSheet: View {
let imageURL: URL
@State private var showSystemShare = false
@State private var showCopiedToast = false
@Environment(\.dismiss) private var dismiss
private var image: UIImage? { UIImage(contentsOfFile: imageURL.path) }
var body: some View {
NavigationStack {
VStack(spacing: 24) {
if let img = image {
Image(uiImage: img)
.resizable()
.scaledToFit()
.frame(maxHeight: 220)
.clipShape(RoundedRectangle(cornerRadius: 14))
.shadow(color: .black.opacity(0.12), radius: 14, x: 0, y: 6)
}
VStack(spacing: 10) {
if canOpenInstagram {
shareOptionRow(
title: "Instagram Stories",
subtitle: String(localized: "share_instagram_subtitle"),
icon: "camera.fill",
color: Color(red: 0.80, green: 0.18, blue: 0.75),
action: shareToInstagramStories
)
}
shareOptionRow(
title: String(localized: "share_copy_image"),
subtitle: String(localized: "share_copy_subtitle"),
icon: "doc.on.doc.fill",
color: .mealMoodCoral,
action: copyImage
)
shareOptionRow(
title: String(localized: "share_more_options"),
subtitle: String(localized: "share_more_subtitle"),
icon: "square.and.arrow.up.fill",
color: Color(hex: "#4A6CF7"),
action: { showSystemShare = true }
)
}
Spacer()
}
.padding(20)
.navigationTitle(String(localized: "share_export_sheet_title"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: "dish_cancel")) { dismiss() }
}
}
}
.toast(isShowing: $showCopiedToast, message: String(localized: "share_copied"))
.sheet(isPresented: $showSystemShare) {
SystemShareSheet(activityItems: [imageURL])
}
}
// MARK: - Row view
private func shareOptionRow(title: String, subtitle: String, icon: String, color: Color, action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack(spacing: 14) {
Image(systemName: icon)
.font(.system(size: 18, weight: .semibold))
.foregroundColor(.white)
.frame(width: 44, height: 44)
.background(color)
.clipShape(RoundedRectangle(cornerRadius: 12))
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.mealMoodBodyBold)
.foregroundColor(.mealMoodTextPrimary)
Text(subtitle)
.font(.mealMoodCaption)
.foregroundColor(.mealMoodTextSecondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .semibold))
.foregroundColor(.mealMoodTextSecondary)
}
.padding(12)
.background(Color.mealMoodSurface)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.buttonStyle(.plain)
}
// MARK: - Actions
private var canOpenInstagram: Bool {
guard let url = URL(string: "instagram-stories://share") else { return false }
return UIApplication.shared.canOpenURL(url)
}
private func shareToInstagramStories() {
guard let img = image,
let data = img.pngData(),
let url = URL(string: "instagram-stories://share?source_application=com.alexandrevazquez.mealmood")
else { return }
UIPasteboard.general.setData(data, forPasteboardType: "com.instagram.sharedSticker.backgroundImage")
UIApplication.shared.open(url)
dismiss()
}
private func copyImage() {
guard let img = image else { return }
UIPasteboard.general.image = img
showCopiedToast = true
HapticManager.shared.notification(type: .success)
}
}
// MARK: - System share wrapper (private)
private struct SystemShareSheet: UIViewControllerRepresentable {
let activityItems: [Any]
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: activityItems, applicationActivities: nil)
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
}
+13 -2
View File
@@ -218,7 +218,8 @@ struct WeekCalendarView: View {
dayOfWeek: day,
mealType: mealType.rawValue,
dishId: preferred.dishId,
isRuleOverridden: preferred.isRuleOverridden
isRuleOverridden: preferred.isRuleOverridden,
isEatingOut: preferred.isEatingOut
)
}
@@ -279,6 +280,14 @@ struct WeekCalendarView: View {
} : nil
)
.draggable("slot:\(slot.slotId.uuidString)")
} else if let slot = slot, slot.isEatingOut {
EatingOutSlotView(mealType: mealType)
.contentShape(Rectangle())
.onTapGesture {
guard viewModel.canEditCurrentWeek else { return }
guard let live = liveSlot(for: slot) else { return }
viewModel.unmarkEatingOut(slot: live, plan: plan, settings: settings)
}
} else if let slot = slot {
EmptySlotView(mealType: mealType)
.contentShape(Rectangle())
@@ -309,6 +318,7 @@ struct WeekCalendarView: View {
let mealType: String
let dishId: UUID?
let isRuleOverridden: Bool
let isEatingOut: Bool
}
private var dishesSnapshotKey: String {
@@ -354,7 +364,8 @@ struct WeekCalendarView: View {
dayOfWeek: preferred.dayOfWeek,
mealType: preferred.mealType,
dishId: preferred.dishId,
isRuleOverridden: preferred.isRuleOverridden
isRuleOverridden: preferred.isRuleOverridden,
isEatingOut: preferred.isEatingOut
)
}
+86 -41
View File
@@ -151,17 +151,29 @@ struct WeekPlanShareView: View {
private var verticalBackground: some View {
ZStack {
Color(hex: "#FFFDF8")
LinearGradient(
colors: [Color(hex: "#FFF7F4"), Color(hex: "#FFF3F7"), Color(hex: "#F6FFF8")],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
Ellipse()
.fill(Color.mealMoodMint.opacity(0.28))
.frame(width: 1100, height: 900)
.offset(x: 800, y: -1200)
Circle()
.fill(Color(hex: "#FFD0DF").opacity(0.6))
.frame(width: 1100, height: 1100)
.blur(radius: 90)
.offset(x: 950, y: -1100)
Ellipse()
.fill(Color.mealMoodCoral.opacity(0.2))
.frame(width: 900, height: 680)
.offset(x: -800, y: 1200)
Circle()
.fill(Color(hex: "#C0EAD8").opacity(0.55))
.frame(width: 1000, height: 1000)
.blur(radius: 80)
.offset(x: -900, y: 1300)
Circle()
.fill(Color(hex: "#FFE0C0").opacity(0.4))
.frame(width: 700, height: 700)
.blur(radius: 100)
.offset(x: 100, y: 200)
}
}
@@ -336,58 +348,91 @@ struct WeekPlanShareView: View {
private var verticalLayout: some View {
ZStack {
RoundedRectangle(cornerRadius: 30)
.fill(Color.white.opacity(0.95))
RoundedRectangle(cornerRadius: 32)
.fill(Color.white.opacity(0.88))
.overlay(
RoundedRectangle(cornerRadius: 30)
.stroke(Color.mealMoodMint.opacity(0.45), lineWidth: 2)
RoundedRectangle(cornerRadius: 32)
.stroke(
LinearGradient(
colors: [Color(hex: "#F0C0CC").opacity(0.7), Color(hex: "#B8E8D4").opacity(0.7)],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
lineWidth: 3
)
)
VStack(spacing: 16) {
VStack(spacing: 20) {
ForEach(Array(dayRange), id: \.self) { day in
VStack(alignment: .leading, spacing: 10) {
Text(dayHeaderTitle(for: day).uppercased(with: locale))
.font(.custom("Didot", size: 50))
.foregroundColor(.mealMoodTextPrimary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 20)
.padding(.vertical, 10)
.background(dayHeaderBackground(day: day))
.clipShape(RoundedRectangle(cornerRadius: 12))
HStack(spacing: 0) {
VStack(spacing: 12) {
Text(dayLongTitle(for: day).uppercased(with: locale))
.font(.custom("Didot", size: 68))
.foregroundColor(Color(hex: "#2C3E35"))
.multilineTextAlignment(.center)
.lineLimit(1)
.minimumScaleFactor(0.38)
.frame(maxWidth: .infinity)
VStack(alignment: .leading, spacing: 8) {
Text(dayNumberOnly(for: day))
.font(.system(size: 96, weight: .bold, design: .rounded))
.foregroundColor(Color(hex: "#2C3E35").opacity(0.6))
}
.frame(width: 420)
.frame(maxHeight: .infinity)
.padding(.vertical, 28)
.background(dayHeaderBackground(day: day))
VStack(alignment: .leading, spacing: 24) {
ForEach(mealTypes, id: \.self) { mealType in
HStack(alignment: .top, spacing: 10) {
Text("\(mealTypeLabel(mealType)):")
.font(.system(size: 34, weight: .bold, design: .serif))
.foregroundColor(Color(hex: "#385549"))
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 14) {
Image(systemName: mealType.icon)
.font(.system(size: 42, weight: .medium))
.foregroundColor(mealType == .lunch ? Color(hex: "#C85E2A") : Color(hex: "#27604A"))
.frame(width: 56)
Text(mealTypeLabel(mealType).uppercased(with: locale))
.font(.system(size: 42, weight: .bold, design: .serif))
.foregroundColor(mealType == .lunch ? Color(hex: "#C85E2A") : Color(hex: "#27604A"))
}
Text(dishName(day: day, mealType: mealType))
.font(.system(size: 35, weight: .regular, design: .serif))
.font(.system(size: 62, weight: .medium, design: .serif))
.foregroundColor(.mealMoodTextPrimary)
.lineLimit(2)
.minimumScaleFactor(0.7)
.minimumScaleFactor(0.5)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.leading, 70)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 6)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.padding(.horizontal, 40)
.padding(.vertical, 28)
.background(Color(hex: "#FFFCF8"))
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background(Color.white)
.frame(maxHeight: .infinity)
.clipShape(RoundedRectangle(cornerRadius: 20))
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(Color(hex: "#EEE4DE"), lineWidth: 1)
RoundedRectangle(cornerRadius: 20)
.stroke(Color(hex: "#EAD8D0"), lineWidth: 1.5)
)
.clipShape(RoundedRectangle(cornerRadius: 16))
.shadow(color: Color.black.opacity(0.04), radius: 10, x: 0, y: 4)
}
}
.padding(.horizontal, 22)
.padding(.vertical, 20)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.horizontal, 28)
.padding(.vertical, 26)
}
}
private func dayNumberOnly(for day: Int) -> String {
let dayDate = plan.weekStartDate.addingDays(day)
let formatter = DateFormatter()
formatter.locale = locale
formatter.dateFormat = "d"
return formatter.string(from: dayDate)
}
private var footerBlock: some View {
HStack(alignment: .center, spacing: 20) {
VStack(alignment: .leading, spacing: 6) {
+126 -15
View File
@@ -4,24 +4,26 @@ struct WelcomeStepView: View {
var onNext: () -> Void
var body: some View {
VStack(spacing: 32) {
VStack(spacing: 0) {
Spacer()
AppIconPlaceholder(size: 120)
AppIconPlaceholder(size: 96)
.padding(.bottom, 12)
VStack(spacing: 12) {
Text("onboarding_welcome_subtitle")
.font(.mealMoodBody)
.foregroundColor(.mealMoodTextSecondary)
.multilineTextAlignment(.center)
}
.padding(.horizontal, 24)
Text("onboarding_welcome_subtitle")
.font(.mealMoodBody)
.foregroundColor(.mealMoodTextSecondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
.padding(.bottom, 24)
VStack(alignment: .leading, spacing: 16) {
BenefitRow(icon: "1.circle.fill", text: String(localized: "onboarding_welcome_step_1"))
BenefitRow(icon: "2.circle.fill", text: String(localized: "onboarding_welcome_step_2"))
BenefitRow(icon: "3.circle.fill", text: String(localized: "onboarding_welcome_step_3"))
BenefitRow(icon: "4.circle.fill", text: String(localized: "onboarding_welcome_step_4"))
weekPreview
.padding(.bottom, 24)
VStack(alignment: .leading, spacing: 14) {
BenefitRow(icon: "wand.and.stars", text: String(localized: "onboarding_welcome_step_1"))
BenefitRow(icon: "square.and.arrow.up", text: String(localized: "onboarding_welcome_step_3"))
BenefitRow(icon: "icloud", text: String(localized: "onboarding_welcome_step_4"))
}
.padding(.horizontal, 40)
@@ -33,6 +35,115 @@ struct WelcomeStepView: View {
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
// MARK: - Week preview
private var weekPreview: some View {
VStack(alignment: .leading, spacing: 8) {
Text(String(localized: "onboarding_welcome_preview_label"))
.font(.mealMoodCaption)
.foregroundColor(.mealMoodTextSecondary)
.padding(.horizontal, 24)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(sampleDays, id: \.name) { day in
VStack(spacing: 4) {
Text(day.name)
.font(.system(size: 10, weight: .bold))
.foregroundColor(.mealMoodTextPrimary)
.frame(maxWidth: .infinity)
.padding(.vertical, 5)
.background(day.color)
.clipShape(RoundedRectangle(cornerRadius: 8))
Text(day.dish)
.font(.system(size: 10, weight: .medium))
.foregroundColor(.mealMoodTextPrimary)
.multilineTextAlignment(.center)
.lineLimit(2)
.minimumScaleFactor(0.8)
.frame(maxWidth: .infinity, minHeight: 38)
.padding(.horizontal, 4)
.padding(.vertical, 5)
.background(Color.white.opacity(0.92))
.clipShape(RoundedRectangle(cornerRadius: 8))
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color(hex: "#EEE4DE"), lineWidth: 1)
)
}
.frame(width: 66)
}
}
.padding(.horizontal, 20)
.padding(.vertical, 8)
}
.background(
RoundedRectangle(cornerRadius: 14)
.fill(Color.mealMoodBackground)
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(Color.mealMoodCoral.opacity(0.25), lineWidth: 1)
)
)
.padding(.horizontal, 16)
}
}
private var sampleDays: [(name: String, dish: String, color: Color)] {
let lang = Locale.current.language.languageCode?.identifier ?? "en"
switch lang {
case "es":
return [
("Lun", "Pasta al horno", Color(hex: "#FFD5D9")),
("Mar", "Pollo asado", Color(hex: "#FFE1C8")),
("Mié", "Lentejas", Color(hex: "#FFF0C8")),
("Jue", "Salmón", Color(hex: "#DFF2CC")),
("Vie", "Tortilla", Color(hex: "#D3F0E7"))
]
case "fr":
return [
("Lun", "Pâtes gratinées", Color(hex: "#FFD5D9")),
("Mar", "Poulet rôti", Color(hex: "#FFE1C8")),
("Mer", "Lentilles", Color(hex: "#FFF0C8")),
("Jeu", "Saumon", Color(hex: "#DFF2CC")),
("Ven", "Omelette", Color(hex: "#D3F0E7"))
]
case "de":
return [
("Mo", "Nudeln", Color(hex: "#FFD5D9")),
("Di", "Hähnchen", Color(hex: "#FFE1C8")),
("Mi", "Linsensuppe", Color(hex: "#FFF0C8")),
("Do", "Lachs", Color(hex: "#DFF2CC")),
("Fr", "Omelett", Color(hex: "#D3F0E7"))
]
case "it":
return [
("Lun", "Pasta al forno", Color(hex: "#FFD5D9")),
("Mar", "Pollo arrosto", Color(hex: "#FFE1C8")),
("Mer", "Lenticchie", Color(hex: "#FFF0C8")),
("Gio", "Salmone", Color(hex: "#DFF2CC")),
("Ven", "Frittata", Color(hex: "#D3F0E7"))
]
case "pt":
return [
("Seg", "Macarrão", Color(hex: "#FFD5D9")),
("Ter", "Frango assado", Color(hex: "#FFE1C8")),
("Qua", "Lentilhas", Color(hex: "#FFF0C8")),
("Qui", "Salmão", Color(hex: "#DFF2CC")),
("Sex", "Omelete", Color(hex: "#D3F0E7"))
]
default:
return [
("Mon", "Baked pasta", Color(hex: "#FFD5D9")),
("Tue", "Roast chicken", Color(hex: "#FFE1C8")),
("Wed", "Lentil soup", Color(hex: "#FFF0C8")),
("Thu", "Salmon", Color(hex: "#DFF2CC")),
("Fri", "Omelette", Color(hex: "#D3F0E7"))
]
}
}
}
private struct BenefitRow: View {
@@ -42,7 +153,7 @@ private struct BenefitRow: View {
var body: some View {
HStack(spacing: 12) {
Image(systemName: icon)
.font(.system(size: 20))
.font(.system(size: 18))
.foregroundColor(.mealMoodCoral)
.frame(width: 28)
+1
View File
@@ -147,6 +147,7 @@ struct PremiumView: View {
}
private func purchase(_ product: Product) async {
AnalyticsService.logPremiumUpgradeTapped(source: "premium_view")
let result = await storeManager.purchase(product)
switch result {
case .success:
@@ -238,6 +238,7 @@ struct SettingsView: View {
.foregroundColor(.mealMoodTextPrimary)
.tint(.mealMoodCoral)
.onAppear {
AnalyticsService.logScreenView("Settings")
if settings.syncEnabled {
let hasCalendar = viewModel.ensureValidCalendarSelection(settings: settings)
settings.syncEnabled = hasCalendar