diff --git a/pedi-foods/Android/gradle.properties b/pedi-foods/Android/gradle.properties index 1b8d060..a175fe4 100644 --- a/pedi-foods/Android/gradle.properties +++ b/pedi-foods/Android/gradle.properties @@ -1,3 +1,6 @@ -org.gradle.jvmargs=-Xmx4g +org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 +org.gradle.workers.max=2 +org.gradle.parallel=false +kotlin.daemon.jvmargs=-Xmx1g android.useAndroidX=true kotlin.code.style=official diff --git a/pedi-foods/Darwin/Assets.xcassets/LaunchBackground.colorset/Contents.json b/pedi-foods/Darwin/Assets.xcassets/LaunchBackground.colorset/Contents.json new file mode 100644 index 0000000..b7320fe --- /dev/null +++ b/pedi-foods/Darwin/Assets.xcassets/LaunchBackground.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "idiom" : "universal", + "color" : { + "color-space" : "srgb", + "components" : { + "red" : "0.9529411764705882", + "green" : "0.9607843137254902", + "blue" : "0.9686274509803922", + "alpha" : "1.0000000000000000" + } + } + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/pedi-foods/Darwin/PediFoods.xcconfig b/pedi-foods/Darwin/PediFoods.xcconfig index 6b94844..d45fdb1 100644 --- a/pedi-foods/Darwin/PediFoods.xcconfig +++ b/pedi-foods/Darwin/PediFoods.xcconfig @@ -22,7 +22,8 @@ GENERATE_INFOPLIST_FILE = YES // iOS-specific Info.plist property keys INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphone*] = YES INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphone*] = YES -INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphone*] = YES +INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphone*] = NO +INFOPLIST_KEY_UILaunchStoryboardName[sdk=iphone*] = LaunchScreen INFOPLIST_KEY_UIStatusBarStyle[sdk=iphone*] = UIStatusBarStyleDefault INFOPLIST_KEY_UISupportedInterfaceOrientations[sdk=iphone*] = UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown diff --git a/pedi-foods/Darwin/PediFoods.xcodeproj/project.pbxproj b/pedi-foods/Darwin/PediFoods.xcodeproj/project.pbxproj index a366a6c..c2e96d7 100644 --- a/pedi-foods/Darwin/PediFoods.xcodeproj/project.pbxproj +++ b/pedi-foods/Darwin/PediFoods.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 496BDBEE2B8A7E9C00C09264 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 496BDBED2B8A7E9C00C09264 /* Localizable.xcstrings */; }; 499CD43B2AC5B799001AE8D8 /* Main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49F90C2B2A52156200F06D93 /* Main.swift */; }; 499CD4402AC5B799001AE8D8 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 49F90C2F2A52156300F06D93 /* Assets.xcassets */; }; + B185A1FD2FB67A12001C7771 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = B185A1FC2FB67A12001C7771 /* LaunchScreen.storyboard */; }; B13CABF22F36437300469FD6 /* LCEssentials in Frameworks */ = {isa = PBXBuildFile; productRef = B13CABF12F36437300469FD6 /* LCEssentials */; }; /* End PBXBuildFile section */ @@ -41,6 +42,7 @@ 49F90C2B2A52156200F06D93 /* Main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Main.swift; path = Sources/Main.swift; sourceTree = SOURCE_ROOT; }; 49F90C2F2A52156300F06D93 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 49F90C312A52156300F06D93 /* Entitlements.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Entitlements.plist; sourceTree = ""; }; + B185A1FC2FB67A12001C7771 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = Sources/LaunchScreen.storyboard; sourceTree = SOURCE_ROOT; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -90,6 +92,7 @@ isa = PBXGroup; children = ( 49F90C2B2A52156200F06D93 /* Main.swift */, + B185A1FC2FB67A12001C7771 /* LaunchScreen.storyboard */, 49F90C2F2A52156300F06D93 /* Assets.xcassets */, 49F90C312A52156300F06D93 /* Entitlements.plist */, 4900101C2BACEA710000DE33 /* Info.plist */, @@ -162,6 +165,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + B185A1FD2FB67A12001C7771 /* LaunchScreen.storyboard in Resources */, 499CD4402AC5B799001AE8D8 /* Assets.xcassets in Resources */, 496BDBEE2B8A7E9C00C09264 /* Localizable.xcstrings in Resources */, ); diff --git a/pedi-foods/Darwin/Sources/LaunchScreen.storyboard b/pedi-foods/Darwin/Sources/LaunchScreen.storyboard new file mode 100644 index 0000000..bd2b472 --- /dev/null +++ b/pedi-foods/Darwin/Sources/LaunchScreen.storyboard @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pedi-foods/PROJECT_ANALYSIS_SKIP.md b/pedi-foods/PROJECT_ANALYSIS_SKIP.md new file mode 100644 index 0000000..b7640e3 --- /dev/null +++ b/pedi-foods/PROJECT_ANALYSIS_SKIP.md @@ -0,0 +1,51 @@ +# Análise Técnica — Arquitetura Skip (PediFoods) + +## Entendimento confirmado + +O projeto usa **Skip com fonte principal em Swift/SwiftUI** e geração/transpilação para Android. + +Diretriz operacional validada: + +1. **iOS é a plataforma prioritária** de desenvolvimento e validação. +2. **Android entra depois**, com adaptações isoladas por compilação condicional. +3. Toda alteração deve preservar comportamento de iOS e evitar regressão cross-platform. + +## Como o projeto está estruturado + +1. Código compartilhado principal: `Sources/PediFoods/*` (Swift/SwiftUI). +2. Entry point iOS: `Darwin/Sources/Main.swift`. +3. Entry point Android: `Android/app/src/main/kotlin/Main.kt`. +4. Configuração Skip: `Sources/PediFoods/Skip/skip.yml` (`mode: native`). +5. Build/transpilação Android via plugin Skip (`skipstone`) no target Swift Package. + +## Evidências no código (padrão já em uso) + +1. Uso extensivo de condicionais: + - `#if os(iOS)` + - `#if os(Android)` + - `#if canImport(UIKit)` +2. Dependência iOS-only no `Package.swift`: + - `LCEssentials` com `condition: .when(platforms: [.iOS])` +3. Exemplo claro de adaptação segura: + - `ContentView.swift`: `SnackbarCenter` com implementação distinta para Android e iOS. + +## Regra prática para próximas tarefas + +1. Implementar primeiro para iOS. +2. Validar iOS (build/execução/comportamento). +3. Só depois adaptar Android, sempre em bloco condicional separado. +4. Nunca alterar caminho iOS por causa de ajuste Android. +5. Em mudanças de UI de inicialização (ex.: splash), tratar iOS e Android em fluxos independentes. + +## Nota específica para Splash + +Estado atual identificado: + +1. iOS com Launch Screen gerado automaticamente (`INFOPLIST_KEY_UILaunchScreen_Generation = YES`). +2. Android sem implementação custom de splash dedicada no `res/values` + tema de launch. + +Implicação: + +1. Mudança de splash para iOS pode ser feita sem tocar Android. +2. Quando Android for tratado, deve entrar em camada própria (tema/recursos Android), sem impactar o fluxo iOS. + diff --git a/pedi-foods/Sources/PediFoods/ContentView.swift b/pedi-foods/Sources/PediFoods/ContentView.swift index f96e72b..318683c 100644 --- a/pedi-foods/Sources/PediFoods/ContentView.swift +++ b/pedi-foods/Sources/PediFoods/ContentView.swift @@ -6,6 +6,11 @@ struct ContentView: View { private let tokenStore: TokenStore = DefaultTokenStore() @State var appState = AppState() @State var isBootstrappingSession = false + @State var hasPerformedInitialLaunchBootstrap = false + @State var showLaunchSplash = true + @State var shouldPulseLaunchSplash = true + @State var shouldPrepareAuthEntryAnimation = DefaultTokenStore().jwt == nil + @State var authEntryAnimationToken = 0 #if os(iOS) @State private var sessionExpiredObserver: NSObjectProtocol? #endif @@ -20,7 +25,14 @@ struct ContentView: View { Group { switch root { case .auth: - AuthFlowView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState) + AuthFlowView( + root: $root, + selectedTab: $selectedTab, + tokenStore: tokenStore, + appState: $appState, + shouldPrepareLoginEntry: shouldPrepareAuthEntryAnimation, + authEntryAnimationToken: authEntryAnimationToken + ) case .main: if isBootstrappingSession { sessionBootstrapLoadingView @@ -31,6 +43,11 @@ struct ContentView: View { } SnackbarOverlay(center: snackbarCenter) + + if showLaunchSplash { + LaunchSplashView(shouldPulse: shouldPulseLaunchSplash) + .transition(.opacity) + } } .sheet(item: $appState.activeModal) { modal in switch modal { @@ -55,8 +72,15 @@ struct ContentView: View { .onReceive(NotificationCenter.default.publisher(for: .cartDidReset)) { _ in appState.cart = CartState() } - .task(id: root) { - await bootstrapSessionStateIfNeeded() + .task { + await performInitialLaunchBootstrap() + } + .onChange(of: root) { _, newValue in + if newValue == .main { + Task { + await bootstrapSessionStateIfNeeded() + } + } } .onAppear { #if os(iOS) @@ -81,6 +105,43 @@ struct ContentView: View { .background(AppColors.backgroundLight) } + @MainActor + private func performInitialLaunchBootstrap() async { + guard hasPerformedInitialLaunchBootstrap == false else { return } + hasPerformedInitialLaunchBootstrap = true + + let start = Date() + await bootstrapSessionStateIfNeeded() + + // Keep the in-app splash visible long enough to avoid abrupt transition + // between native launch screen and app content. + let elapsed = Date().timeIntervalSince(start) + let minimumSplashDuration: TimeInterval = 1.0 + if elapsed < minimumSplashDuration { + let remaining = minimumSplashDuration - elapsed + let nanoseconds = UInt64(remaining * 1_000_000_000) + try? await Task.sleep(nanoseconds: nanoseconds) + } + + if root == .auth { + shouldPrepareAuthEntryAnimation = true + shouldPulseLaunchSplash = false + try? await Task.sleep(nanoseconds: 180_000_000) + withAnimation(.easeInOut(duration: 0.34)) { + showLaunchSplash = false + } + try? await Task.sleep(nanoseconds: 360_000_000) + authEntryAnimationToken += 1 + scheduleDisableAuthEntryPreparation() + return + } + + shouldPulseLaunchSplash = false + withAnimation(.easeOut(duration: 0.28)) { + showLaunchSplash = false + } + } + @MainActor private func bootstrapSessionStateIfNeeded() async { guard root == .main else { return } @@ -215,7 +276,17 @@ struct ContentView: View { isBootstrappingSession = false appState = AppState() selectedTab = .home + shouldPrepareAuthEntryAnimation = true root = .auth + authEntryAnimationToken += 1 + scheduleDisableAuthEntryPreparation() + } + + private func scheduleDisableAuthEntryPreparation() { + Task { @MainActor in + try? await Task.sleep(nanoseconds: 900_000_000) + shouldPrepareAuthEntryAnimation = false + } } private func hasConfiguredAddress() -> Bool { diff --git a/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings b/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings index 59d8729..891c3f9 100644 --- a/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings +++ b/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings @@ -1165,7 +1165,7 @@ "comment" : "A title for the OTP verification screen.", "isCommentAutoGenerated" : true }, - "Versão 4.20.1" : { + "Versão 1.0b" : { "comment" : "The version number of the app.", "isCommentAutoGenerated" : true }, diff --git a/pedi-foods/Sources/PediFoods/Services/ApiClient.swift b/pedi-foods/Sources/PediFoods/Services/ApiClient.swift index 2a32bfc..a74256c 100644 --- a/pedi-foods/Sources/PediFoods/Services/ApiClient.swift +++ b/pedi-foods/Sources/PediFoods/Services/ApiClient.swift @@ -82,15 +82,7 @@ final class ApiClient { } func send(_ request: ApiRequest) async throws -> T { - // NOTE: - // /api/customer/login is strict about body fields (email/phoneNumber/otp). - // On iOS, route this endpoint through URLSession to preserve raw JSON body behavior. - if request.path == "/api/customer/login" { - if let body = request.body, let bodyText = String(data: body, encoding: .utf8) { - print("[ApiClient] /api/customer/login body: \(bodyText)") - } - return try await sendWithURLSession(request) - } + #if canImport(LCEssentials) && os(iOS) return try await sendWithLCEssentials(request) #else diff --git a/pedi-foods/Sources/PediFoods/Views/Auth/AuthFlowView.swift b/pedi-foods/Sources/PediFoods/Views/Auth/AuthFlowView.swift index f599486..1dc0aa9 100644 --- a/pedi-foods/Sources/PediFoods/Views/Auth/AuthFlowView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Auth/AuthFlowView.swift @@ -11,12 +11,21 @@ struct AuthFlowView: View { @Binding var selectedTab: MainTab let tokenStore: TokenStore @Binding var appState: AppState + var shouldPrepareLoginEntry: Bool = false + var authEntryAnimationToken: Int = 0 @State var path: [Route] = [] var body: some View { NavigationStack(path: $path) { - LoginView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState) { route in + LoginView( + root: $root, + selectedTab: $selectedTab, + tokenStore: tokenStore, + appState: $appState, + shouldPrepareEntryAnimation: shouldPrepareLoginEntry, + authEntryAnimationToken: authEntryAnimationToken + ) { route in path.append(route) } .navigationDestination(for: Route.self) { route in diff --git a/pedi-foods/Sources/PediFoods/Views/Auth/LoginView.swift b/pedi-foods/Sources/PediFoods/Views/Auth/LoginView.swift index 2cb2ea8..5988f7a 100644 --- a/pedi-foods/Sources/PediFoods/Views/Auth/LoginView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Auth/LoginView.swift @@ -6,7 +6,13 @@ struct LoginView: View { let tokenStore: TokenStore @Binding var appState: AppState @Environment(\.colorScheme) var colorScheme + var shouldPrepareEntryAnimation: Bool = false + var authEntryAnimationToken: Int = 0 let navigate: (Route) -> Void + @State var heroVisible = true + @State var textVisible = true + @State var buttonVisible = true + @State var lastAnimatedToken = 0 @ViewBuilder private var logoImage: some View { #if os(Android) @@ -31,6 +37,7 @@ struct LoginView: View { var body: some View { GeometryReader { geo in let heroHeight = max(360, geo.size.height * 0.44) + let logoTopInset = max(0, (geo.size.height - 180) / 2) ZStack(alignment: .top) { (colorScheme == .dark ? Color.black : AppColors.backgroundLight) @@ -39,7 +46,7 @@ struct LoginView: View { pinHeroImage .scaledToFill() .frame(height: heroHeight + 80) - .offset(y: -60) + .offset(y: heroVisible ? -60 : -(heroHeight + 220)) .mask( LinearGradient( colors: [.black, .black, .black.opacity(0.0)], @@ -49,7 +56,7 @@ struct LoginView: View { ) VStack(spacing: 18) { - Spacer().frame(height: heroHeight - 100) + Spacer().frame(height: logoTopInset) logoImage .scaledToFit() @@ -61,6 +68,8 @@ struct LoginView: View { .multilineTextAlignment(.leading) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 28) + .opacity(textVisible ? 1.0 : 0.0) + .offset(y: textVisible ? 0 : 24) Spacer().frame(height: 40) @@ -71,6 +80,8 @@ struct LoginView: View { } .padding(.horizontal, 28) .tint(AppColors.tertiary) + .offset(y: buttonVisible ? 0 : 140) + .opacity(buttonVisible ? 1.0 : 0.0) HStack(spacing: 6) { Text("Não tem conta ainda?") @@ -81,11 +92,67 @@ struct LoginView: View { .foregroundStyle(AppColors.primary) } .font(AppTypography.body) + .opacity(textVisible ? 1.0 : 0.0) + .offset(y: textVisible ? 0 : 24) Spacer().frame(height: 12) } } .ignoresSafeArea() + .onAppear { + if shouldPrepareEntryAnimation { + applyHiddenStateWithoutAnimation() + } else { + showFinalStateWithoutAnimation() + } + } + .task(id: authEntryAnimationToken) { + await runEntryAnimationIfNeeded(for: authEntryAnimationToken) + } + } + } + + @MainActor + private func applyHiddenStateWithoutAnimation() { + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + heroVisible = false + textVisible = false + buttonVisible = false + } + } + + @MainActor + private func showFinalStateWithoutAnimation() { + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + heroVisible = true + textVisible = true + buttonVisible = true + } + } + + @MainActor + private func runEntryAnimationIfNeeded(for token: Int) async { + guard token > 0 else { return } + guard token != lastAnimatedToken else { return } + lastAnimatedToken = token + + applyHiddenStateWithoutAnimation() + try? await Task.sleep(nanoseconds: 40_000_000) + + withAnimation(.spring(response: 0.64, dampingFraction: 0.9)) { + heroVisible = true + } + try? await Task.sleep(nanoseconds: 160_000_000) + withAnimation(.easeOut(duration: 0.42)) { + textVisible = true + } + try? await Task.sleep(nanoseconds: 150_000_000) + withAnimation(.spring(response: 0.52, dampingFraction: 0.86)) { + buttonVisible = true } } } diff --git a/pedi-foods/Sources/PediFoods/Views/Launch/LaunchSplashView.swift b/pedi-foods/Sources/PediFoods/Views/Launch/LaunchSplashView.swift new file mode 100644 index 0000000..3b076d1 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Launch/LaunchSplashView.swift @@ -0,0 +1,47 @@ +import SwiftUI + +struct LaunchSplashView: View { + var shouldPulse: Bool = true + @Environment(\.colorScheme) var colorScheme + @State var isAnimating = false + + var body: some View { + GeometryReader { geo in + let logoTopInset = max(0, (geo.size.height - 180) / 2 - 8) + + ZStack(alignment: .top) { + (colorScheme == .dark ? Color.black : AppColors.backgroundLight) + .ignoresSafeArea() + + VStack(spacing: 0) { + Spacer().frame(height: logoTopInset) + + splashLogo + .scaledToFit() + .frame(width: 180, height: 180) + .scaleEffect(isAnimating ? 1.03 : 0.97) + .opacity(isAnimating ? 1.0 : 0.9) + .animation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true), value: isAnimating) + + Spacer() + } + } + } + .onAppear { + isAnimating = shouldPulse + } + .onChange(of: shouldPulse) { _, newValue in + isAnimating = newValue + } + } + + @ViewBuilder var splashLogo: some View { +#if os(Android) + SwiftUI.Image("pedifoods") + .resizable() +#else + SwiftUI.Image("pedifoods") + .resizable() +#endif + } +} diff --git a/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift b/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift index 6ef068d..03e6441 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift @@ -22,8 +22,8 @@ struct HomeView: View { @State var stores: [StoreSummary] = [] private let specials: [SpecialOfferCardModel] = [ - .init(id: "ddddd", title: "Get 50% OFF", subtitle: "For your first order", colors: [Color(hex: "#1E6B43"), Color(hex: "#58A56C")]), - .init(id: "eeeee", title: "Enjoy spicy day", subtitle: "Delivery in 20 min", colors: [Color(hex: "#F04B3E"), Color(hex: "#FF8C42")]) +// .init(id: "ddddd", title: "Get 50% OFF", subtitle: "For your first order", colors: [Color(hex: "#1E6B43"), Color(hex: "#58A56C")]), +// .init(id: "eeeee", title: "Enjoy spicy day", subtitle: "Delivery in 20 min", colors: [Color(hex: "#F04B3E"), Color(hex: "#FF8C42")]) ] private let headerExpandedHeight: CGFloat = 240 @@ -123,7 +123,7 @@ struct HomeView: View { } } - section(title: "#SpecialForYou") { + section(title: "#PediPromo") { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 16) { ForEach(specials) { item in @@ -135,7 +135,7 @@ struct HomeView: View { } } - section(title: "Near you") { + section(title: "Pertinho de você") { if isLoadingStores { HStack { ProgressView() @@ -226,7 +226,7 @@ struct HomeView: View { .buttonStyle(.plain) VStack(alignment: .center, spacing: 4) { - Text("DELIVERY LOCATION") + Text("ENTREGAR EM:") .font(AppTypography.overline) .tracking(AppTypography.captionLetterSpacing) .foregroundStyle(AppColors.brandSoft) diff --git a/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift b/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift index c261cfe..5d98c11 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift @@ -60,12 +60,12 @@ struct ProfileView: View { // } // .buttonStyle(.plain) - NavigationLink { - Text("Ajuda") - } label: { - ProfileMenuRow(icon: "gearshape.fill", title: "Configurações") - } - .buttonStyle(.plain) +// NavigationLink { +// Text("Ajuda") +// } label: { +// ProfileMenuRow(icon: "gearshape.fill", title: "Configurações") +// } +// .buttonStyle(.plain) } .padding(.horizontal, 20) @@ -82,7 +82,7 @@ struct ProfileView: View { .padding(.top, 10) .padding(.horizontal, 20) - Text("Versão 4.20.1") + Text("Versão 1.0b") .font(.system(size: 16, weight: .medium)) .foregroundStyle(AppColors.textMuted) .padding(.bottom, 12) diff --git a/pedi-foods/scripts/android-run.sh b/pedi-foods/scripts/android-run.sh index d221672..0fda50f 100755 --- a/pedi-foods/scripts/android-run.sh +++ b/pedi-foods/scripts/android-run.sh @@ -6,10 +6,15 @@ ANDROID_SDK_ROOT_DEFAULT="$HOME/Library/Android/sdk" EMULATOR_BIN_DEFAULT="$ANDROID_SDK_ROOT_DEFAULT/emulator/emulator" ADB_BIN_DEFAULT="$ANDROID_SDK_ROOT_DEFAULT/platform-tools/adb" -AVD_NAME="${1:-lc_arm64}" +AVD_NAME="${1:-auto}" BOOT_TIMEOUT_SECONDS="${BOOT_TIMEOUT_SECONDS:-120}" ADB_CONNECT_TIMEOUT_SECONDS="${ADB_CONNECT_TIMEOUT_SECONDS:-90}" APP_ID_DEFAULT="com.br.pedifoods.app" +REQUIRED_BRIDGE_SO="${REQUIRED_BRIDGE_SO:-libPediFoods.so}" +SKIP_EXPORT_ARCH="${SKIP_EXPORT_ARCH:-aarch64}" +START_EMULATOR_WHEN_NO_DEVICE="${START_EMULATOR_WHEN_NO_DEVICE:-0}" +EMULATOR_HEADLESS="${EMULATOR_HEADLESS:-1}" +EMULATOR_MEMORY_MB="${EMULATOR_MEMORY_MB:-1536}" if [[ -n "${ANDROID_SDK_ROOT:-}" ]]; then EMULATOR_BIN="$ANDROID_SDK_ROOT/emulator/emulator" @@ -39,73 +44,14 @@ if ! command -v skip >/dev/null 2>&1; then exit 1 fi -if ! "$EMULATOR_BIN" -list-avds | grep -qx "$AVD_NAME"; then - echo "Erro: AVD '$AVD_NAME' não existe. Disponíveis:" - "$EMULATOR_BIN" -list-avds - exit 1 -fi - -TARGET_SERIAL="$("$ADB_BIN" devices | awk 'NR>1 && $2=="device" {print $1; exit}')" +TARGET_SERIAL="" EMULATOR_PID="" -"$ADB_BIN" start-server >/dev/null 2>&1 || true - -if [[ -z "$TARGET_SERIAL" ]]; then - echo "Iniciando emulador: $AVD_NAME" - nohup "$EMULATOR_BIN" -avd "$AVD_NAME" >/tmp/skip-android-emulator.log 2>&1 & - EMULATOR_PID="$!" - sleep 2 -else - BOOTED="$("$ADB_BIN" -s "$TARGET_SERIAL" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" - if [[ "$BOOTED" == "1" ]]; then - echo "Dispositivo já pronto." - else - echo "Dispositivo conectado, aguardando finalização do boot." - fi -fi - -if [[ -z "$TARGET_SERIAL" ]]; then - echo "Aguardando ADB conectar..." - CONNECT_START_TS="$(date +%s)" - while true; do - TARGET_SERIAL="$("$ADB_BIN" devices | awk 'NR>1 && $2=="device" {print $1; exit}')" - if [[ -n "$TARGET_SERIAL" ]]; then - break - fi - - NOW_TS="$(date +%s)" - if (( NOW_TS - CONNECT_START_TS >= ADB_CONNECT_TIMEOUT_SECONDS )); then - echo "Timeout aguardando ADB conectar (${ADB_CONNECT_TIMEOUT_SECONDS}s)." - echo "Estado atual do adb:" - "$ADB_BIN" devices || true - if [[ -n "$EMULATOR_PID" ]] && ! kill -0 "$EMULATOR_PID" 2>/dev/null; then - echo "Processo do emulador encerrou antes da conexão." - fi - echo "Últimas linhas do log do emulador (/tmp/skip-android-emulator.log):" - tail -n 80 /tmp/skip-android-emulator.log || true - exit 1 - fi - sleep 2 - done -fi - -echo "Aguardando boot do Android..." -START_TS="$(date +%s)" -until [[ "$("$ADB_BIN" -s "$TARGET_SERIAL" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" == "1" ]]; do - NOW_TS="$(date +%s)" - if (( NOW_TS - START_TS >= BOOT_TIMEOUT_SECONDS )); then - echo "Timeout aguardando boot do Android (${BOOT_TIMEOUT_SECONDS}s) no dispositivo $TARGET_SERIAL." - "$ADB_BIN" devices || true - tail -n 80 /tmp/skip-android-emulator.log || true - exit 1 - fi - sleep 2 -done - APP_ID="$(sed -nE 's/^[[:space:]]*PRODUCT_BUNDLE_IDENTIFIER[[:space:]]*=[[:space:]]*([^[:space:]]+).*/\1/p' "$PROJECT_DIR/Skip.env" | head -n1)" APP_ID="${APP_ID:-$APP_ID_DEFAULT}" REQUIRE_FRESH_APK="${REQUIRE_FRESH_APK:-1}" ALLOW_INSTALLED_FALLBACK="${ALLOW_INSTALLED_FALLBACK:-1}" +RUN_STABILITY_SECONDS="${RUN_STABILITY_SECONDS:-8}" repair_skip_packages_links() { local swift_dir="$PROJECT_DIR/.build/plugins/outputs/pedi-foods/PediFoods/destination/skipstone/PediFoods/src/main/swift" @@ -147,7 +93,8 @@ clean_skip_generated_outputs() { run_skip_export() { cd "$PROJECT_DIR" - if skip export --debug --no-ios --android; then + echo "Executando skip export com arquitetura: $SKIP_EXPORT_ARCH" + if skip export --debug --no-ios --android --arch "$SKIP_EXPORT_ARCH"; then repair_skip_packages_links return 0 fi @@ -157,7 +104,7 @@ run_skip_export() { repair_skip_packages_links echo "Tentando skip export novamente após limpeza de cache..." - if skip export --debug --no-ios --android; then + if skip export --debug --no-ios --android --arch "$SKIP_EXPORT_ARCH"; then repair_skip_packages_links return 0 fi @@ -166,9 +113,210 @@ run_skip_export() { return 1 } +get_avd_abi() { + local avd_name="$1" + local cfg="$HOME/.android/avd/${avd_name}.avd/config.ini" + if [[ ! -f "$cfg" ]]; then + return 1 + fi + sed -nE 's/^[[:space:]]*abi\.type[[:space:]]*=[[:space:]]*([^[:space:]]+).*/\1/p' "$cfg" | head -n1 +} + +apk_supports_abi() { + local apk="$1" + local abi="$2" + unzip -l "$apk" 2>/dev/null | grep "lib/${abi}/${REQUIRED_BRIDGE_SO}$" >/dev/null +} + +apk_contains_required_so() { + local apk="$1" + unzip -l "$apk" 2>/dev/null | grep "/${REQUIRED_BRIDGE_SO}$" >/dev/null +} + +first_connected_device() { + "$ADB_BIN" devices | awk 'NR>1 && $2=="device" {print $1; exit}' +} + +pick_connected_device_for_apk() { + local apk="$1" + local serial abi + while IFS= read -r serial; do + [[ -n "$serial" ]] || continue + abi="$("$ADB_BIN" -s "$serial" shell getprop ro.product.cpu.abi 2>/dev/null | tr -d '\r')" + if [[ -n "$abi" ]] && apk_supports_abi "$apk" "$abi"; then + echo "$serial" + return 0 + fi + done < <("$ADB_BIN" devices | awk 'NR>1 && $2=="device" {print $1}') + return 1 +} + +pick_compatible_avd() { + local apk="$1" + local preferred="$2" + local avd_list + avd_list="$("$EMULATOR_BIN" -list-avds)" + local host_arch + host_arch="$(uname -m 2>/dev/null || echo "")" + + if [[ "$preferred" != "auto" ]] && echo "$avd_list" | grep -qx "$preferred"; then + local preferred_abi + preferred_abi="$(get_avd_abi "$preferred" || true)" + if [[ -n "$preferred_abi" ]] && apk_supports_abi "$apk" "$preferred_abi"; then + echo "$preferred" + return 0 + fi + fi + + local avd abi + while IFS= read -r avd; do + [[ -n "$avd" ]] || continue + abi="$(get_avd_abi "$avd" || true)" + if [[ "$host_arch" == "arm64" || "$host_arch" == "aarch64" ]]; then + if [[ "$abi" == "x86_64" || "$abi" == "x86" ]]; then + continue + fi + fi + if [[ -n "$abi" ]] && apk_supports_abi "$apk" "$abi"; then + echo "$avd" + return 0 + fi + done <<< "$avd_list" + return 1 +} + +ensure_android_ready() { + local apk_path="${1:-}" + local avd_to_boot="$AVD_NAME" + + if [[ -n "$apk_path" && -f "$apk_path" ]]; then + if ! apk_contains_required_so "$apk_path"; then + echo "Erro: APK não contém ${REQUIRED_BRIDGE_SO}. Build inválido para app Skip." + echo "APK: $apk_path" + exit 1 + fi + local picked + picked="$(pick_compatible_avd "$apk_path" "$AVD_NAME" || true)" + if [[ -n "$picked" ]]; then + avd_to_boot="$picked" + fi + fi + + "$ADB_BIN" start-server >/dev/null 2>&1 || true + if [[ -n "$apk_path" && -f "$apk_path" ]]; then + TARGET_SERIAL="$(pick_connected_device_for_apk "$apk_path" || true)" + else + TARGET_SERIAL="$(first_connected_device)" + fi + + if [[ -z "$TARGET_SERIAL" ]]; then + if [[ "$START_EMULATOR_WHEN_NO_DEVICE" != "1" ]]; then + echo "Erro: nenhum dispositivo Android conectado." + echo "Para evitar consumo de memória, este script não inicia emulador por padrão." + echo "Conecte um celular com USB debugging e rode novamente." + echo "Se quiser forçar emulador: START_EMULATOR_WHEN_NO_DEVICE=1 ./scripts/android-run.sh" + exit 1 + fi + + if ! "$EMULATOR_BIN" -list-avds | grep -qx "$avd_to_boot"; then + echo "Erro: AVD '$avd_to_boot' não existe. Disponíveis:" + "$EMULATOR_BIN" -list-avds + exit 1 + fi + + echo "Iniciando emulador: $avd_to_boot" + local emulator_args=("$EMULATOR_BIN" "-avd" "$avd_to_boot" "-no-snapshot-load") + if [[ "$EMULATOR_HEADLESS" == "1" ]]; then + emulator_args+=("-no-window" "-gpu" "swiftshader_indirect" "-no-audio") + fi + if [[ -n "$EMULATOR_MEMORY_MB" ]]; then + emulator_args+=("-memory" "$EMULATOR_MEMORY_MB") + fi + + nohup "${emulator_args[@]}" >/tmp/skip-android-emulator.log 2>&1 & + EMULATOR_PID="$!" + sleep 2 + else + BOOTED="$("$ADB_BIN" -s "$TARGET_SERIAL" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" + if [[ "$BOOTED" == "1" ]]; then + echo "Dispositivo já pronto." + else + echo "Dispositivo conectado, aguardando finalização do boot." + fi + fi + + if [[ -z "$TARGET_SERIAL" ]]; then + echo "Aguardando ADB conectar..." + CONNECT_START_TS="$(date +%s)" + while true; do + if [[ -n "$apk_path" && -f "$apk_path" ]]; then + TARGET_SERIAL="$(pick_connected_device_for_apk "$apk_path" || true)" + else + TARGET_SERIAL="$(first_connected_device)" + fi + if [[ -n "$TARGET_SERIAL" ]]; then + break + fi + + NOW_TS="$(date +%s)" + if (( NOW_TS - CONNECT_START_TS >= ADB_CONNECT_TIMEOUT_SECONDS )); then + echo "Timeout aguardando ADB conectar (${ADB_CONNECT_TIMEOUT_SECONDS}s)." + echo "Estado atual do adb:" + "$ADB_BIN" devices || true + if [[ -n "$EMULATOR_PID" ]] && ! kill -0 "$EMULATOR_PID" 2>/dev/null; then + echo "Processo do emulador encerrou antes da conexão." + fi + echo "Últimas linhas do log do emulador (/tmp/skip-android-emulator.log):" + tail -n 80 /tmp/skip-android-emulator.log || true + exit 1 + fi + if [[ -n "$EMULATOR_PID" ]] && ! kill -0 "$EMULATOR_PID" 2>/dev/null; then + echo "Processo do emulador encerrou antes da conexão." + echo "Últimas linhas do log do emulador (/tmp/skip-android-emulator.log):" + tail -n 80 /tmp/skip-android-emulator.log || true + exit 1 + fi + sleep 2 + done + fi + + echo "Aguardando boot do Android..." + START_TS="$(date +%s)" + until [[ "$("$ADB_BIN" -s "$TARGET_SERIAL" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" == "1" ]]; do + NOW_TS="$(date +%s)" + if (( NOW_TS - START_TS >= BOOT_TIMEOUT_SECONDS )); then + echo "Timeout aguardando boot do Android (${BOOT_TIMEOUT_SECONDS}s) no dispositivo $TARGET_SERIAL." + "$ADB_BIN" devices || true + tail -n 80 /tmp/skip-android-emulator.log || true + exit 1 + fi + sleep 2 + done + + if [[ -n "$apk_path" && -f "$apk_path" ]]; then + local device_abi + device_abi="$("$ADB_BIN" -s "$TARGET_SERIAL" shell getprop ro.product.cpu.abi 2>/dev/null | tr -d '\r')" + if [[ -n "$device_abi" ]] && ! apk_supports_abi "$apk_path" "$device_abi"; then + echo "Erro: APK não possui ${REQUIRED_BRIDGE_SO} para ABI do dispositivo: $device_abi" + echo "Tente outro AVD: x86_64 para APK x86_64, arm64-v8a para APK arm64." + exit 1 + fi + fi +} + repair_skip_packages_links find_apk_path() { + if [[ -f "$PROJECT_DIR/.build/skip-export/PediFoods-debug.apk" ]]; then + echo "$PROJECT_DIR/.build/skip-export/PediFoods-debug.apk" + return 0 + fi + local exported_apk + exported_apk="$(find "$PROJECT_DIR/.build/skip-export" -type f -name '*-debug.apk' 2>/dev/null | head -n1 || true)" + if [[ -n "$exported_apk" ]]; then + echo "$exported_apk" + return 0 + fi if [[ -f "$PROJECT_DIR/.build/Android/app/outputs/apk/debug/app-debug.apk" ]]; then echo "$PROJECT_DIR/.build/Android/app/outputs/apk/debug/app-debug.apk" return 0 @@ -214,6 +362,7 @@ APK_PATH="$(find_apk_path)" if [[ -z "$APK_PATH" ]]; then if [[ "$ALLOW_INSTALLED_FALLBACK" == "1" ]]; then + ensure_android_ready echo "Aviso: APK não encontrado. Tentando abrir app já instalado: $APP_ID" "$ADB_BIN" -s "$TARGET_SERIAL" shell monkey -p "$APP_ID" -c android.intent.category.LAUNCHER 1 echo "App aberto sem reinstalar (fallback)." @@ -223,6 +372,8 @@ if [[ -z "$APK_PATH" ]]; then exit 1 fi +ensure_android_ready "$APK_PATH" + if [[ "$REQUIRE_FRESH_APK" == "1" ]]; then APK_MTIME="$(stat -f '%m' "$APK_PATH" 2>/dev/null || echo 0)" SOURCE_MTIME="$(find "$PROJECT_DIR/Sources" -type f -name '*.swift' -exec stat -f '%m' {} \; 2>/dev/null | sort -nr | head -n1 || echo 0)" @@ -253,4 +404,21 @@ else "$ADB_BIN" -s "$TARGET_SERIAL" shell monkey -p "$APP_ID" -c android.intent.category.LAUNCHER 1 fi +echo "Validando execução do app..." +sleep 3 +APP_PID="$("$ADB_BIN" -s "$TARGET_SERIAL" shell pidof "$APP_ID" 2>/dev/null | tr -d '\r')" +if [[ -z "$APP_PID" ]]; then + echo "Erro: app não permaneceu em execução após abrir." + "$ADB_BIN" -s "$TARGET_SERIAL" logcat -d | tail -n 120 || true + exit 1 +fi + +sleep "$RUN_STABILITY_SECONDS" +APP_PID_AFTER="$("$ADB_BIN" -s "$TARGET_SERIAL" shell pidof "$APP_ID" 2>/dev/null | tr -d '\r')" +if [[ -z "$APP_PID_AFTER" ]]; then + echo "Erro: app encerrou durante validação de estabilidade (${RUN_STABILITY_SECONDS}s)." + "$ADB_BIN" -s "$TARGET_SERIAL" logcat -d | tail -n 160 || true + exit 1 +fi + echo "App instalado e aberto no Android."