Version casi lista
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
#if canImport(GoogleMobileAds)
|
||||
import GoogleMobileAds
|
||||
|
||||
struct AdBannerView: View {
|
||||
var adUnitId: String = AdMobConfig.resolvedBannerHomeUnitId
|
||||
@State private var state: BannerState = .loading
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Rectangle()
|
||||
.fill(Color.mealMoodTextSecondary.opacity(0.22))
|
||||
.frame(height: 1)
|
||||
|
||||
ZStack {
|
||||
Color.mealMoodSurface
|
||||
|
||||
BannerBridge(adUnitId: adUnitId, state: $state)
|
||||
|
||||
if state != .loaded {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "megaphone")
|
||||
Text(state == .loading ? "ads_loading" : "ads_unavailable")
|
||||
.font(.mealMoodCaption)
|
||||
}
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
}
|
||||
.frame(height: 56)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color.mealMoodSurface)
|
||||
}
|
||||
}
|
||||
|
||||
private enum BannerState {
|
||||
case loading
|
||||
case loaded
|
||||
case failed
|
||||
}
|
||||
|
||||
private struct BannerBridge: UIViewRepresentable {
|
||||
let adUnitId: String
|
||||
@Binding var state: BannerState
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(state: $state)
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> UIView {
|
||||
let container = UIView()
|
||||
container.backgroundColor = UIColor(Color.mealMoodSurface)
|
||||
|
||||
let width = max(UIScreen.main.bounds.width, 320)
|
||||
let adSize = GADCurrentOrientationAnchoredAdaptiveBannerAdSizeWithWidth(width)
|
||||
let banner = GADBannerView(adSize: adSize)
|
||||
banner.adUnitID = adUnitId
|
||||
banner.delegate = context.coordinator
|
||||
banner.rootViewController = rootViewController()
|
||||
banner.translatesAutoresizingMaskIntoConstraints = false
|
||||
banner.backgroundColor = .clear
|
||||
context.coordinator.bannerView = banner
|
||||
|
||||
container.addSubview(banner)
|
||||
NSLayoutConstraint.activate([
|
||||
banner.centerXAnchor.constraint(equalTo: container.centerXAnchor),
|
||||
banner.centerYAnchor.constraint(equalTo: container.centerYAnchor),
|
||||
banner.widthAnchor.constraint(equalToConstant: adSize.size.width),
|
||||
banner.heightAnchor.constraint(equalToConstant: adSize.size.height)
|
||||
])
|
||||
|
||||
context.coordinator.loadBannerIfPossible()
|
||||
return container
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UIView, context: Context) {
|
||||
context.coordinator.bannerView?.rootViewController = rootViewController()
|
||||
context.coordinator.loadBannerIfPossible()
|
||||
}
|
||||
|
||||
private func rootViewController() -> UIViewController? {
|
||||
UIApplication.shared.connectedScenes
|
||||
.compactMap { $0 as? UIWindowScene }
|
||||
.flatMap(\.windows)
|
||||
.first(where: \.isKeyWindow)?
|
||||
.rootViewController
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject, GADBannerViewDelegate {
|
||||
@Binding var state: BannerState
|
||||
weak var bannerView: GADBannerView?
|
||||
private var didRequestLoad = false
|
||||
private var retryCount = 0
|
||||
|
||||
init(state: Binding<BannerState>) {
|
||||
self._state = state
|
||||
}
|
||||
|
||||
func loadBannerIfPossible() {
|
||||
guard let bannerView, bannerView.rootViewController != nil else { return }
|
||||
guard !didRequestLoad else { return }
|
||||
didRequestLoad = true
|
||||
state = .loading
|
||||
bannerView.load(GADRequest())
|
||||
}
|
||||
|
||||
func bannerViewDidReceiveAd(_ bannerView: GADBannerView) {
|
||||
state = .loaded
|
||||
retryCount = 0
|
||||
}
|
||||
|
||||
func bannerView(_ bannerView: GADBannerView, didFailToReceiveAdWithError error: any Error) {
|
||||
state = .failed
|
||||
didRequestLoad = false
|
||||
|
||||
if retryCount < 2 {
|
||||
retryCount += 1
|
||||
loadBannerIfPossible()
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
print("AdMob banner failed (\(retryCount)): \(error.localizedDescription)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
#else
|
||||
struct AdBannerView: View {
|
||||
var adUnitId: String = AdMobConfig.resolvedBannerHomeUnitId
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Rectangle()
|
||||
.fill(Color.mealMoodTextSecondary.opacity(0.22))
|
||||
.frame(height: 1)
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "megaphone")
|
||||
Text("ads_unavailable")
|
||||
.font(.mealMoodCaption)
|
||||
}
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
.frame(maxWidth: .infinity, minHeight: 56)
|
||||
.background(Color.mealMoodSurface)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color.mealMoodSurface)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,9 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AppIconPlaceholder: View {
|
||||
var size: CGFloat = 120
|
||||
|
||||
var body: some View {
|
||||
MealMoodLogoMark(size: size)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import SwiftUI
|
||||
|
||||
struct EmptySlotView: View {
|
||||
let mealType: MealType
|
||||
let isDropTarget: Bool
|
||||
let isValidDrop: Bool?
|
||||
|
||||
init(mealType: MealType, isDropTarget: Bool = false, isValidDrop: Bool? = nil) {
|
||||
self.mealType = mealType
|
||||
self.isDropTarget = isDropTarget
|
||||
self.isValidDrop = isValidDrop
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: mealType.icon)
|
||||
.font(.system(size: 20))
|
||||
.foregroundColor(Color(hex: "#C4C4C4"))
|
||||
|
||||
Text(LocalizedStringKey(mealType.rawValue))
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 80)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color(hex: "#F9F9F9"))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.strokeBorder(
|
||||
style: StrokeStyle(lineWidth: 2, dash: [5])
|
||||
)
|
||||
.foregroundColor(borderColor)
|
||||
)
|
||||
)
|
||||
.animation(.easeInOut(duration: 0.2), value: isDropTarget)
|
||||
}
|
||||
|
||||
private var borderColor: Color {
|
||||
guard isDropTarget, let valid = isValidDrop else {
|
||||
return Color(hex: "#E0E0E0")
|
||||
}
|
||||
return valid ? .mealMoodSuccess : .mealMoodError
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import SwiftUI
|
||||
|
||||
struct FilledSlotView: View {
|
||||
let dishName: String
|
||||
let dishDescription: String?
|
||||
let tags: [(name: String, color: String)]
|
||||
let mealType: MealType
|
||||
var showsRuleWarning: Bool = false
|
||||
var onRemove: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Image(systemName: mealType.icon)
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
|
||||
if showsRuleWarning {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.mealMoodWarning)
|
||||
}
|
||||
Spacer()
|
||||
if onRemove != nil {
|
||||
Button {
|
||||
onRemove?()
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.mealMoodTextSecondary.opacity(0.5))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(dishName)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
HStack(spacing: 3) {
|
||||
ForEach(Array(tags.prefix(2).enumerated()), id: \.offset) { _, tag in
|
||||
TagDot(color: tag.color, size: 9)
|
||||
}
|
||||
if tags.count > 2 {
|
||||
Text("+\(tags.count - 2)")
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.frame(maxWidth: .infinity, minHeight: 80, alignment: .topLeading)
|
||||
.mealCardStyle()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MealMoodLogoMark: View {
|
||||
var size: CGFloat = 120
|
||||
|
||||
var body: some View {
|
||||
Image("AppLogo")
|
||||
.resizable()
|
||||
.interpolation(.high)
|
||||
.scaledToFill()
|
||||
.frame(width: size, height: size)
|
||||
.clipShape(RoundedRectangle(cornerRadius: size * 0.22, style: .continuous))
|
||||
.shadow(color: .black.opacity(0.12), radius: size * 0.08, y: size * 0.04)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PremiumUpsellBanner: View {
|
||||
let messageKey: LocalizedStringKey
|
||||
let actionTitleKey: LocalizedStringKey
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "star.circle")
|
||||
Text(messageKey)
|
||||
Spacer()
|
||||
Button(actionTitleKey, action: action)
|
||||
.font(.mealMoodCaption)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodCoral)
|
||||
.padding(12)
|
||||
.background(Color.mealMoodSurface)
|
||||
.cornerRadius(12)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PrimaryButton: View {
|
||||
let title: String
|
||||
var icon: String? = nil
|
||||
let action: () -> Void
|
||||
var isEnabled: Bool = true
|
||||
var localizeTitle: Bool = true
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 8) {
|
||||
Group {
|
||||
if localizeTitle {
|
||||
Text(LocalizedStringKey(title))
|
||||
} else {
|
||||
Text(verbatim: title)
|
||||
}
|
||||
}
|
||||
.font(.mealMoodBodyBold)
|
||||
if let icon = icon {
|
||||
Text(icon)
|
||||
}
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 14)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: isEnabled
|
||||
? [Color.mealMoodCoral, Color.mealMoodMint]
|
||||
: [Color.gray.opacity(0.3), Color.gray.opacity(0.3)],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
)
|
||||
.cornerRadius(14)
|
||||
.shadow(color: isEnabled ? Color.mealMoodCoral.opacity(0.3) : .clear, radius: 8, y: 4)
|
||||
}
|
||||
.disabled(!isEnabled)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SecondaryButton: View {
|
||||
let title: String
|
||||
var icon: String? = nil
|
||||
let action: () -> Void
|
||||
var localizeTitle: Bool = true
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 8) {
|
||||
Group {
|
||||
if localizeTitle {
|
||||
Text(LocalizedStringKey(title))
|
||||
} else {
|
||||
Text(verbatim: title)
|
||||
}
|
||||
}
|
||||
.font(.mealMoodBodyBold)
|
||||
if let icon = icon {
|
||||
Text(icon)
|
||||
}
|
||||
}
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 14)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color.mealMoodSurface)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(Color.mealMoodTextSecondary.opacity(0.3), lineWidth: 1.5)
|
||||
)
|
||||
.cornerRadius(14)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TagPill: View {
|
||||
let name: String
|
||||
let color: String
|
||||
|
||||
var body: some View {
|
||||
Text(name)
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 4)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(Color(hex: color))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct TagDot: View {
|
||||
let color: String
|
||||
var size: CGFloat = 10
|
||||
|
||||
var body: some View {
|
||||
Circle()
|
||||
.fill(Color(hex: color))
|
||||
.frame(width: size, height: size)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(Color.white.opacity(0.7), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ToastView: View {
|
||||
let message: String
|
||||
@Binding var isShowing: Bool
|
||||
|
||||
var body: some View {
|
||||
if isShowing {
|
||||
VStack {
|
||||
HStack(spacing: 8) {
|
||||
Text(message)
|
||||
.font(.mealMoodSmall)
|
||||
.foregroundColor(.white)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 12)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(Color.mealMoodCoral.opacity(0.95))
|
||||
.shadow(color: .black.opacity(0.1), radius: 8, y: 4)
|
||||
)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.top, 8)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
.onAppear {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
|
||||
withAnimation(.easeOut) {
|
||||
isShowing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ToastModifier: ViewModifier {
|
||||
@Binding var isShowing: Bool
|
||||
let message: String
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
ZStack {
|
||||
content
|
||||
ToastView(message: message, isShowing: $isShowing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func toast(isShowing: Binding<Bool>, message: String) -> some View {
|
||||
modifier(ToastModifier(isShowing: isShowing, message: message))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user