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:
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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)";
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user