diff --git a/dripzone-app/android/app/build.gradle b/dripzone-app/android/app/build.gradle index ab9f6a9..d80787b 100644 --- a/dripzone-app/android/app/build.gradle +++ b/dripzone-app/android/app/build.gradle @@ -1,54 +1,74 @@ -apply plugin: 'com.android.application' +plugins { + id 'com.android.application' + id 'org.jetbrains.kotlin.android' +} android { - namespace = "co.dripzone.app" - compileSdk = rootProject.ext.compileSdkVersion + namespace 'co.dripzone.app' + compileSdk 35 + defaultConfig { applicationId "co.dripzone.app" - minSdkVersion rootProject.ext.minSdkVersion - targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 1 - versionName "1.0" + minSdk 24 + targetSdk 35 + versionCode 2 + versionName "2.0.0" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - aaptOptions { - // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. - // Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61 - ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' - } } + buildTypes { release { minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + debug { + applicationIdSuffix "" + debuggable true } } -} -repositories { - flatDir{ - dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs' + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = '17' + } + + buildFeatures { + viewBinding true } } dependencies { - implementation fileTree(include: ['*.jar'], dir: 'libs') - implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" - implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion" - implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion" - implementation project(':capacitor-android') - testImplementation "junit:junit:$junitVersion" - androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" - androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" - implementation project(':capacitor-cordova-android-plugins') -} + implementation 'androidx.core:core-ktx:1.13.1' + implementation 'androidx.appcompat:appcompat:1.7.0' + implementation 'com.google.android.material:material:1.12.0' + implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' + implementation 'androidx.recyclerview:recyclerview:1.3.2' + implementation 'androidx.cardview:cardview:1.0.0' -apply from: 'capacitor.build.gradle' + // Coroutines & Lifecycle + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1' + implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.8.4' + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.4' -try { - def servicesJSON = file('google-services.json') - if (servicesJSON.text) { - apply plugin: 'com.google.gms.google-services' - } -} catch(Exception e) { - logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work") + // Networking & Serialization (Secure TLS & JSON) + implementation 'com.squareup.okhttp3:okhttp:4.12.0' + implementation 'com.squareup.okhttp3:logging-interceptor:4.12.0' + implementation 'com.google.code.gson:gson:2.10.1' + + // Image loading with Glide + implementation 'com.github.bumptech.glide:glide:4.16.0' + annotationProcessor 'com.github.bumptech.glide:compiler:4.16.0' + + // Modern Security Crypto (EncryptedSharedPreferences) + implementation 'androidx.security:security-crypto:1.1.0-alpha06' + + testImplementation 'junit:junit:4.13.2' + androidTestImplementation 'androidx.test.ext:junit:1.2.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' } diff --git a/dripzone-app/android/app/proguard-rules.pro b/dripzone-app/android/app/proguard-rules.pro index f1b4245..3b02a9e 100644 --- a/dripzone-app/android/app/proguard-rules.pro +++ b/dripzone-app/android/app/proguard-rules.pro @@ -1,21 +1,44 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html +# ProGuard / R8 Rules for DripZone Native Kotlin App -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} +# Preserve model classes used for JSON serialization/deserialization +-keepclassmembers class * implements java.io.Serializable { + static final long serialVersionUID; + private static final java.io.ObjectStreamField[] serialPersistentFields; + !static !transient ; + !private ; + !private ; + private void writeObject(java.io.ObjectOutputStream); + private void readObject(java.io.ObjectInputStream); + java.lang.Object writeReplace(); + java.lang.Object readResolve(); +} -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable +# Keep DripZone Data Models +-keep class co.dripzone.app.model.** { *; } +-keepclassmembers class co.dripzone.app.model.** { *; } -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile +# Gson specific rules +-keepattributes Signature +-keepattributes *Annotation* +-dontwarn sun.misc.** +-keep class com.google.gson.** { *; } + +# OkHttp & Coroutines +-dontwarn okhttp3.** +-dontwarn okio.** +-keep class okhttp3.** { *; } +-keep interface okhttp3.** { *; } +-dontwarn kotlinx.coroutines.** + +# Glide +-keep public class * implements com.bumptech.glide.module.GlideModule +-keep class * extends com.bumptech.glide.module.AppGlideModule { + (...); +} +-keep public enum com.bumptech.glide.load.ImageHeaderParser$** { + **[] $VALUES; + public *; +} + +# Android Security Crypto +-keepnames class androidx.security.crypto.** { *; } diff --git a/dripzone-app/android/app/src/main/AndroidManifest.xml b/dripzone-app/android/app/src/main/AndroidManifest.xml index b06ddbf..43b150e 100644 --- a/dripzone-app/android/app/src/main/AndroidManifest.xml +++ b/dripzone-app/android/app/src/main/AndroidManifest.xml @@ -1,20 +1,41 @@ - + + + + + + + + + + + + + + + + android:networkSecurityConfig="@xml/network_security_config" + android:theme="@style/Theme.DripZone" + tools:targetApi="31"> @@ -24,18 +45,6 @@ - - - - - - diff --git a/dripzone-app/android/app/src/main/java/co/dripzone/app/MainActivity.java b/dripzone-app/android/app/src/main/java/co/dripzone/app/MainActivity.java deleted file mode 100644 index e0a017f..0000000 --- a/dripzone-app/android/app/src/main/java/co/dripzone/app/MainActivity.java +++ /dev/null @@ -1,5 +0,0 @@ -package co.dripzone.app; - -import com.getcapacitor.BridgeActivity; - -public class MainActivity extends BridgeActivity {} diff --git a/dripzone-app/android/app/src/main/java/co/dripzone/app/MainActivity.kt b/dripzone-app/android/app/src/main/java/co/dripzone/app/MainActivity.kt new file mode 100644 index 0000000..30471d9 --- /dev/null +++ b/dripzone-app/android/app/src/main/java/co/dripzone/app/MainActivity.kt @@ -0,0 +1,253 @@ +package co.dripzone.app + +import android.os.Bundle +import android.text.Editable +import android.text.TextWatcher +import android.view.View +import android.view.inputmethod.InputMethodManager +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.GridLayoutManager +import co.dripzone.app.api.ApiClient +import co.dripzone.app.databinding.ActivityMainBinding +import co.dripzone.app.model.Category +import co.dripzone.app.model.Product +import co.dripzone.app.security.SecurityManager +import co.dripzone.app.ui.CategoryAdapter +import co.dripzone.app.ui.CustomOrderBottomSheet +import co.dripzone.app.ui.ProductAdapter +import co.dripzone.app.ui.ProductDetailBottomSheet +import com.google.android.material.snackbar.Snackbar +import kotlinx.coroutines.launch + +class MainActivity : AppCompatActivity() { + + private lateinit var binding: ActivityMainBinding + + private lateinit var productAdapter: ProductAdapter + private lateinit var categoryAdapter: CategoryAdapter + + private var allProducts: List = emptyList() + private var selectedCategory: String = "Todos" + private var searchQuery: String = "" + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) + + setupRecyclerViews() + setupCategories() + setupSearch() + setupListeners() + + loadProducts(forceRefresh = false) + } + + private fun setupRecyclerViews() { + productAdapter = ProductAdapter( + onProductClick = { product -> + ProductDetailBottomSheet.newInstance(product) + .show(supportFragmentManager, ProductDetailBottomSheet.TAG) + }, + onQuickOrderClick = { product -> + val whatsappMsg = buildString { + append("¡Hola DripZone! 🔥 Quiero pedir este producto con *Pago Contraentrega*:\n\n") + append("👟 *${product.name}*\n") + append("🏷️ *Marca:* ${product.brand}\n") + append("💰 *Precio:* ${product.formattedPrice()}\n") + if (product.firstImage().isNotBlank()) { + append("🔗 *Foto:* ${product.firstImage()}\n") + } + append("\n¿Tienen disponibilidad para envío inmediato?") + } + SecurityManager.openWhatsApp( + this, + SecurityManager.WHATSAPP_STORE_PHONE, + whatsappMsg + ) + } + ) + + binding.rvProducts.apply { + layoutManager = GridLayoutManager(this@MainActivity, 2) + adapter = productAdapter + setHasFixedSize(true) + } + } + + private fun setupCategories() { + val categories = listOf( + Category("all", "Todos", isSelected = true), + Category("zapatillas", "Sneakers"), + Category("chanclas", "Chanclas"), + Category("bolsos", "Bolsos"), + Category("gorras", "Gorras"), + Category("bajo_pedido", "Bajo Pedido") + ) + + categoryAdapter = CategoryAdapter(categories) { category -> + selectedCategory = category.name + filterProducts() + } + + binding.rvCategories.adapter = categoryAdapter + } + + private fun setupSearch() { + binding.btnToggleSearch.setOnClickListener { + if (binding.layoutSearchBar.visibility == View.VISIBLE) { + binding.layoutSearchBar.visibility = View.GONE + binding.etSearch.text?.clear() + hideKeyboard() + } else { + binding.layoutSearchBar.visibility = View.VISIBLE + binding.etSearch.requestFocus() + showKeyboard() + } + } + + binding.btnClearSearch.setOnClickListener { + binding.etSearch.text?.clear() + } + + binding.etSearch.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { + searchQuery = SecurityManager.sanitizeInput(s?.toString()) + binding.btnClearSearch.visibility = if (searchQuery.isNotEmpty()) View.VISIBLE else View.GONE + filterProducts() + } + override fun afterTextChanged(s: Editable?) {} + }) + + binding.btnResetFilters.setOnClickListener { + binding.etSearch.text?.clear() + selectedCategory = "Todos" + categoryAdapter = CategoryAdapter( + listOf( + Category("all", "Todos", isSelected = true), + Category("zapatillas", "Sneakers"), + Category("chanclas", "Chanclas"), + Category("bolsos", "Bolsos"), + Category("gorras", "Gorras"), + Category("bajo_pedido", "Bajo Pedido") + ) + ) { category -> + selectedCategory = category.name + filterProducts() + } + binding.rvCategories.adapter = categoryAdapter + filterProducts() + } + } + + private fun setupListeners() { + // Swipe to Refresh: fetches newest drops published from WhatsApp supplier groups + binding.swipeRefresh.setColorSchemeResources(R.color.accent_green) + binding.swipeRefresh.setProgressBackgroundColorSchemeResource(R.color.bg_card) + binding.swipeRefresh.setOnRefreshListener { + loadProducts(forceRefresh = true) + } + + // WhatsApp direct chat from header + binding.btnHeaderWhatsApp.setOnClickListener { + val generalInquiry = "¡Hola DripZone! 🔥 Me comunico desde la aplicación móvil. Quiero información sobre los productos y envíos contraentrega." + SecurityManager.openWhatsApp( + this, + SecurityManager.WHATSAPP_STORE_PHONE, + generalInquiry + ) + } + + // Floating Action Button: Encargos Especiales + binding.fabCustomOrder.setOnClickListener { + CustomOrderBottomSheet.newInstance() + .show(supportFragmentManager, CustomOrderBottomSheet.TAG) + } + } + + private fun loadProducts(forceRefresh: Boolean) { + if (!binding.swipeRefresh.isRefreshing) { + binding.progressBar.visibility = View.VISIBLE + } + + lifecycleScope.launch { + try { + val fetched = ApiClient.getProducts(this@MainActivity, forceRefresh) + allProducts = fetched + filterProducts() + + if (forceRefresh) { + Snackbar.make( + binding.rootCoordinator, + "Catálogo actualizado con los últimos drops", + Snackbar.LENGTH_SHORT + ).setBackgroundTint(getColor(R.color.bg_card_elevated)) + .setTextColor(getColor(R.color.accent_green)) + .show() + } + } catch (e: Exception) { + Snackbar.make( + binding.rootCoordinator, + "Cargando catálogo en modo sin conexión", + Snackbar.LENGTH_LONG + ).show() + } finally { + binding.progressBar.visibility = View.GONE + binding.swipeRefresh.isRefreshing = false + } + } + } + + private fun filterProducts() { + val filtered = allProducts.filter { product -> + val matchesCategory = when (selectedCategory) { + "Todos" -> true + "Sneakers" -> product.category.contains("Zapatilla", ignoreCase = true) || + product.category.contains("Sneaker", ignoreCase = true) || + product.category.contains("Tenis", ignoreCase = true) + "Chanclas" -> product.category.contains("Chancla", ignoreCase = true) || + product.category.contains("Slide", ignoreCase = true) + "Bolsos" -> product.category.contains("Bolso", ignoreCase = true) || + product.category.contains("Morral", ignoreCase = true) || + product.category.contains("Maleta", ignoreCase = true) + "Gorras" -> product.category.contains("Gorra", ignoreCase = true) || + product.category.contains("Accesorio", ignoreCase = true) + "Bajo Pedido" -> !product.inStock + else -> true + } + + val matchesSearch = if (searchQuery.isBlank()) { + true + } else { + product.name.contains(searchQuery, ignoreCase = true) || + product.brand.contains(searchQuery, ignoreCase = true) || + product.category.contains(searchQuery, ignoreCase = true) || + product.sizesSummary().contains(searchQuery, ignoreCase = true) + } + + matchesCategory && matchesSearch + } + + productAdapter.submitList(filtered) + + if (filtered.isEmpty()) { + binding.rvProducts.visibility = View.GONE + binding.layoutEmpty.visibility = View.VISIBLE + } else { + binding.rvProducts.visibility = View.VISIBLE + binding.layoutEmpty.visibility = View.GONE + } + } + + private fun showKeyboard() { + val imm = getSystemService(INPUT_METHOD_SERVICE) as? InputMethodManager + imm?.showSoftInput(binding.etSearch, InputMethodManager.SHOW_IMPLICIT) + } + + private fun hideKeyboard() { + val imm = getSystemService(INPUT_METHOD_SERVICE) as? InputMethodManager + imm?.hideSoftInputFromWindow(binding.etSearch.windowToken, 0) + } +} diff --git a/dripzone-app/android/app/src/main/java/co/dripzone/app/api/ApiClient.kt b/dripzone-app/android/app/src/main/java/co/dripzone/app/api/ApiClient.kt new file mode 100644 index 0000000..ff88b93 --- /dev/null +++ b/dripzone-app/android/app/src/main/java/co/dripzone/app/api/ApiClient.kt @@ -0,0 +1,165 @@ +package co.dripzone.app.api + +import android.content.Context +import android.util.Log +import co.dripzone.app.model.Product +import co.dripzone.app.model.ProductsResponse +import co.dripzone.app.security.SecurityManager +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.ConnectionSpec +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.TlsVersion +import java.io.IOException +import java.util.concurrent.TimeUnit + +object ApiClient { + + private const val TAG = "DripApiClient" + private const val PRIMARY_URL = "https://dripzone.almeira.com.co/api/products" + private const val FALLBACK_URL = "https://news.almeira.com.co/api/products" + private const val CACHE_KEY_PRODUCTS = "cached_products_json" + + private val gson = Gson() + + // OkHttpClient enforcing TLS 1.3 and TLS 1.2 modern cipher suites + private val client: OkHttpClient by lazy { + val modernTlsSpec = ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS) + .tlsVersions(TlsVersion.TLS_1_3, TlsVersion.TLS_1_2) + .build() + + OkHttpClient.Builder() + .connectionSpecs(listOf(modernTlsSpec, ConnectionSpec.COMPATIBLE_TLS)) + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(20, TimeUnit.SECONDS) + .writeTimeout(15, TimeUnit.SECONDS) + .retryOnConnectionFailure(true) + .build() + } + + /** + * Fetches products with fallback endpoints and secure offline cache + */ + suspend fun getProducts(context: Context, forceRefresh: Boolean = false): List = + withContext(Dispatchers.IO) { + // 1. Try primary URL + val primaryResult = fetchFromUrl(PRIMARY_URL) + if (primaryResult != null && primaryResult.isNotEmpty()) { + saveCache(context, primaryResult) + return@withContext primaryResult + } + + // 2. Try fallback URL + val fallbackResult = fetchFromUrl(FALLBACK_URL) + if (fallbackResult != null && fallbackResult.isNotEmpty()) { + saveCache(context, fallbackResult) + return@withContext fallbackResult + } + + // 3. If offline or network failed, return cached products + val cached = loadCache(context) + if (cached.isNotEmpty()) { + Log.i(TAG, "Serving ${cached.size} products from secure local cache") + return@withContext cached + } + + // 4. Return initial fallback sample if cache is empty + return@withContext getSampleProducts() + } + + private fun fetchFromUrl(url: String): List? { + return try { + val request = Request.Builder() + .url(url) + .header("Accept", "application/json") + .header("User-Agent", "DripZone-Android-Native/2.0") + .build() + + val response = client.newCall(request).execute() + if (!response.isSuccessful) { + Log.w(TAG, "Request to $url failed with code: ${response.code}") + return null + } + + val bodyString = response.body?.string() ?: return null + parseProductsJson(bodyString) + } catch (e: IOException) { + Log.e(TAG, "Network error querying $url: ${e.message}") + null + } catch (e: Exception) { + Log.e(TAG, "Unexpected error querying $url: ${e.message}") + null + } + } + + private fun parseProductsJson(json: String): List { + return try { + // First check if wrapped in { "products": [...] } + val wrapper = gson.fromJson(json, ProductsResponse::class.java) + if (wrapper?.products != null && wrapper.products.isNotEmpty()) { + return wrapper.products + } + + // Or direct list [...] + val listType = object : TypeToken>() {}.type + gson.fromJson>(json, listType) ?: emptyList() + } catch (e: Exception) { + Log.e(TAG, "Error parsing products JSON: ${e.message}") + emptyList() + } + } + + private fun saveCache(context: Context, products: List) { + try { + val prefs = SecurityManager.getSecurePreferences(context) + val json = gson.toJson(products) + prefs.edit().putString(CACHE_KEY_PRODUCTS, json).apply() + } catch (e: Exception) { + Log.e(TAG, "Failed to save secure cache", e) + } + } + + private fun loadCache(context: Context): List { + return try { + val prefs = SecurityManager.getSecurePreferences(context) + val json = prefs.getString(CACHE_KEY_PRODUCTS, null) ?: return emptyList() + val listType = object : TypeToken>() {}.type + gson.fromJson>(json, listType) ?: emptyList() + } catch (e: Exception) { + Log.e(TAG, "Failed to load secure cache", e) + emptyList() + } + } + + private fun getSampleProducts(): List { + return listOf( + Product( + id = "sample-1", + name = "Nike Air Jordan 4 Retro 'Military Black'", + brand = "JORDAN", + retailPrice = 285000.0, + category = "Zapatillas", + sizes = listOf("38", "39", "40", "41", "42", "43", "44"), + images = listOf("https://images.unsplash.com/photo-1552346154-21d32810aba3?w=800&q=80"), + description = "Sneakers urbanos de máxima calidad con envío contraentrega en toda Colombia.", + inStock = true, + isFeatured = true + ), + Product( + id = "sample-2", + name = "Yeezy Slide 'Onyx'", + brand = "ADIDAS", + retailPrice = 160000.0, + category = "Chanclas", + sizes = listOf("37", "38", "39", "40", "41", "42"), + images = listOf("https://images.unsplash.com/photo-1608231387042-66d1773070a5?w=800&q=80"), + description = "Chanclas slides ultra cómodas estilo streetwear.", + inStock = true, + isFeatured = true + ) + ) + } +} diff --git a/dripzone-app/android/app/src/main/java/co/dripzone/app/model/Product.kt b/dripzone-app/android/app/src/main/java/co/dripzone/app/model/Product.kt new file mode 100644 index 0000000..2cc0908 --- /dev/null +++ b/dripzone-app/android/app/src/main/java/co/dripzone/app/model/Product.kt @@ -0,0 +1,110 @@ +package co.dripzone.app.model + +import com.google.gson.annotations.SerializedName +import java.io.Serializable +import java.text.NumberFormat +import java.util.Locale + +data class Product( + @SerializedName("id") + val id: String = "", + + @SerializedName("name") + val name: String = "", + + @SerializedName("brand") + val brand: String = "DRIP", + + @SerializedName("retailPrice") + val retailPrice: Double? = null, + + @SerializedName("price") + val price: Double? = null, + + @SerializedName("originalPrice") + val originalPrice: Double? = null, + + @SerializedName("category") + val category: String = "Zapatillas", + + @SerializedName("images") + val images: List? = null, + + @SerializedName("image") + val singleImage: String? = null, + + @SerializedName("sizes") + val sizes: List? = null, + + @SerializedName("description") + val description: String? = null, + + @SerializedName("inStock") + val inStock: Boolean = true, + + @SerializedName("isFeatured") + val isFeatured: Boolean = false, + + @SerializedName("whatsappMessageId") + val whatsappMessageId: String? = null +) : Serializable { + + val effectivePrice: Double + get() = retailPrice ?: price ?: 0.0 + + fun formattedPrice(): String { + return try { + val format = NumberFormat.getCurrencyInstance(Locale("es", "CO")) + format.maximumFractionDigits = 0 + format.format(effectivePrice) + " COP" + } catch (e: Exception) { + val p = effectivePrice.toLong() + "$ $p COP" + } + } + + fun firstImage(): String { + if (!images.isNullOrEmpty() && images.first().isNotBlank()) { + return images.first() + } + if (!singleImage.isNullOrBlank()) { + return singleImage + } + return "" + } + + fun sizesList(): List { + if (!sizes.isNullOrEmpty()) { + return sizes.filter { it.isNotBlank() } + } + return listOf("38", "39", "40", "41", "42", "43") + } + + fun sizesSummary(): String { + val s = sizesList() + return if (s.size > 1) { + "Tallas: ${s.first()} - ${s.last()}" + } else if (s.size == 1) { + "Talla: ${s.first()}" + } else { + "Tallas disponibles" + } + } +} + +data class ProductsResponse( + @SerializedName("products") + val products: List? = null, + + @SerializedName("success") + val success: Boolean = true, + + @SerializedName("total") + val total: Int = 0 +) + +data class Category( + val id: String, + val name: String, + var isSelected: Boolean = false +) diff --git a/dripzone-app/android/app/src/main/java/co/dripzone/app/security/SecurityManager.kt b/dripzone-app/android/app/src/main/java/co/dripzone/app/security/SecurityManager.kt new file mode 100644 index 0000000..c7372cc --- /dev/null +++ b/dripzone-app/android/app/src/main/java/co/dripzone/app/security/SecurityManager.kt @@ -0,0 +1,92 @@ +package co.dripzone.app.security + +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.net.Uri +import android.util.Log +import android.widget.Toast +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKeys +import java.net.URLEncoder +import java.nio.charset.StandardCharsets + +object SecurityManager { + + private const val TAG = "DripSecurity" + private const val PREFS_FILE = "dripzone_secure_prefs" + const val WHATSAPP_STORE_PHONE = "573167455877" + + /** + * Initializes secure EncryptedSharedPreferences using Android KeyStore MasterKey (AES-256 GCM) + */ + fun getSecurePreferences(context: Context): SharedPreferences { + return try { + val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC) + EncryptedSharedPreferences.create( + PREFS_FILE, + masterKeyAlias, + context.applicationContext, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + } catch (e: Exception) { + Log.w(TAG, "Fallback to standard preferences due to security provider issue: ${e.message}") + context.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE) + } + } + + /** + * Safely dispatches an intent to WhatsApp using strict scheme validation + * to prevent Intent Redirection, Tapjacking, and Scheme Spoofing attacks. + */ + fun openWhatsApp(context: Context, phoneNumber: String, message: String) { + try { + val sanitizedPhone = phoneNumber.filter { it.isDigit() } + val encodedMessage = URLEncoder.encode(message, StandardCharsets.UTF_8.toString()) + val httpsUrl = "https://api.whatsapp.com/send?phone=$sanitizedPhone&text=$encodedMessage" + + val intent = Intent(Intent.ACTION_VIEW).apply { + data = Uri.parse(httpsUrl) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + // Verify if WhatsApp or WhatsApp Business is installed + val packageManager = context.packageManager + val activities = packageManager.queryIntentActivities(intent, 0) + val hasWhatsApp = activities.any { + val pkg = it.activityInfo.packageName + pkg == "com.whatsapp" || pkg == "com.whatsapp.w4b" + } + + if (hasWhatsApp) { + // Try targeted WhatsApp package for maximum security + val waIntent = Intent(Intent.ACTION_VIEW).apply { + data = Uri.parse(httpsUrl) + setPackage("com.whatsapp") + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + try { + context.startActivity(waIntent) + return + } catch (e: Exception) { + // Fall back to general chooser/browser + } + } + + // Launch secure HTTPS link in browser or available handler + context.startActivity(intent) + } catch (e: Exception) { + Log.e(TAG, "Error dispatching WhatsApp intent", e) + Toast.makeText(context, "No se pudo abrir WhatsApp: ${e.localizedMessage}", Toast.LENGTH_SHORT).show() + } + } + + /** + * Sanitizes user input string against XSS or command injection when preparing search or orders + */ + fun sanitizeInput(input: String?): String { + if (input.isNullOrBlank()) return "" + return input.replace(Regex("[<>&'\"\\\\]"), "").trim() + } +} diff --git a/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/CategoryAdapter.kt b/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/CategoryAdapter.kt new file mode 100644 index 0000000..96d9c47 --- /dev/null +++ b/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/CategoryAdapter.kt @@ -0,0 +1,59 @@ +package co.dripzone.app.ui + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.RecyclerView +import co.dripzone.app.R +import co.dripzone.app.databinding.ItemCategoryChipBinding +import co.dripzone.app.model.Category + +class CategoryAdapter( + private val categories: List, + private val onCategoryClick: (Category) -> Unit +) : RecyclerView.Adapter() { + + private var selectedIndex = 0 + + inner class CategoryViewHolder(private val binding: ItemCategoryChipBinding) : + RecyclerView.ViewHolder(binding.root) { + + fun bind(category: Category, isSelected: Boolean) { + binding.tvCategoryName.text = category.name + binding.tvCategoryName.isSelected = isSelected + + if (isSelected) { + binding.tvCategoryName.setTextColor( + ContextCompat.getColor(binding.root.context, R.color.text_primary) + ) + } else { + binding.tvCategoryName.setTextColor( + ContextCompat.getColor(binding.root.context, R.color.text_secondary) + ) + } + + binding.root.setOnClickListener { + val previousIndex = selectedIndex + selectedIndex = adapterPosition + notifyItemChanged(previousIndex) + notifyItemChanged(selectedIndex) + onCategoryClick(category) + } + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CategoryViewHolder { + val binding = ItemCategoryChipBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + return CategoryViewHolder(binding) + } + + override fun onBindViewHolder(holder: CategoryViewHolder, position: Int) { + holder.bind(categories[position], position == selectedIndex) + } + + override fun getItemCount(): Int = categories.size +} diff --git a/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/CustomOrderBottomSheet.kt b/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/CustomOrderBottomSheet.kt new file mode 100644 index 0000000..ae54290 --- /dev/null +++ b/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/CustomOrderBottomSheet.kt @@ -0,0 +1,98 @@ +package co.dripzone.app.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import co.dripzone.app.databinding.DialogCustomOrderBinding +import co.dripzone.app.security.SecurityManager +import com.google.android.material.bottomsheet.BottomSheetDialogFragment + +class CustomOrderBottomSheet : BottomSheetDialogFragment() { + + private var _binding: DialogCustomOrderBinding? = null + private val binding get() = _binding!! + + private var initialModel: String = "" + private var initialBrand: String = "" + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + arguments?.let { + initialModel = it.getString(ARG_MODEL, "") + initialBrand = it.getString(ARG_BRAND, "") + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = DialogCustomOrderBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + if (initialModel.isNotBlank()) { + binding.etCustomModel.setText(initialModel) + } + + binding.btnSubmitCustomOrder.setOnClickListener { + val rawModel = binding.etCustomModel.text?.toString() + val rawSize = binding.etCustomSize.text?.toString() + val rawNotes = binding.etCustomNotes.text?.toString() + + val sanitizedModel = SecurityManager.sanitizeInput(rawModel) + val sanitizedSize = SecurityManager.sanitizeInput(rawSize) + val sanitizedNotes = SecurityManager.sanitizeInput(rawNotes) + + if (sanitizedModel.isBlank()) { + binding.etCustomModel.error = "Por favor indica el modelo deseado" + return@setOnClickListener + } + + val message = buildString { + append("¡Hola DripZone! 🚀 Quiero consultar por un *Pedido Especial / Encargo*:\n\n") + append("👟 *Modelo deseado:* $sanitizedModel\n") + if (sanitizedSize.isNotBlank()) { + append("📏 *Talla estimada:* $sanitizedSize\n") + } + if (sanitizedNotes.isNotBlank()) { + append("📝 *Detalles / Colorway:* $sanitizedNotes\n") + } + append("\n¿Tienen disponibilidad o pueden cotizarlo con sus proveedores mayoristas?") + } + + SecurityManager.openWhatsApp( + requireContext(), + SecurityManager.WHATSAPP_STORE_PHONE, + message + ) + dismiss() + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + companion object { + const val TAG = "CustomOrderBottomSheet" + private const val ARG_MODEL = "arg_model" + private const val ARG_BRAND = "arg_brand" + + fun newInstance(model: String = "", brand: String = ""): CustomOrderBottomSheet { + return CustomOrderBottomSheet().apply { + arguments = Bundle().apply { + putString(ARG_MODEL, model) + putString(ARG_BRAND, brand) + } + } + } + } +} diff --git a/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/ProductAdapter.kt b/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/ProductAdapter.kt new file mode 100644 index 0000000..7e02981 --- /dev/null +++ b/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/ProductAdapter.kt @@ -0,0 +1,84 @@ +package co.dripzone.app.ui + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import co.dripzone.app.R +import co.dripzone.app.databinding.ItemProductBinding +import co.dripzone.app.model.Product +import com.bumptech.glide.Glide +import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions + +class ProductAdapter( + private val onProductClick: (Product) -> Unit, + private val onQuickOrderClick: (Product) -> Unit +) : ListAdapter(ProductDiffCallback()) { + + inner class ProductViewHolder(private val binding: ItemProductBinding) : + RecyclerView.ViewHolder(binding.root) { + + fun bind(product: Product) { + val context = binding.root.context + + binding.tvProductName.text = product.name + binding.tvProductPrice.text = product.formattedPrice() + binding.tvSizesAvailable.text = product.sizesSummary() + binding.tvBrandBadge.text = product.brand.uppercase() + + if (product.inStock) { + binding.tvStockBadge.text = "ENVÍO HOY" + binding.tvStockBadge.setTextColor(ContextCompat.getColor(context, R.color.badge_stock_text)) + } else { + binding.tvStockBadge.text = "BAJO PEDIDO" + binding.tvStockBadge.setTextColor(ContextCompat.getColor(context, R.color.badge_pedido_text)) + } + + val imageUrl = product.firstImage() + if (imageUrl.isNotBlank()) { + Glide.with(context) + .load(imageUrl) + .transition(DrawableTransitionOptions.withCrossFade()) + .centerCrop() + .placeholder(R.drawable.bg_product_card) + .into(binding.ivProductImage) + } else { + binding.ivProductImage.setImageResource(R.drawable.splash) + } + + binding.cardProduct.setOnClickListener { + onProductClick(product) + } + + binding.btnQuickOrder.setOnClickListener { + onQuickOrderClick(product) + } + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ProductViewHolder { + val binding = ItemProductBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + return ProductViewHolder(binding) + } + + override fun onBindViewHolder(holder: ProductViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + class ProductDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Product, newItem: Product): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: Product, newItem: Product): Boolean { + return oldItem == newItem + } + } +} diff --git a/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/ProductDetailBottomSheet.kt b/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/ProductDetailBottomSheet.kt new file mode 100644 index 0000000..d8b6f4d --- /dev/null +++ b/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/ProductDetailBottomSheet.kt @@ -0,0 +1,120 @@ +package co.dripzone.app.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import co.dripzone.app.R +import co.dripzone.app.databinding.BottomSheetProductDetailBinding +import co.dripzone.app.model.Product +import co.dripzone.app.security.SecurityManager +import com.bumptech.glide.Glide +import com.google.android.material.bottomsheet.BottomSheetDialogFragment + +class ProductDetailBottomSheet : BottomSheetDialogFragment() { + + private var _binding: BottomSheetProductDetailBinding? = null + private val binding get() = _binding!! + + private var product: Product? = null + private var sizeAdapter: SizeAdapter? = null + private var selectedSize: String = "" + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + arguments?.let { + @Suppress("DEPRECATION") + product = it.getSerializable(ARG_PRODUCT) as? Product + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = BottomSheetProductDetailBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val p = product ?: return + + binding.tvSheetBrand.text = p.brand.uppercase() + binding.tvSheetTitle.text = p.name + binding.tvSheetPrice.text = p.formattedPrice() + + val imageUrl = p.firstImage() + if (imageUrl.isNotBlank()) { + Glide.with(this) + .load(imageUrl) + .centerCrop() + .placeholder(R.drawable.bg_product_card) + .into(binding.ivSheetImage) + } + + // Setup size selection + val sizes = p.sizesList() + sizeAdapter = SizeAdapter(sizes) { size -> + selectedSize = size + } + binding.rvSizes.adapter = sizeAdapter + selectedSize = sizeAdapter?.getSelectedSize() ?: "" + + binding.btnSheetClose.setOnClickListener { + dismiss() + } + + // WhatsApp Order Checkout Button + binding.btnOrderWhatsApp.setOnClickListener { + val sizeToOrder = if (selectedSize.isNotBlank()) selectedSize else sizeAdapter?.getSelectedSize() ?: "Por coordinar" + val whatsappMessage = buildString { + append("¡Hola DripZone! 🔥 Me interesa ordenar este producto:\n\n") + append("👟 *Producto:* ${p.name}\n") + append("🏷️ *Marca:* ${p.brand}\n") + append("📏 *Talla solicitada:* $sizeToOrder\n") + append("💰 *Precio:* ${p.formattedPrice()}\n") + append("🚚 *Método:* Envío con Pago Contraentrega\n") + if (imageUrl.isNotBlank()) { + append("\n🔗 *Foto:* $imageUrl\n") + } + append("\n¿Tienen disponibilidad inmediata para despacho?") + } + + SecurityManager.openWhatsApp( + requireContext(), + SecurityManager.WHATSAPP_STORE_PHONE, + whatsappMessage + ) + dismiss() + } + + // Custom order button inside bottom sheet + binding.btnCustomOrderSheet.setOnClickListener { + dismiss() + val customOrderSheet = CustomOrderBottomSheet.newInstance(p.name, p.brand) + parentFragmentManager.let { + customOrderSheet.show(it, CustomOrderBottomSheet.TAG) + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + companion object { + const val TAG = "ProductDetailBottomSheet" + private const val ARG_PRODUCT = "arg_product" + + fun newInstance(product: Product): ProductDetailBottomSheet { + return ProductDetailBottomSheet().apply { + arguments = Bundle().apply { + putSerializable(ARG_PRODUCT, product) + } + } + } + } +} diff --git a/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/SizeAdapter.kt b/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/SizeAdapter.kt new file mode 100644 index 0000000..092cf76 --- /dev/null +++ b/dripzone-app/android/app/src/main/java/co/dripzone/app/ui/SizeAdapter.kt @@ -0,0 +1,72 @@ +package co.dripzone.app.ui + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.RecyclerView +import co.dripzone.app.R +import co.dripzone.app.databinding.ItemSizeChipBinding + +class SizeAdapter( + private val sizes: List, + private val onSizeSelected: (String) -> Unit +) : RecyclerView.Adapter() { + + private var selectedIndex = 0 + + init { + if (sizes.isNotEmpty()) { + onSizeSelected(sizes[0]) + } + } + + fun getSelectedSize(): String { + return if (sizes.isNotEmpty() && selectedIndex in sizes.indices) { + sizes[selectedIndex] + } else { + "38-43" + } + } + + inner class SizeViewHolder(private val binding: ItemSizeChipBinding) : + RecyclerView.ViewHolder(binding.root) { + + fun bind(size: String, isSelected: Boolean) { + binding.tvSizeLabel.text = size + binding.tvSizeLabel.isSelected = isSelected + + if (isSelected) { + binding.tvSizeLabel.setTextColor( + ContextCompat.getColor(binding.root.context, R.color.text_primary) + ) + } else { + binding.tvSizeLabel.setTextColor( + ContextCompat.getColor(binding.root.context, R.color.text_secondary) + ) + } + + binding.root.setOnClickListener { + val prev = selectedIndex + selectedIndex = adapterPosition + notifyItemChanged(prev) + notifyItemChanged(selectedIndex) + onSizeSelected(size) + } + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SizeViewHolder { + val binding = ItemSizeChipBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + return SizeViewHolder(binding) + } + + override fun onBindViewHolder(holder: SizeViewHolder, position: Int) { + holder.bind(sizes[position], position == selectedIndex) + } + + override fun getItemCount(): Int = sizes.size +} diff --git a/dripzone-app/android/app/src/main/res/drawable/bg_bottom_sheet.xml b/dripzone-app/android/app/src/main/res/drawable/bg_bottom_sheet.xml new file mode 100644 index 0000000..8fdba72 --- /dev/null +++ b/dripzone-app/android/app/src/main/res/drawable/bg_bottom_sheet.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/dripzone-app/android/app/src/main/res/drawable/bg_btn_secondary.xml b/dripzone-app/android/app/src/main/res/drawable/bg_btn_secondary.xml new file mode 100644 index 0000000..c7cd500 --- /dev/null +++ b/dripzone-app/android/app/src/main/res/drawable/bg_btn_secondary.xml @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/dripzone-app/android/app/src/main/res/drawable/bg_btn_whatsapp.xml b/dripzone-app/android/app/src/main/res/drawable/bg_btn_whatsapp.xml new file mode 100644 index 0000000..71310ae --- /dev/null +++ b/dripzone-app/android/app/src/main/res/drawable/bg_btn_whatsapp.xml @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/dripzone-app/android/app/src/main/res/drawable/bg_chip_category.xml b/dripzone-app/android/app/src/main/res/drawable/bg_chip_category.xml new file mode 100644 index 0000000..b3ed10f --- /dev/null +++ b/dripzone-app/android/app/src/main/res/drawable/bg_chip_category.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/dripzone-app/android/app/src/main/res/drawable/bg_chip_size.xml b/dripzone-app/android/app/src/main/res/drawable/bg_chip_size.xml new file mode 100644 index 0000000..7971f32 --- /dev/null +++ b/dripzone-app/android/app/src/main/res/drawable/bg_chip_size.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/dripzone-app/android/app/src/main/res/drawable/bg_product_card.xml b/dripzone-app/android/app/src/main/res/drawable/bg_product_card.xml new file mode 100644 index 0000000..d87fb33 --- /dev/null +++ b/dripzone-app/android/app/src/main/res/drawable/bg_product_card.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/dripzone-app/android/app/src/main/res/drawable/bg_search_input.xml b/dripzone-app/android/app/src/main/res/drawable/bg_search_input.xml new file mode 100644 index 0000000..c2bbc29 --- /dev/null +++ b/dripzone-app/android/app/src/main/res/drawable/bg_search_input.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/dripzone-app/android/app/src/main/res/drawable/ic_close.xml b/dripzone-app/android/app/src/main/res/drawable/ic_close.xml new file mode 100644 index 0000000..113ade3 --- /dev/null +++ b/dripzone-app/android/app/src/main/res/drawable/ic_close.xml @@ -0,0 +1,9 @@ + + + diff --git a/dripzone-app/android/app/src/main/res/drawable/ic_encargos.xml b/dripzone-app/android/app/src/main/res/drawable/ic_encargos.xml new file mode 100644 index 0000000..05588f3 --- /dev/null +++ b/dripzone-app/android/app/src/main/res/drawable/ic_encargos.xml @@ -0,0 +1,9 @@ + + + diff --git a/dripzone-app/android/app/src/main/res/drawable/ic_refresh.xml b/dripzone-app/android/app/src/main/res/drawable/ic_refresh.xml new file mode 100644 index 0000000..9da9b13 --- /dev/null +++ b/dripzone-app/android/app/src/main/res/drawable/ic_refresh.xml @@ -0,0 +1,9 @@ + + + diff --git a/dripzone-app/android/app/src/main/res/drawable/ic_search.xml b/dripzone-app/android/app/src/main/res/drawable/ic_search.xml new file mode 100644 index 0000000..b977528 --- /dev/null +++ b/dripzone-app/android/app/src/main/res/drawable/ic_search.xml @@ -0,0 +1,9 @@ + + + diff --git a/dripzone-app/android/app/src/main/res/drawable/ic_shield_check.xml b/dripzone-app/android/app/src/main/res/drawable/ic_shield_check.xml new file mode 100644 index 0000000..81096ba --- /dev/null +++ b/dripzone-app/android/app/src/main/res/drawable/ic_shield_check.xml @@ -0,0 +1,9 @@ + + + diff --git a/dripzone-app/android/app/src/main/res/drawable/ic_sparkle.xml b/dripzone-app/android/app/src/main/res/drawable/ic_sparkle.xml new file mode 100644 index 0000000..e4ed972 --- /dev/null +++ b/dripzone-app/android/app/src/main/res/drawable/ic_sparkle.xml @@ -0,0 +1,9 @@ + + + diff --git a/dripzone-app/android/app/src/main/res/drawable/ic_truck.xml b/dripzone-app/android/app/src/main/res/drawable/ic_truck.xml new file mode 100644 index 0000000..eb6aceb --- /dev/null +++ b/dripzone-app/android/app/src/main/res/drawable/ic_truck.xml @@ -0,0 +1,9 @@ + + + diff --git a/dripzone-app/android/app/src/main/res/drawable/ic_whatsapp.xml b/dripzone-app/android/app/src/main/res/drawable/ic_whatsapp.xml new file mode 100644 index 0000000..8eb8b1a --- /dev/null +++ b/dripzone-app/android/app/src/main/res/drawable/ic_whatsapp.xml @@ -0,0 +1,9 @@ + + + diff --git a/dripzone-app/android/app/src/main/res/layout/activity_main.xml b/dripzone-app/android/app/src/main/res/layout/activity_main.xml index b5ad138..3cfe194 100644 --- a/dripzone-app/android/app/src/main/res/layout/activity_main.xml +++ b/dripzone-app/android/app/src/main/res/layout/activity_main.xml @@ -2,11 +2,269 @@ + android:background="@color/bg_dark"> - + android:layout_height="wrap_content" + android:background="@color/bg_dark" + app:elevation="0dp"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dripzone-app/android/app/src/main/res/layout/bottom_sheet_product_detail.xml b/dripzone-app/android/app/src/main/res/layout/bottom_sheet_product_detail.xml new file mode 100644 index 0000000..4675970 --- /dev/null +++ b/dripzone-app/android/app/src/main/res/layout/bottom_sheet_product_detail.xml @@ -0,0 +1,189 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dripzone-app/android/app/src/main/res/layout/dialog_custom_order.xml b/dripzone-app/android/app/src/main/res/layout/dialog_custom_order.xml new file mode 100644 index 0000000..6066dac --- /dev/null +++ b/dripzone-app/android/app/src/main/res/layout/dialog_custom_order.xml @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dripzone-app/android/app/src/main/res/layout/item_category_chip.xml b/dripzone-app/android/app/src/main/res/layout/item_category_chip.xml new file mode 100644 index 0000000..e8baf46 --- /dev/null +++ b/dripzone-app/android/app/src/main/res/layout/item_category_chip.xml @@ -0,0 +1,15 @@ + + diff --git a/dripzone-app/android/app/src/main/res/layout/item_product.xml b/dripzone-app/android/app/src/main/res/layout/item_product.xml new file mode 100644 index 0000000..b00a1f0 --- /dev/null +++ b/dripzone-app/android/app/src/main/res/layout/item_product.xml @@ -0,0 +1,140 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dripzone-app/android/app/src/main/res/layout/item_size_chip.xml b/dripzone-app/android/app/src/main/res/layout/item_size_chip.xml new file mode 100644 index 0000000..a8721d1 --- /dev/null +++ b/dripzone-app/android/app/src/main/res/layout/item_size_chip.xml @@ -0,0 +1,14 @@ + + diff --git a/dripzone-app/android/app/src/main/res/values/colors.xml b/dripzone-app/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..c82c3bc --- /dev/null +++ b/dripzone-app/android/app/src/main/res/values/colors.xml @@ -0,0 +1,31 @@ + + + #10B981 + #059669 + #10B981 + + #0B0B0E + #16161A + #22222A + #2D2D38 + #1A1A22 + + #10B981 + #34D399 + #25D366 + #1EBE5D + #F97316 + #F59E0B + + #FFFFFF + #9CA3AF + #6B7280 + #27272A + + #2E1065 + #C084FC + #064E3B + #34D399 + #1E3A8A + #60A5FA + diff --git a/dripzone-app/android/app/src/main/res/values/styles.xml b/dripzone-app/android/app/src/main/res/values/styles.xml index be874e5..163ae4c 100644 --- a/dripzone-app/android/app/src/main/res/values/styles.xml +++ b/dripzone-app/android/app/src/main/res/values/styles.xml @@ -1,22 +1,37 @@ + + - - - + + + + +