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:
+338
@@ -0,0 +1,338 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
CFPropertyList (3.0.8)
|
||||
abbrev (0.1.2)
|
||||
addressable (2.8.9)
|
||||
public_suffix (>= 2.0.2, < 8.0)
|
||||
artifactory (3.0.17)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1229.0)
|
||||
aws-sdk-core (3.244.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
base64
|
||||
bigdecimal
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
logger
|
||||
aws-sdk-kms (1.123.0)
|
||||
aws-sdk-core (~> 3, >= 3.244.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.217.0)
|
||||
aws-sdk-core (~> 3, >= 3.244.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.12.1)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
babosa (1.0.4)
|
||||
base64 (0.2.0)
|
||||
benchmark (0.5.0)
|
||||
bigdecimal (4.0.1)
|
||||
claide (1.1.0)
|
||||
colored (1.2)
|
||||
colored2 (3.1.2)
|
||||
commander (4.6.0)
|
||||
highline (~> 2.0.0)
|
||||
csv (3.3.5)
|
||||
declarative (0.0.20)
|
||||
digest-crc (0.7.0)
|
||||
rake (>= 12.0.0, < 14.0.0)
|
||||
domain_name (0.6.20240107)
|
||||
dotenv (2.8.1)
|
||||
emoji_regex (3.2.3)
|
||||
excon (0.112.0)
|
||||
faraday (1.10.5)
|
||||
faraday-em_http (~> 1.0)
|
||||
faraday-em_synchrony (~> 1.0)
|
||||
faraday-excon (~> 1.1)
|
||||
faraday-httpclient (~> 1.0)
|
||||
faraday-multipart (~> 1.0)
|
||||
faraday-net_http (~> 1.0)
|
||||
faraday-net_http_persistent (~> 1.0)
|
||||
faraday-patron (~> 1.0)
|
||||
faraday-rack (~> 1.0)
|
||||
faraday-retry (~> 1.0)
|
||||
ruby2_keywords (>= 0.0.4)
|
||||
faraday-cookie_jar (0.0.8)
|
||||
faraday (>= 0.8.0)
|
||||
http-cookie (>= 1.0.0)
|
||||
faraday-em_http (1.0.0)
|
||||
faraday-em_synchrony (1.0.1)
|
||||
faraday-excon (1.1.0)
|
||||
faraday-httpclient (1.0.1)
|
||||
faraday-multipart (1.2.0)
|
||||
multipart-post (~> 2.0)
|
||||
faraday-net_http (1.0.2)
|
||||
faraday-net_http_persistent (1.2.0)
|
||||
faraday-patron (1.0.0)
|
||||
faraday-rack (1.0.0)
|
||||
faraday-retry (1.0.4)
|
||||
faraday_middleware (1.2.1)
|
||||
faraday (~> 1.0)
|
||||
fastimage (2.4.1)
|
||||
fastlane (2.232.2)
|
||||
CFPropertyList (>= 2.3, < 4.0.0)
|
||||
abbrev (~> 0.1.2)
|
||||
addressable (>= 2.8, < 3.0.0)
|
||||
artifactory (~> 3.0)
|
||||
aws-sdk-s3 (~> 1.197)
|
||||
babosa (>= 1.0.3, < 2.0.0)
|
||||
base64 (~> 0.2.0)
|
||||
benchmark (>= 0.1.0)
|
||||
bundler (>= 1.17.3, < 5.0.0)
|
||||
colored (~> 1.2)
|
||||
commander (~> 4.6)
|
||||
csv (~> 3.3)
|
||||
dotenv (>= 2.1.1, < 3.0.0)
|
||||
emoji_regex (>= 0.1, < 4.0)
|
||||
excon (>= 0.71.0, < 1.0.0)
|
||||
faraday (~> 1.0)
|
||||
faraday-cookie_jar (~> 0.0.6)
|
||||
faraday_middleware (~> 1.0)
|
||||
fastimage (>= 2.1.0, < 3.0.0)
|
||||
fastlane-sirp (>= 1.0.0)
|
||||
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||
google-apis-androidpublisher_v3 (~> 0.3)
|
||||
google-apis-playcustomapp_v1 (~> 0.1)
|
||||
google-cloud-env (>= 1.6.0, <= 2.1.1)
|
||||
google-cloud-storage (~> 1.31)
|
||||
highline (~> 2.0)
|
||||
http-cookie (~> 1.0.5)
|
||||
json (< 3.0.0)
|
||||
jwt (>= 2.1.0, < 3)
|
||||
logger (>= 1.6, < 2.0)
|
||||
mini_magick (>= 4.9.4, < 5.0.0)
|
||||
multipart-post (>= 2.0.0, < 3.0.0)
|
||||
mutex_m (~> 0.3.0)
|
||||
naturally (~> 2.2)
|
||||
nkf (~> 0.2.0)
|
||||
optparse (>= 0.1.1, < 1.0.0)
|
||||
ostruct (>= 0.1.0)
|
||||
plist (>= 3.1.0, < 4.0.0)
|
||||
rubyzip (>= 2.0.0, < 3.0.0)
|
||||
security (= 0.1.5)
|
||||
simctl (~> 1.6.3)
|
||||
terminal-notifier (>= 2.0.0, < 3.0.0)
|
||||
terminal-table (~> 3)
|
||||
tty-screen (>= 0.6.3, < 1.0.0)
|
||||
tty-spinner (>= 0.8.0, < 1.0.0)
|
||||
word_wrap (~> 1.0.0)
|
||||
xcodeproj (>= 1.13.0, < 2.0.0)
|
||||
xcpretty (~> 0.4.1)
|
||||
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
||||
fastlane-sirp (1.0.0)
|
||||
sysrandom (~> 1.0)
|
||||
gh_inspector (1.1.3)
|
||||
google-apis-androidpublisher_v3 (0.97.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-core (0.18.0)
|
||||
addressable (~> 2.5, >= 2.5.1)
|
||||
googleauth (~> 1.9)
|
||||
httpclient (>= 2.8.3, < 3.a)
|
||||
mini_mime (~> 1.0)
|
||||
mutex_m
|
||||
representable (~> 3.0)
|
||||
retriable (>= 2.0, < 4.a)
|
||||
google-apis-iamcredentials_v1 (0.26.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-playcustomapp_v1 (0.17.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-storage_v1 (0.61.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-cloud-core (1.8.0)
|
||||
google-cloud-env (>= 1.0, < 3.a)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-env (2.1.1)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-errors (1.6.0)
|
||||
google-cloud-storage (1.58.0)
|
||||
addressable (~> 2.8)
|
||||
digest-crc (~> 0.4)
|
||||
google-apis-core (>= 0.18, < 2)
|
||||
google-apis-iamcredentials_v1 (~> 0.18)
|
||||
google-apis-storage_v1 (>= 0.42)
|
||||
google-cloud-core (~> 1.6)
|
||||
googleauth (~> 1.9)
|
||||
mini_mime (~> 1.0)
|
||||
googleauth (1.11.2)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-env (~> 2.1)
|
||||
jwt (>= 1.4, < 3.0)
|
||||
multi_json (~> 1.11)
|
||||
os (>= 0.9, < 2.0)
|
||||
signet (>= 0.16, < 2.a)
|
||||
highline (2.0.3)
|
||||
http-cookie (1.0.8)
|
||||
domain_name (~> 0.5)
|
||||
httpclient (2.9.0)
|
||||
mutex_m
|
||||
jmespath (1.6.2)
|
||||
json (2.19.2)
|
||||
jwt (2.10.2)
|
||||
base64
|
||||
logger (1.7.0)
|
||||
mini_magick (4.13.2)
|
||||
mini_mime (1.1.5)
|
||||
multi_json (1.19.1)
|
||||
multipart-post (2.4.1)
|
||||
mutex_m (0.3.0)
|
||||
nanaimo (0.4.0)
|
||||
naturally (2.3.0)
|
||||
nkf (0.2.0)
|
||||
optparse (0.8.1)
|
||||
os (1.1.4)
|
||||
ostruct (0.6.3)
|
||||
plist (3.7.2)
|
||||
public_suffix (7.0.5)
|
||||
rake (13.3.1)
|
||||
representable (3.2.0)
|
||||
declarative (< 0.1.0)
|
||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||
uber (< 0.2.0)
|
||||
retriable (3.4.1)
|
||||
rexml (3.4.4)
|
||||
rouge (3.28.0)
|
||||
ruby2_keywords (0.0.5)
|
||||
rubyzip (2.4.1)
|
||||
security (0.1.5)
|
||||
signet (0.21.0)
|
||||
addressable (~> 2.8)
|
||||
faraday (>= 0.17.5, < 3.a)
|
||||
jwt (>= 1.5, < 4.0)
|
||||
multi_json (~> 1.10)
|
||||
simctl (1.6.10)
|
||||
CFPropertyList
|
||||
naturally
|
||||
sysrandom (1.0.5)
|
||||
terminal-notifier (2.0.0)
|
||||
terminal-table (3.0.2)
|
||||
unicode-display_width (>= 1.1.1, < 3)
|
||||
trailblazer-option (0.1.2)
|
||||
tty-cursor (0.7.1)
|
||||
tty-screen (0.8.2)
|
||||
tty-spinner (0.9.3)
|
||||
tty-cursor (~> 0.7)
|
||||
uber (0.1.0)
|
||||
unicode-display_width (2.6.0)
|
||||
word_wrap (1.0.0)
|
||||
xcodeproj (1.27.0)
|
||||
CFPropertyList (>= 2.3.3, < 4.0)
|
||||
atomos (~> 0.1.3)
|
||||
claide (>= 1.0.2, < 2.0)
|
||||
colored2 (~> 3.1)
|
||||
nanaimo (~> 0.4.0)
|
||||
rexml (>= 3.3.6, < 4.0)
|
||||
xcpretty (0.4.1)
|
||||
rouge (~> 3.28.0)
|
||||
xcpretty-travis-formatter (1.0.1)
|
||||
xcpretty (~> 0.2, >= 0.0.7)
|
||||
|
||||
PLATFORMS
|
||||
arm64-darwin-25
|
||||
ruby
|
||||
|
||||
DEPENDENCIES
|
||||
fastlane
|
||||
|
||||
CHECKSUMS
|
||||
CFPropertyList (3.0.8)
|
||||
abbrev (0.1.2)
|
||||
addressable (2.8.9)
|
||||
artifactory (3.0.17)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1229.0)
|
||||
aws-sdk-core (3.244.0)
|
||||
aws-sdk-kms (1.123.0)
|
||||
aws-sdk-s3 (1.217.0)
|
||||
aws-sigv4 (1.12.1)
|
||||
babosa (1.0.4)
|
||||
base64 (0.2.0)
|
||||
benchmark (0.5.0)
|
||||
bigdecimal (4.0.1)
|
||||
claide (1.1.0)
|
||||
colored (1.2)
|
||||
colored2 (3.1.2)
|
||||
commander (4.6.0)
|
||||
csv (3.3.5)
|
||||
declarative (0.0.20)
|
||||
digest-crc (0.7.0)
|
||||
domain_name (0.6.20240107)
|
||||
dotenv (2.8.1)
|
||||
emoji_regex (3.2.3)
|
||||
excon (0.112.0)
|
||||
faraday (1.10.5)
|
||||
faraday-cookie_jar (0.0.8)
|
||||
faraday-em_http (1.0.0)
|
||||
faraday-em_synchrony (1.0.1)
|
||||
faraday-excon (1.1.0)
|
||||
faraday-httpclient (1.0.1)
|
||||
faraday-multipart (1.2.0)
|
||||
faraday-net_http (1.0.2)
|
||||
faraday-net_http_persistent (1.2.0)
|
||||
faraday-patron (1.0.0)
|
||||
faraday-rack (1.0.0)
|
||||
faraday-retry (1.0.4)
|
||||
faraday_middleware (1.2.1)
|
||||
fastimage (2.4.1)
|
||||
fastlane (2.232.2)
|
||||
fastlane-sirp (1.0.0)
|
||||
gh_inspector (1.1.3)
|
||||
google-apis-androidpublisher_v3 (0.97.0)
|
||||
google-apis-core (0.18.0)
|
||||
google-apis-iamcredentials_v1 (0.26.0)
|
||||
google-apis-playcustomapp_v1 (0.17.0)
|
||||
google-apis-storage_v1 (0.61.0)
|
||||
google-cloud-core (1.8.0)
|
||||
google-cloud-env (2.1.1)
|
||||
google-cloud-errors (1.6.0)
|
||||
google-cloud-storage (1.58.0)
|
||||
googleauth (1.11.2)
|
||||
highline (2.0.3)
|
||||
http-cookie (1.0.8)
|
||||
httpclient (2.9.0)
|
||||
jmespath (1.6.2)
|
||||
json (2.19.2)
|
||||
jwt (2.10.2)
|
||||
logger (1.7.0)
|
||||
mini_magick (4.13.2)
|
||||
mini_mime (1.1.5)
|
||||
multi_json (1.19.1)
|
||||
multipart-post (2.4.1)
|
||||
mutex_m (0.3.0)
|
||||
nanaimo (0.4.0)
|
||||
naturally (2.3.0)
|
||||
nkf (0.2.0)
|
||||
optparse (0.8.1)
|
||||
os (1.1.4)
|
||||
ostruct (0.6.3)
|
||||
plist (3.7.2)
|
||||
public_suffix (7.0.5)
|
||||
rake (13.3.1)
|
||||
representable (3.2.0)
|
||||
retriable (3.4.1)
|
||||
rexml (3.4.4)
|
||||
rouge (3.28.0)
|
||||
ruby2_keywords (0.0.5)
|
||||
rubyzip (2.4.1)
|
||||
security (0.1.5)
|
||||
signet (0.21.0)
|
||||
simctl (1.6.10)
|
||||
sysrandom (1.0.5)
|
||||
terminal-notifier (2.0.0)
|
||||
terminal-table (3.0.2)
|
||||
trailblazer-option (0.1.2)
|
||||
tty-cursor (0.7.1)
|
||||
tty-screen (0.8.2)
|
||||
tty-spinner (0.9.3)
|
||||
uber (0.1.0)
|
||||
unicode-display_width (2.6.0)
|
||||
word_wrap (1.0.0)
|
||||
xcodeproj (1.27.0)
|
||||
xcpretty (0.4.1)
|
||||
xcpretty-travis-formatter (1.0.1)
|
||||
|
||||
BUNDLED WITH
|
||||
4.0.8
|
||||
@@ -83,6 +83,8 @@
|
||||
FB0011111111111111111101 /* FirebaseAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = FB0011111111111111111105 /* FirebaseAnalytics */; };
|
||||
FB0011111111111111111102 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = FB0011111111111111111103 /* GoogleService-Info.plist */; };
|
||||
FB0011111111111111111106 /* AnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB0011111111111111111107 /* AnalyticsService.swift */; };
|
||||
FB0011111111111111111108 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = FB001111111111111111110B /* FirebaseCrashlytics */; };
|
||||
FB0011111111111111111109 /* CrashlyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB001111111111111111110A /* CrashlyticsService.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -190,6 +192,7 @@
|
||||
F766AA7898D6F391DE1B9A81 /* MealMoodTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MealMoodTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
FB0011111111111111111103 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
|
||||
FB0011111111111111111107 /* AnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService.swift; sourceTree = "<group>"; };
|
||||
FB001111111111111111110A /* CrashlyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrashlyticsService.swift; sourceTree = "<group>"; };
|
||||
FC01EE9D78D980727A644CDF /* View+MealCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+MealCard.swift"; sourceTree = "<group>"; };
|
||||
FD4FD72583C004A5D5C939A3 /* FilledSlotView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilledSlotView.swift; sourceTree = "<group>"; };
|
||||
FD621CE9B44512AE8894D92E /* OnboardingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewModel.swift; sourceTree = "<group>"; };
|
||||
@@ -214,6 +217,7 @@
|
||||
files = (
|
||||
4D53B5101AE845D7C03A0EB4 /* GoogleMobileAds in Frameworks */,
|
||||
FB0011111111111111111101 /* FirebaseAnalytics in Frameworks */,
|
||||
FB0011111111111111111108 /* FirebaseCrashlytics in Frameworks */,
|
||||
C3CB4ACC213DAAD25E610C56 /* WidgetKit.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -460,6 +464,7 @@
|
||||
children = (
|
||||
812303C94C8C1D2FADF3CB37 /* AdMobConfig.swift */,
|
||||
FB0011111111111111111107 /* AnalyticsService.swift */,
|
||||
FB001111111111111111110A /* CrashlyticsService.swift */,
|
||||
CE7AB2ECCA7D330E50AC549E /* AutocompleteEngine.swift */,
|
||||
B0A631C6D0CF7A0724ACF6A5 /* CalendarService.swift */,
|
||||
17E00AB23342224B3407838B /* DefaultDataService.swift */,
|
||||
@@ -519,6 +524,7 @@
|
||||
60AC9519630B6008787A8859 /* Resources */,
|
||||
9240A703A486116A9871C534 /* Frameworks */,
|
||||
DF9575A974376FF5C6420060 /* Embed App Extensions */,
|
||||
FB001111111111111111110C /* Upload dSYM to Crashlytics */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -528,6 +534,7 @@
|
||||
packageProductDependencies = (
|
||||
192E7CF40FD34C3FF4DDFDA3 /* GoogleMobileAds */,
|
||||
FB0011111111111111111105 /* FirebaseAnalytics */,
|
||||
FB001111111111111111110B /* FirebaseCrashlytics */,
|
||||
);
|
||||
productName = MealMood;
|
||||
productReference = C7B13F4D4D362E50CA65A0C1 /* MealMood.app */;
|
||||
@@ -677,11 +684,31 @@
|
||||
17E1054EE183486E7BE38C19 /* WidgetDataStore.swift in Sources */,
|
||||
233B9C1A5CA7E938318491C0 /* SocialShareSheet.swift in Sources */,
|
||||
25B3C328E088A403CABAF20A /* PremiumSyncService.swift in Sources */,
|
||||
FB0011111111111111111109 /* CrashlyticsService.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
FB001111111111111111110C /* Upload dSYM to Crashlytics */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
"${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Resources/DWARF/${TARGET_NAME}",
|
||||
"$(SRCROOT)/$(BUILT_PRODUCTS_DIR)/$(INFOPLIST_PATH)",
|
||||
);
|
||||
name = "Upload dSYM to Crashlytics";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${BUILD_DIR%Build/*}SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run\"\n";
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
C4E8F43173BDFCAE4D6DFF49 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
@@ -828,7 +855,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = MealMood/Resources/MealMood.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CURRENT_PROJECT_VERSION = 21;
|
||||
CURRENT_PROJECT_VERSION = 22;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = MealMood/Resources/Info.plist;
|
||||
@@ -837,7 +864,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.2;
|
||||
MARKETING_VERSION = 1.0.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.mealmood;
|
||||
PRODUCT_NAME = MealMood;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -933,7 +960,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = MealMood/Resources/MealMood.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CURRENT_PROJECT_VERSION = 21;
|
||||
CURRENT_PROJECT_VERSION = 22;
|
||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = MealMood/Resources/Info.plist;
|
||||
@@ -942,7 +969,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.2;
|
||||
MARKETING_VERSION = 1.0.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.mealmood;
|
||||
PRODUCT_NAME = MealMood;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -1021,6 +1048,11 @@
|
||||
package = FB0011111111111111111104 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */;
|
||||
productName = FirebaseAnalytics;
|
||||
};
|
||||
FB001111111111111111110B /* FirebaseCrashlytics */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = FB0011111111111111111104 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */;
|
||||
productName = FirebaseCrashlytics;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 7C11D794B7E45A145D5ADBFD /* Project object */;
|
||||
|
||||
@@ -1,6 +1,123 @@
|
||||
{
|
||||
"originHash" : "3b9752ff554d5fdc91a7c01c2322a5d851b0979c36f02eb55188d9ab9e3d7e77",
|
||||
"originHash" : "f485fb75b59651d1d5e1bfe15bfd2a2b6dd9737b87cd29eaaa02288efe4fe401",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "abseil-cpp-binary",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/abseil-cpp-binary.git",
|
||||
"state" : {
|
||||
"revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5",
|
||||
"version" : "1.2024072200.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "app-check",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/app-check.git",
|
||||
"state" : {
|
||||
"revision" : "61b85103a1aeed8218f17c794687781505fbbef5",
|
||||
"version" : "11.2.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "firebase-ios-sdk",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/firebase/firebase-ios-sdk.git",
|
||||
"state" : {
|
||||
"revision" : "fdc352fabaf5916e7faa1f96ad02b1957e93e5a5",
|
||||
"version" : "11.15.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "google-ads-on-device-conversion-ios-sdk",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
|
||||
"state" : {
|
||||
"revision" : "a2d0f1f1666de591eb1a811f40b1706f5c63a2ed",
|
||||
"version" : "2.3.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "googleappmeasurement",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/GoogleAppMeasurement.git",
|
||||
"state" : {
|
||||
"revision" : "45ce435e9406d3c674dd249a042b932bee006f60",
|
||||
"version" : "11.15.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "googledatatransport",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/GoogleDataTransport.git",
|
||||
"state" : {
|
||||
"revision" : "617af071af9aa1d6a091d59a202910ac482128f9",
|
||||
"version" : "10.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "googleutilities",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/GoogleUtilities.git",
|
||||
"state" : {
|
||||
"revision" : "60da361632d0de02786f709bdc0c4df340f7613e",
|
||||
"version" : "8.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "grpc-binary",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/grpc-binary.git",
|
||||
"state" : {
|
||||
"revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6",
|
||||
"version" : "1.69.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "gtm-session-fetcher",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/gtm-session-fetcher.git",
|
||||
"state" : {
|
||||
"revision" : "c756a29784521063b6a1202907e2cc47f41b667c",
|
||||
"version" : "4.5.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "interop-ios-for-google-sdks",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/interop-ios-for-google-sdks.git",
|
||||
"state" : {
|
||||
"revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe",
|
||||
"version" : "101.0.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "leveldb",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/firebase/leveldb.git",
|
||||
"state" : {
|
||||
"revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1",
|
||||
"version" : "1.22.5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "nanopb",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/firebase/nanopb.git",
|
||||
"state" : {
|
||||
"revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1",
|
||||
"version" : "2.30910.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "promises",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/promises.git",
|
||||
"state" : {
|
||||
"revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac",
|
||||
"version" : "2.4.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-package-manager-google-mobile-ads",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -18,6 +135,15 @@
|
||||
"revision" : "708a282840c2171ee63bd93b87afa49fe507d70e",
|
||||
"version" : "2.7.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-protobuf",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-protobuf.git",
|
||||
"state" : {
|
||||
"revision" : "5596511fce902e649c403cd4d6d5da1254f142b7",
|
||||
"version" : "1.35.1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import XCTest
|
||||
import SwiftData
|
||||
@testable import MealMood
|
||||
|
||||
@MainActor
|
||||
final class PremiumSyncServiceTests: XCTestCase {
|
||||
|
||||
var service: PremiumSyncService!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
service = PremiumSyncService()
|
||||
}
|
||||
|
||||
// MARK: - Active subscription
|
||||
|
||||
func testSync_activeSubscription_whenFree_upgradesTo_premium() async {
|
||||
service.hasActiveSubscription = { true }
|
||||
let result = await service.sync(currentIsPremium: false, isColdLaunch: true)
|
||||
XCTAssertTrue(result, "Active subscription should upgrade free user to premium")
|
||||
}
|
||||
|
||||
func testSync_activeSubscription_whenAlreadyPremium_staysPremium() async {
|
||||
service.hasActiveSubscription = { true }
|
||||
let result = await service.sync(currentIsPremium: true, isColdLaunch: true)
|
||||
XCTAssertTrue(result, "Active subscription keeps premium status")
|
||||
}
|
||||
|
||||
func testSync_activeSubscription_onForeground_upgradesCorrectly() async {
|
||||
service.hasActiveSubscription = { true }
|
||||
let result = await service.sync(currentIsPremium: false, isColdLaunch: false)
|
||||
XCTAssertTrue(result, "Active subscription upgrades on foreground activation")
|
||||
}
|
||||
|
||||
// MARK: - No subscription, was free
|
||||
|
||||
func testSync_noSubscription_whenFree_coldLaunch_staysFree() async {
|
||||
service.hasActiveSubscription = { false }
|
||||
let result = await service.sync(currentIsPremium: false, isColdLaunch: true)
|
||||
XCTAssertFalse(result, "No subscription and was free → stays free")
|
||||
}
|
||||
|
||||
func testSync_noSubscription_whenFree_foreground_staysFree() async {
|
||||
service.hasActiveSubscription = { false }
|
||||
let result = await service.sync(currentIsPremium: false, isColdLaunch: false)
|
||||
XCTAssertFalse(result, "No subscription and was free → stays free on foreground")
|
||||
}
|
||||
|
||||
// MARK: - No subscription, was premium (expired or timing issue)
|
||||
|
||||
func testSync_noSubscription_whenPremium_coldLaunch_downgrades() async {
|
||||
// Subscription expired between sessions → downgrade on cold launch
|
||||
service.hasActiveSubscription = { false }
|
||||
let result = await service.sync(currentIsPremium: true, isColdLaunch: true)
|
||||
XCTAssertFalse(result, "Expired subscription should downgrade on cold launch")
|
||||
}
|
||||
|
||||
func testSync_noSubscription_whenPremium_foreground_keepsPremium() async {
|
||||
// CRITICAL: StoreKit's Transaction.currentEntitlements can return empty for a
|
||||
// few seconds immediately after a purchase completes. We must NOT downgrade
|
||||
// mid-session — let the Transaction.updates listener handle real revocations.
|
||||
service.hasActiveSubscription = { false }
|
||||
let result = await service.sync(currentIsPremium: true, isColdLaunch: false)
|
||||
XCTAssertTrue(result, "Should not downgrade premium on foreground activation (StoreKit timing protection)")
|
||||
}
|
||||
|
||||
// MARK: - Purchase flow simulation
|
||||
|
||||
func testPurchaseFlow_storeKitReturnsFalseRightAfterPurchase_premiumPreserved() async {
|
||||
// Simulate the exact Apple Review rejection scenario:
|
||||
// 1. User purchases successfully (isPremium = true is set by PremiumView)
|
||||
// 2. StoreKit payment sheet closes → scene becomes .active
|
||||
// 3. syncPremiumStatus(isColdLaunch: false) runs
|
||||
// 4. StoreKit returns false momentarily (receipt not yet propagated)
|
||||
// 5. Expected: premium status is PRESERVED
|
||||
service.hasActiveSubscription = { false }
|
||||
let result = await service.sync(currentIsPremium: true, isColdLaunch: false)
|
||||
XCTAssertTrue(result, "Premium must be preserved immediately after purchase even if StoreKit returns false")
|
||||
}
|
||||
|
||||
func testPurchaseFlow_storeKitReturnsTrueAfterPurchase_premiumConfirmed() async {
|
||||
// Normal happy path: purchase completes, StoreKit confirms
|
||||
service.hasActiveSubscription = { true }
|
||||
let result = await service.sync(currentIsPremium: false, isColdLaunch: false)
|
||||
XCTAssertTrue(result, "Purchase confirmed by StoreKit → premium activated")
|
||||
}
|
||||
|
||||
// MARK: - Multiple sync cycles
|
||||
|
||||
func testMultipleForegroundSyncs_withNoSubscription_afterPurchase_keepsPremium() async {
|
||||
// User purchases → goes to background 5 times → StoreKit never confirms
|
||||
// (extreme timing issue). Premium should be preserved in all foreground cycles.
|
||||
service.hasActiveSubscription = { false }
|
||||
var currentPremium = true // Set by purchase
|
||||
|
||||
for _ in 1...5 {
|
||||
currentPremium = await service.sync(currentIsPremium: currentPremium, isColdLaunch: false)
|
||||
}
|
||||
XCTAssertTrue(currentPremium, "Premium must survive multiple foreground cycles when StoreKit timing fails")
|
||||
}
|
||||
|
||||
func testColdLaunchAfterExpiry_downgrades() async {
|
||||
// User had premium, subscription expired, reopens app next day
|
||||
service.hasActiveSubscription = { false }
|
||||
let result = await service.sync(currentIsPremium: true, isColdLaunch: true)
|
||||
XCTAssertFalse(result, "Expired subscription must be detected and downgraded on next cold launch")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - iCloud sync isolation tests
|
||||
|
||||
@MainActor
|
||||
final class ICloudSyncPremiumIsolationTests: XCTestCase {
|
||||
|
||||
func testICloudSync_doesNotOverwritePremiumStatus() async throws {
|
||||
// Arrange: in-memory SwiftData container
|
||||
let config = ModelConfiguration(isStoredInMemoryOnly: true)
|
||||
let container = try ModelContainer(
|
||||
for: AppSettings.self, Tag.self, Dish.self, WeekPlan.self, MealSlot.self,
|
||||
configurations: config
|
||||
)
|
||||
let context = container.mainContext
|
||||
|
||||
let settings = AppSettings()
|
||||
settings.isPremium = true
|
||||
context.insert(settings)
|
||||
try context.save()
|
||||
|
||||
// Verify initial state
|
||||
let before = try context.fetch(FetchDescriptor<AppSettings>()).first
|
||||
XCTAssertTrue(before?.isPremium == true, "Setup: isPremium should be true")
|
||||
|
||||
// Act: simulate what ICloudSyncService.apply does — it should NOT touch isPremium
|
||||
// The fix removes isPremium from SettingsPayload, so apply() can never set it.
|
||||
// We verify by checking that isPremium is unchanged after any settings update.
|
||||
let settingsAfter = try context.fetch(FetchDescriptor<AppSettings>()).first
|
||||
// Simulate a settings-only update (language change etc.) without touching isPremium
|
||||
settingsAfter?.language = "es"
|
||||
try context.save()
|
||||
|
||||
// Assert: isPremium untouched
|
||||
let final = try context.fetch(FetchDescriptor<AppSettings>()).first
|
||||
XCTAssertTrue(final?.isPremium == true, "isPremium must not be overwritten by settings sync operations")
|
||||
XCTAssertEqual(final?.language, "es", "Other settings should still be updated")
|
||||
}
|
||||
|
||||
func testPremiumStatus_persistsAcrossSettingsChanges() async throws {
|
||||
let config = ModelConfiguration(isStoredInMemoryOnly: true)
|
||||
let container = try ModelContainer(
|
||||
for: AppSettings.self, Tag.self, Dish.self, WeekPlan.self, MealSlot.self,
|
||||
configurations: config
|
||||
)
|
||||
let context = container.mainContext
|
||||
|
||||
let settings = AppSettings()
|
||||
settings.isPremium = true
|
||||
context.insert(settings)
|
||||
try context.save()
|
||||
|
||||
// Simulate multiple setting updates
|
||||
let s = try context.fetch(FetchDescriptor<AppSettings>()).first!
|
||||
s.includeWeekends = true
|
||||
s.mealWindows = "dinnerOnly"
|
||||
try context.save()
|
||||
|
||||
let result = try context.fetch(FetchDescriptor<AppSettings>()).first
|
||||
XCTAssertTrue(result?.isPremium == true, "isPremium must persist through unrelated settings changes")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?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>CFBundleDisplayName</key>
|
||||
<string>MealMood</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>XPC!</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.3</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>21</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.widgetkit-extension</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.com.alexandrevazquez.mealmood</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,233 @@
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
import Foundation
|
||||
|
||||
// MARK: - Shared data model (mirrors WidgetDataStore in main app)
|
||||
|
||||
private struct TodayMealData: Codable {
|
||||
let lunch: String?
|
||||
let dinner: String?
|
||||
let lunchLabel: String
|
||||
let dinnerLabel: String
|
||||
let weekdayName: String
|
||||
let updatedAt: Date
|
||||
}
|
||||
|
||||
private enum WidgetStore {
|
||||
static let appGroupID = "group.com.alexandrevazquez.mealmood"
|
||||
static let key = "mealmood.today_meals"
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Timeline
|
||||
|
||||
struct TodayMealEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let lunch: String?
|
||||
let dinner: String?
|
||||
let lunchLabel: String
|
||||
let dinnerLabel: String
|
||||
let weekdayName: String
|
||||
}
|
||||
|
||||
struct Provider: TimelineProvider {
|
||||
func placeholder(in context: Context) -> TodayMealEntry {
|
||||
TodayMealEntry(date: Date(), lunch: "Pasta al pesto", dinner: "Salmón al horno",
|
||||
lunchLabel: "Comida", dinnerLabel: "Cena", weekdayName: "Lunes")
|
||||
}
|
||||
|
||||
func getSnapshot(in context: Context, completion: @escaping (TodayMealEntry) -> Void) {
|
||||
let data = WidgetStore.read()
|
||||
completion(entry(from: data))
|
||||
}
|
||||
|
||||
func getTimeline(in context: Context, completion: @escaping (Timeline<TodayMealEntry>) -> Void) {
|
||||
let data = WidgetStore.read()
|
||||
let nextMidnight = Calendar.current.startOfDay(for: Calendar.current.date(byAdding: .day, value: 1, to: Date())!)
|
||||
completion(Timeline(entries: [entry(from: data)], policy: .after(nextMidnight)))
|
||||
}
|
||||
|
||||
private func entry(from data: TodayMealData?) -> TodayMealEntry {
|
||||
TodayMealEntry(
|
||||
date: Date(),
|
||||
lunch: data?.lunch,
|
||||
dinner: data?.dinner,
|
||||
lunchLabel: data?.lunchLabel ?? "Lunch",
|
||||
dinnerLabel: data?.dinnerLabel ?? "Dinner",
|
||||
weekdayName: data?.weekdayName ?? ""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Colors
|
||||
|
||||
private extension Color {
|
||||
static let mmCoral = Color(red: 0.93, green: 0.42, blue: 0.31)
|
||||
static let mmBgWarm = Color(red: 1.00, green: 0.94, blue: 0.91)
|
||||
static let mmBgMint = Color(red: 0.93, green: 0.98, blue: 0.95)
|
||||
static let mmText = Color(red: 0.13, green: 0.13, blue: 0.13)
|
||||
static let mmSubtext = Color(red: 0.55, green: 0.55, blue: 0.55)
|
||||
static let mmLunchOrange = Color(red: 0.90, green: 0.55, blue: 0.18)
|
||||
static let mmDinnerBlue = Color(red: 0.36, green: 0.48, blue: 0.78)
|
||||
}
|
||||
|
||||
// MARK: - Small widget
|
||||
|
||||
private struct SmallWidgetView: View {
|
||||
let entry: TodayMealEntry
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "fork.knife")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundColor(.mmCoral)
|
||||
Text("MealMood")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundColor(.mmCoral)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
Text(entry.weekdayName)
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundColor(.mmSubtext)
|
||||
.lineLimit(1)
|
||||
.padding(.top, 2)
|
||||
.padding(.bottom, 10)
|
||||
|
||||
Spacer()
|
||||
|
||||
mealRow(icon: "sun.max.fill", label: entry.lunchLabel,
|
||||
name: entry.lunch, color: .mmLunchOrange)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
mealRow(icon: "moon.stars.fill", label: entry.dinnerLabel,
|
||||
name: entry.dinner, color: .mmDinnerBlue)
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||
.background(
|
||||
LinearGradient(colors: [.mmBgWarm, .mmBgMint],
|
||||
startPoint: .topLeading, endPoint: .bottomTrailing)
|
||||
)
|
||||
}
|
||||
|
||||
private func mealRow(icon: String, label: String, name: String?, color: Color) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(color)
|
||||
.frame(width: 18)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(label.uppercased())
|
||||
.font(.system(size: 8, weight: .semibold))
|
||||
.foregroundColor(.mmSubtext)
|
||||
Text(name ?? "—")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundColor(.mmText)
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.75)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Medium widget
|
||||
|
||||
private struct MediumWidgetView: View {
|
||||
let entry: TodayMealEntry
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Image(systemName: "fork.knife")
|
||||
.font(.system(size: 18, weight: .bold))
|
||||
.foregroundColor(.mmCoral)
|
||||
Spacer()
|
||||
Text(entry.weekdayName)
|
||||
.font(.system(size: 16, weight: .bold))
|
||||
.foregroundColor(.mmText)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
Text(Date(), style: .date)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundColor(.mmSubtext)
|
||||
}
|
||||
.padding(14)
|
||||
.frame(width: 112)
|
||||
.frame(maxHeight: .infinity, alignment: .leading)
|
||||
.background(Color.mmBgWarm)
|
||||
|
||||
Rectangle()
|
||||
.fill(Color.mmCoral.opacity(0.2))
|
||||
.frame(width: 1)
|
||||
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
mediumRow(icon: "sun.max.fill", label: entry.lunchLabel,
|
||||
name: entry.lunch, color: .mmLunchOrange)
|
||||
Divider()
|
||||
mediumRow(icon: "moon.stars.fill", label: entry.dinnerLabel,
|
||||
name: entry.dinner, color: .mmDinnerBlue)
|
||||
}
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||
.background(Color.white)
|
||||
}
|
||||
}
|
||||
|
||||
private func mediumRow(icon: String, label: String, name: String?, color: Color) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 16))
|
||||
.foregroundColor(color)
|
||||
.frame(width: 22)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(label.uppercased())
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.foregroundColor(.mmSubtext)
|
||||
Text(name ?? "—")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundColor(.mmText)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Widget entry view
|
||||
|
||||
struct MealMoodWidgetView: View {
|
||||
var entry: Provider.Entry
|
||||
@Environment(\.widgetFamily) var family
|
||||
|
||||
var body: some View {
|
||||
switch family {
|
||||
case .systemMedium:
|
||||
MediumWidgetView(entry: entry)
|
||||
default:
|
||||
SmallWidgetView(entry: entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Widget definition
|
||||
|
||||
@main
|
||||
struct MealMoodWidget: Widget {
|
||||
let kind = "MealMoodWidget"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: kind, provider: Provider()) { entry in
|
||||
MealMoodWidgetView(entry: entry)
|
||||
.containerBackground(for: .widget) { Color.white }
|
||||
}
|
||||
.configurationDisplayName("MealMood")
|
||||
.description("Today's lunch and dinner at a glance.")
|
||||
.supportedFamilies([.systemSmall, .systemMedium])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
app_identifier("com.alexandrevazquez.mealmood") # The bundle identifier of your app
|
||||
apple_id("alexandre.vazquez@gmail.com") # Your Apple Developer Portal username
|
||||
|
||||
itc_team_id("128443966") # App Store Connect Team ID
|
||||
team_id("2825Q76T7H") # Developer Portal Team ID
|
||||
|
||||
# For more information about the Appfile, see:
|
||||
# https://docs.fastlane.tools/advanced/#appfile
|
||||
@@ -0,0 +1 @@
|
||||
run_precheck_before_submit(false)
|
||||
@@ -0,0 +1,784 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>deliver - MealMood: Family Meal Planner
|
||||
</title>
|
||||
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
|
||||
<style>
|
||||
.app-name {
|
||||
font-size: 42px;
|
||||
font-family: 'Helvetica Neue', HelveticaNeue, Helvetica Neue;
|
||||
font-weight: 300;
|
||||
margin-top: 22px;
|
||||
margin-left: 25px;
|
||||
margin-right: 25px;
|
||||
}
|
||||
|
||||
.app-subtitle {
|
||||
font-size: 26px;
|
||||
font-family: 'Helvetica Neue', HelveticaNeue, Helvetica Neue;
|
||||
font-weight: 300;
|
||||
margin-left: 25px;
|
||||
margin-right: 25px;
|
||||
}
|
||||
|
||||
.app-urls {
|
||||
margin-left: 25px;
|
||||
font-size: 14px;
|
||||
margin-top: 4px;
|
||||
margin-right: 25px;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Helvetica Neue', HelveticaNeue;
|
||||
}
|
||||
|
||||
hr {
|
||||
margin-top: 40px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.app-url-descr {
|
||||
height:22px;
|
||||
display: inline-block;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.app-url {
|
||||
color: #0056ba;
|
||||
font-weight: 300;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.app-keyword {
|
||||
margin-left: 25px;
|
||||
margin-right: 25px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.cat-headline {
|
||||
font-weight: 700;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.app-description {
|
||||
margin-left: 25px;
|
||||
margin-right: 25px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.app-description-text {
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.app-changelog {
|
||||
margin-left: 25px;
|
||||
margin-right: 25px;
|
||||
margin-top: 22px;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.app-screenshots {
|
||||
margin-left: 15px;
|
||||
margin-right: 15px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.app-keyword-list {
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.app-changelog-list {
|
||||
list-style-type: square;
|
||||
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.app-screenshot-row {
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.app-screenshot {
|
||||
width: calc(20% - 30px);
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
#app-screenshots .cat-headline {
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.app-icons {
|
||||
overflow: hidden;
|
||||
}
|
||||
.app-icons img {
|
||||
width: 150px;
|
||||
}
|
||||
.app-icons .app-icon {
|
||||
float: left;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
.app-minor-information {
|
||||
margin-left: 15px;
|
||||
margin-right: 15px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.app-minor-information-key {
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="app-icons">
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="app-name">
|
||||
de-DE: MealMood: Wochenmenü-Planer
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="app-subtitle">
|
||||
Subtitle: Planen, teilen und ausdrucken
|
||||
</div>
|
||||
|
||||
|
||||
<div class="app-urls">
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="app-url-descr">
|
||||
support_url: <a target="_blank" class="app-url" href="https://mealmood.app
|
||||
">https://mealmood.app
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="app-keyword">
|
||||
<div class="cat-headline">Keywords</div>
|
||||
<ul class="app-keyword-list">
|
||||
|
||||
<li>Essensplaner</li>
|
||||
|
||||
<li>Wochenplan</li>
|
||||
|
||||
<li>Familienessen</li>
|
||||
|
||||
<li>Menüplan</li>
|
||||
|
||||
<li>Mittagessen planen</li>
|
||||
|
||||
<li>Abendessen</li>
|
||||
|
||||
<li>Rezepte</li>
|
||||
|
||||
<li>Speiseplan</li>
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="app-description">
|
||||
<div class="cat-headline">Description</div>
|
||||
<div class="app-description-text">
|
||||
Plane die Mahlzeiten deiner Familie in Minuten — nicht in Stunden.<br /><br />MealMood ist der einfache, schöne Wochenmenü-Planer für vielbeschäftigte Familien. Füge deine Lieblingsgerichte hinzu, stelle deine Vorlieben ein und lass die App den Rest erledigen.<br /><br />IN SEKUNDEN PLANEN<br />Erstelle eine vollständige Woche mit Mittag- und Abendessen auf Knopfdruck. Die intelligente Autovervollständigung füllt deinen Planer mit deiner Gerichtebibliothek und Tag-Regeln — ohne Wiederholungen, ohne Stress.<br /><br />DEINE GERICHTEBIBLIOTHEK<br />Füge alle Haushaltsfavoriten hinzu und beschrifte sie mit smarten Tags. Stelle Regeln wie „maximal 2 Mal pro Woche" oder „nur Abendessen" ein — MealMood hält sie automatisch ein.<br /><br />WUNDERSCHÖNE EXPORTS — KÜHLSCHRANKFERTIG<br />Teile deinen Wochenplan als Bild in 3 Stilen:<br />• Klassisches Raster<br />• Eleganter Stundenplan-Stil<br />• Hochformat A4 — ausdrucken und an den Kühlschrank hängen<br /><br />IMMER SYNCHRONISIERT<br />iCloud hält deinen Plan auf allen Apple-Geräten aktuell. Ein Plan, überall sichtbar.<br /><br />KALENDERINTEGRATION<br />Synchronisiere Mahlzeiten direkt mit dem Apple-Kalender, damit die ganze Familie immer weiß, was es zu essen gibt.<br /><br />— — —<br /><br />KOSTENLOS<br />• Bis zu 20 Gerichte<br />• Planung der aktuellen Woche<br />• Mit Werbung<br /><br />PREMIUM (Monatsabo)<br />• Unbegrenzte Gerichte<br />• Jede zukünftige Woche planen<br />• Erweiterte Tag-Regeln<br />• Wöchentliche Menü-Exports<br />• Werbefrei<br /><br />— — —<br /><br />Verfügbar auf Deutsch, Spanisch, Englisch, Französisch, Italienisch und Portugiesisch.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="app-changelog">
|
||||
<div class="cat-headline">Changelog</div>
|
||||
Intelligentere Planung in diesem Update:<br /><br />• Die Auto-Vervollständigung vermeidet jetzt die Wiederholung desselben Gerichts, wenn Alternativen verfügbar sind — abwechslungsreichere und ausgewogenere Wochen.<br />• Mehrere Gerichte hinzufügen geht jetzt schneller: Das Formular bleibt nach dem Speichern offen, damit du mehrere hintereinander hinzufügen kannst.<br />• Kalendersynchronisation korrigiert: Ereignisse werden jetzt aktualisiert statt dupliziert, wenn erneut synchronisiert wird.<br />• Einrichtung vereinfacht: Gerichte sind beim ersten Setup jetzt optional — du kannst sie später hinzufügen, wenn du möchtest.<br /><br />Allgemeine Verbesserungen und Fehlerbehebungen.<br />
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="app-changelog">
|
||||
<div class="cat-headline">Promotional Text</div>
|
||||
Plane Mittag- und Abendessen für die ganze Woche in Sekunden. 3 Export-Stile — inkl. A4 für den Kühlschrank. Kostenlos starten.
|
||||
</div>
|
||||
|
||||
|
||||
<div class="app-screenshots">
|
||||
<div class="cat-headline">Screenshots</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
|
||||
<div class="app-name">
|
||||
en-US: MealMood: Family Meal Planner
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="app-subtitle">
|
||||
Subtitle: Plan Your Meals, Share & Print
|
||||
</div>
|
||||
|
||||
|
||||
<div class="app-urls">
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="app-url-descr">
|
||||
support_url: <a target="_blank" class="app-url" href="https://mealmood.app
|
||||
">https://mealmood.app
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="app-url-descr">
|
||||
marketing_url: <a target="_blank" class="app-url" href="https://mealmood.app
|
||||
">https://mealmood.app
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="app-keyword">
|
||||
<div class="cat-headline">Keywords</div>
|
||||
<ul class="app-keyword-list">
|
||||
|
||||
<li>meal planner</li>
|
||||
|
||||
<li>weekly planner</li>
|
||||
|
||||
<li>dinner ideas</li>
|
||||
|
||||
<li>family meals</li>
|
||||
|
||||
<li>lunch planner</li>
|
||||
|
||||
<li>meal schedule</li>
|
||||
|
||||
<li>weekly menu</li>
|
||||
|
||||
<li>recipe</li>
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="app-description">
|
||||
<div class="cat-headline">Description</div>
|
||||
<div class="app-description-text">
|
||||
Plan your family's weekly meals in minutes — not hours.<br /><br />MealMood is the simple, beautiful weekly meal planner for busy families. Build your dish library, plan lunches and dinners for the week, and share a gorgeous printable menu — straight to the fridge.<br /><br />PLAN IN SECONDS<br />Create a full week of lunches and dinners with one tap. MealMood's smart auto-complete fills your planner using your dish library and tag rules — no repetition, no stress.<br /><br />YOUR DISH LIBRARY<br />Add all your household favorites and label them with smart tags. Set rules like "max 2 times per week" or "dinner only" and MealMood will respect them automatically when auto-completing your week.<br /><br />BEAUTIFUL EXPORTS — FRIDGE-READY<br />Share your weekly menu as a gorgeous image in 3 styles:<br />• Classic grid layout<br />• Elegant school timetable style<br />• Vertical A4 portrait — print it and stick it on the fridge<br /><br />ALWAYS IN SYNC<br />iCloud sync keeps your plan up to date across all your Apple devices. One plan, visible everywhere.<br /><br />CALENDAR INTEGRATION<br />Sync your meals directly to Apple Calendar so the whole family always knows what's for lunch and dinner.<br /><br />— — —<br /><br />FREE PLAN<br />• Up to 20 dishes<br />• Plan the current week<br />• Ad-supported<br /><br />PREMIUM (monthly subscription)<br />• Unlimited dishes<br />• Plan any future week<br />• Advanced tag rules<br />• Beautiful week exports<br />• Ad-free experience<br /><br />— — —<br /><br />Available in English, Spanish, French, German, Italian and Portuguese.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="app-changelog">
|
||||
<div class="cat-headline">Changelog</div>
|
||||
Smarter meal planning in this update:<br /><br />• Auto-assign now avoids repeating the same dish twice when alternatives are available — smarter, more varied weekly plans.<br />• Adding multiple dishes is now faster: the form stays open after saving so you can add several in a row.<br />• Calendar sync fixed: events are now updated instead of duplicated when re-syncing.<br />• Onboarding simplified: dishes are now optional during setup — jump in and add them later if you prefer.<br /><br />Various improvements and bug fixes.<br />
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="app-changelog">
|
||||
<div class="cat-headline">Promotional Text</div>
|
||||
Plan lunches & dinners for the whole week in seconds. 3 beautiful export styles — including A4 for the fridge. Free to start, Premium unlocks everything.
|
||||
</div>
|
||||
|
||||
|
||||
<div class="app-screenshots">
|
||||
<div class="cat-headline">Screenshots</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<h4>iPad Pro (12.9-inch) (3rd generation)</h4>
|
||||
<div class="app-screenshot-row">
|
||||
|
||||
|
||||
<a href="screenshots/en-US/0_APP_IPAD_PRO_3GEN_129_0.png" target="_blank"><img class="app-screenshot" src="screenshots/en-US/0_APP_IPAD_PRO_3GEN_129_0.png" title="Screenshot #0 for en-US"></a>
|
||||
|
||||
<a href="screenshots/en-US/1_APP_IPAD_PRO_3GEN_129_1.png" target="_blank"><img class="app-screenshot" src="screenshots/en-US/1_APP_IPAD_PRO_3GEN_129_1.png" title="Screenshot #1 for en-US"></a>
|
||||
|
||||
<a href="screenshots/en-US/2_APP_IPAD_PRO_3GEN_129_2.png" target="_blank"><img class="app-screenshot" src="screenshots/en-US/2_APP_IPAD_PRO_3GEN_129_2.png" title="Screenshot #2 for en-US"></a>
|
||||
|
||||
<a href="screenshots/en-US/3_APP_IPAD_PRO_3GEN_129_3.png" target="_blank"><img class="app-screenshot" src="screenshots/en-US/3_APP_IPAD_PRO_3GEN_129_3.png" title="Screenshot #3 for en-US"></a>
|
||||
|
||||
<a href="screenshots/en-US/4_APP_IPAD_PRO_3GEN_129_4.png" target="_blank"><img class="app-screenshot" src="screenshots/en-US/4_APP_IPAD_PRO_3GEN_129_4.png" title="Screenshot #4 for en-US"></a>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<h4>iPhone 14 Pro Max</h4>
|
||||
<div class="app-screenshot-row">
|
||||
|
||||
|
||||
<a href="screenshots/en-US/0_APP_IPHONE_67_0.png" target="_blank"><img class="app-screenshot" src="screenshots/en-US/0_APP_IPHONE_67_0.png" title="Screenshot #0 for en-US"></a>
|
||||
|
||||
<a href="screenshots/en-US/1_APP_IPHONE_67_1.png" target="_blank"><img class="app-screenshot" src="screenshots/en-US/1_APP_IPHONE_67_1.png" title="Screenshot #1 for en-US"></a>
|
||||
|
||||
<a href="screenshots/en-US/2_APP_IPHONE_67_2.png" target="_blank"><img class="app-screenshot" src="screenshots/en-US/2_APP_IPHONE_67_2.png" title="Screenshot #2 for en-US"></a>
|
||||
|
||||
<a href="screenshots/en-US/3_APP_IPHONE_67_3.png" target="_blank"><img class="app-screenshot" src="screenshots/en-US/3_APP_IPHONE_67_3.png" title="Screenshot #3 for en-US"></a>
|
||||
|
||||
<a href="screenshots/en-US/4_APP_IPHONE_67_4.png" target="_blank"><img class="app-screenshot" src="screenshots/en-US/4_APP_IPHONE_67_4.png" title="Screenshot #4 for en-US"></a>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
|
||||
<div class="app-name">
|
||||
es-ES: MealMood: Planificador Semanal
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="app-subtitle">
|
||||
Subtitle: Planifica, comparte e imprime
|
||||
</div>
|
||||
|
||||
|
||||
<div class="app-urls">
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="app-url-descr">
|
||||
support_url: <a target="_blank" class="app-url" href="https://mealmood.app
|
||||
">https://mealmood.app
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="app-keyword">
|
||||
<div class="cat-headline">Keywords</div>
|
||||
<ul class="app-keyword-list">
|
||||
|
||||
<li>planificador comidas</li>
|
||||
|
||||
<li>menú semanal</li>
|
||||
|
||||
<li>comidas familia</li>
|
||||
|
||||
<li>qué comer</li>
|
||||
|
||||
<li>plan semanal</li>
|
||||
|
||||
<li>cenas semana</li>
|
||||
|
||||
<li>ideas cenas</li>
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="app-description">
|
||||
<div class="cat-headline">Description</div>
|
||||
<div class="app-description-text">
|
||||
Planifica las comidas de la semana en minutos, no en horas.<br /><br />MealMood es el planificador de menú semanal simple y bonito para familias ocupadas. Añade tus platos favoritos, configura tus preferencias y deja que la app haga el resto.<br /><br />PLANIFICA EN SEGUNDOS<br />Crea una semana completa de comidas y cenas con un toque. El autocompletado inteligente rellena tu planificador usando tu biblioteca de platos y reglas de etiquetas — sin repeticiones, sin estrés.<br /><br />TU BIBLIOTECA DE PLATOS<br />Añade todos los favoritos de casa y etiquétalos con reglas inteligentes. Configura reglas como "máximo 2 veces por semana" o "solo cenas" y MealMood las respetará automáticamente.<br /><br />EXPORTACIONES PRECIOSAS — LISTAS PARA LA NEVERA<br />Comparte tu menú semanal como imagen en 3 estilos:<br />• Cuadrícula clásica<br />• Horario escolar elegante<br />• Retrato A4 vertical — imprímelo y ponlo en la nevera<br /><br />SIEMPRE SINCRONIZADO<br />iCloud mantiene tu plan actualizado en todos tus dispositivos Apple. Un plan, visible en todas partes.<br /><br />INTEGRACIÓN CON CALENDARIO<br />Sincroniza tus comidas directamente con el Calendario de Apple para que toda la familia sepa siempre qué hay de comer.<br /><br />— — —<br /><br />PLAN GRATUITO<br />• Hasta 20 platos<br />• Planificación de la semana actual<br />• Con anuncios<br /><br />PREMIUM (suscripción mensual)<br />• Platos ilimitados<br />• Planifica cualquier semana futura<br />• Reglas de etiquetas avanzadas<br />• Exportaciones del menú semanal<br />• Sin anuncios<br /><br />— — —<br /><br />Disponible en español, inglés, francés, alemán, italiano y portugués.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="app-changelog">
|
||||
<div class="cat-headline">Changelog</div>
|
||||
Planificación más inteligente en esta actualización:<br /><br />• El auto-completar ahora evita repetir el mismo plato dos veces si hay alternativas — semanas más variadas y equilibradas.<br />• Añadir varios platos es más rápido: el formulario se queda abierto tras guardar para poder añadir varios seguidos.<br />• Sincronización con el calendario corregida: los eventos ahora se actualizan en lugar de duplicarse al volver a sincronizar.<br />• Incorporación simplificada: los platos ahora son opcionales durante la configuración inicial — puedes añadirlos después si lo prefieres.<br /><br />Mejoras generales y corrección de errores.<br />
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="app-changelog">
|
||||
<div class="cat-headline">Promotional Text</div>
|
||||
Planifica comidas y cenas de toda la semana en segundos. 3 estilos de exportación — incluido A4 para la nevera. Gratis para empezar.
|
||||
</div>
|
||||
|
||||
|
||||
<div class="app-screenshots">
|
||||
<div class="cat-headline">Screenshots</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
|
||||
<div class="app-name">
|
||||
fr-FR: MealMood: Planificateur Repas
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="app-subtitle">
|
||||
Subtitle: Planifiez, partagez, imprimez
|
||||
</div>
|
||||
|
||||
|
||||
<div class="app-urls">
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="app-url-descr">
|
||||
support_url: <a target="_blank" class="app-url" href="https://mealmood.app
|
||||
">https://mealmood.app
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="app-keyword">
|
||||
<div class="cat-headline">Keywords</div>
|
||||
<ul class="app-keyword-list">
|
||||
|
||||
<li>planificateur repas</li>
|
||||
|
||||
<li>menu semaine</li>
|
||||
|
||||
<li>repas famille</li>
|
||||
|
||||
<li>idées dîner</li>
|
||||
|
||||
<li>planning repas</li>
|
||||
|
||||
<li>menu hebdomadaire</li>
|
||||
|
||||
<li>recettes</li>
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="app-description">
|
||||
<div class="cat-headline">Description</div>
|
||||
<div class="app-description-text">
|
||||
Planifiez les repas de la semaine en quelques minutes, pas en heures.<br /><br />MealMood est le planificateur de menus hebdomadaire simple et beau pour les familles actives. Ajoutez vos plats préférés, définissez vos préférences et laissez l'app faire le reste.<br /><br />PLANIFIEZ EN QUELQUES SECONDES<br />Créez une semaine complète de déjeuners et dîners en un tap. La saisie automatique intelligente remplit votre planning avec votre bibliothèque de plats et vos règles d'étiquettes — sans répétition, sans stress.<br /><br />VOTRE BIBLIOTHÈQUE DE PLATS<br />Ajoutez tous vos favoris et étiquetez-les avec des règles intelligentes. Définissez des règles comme « maximum 2 fois par semaine » ou « dîner uniquement » et MealMood les respectera automatiquement.<br /><br />EXPORTS MAGNIFIQUES — PRÊTS POUR LE FRIGO<br />Partagez votre menu en image dans 3 styles :<br />• Grille classique<br />• Emploi du temps élégant<br />• Portrait A4 vertical — imprimez-le et collez-le sur le frigo<br /><br />TOUJOURS SYNCHRONISÉ<br />iCloud maintient votre planning à jour sur tous vos appareils Apple. Un plan, visible partout.<br /><br />INTÉGRATION CALENDRIER<br />Synchronisez vos repas directement dans Calendrier Apple pour que toute la famille sache toujours ce qu'il y a à manger.<br /><br />— — —<br /><br />GRATUIT<br />• Jusqu'à 20 plats<br />• Planification de la semaine en cours<br />• Avec publicités<br /><br />PREMIUM (abonnement mensuel)<br />• Plats illimités<br />• Planifiez n'importe quelle semaine future<br />• Règles d'étiquettes avancées<br />• Exports du menu hebdomadaire<br />• Sans publicité<br /><br />— — —<br /><br />Disponible en français, espagnol, anglais, allemand, italien et portugais.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="app-changelog">
|
||||
<div class="cat-headline">Changelog</div>
|
||||
Une planification plus intelligente dans cette mise à jour :<br /><br />• L'auto-complétion évite désormais de répéter le même plat deux fois lorsqu'il y a des alternatives — des semaines plus variées et équilibrées.<br />• Ajouter plusieurs plats est plus rapide : le formulaire reste ouvert après l'enregistrement pour en ajouter plusieurs à la suite.<br />• Synchronisation du calendrier corrigée : les événements sont maintenant mis à jour au lieu d'être dupliqués lors d'une nouvelle synchronisation.<br />• Démarrage simplifié : les plats sont désormais optionnels pendant la configuration initiale — vous pouvez les ajouter plus tard si vous préférez.<br /><br />Améliorations générales et corrections de bugs.<br />
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="app-changelog">
|
||||
<div class="cat-headline">Promotional Text</div>
|
||||
Planifiez déjeuners et dîners pour toute la semaine en secondes. 3 styles d'export — dont le A4 pour le frigo. Gratuit pour commencer.
|
||||
</div>
|
||||
|
||||
|
||||
<div class="app-screenshots">
|
||||
<div class="cat-headline">Screenshots</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
|
||||
<div class="app-name">
|
||||
it: MealMood: Pianificatore Pasti
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="app-subtitle">
|
||||
Subtitle: Pianifica, condividi, stampa
|
||||
</div>
|
||||
|
||||
|
||||
<div class="app-urls">
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="app-url-descr">
|
||||
support_url: <a target="_blank" class="app-url" href="https://mealmood.app
|
||||
">https://mealmood.app
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="app-keyword">
|
||||
<div class="cat-headline">Keywords</div>
|
||||
<ul class="app-keyword-list">
|
||||
|
||||
<li>pianificatore pasti</li>
|
||||
|
||||
<li>menu settimanale</li>
|
||||
|
||||
<li>pasti famiglia</li>
|
||||
|
||||
<li>idee cena</li>
|
||||
|
||||
<li>piano pasti</li>
|
||||
|
||||
<li>cosa mangiare</li>
|
||||
|
||||
<li>ricette</li>
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="app-description">
|
||||
<div class="cat-headline">Description</div>
|
||||
<div class="app-description-text">
|
||||
Pianifica i pasti della famiglia in pochi minuti, non in ore.<br /><br />MealMood è il pianificatore di menu settimanale semplice e bello per le famiglie indaffarate. Aggiungi i tuoi piatti preferiti, imposta le tue preferenze e lascia che l'app faccia il resto.<br /><br />PIANIFICA IN SECONDI<br />Crea una settimana completa di pranzi e cene con un tocco. Il completamento automatico intelligente riempie il tuo planner con la libreria di piatti e le regole dei tag — niente ripetizioni, niente stress.<br /><br />LA TUA LIBRERIA DI PIATTI<br />Aggiungi tutti i preferiti di casa e etichettali con regole intelligenti. Imposta regole come "massimo 2 volte a settimana" o "solo cena" e MealMood le rispetterà automaticamente.<br /><br />EXPORT BELLISSIMI — PRONTI PER IL FRIGO<br />Condividi il tuo menu settimanale come immagine in 3 stili:<br />• Griglia classica<br />• Elegante orario scolastico<br />• Ritratto A4 verticale — stampalo e mettilo sul frigorifero<br /><br />SEMPRE SINCRONIZZATO<br />iCloud mantiene il tuo piano aggiornato su tutti i dispositivi Apple. Un piano, visibile ovunque.<br /><br />INTEGRAZIONE CALENDARIO<br />Sincronizza i pasti direttamente con Calendario Apple così tutta la famiglia sa sempre cosa c'è da mangiare.<br /><br />— — —<br /><br />GRATUITO<br />• Fino a 20 piatti<br />• Pianificazione della settimana corrente<br />• Con pubblicità<br /><br />PREMIUM (abbonamento mensile)<br />• Piatti illimitati<br />• Pianifica qualsiasi settimana futura<br />• Regole tag avanzate<br />• Export menu settimanale<br />• Senza pubblicità<br /><br />— — —<br /><br />Disponibile in italiano, spagnolo, inglese, francese, tedesco e portoghese.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="app-changelog">
|
||||
<div class="cat-headline">Changelog</div>
|
||||
Pianificazione più intelligente in questo aggiornamento:<br /><br />• Il completamento automatico ora evita di ripetere lo stesso piatto due volte quando ci sono alternative — settimane più varie ed equilibrate.<br />• Aggiungere più piatti è più veloce: il modulo rimane aperto dopo il salvataggio per poterne aggiungere più di uno di seguito.<br />• Sincronizzazione del calendario corretta: gli eventi vengono ora aggiornati invece di essere duplicati quando si risincronizza.<br />• Configurazione semplificata: i piatti sono ora opzionali durante la configurazione iniziale — puoi aggiungerli dopo se preferisci.<br /><br />Miglioramenti generali e correzioni di bug.<br />
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="app-changelog">
|
||||
<div class="cat-headline">Promotional Text</div>
|
||||
Pianifica pranzi e cene per tutta la settimana in secondi. 3 stili di export — incluso A4 per il frigo. Gratis per iniziare.
|
||||
</div>
|
||||
|
||||
|
||||
<div class="app-screenshots">
|
||||
<div class="cat-headline">Screenshots</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
|
||||
<div class="app-name">
|
||||
pt-BR: MealMood: Planejador Semanal
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="app-subtitle">
|
||||
Subtitle: Planeje, compartilhe e imprima
|
||||
</div>
|
||||
|
||||
|
||||
<div class="app-urls">
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="app-url-descr">
|
||||
support_url: <a target="_blank" class="app-url" href="https://mealmood.app
|
||||
">https://mealmood.app
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="app-keyword">
|
||||
<div class="cat-headline">Keywords</div>
|
||||
<ul class="app-keyword-list">
|
||||
|
||||
<li>planejador refeições</li>
|
||||
|
||||
<li>cardápio semanal</li>
|
||||
|
||||
<li>refeições família</li>
|
||||
|
||||
<li>o que comer</li>
|
||||
|
||||
<li>plano alimentar</li>
|
||||
|
||||
<li>jantar</li>
|
||||
|
||||
<li>receitas</li>
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="app-description">
|
||||
<div class="cat-headline">Description</div>
|
||||
<div class="app-description-text">
|
||||
Planeje as refeições da família em minutos — não em horas.<br /><br />MealMood é o planejador de cardápio semanal simples e bonito para famílias ocupadas. Adicione seus pratos favoritos, configure suas preferências e deixe o app fazer o resto.<br /><br />PLANEJE EM SEGUNDOS<br />Crie uma semana completa de almoços e jantares com um toque. O preenchimento automático inteligente preenche seu planner com a biblioteca de pratos e regras de etiquetas — sem repetição, sem estresse.<br /><br />SUA BIBLIOTECA DE PRATOS<br />Adicione todos os favoritos da casa e marque-os com regras inteligentes. Configure regras como "máximo 2 vezes por semana" ou "só jantar" e o MealMood as respeitará automaticamente.<br /><br />EXPORTAÇÕES LINDAS — PRONTAS PARA A GELADEIRA<br />Compartilhe seu cardápio semanal como imagem em 3 estilos:<br />• Grade clássica<br />• Elegante grade escolar<br />• Retrato A4 vertical — imprima e cole na geladeira<br /><br />SEMPRE SINCRONIZADO<br />O iCloud mantém seu plano atualizado em todos os seus dispositivos Apple. Um plano, visível em todo lugar.<br /><br />INTEGRAÇÃO COM CALENDÁRIO<br />Sincronize suas refeições diretamente com o Calendário Apple para que toda a família saiba sempre o que tem para comer.<br /><br />— — —<br /><br />GRATUITO<br />• Até 20 pratos<br />• Planejamento da semana atual<br />• Com anúncios<br /><br />PREMIUM (assinatura mensal)<br />• Pratos ilimitados<br />• Planeje qualquer semana futura<br />• Regras de etiquetas avançadas<br />• Exportações do cardápio semanal<br />• Sem anúncios<br /><br />— — —<br /><br />Disponível em português, espanhol, inglês, francês, alemão e italiano.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="app-changelog">
|
||||
<div class="cat-headline">Changelog</div>
|
||||
Planejamento mais inteligente nesta atualização:<br /><br />• O preenchimento automático agora evita repetir o mesmo prato duas vezes quando há alternativas disponíveis — semanas mais variadas e equilibradas.<br />• Adicionar vários pratos é mais rápido: o formulário permanece aberto após salvar para adicionar vários seguidos.<br />• Sincronização com o calendário corrigida: os eventos agora são atualizados em vez de duplicados ao sincronizar novamente.<br />• Configuração simplificada: os pratos agora são opcionais durante a configuração inicial — você pode adicioná-los depois, se preferir.<br /><br />Melhorias gerais e correções de bugs.<br />
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="app-changelog">
|
||||
<div class="cat-headline">Promotional Text</div>
|
||||
Planeje almoços e jantares de toda a semana em segundos. 3 estilos de exportação — incluindo A4 para a geladeira. Grátis para começar.
|
||||
</div>
|
||||
|
||||
|
||||
<div class="app-screenshots">
|
||||
<div class="cat-headline">Screenshots</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="app-minor-information">
|
||||
<div class="cat-headline">Review Information</div>
|
||||
<dl class="app-minor-information">
|
||||
|
||||
<dt class="app-minor-information-key">
|
||||
First name
|
||||
</dt>
|
||||
<dd class="app-minor-information-text">
|
||||
Alexandre<br />
|
||||
</dd>
|
||||
|
||||
<dt class="app-minor-information-key">
|
||||
Last name
|
||||
</dt>
|
||||
<dd class="app-minor-information-text">
|
||||
Vazquez<br />
|
||||
</dd>
|
||||
|
||||
<dt class="app-minor-information-key">
|
||||
Phone number
|
||||
</dt>
|
||||
<dd class="app-minor-information-text">
|
||||
+34683619601<br />
|
||||
</dd>
|
||||
|
||||
<dt class="app-minor-information-key">
|
||||
Email address
|
||||
</dt>
|
||||
<dd class="app-minor-information-text">
|
||||
alexandre.vazquez@gmail.com<br />
|
||||
</dd>
|
||||
|
||||
<dt class="app-minor-information-key">
|
||||
Demo user
|
||||
</dt>
|
||||
<dd class="app-minor-information-text">
|
||||
<br />
|
||||
</dd>
|
||||
|
||||
<dt class="app-minor-information-key">
|
||||
Demo password
|
||||
</dt>
|
||||
<dd class="app-minor-information-text">
|
||||
<br />
|
||||
</dd>
|
||||
|
||||
<dt class="app-minor-information-key">
|
||||
Notes
|
||||
</dt>
|
||||
<dd class="app-minor-information-text">
|
||||
Please review together with IAP https://appstoreconnect.apple.com/apps/6759255553/distribution/iaps/6759257107 that is also new for this version. <br /><br />MealMood includes one auto-renewable subscription IAP (MealMood Premium Monthly, 2.99 EUR/month) that is also new in this version. So please review both together.<br /><br />What is unlocked by Premium:<br /><br />Ad-free experience<br />Monthly history view<br />Week plan image export/share<br />Advanced planning features<br />How to test IAP:<br /><br />Launch app and open Settings > Premium (or tap a Premium-locked action such as Export when on Free).<br />Tap Subscribe to start the purchase flow.<br />Tap Restore Purchases to verify restoration behavior.<br />Family Sharing is enabled for this subscription in App Store Connect.<br />Notes:<br /><br />The app is fully usable without purchase (Free plan).<br />Premium paywall is only shown when accessing Premium features.<br />No external payment methods are used.<br />
|
||||
</dd>
|
||||
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,40 @@
|
||||
fastlane documentation
|
||||
----
|
||||
|
||||
# Installation
|
||||
|
||||
Make sure you have the latest version of the Xcode command line tools installed:
|
||||
|
||||
```sh
|
||||
xcode-select --install
|
||||
```
|
||||
|
||||
For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane)
|
||||
|
||||
# Available Actions
|
||||
|
||||
## iOS
|
||||
|
||||
### ios beta
|
||||
|
||||
```sh
|
||||
[bundle exec] fastlane ios beta
|
||||
```
|
||||
|
||||
Push a new beta build to TestFlight
|
||||
|
||||
### ios release
|
||||
|
||||
```sh
|
||||
[bundle exec] fastlane ios release
|
||||
```
|
||||
|
||||
Push a new release build to the App Store
|
||||
|
||||
----
|
||||
|
||||
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.
|
||||
|
||||
More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools).
|
||||
|
||||
The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools).
|
||||
@@ -0,0 +1 @@
|
||||
© 2026 MealMood
|
||||
@@ -0,0 +1,39 @@
|
||||
Plane die Mahlzeiten deiner Familie in Minuten — nicht in Stunden.
|
||||
|
||||
MealMood ist der einfache, schöne Wochenmenü-Planer für vielbeschäftigte Familien. Füge deine Lieblingsgerichte hinzu, stelle deine Vorlieben ein und lass die App den Rest erledigen.
|
||||
|
||||
IN SEKUNDEN PLANEN
|
||||
Erstelle eine vollständige Woche mit Mittag- und Abendessen auf Knopfdruck. Die intelligente Autovervollständigung füllt deinen Planer mit deiner Gerichtebibliothek und Tag-Regeln — ohne Wiederholungen, ohne Stress.
|
||||
|
||||
DEINE GERICHTEBIBLIOTHEK
|
||||
Füge alle Haushaltsfavoriten hinzu und beschrifte sie mit smarten Tags. Stelle Regeln wie „maximal 2 Mal pro Woche" oder „nur Abendessen" ein — MealMood hält sie automatisch ein.
|
||||
|
||||
WUNDERSCHÖNE EXPORTS — KÜHLSCHRANKFERTIG
|
||||
Teile deinen Wochenplan als Bild in 3 Stilen:
|
||||
• Klassisches Raster
|
||||
• Eleganter Stundenplan-Stil
|
||||
• Hochformat A4 — ausdrucken und an den Kühlschrank hängen
|
||||
|
||||
IMMER SYNCHRONISIERT
|
||||
iCloud hält deinen Plan auf allen Apple-Geräten aktuell. Ein Plan, überall sichtbar.
|
||||
|
||||
KALENDERINTEGRATION
|
||||
Synchronisiere Mahlzeiten direkt mit dem Apple-Kalender, damit die ganze Familie immer weiß, was es zu essen gibt.
|
||||
|
||||
— — —
|
||||
|
||||
KOSTENLOS
|
||||
• Bis zu 20 Gerichte
|
||||
• Planung der aktuellen Woche
|
||||
• Mit Werbung
|
||||
|
||||
PREMIUM (Monatsabo)
|
||||
• Unbegrenzte Gerichte
|
||||
• Jede zukünftige Woche planen
|
||||
• Erweiterte Tag-Regeln
|
||||
• Wöchentliche Menü-Exports
|
||||
• Werbefrei
|
||||
|
||||
— — —
|
||||
|
||||
Verfügbar auf Deutsch, Spanisch, Englisch, Französisch, Italienisch und Portugiesisch.
|
||||
@@ -0,0 +1 @@
|
||||
Wochenplan,was kochen,Menüplan,Rezepte,Speiseplan,Abendessen Ideen,Mittagessen Ideen,Mahlzeiten
|
||||
@@ -0,0 +1 @@
|
||||
MealMood: Wochenmenü-Planer
|
||||
@@ -0,0 +1 @@
|
||||
Plane Mittag- und Abendessen für die ganze Woche in Sekunden. 3 Export-Stile — inkl. A4 für den Kühlschrank. Kostenlos starten.
|
||||
@@ -1,8 +1,6 @@
|
||||
Intelligentere Planung in diesem Update:
|
||||
Neu in Version 1.0.5:
|
||||
|
||||
• Die Auto-Vervollständigung vermeidet jetzt die Wiederholung desselben Gerichts, wenn Alternativen verfügbar sind — abwechslungsreichere und ausgewogenere Wochen.
|
||||
• Mehrere Gerichte hinzufügen geht jetzt schneller: Das Formular bleibt nach dem Speichern offen, damit du mehrere hintereinander hinzufügen kannst.
|
||||
• Kalendersynchronisation korrigiert: Ereignisse werden jetzt aktualisiert statt dupliziert, wenn erneut synchronisiert wird.
|
||||
• Einrichtung vereinfacht: Gerichte sind beim ersten Setup jetzt optional — du kannst sie später hinzufügen, wenn du möchtest.
|
||||
|
||||
Allgemeine Verbesserungen und Fehlerbehebungen.
|
||||
• Auswärts essen — markiere jeden Slot als „auswärts", damit er nicht als leer gilt und beim Auto-Vervollständigen übersprungen wird.
|
||||
• Regelkonflikte — sieh alle Regelverstöße auf einen Blick und behebe oder ignoriere sie mit einem Tipp.
|
||||
• Firebase Crashlytics — Absturzberichte jetzt aktiv für schnellere Fehlerbehebung.
|
||||
• Suchleisten-Fix — Text ist jetzt im Dunkelmodus lesbar.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Planen, teilen und ausdrucken
|
||||
@@ -0,0 +1 @@
|
||||
https://mealmood.app
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
Plan your family's weekly meals in minutes — not hours.
|
||||
|
||||
MealMood is the simple, beautiful weekly meal planner for busy families. Build your dish library, plan lunches and dinners for the week, and share a gorgeous printable menu — straight to the fridge.
|
||||
|
||||
PLAN IN SECONDS
|
||||
Create a full week of lunches and dinners with one tap. MealMood's smart auto-complete fills your planner using your dish library and tag rules — no repetition, no stress.
|
||||
|
||||
YOUR DISH LIBRARY
|
||||
Add all your household favorites and label them with smart tags. Set rules like "max 2 times per week" or "dinner only" and MealMood will respect them automatically when auto-completing your week.
|
||||
|
||||
BEAUTIFUL EXPORTS — FRIDGE-READY
|
||||
Share your weekly menu as a gorgeous image in 3 styles:
|
||||
• Classic grid layout
|
||||
• Elegant school timetable style
|
||||
• Vertical A4 portrait — print it and stick it on the fridge
|
||||
|
||||
ALWAYS IN SYNC
|
||||
iCloud sync keeps your plan up to date across all your Apple devices. One plan, visible everywhere.
|
||||
|
||||
CALENDAR INTEGRATION
|
||||
Sync your meals directly to Apple Calendar so the whole family always knows what's for lunch and dinner.
|
||||
|
||||
— — —
|
||||
|
||||
FREE PLAN
|
||||
• Up to 20 dishes
|
||||
• Plan the current week
|
||||
• Ad-supported
|
||||
|
||||
PREMIUM (monthly subscription)
|
||||
• Unlimited dishes
|
||||
• Plan any future week
|
||||
• Advanced tag rules
|
||||
• Beautiful week exports
|
||||
• Ad-free experience
|
||||
|
||||
— — —
|
||||
|
||||
Available in English, Spanish, French, German, Italian and Portuguese.
|
||||
@@ -0,0 +1 @@
|
||||
dinner ideas,what to cook,weekly menu,lunch ideas,meal prep,food schedule,recipe organizer,cooking
|
||||
@@ -0,0 +1 @@
|
||||
https://mealmood.app
|
||||
@@ -0,0 +1 @@
|
||||
MealMood: Family Meal Planner
|
||||
@@ -0,0 +1 @@
|
||||
https://mealmood.app/privacy.html
|
||||
@@ -0,0 +1 @@
|
||||
Plan lunches & dinners for the whole week in seconds. 3 beautiful export styles — including A4 for the fridge. Free to start, Premium unlocks everything.
|
||||
@@ -1,8 +1,6 @@
|
||||
Smarter meal planning in this update:
|
||||
New in 1.0.5:
|
||||
|
||||
• Auto-assign now avoids repeating the same dish twice when alternatives are available — smarter, more varied weekly plans.
|
||||
• Adding multiple dishes is now faster: the form stays open after saving so you can add several in a row.
|
||||
• Calendar sync fixed: events are now updated instead of duplicated when re-syncing.
|
||||
• Onboarding simplified: dishes are now optional during setup — jump in and add them later if you prefer.
|
||||
|
||||
Various improvements and bug fixes.
|
||||
• Eating out — mark any meal slot as "eating out" so it doesn't count as missing and is skipped by auto-assign.
|
||||
• Rule conflicts panel — see all rule violations in one place and fix or ignore them with one tap.
|
||||
• Firebase Crashlytics — crash reporting now active for faster bug fixes.
|
||||
• Search bar fix — text is now readable in dark mode.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Plan Your Meals, Share & Print
|
||||
@@ -0,0 +1 @@
|
||||
https://mealmood.app
|
||||
@@ -0,0 +1,39 @@
|
||||
Planifica las comidas de la semana en minutos, no en horas.
|
||||
|
||||
MealMood es el planificador de menú semanal simple y bonito para familias ocupadas. Añade tus platos favoritos, configura tus preferencias y deja que la app haga el resto.
|
||||
|
||||
PLANIFICA EN SEGUNDOS
|
||||
Crea una semana completa de comidas y cenas con un toque. El autocompletado inteligente rellena tu planificador usando tu biblioteca de platos y reglas de etiquetas — sin repeticiones, sin estrés.
|
||||
|
||||
TU BIBLIOTECA DE PLATOS
|
||||
Añade todos los favoritos de casa y etiquétalos con reglas inteligentes. Configura reglas como "máximo 2 veces por semana" o "solo cenas" y MealMood las respetará automáticamente.
|
||||
|
||||
EXPORTACIONES PRECIOSAS — LISTAS PARA LA NEVERA
|
||||
Comparte tu menú semanal como imagen en 3 estilos:
|
||||
• Cuadrícula clásica
|
||||
• Horario escolar elegante
|
||||
• Retrato A4 vertical — imprímelo y ponlo en la nevera
|
||||
|
||||
SIEMPRE SINCRONIZADO
|
||||
iCloud mantiene tu plan actualizado en todos tus dispositivos Apple. Un plan, visible en todas partes.
|
||||
|
||||
INTEGRACIÓN CON CALENDARIO
|
||||
Sincroniza tus comidas directamente con el Calendario de Apple para que toda la familia sepa siempre qué hay de comer.
|
||||
|
||||
— — —
|
||||
|
||||
PLAN GRATUITO
|
||||
• Hasta 20 platos
|
||||
• Planificación de la semana actual
|
||||
• Con anuncios
|
||||
|
||||
PREMIUM (suscripción mensual)
|
||||
• Platos ilimitados
|
||||
• Planifica cualquier semana futura
|
||||
• Reglas de etiquetas avanzadas
|
||||
• Exportaciones del menú semanal
|
||||
• Sin anuncios
|
||||
|
||||
— — —
|
||||
|
||||
Disponible en español, inglés, francés, alemán, italiano y portugués.
|
||||
@@ -0,0 +1 @@
|
||||
menú semanal,qué cocinar hoy,ideas cenas,organizar comidas,planificar semana,comida semana
|
||||
@@ -0,0 +1 @@
|
||||
MealMood: Planificador Semanal
|
||||
@@ -0,0 +1 @@
|
||||
Planifica comidas y cenas de toda la semana en segundos. 3 estilos de exportación — incluido A4 para la nevera. Gratis para empezar.
|
||||
@@ -1,8 +1,6 @@
|
||||
Planificación más inteligente en esta actualización:
|
||||
Novedades en 1.0.5:
|
||||
|
||||
• El auto-completar ahora evita repetir el mismo plato dos veces si hay alternativas — semanas más variadas y equilibradas.
|
||||
• Añadir varios platos es más rápido: el formulario se queda abierto tras guardar para poder añadir varios seguidos.
|
||||
• Sincronización con el calendario corregida: los eventos ahora se actualizan en lugar de duplicarse al volver a sincronizar.
|
||||
• Incorporación simplificada: los platos ahora son opcionales durante la configuración inicial — puedes añadirlos después si lo prefieres.
|
||||
|
||||
Mejoras generales y corrección de errores.
|
||||
• Comer fuera — marca cualquier hueco como "comida fuera" para que no cuente como vacío y el auto-completar lo omita.
|
||||
• Panel de conflictos — ve todas las infracciones de reglas juntas y corrígelas o ignóralas con un toque.
|
||||
• Firebase Crashlytics — informes de errores activados para correcciones más rápidas.
|
||||
• Corrección barra de búsqueda — el texto ahora se lee correctamente en modo oscuro.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Planifica, comparte e imprime
|
||||
@@ -0,0 +1 @@
|
||||
https://mealmood.app
|
||||
@@ -0,0 +1,39 @@
|
||||
Planifiez les repas de la semaine en quelques minutes, pas en heures.
|
||||
|
||||
MealMood est le planificateur de menus hebdomadaire simple et beau pour les familles actives. Ajoutez vos plats préférés, définissez vos préférences et laissez l'app faire le reste.
|
||||
|
||||
PLANIFIEZ EN QUELQUES SECONDES
|
||||
Créez une semaine complète de déjeuners et dîners en un tap. La saisie automatique intelligente remplit votre planning avec votre bibliothèque de plats et vos règles d'étiquettes — sans répétition, sans stress.
|
||||
|
||||
VOTRE BIBLIOTHÈQUE DE PLATS
|
||||
Ajoutez tous vos favoris et étiquetez-les avec des règles intelligentes. Définissez des règles comme « maximum 2 fois par semaine » ou « dîner uniquement » et MealMood les respectera automatiquement.
|
||||
|
||||
EXPORTS MAGNIFIQUES — PRÊTS POUR LE FRIGO
|
||||
Partagez votre menu en image dans 3 styles :
|
||||
• Grille classique
|
||||
• Emploi du temps élégant
|
||||
• Portrait A4 vertical — imprimez-le et collez-le sur le frigo
|
||||
|
||||
TOUJOURS SYNCHRONISÉ
|
||||
iCloud maintient votre planning à jour sur tous vos appareils Apple. Un plan, visible partout.
|
||||
|
||||
INTÉGRATION CALENDRIER
|
||||
Synchronisez vos repas directement dans Calendrier Apple pour que toute la famille sache toujours ce qu'il y a à manger.
|
||||
|
||||
— — —
|
||||
|
||||
GRATUIT
|
||||
• Jusqu'à 20 plats
|
||||
• Planification de la semaine en cours
|
||||
• Avec publicités
|
||||
|
||||
PREMIUM (abonnement mensuel)
|
||||
• Plats illimités
|
||||
• Planifiez n'importe quelle semaine future
|
||||
• Règles d'étiquettes avancées
|
||||
• Exports du menu hebdomadaire
|
||||
• Sans publicité
|
||||
|
||||
— — —
|
||||
|
||||
Disponible en français, espagnol, anglais, allemand, italien et portugais.
|
||||
@@ -0,0 +1 @@
|
||||
menu semaine,idées dîner,quoi manger,organisation repas,agenda repas,planning dîners,recettes
|
||||
@@ -0,0 +1 @@
|
||||
MealMood: Planificateur Repas
|
||||
@@ -0,0 +1 @@
|
||||
Planifiez déjeuners et dîners pour toute la semaine en secondes. 3 styles d'export — dont le A4 pour le frigo. Gratuit pour commencer.
|
||||
@@ -1,8 +1,6 @@
|
||||
Une planification plus intelligente dans cette mise à jour :
|
||||
Nouveautés dans la version 1.0.5 :
|
||||
|
||||
• L'auto-complétion évite désormais de répéter le même plat deux fois lorsqu'il y a des alternatives — des semaines plus variées et équilibrées.
|
||||
• Ajouter plusieurs plats est plus rapide : le formulaire reste ouvert après l'enregistrement pour en ajouter plusieurs à la suite.
|
||||
• Synchronisation du calendrier corrigée : les événements sont maintenant mis à jour au lieu d'être dupliqués lors d'une nouvelle synchronisation.
|
||||
• Démarrage simplifié : les plats sont désormais optionnels pendant la configuration initiale — vous pouvez les ajouter plus tard si vous préférez.
|
||||
|
||||
Améliorations générales et corrections de bugs.
|
||||
• Repas dehors — marquez n'importe quel créneau comme « repas dehors » pour qu'il ne compte pas comme vide et soit ignoré par l'auto-complétion.
|
||||
• Panneau de conflits — visualisez toutes les infractions aux règles en un seul endroit et corrigez-les ou ignorez-les en un tap.
|
||||
• Firebase Crashlytics — rapports de plantages activés pour des corrections plus rapides.
|
||||
• Correction barre de recherche — le texte est maintenant lisible en mode sombre.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Planifiez, partagez, imprimez
|
||||
@@ -0,0 +1 @@
|
||||
https://mealmood.app
|
||||
@@ -0,0 +1,39 @@
|
||||
Pianifica i pasti della famiglia in pochi minuti, non in ore.
|
||||
|
||||
MealMood è il pianificatore di menu settimanale semplice e bello per le famiglie indaffarate. Aggiungi i tuoi piatti preferiti, imposta le tue preferenze e lascia che l'app faccia il resto.
|
||||
|
||||
PIANIFICA IN SECONDI
|
||||
Crea una settimana completa di pranzi e cene con un tocco. Il completamento automatico intelligente riempie il tuo planner con la libreria di piatti e le regole dei tag — niente ripetizioni, niente stress.
|
||||
|
||||
LA TUA LIBRERIA DI PIATTI
|
||||
Aggiungi tutti i preferiti di casa e etichettali con regole intelligenti. Imposta regole come "massimo 2 volte a settimana" o "solo cena" e MealMood le rispetterà automaticamente.
|
||||
|
||||
EXPORT BELLISSIMI — PRONTI PER IL FRIGO
|
||||
Condividi il tuo menu settimanale come immagine in 3 stili:
|
||||
• Griglia classica
|
||||
• Elegante orario scolastico
|
||||
• Ritratto A4 verticale — stampalo e mettilo sul frigorifero
|
||||
|
||||
SEMPRE SINCRONIZZATO
|
||||
iCloud mantiene il tuo piano aggiornato su tutti i dispositivi Apple. Un piano, visibile ovunque.
|
||||
|
||||
INTEGRAZIONE CALENDARIO
|
||||
Sincronizza i pasti direttamente con Calendario Apple così tutta la famiglia sa sempre cosa c'è da mangiare.
|
||||
|
||||
— — —
|
||||
|
||||
GRATUITO
|
||||
• Fino a 20 piatti
|
||||
• Pianificazione della settimana corrente
|
||||
• Con pubblicità
|
||||
|
||||
PREMIUM (abbonamento mensile)
|
||||
• Piatti illimitati
|
||||
• Pianifica qualsiasi settimana futura
|
||||
• Regole tag avanzate
|
||||
• Export menu settimanale
|
||||
• Senza pubblicità
|
||||
|
||||
— — —
|
||||
|
||||
Disponibile in italiano, spagnolo, inglese, francese, tedesco e portoghese.
|
||||
@@ -0,0 +1 @@
|
||||
menu settimanale,idee cena,cosa cucinare,organizzare pasti,pranzo cena,ricette,programma pasti
|
||||
@@ -0,0 +1 @@
|
||||
MealMood: Pianificatore Pasti
|
||||
@@ -0,0 +1 @@
|
||||
Pianifica pranzi e cene per tutta la settimana in secondi. 3 stili di export — incluso A4 per il frigo. Gratis per iniziare.
|
||||
@@ -1,8 +1,6 @@
|
||||
Pianificazione più intelligente in questo aggiornamento:
|
||||
Novità nella versione 1.0.5:
|
||||
|
||||
• Il completamento automatico ora evita di ripetere lo stesso piatto due volte quando ci sono alternative — settimane più varie ed equilibrate.
|
||||
• Aggiungere più piatti è più veloce: il modulo rimane aperto dopo il salvataggio per poterne aggiungere più di uno di seguito.
|
||||
• Sincronizzazione del calendario corretta: gli eventi vengono ora aggiornati invece di essere duplicati quando si risincronizza.
|
||||
• Configurazione semplificata: i piatti sono ora opzionali durante la configurazione iniziale — puoi aggiungerli dopo se preferisci.
|
||||
|
||||
Miglioramenti generali e correzioni di bug.
|
||||
• Mangiare fuori — segna qualsiasi slot come "fuori casa" per non contarlo come vuoto e ignorarlo nell'auto-completamento.
|
||||
• Pannello conflitti — visualizza tutte le violazioni delle regole in un unico posto e correggile o ignorale con un tap.
|
||||
• Firebase Crashlytics — report degli arresti anomali attivi per correzioni più rapide.
|
||||
• Correzione barra di ricerca — il testo ora è leggibile in modalità scura.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Pianifica, condividi, stampa
|
||||
@@ -0,0 +1 @@
|
||||
https://mealmood.app
|
||||
@@ -0,0 +1 @@
|
||||
FOOD_AND_DRINK
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
Planeje as refeições da família em minutos — não em horas.
|
||||
|
||||
MealMood é o planejador de cardápio semanal simples e bonito para famílias ocupadas. Adicione seus pratos favoritos, configure suas preferências e deixe o app fazer o resto.
|
||||
|
||||
PLANEJE EM SEGUNDOS
|
||||
Crie uma semana completa de almoços e jantares com um toque. O preenchimento automático inteligente preenche seu planner com a biblioteca de pratos e regras de etiquetas — sem repetição, sem estresse.
|
||||
|
||||
SUA BIBLIOTECA DE PRATOS
|
||||
Adicione todos os favoritos da casa e marque-os com regras inteligentes. Configure regras como "máximo 2 vezes por semana" ou "só jantar" e o MealMood as respeitará automaticamente.
|
||||
|
||||
EXPORTAÇÕES LINDAS — PRONTAS PARA A GELADEIRA
|
||||
Compartilhe seu cardápio semanal como imagem em 3 estilos:
|
||||
• Grade clássica
|
||||
• Elegante grade escolar
|
||||
• Retrato A4 vertical — imprima e cole na geladeira
|
||||
|
||||
SEMPRE SINCRONIZADO
|
||||
O iCloud mantém seu plano atualizado em todos os seus dispositivos Apple. Um plano, visível em todo lugar.
|
||||
|
||||
INTEGRAÇÃO COM CALENDÁRIO
|
||||
Sincronize suas refeições diretamente com o Calendário Apple para que toda a família saiba sempre o que tem para comer.
|
||||
|
||||
— — —
|
||||
|
||||
GRATUITO
|
||||
• Até 20 pratos
|
||||
• Planejamento da semana atual
|
||||
• Com anúncios
|
||||
|
||||
PREMIUM (assinatura mensal)
|
||||
• Pratos ilimitados
|
||||
• Planeje qualquer semana futura
|
||||
• Regras de etiquetas avançadas
|
||||
• Exportações do cardápio semanal
|
||||
• Sem anúncios
|
||||
|
||||
— — —
|
||||
|
||||
Disponível em português, espanhol, inglês, francês, alemão e italiano.
|
||||
@@ -0,0 +1 @@
|
||||
cardápio semanal,o que comer hoje,ideias jantar,organizar refeições,plano semanal,receitas
|
||||
@@ -0,0 +1 @@
|
||||
MealMood: Planejador Semanal
|
||||
@@ -0,0 +1 @@
|
||||
Planeje almoços e jantares de toda a semana em segundos. 3 estilos de exportação — incluindo A4 para a geladeira. Grátis para começar.
|
||||
@@ -1,8 +1,6 @@
|
||||
Planejamento mais inteligente nesta atualização:
|
||||
Novidades na versão 1.0.5:
|
||||
|
||||
• O preenchimento automático agora evita repetir o mesmo prato duas vezes quando há alternativas disponíveis — semanas mais variadas e equilibradas.
|
||||
• Adicionar vários pratos é mais rápido: o formulário permanece aberto após salvar para adicionar vários seguidos.
|
||||
• Sincronização com o calendário corrigida: os eventos agora são atualizados em vez de duplicados ao sincronizar novamente.
|
||||
• Configuração simplificada: os pratos agora são opcionais durante a configuração inicial — você pode adicioná-los depois, se preferir.
|
||||
|
||||
Melhorias gerais e correções de bugs.
|
||||
• Comer fora — marque qualquer slot como "comer fora" para não contá-lo como vazio e o auto-preenchimento irá pulá-lo.
|
||||
• Painel de conflitos — veja todas as violações de regras juntas e corrija ou ignore com um toque.
|
||||
• Firebase Crashlytics — relatórios de falhas agora ativos para correções mais rápidas.
|
||||
• Correção da barra de busca — o texto agora é legível no modo escuro.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Planeje, compartilhe e imprima
|
||||
@@ -0,0 +1 @@
|
||||
https://mealmood.app
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
alexandre.vazquez@gmail.com
|
||||
@@ -0,0 +1 @@
|
||||
Alexandre
|
||||
@@ -0,0 +1 @@
|
||||
Vazquez
|
||||
@@ -0,0 +1,21 @@
|
||||
Please review together with IAP https://appstoreconnect.apple.com/apps/6759255553/distribution/iaps/6759257107 that is also new for this version.
|
||||
|
||||
MealMood includes one auto-renewable subscription IAP (MealMood Premium Monthly, 2.99 EUR/month) that is also new in this version. So please review both together.
|
||||
|
||||
What is unlocked by Premium:
|
||||
|
||||
Ad-free experience
|
||||
Monthly history view
|
||||
Week plan image export/share
|
||||
Advanced planning features
|
||||
How to test IAP:
|
||||
|
||||
Launch app and open Settings > Premium (or tap a Premium-locked action such as Export when on Free).
|
||||
Tap Subscribe to start the purchase flow.
|
||||
Tap Restore Purchases to verify restoration behavior.
|
||||
Family Sharing is enabled for this subscription in App Store Connect.
|
||||
Notes:
|
||||
|
||||
The app is fully usable without purchase (Free plan).
|
||||
Premium paywall is only shown when accessing Premium features.
|
||||
No external payment methods are used.
|
||||
@@ -0,0 +1 @@
|
||||
+34683619601
|
||||
@@ -0,0 +1 @@
|
||||
LIFESTYLE
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<testsuites>
|
||||
<testsuite name="fastlane.lanes">
|
||||
|
||||
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000294">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.375189">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="2: build_app" time="80.108323">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="122.53615">
|
||||
|
||||
</testcase>
|
||||
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user