Initial project import

This commit is contained in:
Wiraba Salamah
2026-04-24 08:04:20 +07:00
commit 3236104a0f
152 changed files with 26617 additions and 0 deletions

20
.gitignore vendored Normal file
View File

@ -0,0 +1,20 @@
*.iml
.gradle
.kotlin
.claude
/local.properties
/.idea
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
app/release
app/*.jks

2
BRIEF.md Normal file
View File

@ -0,0 +1,2 @@
Aplikasi ini adalah marketplace untuk UMKM di Indonesia.
pada aplikasi ini kita bisa berbelanja produk produk lokal dengan kualitas export.

View File

@ -0,0 +1,52 @@
{
"id": "ef991ef2-7bff-4c23-b065-f58854320d2d",
"name": "Ina Trading Dev",
"values": [
{
"key": "hostname",
"value": "https://be.inatrading.co.id",
"type": "default",
"enabled": true
},
{
"key": "username",
"value": "admin@admin.com",
"type": "default",
"enabled": true
},
{
"key": "password",
"value": "admin",
"type": "default",
"enabled": true
},
{
"key": "channel-id",
"value": "WEB",
"type": "default",
"enabled": true
},
{
"key": "token",
"value": "",
"type": "any",
"enabled": true
},
{
"key": "requestTime",
"value": "",
"type": "any",
"enabled": true
},
{
"key": "refNo",
"value": "",
"type": "any",
"enabled": true
}
],
"color": null,
"_postman_variable_scope": "environment",
"_postman_exported_at": "2026-04-08T08:43:41.211Z",
"_postman_exported_using": "Postman/12.4.2"
}

File diff suppressed because it is too large Load Diff

1
app/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

170
app/build.gradle.kts Normal file
View File

@ -0,0 +1,170 @@
import java.util.Properties
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.hilt.android)
alias(libs.plugins.ksp)
alias(libs.plugins.google.services)
}
val localProperties = Properties().apply {
val localPropertiesFile = rootProject.file("local.properties")
if (localPropertiesFile.exists()) {
localPropertiesFile.inputStream().use(::load)
}
}
val releaseStoreFile = localProperties.getProperty("INATRADING_STORE_FILE")
val releaseStorePassword = localProperties.getProperty("INATRADING_STORE_PASSWORD")
val releaseKeyAlias = localProperties.getProperty("INATRADING_KEY_ALIAS")
val releaseKeyPassword = localProperties.getProperty("INATRADING_KEY_PASSWORD")
val hasReleaseSigning =
!releaseStoreFile.isNullOrBlank() &&
!releaseStorePassword.isNullOrBlank() &&
!releaseKeyAlias.isNullOrBlank() &&
!releaseKeyPassword.isNullOrBlank()
android {
namespace = "id.iiyh.inatrading"
compileSdk {
version = release(36) {
minorApiLevel = 1
}
}
defaultConfig {
applicationId = "id.iiyh.inatrading"
minSdk = 24
targetSdk = 36
versionCode = 11
versionName = "1.0.7"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
buildConfigField("String", "BASE_URL", "\"https://api.inatrading.co.id/\"")
}
buildTypes {
release {
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
buildConfigField("String", "BASE_URL", "\"https://api.inatrading.co.id/\"")
}
debug {
buildConfigField("String", "BASE_URL", "\"https://be.inatrading.co.id/\"")
}
}
signingConfigs {
if (hasReleaseSigning) {
create("release") {
keyAlias = releaseKeyAlias
keyPassword = releaseKeyPassword
storeFile = file(releaseStoreFile!!)
storePassword = releaseStorePassword
}
}
}
buildTypes {
debug {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
if (hasReleaseSigning) {
signingConfig = signingConfigs.getByName("release")
}
isDebuggable = true
buildConfigField("String", "BASE_URL", "\"https://be.inatrading.co.id/\"")
}
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
if (hasReleaseSigning) {
signingConfig = signingConfigs.getByName("release")
}
isDebuggable = false
buildConfigField("String", "BASE_URL", "\"https://api.inatrading.co.id/\"")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
buildFeatures {
compose = true
buildConfig = true
}
}
kotlin {
jvmToolchain(11)
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
// Compose BOM
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.androidx.material.icons.extended)
// Navigation
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
// Hilt
implementation(libs.hilt.android)
ksp(libs.hilt.android.compiler)
implementation(libs.hilt.navigation.compose)
// Retrofit + OkHttp
implementation(libs.retrofit)
implementation(libs.retrofit.converter.gson)
implementation(libs.okhttp)
implementation(libs.okhttp.logging.interceptor)
// Google Fonts
implementation(libs.androidx.ui.text.google.fonts)
// Coil (image loading)
implementation(libs.coil.compose)
// DataStore (token storage)
implementation(libs.datastore.preferences)
// Gson
implementation(libs.gson)
// Firebase
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.messaging)
implementation(libs.firebase.analytics)
// Tests
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
}

29
app/google-services.json Normal file
View File

@ -0,0 +1,29 @@
{
"project_info": {
"project_number": "448330088694",
"project_id": "iptek-ina-trading",
"storage_bucket": "iptek-ina-trading.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:448330088694:android:5ffeaf56fc87a438ecacd7",
"android_client_info": {
"package_name": "id.iiyh.inatrading"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyBVVTSP77CbfInabwhGzNt5kcYS-hqfHyk"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}

21
app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,21 @@
# 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
# 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 *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@ -0,0 +1,24 @@
package id.iiyh.inatrading
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("id.iiyh.inatrading", appContext.packageName)
}
}

View File

@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.NFC" />
<uses-feature
android:name="android.hardware.nfc"
android:required="false" />
<application
android:name=".InaApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.InaTrading">
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<activity
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@ -0,0 +1,7 @@
package id.iiyh.inatrading
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class InaApplication : Application()

View File

@ -0,0 +1,155 @@
package id.iiyh.inatrading
import android.util.Base64
import android.nfc.NdefRecord
import android.nfc.NfcAdapter
import android.nfc.Tag
import android.nfc.tech.Ndef
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import dagger.hilt.android.AndroidEntryPoint
import id.iiyh.inatrading.core.ui.theme.InaTradingTheme
import id.iiyh.inatrading.navigation.AppNavigation
import java.nio.charset.Charset
import javax.crypto.Cipher
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.PBEKeySpec
import javax.crypto.spec.SecretKeySpec
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
private var nfcAdapter: NfcAdapter? = null
private var onRfidValueDetected: ((String) -> Unit)? = null
private var onRfidError: ((String) -> Unit)? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
nfcAdapter = NfcAdapter.getDefaultAdapter(this)
setContent {
InaTradingTheme {
AppNavigation()
}
}
}
fun startRfidScan(
onValueDetected: (String) -> Unit,
onError: (String) -> Unit,
): Boolean {
val adapter = nfcAdapter ?: return false
if (!adapter.isEnabled) {
onError(getString(R.string.rfid_nfc_disabled))
return true
}
onRfidValueDetected = onValueDetected
onRfidError = onError
adapter.enableReaderMode(
this,
{ tag ->
val matchedValue = extractInaTradingValue(tag)
runOnUiThread {
if (matchedValue != null) {
val callback = onRfidValueDetected
stopRfidScan()
callback?.invoke(matchedValue)
} else {
onRfidError?.invoke(getString(R.string.rfid_invalid_tag))
}
}
},
NfcAdapter.FLAG_READER_NFC_A or
NfcAdapter.FLAG_READER_NFC_B or
NfcAdapter.FLAG_READER_NFC_F or
NfcAdapter.FLAG_READER_NFC_V or
NfcAdapter.FLAG_READER_NFC_BARCODE,
null,
)
return true
}
fun stopRfidScan() {
nfcAdapter?.disableReaderMode(this)
onRfidValueDetected = null
onRfidError = null
}
override fun onPause() {
super.onPause()
stopRfidScan()
}
private fun extractInaTradingValue(tag: Tag): String? {
val payloads = buildList<String> {
val ndef = Ndef.get(tag)
if (ndef != null) {
runCatching {
ndef.connect()
ndef.cachedNdefMessage?.records
?.mapNotNull(::recordToText)
?.let(::addAll)
ndef.ndefMessage?.records
?.mapNotNull(::recordToText)
?.let(::addAll)
}
runCatching { if (ndef.isConnected) ndef.close() }
}
}
val rawValue = payloads.firstOrNull { it.startsWith(RFID_PREFIX) } ?: return null
val encryptedPart = rawValue.substringAfter(':', missingDelimiterValue = "").trim()
if (encryptedPart.isBlank()) return null
return decryptInaTradingValue(encryptedPart).takeIf { it.isNotBlank() }
}
private fun recordToText(record: NdefRecord): String? {
return when {
record.tnf == NdefRecord.TNF_WELL_KNOWN &&
record.type.contentEquals(NdefRecord.RTD_TEXT) -> parseTextRecord(record.payload)
else -> record.payload.toString(Charsets.UTF_8).trim().takeIf { it.isNotBlank() }
}
}
private fun parseTextRecord(payload: ByteArray): String? {
if (payload.isEmpty()) return null
val status = payload[0].toInt()
val isUtf16 = status and 0x80 != 0
val languageCodeLength = status and 0x3F
if (payload.size <= languageCodeLength + 1) return null
val textBytes = payload.copyOfRange(languageCodeLength + 1, payload.size)
val charset = if (isUtf16) Charset.forName("UTF-16") else Charsets.UTF_8
return textBytes.toString(charset).trim()
}
private fun decryptInaTradingValue(cipherText: String): String {
return runCatching {
val keyFactory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
val keySpec = PBEKeySpec(
AES_SECRET_KEY.toCharArray(),
AES_SALT.toByteArray(Charsets.UTF_8),
65536,
256,
)
val secretKey = SecretKeySpec(
keyFactory.generateSecret(keySpec).encoded,
"AES",
)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
val ivSpec = IvParameterSpec(ByteArray(16))
cipher.init(Cipher.DECRYPT_MODE, secretKey, ivSpec)
val decodedBytes = Base64.decode(cipherText, Base64.NO_WRAP)
val decryptedBytes = cipher.doFinal(decodedBytes)
String(decryptedBytes, Charsets.UTF_8)
}.getOrDefault("")
}
private companion object {
const val RFID_PREFIX = "inaTrading:"
const val AES_SECRET_KEY = "inaTrading"
const val AES_SALT = "this is hard!!!!"
}
}

View File

@ -0,0 +1,72 @@
package id.iiyh.inatrading.core.data.local
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "ina_session")
@Singleton
class SessionManager @Inject constructor(
@ApplicationContext private val context: Context,
) {
companion object {
private val TOKEN_KEY = stringPreferencesKey("auth_token")
private val EMAIL_KEY = stringPreferencesKey("user_email")
private val NAME_KEY = stringPreferencesKey("user_name")
private val AVATAR_URL_KEY = stringPreferencesKey("user_avatar_url")
private val USER_TYPE_KEY = stringPreferencesKey("user_type")
}
val token: Flow<String?> = context.dataStore.data.map { it[TOKEN_KEY] }
val email: Flow<String?> = context.dataStore.data.map { it[EMAIL_KEY] }
val name: Flow<String?> = context.dataStore.data.map { it[NAME_KEY] }
val avatarUrl: Flow<String?> = context.dataStore.data.map { it[AVATAR_URL_KEY] }
val userType: Flow<String?> = context.dataStore.data.map { it[USER_TYPE_KEY] }
suspend fun saveSession(
token: String,
email: String,
name: String,
userType: String,
avatarUrl: String = "",
) {
context.dataStore.edit { prefs ->
prefs[TOKEN_KEY] = token
prefs[EMAIL_KEY] = email
prefs[NAME_KEY] = name
prefs[AVATAR_URL_KEY] = avatarUrl
prefs[USER_TYPE_KEY] = userType
}
}
suspend fun saveToken(token: String) {
context.dataStore.edit { it[TOKEN_KEY] = token }
}
suspend fun updateProfile(name: String, email: String, avatarUrl: String? = null) {
context.dataStore.edit { prefs ->
prefs[NAME_KEY] = name
prefs[EMAIL_KEY] = email
avatarUrl?.let { prefs[AVATAR_URL_KEY] = it }
}
}
suspend fun clearToken() {
context.dataStore.edit { prefs ->
prefs.remove(TOKEN_KEY)
prefs.remove(EMAIL_KEY)
prefs.remove(NAME_KEY)
prefs.remove(AVATAR_URL_KEY)
prefs.remove(USER_TYPE_KEY)
}
}
}

View File

@ -0,0 +1,161 @@
package id.iiyh.inatrading.core.data.remote
import id.iiyh.inatrading.core.data.remote.model.ApiResponse
import id.iiyh.inatrading.core.data.remote.model.LoginData
import id.iiyh.inatrading.feature.auth.data.model.RegisterRequest
import id.iiyh.inatrading.feature.cart.data.model.CartCreateRequest
import id.iiyh.inatrading.feature.cart.data.model.CartListResponse
import id.iiyh.inatrading.feature.cart.data.model.CartUpdateRequest
import id.iiyh.inatrading.feature.favorite.data.model.FavoriteCreateRequest
import id.iiyh.inatrading.feature.favorite.data.model.FavoriteItemsResponse
import id.iiyh.inatrading.feature.favorite.data.model.FavoriteListResponse
import id.iiyh.inatrading.feature.favorite.data.model.AddToFavoriteRequest
import id.iiyh.inatrading.feature.explore.data.model.LocationListResponse
import id.iiyh.inatrading.feature.news.data.model.NewsListResponse
import id.iiyh.inatrading.feature.profile.data.model.BuyerProfile
import id.iiyh.inatrading.feature.profile.data.model.CityListResponse
import id.iiyh.inatrading.feature.profile.data.model.ChangePasswordRequest
import id.iiyh.inatrading.feature.profile.data.model.CreateShippingAddressRequest
import id.iiyh.inatrading.feature.profile.data.model.FileUploadData
import id.iiyh.inatrading.feature.profile.data.model.ProvinceListResponse
import id.iiyh.inatrading.feature.profile.data.model.ShippingAddressListResponse
import id.iiyh.inatrading.feature.profile.data.model.UpdateBuyerProfileRequest
import id.iiyh.inatrading.feature.product.data.model.ProductDetailResponse
import id.iiyh.inatrading.feature.product.data.model.ProductListResponse
import okhttp3.MultipartBody
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Part
import retrofit2.http.Query
import retrofit2.http.PUT
interface ApiService {
@POST("api/v1.0/buyer/login")
suspend fun loginBuyer(
@Header("Authorization") basicAuth: String,
): ApiResponse<LoginData>
@POST("api/v1.0/buyer/register")
suspend fun registerBuyer(
@Body request: RegisterRequest,
): ApiResponse<Unit?>
@GET("api/v1.0/seller/profile")
suspend fun getBuyerProfile(): ApiResponse<BuyerProfile>
@GET("api/v1.0/addresses")
suspend fun getAddresses(): ShippingAddressListResponse
@GET("api/v1.0/provinces")
suspend fun getProvinces(
@Query("page") page: Int,
@Query("limit") limit: Int,
): ProvinceListResponse
@GET("api/v1.0/cities")
suspend fun getCities(
@Query("provinceId") provinceId: String,
@Query("page") page: Int,
@Query("limit") limit: Int,
): CityListResponse
@POST("api/v1.0/addresses")
suspend fun createAddress(
@Body request: CreateShippingAddressRequest,
): ApiResponse<Unit?>
@PUT("api/v1.0/addresses/{addressId}")
suspend fun updateAddress(
@Path("addressId") addressId: String,
@Body request: CreateShippingAddressRequest,
): ApiResponse<Unit?>
@DELETE("api/v1.0/addresses/{addressId}")
suspend fun deleteAddress(
@Path("addressId") addressId: String,
): ApiResponse<Unit?>
@PUT("api/v1.0/seller/profile")
suspend fun updateBuyerProfile(
@Body request: UpdateBuyerProfileRequest,
): ApiResponse<Unit?>
@PUT("api/v1.0/profile/change-password")
suspend fun changePassword(
@Body request: ChangePasswordRequest,
): ApiResponse<Unit?>
@Multipart
@POST("api/v1.0/file/upload")
suspend fun uploadFile(
@Part file: MultipartBody.Part,
): ApiResponse<FileUploadData>
@GET("api/v1.0/newsarticles")
suspend fun getNewsArticles(): NewsListResponse
@GET("api/v1.0/product")
suspend fun getProducts(
@Query("page") page: Int,
@Query("limit") limit: Int,
): ProductListResponse
@GET("api/v1.0/locations")
suspend fun getLocations(
@Query("page") page: Int,
@Query("limit") limit: Int,
): LocationListResponse
@GET("api/v1.0/product/{productId}")
suspend fun getProductDetail(
@Path("productId") productId: String,
): ProductDetailResponse
@GET("api/v1.0/favorites")
suspend fun getFavorites(): FavoriteListResponse
@GET("api/v1.0/carts")
suspend fun getCarts(): CartListResponse
@POST("api/v1.0/cart")
suspend fun createCart(
@Body request: CartCreateRequest,
): ApiResponse<Unit?>
@PUT("api/v1.0/cart")
suspend fun updateCart(
@Body request: CartUpdateRequest,
): ApiResponse<Unit?>
@GET("api/v1.0/favorite/item/{favoriteId}")
suspend fun getFavoriteItems(
@Path("favoriteId") favoriteId: String,
): FavoriteItemsResponse
@POST("api/v1.0/favorite")
suspend fun createFavorite(
@Body request: FavoriteCreateRequest,
): ApiResponse<Unit?>
@PUT("api/v1.0/favorite/{favoriteId}")
suspend fun updateFavorite(
@Path("favoriteId") favoriteId: String,
@Body request: FavoriteCreateRequest,
): ApiResponse<Unit?>
@DELETE("api/v1.0/favorite/product/{productId}")
suspend fun deleteFavoriteItem(
@Path("productId") productId: String,
): ApiResponse<Unit?>
@POST("api/v1.0/favorite/add")
suspend fun addToFavorite(
@Body request: AddToFavoriteRequest,
): ApiResponse<Unit?>
}

View File

@ -0,0 +1,3 @@
package id.iiyh.inatrading.core.data.remote
class SessionExpiredException : Exception("Session expired")

View File

@ -0,0 +1,36 @@
package id.iiyh.inatrading.core.data.remote.interceptor
import id.iiyh.inatrading.core.data.local.SessionManager
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.runBlocking
import okhttp3.Interceptor
import okhttp3.Response
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.UUID
import javax.inject.Inject
class HeaderInterceptor @Inject constructor(
private val sessionManager: SessionManager,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val token = runBlocking { sessionManager.token.firstOrNull() }
val requestTime = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date())
val refNo = "REF${System.currentTimeMillis()}${UUID.randomUUID().toString().replace("-", "").take(6).uppercase()}"
val original = chain.request()
val builder = original.newBuilder()
.header("Request-Time", requestTime)
.header("Channel-Id", "WEB")
.header("Reference-Number", refNo)
// Add Bearer token only if not already overridden (e.g. login uses Basic Auth via @Header)
if (original.header("Authorization") == null && !token.isNullOrEmpty()) {
builder.header("Authorization", "Bearer $token")
}
return chain.proceed(builder.build())
}
}

View File

@ -0,0 +1,17 @@
package id.iiyh.inatrading.core.data.remote.model
data class ApiResponse<T>(
val responseCode: String? = null,
val responseDesc: String? = null,
val data: T? = null,
) {
val isSuccess: Boolean get() = responseCode == "0000"
}
data class LoginData(
val id: String? = null,
val name: String? = null,
val session: String? = null,
val userType: String? = null,
val isSellerActive: Boolean? = null,
)

View File

@ -0,0 +1,18 @@
package id.iiyh.inatrading.core.di
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import id.iiyh.inatrading.feature.auth.data.repository.AuthRepositoryImpl
import id.iiyh.inatrading.feature.auth.domain.repository.AuthRepository
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class AuthModule {
@Binds
@Singleton
abstract fun bindAuthRepository(impl: AuthRepositoryImpl): AuthRepository
}

View File

@ -0,0 +1,19 @@
package id.iiyh.inatrading.core.di
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import id.iiyh.inatrading.feature.cart.data.repository.CartRepositoryImpl
import id.iiyh.inatrading.feature.cart.domain.CartRepository
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class CartModule {
@Binds
@Singleton
abstract fun bindCartRepository(impl: CartRepositoryImpl): CartRepository
}

View File

@ -0,0 +1,18 @@
package id.iiyh.inatrading.core.di
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import id.iiyh.inatrading.feature.explore.data.repository.LocationRepositoryImpl
import id.iiyh.inatrading.feature.explore.domain.LocationRepository
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class ExploreModule {
@Binds
@Singleton
abstract fun bindLocationRepository(impl: LocationRepositoryImpl): LocationRepository
}

View File

@ -0,0 +1,18 @@
package id.iiyh.inatrading.core.di
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import id.iiyh.inatrading.feature.favorite.data.repository.FavoriteRepositoryImpl
import id.iiyh.inatrading.feature.favorite.domain.FavoriteRepository
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class FavoriteModule {
@Binds
@Singleton
abstract fun bindFavoriteRepository(impl: FavoriteRepositoryImpl): FavoriteRepository
}

View File

@ -0,0 +1,60 @@
package id.iiyh.inatrading.core.di
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import id.iiyh.inatrading.BuildConfig
import id.iiyh.inatrading.core.data.remote.ApiService
import id.iiyh.inatrading.core.data.remote.interceptor.HeaderInterceptor
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideGson(): Gson = GsonBuilder().create()
@Provides
@Singleton
fun provideLoggingInterceptor(): HttpLoggingInterceptor =
HttpLoggingInterceptor().apply {
level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY
else HttpLoggingInterceptor.Level.NONE
}
@Provides
@Singleton
fun provideOkHttpClient(
headerInterceptor: HeaderInterceptor,
loggingInterceptor: HttpLoggingInterceptor,
): OkHttpClient = OkHttpClient.Builder()
.addInterceptor(headerInterceptor)
.addInterceptor(loggingInterceptor)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
@Provides
@Singleton
fun provideRetrofit(client: OkHttpClient, gson: Gson): Retrofit =
Retrofit.Builder()
.baseUrl(BuildConfig.BASE_URL)
.client(client)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
@Provides
@Singleton
fun provideApiService(retrofit: Retrofit): ApiService =
retrofit.create(ApiService::class.java)
}

View File

@ -0,0 +1,18 @@
package id.iiyh.inatrading.core.di
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import id.iiyh.inatrading.feature.news.data.repository.NewsRepositoryImpl
import id.iiyh.inatrading.feature.news.domain.NewsRepository
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class NewsModule {
@Binds
@Singleton
abstract fun bindNewsRepository(impl: NewsRepositoryImpl): NewsRepository
}

View File

@ -0,0 +1,18 @@
package id.iiyh.inatrading.core.di
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import id.iiyh.inatrading.feature.product.data.repository.ProductRepositoryImpl
import id.iiyh.inatrading.feature.product.domain.ProductRepository
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class ProductModule {
@Binds
@Singleton
abstract fun bindProductRepository(impl: ProductRepositoryImpl): ProductRepository
}

View File

@ -0,0 +1,125 @@
package id.iiyh.inatrading.core.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import id.iiyh.inatrading.core.ui.theme.BrandRed
import id.iiyh.inatrading.core.ui.theme.OnSurface
import id.iiyh.inatrading.core.ui.theme.OnSurfaceVariant
import id.iiyh.inatrading.navigation.BottomNavItem
import id.iiyh.inatrading.navigation.Screen
import id.iiyh.inatrading.navigation.bottomNavItems
/**
* Bottom Navigation Bar bersama untuk 4 main screen.
*
* - Frosted glass: putih 85% opacity
* - Active tab: background merah/10 + filled icon + label bold
* - Inner screens: tidak menampilkan BottomNav ini
*/
@Composable
fun InaBottomNavBar(
currentRoute: String?,
onTabSelected: (Screen) -> Unit,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.fillMaxWidth()
.shadow(
elevation = 0.dp,
ambientColor = OnSurface.copy(alpha = 0.04f),
spotColor = OnSurface.copy(alpha = 0.04f),
)
.background(Color.White.copy(alpha = 0.85f))
.clip(RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp))
) {
Row(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
.padding(horizontal = 10.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceAround,
verticalAlignment = Alignment.CenterVertically,
) {
bottomNavItems.forEach { item ->
BottomNavTab(
modifier = Modifier.weight(1f),
item = item,
isSelected = currentRoute == item.screen.route,
onClick = { onTabSelected(item.screen) },
)
}
}
}
}
@Composable
private fun BottomNavTab(
modifier: Modifier = Modifier,
item: BottomNavItem,
isSelected: Boolean,
onClick: () -> Unit,
) {
val bgColor = if (isSelected) BrandRed.copy(alpha = 0.10f) else Color.Transparent
val iconTint = if (isSelected) BrandRed else OnSurfaceVariant
val labelColor = if (isSelected) BrandRed else OnSurfaceVariant
val icon = if (isSelected) item.iconSelected else item.icon
Column(
modifier = modifier
.clip(RoundedCornerShape(16.dp))
.background(bgColor)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(color = BrandRed.copy(alpha = 0.12f)),
onClick = onClick,
)
.heightIn(min = 52.dp)
.padding(horizontal = 10.dp, vertical = 6.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(2.dp, Alignment.CenterVertically),
) {
Icon(
imageVector = icon,
contentDescription = stringResource(item.labelRes),
tint = iconTint,
modifier = Modifier.size(22.dp),
)
Text(
text = stringResource(item.labelRes),
style = MaterialTheme.typography.labelSmall,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium,
color = labelColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
)
}
}

View File

@ -0,0 +1,168 @@
package id.iiyh.inatrading.core.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import id.iiyh.inatrading.core.ui.theme.AccentBlueContainer
import id.iiyh.inatrading.core.ui.theme.AccentPurple
import id.iiyh.inatrading.core.ui.theme.BrandNavy
import id.iiyh.inatrading.core.ui.theme.BrandRed
import id.iiyh.inatrading.core.ui.theme.BrandRedLight
import id.iiyh.inatrading.core.ui.theme.InaTradingTheme
import id.iiyh.inatrading.core.ui.theme.OnAccentBlueContainer
import id.iiyh.inatrading.core.ui.theme.InaShape
private val ButtonShape = RoundedCornerShape(InaShape.md)
private val ButtonHeight = 52.dp
// ─── Primary — gradient CTA ───────────────────────────────────────────────────
@Composable
fun InaPrimaryButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
isLoading: Boolean = false,
) {
val gradient = Brush.linearGradient(
colors = if (enabled) listOf(BrandRed, BrandRedLight)
else listOf(Color(0xFFCCCCCC), Color(0xFFBBBBBB)),
start = androidx.compose.ui.geometry.Offset(0f, 0f),
end = androidx.compose.ui.geometry.Offset(Float.POSITIVE_INFINITY, Float.POSITIVE_INFINITY),
)
Box(
modifier = modifier
.fillMaxWidth()
.height(ButtonHeight)
.clip(ButtonShape)
.background(gradient)
.clickable(
enabled = enabled && !isLoading,
interactionSource = remember { MutableInteractionSource() },
indication = ripple(color = Color.White.copy(alpha = 0.2f)),
onClick = onClick,
),
contentAlignment = Alignment.Center,
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(22.dp),
color = Color.White,
strokeWidth = 2.5.dp,
)
} else {
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
color = Color.White,
)
}
}
}
// ─── Secondary — filled tonal, no border ─────────────────────────────────────
@Composable
fun InaSecondaryButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
Box(
modifier = modifier
.fillMaxWidth()
.height(ButtonHeight)
.clip(ButtonShape)
.background(
if (enabled) AccentBlueContainer else Color(0xFFE0E0E0)
)
.clickable(
enabled = enabled,
interactionSource = remember { MutableInteractionSource() },
indication = ripple(color = BrandNavy.copy(alpha = 0.12f)),
onClick = onClick,
),
contentAlignment = Alignment.Center,
) {
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
color = if (enabled) OnAccentBlueContainer else Color(0xFF9E9E9E),
)
}
}
// ─── Tertiary — text only ─────────────────────────────────────────────────────
@Composable
fun InaTertiaryButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
Box(
modifier = modifier
.height(ButtonHeight)
.clip(ButtonShape)
.clickable(
enabled = enabled,
interactionSource = remember { MutableInteractionSource() },
indication = ripple(color = AccentPurple.copy(alpha = 0.12f)),
onClick = onClick,
)
.padding(horizontal = 16.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
color = if (enabled) AccentPurple else Color(0xFF9E9E9E),
)
}
}
// ─── Preview ──────────────────────────────────────────────────────────────────
@Preview(showBackground = true, backgroundColor = 0xFFF8FAFB)
@Composable
private fun InaButtonPreview() {
InaTradingTheme {
Column(
modifier = Modifier.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
InaPrimaryButton(text = "Masuk", onClick = {})
InaPrimaryButton(text = "Loading...", onClick = {}, isLoading = true)
InaPrimaryButton(text = "Disabled", onClick = {}, enabled = false)
InaSecondaryButton(text = "Daftar Sekarang", onClick = {})
InaTertiaryButton(text = "Lupa Password?", onClick = {})
}
}
}

View File

@ -0,0 +1,92 @@
package id.iiyh.inatrading.core.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import id.iiyh.inatrading.core.ui.theme.InaTradingTheme
import id.iiyh.inatrading.core.ui.theme.OnSurface
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerLowest
import id.iiyh.inatrading.core.ui.theme.InaShape
private val CardShape = RoundedCornerShape(InaShape.md)
/**
* Card dasar sesuai design spec:
* - Zero border (no outline)
* - Background: SurfaceContainerLowest (#ffffff) di atas SurfaceContainerLow (#f2f4f5)
* - Padding minimal 24dp
* - On press: background shift + ambient shadow naik sedikit
*/
@Composable
fun InaCard(
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
content: @Composable () -> Unit,
) {
val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
// Ambient shadow sesuai design spec: on-surface @ 4% opacity, blur 32dp, offsetY 8dp
// Saat pressed, shadow sedikit lebih dalam
val shadowElevation = if (isPressed) 6.dp else 2.dp
val bgColor = if (isPressed) Color(0xFFFAFAFA) else SurfaceContainerLowest
val clickModifier = if (onClick != null) {
Modifier.clickable(
interactionSource = interactionSource,
indication = ripple(color = OnSurface.copy(alpha = 0.06f)),
onClick = onClick,
)
} else Modifier
Column(
modifier = modifier
.shadow(
elevation = shadowElevation,
shape = CardShape,
ambientColor = OnSurface.copy(alpha = 0.04f),
spotColor = OnSurface.copy(alpha = 0.04f),
)
.clip(CardShape)
.background(bgColor)
.then(clickModifier)
.padding(24.dp),
) {
content()
}
}
// ─── Preview ──────────────────────────────────────────────────────────────────
@Preview(showBackground = true, backgroundColor = 0xFFF2F4F5)
@Composable
private fun InaCardPreview() {
InaTradingTheme {
Column(
modifier = Modifier.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
InaCard {
Text("Card tanpa interaksi")
}
InaCard(onClick = {}) {
Text("Card dengan onClick (clickable)")
}
}
}
}

View File

@ -0,0 +1,114 @@
package id.iiyh.inatrading.core.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import id.iiyh.inatrading.core.ui.theme.AccentBlue
import id.iiyh.inatrading.core.ui.theme.AccentBlueContainer
import id.iiyh.inatrading.core.ui.theme.AccentPurple
import id.iiyh.inatrading.core.ui.theme.AccentPurpleContainer
import id.iiyh.inatrading.core.ui.theme.BrandRed
import id.iiyh.inatrading.core.ui.theme.BrandRedContainer
import id.iiyh.inatrading.core.ui.theme.InaTradingTheme
import id.iiyh.inatrading.core.ui.theme.OnAccentBlueContainer
import id.iiyh.inatrading.core.ui.theme.OnAccentPurpleContainer
import id.iiyh.inatrading.core.ui.theme.InaShape
// Full roundness sesuai design spec — chip merchant harus kontras vs md (12dp) pada kartu
private val ChipShape = RoundedCornerShape(InaShape.full.dp)
enum class InaChipVariant {
/** Kategori produk — Secondary blue */
Category,
/** Featured merchant / premium — Tertiary purple */
Featured,
/** Status aktif / promosi — Primary red */
Promo,
}
/**
* Chip sesuai design spec:
* - Shape: full roundness (9999dp) — beda dengan md (12dp) pada kartu
* - Featured merchant → `tertiary_container` + `on_tertiary_container`
* - Category → `secondary_container` + `on_secondary_container`
* - Promo → `primary_container` + teks merah
*/
@Composable
fun InaChip(
label: String,
modifier: Modifier = Modifier,
variant: InaChipVariant = InaChipVariant.Category,
selected: Boolean = false,
onClick: (() -> Unit)? = null,
) {
val (bg, fg) = when (variant) {
InaChipVariant.Category -> {
if (selected) AccentBlue to Color.White
else AccentBlueContainer to OnAccentBlueContainer
}
InaChipVariant.Featured -> {
if (selected) AccentPurple to Color.White
else AccentPurpleContainer to OnAccentPurpleContainer
}
InaChipVariant.Promo -> {
if (selected) BrandRed to Color.White
else BrandRedContainer to BrandRed
}
}
val clickModifier = if (onClick != null) {
Modifier.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(color = fg.copy(alpha = 0.15f)),
onClick = onClick,
)
} else Modifier
Row(
modifier = modifier
.clip(ChipShape)
.background(bg)
.then(clickModifier)
.padding(horizontal = 14.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = fg,
)
}
}
// ─── Preview ──────────────────────────────────────────────────────────────────
@Preview(showBackground = true, backgroundColor = 0xFFF2F4F5)
@Composable
private fun InaChipPreview() {
InaTradingTheme {
Row(
modifier = Modifier.padding(24.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
InaChip(label = "Kuliner", variant = InaChipVariant.Category, onClick = {})
InaChip(label = "Kuliner", variant = InaChipVariant.Category, selected = true, onClick = {})
InaChip(label = "Featured", variant = InaChipVariant.Featured, onClick = {})
InaChip(label = "Promo", variant = InaChipVariant.Promo, onClick = {})
}
}
}

View File

@ -0,0 +1,71 @@
package id.iiyh.inatrading.core.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material3.Icon
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import id.iiyh.inatrading.R
import id.iiyh.inatrading.core.ui.theme.BrandRed
import id.iiyh.inatrading.core.ui.theme.OnSurface
/**
* Top App Bar untuk inner screen (tanpa BottomNav).
*
* Layout: [Back Arrow (merah)] ——— [Logo center] ———
*
* Frosted glass sama seperti main TopAppBar.
*/
@Composable
fun InaInnerTopAppBar(
onBack: (() -> Unit)?,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.fillMaxWidth()
.background(Color.White.copy(alpha = 0.85f))
.padding(horizontal = 20.dp, vertical = 14.dp),
) {
// Back arrow — kiri (hidden when onBack is null)
if (onBack != null) {
Box(
modifier = Modifier
.size(40.dp)
.align(Alignment.CenterStart)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(bounded = false, radius = 20.dp),
onClick = onBack,
),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
contentDescription = stringResource(R.string.topbar_menu),
tint = BrandRed,
modifier = Modifier.size(24.dp),
)
}
}
// Logo — center
InaLogo(
modifier = Modifier.align(Alignment.Center),
size = LogoSize.Small,
)
}
}

View File

@ -0,0 +1,41 @@
package id.iiyh.inatrading.core.ui.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import id.iiyh.inatrading.R
/**
* INA Trading logo — renders the official PNG asset.
*
* @param size Skala logo — [LogoSize.Small] untuk appbar, [LogoSize.Large] untuk splash.
*/
@Composable
fun InaLogo(
modifier: Modifier = Modifier,
size: LogoSize = LogoSize.Medium,
) {
Image(
painter = painterResource(R.drawable.header_new),
contentDescription = "INA Trading",
contentScale = ContentScale.Fit,
modifier = modifier.then(size.modifier),
)
}
// ─── Size variants ────────────────────────────────────────────────────────────
enum class LogoSize(val modifier: Modifier) {
/** Top App Bar */
Small(modifier = Modifier.height(36.dp)),
/** Default — onboarding header */
Medium(modifier = Modifier.height(56.dp)),
/** Splash screen */
Large(modifier = Modifier.height(80.dp)),
}

View File

@ -0,0 +1,193 @@
package id.iiyh.inatrading.core.ui.components
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusState
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import id.iiyh.inatrading.core.ui.theme.BrandRed
import id.iiyh.inatrading.core.ui.theme.InaTradingTheme
import id.iiyh.inatrading.core.ui.theme.OnSurface
import id.iiyh.inatrading.core.ui.theme.OnSurfaceVariant
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerLow
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerLowest
import id.iiyh.inatrading.core.ui.theme.InaShape
private val FieldShape = RoundedCornerShape(InaShape.md)
/**
* Input field sesuai design spec:
* - Normal state: background SurfaceContainerLow, no border
* - Focus state: background SurfaceContainerLowest + "ghost border" primary @ 40% opacity
* - Error state: ghost border ErrorRed @ 60% opacity
*/
@Composable
fun InaTextField(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
label: String = "",
placeholder: String = "",
isPassword: Boolean = false,
isError: Boolean = false,
errorMessage: String = "",
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
onFocusChanged: ((FocusState) -> Unit)? = null,
) {
var isFocused by remember { mutableStateOf(false) }
val backgroundColor by animateColorAsState(
targetValue = if (isFocused) SurfaceContainerLowest else SurfaceContainerLow,
animationSpec = tween(150),
label = "fieldBg",
)
val borderColor = when {
isError -> Color(0xFFBA1A1A).copy(alpha = 0.6f)
isFocused -> BrandRed.copy(alpha = 0.4f)
else -> Color.Transparent
}
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(6.dp)) {
if (label.isNotEmpty()) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = if (isError) Color(0xFFBA1A1A) else OnSurfaceVariant,
)
}
BasicTextField(
value = value,
onValueChange = onValueChange,
textStyle = MaterialTheme.typography.bodyLarge.copy(color = OnSurface),
cursorBrush = SolidColor(BrandRed),
visualTransformation = if (isPassword) PasswordVisualTransformation() else VisualTransformation.None,
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.onFocusChanged {
isFocused = it.isFocused
onFocusChanged?.invoke(it)
}
.background(backgroundColor, FieldShape)
.border(width = 1.dp, color = borderColor, shape = FieldShape)
.padding(horizontal = 16.dp, vertical = 14.dp),
decorationBox = { innerTextField ->
Box(modifier = Modifier.fillMaxWidth()) {
if (leadingIcon != null) {
Box(
modifier = Modifier
.size(20.dp)
.align(androidx.compose.ui.Alignment.CenterStart)
) {
leadingIcon()
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.padding(
start = if (leadingIcon != null) 32.dp else 0.dp,
end = if (trailingIcon != null) 32.dp else 0.dp,
)
) {
if (value.isEmpty() && placeholder.isNotEmpty()) {
Text(
text = placeholder,
style = MaterialTheme.typography.bodyLarge,
color = OnSurfaceVariant.copy(alpha = 0.6f),
)
}
innerTextField()
}
if (trailingIcon != null) {
Box(
modifier = Modifier
.size(20.dp)
.align(androidx.compose.ui.Alignment.CenterEnd)
) {
trailingIcon()
}
}
}
},
)
if (isError && errorMessage.isNotEmpty()) {
Text(
text = errorMessage,
style = MaterialTheme.typography.bodySmall,
color = Color(0xFFBA1A1A),
)
}
}
}
// ─── Preview ──────────────────────────────────────────────────────────────────
@Preview(showBackground = true, backgroundColor = 0xFFF8FAFB)
@Composable
private fun InaTextFieldPreview() {
InaTradingTheme {
var text by remember { mutableStateOf("") }
var pass by remember { mutableStateOf("") }
Column(
modifier = Modifier.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
InaTextField(
value = text,
onValueChange = { text = it },
label = "Email",
placeholder = "contoh@email.com",
)
InaTextField(
value = pass,
onValueChange = { pass = it },
label = "Password",
placeholder = "Minimal 8 karakter",
isPassword = true,
)
InaTextField(
value = "email-salah",
onValueChange = {},
label = "Email",
isError = true,
errorMessage = "Format email tidak valid",
)
}
}
}

View File

@ -0,0 +1,140 @@
package id.iiyh.inatrading.core.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Notifications
import androidx.compose.material.icons.outlined.ShoppingCart
import androidx.compose.material.icons.outlined.SettingsInputAntenna
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import id.iiyh.inatrading.R
import id.iiyh.inatrading.core.ui.theme.BrandRed
import id.iiyh.inatrading.core.ui.theme.OnSurface
import id.iiyh.inatrading.core.ui.theme.OnSurfaceVariant
/**
* Top App Bar bersama untuk semua main screen.
*
* Layout: [Logo] ————————————— [Cart+Badge] [Notif]
*/
@Composable
fun InaTopAppBar(
modifier: Modifier = Modifier,
cartItemCount: Int = 0,
onMenuClick: () -> Unit = {},
onRfidClick: () -> Unit = {},
onCartClick: () -> Unit = {},
onNotifClick: () -> Unit = {},
) {
Box(
modifier = modifier
.fillMaxWidth()
.shadow(
elevation = 0.dp,
ambientColor = OnSurface.copy(alpha = 0.04f),
spotColor = OnSurface.copy(alpha = 0.04f),
)
.background(Color.White.copy(alpha = 0.85f))
.statusBarsPadding()
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
// INA Trading logo image
InaLogo(size = LogoSize.Small)
Spacer(modifier = Modifier.weight(1f))
// Cart icon dengan badge
TopBarIcon(
icon = Icons.Outlined.SettingsInputAntenna,
contentDesc = stringResource(R.string.topbar_rfid),
onClick = onRfidClick,
)
Box {
TopBarIcon(
icon = Icons.Outlined.ShoppingCart,
contentDesc = stringResource(R.string.topbar_cart),
onClick = onCartClick,
)
if (cartItemCount > 0) {
Box(
modifier = Modifier
.size(14.dp)
.offset(x = 6.dp, y = (-2).dp)
.background(BrandRed, CircleShape)
.align(Alignment.TopEnd),
contentAlignment = Alignment.Center,
) {
Text(
text = if (cartItemCount > 9) "9+" else cartItemCount.toString(),
style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp),
color = Color.White,
)
}
}
}
// Notif icon
TopBarIcon(
icon = Icons.Outlined.Notifications,
contentDesc = stringResource(R.string.topbar_notifications),
onClick = onNotifClick,
modifier = Modifier.padding(start = 4.dp),
)
}
}
}
@Composable
private fun TopBarIcon(
icon: ImageVector,
contentDesc: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.size(36.dp)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(bounded = false, radius = 18.dp),
onClick = onClick,
),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = icon,
contentDescription = contentDesc,
tint = OnSurfaceVariant,
modifier = Modifier.size(22.dp),
)
}
}

View File

@ -0,0 +1,48 @@
package id.iiyh.inatrading.core.ui.theme
import androidx.compose.ui.graphics.Color
// ─── Brand ───────────────────────────────────────────────────────────────────
/** Merah INA — anchor utama brand */
val BrandRed = Color(0xFFB7131A)
val BrandRedLight = Color(0xFFDB322F) // gradient endpoint CTA
val BrandRedContainer = Color(0xFFFFDAD6)
/** Navy TRADING — dari wordmark logo */
val BrandNavy = Color(0xFF1D3461)
// ─── Accent ──────────────────────────────────────────────────────────────────
/** Secondary — Biru, progress & kategori */
val AccentBlue = Color(0xFF4355B9)
val AccentBlueContainer = Color(0xFFDEE0FF)
val OnAccentBlue = Color(0xFFFFFFFF)
val OnAccentBlueContainer = Color(0xFF000F5C)
/** Tertiary — Purple, fitur premium & discovery */
val AccentPurple = Color(0xFF6D45B0)
val AccentPurpleContainer = Color(0xFFEBDDFF)
val OnAccentPurple = Color(0xFFFFFFFF)
val OnAccentPurpleContainer = Color(0xFF260065)
// ─── Surface / Background ────────────────────────────────────────────────────
val Background = Color(0xFFF8FAFB) // Level 0 — base screen
val SurfaceContainerLow = Color(0xFFF2F4F5) // Level 1 — sections
val SurfaceContainerLowest = Color(0xFFFFFFFF) // Level 2 — cards / content
val SurfaceBright = Color(0xFFFFFFFF)
val SurfaceContainerHighest = Color(0xFFE2E4E5) // "Brand Slope" element
// ─── On-Surface ──────────────────────────────────────────────────────────────
/** Bukan pure black — premium soft look */
val OnSurface = Color(0xFF191C1D)
val OnSurfaceVariant = Color(0xFF41484D)
val OutlineVariant = Color(0xFFC0C8CD) // dipakai di 20% opacity ("ghost border")
// ─── Error ───────────────────────────────────────────────────────────────────
val ErrorRed = Color(0xFFBA1A1A)
val ErrorContainer = Color(0xFFFFDAD6)
val OnError = Color(0xFFFFFFFF)
val OnErrorContainer = Color(0xFF410002)
// ─── Functional accents ──────────────────────────────────────────────────────
val SuccessGreen = Color(0xFF2E7D32)
val WarningAmber = Color(0xFFF57F17)

View File

@ -0,0 +1,114 @@
package id.iiyh.inatrading.core.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
// ─── Shape tokens ─────────────────────────────────────────────────────────────
// Dipakai langsung sebagai Dp di komponen, bukan lewat MaterialTheme.shapes
// agar bisa mix sm/md/full sesuai design spec
object InaShape {
val sm = 4 // tags kecil
val md = 12 // kartu & container besar
val lg = 16
val full = 9999 // chip merchant
}
// ─── Elevation tokens ─────────────────────────────────────────────────────────
object InaElevation {
val none = 0
/** Ambient shadow untuk floating element: on-surface @ 4% opacity */
val ambient = 0.04f
val blurDp = 32
val offsetY = 8
}
// ─── Light Color Scheme ───────────────────────────────────────────────────────
private val LightColorScheme = lightColorScheme(
primary = BrandRed,
onPrimary = Color.White,
primaryContainer = BrandRedContainer,
onPrimaryContainer = Color(0xFF410002),
secondary = AccentBlue,
onSecondary = OnAccentBlue,
secondaryContainer = AccentBlueContainer,
onSecondaryContainer = OnAccentBlueContainer,
tertiary = AccentPurple,
onTertiary = OnAccentPurple,
tertiaryContainer = AccentPurpleContainer,
onTertiaryContainer = OnAccentPurpleContainer,
error = ErrorRed,
onError = OnError,
errorContainer = ErrorContainer,
onErrorContainer = OnErrorContainer,
background = Background,
onBackground = OnSurface,
surface = Background,
onSurface = OnSurface,
onSurfaceVariant = OnSurfaceVariant,
surfaceContainerLowest = SurfaceContainerLowest,
surfaceContainerLow = SurfaceContainerLow,
surfaceContainerHigh = SurfaceContainerHighest,
outline = OutlineVariant,
outlineVariant = OutlineVariant,
)
// ─── Dark Color Scheme ────────────────────────────────────────────────────────
private val DarkColorScheme = darkColorScheme(
primary = Color(0xFFFFB3AC),
onPrimary = Color(0xFF680005),
primaryContainer = Color(0xFF93000F),
onPrimaryContainer = BrandRedContainer,
secondary = Color(0xFFBBC3FF),
onSecondary = Color(0xFF00178E),
secondaryContainer = Color(0xFF203097),
onSecondaryContainer = Color(0xFFDEE0FF),
tertiary = Color(0xFFD3BBFF),
onTertiary = Color(0xFF3C007E),
tertiaryContainer = Color(0xFF560097),
onTertiaryContainer = AccentPurpleContainer,
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = ErrorContainer,
background = Color(0xFF191C1D),
onBackground = Color(0xFFE1E3E4),
surface = Color(0xFF191C1D),
onSurface = Color(0xFFE1E3E4),
onSurfaceVariant = Color(0xFFC0C8CD),
surfaceContainerLowest = Color(0xFF0E1112),
surfaceContainerLow = Color(0xFF191C1D),
surfaceContainerHigh = Color(0xFF2C3032),
outline = Color(0xFF8A9297),
outlineVariant = Color(0xFF41484D),
)
// ─── Theme Entry Point ────────────────────────────────────────────────────────
@Composable
fun InaTradingTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
MaterialTheme(
colorScheme = colorScheme,
typography = InaTypography,
content = content
)
}

View File

@ -0,0 +1,155 @@
package id.iiyh.inatrading.core.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.googlefonts.Font
import androidx.compose.ui.text.googlefonts.GoogleFont
import androidx.compose.ui.unit.sp
import id.iiyh.inatrading.R
private val provider = GoogleFont.Provider(
providerAuthority = "com.google.android.gms.fonts",
providerPackage = "com.google.android.gms",
certificates = R.array.com_google_android_gms_fonts_certs
)
// ─── Manrope — Display & Headlines ───────────────────────────────────────────
private val ManropeFont = GoogleFont("Manrope")
val ManropeFontFamily = FontFamily(
Font(googleFont = ManropeFont, fontProvider = provider, weight = FontWeight.Normal),
Font(googleFont = ManropeFont, fontProvider = provider, weight = FontWeight.Medium),
Font(googleFont = ManropeFont, fontProvider = provider, weight = FontWeight.SemiBold),
Font(googleFont = ManropeFont, fontProvider = provider, weight = FontWeight.Bold),
Font(googleFont = ManropeFont, fontProvider = provider, weight = FontWeight.ExtraBold),
)
// ─── Inter — Body & Labels ────────────────────────────────────────────────────
private val InterFont = GoogleFont("Inter")
val InterFontFamily = FontFamily(
Font(googleFont = InterFont, fontProvider = provider, weight = FontWeight.Normal),
Font(googleFont = InterFont, fontProvider = provider, weight = FontWeight.Medium),
Font(googleFont = InterFont, fontProvider = provider, weight = FontWeight.SemiBold),
Font(googleFont = InterFont, fontProvider = provider, weight = FontWeight.Bold),
)
// ─── Typography Scale ─────────────────────────────────────────────────────────
val InaTypography = Typography(
// Display — hero sections, editorial feel
displayLarge = TextStyle(
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.ExtraBold,
fontSize = 56.sp,
lineHeight = 64.sp,
letterSpacing = (-0.25).sp
),
displayMedium = TextStyle(
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 45.sp,
lineHeight = 52.sp,
letterSpacing = 0.sp
),
displaySmall = TextStyle(
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 36.sp,
lineHeight = 44.sp,
letterSpacing = 0.sp
),
// Headline — section anchors
headlineLarge = TextStyle(
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 28.sp,
lineHeight = 36.sp,
letterSpacing = 0.sp
),
headlineMedium = TextStyle(
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 24.sp,
lineHeight = 32.sp,
letterSpacing = 0.sp
),
headlineSmall = TextStyle(
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.SemiBold,
fontSize = 20.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
// Title — card headers
titleLarge = TextStyle(
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.SemiBold,
fontSize = 18.sp,
lineHeight = 26.sp,
letterSpacing = 0.sp
),
titleMedium = TextStyle(
fontFamily = InterFontFamily,
fontWeight = FontWeight.SemiBold,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.15.sp
),
titleSmall = TextStyle(
fontFamily = InterFontFamily,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp
),
// Body — descriptions & content
bodyLarge = TextStyle(
fontFamily = InterFontFamily,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
),
bodyMedium = TextStyle(
fontFamily = InterFontFamily,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.25.sp
),
bodySmall = TextStyle(
fontFamily = InterFontFamily,
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.4.sp
),
// Label — buttons, tags, all-caps identifiers
labelLarge = TextStyle(
fontFamily = InterFontFamily,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp
),
labelMedium = TextStyle(
fontFamily = InterFontFamily,
fontWeight = FontWeight.Medium,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
),
labelSmall = TextStyle(
fontFamily = InterFontFamily,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
),
)

View File

@ -0,0 +1,8 @@
package id.iiyh.inatrading.feature.auth.data.model
data class RegisterRequest(
val name: String,
val email: String,
val mobile: String,
val password: String,
)

View File

@ -0,0 +1,334 @@
package id.iiyh.inatrading.feature.auth.data.repository
import android.content.Context
import android.net.Uri
import android.util.Base64
import android.webkit.MimeTypeMap
import androidx.core.net.toFile
import dagger.hilt.android.qualifiers.ApplicationContext
import id.iiyh.inatrading.core.data.local.SessionManager
import id.iiyh.inatrading.core.data.remote.ApiService
import id.iiyh.inatrading.feature.auth.data.model.RegisterRequest
import id.iiyh.inatrading.feature.auth.domain.repository.AuthRepository
import id.iiyh.inatrading.feature.profile.data.model.BuyerProfile
import id.iiyh.inatrading.feature.profile.data.model.CityItem
import id.iiyh.inatrading.feature.profile.data.model.ChangePasswordRequest
import id.iiyh.inatrading.feature.profile.data.model.CreateShippingAddressRequest
import id.iiyh.inatrading.feature.profile.data.model.ProvinceItem
import id.iiyh.inatrading.feature.profile.data.model.ShippingAddress
import id.iiyh.inatrading.feature.profile.data.model.UpdateBuyerProfileRequest
import java.io.File
import java.util.UUID
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody
import retrofit2.HttpException
import javax.inject.Inject
class AuthRepositoryImpl @Inject constructor(
@ApplicationContext private val context: Context,
private val apiService: ApiService,
private val sessionManager: SessionManager,
) : AuthRepository {
private companion object {
const val REGION_PAGE_LIMIT = 100
}
override suspend fun login(email: String, password: String): Result<Unit> {
return try {
val credentials = Base64.encodeToString("$email:$password".toByteArray(), Base64.NO_WRAP)
val basicAuth = "Basic $credentials"
val response = apiService.loginBuyer(basicAuth)
if (!response.isSuccess) {
return Result.failure(Exception(response.responseDesc ?: "Login gagal"))
}
val data = response.data
?: return Result.failure(Exception("Session tidak ditemukan"))
val session = data.session
?: return Result.failure(Exception("Session tidak ditemukan"))
sessionManager.saveSession(
token = session,
email = email,
name = data.name.orEmpty(),
userType = data.userType.orEmpty(),
)
Result.success(Unit)
} catch (e: HttpException) {
Result.failure(Exception(e.parseMessage()))
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun register(
name: String,
email: String,
mobile: String,
password: String,
): Result<Unit> {
return try {
val response = apiService.registerBuyer(
RegisterRequest(name = name, email = email, mobile = mobile, password = password)
)
if (!response.isSuccess) {
return Result.failure(Exception(response.responseDesc ?: "Registrasi gagal"))
}
Result.success(Unit)
} catch (e: HttpException) {
Result.failure(Exception(e.parseMessage()))
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun getBuyerProfile(): Result<BuyerProfile> {
return try {
val response = apiService.getBuyerProfile()
if (!response.isSuccess) {
return Result.failure(Exception(response.responseDesc ?: "Gagal memuat profil"))
}
val data = response.data
?: return Result.failure(Exception("Data profil tidak ditemukan"))
sessionManager.updateProfile(
name = data.name.orEmpty(),
email = data.email.orEmpty(),
avatarUrl = data.imageId.orEmpty(),
)
Result.success(data)
} catch (e: HttpException) {
Result.failure(Exception(e.parseMessage()))
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun getAddresses(): Result<List<ShippingAddress>> {
return try {
val response = apiService.getAddresses()
if (!response.isSuccess) {
return Result.failure(Exception(response.responseDesc ?: "Gagal memuat alamat pengiriman"))
}
Result.success(response.rows)
} catch (e: HttpException) {
Result.failure(Exception(e.parseMessage()))
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun getProvinces(): Result<List<ProvinceItem>> {
return try {
val provinces = mutableListOf<ProvinceItem>()
var page = 1
var totalPage = 1
while (page <= totalPage) {
val response = apiService.getProvinces(
page = page,
limit = REGION_PAGE_LIMIT,
)
if (!response.isSuccess) {
return Result.failure(Exception(response.responseDesc ?: "Gagal memuat provinsi"))
}
provinces += response.rows
totalPage = response.totalPage.coerceAtLeast(1)
page += 1
}
Result.success(
provinces.distinctBy { it.id ?: it.code ?: it.name.orEmpty() }
)
} catch (e: HttpException) {
Result.failure(Exception(e.parseMessage()))
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun getCities(provinceId: String): Result<List<CityItem>> {
return try {
val cities = mutableListOf<CityItem>()
var page = 1
var totalPage = 1
while (page <= totalPage) {
val response = apiService.getCities(
provinceId = provinceId,
page = page,
limit = REGION_PAGE_LIMIT,
)
if (!response.isSuccess) {
return Result.failure(Exception(response.responseDesc ?: "Gagal memuat kota"))
}
cities += response.rows
totalPage = response.totalPage.coerceAtLeast(1)
page += 1
}
Result.success(
cities.distinctBy { it.id ?: it.code ?: it.name.orEmpty() }
)
} catch (e: HttpException) {
Result.failure(Exception(e.parseMessage()))
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun createAddress(request: CreateShippingAddressRequest): Result<Unit> {
return try {
val response = apiService.createAddress(request)
if (!response.isSuccess) {
return Result.failure(Exception(response.responseDesc ?: "Gagal menyimpan alamat"))
}
Result.success(Unit)
} catch (e: HttpException) {
Result.failure(Exception(e.parseMessage()))
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun updateAddress(
addressId: String,
request: CreateShippingAddressRequest,
): Result<Unit> {
return try {
val response = apiService.updateAddress(addressId, request)
if (!response.isSuccess) {
return Result.failure(Exception(response.responseDesc ?: "Gagal memperbarui alamat"))
}
Result.success(Unit)
} catch (e: HttpException) {
Result.failure(Exception(e.parseMessage()))
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun deleteAddress(addressId: String): Result<Unit> {
return try {
val response = apiService.deleteAddress(addressId)
if (!response.isSuccess) {
return Result.failure(Exception(response.responseDesc ?: "Gagal menghapus alamat"))
}
Result.success(Unit)
} catch (e: HttpException) {
Result.failure(Exception(e.parseMessage()))
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun uploadFile(uri: Uri): Result<String> {
return try {
val file = copyUriToTempFile(uri)
val mimeType = context.contentResolver.getType(uri).orEmpty().ifBlank { "image/*" }
val requestBody = file.asRequestBody(mimeType.toMediaTypeOrNull())
val part = MultipartBody.Part.createFormData("file", file.name, requestBody)
val response = apiService.uploadFile(part)
file.delete()
if (!response.isSuccess) {
return Result.failure(Exception(response.responseDesc ?: "Gagal mengunggah gambar"))
}
val fileId = response.data?.fileId
?: return Result.failure(Exception("File ID tidak ditemukan"))
Result.success(fileId)
} catch (e: HttpException) {
Result.failure(Exception(e.parseMessage()))
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun updateBuyerProfile(
name: String,
mobile: String,
imageId: String?,
email: String,
profileDescription: String?,
): Result<Unit> {
return try {
val response = apiService.updateBuyerProfile(
UpdateBuyerProfileRequest(
email = email,
imageId = imageId,
mobile = mobile,
name = name,
profileDescription = profileDescription,
)
)
if (!response.isSuccess) {
return Result.failure(Exception(response.responseDesc ?: "Gagal menyimpan profil"))
}
val latestProfileResponse = apiService.getBuyerProfile()
if (latestProfileResponse.isSuccess) {
val latestProfile = latestProfileResponse.data
sessionManager.updateProfile(
name = latestProfile?.name.orEmpty().ifBlank { name },
email = latestProfile?.email.orEmpty().ifBlank { email },
avatarUrl = latestProfile?.imageId.orEmpty(),
)
} else {
sessionManager.updateProfile(
name = name,
email = email,
)
}
Result.success(Unit)
} catch (e: HttpException) {
Result.failure(Exception(e.parseMessage()))
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun changePassword(
oldPassword: String,
newPassword: String,
): Result<Unit> {
return try {
val response = apiService.changePassword(
ChangePasswordRequest(
newPassword = newPassword,
oldPassword = oldPassword,
)
)
if (!response.isSuccess) {
return Result.failure(Exception(response.responseDesc ?: "Gagal mengubah kata sandi"))
}
Result.success(Unit)
} catch (e: HttpException) {
Result.failure(Exception(e.parseMessage()))
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun logout() {
sessionManager.clearToken()
}
private fun copyUriToTempFile(uri: Uri): File {
val extension = context.contentResolver.getType(uri)
?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) }
?: "jpg"
val tempFile = File(context.cacheDir, "upload-${UUID.randomUUID()}.$extension")
context.contentResolver.openInputStream(uri)?.use { input ->
tempFile.outputStream().use { output -> input.copyTo(output) }
} ?: throw IllegalStateException("Tidak dapat membaca file gambar")
return tempFile
}
private fun HttpException.parseMessage(): String {
return try {
val body = response()?.errorBody()?.string() ?: return "Terjadi kesalahan"
// Try to extract responseDesc from error body
val match = Regex(""""responseDesc"\s*:\s*"([^"]+)"""").find(body)
match?.groupValues?.get(1) ?: "Terjadi kesalahan (${code()})"
} catch (_: Exception) {
"Terjadi kesalahan (${code()})"
}
}
}

View File

@ -0,0 +1,30 @@
package id.iiyh.inatrading.feature.auth.domain.repository
import android.net.Uri
import id.iiyh.inatrading.feature.profile.data.model.BuyerProfile
import id.iiyh.inatrading.feature.profile.data.model.CityItem
import id.iiyh.inatrading.feature.profile.data.model.CreateShippingAddressRequest
import id.iiyh.inatrading.feature.profile.data.model.ProvinceItem
import id.iiyh.inatrading.feature.profile.data.model.ShippingAddress
interface AuthRepository {
suspend fun login(email: String, password: String): Result<Unit>
suspend fun register(name: String, email: String, mobile: String, password: String): Result<Unit>
suspend fun getBuyerProfile(): Result<BuyerProfile>
suspend fun getAddresses(): Result<List<ShippingAddress>>
suspend fun getProvinces(): Result<List<ProvinceItem>>
suspend fun getCities(provinceId: String): Result<List<CityItem>>
suspend fun createAddress(request: CreateShippingAddressRequest): Result<Unit>
suspend fun updateAddress(addressId: String, request: CreateShippingAddressRequest): Result<Unit>
suspend fun deleteAddress(addressId: String): Result<Unit>
suspend fun uploadFile(uri: Uri): Result<String>
suspend fun updateBuyerProfile(
name: String,
mobile: String,
imageId: String?,
email: String,
profileDescription: String? = null,
): Result<Unit>
suspend fun changePassword(oldPassword: String, newPassword: String): Result<Unit>
suspend fun logout()
}

View File

@ -0,0 +1,84 @@
package id.iiyh.inatrading.feature.auth.presentation
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import id.iiyh.inatrading.R
import id.iiyh.inatrading.core.ui.components.InaInnerTopAppBar
import id.iiyh.inatrading.core.ui.components.InaPrimaryButton
import id.iiyh.inatrading.core.ui.components.InaTextField
import id.iiyh.inatrading.core.ui.components.InaTertiaryButton
import id.iiyh.inatrading.core.ui.theme.Background
import id.iiyh.inatrading.core.ui.theme.ManropeFontFamily
import id.iiyh.inatrading.core.ui.theme.OnSurfaceVariant
// TODO: Implement sesuai desain yang akan dikirim
@Composable
fun ForgotPasswordScreen(
onBack: () -> Unit,
) {
var email by remember { mutableStateOf("") }
Scaffold(
topBar = { InaInnerTopAppBar(onBack = onBack) },
containerColor = Background,
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.background(Background)
.padding(innerPadding)
.padding(horizontal = 24.dp, vertical = 32.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = stringResource(R.string.forgot_password_title),
fontFamily = ManropeFontFamily,
style = MaterialTheme.typography.headlineMedium,
)
Text(
text = stringResource(R.string.forgot_password_desc),
style = MaterialTheme.typography.bodyMedium,
color = OnSurfaceVariant,
)
}
InaTextField(
value = email,
onValueChange = { email = it },
label = stringResource(R.string.login_email_label),
placeholder = stringResource(R.string.login_email_placeholder),
)
InaPrimaryButton(
text = stringResource(R.string.forgot_password_cta),
onClick = { /* TODO */ },
enabled = email.isNotBlank(),
)
Spacer(modifier = Modifier.weight(1f))
InaTertiaryButton(
text = stringResource(R.string.forgot_password_back_to_login),
onClick = onBack,
modifier = Modifier.fillMaxWidth(),
)
}
}
}

View File

@ -0,0 +1,543 @@
package id.iiyh.inatrading.feature.auth.presentation
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Visibility
import androidx.compose.material.icons.outlined.VisibilityOff
import androidx.compose.material.icons.outlined.VerifiedUser
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import coil.compose.AsyncImage
import id.iiyh.inatrading.R
import id.iiyh.inatrading.core.ui.components.InaInnerTopAppBar
import id.iiyh.inatrading.core.ui.components.InaPrimaryButton
import id.iiyh.inatrading.core.ui.components.InaTextField
import id.iiyh.inatrading.core.ui.theme.AccentBlue
import id.iiyh.inatrading.core.ui.theme.AccentPurple
import id.iiyh.inatrading.core.ui.theme.AccentPurpleContainer
import id.iiyh.inatrading.core.ui.theme.Background
import id.iiyh.inatrading.core.ui.theme.BrandRed
import id.iiyh.inatrading.core.ui.theme.BrandRedLight
import id.iiyh.inatrading.core.ui.theme.InterFontFamily
import id.iiyh.inatrading.core.ui.theme.ManropeFontFamily
import id.iiyh.inatrading.core.ui.theme.OnSurface
import id.iiyh.inatrading.core.ui.theme.OnSurfaceVariant
import id.iiyh.inatrading.core.ui.theme.OutlineVariant
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerLow
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerLowest
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable
fun LoginScreen(
onBack: () -> Unit,
onLoginSuccess: () -> Unit,
onForgotPassword: () -> Unit,
onRegister: () -> Unit,
viewModel: LoginViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsState()
val coroutineScope = rememberCoroutineScope()
val scrollState = rememberScrollState()
val density = LocalDensity.current
val desiredFieldTopPx = with(density) { 96.dp.toPx() }
var emailTopPx by remember { mutableFloatStateOf(0f) }
var passwordTopPx by remember { mutableFloatStateOf(0f) }
LaunchedEffect(uiState.isSuccess) {
if (uiState.isSuccess) onLoginSuccess()
}
Scaffold(
topBar = { InaInnerTopAppBar(onBack = onBack) },
containerColor = Background,
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.background(Background)
.padding(innerPadding)
.verticalScroll(scrollState),
) {
// ── Hero Section ──────────────────────────────────────────────
HeroSection()
// ── Login Card (overlap hero) ─────────────────────────────────
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.offset(y = (-32).dp), // -mt-8 sesuai desain
) {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(SurfaceContainerLowest)
.padding(28.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
// Email
InaTextField(
value = uiState.email,
onValueChange = viewModel::onEmailChange,
label = stringResource(R.string.login_email_label),
placeholder = stringResource(R.string.login_email_placeholder),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
modifier = Modifier.onGloballyPositioned {
emailTopPx = it.positionInRoot().y
},
onFocusChanged = {
if (it.isFocused) {
coroutineScope.launch {
delay(150)
scrollFieldNearTop(
scrollState = scrollState,
fieldTopPx = emailTopPx,
desiredTopPx = desiredFieldTopPx,
)
}
}
},
)
// Password
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 2.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(R.string.login_password_label).uppercase(),
style = MaterialTheme.typography.labelMedium,
color = OnSurfaceVariant,
letterSpacing = 1.sp,
)
Text(
text = stringResource(R.string.login_forgot_password),
style = MaterialTheme.typography.labelMedium,
color = AccentBlue,
fontWeight = FontWeight.Medium,
modifier = Modifier.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(bounded = false),
onClick = onForgotPassword,
),
)
}
InaTextField(
value = uiState.password,
onValueChange = viewModel::onPasswordChange,
placeholder = stringResource(R.string.login_password_placeholder),
isPassword = !uiState.isPasswordVisible,
modifier = Modifier.onGloballyPositioned {
passwordTopPx = it.positionInRoot().y
},
trailingIcon = {
Icon(
imageVector = if (uiState.isPasswordVisible)
Icons.Outlined.VisibilityOff
else
Icons.Outlined.Visibility,
contentDescription = if (uiState.isPasswordVisible)
stringResource(R.string.login_hide_password)
else
stringResource(R.string.login_show_password),
tint = OnSurfaceVariant,
modifier = Modifier
.size(20.dp)
.clickable(onClick = viewModel::togglePasswordVisibility),
)
},
onFocusChanged = {
if (it.isFocused) {
coroutineScope.launch {
delay(150)
scrollFieldNearTop(
scrollState = scrollState,
fieldTopPx = passwordTopPx,
desiredTopPx = desiredFieldTopPx,
)
}
}
},
)
}
// API error
if (uiState.errorMessage != null) {
Text(
text = uiState.errorMessage!!,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
// CTA
InaPrimaryButton(
text = stringResource(R.string.login_cta),
onClick = viewModel::login,
isLoading = uiState.isLoading,
enabled = uiState.email.isNotBlank() && uiState.password.isNotBlank(),
)
// Divider
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
HorizontalDivider(
modifier = Modifier.weight(1f),
color = OutlineVariant.copy(alpha = 0.25f),
)
Text(
text = stringResource(R.string.login_or_with).uppercase(),
style = MaterialTheme.typography.labelSmall,
color = OnSurfaceVariant.copy(alpha = 0.5f),
letterSpacing = 1.5.sp,
)
HorizontalDivider(
modifier = Modifier.weight(1f),
color = OutlineVariant.copy(alpha = 0.25f),
)
}
// Google Sign-In
GoogleSignInButton(onClick = viewModel::loginWithGoogle)
// Register link
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
Text(
text = buildAnnotatedString {
withStyle(SpanStyle(
fontFamily = InterFontFamily,
fontSize = 13.sp,
color = OnSurfaceVariant,
)) { append(stringResource(R.string.login_no_account).replace("<b>", "").replace("</b>", "")) }
},
modifier = Modifier.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onRegister,
),
)
}
}
}
// ── Security Badge ────────────────────────────────────────────
Box(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 24.dp),
contentAlignment = Alignment.Center,
) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(9999.dp))
.background(AccentPurpleContainer.copy(alpha = 0.15f))
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Outlined.VerifiedUser,
contentDescription = null,
tint = AccentPurple,
modifier = Modifier.size(16.dp),
)
Text(
text = stringResource(R.string.login_security_badge).uppercase(),
style = MaterialTheme.typography.labelSmall,
color = AccentPurple,
letterSpacing = 1.sp,
)
}
}
// ── Footer ────────────────────────────────────────────────────
LoginFooter(
onHelp = {},
onTerms = {},
)
}
}
}
private suspend fun scrollFieldNearTop(
scrollState: androidx.compose.foundation.ScrollState,
fieldTopPx: Float,
desiredTopPx: Float,
) {
if (fieldTopPx <= 0f) return
val scrollDelta = fieldTopPx - desiredTopPx
if (scrollDelta > 0f) {
scrollState.animateScrollBy(scrollDelta)
}
}
// ─── Hero Section ─────────────────────────────────────────────────────────────
@Composable
private fun HeroSection() {
Box(
modifier = Modifier
.fillMaxWidth()
.height(280.dp)
.graphicsLayer {
// Brand slope clip-path: polygon(0 0, 100% 0, 100% 85%, 0 100%)
clip = true
shape = BrandSlopeShape
},
) {
// Hero image (placeholder)
AsyncImage(
model = null, // TODO: ganti dengan URL gambar marketplace
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxSize()
.background(SurfaceContainerLow), // fallback placeholder color
)
// Gradient overlay bawah ke atas
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(Color.Transparent, Background),
startY = 80f,
)
)
)
// Text overlay
Column(
modifier = Modifier
.align(Alignment.BottomStart)
.padding(start = 24.dp, bottom = 48.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = stringResource(R.string.login_welcome_label).uppercase(),
style = MaterialTheme.typography.labelSmall,
color = BrandRed,
fontWeight = FontWeight.Bold,
letterSpacing = 2.sp,
)
Text(
text = buildAnnotatedString {
val lines = stringResource(R.string.login_hero_headline).split("\n")
withStyle(SpanStyle(
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.ExtraBold,
fontSize = 36.sp,
color = OnSurface,
)) { append(lines.firstOrNull() ?: "") }
append("\n")
withStyle(SpanStyle(
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.ExtraBold,
fontSize = 36.sp,
color = BrandRed,
)) { append(lines.getOrNull(1) ?: "") }
},
)
}
}
}
// ─── Brand Slope Shape ────────────────────────────────────────────────────────
private val BrandSlopeShape = object : androidx.compose.ui.graphics.Shape {
override fun createOutline(
size: androidx.compose.ui.geometry.Size,
layoutDirection: androidx.compose.ui.unit.LayoutDirection,
density: androidx.compose.ui.unit.Density,
): androidx.compose.ui.graphics.Outline {
val path = androidx.compose.ui.graphics.Path().apply {
moveTo(0f, 0f)
lineTo(size.width, 0f)
lineTo(size.width, size.height * 0.85f)
lineTo(0f, size.height)
close()
}
return androidx.compose.ui.graphics.Outline.Generic(path)
}
}
// ─── Google Sign-In Button ────────────────────────────────────────────────────
@Composable
private fun GoogleSignInButton(onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(SurfaceContainerLow)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(color = OnSurface.copy(alpha = 0.06f)),
onClick = onClick,
)
.padding(vertical = 14.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
// Google G logo (SVG re-created via Canvas)
GoogleLogo(modifier = Modifier.size(20.dp))
Spacer(modifier = Modifier.size(12.dp))
Text(
text = stringResource(R.string.login_google),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
color = OnSurface,
)
}
}
@Composable
private fun GoogleLogo(modifier: Modifier = Modifier) {
androidx.compose.foundation.Canvas(modifier = modifier) {
val w = size.width
val h = size.height
// Blue path (top-right)
drawArc(
color = Color(0xFF4285F4),
startAngle = -90f, sweepAngle = 90f, useCenter = false,
size = androidx.compose.ui.geometry.Size(w, h),
style = androidx.compose.ui.graphics.drawscope.Stroke(width = w * 0.22f),
)
// Green path (bottom)
drawArc(
color = Color(0xFF34A853),
startAngle = 0f, sweepAngle = 90f, useCenter = false,
size = androidx.compose.ui.geometry.Size(w, h),
style = androidx.compose.ui.graphics.drawscope.Stroke(width = w * 0.22f),
)
// Yellow path (left)
drawArc(
color = Color(0xFFFBBC05),
startAngle = 90f, sweepAngle = 90f, useCenter = false,
size = androidx.compose.ui.geometry.Size(w, h),
style = androidx.compose.ui.graphics.drawscope.Stroke(width = w * 0.22f),
)
// Red path (top-left)
drawArc(
color = Color(0xFFEA4335),
startAngle = 180f, sweepAngle = 90f, useCenter = false,
size = androidx.compose.ui.geometry.Size(w, h),
style = androidx.compose.ui.graphics.drawscope.Stroke(width = w * 0.22f),
)
}
}
// ─── Footer ───────────────────────────────────────────────────────────────────
@Composable
private fun LoginFooter(
onHelp: () -> Unit,
onTerms: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(Color(0xFFF8FAFC))
.padding(vertical = 28.dp, horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = "INA Trading",
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Black,
fontSize = 15.sp,
color = OnSurface,
)
Text(
text = stringResource(R.string.footer_copyright).uppercase(),
style = MaterialTheme.typography.labelSmall,
color = OnSurfaceVariant.copy(alpha = 0.5f),
letterSpacing = 0.5.sp,
)
Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) {
Text(
text = stringResource(R.string.footer_help).uppercase(),
style = MaterialTheme.typography.labelSmall,
color = OnSurfaceVariant.copy(alpha = 0.5f),
letterSpacing = 1.sp,
modifier = Modifier.clickable(onClick = onHelp),
)
Text(
text = stringResource(R.string.footer_terms).uppercase(),
style = MaterialTheme.typography.labelSmall,
color = OnSurfaceVariant.copy(alpha = 0.5f),
letterSpacing = 1.sp,
modifier = Modifier.clickable(onClick = onTerms),
)
}
}
}
// ─── Extension helper ─────────────────────────────────────────────────────────
@Composable
private fun Spacer(modifier: Modifier) {
androidx.compose.foundation.layout.Spacer(modifier = modifier)
}

View File

@ -0,0 +1,70 @@
package id.iiyh.inatrading.feature.auth.presentation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import id.iiyh.inatrading.feature.auth.domain.repository.AuthRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
data class LoginUiState(
val email: String = "",
val password: String = "",
val isPasswordVisible: Boolean = false,
val isLoading: Boolean = false,
val errorMessage: String? = null,
val isSuccess: Boolean = false,
)
@HiltViewModel
class LoginViewModel @Inject constructor(
private val authRepository: AuthRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(LoginUiState())
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
fun onEmailChange(value: String) {
_uiState.update { it.copy(email = value, errorMessage = null) }
}
fun onPasswordChange(value: String) {
_uiState.update { it.copy(password = value, errorMessage = null) }
}
fun togglePasswordVisibility() {
_uiState.update { it.copy(isPasswordVisible = !it.isPasswordVisible) }
}
fun login() {
if (!isInputValid()) return
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
authRepository.login(_uiState.value.email, _uiState.value.password)
.onSuccess {
_uiState.update { it.copy(isLoading = false, isSuccess = true) }
}
.onFailure { e ->
_uiState.update { it.copy(isLoading = false, errorMessage = e.message) }
}
}
}
fun loginWithGoogle() {
// TODO: Implement Google Sign-In
}
fun clearError() {
_uiState.update { it.copy(errorMessage = null) }
}
private fun isInputValid(): Boolean {
val state = _uiState.value
return state.email.isNotBlank() && state.password.isNotBlank()
}
}

View File

@ -0,0 +1,405 @@
package id.iiyh.inatrading.feature.auth.presentation
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.Mail
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material.icons.outlined.PhoneIphone
import androidx.compose.material.icons.outlined.Visibility
import androidx.compose.material.icons.outlined.VisibilityOff
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import id.iiyh.inatrading.R
import id.iiyh.inatrading.core.ui.components.InaInnerTopAppBar
import id.iiyh.inatrading.core.ui.components.InaPrimaryButton
import id.iiyh.inatrading.core.ui.components.InaTextField
import id.iiyh.inatrading.core.ui.theme.AccentBlue
import id.iiyh.inatrading.core.ui.theme.Background
import id.iiyh.inatrading.core.ui.theme.BrandRed
import id.iiyh.inatrading.core.ui.theme.InterFontFamily
import id.iiyh.inatrading.core.ui.theme.ManropeFontFamily
import id.iiyh.inatrading.core.ui.theme.OnSurface
import id.iiyh.inatrading.core.ui.theme.OnSurfaceVariant
import id.iiyh.inatrading.core.ui.theme.OutlineVariant
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerHighest
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerLow
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerLowest
@Composable
fun RegisterScreen(
onBack: () -> Unit,
onRegisterSuccess: () -> Unit,
onLoginClick: () -> Unit,
viewModel: RegisterViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsState()
LaunchedEffect(uiState.isSuccess) {
if (uiState.isSuccess) onRegisterSuccess()
}
Scaffold(
topBar = { InaInnerTopAppBar(onBack = onBack) },
containerColor = Background,
) { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
// Brand slope background: diagonal gradient kanan bawah
.drawBehind {
val breakX = size.width * 0.0f
val breakY = size.height * 0.6f
drawRect(
brush = Brush.linearGradient(
colors = listOf(Color.Transparent, SurfaceContainerHighest.copy(alpha = 0.4f)),
start = Offset(breakX, breakY),
end = Offset(size.width, size.height),
)
)
}
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp)
.padding(bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(0.dp),
) {
// ── Editorial Headline ────────────────────────────────────
Spacer(modifier = Modifier.height(32.dp))
Text(
text = stringResource(R.string.register_label).uppercase(),
style = MaterialTheme.typography.labelMedium,
color = AccentBlue,
fontWeight = FontWeight.SemiBold,
letterSpacing = 1.5.sp,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.register_title),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.ExtraBold,
fontSize = 34.sp,
color = OnSurface,
lineHeight = 40.sp,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.register_subtitle),
style = MaterialTheme.typography.bodyMedium,
color = OnSurfaceVariant,
)
Spacer(modifier = Modifier.height(28.dp))
// ── Form Card ─────────────────────────────────────────────
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(SurfaceContainerLowest)
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
// Full Name
InaTextField(
value = uiState.name,
onValueChange = viewModel::onNameChange,
label = stringResource(R.string.register_full_name_label),
placeholder = stringResource(R.string.register_full_name_placeholder),
isError = uiState.errors.name != null,
errorMessage = if (uiState.errors.name != null)
stringResource(R.string.register_error_name_empty) else "",
trailingIcon = {
Icon(
Icons.Outlined.Person,
contentDescription = null,
tint = OnSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.size(20.dp),
)
},
)
// Email
InaTextField(
value = uiState.email,
onValueChange = viewModel::onEmailChange,
label = stringResource(R.string.login_email_label),
placeholder = stringResource(R.string.login_email_placeholder),
isError = uiState.errors.email != null,
errorMessage = when (uiState.errors.email) {
"invalid" -> stringResource(R.string.register_error_invalid_email)
else -> if (uiState.errors.email != null)
stringResource(R.string.register_error_invalid_email) else ""
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
trailingIcon = {
Icon(
Icons.Outlined.Mail,
contentDescription = null,
tint = OnSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.size(20.dp),
)
},
)
// Phone
InaTextField(
value = uiState.phone,
onValueChange = viewModel::onPhoneChange,
label = stringResource(R.string.register_phone_label),
placeholder = stringResource(R.string.register_phone_placeholder),
isError = uiState.errors.phone != null,
errorMessage = if (uiState.errors.phone != null)
stringResource(R.string.register_error_phone_empty) else "",
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
trailingIcon = {
Icon(
Icons.Outlined.PhoneIphone,
contentDescription = null,
tint = OnSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.size(20.dp),
)
},
)
// Password
InaTextField(
value = uiState.password,
onValueChange = viewModel::onPasswordChange,
label = stringResource(R.string.login_password_label),
placeholder = stringResource(R.string.register_password_placeholder),
isPassword = !uiState.isPasswordVisible,
isError = uiState.errors.password != null,
errorMessage = when (uiState.errors.password) {
"too_short" -> stringResource(R.string.register_error_password_too_short)
else -> if (uiState.errors.password != null)
stringResource(R.string.register_error_password_too_short) else ""
},
trailingIcon = {
Icon(
imageVector = if (uiState.isPasswordVisible)
Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
contentDescription = null,
tint = OnSurfaceVariant,
modifier = Modifier
.size(20.dp)
.clickable(onClick = viewModel::togglePasswordVisibility),
)
},
)
// Confirm Password
InaTextField(
value = uiState.confirmPassword,
onValueChange = viewModel::onConfirmPasswordChange,
label = stringResource(R.string.register_confirm_password_label),
placeholder = stringResource(R.string.register_confirm_password_placeholder),
isPassword = !uiState.isConfirmPasswordVisible,
isError = uiState.errors.confirmPassword != null,
errorMessage = if (uiState.errors.confirmPassword != null)
stringResource(R.string.register_error_password_mismatch) else "",
trailingIcon = {
Icon(
imageVector = if (uiState.isConfirmPasswordVisible)
Icons.Outlined.VisibilityOff else Icons.Outlined.Lock,
contentDescription = null,
tint = OnSurfaceVariant.copy(
alpha = if (uiState.confirmPassword.isEmpty()) 0.5f else 1f
),
modifier = Modifier
.size(20.dp)
.clickable(
enabled = uiState.confirmPassword.isNotEmpty(),
onClick = viewModel::toggleConfirmPasswordVisibility,
),
)
},
)
// API error banner
if (uiState.apiError != null) {
Text(
text = uiState.apiError!!,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
// CTA
Spacer(modifier = Modifier.height(4.dp))
InaPrimaryButton(
text = stringResource(R.string.register_cta),
onClick = viewModel::register,
isLoading = uiState.isLoading,
)
// Divider
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
HorizontalDivider(
modifier = Modifier.weight(1f),
color = OutlineVariant.copy(alpha = 0.3f),
)
Text(
text = stringResource(R.string.register_or_with).uppercase(),
style = MaterialTheme.typography.labelSmall,
color = OnSurfaceVariant.copy(alpha = 0.5f),
letterSpacing = 1.5.sp,
)
HorizontalDivider(
modifier = Modifier.weight(1f),
color = OutlineVariant.copy(alpha = 0.3f),
)
}
// Google button (reuse dari LoginScreen)
RegisterGoogleButton(onClick = viewModel::registerWithGoogle)
}
// ── "Already have account?" ───────────────────────────────
Spacer(modifier = Modifier.height(24.dp))
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
Text(
text = buildAnnotatedString {
withStyle(SpanStyle(
fontFamily = InterFontFamily,
fontSize = 13.sp,
color = OnSurfaceVariant,
)) { append(stringResource(R.string.register_have_account)
.replace("<b>", "").replace("</b>", "")) }
},
modifier = Modifier.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onLoginClick,
),
)
}
// ── Terms of Service ──────────────────────────────────────
Spacer(modifier = Modifier.height(16.dp))
Text(
text = buildAnnotatedString {
val full = stringResource(R.string.register_terms)
val terms = stringResource(R.string.settings_terms)
val priv = stringResource(R.string.settings_privacy_policy)
// Render teks biasa, link underline untuk terms & privacy
val baseStyle = SpanStyle(
fontFamily = InterFontFamily,
fontSize = 11.sp,
color = OnSurfaceVariant.copy(alpha = 0.6f),
)
val linkStyle = baseStyle.copy(
textDecoration = TextDecoration.Underline,
color = OnSurfaceVariant.copy(alpha = 0.8f),
)
withStyle(baseStyle) {
append("Dengan mendaftar, Anda menyetujui ")
}
withStyle(linkStyle) { append(terms) }
withStyle(baseStyle) { append(" serta ") }
withStyle(linkStyle) { append(priv) }
withStyle(baseStyle) {
append(" INA Trading untuk mendukung pertumbuhan UMKM Nasional.")
}
},
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 8.dp),
)
}
}
}
}
@Composable
private fun RegisterGoogleButton(onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(SurfaceContainerLow)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(color = OnSurface.copy(alpha = 0.06f)),
onClick = onClick,
)
.padding(vertical = 14.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
// Google G logo
androidx.compose.foundation.Canvas(modifier = Modifier.size(20.dp)) {
val w = size.width
val h = size.height
val stroke = androidx.compose.ui.graphics.drawscope.Stroke(width = w * 0.22f)
drawArc(Color(0xFF4285F4), -90f, 90f, false, size = androidx.compose.ui.geometry.Size(w, h), style = stroke)
drawArc(Color(0xFF34A853), 0f, 90f, false, size = androidx.compose.ui.geometry.Size(w, h), style = stroke)
drawArc(Color(0xFFFBBC05), 90f, 90f, false, size = androidx.compose.ui.geometry.Size(w, h), style = stroke)
drawArc(Color(0xFFEA4335), 180f, 90f, false, size = androidx.compose.ui.geometry.Size(w, h), style = stroke)
}
Spacer(modifier = Modifier.size(12.dp))
Text(
text = stringResource(R.string.login_google),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
color = OnSurface,
)
}
}

View File

@ -0,0 +1,98 @@
package id.iiyh.inatrading.feature.auth.presentation
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import id.iiyh.inatrading.R
import id.iiyh.inatrading.core.ui.components.InaInnerTopAppBar
import id.iiyh.inatrading.core.ui.components.InaPrimaryButton
import id.iiyh.inatrading.core.ui.theme.BrandNavy
import id.iiyh.inatrading.core.ui.theme.BrandRed
import id.iiyh.inatrading.core.ui.theme.ManropeFontFamily
@Composable
fun RegisterSuccessScreen(
onLoginClick: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
) {
InaInnerTopAppBar(
onBack = null, // no back on success page
)
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Box(
modifier = Modifier
.size(96.dp)
.background(BrandRed.copy(alpha = 0.10f), CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Filled.CheckCircle,
contentDescription = null,
tint = BrandRed,
modifier = Modifier.size(56.dp),
)
}
Spacer(modifier = Modifier.height(32.dp))
Text(
text = stringResource(R.string.register_success_title),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.ExtraBold,
fontSize = 26.sp,
color = BrandNavy,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(R.string.register_success_subtitle),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Normal,
fontSize = 15.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
lineHeight = 22.sp,
)
Spacer(modifier = Modifier.height(48.dp))
InaPrimaryButton(
text = stringResource(R.string.register_success_cta),
onClick = onLoginClick,
)
}
}
}

View File

@ -0,0 +1,133 @@
package id.iiyh.inatrading.feature.auth.presentation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import id.iiyh.inatrading.feature.auth.domain.repository.AuthRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
data class RegisterFormError(
val name: String? = null,
val email: String? = null,
val phone: String? = null,
val password: String? = null,
val confirmPassword: String? = null,
)
data class RegisterUiState(
val name: String = "",
val email: String = "",
val phone: String = "",
val password: String = "",
val confirmPassword: String = "",
val isPasswordVisible: Boolean = false,
val isConfirmPasswordVisible: Boolean = false,
val isLoading: Boolean = false,
val errors: RegisterFormError = RegisterFormError(),
val apiError: String? = null,
val isSuccess: Boolean = false,
)
@HiltViewModel
class RegisterViewModel @Inject constructor(
private val authRepository: AuthRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(RegisterUiState())
val uiState: StateFlow<RegisterUiState> = _uiState.asStateFlow()
fun onNameChange(value: String) =
_uiState.update { it.copy(name = value, errors = it.errors.copy(name = null), apiError = null) }
fun onEmailChange(value: String) =
_uiState.update { it.copy(email = value, errors = it.errors.copy(email = null), apiError = null) }
fun onPhoneChange(value: String) =
_uiState.update { it.copy(phone = value, errors = it.errors.copy(phone = null), apiError = null) }
fun onPasswordChange(value: String) {
_uiState.update {
it.copy(
password = value,
apiError = null,
errors = it.errors.copy(
password = null,
confirmPassword = if (it.confirmPassword.isNotEmpty() && value != it.confirmPassword)
"mismatch" else null,
)
)
}
}
fun onConfirmPasswordChange(value: String) {
_uiState.update {
it.copy(
confirmPassword = value,
errors = it.errors.copy(
confirmPassword = if (value.isNotEmpty() && value != it.password) "mismatch" else null
)
)
}
}
fun togglePasswordVisibility() =
_uiState.update { it.copy(isPasswordVisible = !it.isPasswordVisible) }
fun toggleConfirmPasswordVisibility() =
_uiState.update { it.copy(isConfirmPasswordVisible = !it.isConfirmPasswordVisible) }
fun register() {
if (!validate()) return
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, apiError = null) }
val s = _uiState.value
authRepository.register(
name = s.name,
email = s.email,
mobile = s.phone,
password = s.password,
).onSuccess {
_uiState.update { it.copy(isLoading = false, isSuccess = true) }
}.onFailure { e ->
_uiState.update { it.copy(isLoading = false, apiError = e.message) }
}
}
}
fun registerWithGoogle() {
// TODO: Implement Google Sign-In
}
private fun validate(): Boolean {
val s = _uiState.value
val errors = RegisterFormError(
name = if (s.name.isBlank()) "empty" else null,
email = when {
s.email.isBlank() -> "empty"
!android.util.Patterns.EMAIL_ADDRESS.matcher(s.email).matches() -> "invalid"
else -> null
},
phone = if (s.phone.isBlank()) "empty" else null,
password = when {
s.password.isBlank() -> "empty"
s.password.length < 8 -> "too_short"
else -> null
},
confirmPassword = when {
s.confirmPassword.isBlank() -> "empty"
s.confirmPassword != s.password -> "mismatch"
else -> null
},
)
_uiState.update { it.copy(errors = errors) }
return errors.name == null && errors.email == null &&
errors.phone == null && errors.password == null &&
errors.confirmPassword == null
}
}

View File

@ -0,0 +1,57 @@
package id.iiyh.inatrading.feature.cart.data.model
data class CartCreateRequest(
val sellerId: String,
val quantity: Int,
val productModelId: String,
val productMeasurementId: String = "",
val warehouseId: String,
)
data class CartUpdateRequest(
val cartItemId: String,
val quantity: Int,
)
data class CartSeller(
val id: String? = null,
val image: String? = null,
val name: String? = null,
)
data class CartProductModel(
val currency: String? = null,
val id: String? = null,
val image: String? = null,
val name: String? = null,
val price: Double? = null,
)
data class CartProductMeasurement(
val id: String? = null,
val name: String? = null,
)
data class CartItem(
val id: String? = null,
val productMeasurement: CartProductMeasurement? = null,
val productModel: CartProductModel? = null,
val quantity: Int? = null,
val warehouseId: String? = null,
)
data class CartGroup(
val cartItems: List<CartItem> = emptyList(),
val id: String? = null,
val seller: CartSeller? = null,
)
data class CartListResponse(
val responseCode: String? = null,
val responseDesc: String? = null,
val rows: List<CartGroup> = emptyList(),
val totalItem: Int = 0,
val totalPage: Int = 0,
) {
val isSuccess: Boolean get() = responseCode == "0000"
}

View File

@ -0,0 +1,127 @@
package id.iiyh.inatrading.feature.cart.data.repository
import id.iiyh.inatrading.core.data.remote.ApiService
import id.iiyh.inatrading.core.data.remote.SessionExpiredException
import id.iiyh.inatrading.feature.cart.data.model.CartCreateRequest
import id.iiyh.inatrading.feature.cart.data.model.CartGroup
import id.iiyh.inatrading.feature.cart.data.model.CartUpdateRequest
import id.iiyh.inatrading.feature.cart.domain.CartRepository
import retrofit2.HttpException
import javax.inject.Inject
class CartRepositoryImpl @Inject constructor(
private val apiService: ApiService,
) : CartRepository {
override suspend fun getCartGroups(): Result<List<CartGroup>> {
return try {
val response = apiService.getCarts()
if (!response.isSuccess) {
Result.failure(Exception(response.responseDesc ?: "Gagal memuat keranjang"))
} else {
Result.success(response.rows.sanitized())
}
} catch (e: HttpException) {
Result.failure(e.toRepositoryException())
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun getCartCount(): Result<Int> {
return try {
val response = apiService.getCarts()
if (!response.isSuccess) {
return Result.failure(Exception(response.responseDesc ?: "Gagal memuat keranjang"))
}
val sanitizedGroups = response.rows.sanitized()
val count = sanitizedGroups.sumOf { it.cartItems.size }
.takeIf { it > 0 }
?: sanitizedGroups.sumOf { group ->
group.cartItems.sumOf { item -> item.quantity ?: 0 }
}.takeIf { it > 0 }
?: response.totalItem.takeIf { it > 0 }
?: 0
Result.success(count)
} catch (e: HttpException) {
Result.failure(e.toRepositoryException())
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun updateCartQuantity(
cartItemId: String,
quantityDelta: Int,
): Result<Unit> {
return try {
val response = apiService.updateCart(
CartUpdateRequest(
cartItemId = cartItemId,
quantity = quantityDelta,
)
)
if (response.isSuccess) {
Result.success(Unit)
} else {
Result.failure(Exception(response.responseDesc ?: "Gagal memperbarui jumlah keranjang"))
}
} catch (e: HttpException) {
Result.failure(e.toRepositoryException())
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun addToCart(
sellerId: String,
productModelId: String,
warehouseId: String,
quantity: Int,
productMeasurementId: String,
): Result<Unit> {
return try {
val response = apiService.createCart(
CartCreateRequest(
sellerId = sellerId,
quantity = quantity,
productModelId = productModelId,
productMeasurementId = productMeasurementId,
warehouseId = warehouseId,
)
)
if (response.isSuccess) {
Result.success(Unit)
} else {
Result.failure(Exception(response.responseDesc ?: "Gagal menambahkan produk ke keranjang"))
}
} catch (e: HttpException) {
Result.failure(e.toRepositoryException())
} catch (e: Exception) {
Result.failure(e)
}
}
private fun HttpException.toRepositoryException(): Exception {
if (code() == 401) return SessionExpiredException()
return Exception(parseMessage())
}
private fun HttpException.parseMessage(): String {
return try {
val body = response()?.errorBody()?.string() ?: return "Terjadi kesalahan (${code()})"
val match = Regex(""""responseDesc"\s*:\s*"([^"]+)"""").find(body)
match?.groupValues?.get(1) ?: "Terjadi kesalahan (${code()})"
} catch (_: Exception) {
"Terjadi kesalahan (${code()})"
}
}
private fun List<CartGroup>.sanitized(): List<CartGroup> {
return map { group ->
group.copy(
cartItems = group.cartItems.filter { item -> (item.quantity ?: 0) > 0 }
)
}.filter { it.cartItems.isNotEmpty() }
}
}

View File

@ -0,0 +1,19 @@
package id.iiyh.inatrading.feature.cart.domain
import id.iiyh.inatrading.feature.cart.data.model.CartGroup
interface CartRepository {
suspend fun getCartGroups(): Result<List<CartGroup>>
suspend fun getCartCount(): Result<Int>
suspend fun updateCartQuantity(
cartItemId: String,
quantityDelta: Int,
): Result<Unit>
suspend fun addToCart(
sellerId: String,
productModelId: String,
warehouseId: String,
quantity: Int = 1,
productMeasurementId: String = "",
): Result<Unit>
}

View File

@ -0,0 +1,738 @@
package id.iiyh.inatrading.feature.cart.presentation
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.automirrored.outlined.ArrowForward
import androidx.compose.material.icons.automirrored.outlined.Login
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Remove
import androidx.compose.material.icons.outlined.Storefront
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import id.iiyh.inatrading.R
import id.iiyh.inatrading.core.ui.components.InaLogo
import id.iiyh.inatrading.core.ui.components.LogoSize
import id.iiyh.inatrading.core.ui.theme.AccentBlue
import id.iiyh.inatrading.core.ui.theme.AccentBlueContainer
import id.iiyh.inatrading.core.ui.theme.AccentPurple
import id.iiyh.inatrading.core.ui.theme.AccentPurpleContainer
import id.iiyh.inatrading.core.ui.theme.Background
import id.iiyh.inatrading.core.ui.theme.BrandRed
import id.iiyh.inatrading.core.ui.theme.ManropeFontFamily
import id.iiyh.inatrading.core.ui.theme.OnAccentBlueContainer
import id.iiyh.inatrading.core.ui.theme.OnSurface
import id.iiyh.inatrading.core.ui.theme.OnSurfaceVariant
import id.iiyh.inatrading.core.ui.theme.OutlineVariant
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerHighest
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerLow
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerLowest
import id.iiyh.inatrading.feature.cart.data.model.CartGroup
import id.iiyh.inatrading.feature.cart.data.model.CartItem
import java.text.NumberFormat
import java.util.Locale
@Composable
fun CartScreen(
onBack: () -> Unit,
onLoginRequired: () -> Unit,
viewModel: CartViewModel,
) {
val uiState by viewModel.uiState.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(uiState.isLoggedIn) {
if (uiState.isLoggedIn) {
viewModel.loadCart()
} else {
viewModel.clearError()
}
}
LaunchedEffect(uiState.errorMessage, uiState.cartGroups.isNotEmpty()) {
val message = uiState.errorMessage ?: return@LaunchedEffect
if (uiState.cartGroups.isNotEmpty()) {
snackbarHostState.showSnackbar(message)
viewModel.clearError()
}
}
Scaffold(
topBar = { CartTopBar(onBack = onBack) },
snackbarHost = { SnackbarHost(snackbarHostState) },
bottomBar = {
if (uiState.isLoggedIn && uiState.cartGroups.isNotEmpty()) {
CartFooter(
totalPrice = uiState.totalPrice,
)
}
},
containerColor = Background,
) { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(220.dp)
.background(SurfaceContainerHighest.copy(alpha = 0.6f)),
)
when {
!uiState.isLoggedIn -> {
CartGuestState(
onLoginRequired = onLoginRequired,
modifier = Modifier.fillMaxSize(),
)
}
uiState.isLoadingCart -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(color = BrandRed)
}
}
uiState.errorMessage != null && uiState.cartGroups.isEmpty() -> {
CartMessageState(
title = stringResource(R.string.cart_error_title),
body = uiState.errorMessage ?: "",
actionLabel = stringResource(R.string.cart_retry),
onAction = viewModel::loadCart,
modifier = Modifier.fillMaxSize(),
)
}
uiState.cartGroups.isEmpty() -> {
CartMessageState(
title = stringResource(R.string.cart_empty_title),
body = stringResource(R.string.cart_empty_body),
actionLabel = stringResource(R.string.cart_browse_products),
onAction = onBack,
modifier = Modifier.fillMaxSize(),
)
}
else -> {
CartContent(
updatingItemIds = uiState.updatingItemIds,
groups = uiState.cartGroups,
onDecreaseQuantity = { item ->
viewModel.changeQuantity(
cartItemId = item.id.orEmpty(),
currentQuantity = item.quantity ?: 0,
quantityDelta = -1,
)
},
onIncreaseQuantity = { item ->
viewModel.changeQuantity(
cartItemId = item.id.orEmpty(),
currentQuantity = item.quantity ?: 0,
quantityDelta = 1,
)
},
modifier = Modifier.fillMaxSize(),
)
}
}
}
}
}
@Composable
private fun CartTopBar(
onBack: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(Color.White.copy(alpha = 0.86f))
.padding(horizontal = 20.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
contentDescription = stringResource(R.string.favorite_back),
tint = BrandRed,
)
}
InaLogo(size = LogoSize.Small)
}
Spacer(modifier = Modifier.size(40.dp))
}
}
@Composable
private fun CartContent(
updatingItemIds: Set<String>,
groups: List<CartGroup>,
onDecreaseQuantity: (CartItem) -> Unit,
onIncreaseQuantity: (CartItem) -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier,
contentPadding = PaddingValues(
start = 24.dp,
end = 24.dp,
top = 28.dp,
bottom = 180.dp,
),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
item {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = stringResource(R.string.cart_eyebrow),
color = AccentPurple,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.ExtraBold,
letterSpacing = 2.sp,
)
Text(
text = stringResource(R.string.cart_title),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.ExtraBold,
fontSize = 34.sp,
lineHeight = 38.sp,
color = OnSurface,
)
}
}
items(groups, key = { it.id ?: it.seller?.id.orEmpty() }) { group ->
SellerCartSection(
group = group,
updatingItemIds = updatingItemIds,
onDecreaseQuantity = onDecreaseQuantity,
onIncreaseQuantity = onIncreaseQuantity,
)
}
item {
Surface(
color = AccentBlue.copy(alpha = 0.06f),
shape = RoundedCornerShape(20.dp),
tonalElevation = 0.dp,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(18.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(AccentBlueContainer),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Outlined.Storefront,
contentDescription = null,
tint = OnAccentBlueContainer,
)
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.cart_checkout_note_title),
fontWeight = FontWeight.Bold,
color = OnSurface,
)
Text(
text = stringResource(R.string.cart_checkout_note_body),
color = OnSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}
}
}
@Composable
private fun SellerCartSection(
group: CartGroup,
updatingItemIds: Set<String>,
onDecreaseQuantity: (CartItem) -> Unit,
onIncreaseQuantity: (CartItem) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
modifier = Modifier
.size(32.dp)
.clip(CircleShape)
.background(AccentBlueContainer),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Outlined.Storefront,
contentDescription = null,
tint = AccentBlue,
modifier = Modifier.size(18.dp),
)
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = group.seller?.name.orEmpty().ifBlank {
stringResource(R.string.cart_seller_fallback)
},
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
color = OnSurface,
)
Text(
text = stringResource(R.string.cart_seller_subtitle),
color = OnSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
)
}
}
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
group.cartItems.forEach { item ->
CartItemCard(
item = item,
isUpdating = item.id != null && item.id in updatingItemIds,
onDecreaseQuantity = { onDecreaseQuantity(item) },
onIncreaseQuantity = { onIncreaseQuantity(item) },
)
}
}
}
}
@Composable
private fun CartItemCard(
item: CartItem,
isUpdating: Boolean,
onDecreaseQuantity: () -> Unit,
onIncreaseQuantity: () -> Unit,
) {
Surface(
color = SurfaceContainerLowest,
shape = RoundedCornerShape(24.dp),
shadowElevation = 2.dp,
) {
BoxWithConstraints(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
) {
val compactLayout = maxWidth < 320.dp
val imageSize = if (compactLayout) 82.dp else 110.dp
val titleFontSize = if (compactLayout) 16.sp else 18.sp
val priceFontSize = if (compactLayout) 18.sp else 22.sp
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
AsyncImage(
model = item.productModel?.image,
contentDescription = item.productModel?.name,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(imageSize)
.clip(RoundedCornerShape(16.dp))
.background(SurfaceContainerLow),
)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(if (compactLayout) 10.dp else 14.dp),
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = item.productModel?.name.orEmpty().ifBlank {
stringResource(R.string.cart_item_name_fallback)
},
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
fontSize = titleFontSize,
lineHeight = if (compactLayout) 20.sp else 22.sp,
color = OnSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
IconButton(
onClick = {},
enabled = false,
modifier = Modifier
.alpha(0.45f)
.size(if (compactLayout) 32.dp else 40.dp),
) {
Icon(
imageVector = Icons.Outlined.Delete,
contentDescription = stringResource(R.string.cart_delete),
tint = OnSurfaceVariant,
)
}
}
Text(
text = item.productModel?.price.toCurrency(),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.ExtraBold,
fontSize = priceFontSize,
color = BrandRed,
)
item.productMeasurement?.name?.takeIf { it.isNotBlank() }?.let { measurement ->
Text(
text = measurement,
color = AccentPurple,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier
.clip(RoundedCornerShape(999.dp))
.background(AccentPurpleContainer)
.padding(horizontal = 10.dp, vertical = 5.dp),
)
}
if (compactLayout) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.End,
) {
StockChip()
QuantityControl(
quantity = item.quantity ?: 0,
isUpdating = isUpdating,
canDecrease = (item.quantity ?: 0) > 1,
onDecrease = onDecreaseQuantity,
onIncrease = onIncreaseQuantity,
)
}
} else {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
StockChip()
QuantityControl(
quantity = item.quantity ?: 0,
isUpdating = isUpdating,
canDecrease = (item.quantity ?: 0) > 1,
onDecrease = onDecreaseQuantity,
onIncrease = onIncreaseQuantity,
)
}
}
}
}
}
}
}
@Composable
private fun StockChip() {
Text(
text = stringResource(R.string.cart_stock_available),
color = AccentPurple,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier
.clip(RoundedCornerShape(999.dp))
.background(AccentPurpleContainer.copy(alpha = 0.75f))
.padding(horizontal = 12.dp, vertical = 8.dp),
)
}
@Composable
private fun QuantityControl(
quantity: Int,
isUpdating: Boolean,
canDecrease: Boolean,
onDecrease: () -> Unit,
onIncrease: () -> Unit,
) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(14.dp))
.background(SurfaceContainerLow)
.padding(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
QuantityButton(
icon = Icons.Outlined.Remove,
contentDescription = stringResource(R.string.cart_decrease_quantity),
enabled = canDecrease && !isUpdating,
onClick = onDecrease,
)
if (isUpdating) {
CircularProgressIndicator(
color = BrandRed,
strokeWidth = 2.dp,
modifier = Modifier
.padding(horizontal = 10.dp)
.size(18.dp),
)
} else {
Text(
text = quantity.toString(),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
color = OnSurface,
modifier = Modifier.padding(horizontal = 10.dp),
)
}
QuantityButton(
icon = Icons.Outlined.Add,
contentDescription = stringResource(R.string.cart_increase_quantity),
enabled = !isUpdating,
onClick = onIncrease,
)
}
}
@Composable
private fun QuantityButton(
icon: androidx.compose.ui.graphics.vector.ImageVector,
contentDescription: String,
enabled: Boolean,
onClick: () -> Unit,
) {
IconButton(
onClick = onClick,
enabled = enabled,
modifier = Modifier
.size(28.dp)
.alpha(if (enabled) 1f else 0.45f),
) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
tint = OnSurface,
modifier = Modifier.size(16.dp),
)
}
}
@Composable
private fun CartFooter(
totalPrice: Double,
) {
Surface(
color = SurfaceContainerLowest.copy(alpha = 0.9f),
shadowElevation = 10.dp,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(
start = 24.dp,
end = 24.dp,
top = 18.dp,
bottom = 18.dp,
),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
HorizontalDivider(color = OutlineVariant.copy(alpha = 0.45f))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth(),
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = stringResource(R.string.cart_total_label),
color = OnSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
)
Text(
text = totalPrice.toCurrency(),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.ExtraBold,
fontSize = 28.sp,
color = OnSurface,
)
}
Button(
onClick = {},
colors = ButtonDefaults.buttonColors(
containerColor = BrandRed,
contentColor = Color.White,
),
shape = RoundedCornerShape(18.dp),
) {
Text(
text = stringResource(R.string.cart_checkout),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
)
Spacer(modifier = Modifier.size(8.dp))
Icon(
imageVector = Icons.AutoMirrored.Outlined.ArrowForward,
contentDescription = null,
)
}
}
}
}
}
@Composable
private fun CartGuestState(
onLoginRequired: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier.padding(24.dp),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = stringResource(R.string.cart_guest_title),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.ExtraBold,
fontSize = 26.sp,
color = OnSurface,
)
Text(
text = stringResource(R.string.cart_guest_body),
color = OnSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
)
Button(
onClick = onLoginRequired,
colors = ButtonDefaults.buttonColors(
containerColor = BrandRed,
contentColor = Color.White,
),
shape = RoundedCornerShape(16.dp),
) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.Login,
contentDescription = null,
)
Spacer(modifier = Modifier.size(8.dp))
Text(
text = stringResource(R.string.profile_guest_cta),
fontWeight = FontWeight.Bold,
)
}
}
}
}
@Composable
private fun CartMessageState(
title: String,
body: String,
actionLabel: String,
onAction: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier.padding(24.dp),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = title,
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.ExtraBold,
fontSize = 28.sp,
color = OnSurface,
)
Text(
text = body,
color = OnSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
)
TextButton(onClick = onAction) {
Text(
text = actionLabel,
color = BrandRed,
fontWeight = FontWeight.Bold,
)
}
}
}
}
@Composable
private fun Double?.toCurrency(): String {
if (this == null || this <= 0.0) return stringResource(R.string.products_contact_price)
return NumberFormat.getCurrencyInstance(Locale.forLanguageTag("id-ID"))
.format(this)
.replace("Rp", "Rp ")
}

View File

@ -0,0 +1,189 @@
package id.iiyh.inatrading.feature.cart.presentation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import id.iiyh.inatrading.core.data.local.SessionManager
import id.iiyh.inatrading.core.data.remote.SessionExpiredException
import id.iiyh.inatrading.feature.auth.domain.repository.AuthRepository
import id.iiyh.inatrading.feature.cart.data.model.CartGroup
import id.iiyh.inatrading.feature.cart.domain.CartRepository
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class CartUiState(
val itemCount: Int = 0,
val isLoggedIn: Boolean = false,
val isLoadingCart: Boolean = false,
val updatingItemIds: Set<String> = emptySet(),
val cartGroups: List<CartGroup> = emptyList(),
val totalPrice: Double = 0.0,
val errorMessage: String? = null,
)
@HiltViewModel
class CartViewModel @Inject constructor(
private val cartRepository: CartRepository,
private val authRepository: AuthRepository,
sessionManager: SessionManager,
) : ViewModel() {
private val _uiState = MutableStateFlow(CartUiState())
val uiState: StateFlow<CartUiState> = _uiState.asStateFlow()
init {
sessionManager.token
.distinctUntilChanged()
.onEach { token ->
val isLoggedIn = !token.isNullOrBlank()
_uiState.update {
it.copy(
isLoggedIn = isLoggedIn,
itemCount = if (isLoggedIn) it.itemCount else 0,
cartGroups = if (isLoggedIn) it.cartGroups else emptyList(),
totalPrice = if (isLoggedIn) it.totalPrice else 0.0,
errorMessage = null,
)
}
if (isLoggedIn) {
refreshCartCount()
}
}
.launchIn(viewModelScope)
}
fun refreshCartCount() {
if (!_uiState.value.isLoggedIn) {
_uiState.update { it.copy(itemCount = 0) }
return
}
viewModelScope.launch {
cartRepository.getCartCount()
.onSuccess { count ->
_uiState.update { it.copy(itemCount = count) }
}
.onFailure(::handleFailure)
}
}
fun onItemAdded(quantity: Int = 1) {
if (!_uiState.value.isLoggedIn || quantity <= 0) return
_uiState.update { state ->
state.copy(itemCount = state.itemCount + quantity)
}
}
fun loadCart() {
if (!_uiState.value.isLoggedIn) {
_uiState.update {
it.copy(
isLoadingCart = false,
updatingItemIds = emptySet(),
cartGroups = emptyList(),
totalPrice = 0.0,
errorMessage = null,
)
}
return
}
viewModelScope.launch {
_uiState.update { it.copy(isLoadingCart = true, errorMessage = null) }
cartRepository.getCartGroups()
.onSuccess { groups ->
val totalPrice = groups.sumOf { group ->
group.cartItems.sumOf { item ->
(item.productModel?.price ?: 0.0) * (item.quantity ?: 0)
}
}
val count = groups.sumOf { group ->
group.cartItems.sumOf { item -> item.quantity ?: 0 }
}
_uiState.update {
it.copy(
isLoadingCart = false,
cartGroups = groups,
totalPrice = totalPrice,
itemCount = count,
updatingItemIds = emptySet(),
errorMessage = null,
)
}
}
.onFailure { error ->
_uiState.update { it.copy(isLoadingCart = false) }
handleFailure(error)
}
}
}
fun changeQuantity(
cartItemId: String,
currentQuantity: Int,
quantityDelta: Int,
) {
if (!_uiState.value.isLoggedIn) return
if (cartItemId.isBlank()) return
if (currentQuantity + quantityDelta < 1) {
_uiState.update {
it.copy(errorMessage = "Jumlah minimum produk di keranjang adalah 1")
}
return
}
if (_uiState.value.updatingItemIds.contains(cartItemId)) return
viewModelScope.launch {
_uiState.update {
it.copy(updatingItemIds = it.updatingItemIds + cartItemId)
}
cartRepository.updateCartQuantity(
cartItemId = cartItemId,
quantityDelta = quantityDelta,
).onSuccess {
loadCart()
}.onFailure { error ->
_uiState.update {
it.copy(updatingItemIds = it.updatingItemIds - cartItemId)
}
handleFailure(error)
}
}
}
fun clearError() {
_uiState.update { it.copy(errorMessage = null) }
}
private fun handleFailure(error: Throwable) {
if (error is SessionExpiredException) {
viewModelScope.launch { authRepository.logout() }
_uiState.update {
it.copy(
itemCount = 0,
isLoggedIn = false,
isLoadingCart = false,
updatingItemIds = emptySet(),
cartGroups = emptyList(),
totalPrice = 0.0,
errorMessage = null,
)
}
return
}
_uiState.update {
it.copy(
isLoadingCart = false,
errorMessage = error.message ?: "Gagal memuat keranjang",
)
}
}
}

View File

@ -0,0 +1,32 @@
package id.iiyh.inatrading.feature.explore.data.model
data class LocationItem(
val address: String? = null,
val city: String? = null,
val contact: String? = null,
val country: String? = null,
val description: String? = null,
val id: String,
val image1: String? = null,
val image2: String? = null,
val image3: String? = null,
val image4: String? = null,
val image5: String? = null,
val latitude: Double? = null,
val longitude: Double? = null,
val name: String,
val province: String? = null,
val status: String? = null,
val type: String? = null,
val userInput: String? = null,
)
data class LocationListResponse(
val responseCode: String? = null,
val responseDesc: String? = null,
val rows: List<LocationItem> = emptyList(),
val totalItem: Int = 0,
val totalPage: Int = 0,
) {
val isSuccess: Boolean get() = responseCode == "0000"
}

View File

@ -0,0 +1,30 @@
package id.iiyh.inatrading.feature.explore.data.repository
import id.iiyh.inatrading.core.data.remote.ApiService
import id.iiyh.inatrading.feature.explore.domain.LocationPage
import id.iiyh.inatrading.feature.explore.domain.LocationRepository
import javax.inject.Inject
class LocationRepositoryImpl @Inject constructor(
private val apiService: ApiService,
) : LocationRepository {
override suspend fun getLocations(page: Int, limit: Int): Result<LocationPage> {
return try {
val response = apiService.getLocations(page = page, limit = limit)
if (response.isSuccess) {
Result.success(
LocationPage(
items = response.rows,
totalItem = response.totalItem,
totalPage = response.totalPage,
)
)
} else {
Result.failure(Exception(response.responseDesc ?: "Gagal memuat lokasi"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
}

View File

@ -0,0 +1,13 @@
package id.iiyh.inatrading.feature.explore.domain
import id.iiyh.inatrading.feature.explore.data.model.LocationItem
data class LocationPage(
val items: List<LocationItem>,
val totalItem: Int,
val totalPage: Int,
)
interface LocationRepository {
suspend fun getLocations(page: Int, limit: Int): Result<LocationPage>
}

View File

@ -0,0 +1,651 @@
package id.iiyh.inatrading.feature.explore.presentation
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.outlined.Call
import androidx.compose.material.icons.outlined.LocationOn
import androidx.compose.material.icons.outlined.Map
import androidx.compose.material.icons.outlined.QrCode2
import androidx.compose.material.icons.outlined.Schedule
import androidx.compose.material.icons.outlined.ShoppingBag
import androidx.compose.material.icons.outlined.Verified
import androidx.compose.material3.Button
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import id.iiyh.inatrading.R
import id.iiyh.inatrading.core.ui.theme.AccentBlue
import id.iiyh.inatrading.core.ui.theme.AccentPurple
import id.iiyh.inatrading.core.ui.theme.AccentPurpleContainer
import id.iiyh.inatrading.core.ui.theme.Background
import id.iiyh.inatrading.core.ui.theme.BrandRed
import id.iiyh.inatrading.core.ui.theme.BrandRedLight
import id.iiyh.inatrading.core.ui.theme.ManropeFontFamily
import id.iiyh.inatrading.core.ui.theme.OnAccentPurpleContainer
import id.iiyh.inatrading.core.ui.theme.OnSurface
import id.iiyh.inatrading.core.ui.theme.OnSurfaceVariant
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerHighest
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerLow
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerLowest
import id.iiyh.inatrading.feature.explore.data.model.LocationItem
@Composable
fun ExploreDetailScreen(
location: LocationItem,
onBack: () -> Unit,
) {
val context = LocalContext.current
val gallery = listOfNotNull(
location.image1,
location.image2,
location.image3,
location.image4,
location.image5,
).ifEmpty { listOf("") }
Scaffold(
topBar = {
ExploreDetailTopBar(
title = location.name,
onBack = onBack,
)
},
floatingActionButton = {
FloatingActionButton(
onClick = {},
containerColor = BrandRed,
contentColor = Color.White,
shape = RoundedCornerShape(20.dp),
) {
Icon(
imageVector = Icons.Outlined.ShoppingBag,
contentDescription = stringResource(R.string.explore_detail_fab),
)
}
},
containerColor = Background,
) { innerPadding ->
androidx.compose.foundation.lazy.LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.background(Background),
contentPadding = PaddingValues(start = 24.dp, end = 24.dp, top = 12.dp, bottom = 120.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
item {
HeroGallerySection(
imageUrl = location.image1,
showVerified = location.status.equals("APPROVED", ignoreCase = true),
)
}
item {
TitleIdentitySection(location = location)
}
item {
StorySection(location = location)
}
item {
VisitSection(
location = location,
onOpenMaps = {
openLocationInMaps(context = context, location = location)
},
)
}
item {
SignaturePrintsSection(
title = location.type ?: stringResource(R.string.explore_type_fallback),
gallery = gallery,
)
}
}
}
}
@Composable
private fun ExploreDetailTopBar(
title: String,
onBack: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(Color.White.copy(alpha = 0.88f))
.padding(horizontal = 20.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
DetailIconButton(
icon = Icons.AutoMirrored.Outlined.ArrowBack,
contentDescription = stringResource(R.string.favorite_back),
onClick = onBack,
)
Text(
text = title,
modifier = Modifier.weight(1f).padding(horizontal = 12.dp),
style = MaterialTheme.typography.titleLarge,
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.ExtraBold,
color = BrandRed,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
DetailIconButton(
icon = Icons.Outlined.Verified,
contentDescription = stringResource(R.string.explore_detail_verified),
onClick = {},
)
}
}
@Composable
private fun HeroGallerySection(
imageUrl: String?,
showVerified: Boolean,
) {
Box {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(4f / 5f)
.clip(RoundedCornerShape(28.dp))
.background(SurfaceContainerLow),
) {
if (!imageUrl.isNullOrBlank()) {
AsyncImage(
model = imageUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
} else {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Outlined.LocationOn,
contentDescription = null,
tint = BrandRed,
modifier = Modifier.size(48.dp),
)
}
}
}
if (showVerified) {
Surface(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 20.dp, bottom = 20.dp),
color = AccentPurple,
shape = RoundedCornerShape(24.dp),
shadowElevation = 6.dp,
) {
Box(
modifier = Modifier
.size(88.dp)
.padding(8.dp),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Outlined.Verified,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(40.dp),
)
}
}
}
}
}
@Composable
private fun TitleIdentitySection(location: LocationItem) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Surface(
color = AccentPurpleContainer,
shape = RoundedCornerShape(999.dp),
) {
Text(
text = stringResource(R.string.explore_featured_eyebrow),
modifier = Modifier.padding(horizontal = 12.dp, vertical = 7.dp),
style = MaterialTheme.typography.labelMedium,
color = OnAccentPurpleContainer,
fontWeight = FontWeight.Bold,
)
}
Text(
text = location.type.orEmpty().ifBlank { stringResource(R.string.explore_type_fallback) },
style = MaterialTheme.typography.labelMedium,
color = OnSurfaceVariant,
fontWeight = FontWeight.Bold,
)
}
Text(
text = location.name,
style = MaterialTheme.typography.displaySmall,
color = OnSurface,
fontWeight = FontWeight.ExtraBold,
)
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Outlined.LocationOn,
contentDescription = null,
tint = BrandRed,
modifier = Modifier.size(18.dp),
)
Text(
text = location.address?.takeIf { it.isNotBlank() } ?: buildLocationLabel(location),
style = MaterialTheme.typography.bodyMedium,
color = OnSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
private fun StorySection(location: LocationItem) {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(28.dp))
.background(SurfaceContainerHighest.copy(alpha = 0.7f))
.padding(vertical = 28.dp, horizontal = 20.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(
text = stringResource(R.string.explore_detail_story_title),
style = MaterialTheme.typography.headlineSmall,
color = OnSurface,
fontWeight = FontWeight.Bold,
)
Text(
text = location.description.orEmpty().ifBlank {
stringResource(R.string.explore_description_fallback)
},
style = MaterialTheme.typography.bodyLarge,
color = OnSurfaceVariant,
)
}
Button(
onClick = {},
shape = RoundedCornerShape(18.dp),
) {
Text(text = stringResource(R.string.explore_detail_explore_collection))
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
DetailInfoCard(
modifier = Modifier.weight(1f),
icon = Icons.Outlined.Call,
title = stringResource(R.string.explore_detail_contact),
value = location.contact ?: stringResource(R.string.explore_detail_value_unavailable),
iconTint = AccentBlue,
)
DetailInfoCard(
modifier = Modifier.weight(1f),
icon = Icons.Outlined.Schedule,
title = stringResource(R.string.explore_detail_status),
value = location.status ?: stringResource(R.string.explore_detail_open_daily),
iconTint = AccentPurple,
)
}
DetailMerchantCard(location = location)
}
}
@Composable
private fun DetailInfoCard(
modifier: Modifier = Modifier,
icon: ImageVector,
title: String,
value: String,
iconTint: Color,
) {
Surface(
modifier = modifier,
shape = RoundedCornerShape(22.dp),
color = SurfaceContainerLowest,
) {
Column(
modifier = Modifier.padding(18.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = iconTint,
)
Text(
text = title,
style = MaterialTheme.typography.labelSmall,
color = OnSurfaceVariant,
fontWeight = FontWeight.Bold,
)
Text(
text = value,
style = MaterialTheme.typography.titleMedium,
color = OnSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
private fun DetailMerchantCard(location: LocationItem) {
Surface(
shape = RoundedCornerShape(22.dp),
color = SurfaceContainerLowest,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(18.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = stringResource(R.string.explore_detail_merchant_id),
style = MaterialTheme.typography.labelSmall,
color = OnSurfaceVariant,
fontWeight = FontWeight.Bold,
)
Text(
text = buildMerchantCode(location),
style = MaterialTheme.typography.titleMedium,
color = OnSurface,
)
}
Box(
modifier = Modifier
.size(48.dp)
.clip(RoundedCornerShape(14.dp))
.background(SurfaceContainerLow),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Outlined.QrCode2,
contentDescription = null,
tint = OnSurface,
)
}
}
}
}
@Composable
private fun VisitSection(
location: LocationItem,
onOpenMaps: () -> Unit,
) {
Surface(
shape = RoundedCornerShape(28.dp),
color = SurfaceContainerLowest,
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = stringResource(R.string.explore_detail_visit_title),
style = MaterialTheme.typography.headlineSmall,
color = OnSurface,
fontWeight = FontWeight.ExtraBold,
)
Text(
text = location.address?.takeIf { it.isNotBlank() } ?: buildLocationLabel(location),
style = MaterialTheme.typography.bodyMedium,
color = OnSurfaceVariant,
)
}
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(21f / 9f)
.clip(RoundedCornerShape(22.dp))
.background(SurfaceContainerHighest),
) {
if (!location.image1.isNullOrBlank()) {
AsyncImage(
model = location.image1,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
alpha = 0.45f,
)
}
Box(
modifier = Modifier
.align(Alignment.Center)
.size(56.dp)
.clip(RoundedCornerShape(999.dp))
.background(BrandRed),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Outlined.LocationOn,
contentDescription = null,
tint = Color.White,
)
}
}
Button(
onClick = onOpenMaps,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(18.dp),
) {
Icon(
imageVector = Icons.Outlined.Map,
contentDescription = null,
)
Spacer(modifier = Modifier.width(8.dp))
Text(text = stringResource(R.string.explore_detail_open_maps))
}
}
}
}
@Composable
private fun SignaturePrintsSection(
title: String,
gallery: List<String>,
) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(R.string.explore_detail_signature_title),
style = MaterialTheme.typography.headlineSmall,
color = OnSurface,
fontWeight = FontWeight.ExtraBold,
)
Text(
text = stringResource(R.string.profile_view_all),
style = MaterialTheme.typography.labelMedium,
color = BrandRed,
fontWeight = FontWeight.Bold,
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
gallery.forEachIndexed { index, imageUrl ->
Column(
modifier = Modifier.width(220.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.clip(RoundedCornerShape(24.dp))
.background(SurfaceContainerLow),
) {
if (imageUrl.isNotBlank()) {
AsyncImage(
model = imageUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
}
}
Text(
text = title,
style = MaterialTheme.typography.labelSmall,
color = OnSurfaceVariant,
fontWeight = FontWeight.Bold,
)
Text(
text = stringResource(R.string.explore_detail_gallery_item, index + 1),
style = MaterialTheme.typography.titleMedium,
color = OnSurface,
)
}
}
}
}
}
@Composable
private fun DetailIconButton(
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(999.dp))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(),
onClick = onClick,
),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
tint = BrandRed,
)
}
}
private fun openLocationInMaps(
context: android.content.Context,
location: LocationItem,
) {
val label = Uri.encode(location.name)
val address = Uri.encode(location.address ?: buildLocationLabel(location))
val uri = if (location.latitude != null && location.longitude != null) {
Uri.parse("geo:${location.latitude},${location.longitude}?q=${location.latitude},${location.longitude}($label)")
} else {
Uri.parse("geo:0,0?q=$address")
}
val intent = Intent(Intent.ACTION_VIEW, uri)
runCatching { context.startActivity(intent) }
}
private fun buildLocationLabel(location: LocationItem): String {
return listOfNotNull(
location.city?.takeIf { it.isNotBlank() },
location.province?.takeIf { it.isNotBlank() },
location.country?.takeIf { it.isNotBlank() },
).joinToString(", ").ifBlank { "Indonesia" }
}
private fun buildMerchantCode(location: LocationItem): String {
val prefix = location.name
.split(" ")
.filter { it.isNotBlank() }
.take(2)
.joinToString("") { it.take(1).uppercase() }
.ifBlank { "LOC" }
val area = (location.city ?: location.province ?: location.country)
.orEmpty()
.replace(Regex("[^A-Za-z]"), "")
.take(3)
.uppercase()
.ifBlank { "IDN" }
val suffix = location.id.takeLast(4).uppercase()
return "$prefix-$area-$suffix"
}

View File

@ -0,0 +1,20 @@
package id.iiyh.inatrading.feature.explore.presentation
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import id.iiyh.inatrading.feature.explore.data.model.LocationItem
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@HiltViewModel
class ExploreNavViewModel @Inject constructor() : ViewModel() {
private val _selectedLocation = MutableStateFlow<LocationItem?>(null)
val selectedLocation: StateFlow<LocationItem?> = _selectedLocation.asStateFlow()
fun select(location: LocationItem) {
_selectedLocation.value = location
}
}

View File

@ -0,0 +1,676 @@
package id.iiyh.inatrading.feature.explore.presentation
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.LocationOn
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Tune
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import coil.compose.AsyncImage
import id.iiyh.inatrading.R
import id.iiyh.inatrading.core.ui.components.InaTextField
import id.iiyh.inatrading.core.ui.theme.AccentBlue
import id.iiyh.inatrading.core.ui.theme.AccentPurple
import id.iiyh.inatrading.core.ui.theme.AccentPurpleContainer
import id.iiyh.inatrading.core.ui.theme.Background
import id.iiyh.inatrading.core.ui.theme.BrandRed
import id.iiyh.inatrading.core.ui.theme.OnAccentPurpleContainer
import id.iiyh.inatrading.core.ui.theme.OnSurface
import id.iiyh.inatrading.core.ui.theme.OnSurfaceVariant
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerHighest
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerLow
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerLowest
import id.iiyh.inatrading.feature.explore.data.model.LocationItem
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
@Composable
fun ExploreScreen(
onLocationClick: (LocationItem) -> Unit = {},
viewModel: ExploreViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsState()
val gridState = rememberLazyGridState()
LaunchedEffect(gridState) {
snapshotFlow {
val layoutInfo = gridState.layoutInfo
val lastVisible = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
lastVisible to layoutInfo.totalItemsCount
}
.filter { (_, totalCount) -> totalCount > 0 }
.distinctUntilChanged()
.collect { (lastVisible, totalCount) ->
if (lastVisible >= totalCount - 5) {
viewModel.loadNextPage()
}
}
}
val featuredItems = uiState.filteredItems.take(5)
val recommendedItems = uiState.filteredItems.drop(featuredItems.size)
Box(
modifier = Modifier
.fillMaxSize()
.background(Background),
) {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
state = gridState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 20.dp, bottom = 112.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
item(span = { GridItemSpan(maxLineSpan) }) {
ExploreHero(
totalItem = uiState.totalItem,
totalLoaded = uiState.items.size,
)
}
item(span = { GridItemSpan(maxLineSpan) }) {
ExploreSearchRow(
query = uiState.searchQuery,
onQueryChange = viewModel::onSearchQueryChange,
)
}
when {
uiState.isInitialLoading -> {
item(span = { GridItemSpan(maxLineSpan) }) {
ExploreCenterState(
title = stringResource(R.string.explore_loading_title),
body = stringResource(R.string.explore_loading_body),
loading = true,
)
}
}
uiState.errorMessage != null && uiState.items.isEmpty() -> {
item(span = { GridItemSpan(maxLineSpan) }) {
ExploreCenterState(
title = stringResource(R.string.explore_error_title),
body = uiState.errorMessage.orEmpty(),
actionLabel = stringResource(R.string.explore_retry),
onAction = viewModel::loadInitial,
)
}
}
uiState.filteredItems.isEmpty() -> {
item(span = { GridItemSpan(maxLineSpan) }) {
ExploreCenterState(
title = stringResource(R.string.explore_empty_title),
body = stringResource(R.string.explore_empty_body),
)
}
}
else -> {
if (featuredItems.isNotEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
SectionHeader(
eyebrow = stringResource(R.string.explore_featured_eyebrow),
title = stringResource(R.string.explore_featured_title),
)
}
item(span = { GridItemSpan(maxLineSpan) }) {
FeaturedHeroCard(
location = featuredItems.first(),
onClick = { onLocationClick(featuredItems.first()) },
)
}
if (featuredItems.size > 1) {
item(span = { GridItemSpan(maxLineSpan) }) {
FeaturedWideCard(
location = featuredItems[1],
onClick = { onLocationClick(featuredItems[1]) },
)
}
}
featuredItems.drop(2).forEach { location ->
item {
FeaturedCompactCard(
location = location,
onClick = { onLocationClick(location) },
)
}
}
}
if (recommendedItems.isNotEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
SectionHeader(
title = stringResource(R.string.explore_recommended_title),
body = stringResource(R.string.explore_recommended_body),
)
}
recommendedItems.forEach { location ->
item(key = location.id) {
RecommendedCard(
location = location,
onClick = { onLocationClick(location) },
)
}
}
}
if (uiState.isAppending) {
item(span = { GridItemSpan(maxLineSpan) }) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
horizontalArrangement = Arrangement.Center,
) {
CircularProgressIndicator(
modifier = Modifier.size(28.dp),
color = BrandRed,
strokeWidth = 2.5.dp,
)
}
}
}
}
}
}
}
}
@Composable
private fun ExploreHero(
totalItem: Int,
totalLoaded: Int,
) {
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(28.dp))
.background(
brush = Brush.linearGradient(
listOf(
SurfaceContainerHighest,
Background,
)
)
)
.padding(24.dp),
) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.width(140.dp)
.height(88.dp)
.clip(RoundedCornerShape(24.dp))
.background(BrandRed.copy(alpha = 0.08f)),
)
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
text = stringResource(R.string.explore_hero_eyebrow),
style = MaterialTheme.typography.labelMedium,
color = AccentPurple,
fontWeight = FontWeight.Bold,
)
Text(
text = buildString {
append(stringResource(R.string.explore_hero_title_prefix))
append(" ")
append(stringResource(R.string.explore_hero_title_emphasis))
},
style = MaterialTheme.typography.displaySmall,
color = OnSurface,
)
Text(
text = stringResource(R.string.explore_hero_body),
style = MaterialTheme.typography.bodyMedium,
color = OnSurfaceVariant,
)
Surface(
color = AccentPurpleContainer,
shape = RoundedCornerShape(999.dp),
) {
Text(
text = stringResource(R.string.explore_hero_counter, totalLoaded, totalItem),
modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
style = MaterialTheme.typography.labelMedium,
color = OnAccentPurpleContainer,
fontWeight = FontWeight.Bold,
)
}
}
}
}
@Composable
private fun ExploreSearchRow(
query: String,
onQueryChange: (String) -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
InaTextField(
value = query,
onValueChange = onQueryChange,
modifier = Modifier.weight(1f),
placeholder = stringResource(R.string.explore_search_placeholder),
leadingIcon = {
Icon(
imageVector = Icons.Outlined.Search,
contentDescription = null,
tint = OnSurfaceVariant,
)
},
)
Surface(
modifier = Modifier.size(52.dp),
shape = RoundedCornerShape(18.dp),
color = SurfaceContainerLow,
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Outlined.Tune,
contentDescription = stringResource(R.string.explore_filter),
tint = OnSurface,
)
}
}
}
}
@Composable
private fun SectionHeader(
title: String,
eyebrow: String? = null,
body: String? = null,
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
eyebrow?.let {
Text(
text = it,
style = MaterialTheme.typography.labelMedium,
color = AccentBlue,
fontWeight = FontWeight.Bold,
)
}
Text(
text = title,
style = MaterialTheme.typography.headlineMedium,
color = OnSurface,
)
body?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = OnSurfaceVariant,
)
}
}
}
@Composable
private fun FeaturedHeroCard(
location: LocationItem,
onClick: () -> Unit,
) {
Surface(
onClick = onClick,
shape = RoundedCornerShape(28.dp),
color = SurfaceContainerLowest,
shadowElevation = 2.dp,
) {
Column {
LocationImage(
imageUrl = location.image1,
modifier = Modifier
.fillMaxWidth()
.height(280.dp),
)
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
LocationTagRow(location = location, featured = true)
Text(
text = location.name,
style = MaterialTheme.typography.headlineSmall,
color = OnSurface,
)
Text(
text = location.description.orEmpty().ifBlank {
stringResource(R.string.explore_description_fallback)
},
style = MaterialTheme.typography.bodyMedium,
color = OnSurfaceVariant,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
@Composable
private fun FeaturedWideCard(
location: LocationItem,
onClick: () -> Unit,
) {
Surface(
onClick = onClick,
shape = RoundedCornerShape(24.dp),
color = SurfaceContainerLowest,
shadowElevation = 1.dp,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
LocationImage(
imageUrl = location.image1,
modifier = Modifier
.weight(0.38f)
.height(160.dp),
)
Column(
modifier = Modifier
.weight(0.62f)
.padding(18.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
LocationTagRow(location = location)
Text(
text = location.name,
style = MaterialTheme.typography.titleLarge,
color = OnSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
LocationMeta(location = location)
}
}
}
}
@Composable
private fun FeaturedCompactCard(
location: LocationItem,
onClick: () -> Unit,
) {
Surface(
onClick = onClick,
shape = RoundedCornerShape(22.dp),
color = SurfaceContainerLowest,
shadowElevation = 1.dp,
) {
Column {
LocationImage(
imageUrl = location.image1,
modifier = Modifier
.fillMaxWidth()
.height(136.dp),
)
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = location.type.orEmpty().ifBlank {
stringResource(R.string.explore_type_fallback)
},
style = MaterialTheme.typography.labelSmall,
color = AccentBlue,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = location.name,
style = MaterialTheme.typography.titleMedium,
color = OnSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
@Composable
private fun RecommendedCard(
location: LocationItem,
onClick: () -> Unit,
) {
Column(
modifier = Modifier.clickable(onClick = onClick),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(4f / 3f)
.clip(RoundedCornerShape(22.dp)),
) {
LocationImage(
imageUrl = location.image1,
modifier = Modifier.fillMaxSize(),
)
Surface(
modifier = Modifier
.align(Alignment.BottomStart)
.padding(12.dp),
shape = RoundedCornerShape(999.dp),
color = Color.White.copy(alpha = 0.92f),
) {
Text(
text = location.type.orEmpty().ifBlank {
stringResource(R.string.explore_type_fallback)
},
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
style = MaterialTheme.typography.labelSmall,
color = OnSurface,
fontWeight = FontWeight.Bold,
)
}
}
Text(
text = location.name,
style = MaterialTheme.typography.titleLarge,
color = OnSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
LocationMeta(location = location)
}
}
@Composable
private fun LocationImage(
imageUrl: String?,
modifier: Modifier = Modifier,
) {
if (imageUrl.isNullOrBlank()) {
Box(
modifier = modifier.background(SurfaceContainerLow),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Outlined.LocationOn,
contentDescription = null,
tint = BrandRed.copy(alpha = 0.7f),
modifier = Modifier.size(28.dp),
)
}
return
}
AsyncImage(
model = imageUrl,
contentDescription = null,
modifier = modifier,
contentScale = ContentScale.Crop,
)
}
@Composable
private fun LocationTagRow(
location: LocationItem,
featured: Boolean = false,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = buildLocationBadge(location).ifBlank {
stringResource(R.string.explore_type_fallback)
},
style = MaterialTheme.typography.labelSmall,
color = AccentBlue,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (featured) {
Surface(
color = BrandRed.copy(alpha = 0.12f),
shape = RoundedCornerShape(999.dp),
) {
Text(
text = stringResource(R.string.explore_featured_badge),
modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp),
style = MaterialTheme.typography.labelSmall,
color = BrandRed,
fontWeight = FontWeight.Bold,
)
}
}
}
}
@Composable
private fun LocationMeta(location: LocationItem) {
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Outlined.LocationOn,
contentDescription = null,
tint = OnSurfaceVariant,
modifier = Modifier.size(16.dp),
)
Text(
text = buildLocationSubtitle(location),
style = MaterialTheme.typography.bodySmall,
color = OnSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
private fun ExploreCenterState(
title: String,
body: String,
loading: Boolean = false,
actionLabel: String? = null,
onAction: (() -> Unit)? = null,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 48.dp, horizontal = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (loading) {
CircularProgressIndicator(
color = BrandRed,
strokeWidth = 3.dp,
)
}
Text(
text = title,
style = MaterialTheme.typography.headlineSmall,
color = OnSurface,
)
Text(
text = body,
style = MaterialTheme.typography.bodyMedium,
color = OnSurfaceVariant,
)
if (actionLabel != null && onAction != null) {
Button(onClick = onAction) {
Text(text = actionLabel)
}
}
}
}
private fun buildLocationBadge(location: LocationItem): String {
val type = location.type?.takeIf { it.isNotBlank() }
val area = location.city?.takeIf { it.isNotBlank() }
?: location.province?.takeIf { it.isNotBlank() }
?: location.country?.takeIf { it.isNotBlank() }
return listOfNotNull(type, area).joinToString("")
}
private fun buildLocationSubtitle(location: LocationItem): String {
return listOfNotNull(
location.city?.takeIf { it.isNotBlank() },
location.province?.takeIf { it.isNotBlank() },
location.country?.takeIf { it.isNotBlank() },
).joinToString(", ").ifBlank {
location.address?.takeIf { it.isNotBlank() } ?: ""
}.ifBlank {
"Indonesia"
}
}

View File

@ -0,0 +1,121 @@
package id.iiyh.inatrading.feature.explore.presentation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import id.iiyh.inatrading.feature.explore.data.model.LocationItem
import id.iiyh.inatrading.feature.explore.domain.LocationRepository
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
private const val LOCATION_PAGE_LIMIT = 10
data class ExploreUiState(
val items: List<LocationItem> = emptyList(),
val isInitialLoading: Boolean = false,
val isAppending: Boolean = false,
val errorMessage: String? = null,
val currentPage: Int = 0,
val totalPage: Int = 0,
val totalItem: Int = 0,
val searchQuery: String = "",
) {
val hasMore: Boolean
get() = totalPage == 0 || currentPage < totalPage || items.size < totalItem
val filteredItems: List<LocationItem>
get() = items.filter { location ->
val query = searchQuery.trim()
if (query.isBlank()) {
true
} else {
listOf(
location.name,
location.type,
location.city,
location.province,
location.country,
location.address,
location.description,
).any { it.orEmpty().contains(query, ignoreCase = true) }
}
}
}
@HiltViewModel
class ExploreViewModel @Inject constructor(
private val locationRepository: LocationRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(ExploreUiState())
val uiState: StateFlow<ExploreUiState> = _uiState.asStateFlow()
init {
loadInitial()
}
fun loadInitial() {
_uiState.update {
it.copy(
items = emptyList(),
isInitialLoading = true,
isAppending = false,
errorMessage = null,
currentPage = 0,
totalPage = 0,
totalItem = 0,
)
}
loadPage(1)
}
fun loadNextPage() {
val state = _uiState.value
if (state.isInitialLoading || state.isAppending || !state.hasMore) return
loadPage(state.currentPage + 1)
}
fun onSearchQueryChange(value: String) {
_uiState.update { it.copy(searchQuery = value) }
}
private fun loadPage(page: Int) {
viewModelScope.launch {
_uiState.update {
it.copy(
isInitialLoading = page == 1,
isAppending = page > 1,
errorMessage = if (page == 1) null else it.errorMessage,
)
}
locationRepository.getLocations(page = page, limit = LOCATION_PAGE_LIMIT)
.onSuccess { result ->
_uiState.update { state ->
state.copy(
items = if (page == 1) result.items else state.items + result.items,
isInitialLoading = false,
isAppending = false,
errorMessage = null,
currentPage = page,
totalPage = result.totalPage,
totalItem = result.totalItem,
)
}
}
.onFailure { error ->
_uiState.update {
it.copy(
isInitialLoading = false,
isAppending = false,
errorMessage = error.message ?: "Gagal memuat lokasi",
)
}
}
}
}
}

View File

@ -0,0 +1,47 @@
package id.iiyh.inatrading.feature.favorite.data.model
data class FavoriteGroupItem(
val id: String,
val isDefault: Boolean = false,
val name: String,
)
data class FavoriteListResponse(
val responseCode: String? = null,
val responseDesc: String? = null,
val rows: List<FavoriteGroupItem> = emptyList(),
val totalItem: Int = 0,
val totalPage: Int = 0,
) {
val isSuccess: Boolean get() = responseCode == "0000"
}
data class FavoriteItem(
val id: String? = null,
val productDescription: String? = null,
val productId: String? = null,
val productImage: String? = null,
val productName: String? = null,
val productRating: Double? = null,
val sellerImage: String? = null,
val sellerName: String? = null,
)
data class FavoriteItemsResponse(
val responseCode: String? = null,
val responseDesc: String? = null,
val rows: List<FavoriteItem> = emptyList(),
val totalItem: Int = 0,
val totalPage: Int = 0,
) {
val isSuccess: Boolean get() = responseCode == "0000"
}
data class FavoriteCreateRequest(
val name: String,
)
data class AddToFavoriteRequest(
val favoriteId: String,
val productId: String,
)

View File

@ -0,0 +1,180 @@
package id.iiyh.inatrading.feature.favorite.data.repository
import id.iiyh.inatrading.core.data.remote.ApiService
import id.iiyh.inatrading.core.data.remote.SessionExpiredException
import id.iiyh.inatrading.feature.favorite.data.model.AddToFavoriteRequest
import id.iiyh.inatrading.feature.favorite.data.model.FavoriteCreateRequest
import id.iiyh.inatrading.feature.favorite.domain.FavoriteGroupProduct
import id.iiyh.inatrading.feature.favorite.domain.FavoriteGroupSummary
import id.iiyh.inatrading.feature.favorite.domain.FavoriteRepository
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import retrofit2.HttpException
import javax.inject.Inject
class FavoriteRepositoryImpl @Inject constructor(
private val apiService: ApiService,
) : FavoriteRepository {
override suspend fun getFavoriteGroups(): Result<List<FavoriteGroupSummary>> {
return try {
val response = apiService.getFavorites()
if (!response.isSuccess) {
return Result.failure(Exception(response.responseDesc ?: "Gagal memuat koleksi favorit"))
}
val groups = coroutineScope {
response.rows.map { group ->
async {
val itemResponse = apiService.getFavoriteItems(group.id)
val itemCount = if (itemResponse.isSuccess) {
itemResponse.totalItem.takeIf { it > 0 } ?: itemResponse.rows.size
} else {
0
}
FavoriteGroupSummary(
id = group.id,
name = group.name,
isDefault = group.isDefault,
itemCount = itemCount,
)
}
}.awaitAll()
}
Result.success(groups)
} catch (e: HttpException) {
Result.failure(e.toRepositoryException())
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun createFavoriteGroup(name: String): Result<Unit> {
return try {
val response = apiService.createFavorite(FavoriteCreateRequest(name = name))
if (response.isSuccess) {
Result.success(Unit)
} else {
Result.failure(Exception(response.responseDesc ?: "Gagal membuat koleksi favorit"))
}
} catch (e: HttpException) {
Result.failure(e.toRepositoryException())
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun getFavoriteGroupItems(favoriteId: String): Result<List<FavoriteGroupProduct>> {
return try {
val response = apiService.getFavoriteItems(favoriteId)
if (!response.isSuccess) {
return Result.failure(Exception(response.responseDesc ?: "Gagal memuat item favorit"))
}
Result.success(
response.rows.mapNotNull { item ->
val productId = item.productId ?: return@mapNotNull null
val productName = item.productName ?: return@mapNotNull null
FavoriteGroupProduct(
id = item.id ?: productId,
productId = productId,
productName = productName,
productDescription = item.productDescription,
productImage = item.productImage,
sellerName = item.sellerName,
)
}
)
} catch (e: HttpException) {
Result.failure(e.toRepositoryException())
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun updateFavoriteGroup(favoriteId: String, name: String): Result<Unit> {
return try {
val response = apiService.updateFavorite(
favoriteId = favoriteId,
request = FavoriteCreateRequest(name = name),
)
if (response.isSuccess) {
Result.success(Unit)
} else {
Result.failure(Exception(response.responseDesc ?: "Gagal mengubah nama koleksi"))
}
} catch (e: HttpException) {
Result.failure(e.toRepositoryException())
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun deleteFavoriteGroup(favoriteId: String): Result<Unit> {
return try {
val response = apiService.deleteFavoriteItem(productId = favoriteId)
if (response.isSuccess) {
Result.success(Unit)
} else {
Result.failure(Exception(response.responseDesc ?: "Gagal menghapus koleksi"))
}
} catch (e: HttpException) {
Result.failure(e.toRepositoryException())
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun addProductToFavorite(favoriteId: String, productId: String): Result<Unit> {
return try {
val response = apiService.addToFavorite(
AddToFavoriteRequest(
favoriteId = favoriteId,
productId = productId,
)
)
if (response.isSuccess) {
Result.success(Unit)
} else {
Result.failure(Exception(response.responseDesc ?: "Gagal menambahkan produk ke favorit"))
}
} catch (e: HttpException) {
Result.failure(e.toRepositoryException())
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun removeProductFromFavorite(productId: String): Result<Unit> {
return try {
val response = apiService.deleteFavoriteItem(productId = productId)
if (response.isSuccess) {
Result.success(Unit)
} else {
Result.failure(Exception(response.responseDesc ?: "Gagal menghapus produk dari favorit"))
}
} catch (e: HttpException) {
Result.failure(e.toRepositoryException())
} catch (e: Exception) {
Result.failure(e)
}
}
private fun HttpException.toRepositoryException(): Exception {
if (code() == 401) return SessionExpiredException()
return Exception(parseMessage())
}
private fun HttpException.parseMessage(): String {
return try {
val body = response()?.errorBody()?.string() ?: return "Terjadi kesalahan (${code()})"
val match = Regex(""""responseDesc"\s*:\s*"([^"]+)"""").find(body)
match?.groupValues?.get(1) ?: "Terjadi kesalahan (${code()})"
} catch (_: Exception) {
"Terjadi kesalahan (${code()})"
}
}
}

View File

@ -0,0 +1,27 @@
package id.iiyh.inatrading.feature.favorite.domain
data class FavoriteGroupSummary(
val id: String,
val name: String,
val isDefault: Boolean,
val itemCount: Int,
)
data class FavoriteGroupProduct(
val id: String,
val productId: String,
val productName: String,
val productDescription: String?,
val productImage: String?,
val sellerName: String?,
)
interface FavoriteRepository {
suspend fun getFavoriteGroups(): Result<List<FavoriteGroupSummary>>
suspend fun getFavoriteGroupItems(favoriteId: String): Result<List<FavoriteGroupProduct>>
suspend fun createFavoriteGroup(name: String): Result<Unit>
suspend fun updateFavoriteGroup(favoriteId: String, name: String): Result<Unit>
suspend fun deleteFavoriteGroup(favoriteId: String): Result<Unit>
suspend fun addProductToFavorite(favoriteId: String, productId: String): Result<Unit>
suspend fun removeProductFromFavorite(productId: String): Result<Unit>
}

View File

@ -0,0 +1,142 @@
package id.iiyh.inatrading.feature.favorite.presentation
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import id.iiyh.inatrading.core.data.remote.SessionExpiredException
import id.iiyh.inatrading.feature.auth.domain.repository.AuthRepository
import id.iiyh.inatrading.feature.favorite.domain.FavoriteGroupProduct
import id.iiyh.inatrading.feature.favorite.domain.FavoriteRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
data class FavoriteGroupDetailUiState(
val groupId: String = "",
val groupName: String = "",
val items: List<FavoriteGroupProduct> = emptyList(),
val isLoading: Boolean = false,
val removingProductId: String? = null,
val errorMessage: String? = null,
val sessionExpired: Boolean = false,
)
@HiltViewModel
class FavoriteGroupDetailViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val favoriteRepository: FavoriteRepository,
private val authRepository: AuthRepository,
) : ViewModel() {
private val groupId: String = savedStateHandle.get<String>("groupId").orEmpty()
private val groupName: String = savedStateHandle.get<String>("groupName").orEmpty()
private val _uiState = MutableStateFlow(
FavoriteGroupDetailUiState(
groupId = groupId,
groupName = groupName,
)
)
val uiState: StateFlow<FavoriteGroupDetailUiState> = _uiState.asStateFlow()
init {
loadItems()
}
fun loadItems() {
if (groupId.isBlank()) {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Invalid favorite group",
)
}
return
}
viewModelScope.launch {
_uiState.update {
it.copy(
isLoading = true,
errorMessage = null,
sessionExpired = false,
)
}
favoriteRepository.getFavoriteGroupItems(groupId)
.onSuccess { items ->
_uiState.update {
it.copy(
items = items,
isLoading = false,
removingProductId = null,
errorMessage = null,
sessionExpired = false,
)
}
}
.onFailure { handleFailure(it, fallbackMessage = "Failed to load favorite items") }
}
}
fun removeFavorite(productId: String) {
viewModelScope.launch {
_uiState.update {
it.copy(
removingProductId = productId,
errorMessage = null,
sessionExpired = false,
)
}
favoriteRepository.removeProductFromFavorite(productId)
.onSuccess {
_uiState.update { state ->
state.copy(
items = state.items.filterNot { item -> item.productId == productId },
removingProductId = null,
errorMessage = null,
sessionExpired = false,
)
}
}
.onFailure { handleFailure(it, fallbackMessage = "Failed to remove favorite item") }
}
}
fun consumeError() {
_uiState.update { it.copy(errorMessage = null) }
}
fun consumeSessionExpired() {
_uiState.update { it.copy(sessionExpired = false) }
}
private suspend fun handleFailure(error: Throwable, fallbackMessage: String) {
if (error is SessionExpiredException) {
authRepository.logout()
_uiState.update {
it.copy(
isLoading = false,
removingProductId = null,
errorMessage = null,
sessionExpired = true,
)
}
return
}
_uiState.update {
it.copy(
isLoading = false,
removingProductId = null,
errorMessage = error.message ?: fallbackMessage,
sessionExpired = false,
)
}
}
}

View File

@ -0,0 +1,127 @@
package id.iiyh.inatrading.feature.favorite.presentation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import id.iiyh.inatrading.core.data.remote.SessionExpiredException
import id.iiyh.inatrading.feature.auth.domain.repository.AuthRepository
import id.iiyh.inatrading.feature.favorite.domain.FavoriteGroupSummary
import id.iiyh.inatrading.feature.favorite.domain.FavoriteRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
data class FavoritesUiState(
val groups: List<FavoriteGroupSummary> = emptyList(),
val isLoading: Boolean = false,
val isSubmitting: Boolean = false,
val errorMessage: String? = null,
val sessionExpired: Boolean = false,
)
@HiltViewModel
class FavoritesViewModel @Inject constructor(
private val favoriteRepository: FavoriteRepository,
private val authRepository: AuthRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(FavoritesUiState())
val uiState: StateFlow<FavoritesUiState> = _uiState.asStateFlow()
init {
loadFavorites()
}
fun loadFavorites() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, isSubmitting = false, errorMessage = null, sessionExpired = false) }
favoriteRepository.getFavoriteGroups()
.onSuccess { groups ->
_uiState.update {
it.copy(
groups = groups.sortedByDescending(FavoriteGroupSummary::isDefault).sortedBy { item -> item.name.lowercase() },
isLoading = false,
isSubmitting = false,
errorMessage = null,
sessionExpired = false,
)
}
}
.onFailure { error ->
handleFailure(
error = error,
fallbackMessage = "Gagal memuat koleksi favorit",
updateLoading = true,
)
}
}
}
fun createGroup(name: String) {
mutate {
favoriteRepository.createFavoriteGroup(name)
}
}
fun renameGroup(favoriteId: String, name: String) {
mutate {
favoriteRepository.updateFavoriteGroup(favoriteId = favoriteId, name = name)
}
}
fun deleteGroup(favoriteId: String) {
mutate {
favoriteRepository.deleteFavoriteGroup(favoriteId)
}
}
private fun mutate(block: suspend () -> Result<Unit>) {
viewModelScope.launch {
_uiState.update { it.copy(isSubmitting = true, errorMessage = null, sessionExpired = false) }
block()
.onSuccess { loadFavorites() }
.onFailure { error ->
handleFailure(
error = error,
fallbackMessage = "Terjadi kesalahan",
updateLoading = false,
)
}
}
}
fun consumeSessionExpired() {
_uiState.update { it.copy(sessionExpired = false) }
}
private suspend fun handleFailure(
error: Throwable,
fallbackMessage: String,
updateLoading: Boolean,
) {
if (error is SessionExpiredException) {
authRepository.logout()
_uiState.update {
it.copy(
isLoading = false,
isSubmitting = false,
errorMessage = null,
sessionExpired = true,
)
}
return
}
_uiState.update {
it.copy(
isLoading = if (updateLoading) false else it.isLoading,
isSubmitting = false,
errorMessage = error.message ?: fallbackMessage,
sessionExpired = false,
)
}
}
}

View File

@ -0,0 +1,664 @@
package id.iiyh.inatrading.feature.home.presentation
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.Image
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.BusinessCenter
import androidx.compose.material.icons.outlined.Groups
import androidx.compose.material.icons.outlined.Hub
import androidx.compose.material.icons.outlined.Image
import androidx.compose.material.icons.outlined.Language
import androidx.compose.material.icons.outlined.OpenInNew
import androidx.compose.material.icons.outlined.RocketLaunch
import androidx.compose.material.icons.outlined.Storefront
import androidx.compose.material.icons.outlined.VerifiedUser
import androidx.compose.material.icons.outlined.WebAsset
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import id.iiyh.inatrading.R
import id.iiyh.inatrading.core.ui.theme.AccentBlue
import id.iiyh.inatrading.core.ui.theme.AccentPurple
import id.iiyh.inatrading.core.ui.theme.AccentPurpleContainer
import id.iiyh.inatrading.core.ui.theme.Background
import id.iiyh.inatrading.core.ui.theme.BrandRed
import id.iiyh.inatrading.core.ui.theme.ManropeFontFamily
import id.iiyh.inatrading.core.ui.theme.OnSurface
import id.iiyh.inatrading.core.ui.theme.OnSurfaceVariant
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerHighest
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerLow
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerLowest
import id.iiyh.inatrading.feature.news.data.model.NewsArticle
@Composable
fun HomeScreen(
onNewsClick: (NewsArticle) -> Unit = {},
onViewAllNews: () -> Unit = {},
viewModel: HomeViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsState()
Column(
modifier = Modifier
.fillMaxSize()
.background(Background)
.verticalScroll(rememberScrollState()),
) {
WhatIsInaSection()
CorePillarsSection()
WhoUsesSection()
CaseStudiesSection()
LatestNewsSection(
articles = uiState.latestNews,
isLoading = uiState.isLoadingNews,
onNewsClick = onNewsClick,
onViewAll = onViewAllNews,
)
Spacer(modifier = Modifier.height(16.dp))
}
}
// ─── Section 1: What is INA Trading ──────────────────────────────────────────
@Composable
private fun WhatIsInaSection() {
Column(
modifier = Modifier
.fillMaxWidth()
.background(SurfaceContainerLow)
.padding(horizontal = 24.dp)
.padding(top = 32.dp, bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
// Title
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = buildAnnotatedString {
append(stringResource(R.string.home_what_is_prefix))
withStyle(SpanStyle(color = BrandRed)) { append(stringResource(R.string.home_what_is_highlight)) }
},
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.ExtraBold,
fontSize = 28.sp,
color = OnSurface,
lineHeight = 36.sp,
)
Text(
text = stringResource(R.string.home_intro),
style = MaterialTheme.typography.bodyMedium,
color = OnSurfaceVariant,
lineHeight = 22.sp,
)
}
// Card 1 — Hybrid B2B
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(SurfaceContainerLowest)
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Box(
modifier = Modifier
.size(48.dp)
.clip(RoundedCornerShape(12.dp))
.background(BrandRed.copy(alpha = 0.10f)),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Outlined.Hub, contentDescription = null, tint = BrandRed, modifier = Modifier.size(24.dp))
}
Text(
text = stringResource(R.string.home_hybrid_title),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
color = OnSurface,
)
Text(
text = stringResource(R.string.home_hybrid_desc),
style = MaterialTheme.typography.bodyMedium,
color = OnSurfaceVariant,
lineHeight = 22.sp,
)
Image(
modifier = Modifier
.fillMaxWidth()
.height(160.dp)
.clip(RoundedCornerShape(12.dp)),
painter = painterResource(R.drawable.logistic),
contentDescription = stringResource(R.string.home_logistics_dashboard),
contentScale = ContentScale.Crop,
)
}
// Card 2 — Nusantara Modernity (gradient)
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(
Brush.linearGradient(
colors = listOf(AccentPurple, Color(0xFF865FCB)),
)
)
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = stringResource(R.string.home_nusantara_title),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
color = Color.White,
)
Text(
text = stringResource(R.string.home_nusantara_desc),
style = MaterialTheme.typography.bodyMedium,
color = Color.White.copy(alpha = 0.85f),
lineHeight = 22.sp,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "15k+",
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Black,
fontSize = 40.sp,
color = Color.White,
)
Text(
text = stringResource(R.string.home_verified_merchants),
style = MaterialTheme.typography.labelSmall,
color = Color.White.copy(alpha = 0.65f),
letterSpacing = 2.sp,
)
}
}
}
// ─── Section 2: Core Pillars ──────────────────────────────────────────────────
@Composable
private fun CorePillarsSection() {
Column(
modifier = Modifier
.fillMaxWidth()
.background(Background)
.padding(horizontal = 24.dp)
.padding(top = 36.dp, bottom = 36.dp),
verticalArrangement = Arrangement.spacedBy(32.dp),
) {
// Feature list
Column(verticalArrangement = Arrangement.spacedBy(28.dp)) {
PillarItem(
icon = Icons.Outlined.Language,
iconTint = BrandRed,
iconBg = BrandRed.copy(alpha = 0.10f),
title = stringResource(R.string.home_global_reach),
desc = stringResource(R.string.home_global_reach_desc),
)
PillarItem(
icon = Icons.Outlined.WebAsset,
iconTint = AccentBlue,
iconBg = AccentBlue.copy(alpha = 0.10f),
title = stringResource(R.string.home_digital_integration),
desc = stringResource(R.string.home_digital_integration_desc),
)
PillarItem(
icon = Icons.Outlined.VerifiedUser,
iconTint = AccentPurple,
iconBg = AccentPurple.copy(alpha = 0.10f),
title = stringResource(R.string.home_secure_transaction),
desc = stringResource(R.string.home_secure_transaction_desc),
)
}
// Staggered image placeholders
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Image(
modifier = Modifier
.weight(1f)
.aspectRatio(3f / 4f)
.offset(y = (-16).dp)
.clip(RoundedCornerShape(16.dp)),
painter = painterResource(R.drawable.kerajinan),
contentDescription = stringResource(R.string.home_craftsmanship),
contentScale = ContentScale.Crop,
)
Image(
modifier = Modifier
.weight(1f)
.aspectRatio(3f / 4f)
.offset(y = 16.dp)
.clip(RoundedCornerShape(16.dp)),
painter = painterResource(R.drawable.tech),
contentDescription = stringResource(R.string.home_tech_interface),
contentScale = ContentScale.Crop,
)
}
}
}
@Composable
private fun PillarItem(
icon: ImageVector,
iconTint: Color,
iconBg: Color,
title: String,
desc: String,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.Top,
) {
Box(
modifier = Modifier
.size(52.dp)
.clip(RoundedCornerShape(14.dp))
.background(iconBg),
contentAlignment = Alignment.Center,
) {
Icon(icon, contentDescription = null, tint = iconTint, modifier = Modifier.size(26.dp))
}
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = title,
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
color = OnSurface,
)
Text(
text = desc,
style = MaterialTheme.typography.bodySmall,
color = OnSurfaceVariant,
lineHeight = 20.sp,
)
}
}
}
// ─── Section 3: Who Uses ──────────────────────────────────────────────────────
@Composable
private fun WhoUsesSection() {
Column(
modifier = Modifier
.fillMaxWidth()
.background(SurfaceContainerHighest.copy(alpha = 0.30f))
.padding(horizontal = 24.dp)
.padding(top = 32.dp, bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
Text(
text = buildAnnotatedString {
append(stringResource(R.string.home_who_uses_prefix))
withStyle(SpanStyle(color = BrandRed)) { append(stringResource(R.string.home_what_is_highlight)) }
},
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.ExtraBold,
fontSize = 24.sp,
color = OnSurface,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
// 2x2 grid
val users = listOf(
Triple(Icons.Outlined.Storefront, BrandRed, stringResource(R.string.home_msmes)),
Triple(Icons.Outlined.Groups, AccentBlue, stringResource(R.string.home_aggregators)),
Triple(Icons.Outlined.RocketLaunch, AccentPurple,stringResource(R.string.home_exporters)),
Triple(Icons.Outlined.BusinessCenter, OnSurface, stringResource(R.string.home_business_owners)),
)
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
users.chunked(2).forEach { pair ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
pair.forEach { (icon, tint, label) ->
WhoUsesCard(
modifier = Modifier.weight(1f),
icon = icon,
iconTint = tint,
label = label,
)
}
}
}
}
}
}
@Composable
private fun WhoUsesCard(
icon: ImageVector,
iconTint: Color,
label: String,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.clip(RoundedCornerShape(16.dp))
.background(SurfaceContainerLowest)
.padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(icon, contentDescription = null, tint = iconTint, modifier = Modifier.size(36.dp))
Text(
text = label,
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 14.sp,
color = OnSurface,
textAlign = TextAlign.Center,
)
}
}
// ─── Section 4: Case Studies ──────────────────────────────────────────────────
@Composable
private fun CaseStudiesSection() {
Column(
modifier = Modifier
.fillMaxWidth()
.background(Background)
.padding(horizontal = 24.dp)
.padding(top = 36.dp, bottom = 36.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = stringResource(R.string.home_case_studies),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = BrandRed,
letterSpacing = 2.sp,
)
Text(
text = stringResource(R.string.home_real_world_examples),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.ExtraBold,
fontSize = 26.sp,
color = OnSurface,
)
}
CaseStudyCard(
overlayColor = AccentBlue.copy(alpha = 0.40f),
title = stringResource(R.string.home_case_coffee_title),
desc = stringResource(R.string.home_case_coffee_desc),
imageRes = R.drawable.restoran,
imageLabel = stringResource(R.string.home_case_coffee_label),
)
CaseStudyCard(
overlayColor = BrandRed.copy(alpha = 0.40f),
title = stringResource(R.string.home_case_restaurant_title),
desc = stringResource(R.string.home_case_restaurant_desc),
imageRes = R.drawable.coffee,
imageLabel = stringResource(R.string.home_case_restaurant_label),
)
}
}
@Composable
private fun CaseStudyCard(
overlayColor: Color,
title: String,
desc: String,
imageRes: Int,
imageLabel: String,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(),
onClick = {},
),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
// Image with overlay
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.clip(RoundedCornerShape(20.dp)),
) {
Image(
modifier = Modifier.fillMaxSize(),
painter = painterResource(imageRes),
contentDescription = imageLabel,
contentScale = ContentScale.Crop,
)
Box(
modifier = Modifier
.fillMaxSize()
.background(overlayColor),
)
}
Text(
text = title,
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
color = OnSurface,
)
Text(
text = desc,
style = MaterialTheme.typography.bodyMedium,
color = OnSurfaceVariant,
lineHeight = 22.sp,
)
}
}
// ─── Section 5: Latest News ───────────────────────────────────────────────────
@Composable
private fun LatestNewsSection(
articles: List<NewsArticle>,
isLoading: Boolean,
onNewsClick: (NewsArticle) -> Unit,
onViewAll: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(SurfaceContainerLow)
.padding(horizontal = 24.dp)
.padding(top = 32.dp, bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(R.string.home_latest_news),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.ExtraBold,
fontSize = 22.sp,
color = OnSurface,
)
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(bounded = false),
onClick = onViewAll,
),
) {
Text(
text = stringResource(R.string.home_view_all),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = BrandRed,
)
Icon(Icons.Outlined.OpenInNew, contentDescription = null, tint = BrandRed, modifier = Modifier.size(14.dp))
}
}
when {
isLoading -> {
Box(
modifier = Modifier.fillMaxWidth().height(120.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(color = BrandRed, modifier = Modifier.size(32.dp))
}
}
articles.isEmpty() -> {
Text(
text = stringResource(R.string.home_no_news),
style = MaterialTheme.typography.bodyMedium,
color = OnSurfaceVariant,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
)
}
else -> {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
articles.forEach { article ->
NewsCard(article = article, onClick = { onNewsClick(article) })
}
}
}
}
}
}
@Composable
private fun NewsCard(article: NewsArticle, onClick: () -> Unit) {
val summary = article.summary?.takeIf { it.isNotBlank() } ?: article.subtitle.orEmpty()
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(SurfaceContainerLowest)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(),
onClick = onClick,
)
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (!article.category.isNullOrBlank()) {
Text(
text = article.category.uppercase(),
style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp),
fontWeight = FontWeight.Bold,
color = OnSurfaceVariant,
letterSpacing = 1.sp,
)
}
Text(
text = article.title,
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 15.sp,
color = OnSurface,
lineHeight = 22.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
if (summary.isNotBlank()) {
Text(
text = summary,
style = MaterialTheme.typography.bodySmall,
color = OnSurfaceVariant,
lineHeight = 20.sp,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
}
}
// ─── Shared: Image Placeholder ────────────────────────────────────────────────
@Composable
private fun ImagePlaceholder(
modifier: Modifier = Modifier,
label: String = "",
) {
Box(
modifier = modifier.background(SurfaceContainerHighest),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(
Icons.Outlined.Image,
contentDescription = null,
tint = OnSurfaceVariant.copy(alpha = 0.35f),
modifier = Modifier.size(32.dp),
)
if (label.isNotEmpty()) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = OnSurfaceVariant.copy(alpha = 0.45f),
)
}
}
}
}

View File

@ -0,0 +1,50 @@
package id.iiyh.inatrading.feature.home.presentation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import id.iiyh.inatrading.feature.news.data.model.NewsArticle
import id.iiyh.inatrading.feature.news.domain.NewsRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
data class HomeUiState(
val latestNews: List<NewsArticle> = emptyList(),
val isLoadingNews: Boolean = false,
val newsError: String? = null,
)
@HiltViewModel
class HomeViewModel @Inject constructor(
private val newsRepository: NewsRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(HomeUiState())
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
init {
loadLatestNews()
}
fun loadLatestNews() {
viewModelScope.launch {
_uiState.update { it.copy(isLoadingNews = true, newsError = null) }
newsRepository.getArticles()
.onSuccess { articles ->
_uiState.update {
it.copy(
isLoadingNews = false,
latestNews = articles.take(3),
)
}
}
.onFailure { e ->
_uiState.update { it.copy(isLoadingNews = false, newsError = e.message) }
}
}
}
}

View File

@ -0,0 +1,455 @@
package id.iiyh.inatrading.feature.info.presentation
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Dataset
import androidx.compose.material.icons.outlined.HeadsetMic
import androidx.compose.material.icons.outlined.Public
import androidx.compose.material.icons.outlined.Security
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import id.iiyh.inatrading.R
import id.iiyh.inatrading.core.ui.components.InaInnerTopAppBar
import id.iiyh.inatrading.core.ui.components.InaPrimaryButton
import id.iiyh.inatrading.core.ui.components.InaSecondaryButton
import id.iiyh.inatrading.core.ui.components.InaTertiaryButton
import id.iiyh.inatrading.core.ui.theme.AccentBlue
import id.iiyh.inatrading.core.ui.theme.AccentPurple
import id.iiyh.inatrading.core.ui.theme.AccentPurpleContainer
import id.iiyh.inatrading.core.ui.theme.Background
import id.iiyh.inatrading.core.ui.theme.BrandNavy
import id.iiyh.inatrading.core.ui.theme.BrandRed
import id.iiyh.inatrading.core.ui.theme.BrandRedLight
import id.iiyh.inatrading.core.ui.theme.InterFontFamily
import id.iiyh.inatrading.core.ui.theme.ManropeFontFamily
import id.iiyh.inatrading.core.ui.theme.OnSurface
import id.iiyh.inatrading.core.ui.theme.OnSurfaceVariant
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerHighest
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerLow
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerLowest
private const val WA_SUPPORT_NUMBER = ""
@Composable
fun AboutScreen(
onBack: () -> Unit,
) {
val context = LocalContext.current
Scaffold(
topBar = { InaInnerTopAppBar(onBack = onBack) },
containerColor = Background,
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.verticalScroll(rememberScrollState()),
) {
// ── Hero Section ──────────────────────────────────────────────
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(top = 24.dp, bottom = 8.dp),
) {
// Decorative slope — right side
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.offset(x = 24.dp)
.width(220.dp)
.height(200.dp)
.clip(RoundedCornerShape(topStart = 0.dp, topEnd = 0.dp, bottomStart = 64.dp, bottomEnd = 0.dp))
.background(SurfaceContainerHighest.copy(alpha = 0.50f))
)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = stringResource(R.string.about_label).uppercase(),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
color = BrandRed,
letterSpacing = 1.5.sp,
)
Text(
text = buildAnnotatedString {
withStyle(SpanStyle(color = OnSurface)) {
append(stringResource(R.string.about_headline_part1))
append(" ")
}
withStyle(SpanStyle(color = BrandRed)) {
append(stringResource(R.string.about_headline_part2))
}
withStyle(SpanStyle(color = OnSurface)) {
append(" ")
append(stringResource(R.string.about_headline_part3))
}
},
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.ExtraBold,
fontSize = 38.sp,
lineHeight = 46.sp,
)
Text(
text = stringResource(R.string.about_intro),
fontFamily = InterFontFamily,
fontSize = 15.sp,
color = OnSurfaceVariant,
lineHeight = 24.sp,
)
}
}
Spacer(modifier = Modifier.height(24.dp))
// ── Bento Grid ────────────────────────────────────────────────
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
// Row 1: Large card (Secure Transactions) + Small card (Digital Integration)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
// Large — Secure Transactions
Box(
modifier = Modifier
.weight(1.6f)
.clip(RoundedCornerShape(16.dp))
.background(SurfaceContainerLowest)
.padding(20.dp),
) {
// Blur decoration
Box(
modifier = Modifier
.size(100.dp)
.align(Alignment.BottomEnd)
.offset(x = 20.dp, y = 20.dp)
.background(BrandRed.copy(alpha = 0.06f), CircleShape)
)
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
BentoIcon(icon = Icons.Outlined.Security, bg = BrandRed.copy(alpha = 0.10f), tint = BrandRed)
Text(
text = stringResource(R.string.about_feature1_title),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
color = OnSurface,
)
Text(
text = stringResource(R.string.about_feature1_desc),
style = MaterialTheme.typography.bodySmall,
color = OnSurfaceVariant,
lineHeight = 18.sp,
)
}
}
// Small — Digital Integration
Column(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(16.dp))
.background(AccentBlue.copy(alpha = 0.12f))
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
BentoIcon(icon = Icons.Outlined.Dataset, bg = AccentBlue.copy(alpha = 0.15f), tint = AccentBlue)
Text(
text = stringResource(R.string.about_feature2_title),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 17.sp,
color = BrandNavy,
)
Text(
text = stringResource(R.string.about_feature2_desc),
style = MaterialTheme.typography.bodySmall,
color = BrandNavy.copy(alpha = 0.70f),
lineHeight = 18.sp,
)
}
}
// Row 2: Small card (Global Reach) + Image card
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
// Small — Global Reach
Column(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(16.dp))
.background(AccentPurple)
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
BentoIcon(icon = Icons.Outlined.Public, bg = Color.White.copy(alpha = 0.20f), tint = Color.White)
Text(
text = stringResource(R.string.about_feature3_title),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 17.sp,
color = Color.White,
)
Text(
text = stringResource(R.string.about_feature3_desc),
style = MaterialTheme.typography.bodySmall,
color = Color.White.copy(alpha = 0.85f),
lineHeight = 18.sp,
)
}
// Image placeholder + quote
Box(
modifier = Modifier
.weight(1.6f)
.height(180.dp)
.clip(RoundedCornerShape(16.dp))
.background(SurfaceContainerHighest),
) {
// Placeholder label
Text(
text = "[ Office Image ]",
style = MaterialTheme.typography.bodySmall,
color = OnSurfaceVariant.copy(alpha = 0.4f),
modifier = Modifier.align(Alignment.Center),
)
// Gradient overlay + quote
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.55f)),
)
),
contentAlignment = Alignment.BottomStart,
) {
Text(
text = stringResource(R.string.about_image_quote),
style = MaterialTheme.typography.bodySmall,
color = Color.White,
fontFamily = InterFontFamily,
modifier = Modifier.padding(16.dp),
)
}
}
}
}
Spacer(modifier = Modifier.height(28.dp))
// ── Mission Statement ─────────────────────────────────────────
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.clip(RoundedCornerShape(16.dp))
.background(SurfaceContainerLow)
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
Text(
text = stringResource(R.string.about_mission_title),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 28.sp,
color = OnSurface,
)
Text(
text = stringResource(R.string.about_mission_body),
fontFamily = InterFontFamily,
fontSize = 15.sp,
color = OnSurfaceVariant,
lineHeight = 26.sp,
)
// TODO: wire Learn More
InaPrimaryButton(
text = stringResource(R.string.about_mission_cta),
onClick = { /* TODO */ },
)
// Stats grid
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
StatCard(
modifier = Modifier.weight(1f),
value = "500+",
label = stringResource(R.string.about_stat1_label),
valueColor = BrandRed,
)
StatCard(
modifier = Modifier.weight(1f),
value = "12k+",
label = stringResource(R.string.about_stat2_label),
valueColor = AccentBlue,
)
}
}
Spacer(modifier = Modifier.height(28.dp))
// ── CTA Section ───────────────────────────────────────────────
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 40.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(percent = 50))
.background(AccentPurpleContainer.copy(alpha = 0.25f))
.padding(horizontal = 16.dp, vertical = 6.dp),
) {
Text(
text = stringResource(R.string.about_cta_label).uppercase(),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
color = AccentPurple,
letterSpacing = 1.5.sp,
)
}
Text(
text = stringResource(R.string.about_cta_title),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 24.sp,
color = OnSurface,
textAlign = TextAlign.Center,
lineHeight = 32.sp,
)
Text(
text = stringResource(R.string.about_cta_desc),
style = MaterialTheme.typography.bodyMedium,
color = OnSurfaceVariant,
textAlign = TextAlign.Center,
lineHeight = 22.sp,
)
Spacer(modifier = Modifier.height(4.dp))
// Register as Seller — placeholder
InaPrimaryButton(
text = stringResource(R.string.about_cta_register),
onClick = { /* TODO: navigate to seller register */ },
)
// Contact Support — WA
InaSecondaryButton(
text = stringResource(R.string.about_cta_contact),
onClick = {
if (WA_SUPPORT_NUMBER.isNotEmpty()) {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://wa.me/$WA_SUPPORT_NUMBER"))
context.startActivity(intent)
}
},
)
}
}
}
}
@Composable
private fun BentoIcon(
icon: ImageVector,
bg: Color,
tint: Color,
) {
Box(
modifier = Modifier
.size(44.dp)
.clip(RoundedCornerShape(10.dp))
.background(bg),
contentAlignment = Alignment.Center,
) {
Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(22.dp))
}
}
@Composable
private fun StatCard(
value: String,
label: String,
valueColor: Color,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.clip(RoundedCornerShape(12.dp))
.background(SurfaceContainerLowest)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = value,
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.ExtraBold,
fontSize = 32.sp,
color = valueColor,
)
Text(
text = label.uppercase(),
style = MaterialTheme.typography.labelSmall,
color = OnSurfaceVariant,
letterSpacing = 1.sp,
textAlign = TextAlign.Center,
)
}
}

View File

@ -0,0 +1,665 @@
package id.iiyh.inatrading.feature.info.presentation
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.Chat
import androidx.compose.material.icons.outlined.AccountBalanceWallet
import androidx.compose.material.icons.outlined.ChevronRight
import androidx.compose.material.icons.outlined.Email
import androidx.compose.material.icons.outlined.LocalShipping
import androidx.compose.material.icons.outlined.Payments
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Shield
import androidx.compose.material.icons.outlined.ShoppingBag
import androidx.compose.material.icons.outlined.Store
import androidx.compose.material.icons.outlined.Warehouse
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import id.iiyh.inatrading.R
import id.iiyh.inatrading.core.ui.components.InaInnerTopAppBar
import id.iiyh.inatrading.core.ui.theme.AccentBlue
import id.iiyh.inatrading.core.ui.theme.AccentBlueContainer
import id.iiyh.inatrading.core.ui.theme.AccentPurple
import id.iiyh.inatrading.core.ui.theme.AccentPurpleContainer
import id.iiyh.inatrading.core.ui.theme.Background
import id.iiyh.inatrading.core.ui.theme.BrandRed
import id.iiyh.inatrading.core.ui.theme.BrandRedContainer
import id.iiyh.inatrading.core.ui.theme.ManropeFontFamily
import id.iiyh.inatrading.core.ui.theme.OnAccentBlueContainer
import id.iiyh.inatrading.core.ui.theme.OnSurface
import id.iiyh.inatrading.core.ui.theme.OnSurfaceVariant
import id.iiyh.inatrading.core.ui.theme.OutlineVariant
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerHighest
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerLow
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerLowest
private data class HelpCategory(
val icon: ImageVector,
val title: String,
val body: String,
val iconContainer: Color,
val iconTint: Color,
)
private data class HelpFaq(
val question: String,
val answer: String,
)
private const val HELP_CENTER_WHATSAPP_NUMBER = "6281181190222"
private const val HELP_CENTER_SUPPORT_EMAIL = "marketing@inatrading.co.id"
@Composable
fun HelpCenterScreen(
onBack: () -> Unit,
) {
var query by rememberSaveable { mutableStateOf("") }
val sidebarEntries = listOf(
Icons.Outlined.Warehouse to stringResource(R.string.help_center_sidebar_orders),
Icons.Outlined.Payments to stringResource(R.string.help_center_sidebar_payment),
Icons.Outlined.LocalShipping to stringResource(R.string.help_center_sidebar_shipping),
Icons.Outlined.Person to stringResource(R.string.help_center_sidebar_account),
)
val categories = listOf(
HelpCategory(
icon = Icons.Outlined.ShoppingBag,
title = stringResource(R.string.help_center_cat_orders_title),
body = stringResource(R.string.help_center_cat_orders_body),
iconContainer = BrandRedContainer.copy(alpha = 0.25f),
iconTint = BrandRed,
),
HelpCategory(
icon = Icons.Outlined.AccountBalanceWallet,
title = stringResource(R.string.help_center_cat_payment_title),
body = stringResource(R.string.help_center_cat_payment_body),
iconContainer = AccentBlueContainer.copy(alpha = 0.45f),
iconTint = AccentBlue,
),
HelpCategory(
icon = Icons.Outlined.Shield,
title = stringResource(R.string.help_center_cat_account_title),
body = stringResource(R.string.help_center_cat_account_body),
iconContainer = SurfaceContainerHighest,
iconTint = OnSurface,
),
HelpCategory(
icon = Icons.Outlined.Store,
title = stringResource(R.string.help_center_cat_merchant_title),
body = stringResource(R.string.help_center_cat_merchant_body),
iconContainer = BrandRedContainer.copy(alpha = 0.22f),
iconTint = BrandRed,
),
)
val faqs = listOf(
HelpFaq(
question = stringResource(R.string.help_center_faq_track_question),
answer = stringResource(R.string.help_center_faq_track_answer),
),
HelpFaq(
question = stringResource(R.string.help_center_faq_payment_question),
answer = stringResource(R.string.help_center_faq_payment_answer),
),
HelpFaq(
question = stringResource(R.string.help_center_faq_refund_question),
answer = stringResource(R.string.help_center_faq_refund_answer),
),
HelpFaq(
question = stringResource(R.string.help_center_faq_address_question),
answer = stringResource(R.string.help_center_faq_address_answer),
),
)
val normalizedQuery = query.trim().lowercase()
val filteredCategories = categories.filter {
normalizedQuery.isBlank() ||
it.title.lowercase().contains(normalizedQuery) ||
it.body.lowercase().contains(normalizedQuery)
}
val filteredFaqs = faqs.filter {
normalizedQuery.isBlank() ||
it.question.lowercase().contains(normalizedQuery) ||
it.answer.lowercase().contains(normalizedQuery)
}
Scaffold(
topBar = { InaInnerTopAppBar(onBack = onBack) },
containerColor = Background,
) { innerPadding ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.background(Background),
contentPadding = PaddingValues(top = 16.dp, bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
item {
HeroSection(
query = query,
onQueryChange = { query = it },
)
}
item {
SupportCategoriesCard(entries = sidebarEntries)
}
item {
ContactSupportCard()
}
item {
Column(
modifier = Modifier.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
SectionHeader(
title = stringResource(R.string.help_center_main_categories),
action = stringResource(R.string.profile_view_all),
)
filteredCategories.forEach { category ->
HelpCategoryCard(category = category)
}
}
}
item {
FaqSection(faqs = filteredFaqs)
}
item {
FooterCaption()
}
}
}
}
@Composable
private fun HeroSection(
query: String,
onQueryChange: (String) -> Unit,
) {
Box(
modifier = Modifier
.fillMaxWidth()
.background(SurfaceContainerHighest.copy(alpha = 0.24f)),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 24.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
Text(
text = stringResource(R.string.help_center_eyebrow).uppercase(),
style = MaterialTheme.typography.labelSmall,
color = BrandRed,
fontWeight = FontWeight.Bold,
letterSpacing = 1.6.sp,
modifier = Modifier.align(Alignment.CenterHorizontally),
)
Text(
text = stringResource(R.string.help_center_title),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.ExtraBold,
fontSize = 26.sp,
lineHeight = 30.sp,
color = OnSurface,
textAlign = TextAlign.Center,
modifier = Modifier.align(Alignment.CenterHorizontally),
)
Surface(
color = SurfaceContainerLowest,
shape = RoundedCornerShape(16.dp),
shadowElevation = 6.dp,
) {
OutlinedTextField(
value = query,
onValueChange = onQueryChange,
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(16.dp),
placeholder = {
Text(
text = stringResource(R.string.help_center_search_placeholder),
color = OnSurfaceVariant.copy(alpha = 0.62f),
)
},
leadingIcon = {
Icon(
imageVector = Icons.Outlined.Search,
contentDescription = null,
tint = OnSurfaceVariant,
)
},
)
}
}
}
}
@Composable
private fun SupportCategoriesCard(
entries: List<Pair<ImageVector, String>>,
) {
Surface(
modifier = Modifier.padding(horizontal = 16.dp),
color = SurfaceContainerLowest,
shape = RoundedCornerShape(18.dp),
shadowElevation = 2.dp,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
text = stringResource(R.string.help_center_sidebar_title),
fontWeight = FontWeight.Medium,
color = OnSurface,
)
entries.forEachIndexed { index, (icon, label) ->
Row(
modifier = Modifier
.fillMaxWidth()
.background(
if (index == 0) SurfaceContainerLow else Color.Transparent,
RoundedCornerShape(12.dp),
)
.padding(horizontal = 12.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = if (index == 0) BrandRed else OnSurfaceVariant,
modifier = Modifier.size(18.dp),
)
Text(
text = label,
color = if (index == 0) BrandRed else OnSurface,
fontWeight = if (index == 0) FontWeight.SemiBold else FontWeight.Normal,
)
}
}
}
}
}
@Composable
private fun ContactSupportCard() {
val context = LocalContext.current
Surface(
modifier = Modifier.padding(horizontal = 16.dp),
color = AccentPurple,
shape = RoundedCornerShape(18.dp),
shadowElevation = 8.dp,
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
text = stringResource(R.string.help_center_contact_title),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.ExtraBold,
fontSize = 24.sp,
color = Color.White,
)
Text(
text = stringResource(R.string.help_center_contact_body),
color = Color.White.copy(alpha = 0.92f),
style = MaterialTheme.typography.bodySmall,
)
SupportButton(
icon = Icons.AutoMirrored.Outlined.Chat,
text = stringResource(R.string.help_center_contact_whatsapp),
containerColor = Color.White,
contentColor = AccentPurple,
onClick = {
val intent = Intent(
Intent.ACTION_VIEW,
Uri.parse("https://wa.me/$HELP_CENTER_WHATSAPP_NUMBER")
)
context.startActivity(intent)
},
)
SupportButton(
icon = Icons.Outlined.Email,
text = stringResource(R.string.help_center_contact_email),
containerColor = Color.White.copy(alpha = 0.12f),
contentColor = Color.White,
onClick = {
val intent = Intent(Intent.ACTION_SENDTO).apply {
data = Uri.parse("mailto:$HELP_CENTER_SUPPORT_EMAIL")
}
context.startActivity(intent)
},
)
}
Icon(
imageVector = Icons.Outlined.ChevronRight,
contentDescription = null,
tint = Color.White.copy(alpha = 0.08f),
modifier = Modifier
.align(Alignment.BottomEnd)
.size(68.dp),
)
}
}
}
@Composable
private fun SupportButton(
icon: ImageVector,
text: String,
containerColor: Color,
contentColor: Color,
onClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.background(containerColor, RoundedCornerShape(12.dp))
.padding(horizontal = 14.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = contentColor,
modifier = Modifier.size(16.dp),
)
Spacer(modifier = Modifier.size(8.dp))
Text(
text = text,
color = contentColor,
fontWeight = FontWeight.SemiBold,
)
}
}
@Composable
private fun SectionHeader(
title: String,
action: String,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = title,
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.ExtraBold,
fontSize = 20.sp,
color = OnSurface,
)
Text(
text = action,
color = BrandRed,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
)
}
}
@Composable
private fun HelpCategoryCard(
category: HelpCategory,
) {
Surface(
color = SurfaceContainerLowest,
shape = RoundedCornerShape(16.dp),
shadowElevation = 1.dp,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.Top,
) {
Box(
modifier = Modifier
.size(42.dp)
.background(category.iconContainer, RoundedCornerShape(12.dp)),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = category.icon,
contentDescription = null,
tint = category.iconTint,
modifier = Modifier.size(20.dp),
)
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = category.title,
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 18.sp,
lineHeight = 22.sp,
color = OnSurface,
)
Text(
text = category.body,
color = OnSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}
@Composable
private fun FaqSection(
faqs: List<HelpFaq>,
) {
Surface(
modifier = Modifier.padding(horizontal = 16.dp),
color = SurfaceContainerLow,
shape = RoundedCornerShape(20.dp),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = stringResource(R.string.help_center_faq_title),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.ExtraBold,
fontSize = 20.sp,
lineHeight = 26.sp,
color = OnSurface,
)
faqs.forEachIndexed { index, faq ->
FaqCard(
faq = faq,
expandedByDefault = index == 0,
)
}
CalloutCard()
}
}
}
@Composable
private fun FaqCard(
faq: HelpFaq,
expandedByDefault: Boolean = false,
) {
var expanded by remember { mutableStateOf(expandedByDefault) }
Surface(
color = SurfaceContainerLowest,
shape = RoundedCornerShape(14.dp),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.clickable { expanded = !expanded }
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = faq.question,
fontWeight = FontWeight.Medium,
fontSize = 16.sp,
lineHeight = 22.sp,
color = OnSurface,
modifier = Modifier.weight(1f),
)
Spacer(modifier = Modifier.size(12.dp))
Icon(
imageVector = Icons.Outlined.ChevronRight,
contentDescription = null,
tint = BrandRed,
modifier = Modifier.size(18.dp),
)
}
if (expanded) {
Text(
text = faq.answer,
color = OnSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}
@Composable
private fun CalloutCard() {
Surface(
color = SurfaceContainerLowest,
shape = RoundedCornerShape(16.dp),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 22.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
modifier = Modifier
.size(46.dp)
.background(AccentBlueContainer.copy(alpha = 0.5f), CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Outlined.Person,
contentDescription = null,
tint = OnAccentBlueContainer,
modifier = Modifier.size(18.dp),
)
}
Text(
text = stringResource(R.string.help_center_callout_title),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 22.sp,
lineHeight = 26.sp,
textAlign = TextAlign.Center,
color = OnSurface,
)
Text(
text = stringResource(R.string.help_center_callout_body),
color = OnSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center,
)
Box(
modifier = Modifier
.background(BrandRed, RoundedCornerShape(12.dp))
.padding(horizontal = 20.dp, vertical = 12.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(R.string.help_center_callout_action),
color = Color.White,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
)
}
}
}
}
@Composable
private fun FooterCaption() {
Box(
modifier = Modifier
.fillMaxWidth()
.background(SurfaceContainerHighest.copy(alpha = 0.28f))
.padding(horizontal = 16.dp, vertical = 28.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(R.string.help_center_footer),
color = OnSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center,
)
}
}

View File

@ -0,0 +1,386 @@
package id.iiyh.inatrading.feature.info.presentation
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Analytics
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.VerifiedUser
import androidx.compose.material.icons.outlined.SettingsAccessibility
import androidx.compose.material.icons.outlined.Update
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import id.iiyh.inatrading.R
import id.iiyh.inatrading.core.ui.components.InaInnerTopAppBar
import id.iiyh.inatrading.core.ui.components.InaPrimaryButton
import id.iiyh.inatrading.core.ui.components.InaSecondaryButton
import id.iiyh.inatrading.core.ui.theme.AccentBlue
import id.iiyh.inatrading.core.ui.theme.AccentPurple
import id.iiyh.inatrading.core.ui.theme.AccentPurpleContainer
import id.iiyh.inatrading.core.ui.theme.Background
import id.iiyh.inatrading.core.ui.theme.BrandNavy
import id.iiyh.inatrading.core.ui.theme.BrandRed
import id.iiyh.inatrading.core.ui.theme.ManropeFontFamily
import id.iiyh.inatrading.core.ui.theme.OnSurface
import id.iiyh.inatrading.core.ui.theme.OnSurfaceVariant
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerLow
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerLowest
private const val PRIVACY_EMAIL = "privacy@inatrading.com"
@Composable
fun PrivacyPolicyScreen(
onBack: () -> Unit,
onFullTermsClick: () -> Unit,
) {
val context = LocalContext.current
Scaffold(
topBar = { InaInnerTopAppBar(onBack = onBack) },
containerColor = Background,
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp)
.padding(bottom = 40.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
Spacer(modifier = Modifier.height(8.dp))
// ── Hero ──────────────────────────────────────────────────────
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
text = stringResource(R.string.privacy_label).uppercase(),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = AccentBlue,
letterSpacing = 1.5.sp,
)
Text(
text = stringResource(R.string.privacy_title),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.ExtraBold,
fontSize = 36.sp,
color = OnSurface,
lineHeight = 44.sp,
)
Text(
text = stringResource(R.string.privacy_intro),
style = MaterialTheme.typography.bodyMedium,
color = OnSurfaceVariant,
lineHeight = 22.sp,
)
// Date pill
Row(
modifier = Modifier
.clip(RoundedCornerShape(percent = 50))
.background(SurfaceContainerLow)
.padding(horizontal = 14.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(
Icons.Outlined.Update,
contentDescription = null,
tint = OnSurfaceVariant,
modifier = Modifier.size(16.dp),
)
Text(
text = stringResource(R.string.privacy_last_updated),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Medium,
color = OnSurfaceVariant,
)
}
}
// ── Card 1: Data Collection ───────────────────────────────────
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(SurfaceContainerLowest)
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Box(
modifier = Modifier
.size(48.dp)
.clip(RoundedCornerShape(12.dp))
.background(BrandRed.copy(alpha = 0.12f)),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Filled.Analytics, contentDescription = null, tint = BrandRed, modifier = Modifier.size(24.dp))
}
Text(
text = stringResource(R.string.privacy_section1_title),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
color = OnSurface,
)
Text(
text = stringResource(R.string.privacy_section1_body),
style = MaterialTheme.typography.bodyMedium,
color = OnSurfaceVariant,
lineHeight = 22.sp,
)
PrivacyCheckItem(
bold = stringResource(R.string.privacy_check1_bold),
rest = stringResource(R.string.privacy_check1_rest),
)
PrivacyCheckItem(
bold = stringResource(R.string.privacy_check2_bold),
rest = stringResource(R.string.privacy_check2_rest),
)
}
// ── Card 2: Security Infrastructure ──────────────────────────
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(AccentBlue.copy(alpha = 0.12f))
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(Icons.Filled.VerifiedUser, contentDescription = null, tint = BrandNavy, modifier = Modifier.size(36.dp))
Text(
text = stringResource(R.string.privacy_section2_title),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 18.sp,
color = BrandNavy,
)
Text(
text = stringResource(R.string.privacy_section2_body),
style = MaterialTheme.typography.bodySmall,
color = BrandNavy.copy(alpha = 0.70f),
lineHeight = 20.sp,
)
Spacer(modifier = Modifier.height(4.dp))
// Progress bar
Box(
modifier = Modifier
.fillMaxWidth()
.height(6.dp)
.clip(RoundedCornerShape(percent = 50))
.background(BrandNavy.copy(alpha = 0.10f)),
) {
Box(
modifier = Modifier
.fillMaxWidth(0.75f)
.height(6.dp)
.clip(RoundedCornerShape(percent = 50))
.background(BrandNavy.copy(alpha = 0.60f)),
)
}
Text(
text = stringResource(R.string.privacy_section2_protocol).uppercase(),
style = MaterialTheme.typography.labelSmall,
color = BrandNavy.copy(alpha = 0.55f),
letterSpacing = 1.sp,
)
}
// ── Card 3: Third-party Sharing ───────────────────────────────
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(AccentPurpleContainer.copy(alpha = 0.35f))
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = stringResource(R.string.privacy_section3_title),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
color = OnSurface,
)
Text(
text = stringResource(R.string.privacy_section3_body),
style = MaterialTheme.typography.bodySmall,
color = OnSurfaceVariant,
lineHeight = 20.sp,
)
// Partner chips
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
listOf(
stringResource(R.string.privacy_chip_logistics),
stringResource(R.string.privacy_chip_payment),
stringResource(R.string.privacy_chip_compliance),
).forEach { label ->
Box(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(Color.White.copy(alpha = 0.50f))
.padding(horizontal = 10.dp, vertical = 6.dp),
) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = AccentPurple,
letterSpacing = 0.5.sp,
)
}
}
}
}
// ── Card 4: Your Privacy Rights ───────────────────────────────
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(SurfaceContainerLowest)
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
Box(
modifier = Modifier
.size(44.dp)
.clip(RoundedCornerShape(12.dp))
.background(AccentPurple.copy(alpha = 0.15f)),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Outlined.SettingsAccessibility, contentDescription = null, tint = AccentPurple, modifier = Modifier.size(22.dp))
}
Text(
text = stringResource(R.string.privacy_section4_title),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 18.sp,
color = OnSurface,
)
}
Text(
text = stringResource(R.string.privacy_section4_body),
style = MaterialTheme.typography.bodySmall,
color = OnSurfaceVariant,
lineHeight = 20.sp,
)
// TODO: wire to actual preferences screen
InaPrimaryButton(
text = stringResource(R.string.privacy_section4_cta),
onClick = { /* TODO */ },
)
}
// ── Card 5: Have questions? ───────────────────────────────────
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(SurfaceContainerLow)
.padding(start = 4.dp),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(topStart = 0.dp, topEnd = 16.dp, bottomStart = 0.dp, bottomEnd = 16.dp))
.background(SurfaceContainerLow)
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = stringResource(R.string.privacy_contact_title),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
color = OnSurface,
)
Text(
text = stringResource(R.string.privacy_contact_body),
style = MaterialTheme.typography.bodySmall,
color = OnSurfaceVariant,
lineHeight = 20.sp,
)
// Email Legal
InaPrimaryButton(
text = stringResource(R.string.privacy_contact_email),
onClick = {
val intent = Intent(Intent.ACTION_SENDTO).apply {
data = Uri.parse("mailto:$PRIVACY_EMAIL")
}
context.startActivity(intent)
},
)
// Full Terms
InaSecondaryButton(
text = stringResource(R.string.privacy_contact_terms),
onClick = onFullTermsClick,
)
}
}
}
}
}
@Composable
private fun PrivacyCheckItem(bold: String, rest: String) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top,
) {
Icon(
imageVector = Icons.Filled.CheckCircle,
contentDescription = null,
tint = AccentBlue,
modifier = Modifier
.size(20.dp)
.padding(top = 2.dp),
)
Text(
text = buildAnnotatedString {
withStyle(SpanStyle(fontWeight = FontWeight.Bold, color = OnSurface)) {
append(bold)
}
withStyle(SpanStyle(color = OnSurfaceVariant)) {
append(" $rest")
}
},
style = MaterialTheme.typography.bodyMedium,
lineHeight = 22.sp,
)
}
}

View File

@ -0,0 +1,373 @@
package id.iiyh.inatrading.feature.info.presentation
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CheckCircle
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import id.iiyh.inatrading.R
import id.iiyh.inatrading.core.ui.components.InaInnerTopAppBar
import id.iiyh.inatrading.core.ui.components.InaPrimaryButton
import id.iiyh.inatrading.core.ui.theme.AccentBlue
import id.iiyh.inatrading.core.ui.theme.AccentPurple
import id.iiyh.inatrading.core.ui.theme.AccentPurpleContainer
import id.iiyh.inatrading.core.ui.theme.Background
import id.iiyh.inatrading.core.ui.theme.BrandRed
import id.iiyh.inatrading.core.ui.theme.BrandRedLight
import id.iiyh.inatrading.core.ui.theme.ManropeFontFamily
import id.iiyh.inatrading.core.ui.theme.OnSurface
import id.iiyh.inatrading.core.ui.theme.OnSurfaceVariant
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerHighest
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerLow
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerLowest
// TODO: set to real WA support number e.g. "628123456789"
private const val WA_SUPPORT_NUMBER = ""
@Composable
fun TermsConditionsScreen(
onBack: () -> Unit,
) {
val context = LocalContext.current
Scaffold(
topBar = { InaInnerTopAppBar(onBack = onBack) },
containerColor = Background,
) { innerPadding ->
Box(modifier = Modifier.fillMaxSize()) {
// Brand slope decorative background
Box(
modifier = Modifier
.offset(x = 80.dp, y = 160.dp)
.width(320.dp)
.height(600.dp)
.rotate(-2f)
.clip(RoundedCornerShape(48.dp))
.background(SurfaceContainerHighest.copy(alpha = 0.30f))
)
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp)
.padding(bottom = 40.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
Spacer(modifier = Modifier.height(12.dp))
// ── Hero ──────────────────────────────────────────────────────
Box(
modifier = Modifier
.clip(RoundedCornerShape(percent = 50))
.background(AccentPurpleContainer.copy(alpha = 0.25f))
.padding(horizontal = 16.dp, vertical = 6.dp),
) {
Text(
text = stringResource(R.string.terms_label).uppercase(),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
color = AccentPurple,
letterSpacing = 1.5.sp,
)
}
Text(
text = buildAnnotatedString {
withStyle(SpanStyle(color = OnSurface)) {
append(stringResource(R.string.terms_headline_part1))
append("\n")
}
withStyle(SpanStyle(color = BrandRed)) {
append(stringResource(R.string.terms_headline_part2))
}
},
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.ExtraBold,
fontSize = 42.sp,
lineHeight = 50.sp,
)
Text(
text = stringResource(R.string.terms_intro),
style = MaterialTheme.typography.bodyLarge,
color = OnSurfaceVariant,
lineHeight = 26.sp,
)
Spacer(modifier = Modifier.height(4.dp))
// ── Section 01 ────────────────────────────────────────────────
TermsArticle(
number = "01",
numberColor = BrandRed.copy(alpha = 0.20f),
title = stringResource(R.string.terms_section1_title),
accentColor = BrandRed,
content = {
Text(
text = stringResource(R.string.terms_section1_p1),
style = MaterialTheme.typography.bodyMedium,
color = OnSurfaceVariant,
lineHeight = 22.sp,
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(R.string.terms_section1_p2),
style = MaterialTheme.typography.bodyMedium,
color = OnSurfaceVariant,
lineHeight = 22.sp,
)
},
)
// ── Section 02 ────────────────────────────────────────────────
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(SurfaceContainerLow)
.padding(start = 4.dp),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(topStart = 0.dp, topEnd = 12.dp, bottomStart = 0.dp, bottomEnd = 12.dp))
.background(SurfaceContainerLow)
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = "02",
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.ExtraBold,
fontSize = 32.sp,
color = AccentBlue.copy(alpha = 0.20f),
)
Text(
text = stringResource(R.string.terms_section2_title),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
color = OnSurface,
)
}
Text(
text = stringResource(R.string.terms_section2_p1),
style = MaterialTheme.typography.bodyMedium,
color = OnSurfaceVariant,
lineHeight = 22.sp,
)
// Checklist items
ChecklistItem(text = stringResource(R.string.terms_section2_check1))
ChecklistItem(text = stringResource(R.string.terms_section2_check2))
// Image placeholder
Box(
modifier = Modifier
.fillMaxWidth()
.height(160.dp)
.clip(RoundedCornerShape(12.dp))
.background(SurfaceContainerHighest),
contentAlignment = Alignment.Center,
) {
Text(
text = "[ Payment Image ]",
style = MaterialTheme.typography.bodySmall,
color = OnSurfaceVariant.copy(alpha = 0.4f),
)
}
}
}
// ── Section 03 ────────────────────────────────────────────────
TermsArticle(
number = "03",
numberColor = AccentPurple.copy(alpha = 0.20f),
title = stringResource(R.string.terms_section3_title),
accentColor = AccentPurple,
content = {
Text(
text = stringResource(R.string.terms_section3_p1),
style = MaterialTheme.typography.bodyMedium,
color = OnSurfaceVariant,
lineHeight = 22.sp,
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(R.string.terms_section3_p2),
style = MaterialTheme.typography.bodyMedium,
color = OnSurfaceVariant,
lineHeight = 22.sp,
)
},
)
// ── Contact CTA ───────────────────────────────────────────────
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(SurfaceContainerHighest)
.padding(24.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.terms_contact_title),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
color = OnSurface,
)
Text(
text = stringResource(R.string.terms_contact_desc),
style = MaterialTheme.typography.bodySmall,
color = OnSurfaceVariant,
)
}
InaPrimaryButton(
text = stringResource(R.string.terms_contact_cta),
onClick = {
if (WA_SUPPORT_NUMBER.isNotEmpty()) {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://wa.me/$WA_SUPPORT_NUMBER"))
context.startActivity(intent)
}
},
modifier = Modifier.width(140.dp),
)
}
// ── Footer ────────────────────────────────────────────────────
Text(
text = stringResource(R.string.terms_footer),
style = MaterialTheme.typography.labelSmall,
color = OnSurfaceVariant.copy(alpha = 0.4f),
letterSpacing = 1.5.sp,
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp),
)
}
}
}
}
@Composable
private fun TermsArticle(
number: String,
numberColor: androidx.compose.ui.graphics.Color,
title: String,
accentColor: androidx.compose.ui.graphics.Color,
content: @Composable () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(SurfaceContainerLowest)
.padding(start = 4.dp),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(topStart = 0.dp, topEnd = 12.dp, bottomStart = 0.dp, bottomEnd = 12.dp))
.background(SurfaceContainerLowest)
.padding(24.dp),
) {
// Left accent bar
Box(
modifier = Modifier
.align(Alignment.TopStart)
.offset(x = (-24).dp)
.width(4.dp)
.height(40.dp)
.background(accentColor)
)
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = number,
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.ExtraBold,
fontSize = 32.sp,
color = numberColor,
)
Text(
text = title,
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
color = OnSurface,
)
}
content()
}
}
}
}
@Composable
private fun ChecklistItem(text: String) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top,
) {
Icon(
imageVector = Icons.Outlined.CheckCircle,
contentDescription = null,
tint = AccentBlue,
modifier = Modifier.size(18.dp).padding(top = 2.dp),
)
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = OnSurfaceVariant,
lineHeight = 22.sp,
)
}
}

View File

@ -0,0 +1,36 @@
package id.iiyh.inatrading.feature.news.data.model
import com.google.gson.annotations.SerializedName
data class NewsArticle(
val id: String,
val title: String,
val subtitle: String? = null,
val summary: String? = null,
val category: String? = null,
val reporter: String? = null,
val section1: String? = null,
val section2: String? = null,
val section3: String? = null,
val image1: String? = null,
val image2: String? = null,
val image3: String? = null,
val image4: String? = null,
val image5: String? = null,
) {
/** summary jika ada, fallback ke subtitle */
val displaySummary: String get() = summary?.takeIf { it.isNotBlank() } ?: subtitle.orEmpty()
/** Semua gambar yang tidak null */
val images: List<String> get() = listOfNotNull(image1, image2, image3, image4, image5)
}
data class NewsListResponse(
val responseCode: String? = null,
val responseDesc: String? = null,
val rows: List<NewsArticle> = emptyList(),
val totalItem: Int = 0,
val totalPage: Int = 0,
) {
val isSuccess: Boolean get() = responseCode == "0000"
}

View File

@ -0,0 +1,24 @@
package id.iiyh.inatrading.feature.news.data.repository
import id.iiyh.inatrading.core.data.remote.ApiService
import id.iiyh.inatrading.feature.news.data.model.NewsArticle
import id.iiyh.inatrading.feature.news.domain.NewsRepository
import javax.inject.Inject
class NewsRepositoryImpl @Inject constructor(
private val apiService: ApiService,
) : NewsRepository {
override suspend fun getArticles(): Result<List<NewsArticle>> {
return try {
val response = apiService.getNewsArticles()
if (response.isSuccess) {
Result.success(response.rows)
} else {
Result.failure(Exception(response.responseDesc ?: "Gagal memuat berita"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
}

View File

@ -0,0 +1,7 @@
package id.iiyh.inatrading.feature.news.domain
import id.iiyh.inatrading.feature.news.data.model.NewsArticle
interface NewsRepository {
suspend fun getArticles(): Result<List<NewsArticle>>
}

View File

@ -0,0 +1,334 @@
package id.iiyh.inatrading.feature.news.presentation
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import id.iiyh.inatrading.R
import id.iiyh.inatrading.core.ui.components.InaInnerTopAppBar
import id.iiyh.inatrading.core.ui.theme.AccentPurple
import id.iiyh.inatrading.core.ui.theme.AccentPurpleContainer
import id.iiyh.inatrading.core.ui.theme.Background
import id.iiyh.inatrading.core.ui.theme.BrandRed
import id.iiyh.inatrading.core.ui.theme.ManropeFontFamily
import id.iiyh.inatrading.core.ui.theme.OnSurface
import id.iiyh.inatrading.core.ui.theme.OnSurfaceVariant
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerHighest
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerLow
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerLowest
import id.iiyh.inatrading.feature.news.data.model.NewsArticle
@Composable
fun NewsDetailScreen(
article: NewsArticle,
onBack: () -> Unit,
) {
Scaffold(
topBar = { InaInnerTopAppBar(onBack = onBack) },
containerColor = Background,
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.verticalScroll(rememberScrollState()),
) {
// ── Hero Image ────────────────────────────────────────────────────
if (article.image1 != null) {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f),
) {
AsyncImage(
model = article.image1,
contentDescription = article.title,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
// Gradient overlay
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color.Transparent,
Color.Black.copy(alpha = 0.75f),
),
startY = 0.3f,
)
)
)
// Title overlay at bottom of hero
Column(
modifier = Modifier
.align(Alignment.BottomStart)
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (!article.category.isNullOrBlank()) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(percent = 50))
.background(AccentPurpleContainer.copy(alpha = 0.85f))
.padding(horizontal = 12.dp, vertical = 4.dp),
) {
Text(
text = article.category.uppercase(),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = AccentPurple,
letterSpacing = 1.5.sp,
)
}
}
Text(
text = article.title,
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.ExtraBold,
fontSize = 22.sp,
color = Color.White,
lineHeight = 30.sp,
)
if (!article.subtitle.isNullOrBlank()) {
Text(
text = article.subtitle,
style = MaterialTheme.typography.bodyMedium,
color = Color.White.copy(alpha = 0.80f),
lineHeight = 22.sp,
)
}
}
}
} else {
// No image — title block with colored background
Box(
modifier = Modifier
.fillMaxWidth()
.background(SurfaceContainerHighest)
.padding(horizontal = 24.dp, vertical = 40.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
if (!article.category.isNullOrBlank()) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(percent = 50))
.background(AccentPurpleContainer)
.padding(horizontal = 12.dp, vertical = 4.dp),
) {
Text(
text = article.category.uppercase(),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = AccentPurple,
letterSpacing = 1.5.sp,
)
}
}
Text(
text = article.title,
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.ExtraBold,
fontSize = 24.sp,
color = OnSurface,
lineHeight = 32.sp,
)
if (!article.subtitle.isNullOrBlank()) {
Text(
text = article.subtitle,
style = MaterialTheme.typography.bodyMedium,
color = OnSurfaceVariant,
lineHeight = 22.sp,
)
}
}
}
}
// ── Content Body ──────────────────────────────────────────────────
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(top = 24.dp, bottom = 40.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
// Reporter card
if (!article.reporter.isNullOrBlank()) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(SurfaceContainerLow)
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Avatar initials
Box(
modifier = Modifier
.size(44.dp)
.background(BrandRed, CircleShape),
contentAlignment = Alignment.Center,
) {
Text(
text = article.reporter
.split(" ")
.take(2)
.mapNotNull { it.firstOrNull()?.uppercaseChar() }
.joinToString(""),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 14.sp,
color = Color.White,
)
}
Column {
Text(
text = stringResource(R.string.news_reporter),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = OnSurfaceVariant,
letterSpacing = 1.sp,
)
Text(
text = article.reporter,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
color = OnSurface,
)
}
}
}
// Summary / quote block
val summary = article.summary?.takeIf { it.isNotBlank() }
?: article.subtitle?.takeIf { it.isNotBlank() }
if (!summary.isNullOrBlank()) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
modifier = Modifier
.width(4.dp)
.height(80.dp)
.background(BrandRed, RoundedCornerShape(2.dp))
)
Text(
text = "\u201C$summary\u201D",
style = MaterialTheme.typography.bodyLarge,
fontStyle = FontStyle.Italic,
color = OnSurfaceVariant,
lineHeight = 28.sp,
)
}
}
// Section 1
if (!article.section1.isNullOrBlank()) {
Text(
text = article.section1,
style = MaterialTheme.typography.bodyLarge,
color = OnSurface,
lineHeight = 28.sp,
)
}
// Image 2 (inline)
if (article.image2 != null) {
AsyncImage(
model = article.image2,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.clip(RoundedCornerShape(12.dp)),
)
}
// Section 2
if (!article.section2.isNullOrBlank()) {
Text(
text = article.section2,
style = MaterialTheme.typography.bodyMedium,
color = OnSurface,
lineHeight = 26.sp,
)
}
// Section 3
if (!article.section3.isNullOrBlank()) {
Text(
text = article.section3,
style = MaterialTheme.typography.bodyMedium,
color = OnSurface,
lineHeight = 26.sp,
)
}
// Photo gallery (images 25 if multiple)
val galleryImages = listOfNotNull(article.image2, article.image3, article.image4, article.image5)
.filter { article.image2 == null || it != article.image2 } // skip image2 if already shown inline
.ifEmpty {
// if section2 wasn't null, image2 was shown inline, use remaining
listOfNotNull(article.image3, article.image4, article.image5)
}
if (galleryImages.isNotEmpty()) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
// Text(
// text = "Dokumentasi",
// fontFamily = ManropeFontFamily,
// fontWeight = FontWeight.Bold,
// fontSize = 18.sp,
// color = OnSurface,
// )
galleryImages.forEach { imageUrl ->
AsyncImage(
model = imageUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(4f / 3f)
.clip(RoundedCornerShape(12.dp)),
)
}
}
}
}
}
}
}

View File

@ -0,0 +1,644 @@
package id.iiyh.inatrading.feature.news.presentation
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowForward
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import coil.compose.AsyncImage
import id.iiyh.inatrading.R
import id.iiyh.inatrading.core.ui.components.InaInnerTopAppBar
import id.iiyh.inatrading.core.ui.theme.AccentBlue
import id.iiyh.inatrading.core.ui.theme.AccentBlueContainer
import id.iiyh.inatrading.core.ui.theme.AccentPurple
import id.iiyh.inatrading.core.ui.theme.AccentPurpleContainer
import id.iiyh.inatrading.core.ui.theme.Background
import id.iiyh.inatrading.core.ui.theme.BrandRed
import id.iiyh.inatrading.core.ui.theme.BrandRedContainer
import id.iiyh.inatrading.core.ui.theme.ManropeFontFamily
import id.iiyh.inatrading.core.ui.theme.OnSurface
import id.iiyh.inatrading.core.ui.theme.OnSurfaceVariant
import id.iiyh.inatrading.core.ui.theme.OutlineVariant
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerHighest
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerLow
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerLowest
import id.iiyh.inatrading.feature.news.data.model.NewsArticle
@Composable
fun NewsListScreen(
onBack: () -> Unit,
onArticleClick: (NewsArticle) -> Unit,
viewModel: NewsListViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsState()
Scaffold(
topBar = { InaInnerTopAppBar(onBack = onBack) },
containerColor = Background,
) { innerPadding ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.background(Background)
.padding(innerPadding),
) {
item {
NewsListHero()
}
when {
uiState.isLoading -> {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 64.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(color = BrandRed)
}
}
}
uiState.errorMessage != null -> {
item {
NewsListMessageCard(
title = stringResource(R.string.news_list_error_title),
body = uiState.errorMessage!!,
accent = AccentBlue,
)
}
}
uiState.articles.isEmpty() -> {
item {
NewsListMessageCard(
title = stringResource(R.string.news_list_empty_title),
body = stringResource(R.string.news_list_empty_body),
accent = AccentPurple,
)
}
}
else -> {
val articles = uiState.articles
item {
SectionTitle()
}
item {
FeaturedArticleCard(
article = articles.first(),
onClick = { onArticleClick(articles.first()) },
)
}
val secondary = articles.drop(1).take(2)
if (secondary.isNotEmpty()) {
item {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
secondary.forEach { article ->
CompactArticleCard(
article = article,
modifier = Modifier.weight(1f),
onClick = { onArticleClick(article) },
)
}
if (secondary.size == 1) {
Spacer(modifier = Modifier.weight(1f))
}
}
}
}
val highlighted = articles.drop(3).firstOrNull()
if (highlighted != null) {
item {
WideArticleCard(
article = highlighted,
onClick = { onArticleClick(highlighted) },
)
}
}
val remaining = articles.drop(4)
items(remaining.size) { index ->
val article = remaining[index]
EditorialArticleCard(
article = article,
onClick = { onArticleClick(article) },
)
}
}
}
}
}
}
@Composable
private fun NewsListHero() {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(top = 24.dp, bottom = 8.dp),
) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.width(220.dp)
.height(200.dp)
.clip(
RoundedCornerShape(
topStart = 0.dp,
topEnd = 0.dp,
bottomStart = 64.dp,
bottomEnd = 0.dp,
)
)
.background(SurfaceContainerHighest.copy(alpha = 0.55f)),
)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = stringResource(R.string.news_list_room),
style = MaterialTheme.typography.labelSmall,
color = BrandRed,
letterSpacing = 1.5.sp,
fontWeight = FontWeight.SemiBold,
)
Text(
text = stringResource(R.string.news_list_title),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.ExtraBold,
fontSize = 38.sp,
color = OnSurface,
lineHeight = 46.sp,
)
Text(
text = stringResource(R.string.news_list_intro),
style = MaterialTheme.typography.bodyMedium,
color = OnSurfaceVariant,
lineHeight = 24.sp,
)
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
AccentPill(
label = stringResource(R.string.news_list_pill_editorial),
container = BrandRedContainer,
content = BrandRed,
)
AccentPill(
label = stringResource(R.string.news_list_pill_trade_signals),
container = AccentBlueContainer,
content = AccentBlue,
)
AccentPill(
label = stringResource(R.string.news_list_pill_merchant_stories),
container = AccentPurpleContainer,
content = AccentPurple,
)
}
}
}
}
@Composable
private fun SectionTitle() {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 18.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.news_list_latest_updates),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.ExtraBold,
fontSize = 28.sp,
color = OnSurface,
)
Box(
modifier = Modifier
.width(64.dp)
.height(2.dp)
.background(OutlineVariant),
)
}
}
@Composable
private fun FeaturedArticleCard(
article: NewsArticle,
onClick: () -> Unit,
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 18.dp)
.clip(RoundedCornerShape(28.dp))
.background(SurfaceContainerLowest)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(),
onClick = onClick,
),
) {
Column {
NewsImage(
article = article,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 10f),
)
Column(
modifier = Modifier.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
CategoryBadge(article = article)
Text(
text = article.title,
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.ExtraBold,
fontSize = 24.sp,
lineHeight = 32.sp,
color = OnSurface,
)
val summary = article.displaySummary.ifBlank {
stringResource(R.string.news_list_featured_fallback)
}
Text(
text = summary,
style = MaterialTheme.typography.bodyMedium,
color = OnSurfaceVariant,
lineHeight = 24.sp,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
DetailLink()
}
}
}
}
@Composable
private fun CompactArticleCard(
article: NewsArticle,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
Column(
modifier = modifier
.clip(RoundedCornerShape(20.dp))
.background(SurfaceContainerLowest)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(),
onClick = onClick,
)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
NewsImage(
article = article,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(4f / 3f)
.clip(RoundedCornerShape(16.dp)),
)
CategoryBadge(article = article)
Text(
text = article.title,
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 18.sp,
lineHeight = 24.sp,
color = OnSurface,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
Text(
text = article.displaySummary.ifBlank { stringResource(R.string.news_list_compact_fallback) },
style = MaterialTheme.typography.bodySmall,
color = OnSurfaceVariant,
lineHeight = 20.sp,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
private fun WideArticleCard(
article: NewsArticle,
onClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 18.dp)
.clip(RoundedCornerShape(24.dp))
.background(SurfaceContainerLowest)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(),
onClick = onClick,
),
) {
NewsImage(
article = article,
modifier = Modifier
.weight(1.05f)
.aspectRatio(1f),
)
Column(
modifier = Modifier
.weight(1f)
.padding(22.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
CategoryBadge(article = article)
Text(
text = article.title,
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.ExtraBold,
fontSize = 22.sp,
lineHeight = 30.sp,
color = OnSurface,
maxLines = 4,
overflow = TextOverflow.Ellipsis,
)
Text(
text = article.displaySummary.ifBlank { stringResource(R.string.news_list_wide_fallback) },
style = MaterialTheme.typography.bodyMedium,
color = OnSurfaceVariant,
lineHeight = 22.sp,
maxLines = 4,
overflow = TextOverflow.Ellipsis,
)
DetailLink(label = stringResource(R.string.news_list_explore_update))
}
}
}
@Composable
private fun EditorialArticleCard(
article: NewsArticle,
onClick: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 18.dp)
.clip(RoundedCornerShape(24.dp))
.background(SurfaceContainerLow)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(),
onClick = onClick,
)
.padding(18.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
NewsImage(
article = article,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.clip(RoundedCornerShape(18.dp)),
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
CategoryBadge(article = article)
if (!article.reporter.isNullOrBlank()) {
Text(
text = article.reporter.uppercase(),
style = MaterialTheme.typography.labelSmall,
color = OnSurfaceVariant,
letterSpacing = 1.2.sp,
)
}
}
Text(
text = article.title,
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
lineHeight = 28.sp,
color = OnSurface,
)
Text(
text = article.displaySummary.ifBlank { stringResource(R.string.news_list_editorial_fallback) },
style = MaterialTheme.typography.bodyMedium,
color = OnSurfaceVariant,
lineHeight = 22.sp,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
DetailLink()
}
}
@Composable
private fun NewsListMessageCard(
title: String,
body: String,
accent: Color,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 18.dp)
.clip(RoundedCornerShape(24.dp))
.background(SurfaceContainerLowest)
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(accent.copy(alpha = 0.14f)),
)
Text(
text = title,
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.ExtraBold,
fontSize = 22.sp,
color = OnSurface,
)
Text(
text = body,
style = MaterialTheme.typography.bodyMedium,
color = OnSurfaceVariant,
lineHeight = 22.sp,
)
}
}
@Composable
private fun AccentPill(
label: String,
container: Color,
content: Color,
) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(999.dp))
.background(container)
.padding(horizontal = 14.dp, vertical = 8.dp),
) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = content,
)
}
}
@Composable
private fun CategoryBadge(article: NewsArticle) {
val label = article.category?.takeIf { it.isNotBlank() } ?: stringResource(R.string.news_list_latest_update)
val container = when (label.lowercase()) {
"ekonomi" -> BrandRedContainer
"merchant success" -> AccentBlueContainer
"trade policy" -> AccentPurpleContainer
else -> SurfaceContainerHighest
}
val content = when (label.lowercase()) {
"ekonomi" -> BrandRed
"merchant success" -> AccentBlue
"trade policy" -> AccentPurple
else -> OnSurfaceVariant
}
Box(
modifier = Modifier
.clip(RoundedCornerShape(10.dp))
.background(container)
.padding(horizontal = 10.dp, vertical = 6.dp),
) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = content,
letterSpacing = 0.8.sp,
)
}
}
@Composable
private fun DetailLink(label: String? = null) {
val resolvedLabel = label ?: stringResource(R.string.news_list_view_details)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = resolvedLabel,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
color = BrandRed,
)
Icon(
imageVector = Icons.AutoMirrored.Outlined.ArrowForward,
contentDescription = null,
tint = BrandRed,
modifier = Modifier.size(18.dp),
)
}
}
@Composable
private fun NewsImage(
article: NewsArticle,
modifier: Modifier = Modifier,
) {
val imageUrl = article.image1 ?: article.image2 ?: article.image3
if (imageUrl != null) {
AsyncImage(
model = imageUrl,
contentDescription = article.title,
contentScale = ContentScale.Crop,
modifier = modifier,
)
} else {
Box(
modifier = modifier
.background(
Brush.linearGradient(
colors = listOf(
SurfaceContainerHighest,
SurfaceContainerLow,
)
)
),
) {
Text(
text = article.category?.uppercase() ?: "INA",
style = MaterialTheme.typography.labelLarge,
color = OnSurfaceVariant.copy(alpha = 0.7f),
modifier = Modifier
.align(Alignment.Center)
.padding(12.dp),
)
}
}
}

View File

@ -0,0 +1,55 @@
package id.iiyh.inatrading.feature.news.presentation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import id.iiyh.inatrading.feature.news.data.model.NewsArticle
import id.iiyh.inatrading.feature.news.domain.NewsRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
data class NewsListUiState(
val articles: List<NewsArticle> = emptyList(),
val isLoading: Boolean = false,
val errorMessage: String? = null,
)
@HiltViewModel
class NewsListViewModel @Inject constructor(
private val newsRepository: NewsRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(NewsListUiState())
val uiState: StateFlow<NewsListUiState> = _uiState.asStateFlow()
init {
loadArticles()
}
fun loadArticles() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
newsRepository.getArticles()
.onSuccess { articles ->
_uiState.update {
it.copy(
articles = articles,
isLoading = false,
)
}
}
.onFailure { error ->
_uiState.update {
it.copy(
isLoading = false,
errorMessage = error.message ?: "Failed to load news.",
)
}
}
}
}
}

View File

@ -0,0 +1,21 @@
package id.iiyh.inatrading.feature.news.presentation
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import id.iiyh.inatrading.feature.news.data.model.NewsArticle
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
/** Shared ViewModel untuk passing NewsArticle ke detail screen tanpa serialisasi */
@HiltViewModel
class NewsNavViewModel @Inject constructor() : ViewModel() {
private val _selectedArticle = MutableStateFlow<NewsArticle?>(null)
val selectedArticle: StateFlow<NewsArticle?> = _selectedArticle.asStateFlow()
fun select(article: NewsArticle) {
_selectedArticle.value = article
}
}

View File

@ -0,0 +1,148 @@
package id.iiyh.inatrading.feature.product.data.model
import com.google.gson.annotations.SerializedName
data class ProductItem(
val id: String,
val image: String? = null,
val isFavorite: Boolean = false,
val market: String? = null,
val maxPrice: Double = 0.0,
val minPrice: Double = 0.0,
val name: String,
val totalStock: Int = 0,
)
data class ProductListResponse(
val responseCode: String? = null,
val responseDesc: String? = null,
val rows: List<ProductItem> = emptyList(),
val totalItem: Int = 0,
val totalPage: Int = 0,
) {
val isSuccess: Boolean get() = responseCode == "0000"
}
data class ProductDetailResponse(
val responseCode: String? = null,
val responseDesc: String? = null,
val data: ProductDetail? = null,
) {
val isSuccess: Boolean get() = responseCode == "0000"
}
data class ProductDetail(
val id: String,
val description: String? = null,
@SerializedName(value = "image", alternate = ["imageId"])
val image: String? = null,
val isEligibleToExport: Boolean = false,
val isNew: Boolean = false,
val isPreOrder: Boolean = false,
val name: String,
val preOrderDay: Int? = null,
val productFiles: List<String> = emptyList(),
val productFeatures: List<String> = emptyList(),
val productImages: List<ProductImage> = emptyList(),
val productInformations: List<ProductInformation> = emptyList(),
val categoryInformations: List<ProductInformation> = emptyList(),
val productKeyWords: List<String> = emptyList(),
val productModels: List<ProductModel> = emptyList(),
val seller: ProductSeller? = null,
val subCategory: ProductSubCategory? = null,
val state: String? = null,
val complianceInformation: ComplianceInformation? = null,
val warrantyInformation: WarrantyInformation? = null,
)
data class ProductImage(
val id: String? = null,
@SerializedName(value = "image", alternate = ["imageId"])
val image: String? = null,
val sequence: Int = 0,
)
data class ProductInformation(
val id: String? = null,
val paramName: String? = null,
val paramValue: String? = null,
)
data class ProductModel(
val id: String? = null,
val currency: String? = null,
val dimensionType: String? = null,
val height: Double? = null,
@SerializedName(value = "image", alternate = ["imageId"])
val image: String? = null,
val isConfigurePromotionPrice: Boolean = false,
val isMeasurement: Boolean = false,
val name: String? = null,
val price: Double? = null,
val sku: String? = null,
val warehouses: List<ProductWarehouse> = emptyList(),
val weight: Double? = null,
val weightType: String? = null,
val width: Double? = null,
val length: Double? = null,
val promotionPrice: Double? = null,
val promotionCurrency: String? = null,
val packagingWeight: Double? = null,
val packagingWeightType: String? = null,
val packagingLength: Double? = null,
val packagingWidth: Double? = null,
val packagingHeight: Double? = null,
val packagingDimensionType: String? = null,
val productMeasurements: List<ProductMeasurement> = emptyList(),
)
data class ProductWarehouse(
val id: String,
val address: String? = null,
val country: String? = null,
val province: String? = null,
val city: String? = null,
val postalCode: String? = null,
val latitude: Double? = null,
val longitude: Double? = null,
val stock: Int = 0,
)
data class ProductSeller(
val id: String,
val image: String? = null,
val name: String? = null,
)
data class ProductSubCategory(
val id: String,
val name: String? = null,
)
data class ProductMeasurement(
val id: String? = null,
val measurementType: String? = null,
val measurementValue: String? = null,
val price: Double? = null,
val promotionPrice: Double? = null,
val weight: Double? = null,
val weightType: String? = null,
val length: Double? = null,
val width: Double? = null,
val height: Double? = null,
val dimensionType: String? = null,
val warehouses: List<ProductWarehouse> = emptyList(),
)
data class ComplianceInformation(
val safetyWarning: String? = null,
val countryOfOrigin: String? = null,
val isDangerousGoodRegulation: Boolean? = null,
val fileId: String? = null,
)
data class WarrantyInformation(
val type: String? = null,
val duration: Int? = null,
val durationType: String? = null,
)

View File

@ -0,0 +1,45 @@
package id.iiyh.inatrading.feature.product.data.repository
import id.iiyh.inatrading.core.data.remote.ApiService
import id.iiyh.inatrading.feature.product.data.model.ProductDetail
import id.iiyh.inatrading.feature.product.domain.ProductPage
import id.iiyh.inatrading.feature.product.domain.ProductRepository
import javax.inject.Inject
class ProductRepositoryImpl @Inject constructor(
private val apiService: ApiService,
) : ProductRepository {
override suspend fun getProducts(page: Int, limit: Int): Result<ProductPage> {
return try {
val response = apiService.getProducts(page = page, limit = limit)
if (response.isSuccess) {
Result.success(
ProductPage(
items = response.rows,
totalItem = response.totalItem,
totalPage = response.totalPage,
)
)
} else {
Result.failure(Exception(response.responseDesc ?: "Gagal memuat produk"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun getProductDetail(productId: String): Result<ProductDetail> {
return try {
val response = apiService.getProductDetail(productId = productId)
if (!response.isSuccess) {
return Result.failure(Exception(response.responseDesc ?: "Gagal memuat detail produk"))
}
val data = response.data
?: return Result.failure(Exception("Detail produk tidak ditemukan"))
Result.success(data)
} catch (e: Exception) {
Result.failure(e)
}
}
}

View File

@ -0,0 +1,15 @@
package id.iiyh.inatrading.feature.product.domain
import id.iiyh.inatrading.feature.product.data.model.ProductDetail
import id.iiyh.inatrading.feature.product.data.model.ProductItem
data class ProductPage(
val items: List<ProductItem>,
val totalItem: Int,
val totalPage: Int,
)
interface ProductRepository {
suspend fun getProducts(page: Int, limit: Int): Result<ProductPage>
suspend fun getProductDetail(productId: String): Result<ProductDetail>
}

View File

@ -0,0 +1,995 @@
package id.iiyh.inatrading.feature.product.presentation
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.outlined.Battery6Bar
import androidx.compose.material.icons.outlined.CheckCircle
import androidx.compose.material.icons.outlined.Inventory2
import androidx.compose.material.icons.outlined.PhotoCamera
import androidx.compose.material.icons.outlined.Public
import androidx.compose.material.icons.outlined.ShoppingCart
import androidx.compose.material.icons.outlined.Verified
import androidx.compose.material.icons.outlined.Warning
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.text.HtmlCompat
import androidx.hilt.navigation.compose.hiltViewModel
import coil.compose.AsyncImage
import id.iiyh.inatrading.R
import id.iiyh.inatrading.core.ui.theme.AccentBlue
import id.iiyh.inatrading.core.ui.theme.AccentBlueContainer
import id.iiyh.inatrading.core.ui.theme.AccentPurple
import id.iiyh.inatrading.core.ui.theme.AccentPurpleContainer
import id.iiyh.inatrading.core.ui.theme.Background
import id.iiyh.inatrading.core.ui.theme.BrandRed
import id.iiyh.inatrading.core.ui.theme.BrandRedContainer
import id.iiyh.inatrading.core.ui.theme.ManropeFontFamily
import id.iiyh.inatrading.core.ui.theme.OnSurface
import id.iiyh.inatrading.core.ui.theme.OnSurfaceVariant
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerHighest
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerLow
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerLowest
import id.iiyh.inatrading.feature.product.data.model.ComplianceInformation
import id.iiyh.inatrading.feature.product.data.model.ProductDetail
import id.iiyh.inatrading.feature.product.data.model.ProductImage
import id.iiyh.inatrading.feature.product.data.model.ProductInformation
import id.iiyh.inatrading.feature.product.data.model.ProductModel
import java.text.NumberFormat
import java.util.Locale
@Composable
fun ProductDetailScreen(
onBack: () -> Unit,
onLoginRequired: () -> Unit = {},
onSessionExpired: () -> Unit = onLoginRequired,
onCartAdded: () -> Unit = {},
cartItemCount: Int = 0,
onCartClick: () -> Unit = {},
viewModel: ProductDetailViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
val cartAddedMessage = stringResource(R.string.cart_add_success)
LaunchedEffect(uiState.cartMessage) {
uiState.cartMessage?.let { message ->
snackbarHostState.showSnackbar(
if (message == CART_ADDED_MESSAGE) cartAddedMessage else message,
)
if (message == CART_ADDED_MESSAGE) {
onCartAdded()
}
viewModel.consumeCartMessage()
}
}
LaunchedEffect(uiState.sessionExpired) {
if (uiState.sessionExpired) {
viewModel.consumeSessionExpired()
onSessionExpired()
}
}
Scaffold(
topBar = {
ProductDetailTopBar(
onBack = onBack,
cartItemCount = cartItemCount,
onCartClick = onCartClick,
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
bottomBar = {
uiState.product?.let { product ->
ProductDetailBottomBar(
product = product,
isAddingToCart = uiState.isAddingToCart,
canAddToCart = uiState.canAddToCart,
onAddToCart = {
if (uiState.isLoggedIn) {
viewModel.addToCart()
} else {
onLoginRequired()
}
},
)
}
},
containerColor = Background,
) { innerPadding ->
when {
uiState.isLoading -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(color = BrandRed)
}
}
uiState.errorMessage != null -> {
ErrorState(
message = uiState.errorMessage!!,
onRetry = viewModel::loadProduct,
modifier = Modifier.padding(innerPadding),
)
}
uiState.product != null -> {
ProductDetailContent(
product = uiState.product!!,
modifier = Modifier.padding(innerPadding),
)
}
}
}
}
@Composable
private fun ProductDetailTopBar(
onBack: () -> Unit,
cartItemCount: Int,
onCartClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(Color.White.copy(alpha = 0.88f))
.padding(horizontal = 20.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
TopBarIcon(
icon = Icons.AutoMirrored.Outlined.ArrowBack,
onClick = onBack,
)
Text(
text = stringResource(R.string.product_detail_title),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 18.sp,
color = BrandRed,
)
}
Box {
TopBarIcon(
icon = Icons.Outlined.ShoppingCart,
onClick = onCartClick,
)
if (cartItemCount > 0) {
Box(
modifier = Modifier
.size(16.dp)
.offset(x = 8.dp, y = (-4).dp)
.background(BrandRed, CircleShape)
.align(Alignment.TopEnd),
contentAlignment = Alignment.Center,
) {
Text(
text = if (cartItemCount > 9) "9+" else cartItemCount.toString(),
style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp),
color = Color.White,
)
}
}
}
}
}
@Composable
private fun ProductDetailContent(
product: ProductDetail,
modifier: Modifier = Modifier,
) {
val model = product.primaryModel()
val gallery = product.galleryImages()
var selectedImageIndex by rememberSaveable(product.id) { mutableIntStateOf(0) }
LaunchedEffect(gallery.size) {
if (selectedImageIndex >= gallery.size) selectedImageIndex = 0
}
LazyColumn(
modifier = modifier
.fillMaxSize()
.background(Background),
contentPadding = PaddingValues(start = 24.dp, end = 24.dp, top = 20.dp, bottom = 112.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
item {
ProductGallerySection(
product = product,
gallery = gallery,
selectedImageIndex = selectedImageIndex,
onImageSelected = { selectedImageIndex = it },
)
}
item {
ProductSummarySection(product = product, model = model)
}
if (product.productFeatures.isNotEmpty()) {
item {
FeatureCardList(features = product.productFeatures)
}
}
item {
OverviewStatsSection(product = product, model = model)
}
product.complianceInformation?.takeIf { it.hasVisibleData() }?.let { compliance ->
item {
ComplianceWarningSection(compliance = compliance)
}
}
item {
DetailedSpecificationsSection(product = product, model = model)
}
if (product.productKeyWords.isNotEmpty()) {
item {
KeywordSection(product.productKeyWords)
}
}
}
}
@Composable
private fun ProductGallerySection(
product: ProductDetail,
gallery: List<String>,
selectedImageIndex: Int,
onImageSelected: (Int) -> Unit,
) {
val selectedImage = gallery.getOrNull(selectedImageIndex)
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 10f)
.clip(RoundedCornerShape(24.dp))
.background(SurfaceContainerLow),
) {
if (selectedImage != null) {
AsyncImage(
model = selectedImage,
contentDescription = product.name,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
}
Column(
modifier = Modifier
.align(Alignment.TopStart)
.padding(18.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (product.isNew) {
EditorialBadge(
text = stringResource(R.string.product_detail_status_new),
container = AccentPurpleContainer,
content = AccentPurple,
)
}
if (product.isEligibleToExport) {
EditorialBadge(
text = stringResource(R.string.product_detail_status_export),
container = AccentBlue,
content = Color.White,
icon = Icons.Outlined.Public,
)
}
}
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(18.dp)
.size(116.dp)
.background(
SurfaceContainerHighest.copy(alpha = 0.18f),
RoundedCornerShape(26.dp),
)
)
}
if (gallery.isNotEmpty()) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
gallery.take(3).forEachIndexed { index, image ->
AsyncImage(
model = image,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.weight(1f)
.aspectRatio(1f)
.clip(RoundedCornerShape(16.dp))
.background(SurfaceContainerLow)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(),
onClick = { onImageSelected(index) },
)
.then(
if (selectedImageIndex == index) Modifier.background(BrandRedContainer)
else Modifier
),
)
}
if (gallery.size > 3) {
Box(
modifier = Modifier
.weight(1f)
.aspectRatio(1f)
.clip(RoundedCornerShape(16.dp))
.background(SurfaceContainerHighest)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(),
onClick = { onImageSelected(3) },
),
contentAlignment = Alignment.Center,
) {
Text(
text = "+${gallery.size - 3}",
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
color = OnSurfaceVariant,
)
}
}
}
}
}
}
@Composable
private fun ProductSummarySection(
product: ProductDetail,
model: ProductModel?,
) {
val eyebrow = buildList {
product.brand()?.takeIf(String::isNotBlank)?.let(::add)
product.categoryLabel()?.takeIf(String::isNotBlank)?.let(::add)
}.joinToString("")
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
if (eyebrow.isNotBlank()) {
Text(
text = eyebrow,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = AccentPurple,
letterSpacing = 2.sp,
)
}
Text(
text = product.name,
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.ExtraBold,
fontSize = 36.sp,
lineHeight = 42.sp,
color = OnSurface,
)
PriceBlock(model = model)
product.description.orEmpty().toPlainText().takeIf(String::isNotBlank)?.let { description ->
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
color = OnSurfaceVariant,
lineHeight = 24.sp,
)
}
}
}
@Composable
private fun PriceBlock(model: ProductModel?) {
val salePrice = model?.promotionPrice
val regularPrice = model?.price
Row(
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = (salePrice ?: regularPrice).toDisplayPrice(),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.ExtraBold,
fontSize = 28.sp,
color = BrandRed,
)
if (salePrice != null && regularPrice != null && regularPrice > salePrice) {
Text(
text = regularPrice.toDisplayPrice(),
style = MaterialTheme.typography.titleMedium,
color = OnSurfaceVariant.copy(alpha = 0.6f),
)
}
}
}
@Composable
private fun FeatureCardList(
features: List<String>,
) {
val icons = listOf(
Icons.Outlined.CheckCircle,
Icons.Outlined.Battery6Bar,
Icons.Outlined.PhotoCamera,
)
val colors = listOf(BrandRed, AccentBlue, AccentPurple)
val containers = listOf(BrandRedContainer, AccentBlueContainer, AccentPurpleContainer)
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
features.take(3).forEachIndexed { index, feature ->
FeatureCard(
icon = icons[index % icons.size],
iconTint = colors[index % colors.size],
iconContainer = containers[index % containers.size],
title = feature,
)
}
}
}
@Composable
private fun FeatureCard(
icon: ImageVector,
iconTint: Color,
iconContainer: Color,
title: String,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(18.dp))
.background(SurfaceContainerLow)
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(48.dp)
.clip(RoundedCornerShape(14.dp))
.background(iconContainer),
contentAlignment = Alignment.Center,
) {
Icon(icon, contentDescription = null, tint = iconTint)
}
Text(
text = title,
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
color = OnSurface,
)
}
}
@Composable
private fun OverviewStatsSection(
product: ProductDetail,
model: ProductModel?,
) {
val stock = model?.warehouses?.sumOf { it.stock }?.takeIf { it > 0 }
val warranty = product.warrantyInformation?.formattedText()
if (stock == null && warranty == null) return
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
warranty?.let {
StatCard(
modifier = Modifier.weight(1f),
icon = Icons.Outlined.Verified,
iconTint = BrandRed,
label = stringResource(R.string.product_detail_warranty),
value = it,
)
}
stock?.let {
StatCard(
modifier = Modifier.weight(1f),
icon = Icons.Outlined.Inventory2,
iconTint = AccentBlue,
label = stringResource(R.string.product_detail_stock),
value = stringResource(R.string.product_detail_stock_available, it),
)
}
}
}
@Composable
private fun StatCard(
label: String,
value: String,
icon: ImageVector,
iconTint: Color,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.clip(RoundedCornerShape(18.dp))
.background(SurfaceContainerLowest)
.padding(18.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(icon, contentDescription = null, tint = iconTint)
Text(
text = label.uppercase(),
style = MaterialTheme.typography.labelSmall,
color = OnSurfaceVariant,
letterSpacing = 1.sp,
)
Text(
text = value,
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
color = OnSurface,
)
}
}
@Composable
private fun ComplianceWarningSection(
compliance: ComplianceInformation,
) {
val lines = buildList {
compliance.safetyWarning?.takeIf(String::isNotBlank)?.let(::add)
compliance.countryOfOrigin?.takeIf(String::isNotBlank)?.let {
add(stringResource(R.string.product_detail_origin_value, it))
}
}
if (lines.isEmpty()) return
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(18.dp))
.background(BrandRedContainer.copy(alpha = 0.22f))
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top,
) {
Icon(
imageVector = Icons.Outlined.Warning,
contentDescription = null,
tint = BrandRed,
modifier = Modifier.padding(top = 2.dp),
)
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = stringResource(R.string.product_detail_safety_warning),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = BrandRed,
letterSpacing = 1.sp,
)
lines.forEach {
Text(text = it, style = MaterialTheme.typography.bodyMedium, color = OnSurfaceVariant)
}
}
}
}
@Composable
private fun DetailedSpecificationsSection(
product: ProductDetail,
model: ProductModel?,
) {
val specifications = buildSpecifications(product, model)
if (specifications.isEmpty()) return
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
text = stringResource(R.string.product_detail_detailed_specifications),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 28.sp,
color = OnSurface,
)
specifications.forEach { (label, value) ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top,
) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = OnSurfaceVariant,
modifier = Modifier.weight(1f),
)
Spacer(modifier = Modifier.width(16.dp))
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
color = OnSurface,
modifier = Modifier.weight(1f),
textAlign = TextAlign.End,
)
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(1.dp)
.background(SurfaceContainerHighest)
)
}
}
}
@Composable
private fun KeywordSection(
keywords: List<String>,
) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
text = stringResource(R.string.product_detail_keywords),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 18.sp,
color = OnSurface,
)
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
itemsIndexed(keywords) { _, keyword ->
Box(
modifier = Modifier
.clip(RoundedCornerShape(999.dp))
.background(AccentPurpleContainer.copy(alpha = 0.3f))
.padding(horizontal = 14.dp, vertical = 8.dp),
) {
Text(
text = keyword,
style = MaterialTheme.typography.labelMedium,
color = AccentPurple,
fontWeight = FontWeight.Bold,
)
}
}
}
}
}
@Composable
private fun ProductDetailBottomBar(
product: ProductDetail,
isAddingToCart: Boolean,
canAddToCart: Boolean,
onAddToCart: () -> Unit,
) {
Box(
modifier = Modifier
.fillMaxWidth()
.background(Color.White.copy(alpha = 0.95f))
.padding(horizontal = 16.dp, vertical = 12.dp),
) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
SecondaryBottomAction(
text = stringResource(R.string.product_detail_add_to_cart),
icon = Icons.Outlined.ShoppingCart,
enabled = canAddToCart && !isAddingToCart,
loading = isAddingToCart,
onClick = onAddToCart,
modifier = Modifier.weight(1f),
)
PrimaryBottomAction(
text = stringResource(R.string.product_detail_buy_now),
modifier = Modifier.weight(1.45f),
)
}
}
}
@Composable
private fun SecondaryBottomAction(
text: String,
icon: ImageVector,
enabled: Boolean,
loading: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.height(56.dp)
.clip(RoundedCornerShape(18.dp))
.background(Color.White)
.clickable(
enabled = enabled && !loading,
interactionSource = remember { MutableInteractionSource() },
indication = ripple(),
onClick = onClick,
),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
if (loading) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
color = BrandRed,
strokeWidth = 2.dp,
)
} else {
Icon(icon, contentDescription = null, tint = OnSurface)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = text,
color = OnSurface,
fontWeight = FontWeight.Bold,
)
}
}
}
@Composable
private fun PrimaryBottomAction(
text: String,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.height(56.dp)
.clip(RoundedCornerShape(18.dp))
.background(Brush.linearGradient(listOf(BrandRed, Color(0xFFDB322F))))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(color = Color.White.copy(alpha = 0.18f)),
onClick = {},
),
contentAlignment = Alignment.Center,
) {
Text(
text = text,
color = Color.White,
fontWeight = FontWeight.Bold,
fontFamily = ManropeFontFamily,
)
}
}
@Composable
private fun TopBarIcon(
icon: ImageVector,
onClick: () -> Unit,
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(12.dp))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(),
onClick = onClick,
),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = OnSurfaceVariant,
)
}
}
@Composable
private fun EditorialBadge(
text: String,
container: Color,
content: Color,
icon: ImageVector? = null,
) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(999.dp))
.background(container)
.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (icon != null) {
Icon(icon, contentDescription = null, tint = content, modifier = Modifier.size(14.dp))
}
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
color = content,
fontWeight = FontWeight.Bold,
letterSpacing = 1.sp,
)
}
}
@Composable
private fun ErrorState(
message: String,
onRetry: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(R.string.product_detail_load_error),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.ExtraBold,
fontSize = 24.sp,
color = OnSurface,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = OnSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(20.dp))
androidx.compose.material3.Button(onClick = onRetry) {
Text(stringResource(R.string.product_detail_retry))
}
}
}
private fun ProductDetail.primaryModel(): ProductModel? = productModels.firstOrNull()
private fun ProductDetail.brand(): String? =
productInformations.firstOrNull { it.paramName.equals("Brand", ignoreCase = true) }?.paramValue
private fun ProductDetail.categoryLabel(): String? =
categoryInformations.firstOrNull()?.paramValue
?: subCategory?.name
private fun ProductDetail.galleryImages(): List<String> {
val ordered = productImages
.sortedBy(ProductImage::sequence)
.mapNotNull { it.image }
val fallback = listOfNotNull(image, primaryModel()?.image)
return (ordered + fallback).distinct()
}
private fun ComplianceInformation.hasVisibleData(): Boolean =
!safetyWarning.isNullOrBlank() || !countryOfOrigin.isNullOrBlank()
private fun id.iiyh.inatrading.feature.product.data.model.WarrantyInformation.formattedText(): String? {
val durationText = duration?.takeIf { it > 0 }?.let {
buildString {
append(it)
durationType?.takeIf(String::isNotBlank)?.let { type ->
append(" ")
append(type)
}
}
}
return listOfNotNull(type?.takeIf(String::isNotBlank), durationText).joinToString(" ").ifBlank { null }
}
@Composable
private fun buildSpecifications(
product: ProductDetail,
model: ProductModel?,
): List<Pair<String, String>> {
val items = mutableListOf<Pair<String, String>>()
product.brand()?.takeIf(String::isNotBlank)?.let {
items += stringResource(R.string.product_detail_brand) to it
}
product.productInformations.firstOrNull { it.paramName.equals("Battery", ignoreCase = true) }?.paramValue
?.takeIf(String::isNotBlank)?.let {
items += stringResource(R.string.product_detail_battery_capacity) to it
}
product.categoryLabel()?.takeIf(String::isNotBlank)?.let {
items += stringResource(R.string.product_detail_category) to it
}
product.complianceInformation?.countryOfOrigin?.takeIf(String::isNotBlank)?.let {
items += stringResource(R.string.product_detail_origin) to it
}
model?.sku?.takeIf(String::isNotBlank)?.let {
items += stringResource(R.string.product_detail_sku) to it
}
model?.name?.takeIf(String::isNotBlank)?.let {
items += stringResource(R.string.product_detail_model_name) to it
}
model?.weight?.let { weight ->
model.weightType?.takeIf(String::isNotBlank)?.let { type ->
items += stringResource(R.string.product_detail_weight) to "${trimTrailingZero(weight)} $type"
}
}
model?.let { currentModel ->
val parts = listOfNotNull(currentModel.length, currentModel.width, currentModel.height).map(::trimTrailingZero)
if (parts.size == 3 && !currentModel.dimensionType.isNullOrBlank()) {
items += stringResource(R.string.product_detail_dimensions) to
"${parts[0]} x ${parts[1]} x ${parts[2]} ${currentModel.dimensionType.lowercase()}"
}
}
product.warrantyInformation?.formattedText()?.let {
items += stringResource(R.string.product_detail_warranty) to it
}
return items
}
private fun String.toPlainText(): String =
HtmlCompat.fromHtml(this, HtmlCompat.FROM_HTML_MODE_LEGACY).toString().trim()
private fun trimTrailingZero(value: Double): String =
if (value % 1.0 == 0.0) value.toInt().toString() else value.toString()
@Composable
private fun Double?.toDisplayPrice(): String {
if (this == null || this <= 0.0) return stringResource(R.string.products_contact_price)
return NumberFormat.getCurrencyInstance(Locale.forLanguageTag("id-ID"))
.apply { maximumFractionDigits = 0 }
.format(this)
.replace("Rp", "Rp ")
}

View File

@ -0,0 +1,192 @@
package id.iiyh.inatrading.feature.product.presentation
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import id.iiyh.inatrading.core.data.local.SessionManager
import id.iiyh.inatrading.core.data.remote.SessionExpiredException
import id.iiyh.inatrading.feature.auth.domain.repository.AuthRepository
import id.iiyh.inatrading.feature.cart.domain.CartRepository
import id.iiyh.inatrading.feature.product.data.model.ProductDetail
import id.iiyh.inatrading.feature.product.data.model.ProductModel
import id.iiyh.inatrading.feature.product.domain.ProductRepository
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
const val CART_ADDED_MESSAGE = "__cart_added__"
data class ProductDetailUiState(
val product: ProductDetail? = null,
val isLoading: Boolean = false,
val errorMessage: String? = null,
val isLoggedIn: Boolean = false,
val isAddingToCart: Boolean = false,
val cartMessage: String? = null,
val sessionExpired: Boolean = false,
val canAddToCart: Boolean = false,
)
@HiltViewModel
class ProductDetailViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val productRepository: ProductRepository,
private val cartRepository: CartRepository,
private val authRepository: AuthRepository,
sessionManager: SessionManager,
) : ViewModel() {
private val productId: String = checkNotNull(savedStateHandle["productId"])
private val _uiState = MutableStateFlow(ProductDetailUiState(isLoading = true))
val uiState: StateFlow<ProductDetailUiState> = combine(
_uiState,
sessionManager.token,
) { state, token ->
state.copy(isLoggedIn = !token.isNullOrBlank())
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = ProductDetailUiState(isLoading = true),
)
init {
loadProduct()
}
fun loadProduct() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
productRepository.getProductDetail(productId)
.onSuccess { product ->
_uiState.update {
it.copy(
product = product,
isLoading = false,
errorMessage = null,
canAddToCart = product.primaryCartModel()?.let { model ->
model.primaryAvailableStock() > 0 && model.displayCartPrice() > 0.0
} == true,
)
}
}
.onFailure { error ->
_uiState.update {
it.copy(
isLoading = false,
errorMessage = error.message ?: "Gagal memuat detail produk",
)
}
}
}
}
fun addToCart() {
val product = _uiState.value.product ?: return
val sellerId = product.seller?.id
?: run {
_uiState.update { it.copy(cartMessage = "Seller produk tidak ditemukan") }
return
}
val model = product.primaryCartModel()
?: run {
_uiState.update { it.copy(cartMessage = "Model produk belum tersedia") }
return
}
val productModelId = model.id
?: run {
_uiState.update { it.copy(cartMessage = "ID model produk tidak ditemukan") }
return
}
val warehouseId = model.primaryWarehouseId()
?: run {
_uiState.update { it.copy(cartMessage = "Warehouse produk tidak ditemukan") }
return
}
if (model.primaryAvailableStock() <= 0) {
_uiState.update { it.copy(cartMessage = "Stok produk habis") }
return
}
if (model.displayCartPrice() <= 0.0) {
_uiState.update { it.copy(cartMessage = "Produk ini belum memiliki harga untuk ditambahkan ke keranjang") }
return
}
viewModelScope.launch {
_uiState.update { it.copy(isAddingToCart = true, cartMessage = null, sessionExpired = false) }
cartRepository.addToCart(
sellerId = sellerId,
productModelId = productModelId,
warehouseId = warehouseId,
quantity = 1,
productMeasurementId = model.primaryMeasurementId(),
).onSuccess {
_uiState.update {
it.copy(
isAddingToCart = false,
cartMessage = CART_ADDED_MESSAGE,
)
}
}.onFailure { error ->
if (error is SessionExpiredException) {
authRepository.logout()
_uiState.update {
it.copy(
isAddingToCart = false,
sessionExpired = true,
cartMessage = null,
)
}
} else {
_uiState.update {
it.copy(
isAddingToCart = false,
cartMessage = error.message ?: "Gagal menambahkan produk ke keranjang",
)
}
}
}
}
}
fun consumeCartMessage() {
_uiState.update { it.copy(cartMessage = null) }
}
fun consumeSessionExpired() {
_uiState.update { it.copy(sessionExpired = false) }
}
}
private fun ProductDetail.primaryCartModel(): ProductModel? = productModels.firstOrNull()
private fun ProductModel.primaryWarehouseId(): String? =
productMeasurements.firstOrNull()
?.warehouses
?.firstOrNull()
?.id
?: warehouses.firstOrNull()?.id
private fun ProductModel.primaryMeasurementId(): String =
productMeasurements.firstOrNull()?.id.orEmpty()
private fun ProductModel.primaryAvailableStock(): Int =
productMeasurements.firstOrNull()
?.warehouses
?.firstOrNull()
?.stock
?: warehouses.firstOrNull()?.stock
?: 0
private fun ProductModel.displayCartPrice(): Double {
return promotionPrice?.takeIf { it > 0.0 }
?: price?.takeIf { it > 0.0 }
?: 0.0
}

View File

@ -0,0 +1,20 @@
package id.iiyh.inatrading.feature.product.presentation
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import id.iiyh.inatrading.feature.product.data.model.ProductItem
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
@HiltViewModel
class ProductNavViewModel @Inject constructor() : ViewModel() {
private val _selectedProduct = MutableStateFlow<ProductItem?>(null)
val selectedProduct: StateFlow<ProductItem?> = _selectedProduct.asStateFlow()
fun select(product: ProductItem) {
_selectedProduct.value = product
}
}

View File

@ -0,0 +1,896 @@
package id.iiyh.inatrading.feature.product.presentation
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.automirrored.outlined.ArrowForward
import androidx.compose.material.icons.outlined.FavoriteBorder
import androidx.compose.material.icons.outlined.LocationOn
import androidx.compose.material.icons.outlined.Public
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.ShoppingCart
import androidx.compose.material.icons.outlined.Storefront
import androidx.compose.material.icons.outlined.Tune
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import coil.compose.AsyncImage
import id.iiyh.inatrading.R
import id.iiyh.inatrading.core.ui.components.InaChip
import id.iiyh.inatrading.core.ui.components.InaChipVariant
import id.iiyh.inatrading.core.ui.components.InaPrimaryButton
import id.iiyh.inatrading.core.ui.components.InaTextField
import id.iiyh.inatrading.core.ui.theme.AccentBlue
import id.iiyh.inatrading.core.ui.theme.AccentBlueContainer
import id.iiyh.inatrading.core.ui.theme.AccentPurple
import id.iiyh.inatrading.core.ui.theme.AccentPurpleContainer
import id.iiyh.inatrading.core.ui.theme.Background
import id.iiyh.inatrading.core.ui.theme.BrandRed
import id.iiyh.inatrading.core.ui.theme.BrandRedContainer
import id.iiyh.inatrading.core.ui.theme.ManropeFontFamily
import id.iiyh.inatrading.core.ui.theme.OnSurface
import id.iiyh.inatrading.core.ui.theme.OnSurfaceVariant
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerHighest
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerLow
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerLowest
import id.iiyh.inatrading.feature.product.data.model.ProductItem
import java.text.NumberFormat
import java.util.Locale
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
@Composable
fun ProductsScreen(
onProductClick: (ProductItem) -> Unit = {},
onLoginRequired: () -> Unit = {},
onSessionExpired: () -> Unit = onLoginRequired,
viewModel: ProductsViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsState()
val gridState = rememberLazyGridState()
val snackbarHostState = remember { SnackbarHostState() }
val favoriteAddedMessage = stringResource(R.string.products_favorite_added)
val favoriteRemovedMessage = stringResource(R.string.products_favorite_removed)
LaunchedEffect(gridState) {
snapshotFlow {
val layoutInfo = gridState.layoutInfo
val lastVisible = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
lastVisible to layoutInfo.totalItemsCount
}
.filter { (_, totalCount) -> totalCount > 0 }
.distinctUntilChanged()
.collect { (lastVisible, totalCount) ->
if (lastVisible >= totalCount - 5) {
viewModel.loadNextPage()
}
}
}
LaunchedEffect(uiState.favoriteMessage) {
uiState.favoriteMessage?.let {
val message = when (it) {
PRODUCT_FAVORITE_ADDED_MESSAGE -> favoriteAddedMessage
PRODUCT_FAVORITE_REMOVED_MESSAGE -> favoriteRemovedMessage
else -> it
}
snackbarHostState.showSnackbar(message)
viewModel.consumeFavoriteMessage()
}
}
LaunchedEffect(uiState.sessionExpired) {
if (uiState.sessionExpired) {
viewModel.consumeSessionExpired()
onSessionExpired()
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(Background),
) {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
state = gridState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 20.dp, bottom = 112.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
item(span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) {
ProductsHero(
totalItem = uiState.totalItem,
totalLoaded = uiState.items.size,
)
}
item(span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) {
SearchAndActionRow(
query = uiState.searchQuery,
onQueryChange = viewModel::onSearchQueryChange,
)
}
item(span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) {
CategoryRow(
selected = uiState.selectedCategory,
onSelected = viewModel::onCategorySelected,
)
}
when {
uiState.isInitialLoading -> {
item(span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) {
CenterState(
title = stringResource(R.string.products_loading_title),
body = stringResource(R.string.products_loading_body),
loading = true,
)
}
}
uiState.errorMessage != null && uiState.items.isEmpty() -> {
item(span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) {
CenterState(
title = stringResource(R.string.products_error_title),
body = uiState.errorMessage!!,
actionLabel = stringResource(R.string.product_detail_retry),
onAction = viewModel::loadInitial,
)
}
}
uiState.filteredItems.isEmpty() -> {
item(span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) {
CenterState(
title = stringResource(R.string.products_empty_title),
body = stringResource(R.string.products_empty_body),
)
}
}
else -> {
items(uiState.filteredItems, key = { it.id }) { product ->
ProductCard(
product = product,
isFavorite = product.id in uiState.favoriteIds,
onFavoriteClick = {
if (uiState.isLoggedIn) {
viewModel.toggleFavorite(product.id)
} else {
onLoginRequired()
}
},
onClick = { onProductClick(product) },
)
}
if (uiState.isAppending) {
item(span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
horizontalArrangement = Arrangement.Center,
) {
CircularProgressIndicator(
color = BrandRed,
modifier = Modifier.size(28.dp),
strokeWidth = 2.5.dp,
)
}
}
}
}
}
}
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 88.dp),
)
}
if (uiState.favoriteDialogOpen) {
FavoritePickerDialog(
groups = uiState.favoriteGroups,
createMode = uiState.favoriteCreateMode,
isLoading = uiState.favoriteActionLoading,
onDismiss = viewModel::dismissFavoriteDialog,
onCreateMode = viewModel::showCreateFavoriteGroup,
onCancelCreate = viewModel::hideCreateFavoriteGroup,
onCreateGroup = viewModel::createFavoriteGroup,
onSelectGroup = viewModel::addProductToFavorite,
)
}
}
@Composable
private fun ProductsHero(
totalItem: Int,
totalLoaded: Int,
) {
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(28.dp))
.background(
Brush.linearGradient(
colors = listOf(
SurfaceContainerLowest,
SurfaceContainerLow,
)
)
)
.padding(20.dp),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(220.dp)
.clip(RoundedCornerShape(36.dp))
.background(SurfaceContainerHighest.copy(alpha = 0.35f)),
)
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text(
text = stringResource(R.string.products_limited_edition),
style = MaterialTheme.typography.labelSmall,
color = AccentBlue,
fontWeight = FontWeight.Bold,
letterSpacing = 2.sp,
)
Text(
text = stringResource(R.string.products_hero_title),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.ExtraBold,
fontSize = 34.sp,
lineHeight = 40.sp,
color = OnSurface,
)
Text(
text = stringResource(R.string.products_hero_body),
style = MaterialTheme.typography.bodyMedium,
color = OnSurfaceVariant,
lineHeight = 22.sp,
modifier = Modifier.fillMaxWidth(0.85f),
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
HeroStat(
label = stringResource(R.string.products_loaded),
value = totalLoaded.toString(),
containerColor = BrandRedContainer,
contentColor = BrandRed,
)
HeroStat(
label = stringResource(R.string.products_catalog),
value = if (totalItem > 0) totalItem.toString() else "0",
containerColor = AccentPurpleContainer,
contentColor = AccentPurple,
)
}
}
}
}
@Composable
private fun HeroStat(
label: String,
value: String,
containerColor: Color,
contentColor: Color,
) {
Column(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.background(containerColor)
.padding(horizontal = 14.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
text = label.uppercase(),
style = MaterialTheme.typography.labelSmall,
color = contentColor,
letterSpacing = 1.sp,
)
Text(
text = value,
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp,
color = contentColor,
)
}
}
@Composable
private fun SearchAndActionRow(
query: String,
onQueryChange: (String) -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(modifier = Modifier.weight(1f)) {
InaTextField(
value = query,
onValueChange = onQueryChange,
placeholder = stringResource(R.string.products_search_placeholder),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
modifier = Modifier.fillMaxWidth(),
leadingIcon = {
Icon(
imageVector = Icons.Outlined.Search,
contentDescription = null,
tint = OnSurfaceVariant,
modifier = Modifier.size(20.dp),
)
},
)
}
IconActionButton(
icon = Icons.Outlined.Tune,
contentDescription = stringResource(R.string.products_filter),
)
}
}
@Composable
private fun CategoryRow(
selected: ProductCategory,
onSelected: (ProductCategory) -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
ProductCategory.entries.forEach { category ->
InaChip(
label = category.label,
variant = when (category) {
ProductCategory.All -> InaChipVariant.Promo
ProductCategory.Local -> InaChipVariant.Category
ProductCategory.International -> InaChipVariant.Featured
},
selected = selected == category,
onClick = { onSelected(category) },
)
}
}
}
@Composable
private fun ProductCard(
product: ProductItem,
isFavorite: Boolean,
onFavoriteClick: () -> Unit,
onClick: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(),
onClick = onClick,
),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(4f / 5f)
.clip(RoundedCornerShape(18.dp))
.background(SurfaceContainerLow),
) {
if (product.image != null) {
AsyncImage(
model = product.image,
contentDescription = product.name,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
} else {
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.linearGradient(
colors = listOf(
SurfaceContainerHighest,
SurfaceContainerLow,
)
)
),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
imageVector = Icons.Outlined.Storefront,
contentDescription = null,
tint = OnSurfaceVariant.copy(alpha = 0.75f),
modifier = Modifier.size(30.dp),
)
Spacer(modifier = Modifier.height(6.dp))
Text(
text = product.market ?: stringResource(R.string.app_name),
style = MaterialTheme.typography.labelSmall,
color = OnSurfaceVariant.copy(alpha = 0.75f),
)
}
}
}
FavoriteButton(
isFavorite = isFavorite,
onClick = onFavoriteClick,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(10.dp),
)
MarketBadge(
market = product.market,
modifier = Modifier
.align(Alignment.BottomStart)
.padding(10.dp),
)
}
Column(
modifier = Modifier.padding(horizontal = 2.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = product.name,
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 15.sp,
lineHeight = 20.sp,
color = OnSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
ProductMetaRow(product = product)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Bottom,
) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
text = stringResource(R.string.products_price),
style = MaterialTheme.typography.labelSmall,
color = OnSurfaceVariant,
)
Text(
text = product.displayPrice(),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.ExtraBold,
fontSize = 15.sp,
lineHeight = 18.sp,
color = BrandRed,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(modifier = Modifier.width(8.dp))
Box(
modifier = Modifier
.size(38.dp)
.clip(RoundedCornerShape(12.dp))
.background(SurfaceContainerHighest)
.alpha(if (product.canAddToCartFromList()) 1f else 0.45f)
.clickable(
enabled = product.canAddToCartFromList(),
interactionSource = remember { MutableInteractionSource() },
indication = ripple(),
onClick = onClick,
),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Outlined.ShoppingCart,
contentDescription = null,
tint = OnSurfaceVariant,
modifier = Modifier.size(20.dp),
)
}
}
}
}
}
@Composable
private fun ProductMetaRow(product: ProductItem) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = if (product.market.equals("International", ignoreCase = true)) {
Icons.Outlined.Public
} else {
Icons.Outlined.LocationOn
},
contentDescription = null,
tint = OnSurfaceVariant,
modifier = Modifier.size(14.dp),
)
Text(
text = product.market ?: stringResource(R.string.products_marketplace),
style = MaterialTheme.typography.bodySmall,
color = OnSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Text(
text = if (product.totalStock > 0) {
stringResource(R.string.products_stock, product.totalStock)
} else {
stringResource(R.string.products_out_of_stock)
},
style = MaterialTheme.typography.labelSmall,
color = if (product.totalStock > 0) AccentBlue else BrandRed,
fontWeight = FontWeight.SemiBold,
)
}
}
@Composable
private fun FavoriteButton(
isFavorite: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.size(36.dp)
.clip(CircleShape)
.background(Color.White.copy(alpha = 0.88f))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(bounded = true),
onClick = onClick,
),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = if (isFavorite) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
contentDescription = null,
tint = if (isFavorite) BrandRed else OnSurfaceVariant,
modifier = Modifier.size(18.dp),
)
}
}
@Composable
private fun MarketBadge(
market: String?,
modifier: Modifier = Modifier,
) {
val text = market ?: stringResource(R.string.products_general)
val (container, content) = if (market.equals("International", ignoreCase = true)) {
AccentPurpleContainer to AccentPurple
} else {
AccentBlueContainer to AccentBlue
}
Box(
modifier = modifier
.clip(RoundedCornerShape(999.dp))
.background(container.copy(alpha = 0.95f))
.padding(horizontal = 10.dp, vertical = 6.dp),
) {
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
color = content,
fontWeight = FontWeight.Bold,
)
}
}
@Composable
private fun IconActionButton(
icon: androidx.compose.ui.graphics.vector.ImageVector,
contentDescription: String,
) {
Box(
modifier = Modifier
.size(52.dp)
.clip(RoundedCornerShape(14.dp))
.background(SurfaceContainerLowest)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(),
onClick = {},
),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
tint = OnSurfaceVariant,
modifier = Modifier.size(22.dp),
)
}
}
@Composable
private fun CenterState(
title: String,
body: String,
loading: Boolean = false,
actionLabel: String? = null,
onAction: (() -> Unit)? = null,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 32.dp)
.clip(RoundedCornerShape(24.dp))
.background(SurfaceContainerLowest)
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
if (loading) {
CircularProgressIndicator(
color = BrandRed,
modifier = Modifier.size(32.dp),
strokeWidth = 2.5.dp,
)
} else {
Text(
text = stringResource(R.string.products_catalog_label),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.ExtraBold,
fontSize = 20.sp,
color = BrandRed,
fontStyle = FontStyle.Italic,
)
}
Text(
text = title,
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.ExtraBold,
fontSize = 24.sp,
color = OnSurface,
)
Text(
text = body,
style = MaterialTheme.typography.bodyMedium,
color = OnSurfaceVariant,
lineHeight = 22.sp,
)
if (actionLabel != null && onAction != null) {
InaPrimaryButton(
text = actionLabel,
onClick = onAction,
modifier = Modifier.padding(top = 6.dp),
)
}
}
}
@Composable
private fun FavoritePickerDialog(
groups: List<id.iiyh.inatrading.feature.favorite.domain.FavoriteGroupSummary>,
createMode: Boolean,
isLoading: Boolean,
onDismiss: () -> Unit,
onCreateMode: () -> Unit,
onCancelCreate: () -> Unit,
onCreateGroup: (String) -> Unit,
onSelectGroup: (String) -> Unit,
) {
var groupName by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = {
if (!isLoading) onDismiss()
},
title = {
Text(
text = if (createMode) {
stringResource(R.string.favorite_create_collection)
} else {
stringResource(R.string.products_select_favorite_group)
},
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
)
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
if (isLoading) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
) {
CircularProgressIndicator(
color = BrandRed,
modifier = Modifier.size(28.dp),
strokeWidth = 2.5.dp,
)
}
} else if (createMode) {
InaTextField(
value = groupName,
onValueChange = { groupName = it },
placeholder = stringResource(R.string.favorite_name_placeholder),
)
} else {
Text(
text = stringResource(R.string.products_choose_favorite_group),
style = MaterialTheme.typography.bodyMedium,
color = OnSurfaceVariant,
)
groups.forEach { group ->
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.background(SurfaceContainerLow)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(),
onClick = { onSelectGroup(group.id) },
)
.padding(horizontal = 14.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
text = group.name,
fontWeight = FontWeight.SemiBold,
color = OnSurface,
)
Text(
text = stringResource(R.string.products_favorite_group_count, group.itemCount),
style = MaterialTheme.typography.bodySmall,
color = OnSurfaceVariant,
)
}
Icon(
imageVector = Icons.AutoMirrored.Outlined.ArrowForward,
contentDescription = null,
tint = BrandRed,
modifier = Modifier.size(18.dp),
)
}
}
}
}
},
confirmButton = {
when {
createMode -> {
TextButton(
enabled = groupName.trim().isNotEmpty() && !isLoading,
onClick = {
onCreateGroup(groupName.trim())
groupName = ""
},
) {
Text(stringResource(R.string.favorite_create_action))
}
}
else -> {
TextButton(
enabled = !isLoading,
onClick = onCreateMode,
) {
Text(stringResource(R.string.favorite_create_collection))
}
}
}
},
dismissButton = {
TextButton(
enabled = !isLoading,
onClick = {
if (createMode && groups.isNotEmpty()) {
onCancelCreate()
} else {
onDismiss()
}
},
) {
Text(stringResource(R.string.dialog_cancel))
}
},
)
}
@Composable
private fun ProductItem.displayPrice(): String {
val min = minPrice
val max = maxPrice
if (min <= 0.0 && max <= 0.0) return stringResource(R.string.products_contact_price)
return if (min > 0.0 && max > 0.0 && min != max) {
"${min.toCurrency()} - ${max.toCurrency()}"
} else {
maxOf(min, max).toCurrency()
}
}
private fun ProductItem.canAddToCartFromList(): Boolean {
return maxOf(minPrice, maxPrice) > 0.0
}
private fun Double.toCurrency(): String {
val formatter = NumberFormat.getCurrencyInstance(Locale("in", "ID")).apply {
maximumFractionDigits = 0
}
return formatter.format(this).replace("Rp", "Rp ")
}

View File

@ -0,0 +1,332 @@
package id.iiyh.inatrading.feature.product.presentation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import id.iiyh.inatrading.core.data.remote.SessionExpiredException
import id.iiyh.inatrading.core.data.local.SessionManager
import id.iiyh.inatrading.feature.auth.domain.repository.AuthRepository
import id.iiyh.inatrading.feature.favorite.domain.FavoriteGroupSummary
import id.iiyh.inatrading.feature.favorite.domain.FavoriteRepository
import id.iiyh.inatrading.feature.product.data.model.ProductItem
import id.iiyh.inatrading.feature.product.domain.ProductRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.SharingStarted
import javax.inject.Inject
private const val PRODUCT_PAGE_LIMIT = 10
data class ProductsUiState(
val items: List<ProductItem> = emptyList(),
val isInitialLoading: Boolean = false,
val isAppending: Boolean = false,
val errorMessage: String? = null,
val currentPage: Int = 0,
val totalPage: Int = 0,
val totalItem: Int = 0,
val pageSize: Int = PRODUCT_PAGE_LIMIT,
val selectedCategory: ProductCategory = ProductCategory.All,
val searchQuery: String = "",
val favoriteIds: Set<String> = emptySet(),
val isLoggedIn: Boolean = false,
val favoriteGroups: List<FavoriteGroupSummary> = emptyList(),
val favoriteProductId: String? = null,
val favoriteDialogOpen: Boolean = false,
val favoriteCreateMode: Boolean = false,
val favoriteActionLoading: Boolean = false,
val favoriteMessage: String? = null,
val sessionExpired: Boolean = false,
) {
val hasMore: Boolean
get() = totalPage == 0 || currentPage < totalPage || items.size < totalItem
val filteredItems: List<ProductItem>
get() = items.filter { product ->
val matchesCategory = when (selectedCategory) {
ProductCategory.All -> true
ProductCategory.Local -> product.market.equals("Local Market", ignoreCase = true)
ProductCategory.International -> product.market.equals("International", ignoreCase = true)
}
val matchesQuery = searchQuery.isBlank() ||
product.name.contains(searchQuery, ignoreCase = true) ||
product.market.orEmpty().contains(searchQuery, ignoreCase = true)
matchesCategory && matchesQuery
}
}
enum class ProductCategory(val label: String) {
All("Semua"),
Local("Lokal"),
International("Internasional"),
}
@HiltViewModel
class ProductsViewModel @Inject constructor(
private val productRepository: ProductRepository,
private val favoriteRepository: FavoriteRepository,
private val authRepository: AuthRepository,
sessionManager: SessionManager,
) : ViewModel() {
private val baseState = MutableStateFlow(ProductsUiState())
val uiState: StateFlow<ProductsUiState> = combine(
baseState,
sessionManager.token,
) { state, token ->
state.copy(isLoggedIn = !token.isNullOrBlank())
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = ProductsUiState(),
)
init {
loadInitial()
}
fun loadInitial() {
baseState.update {
it.copy(
items = emptyList(),
isInitialLoading = true,
isAppending = false,
errorMessage = null,
currentPage = 0,
totalPage = 0,
totalItem = 0,
)
}
loadPage(1)
}
fun loadNextPage() {
val state = baseState.value
if (state.isInitialLoading || state.isAppending || !state.hasMore) return
loadPage(state.currentPage + 1)
}
fun onSearchQueryChange(value: String) {
baseState.update { it.copy(searchQuery = value) }
}
fun onCategorySelected(category: ProductCategory) {
baseState.update { it.copy(selectedCategory = category) }
}
fun toggleFavorite(productId: String) {
if (baseState.value.favoriteIds.contains(productId)) {
removeProductFromFavorite(productId)
return
}
openFavoriteDialog(productId)
}
fun dismissFavoriteDialog() {
baseState.update {
it.copy(
favoriteDialogOpen = false,
favoriteCreateMode = false,
favoriteActionLoading = false,
favoriteGroups = emptyList(),
favoriteProductId = null,
)
}
}
fun showCreateFavoriteGroup() {
baseState.update { it.copy(favoriteCreateMode = true) }
}
fun hideCreateFavoriteGroup() {
baseState.update { it.copy(favoriteCreateMode = false) }
}
fun consumeFavoriteMessage() {
baseState.update { it.copy(favoriteMessage = null) }
}
fun consumeSessionExpired() {
baseState.update { it.copy(sessionExpired = false) }
}
fun createFavoriteGroup(name: String) {
val productId = baseState.value.favoriteProductId ?: return
viewModelScope.launch {
baseState.update { it.copy(favoriteActionLoading = true, favoriteMessage = null) }
favoriteRepository.createFavoriteGroup(name)
.onSuccess {
favoriteRepository.getFavoriteGroups()
.onSuccess { groups ->
baseState.update {
it.copy(
favoriteGroups = groups,
favoriteCreateMode = false,
favoriteActionLoading = false,
favoriteProductId = productId,
)
}
}
.onFailure { handleFavoriteFailure(it, "Gagal memuat koleksi favorit") }
}
.onFailure { handleFavoriteFailure(it, "Gagal membuat koleksi favorit") }
}
}
fun addProductToFavorite(favoriteId: String) {
val productId = baseState.value.favoriteProductId ?: return
viewModelScope.launch {
baseState.update { it.copy(favoriteActionLoading = true, favoriteMessage = null) }
favoriteRepository.addProductToFavorite(
favoriteId = favoriteId,
productId = productId,
).onSuccess {
baseState.update { state ->
state.copy(
favoriteIds = state.favoriteIds + productId,
favoriteDialogOpen = false,
favoriteCreateMode = false,
favoriteActionLoading = false,
favoriteGroups = emptyList(),
favoriteProductId = null,
favoriteMessage = PRODUCT_FAVORITE_ADDED_MESSAGE,
)
}
}.onFailure {
handleFavoriteFailure(it, "Gagal menambahkan produk ke favorit")
}
}
}
private fun removeProductFromFavorite(productId: String) {
viewModelScope.launch {
baseState.update { it.copy(favoriteActionLoading = true, favoriteMessage = null) }
favoriteRepository.removeProductFromFavorite(productId)
.onSuccess {
baseState.update { state ->
state.copy(
favoriteIds = state.favoriteIds - productId,
favoriteActionLoading = false,
favoriteMessage = PRODUCT_FAVORITE_REMOVED_MESSAGE,
)
}
}
.onFailure {
handleFavoriteFailure(it, "Gagal menghapus produk dari favorit")
}
}
}
private fun loadPage(page: Int) {
viewModelScope.launch {
baseState.update {
it.copy(
isInitialLoading = page == 1,
isAppending = page > 1,
errorMessage = if (page == 1) null else it.errorMessage,
)
}
productRepository.getProducts(page = page, limit = PRODUCT_PAGE_LIMIT)
.onSuccess { result ->
baseState.update { state ->
val mergedItems = if (page == 1) result.items else state.items + result.items
val responseFavoriteIds = result.items
.filter(ProductItem::isFavorite)
.map(ProductItem::id)
.toSet()
val favoriteIds = if (page == 1) {
mergedItems.filter(ProductItem::isFavorite).map(ProductItem::id).toSet()
} else {
state.favoriteIds + responseFavoriteIds
}
state.copy(
items = mergedItems,
isInitialLoading = false,
isAppending = false,
errorMessage = null,
currentPage = page,
totalPage = result.totalPage,
totalItem = result.totalItem,
favoriteIds = favoriteIds,
)
}
}
.onFailure { error ->
baseState.update { state ->
state.copy(
isInitialLoading = false,
isAppending = false,
errorMessage = error.message ?: "Gagal memuat produk",
)
}
}
}
}
private fun openFavoriteDialog(productId: String) {
viewModelScope.launch {
baseState.update {
it.copy(
favoriteDialogOpen = true,
favoriteProductId = productId,
favoriteCreateMode = false,
favoriteActionLoading = true,
favoriteMessage = null,
sessionExpired = false,
)
}
favoriteRepository.getFavoriteGroups()
.onSuccess { groups ->
baseState.update {
it.copy(
favoriteGroups = groups,
favoriteCreateMode = groups.isEmpty(),
favoriteActionLoading = false,
)
}
}
.onFailure {
handleFavoriteFailure(it, "Gagal memuat koleksi favorit")
}
}
}
private suspend fun handleFavoriteFailure(
error: Throwable,
fallbackMessage: String,
) {
if (error is SessionExpiredException) {
authRepository.logout()
baseState.update {
it.copy(
favoriteDialogOpen = false,
favoriteCreateMode = false,
favoriteActionLoading = false,
favoriteGroups = emptyList(),
favoriteProductId = null,
favoriteMessage = null,
sessionExpired = true,
)
}
return
}
baseState.update {
it.copy(
favoriteActionLoading = false,
favoriteMessage = error.message ?: fallbackMessage,
)
}
}
}
const val PRODUCT_FAVORITE_ADDED_MESSAGE = "PRODUCT_FAVORITE_ADDED"
const val PRODUCT_FAVORITE_REMOVED_MESSAGE = "PRODUCT_FAVORITE_REMOVED"

View File

@ -0,0 +1,26 @@
package id.iiyh.inatrading.feature.profile.data.model
data class BuyerProfile(
val email: String? = null,
val imageId: String? = null,
val mobile: String? = null,
val name: String? = null,
val profileDescription: String? = null,
)
data class UpdateBuyerProfileRequest(
val email: String? = null,
val imageId: String? = null,
val mobile: String? = null,
val name: String? = null,
val profileDescription: String? = null,
)
data class ChangePasswordRequest(
val newPassword: String,
val oldPassword: String,
)
data class FileUploadData(
val fileId: String? = null,
)

View File

@ -0,0 +1,35 @@
package id.iiyh.inatrading.feature.profile.data.model
data class ProvinceItem(
val code: String? = null,
val id: String? = null,
val name: String? = null,
)
data class ProvinceListResponse(
val responseCode: String? = null,
val responseDesc: String? = null,
val rows: List<ProvinceItem> = emptyList(),
val totalItem: Int = 0,
val totalPage: Int = 0,
) {
val isSuccess: Boolean get() = responseCode == "0000"
}
data class CityItem(
val code: String? = null,
val id: String? = null,
val name: String? = null,
val provinceId: String? = null,
val provinceName: String? = null,
)
data class CityListResponse(
val responseCode: String? = null,
val responseDesc: String? = null,
val rows: List<CityItem> = emptyList(),
val totalItem: Int = 0,
val totalPage: Int = 0,
) {
val isSuccess: Boolean get() = responseCode == "0000"
}

View File

@ -0,0 +1,44 @@
package id.iiyh.inatrading.feature.profile.data.model
import com.google.gson.annotations.SerializedName
data class ShippingAddress(
val address: String? = null,
val city: String? = null,
val country: String? = null,
val id: String? = null,
val isPrimary: Boolean = false,
val label: String? = null,
val latitude: Double? = null,
val longitude: Double? = null,
val postalCode: String? = null,
val province: String? = null,
val recipient: String? = null,
@SerializedName(value = "mobile", alternate = ["phone"])
val mobile: String? = null,
)
data class CreateShippingAddressRequest(
val label: String,
val recipient: String,
@SerializedName("mobile")
val mobile: String,
val address: String,
val country: String,
val province: String,
val city: String,
val postalCode: String,
val isPrimary: Boolean,
val latitude: Double? = null,
val longitude: Double? = null,
)
data class ShippingAddressListResponse(
val responseCode: String? = null,
val responseDesc: String? = null,
val rows: List<ShippingAddress> = emptyList(),
val totalItem: Int = 0,
val totalPage: Int = 0,
) {
val isSuccess: Boolean get() = responseCode == "0000"
}

View File

@ -0,0 +1,418 @@
package id.iiyh.inatrading.feature.profile.presentation
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.LocationOn
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import id.iiyh.inatrading.R
import id.iiyh.inatrading.core.ui.components.InaInnerTopAppBar
import id.iiyh.inatrading.core.ui.components.InaPrimaryButton
import id.iiyh.inatrading.core.ui.components.InaTextField
import id.iiyh.inatrading.core.ui.theme.AccentPurple
import id.iiyh.inatrading.core.ui.theme.Background
import id.iiyh.inatrading.core.ui.theme.BrandRed
import id.iiyh.inatrading.core.ui.theme.BrandRedContainer
import id.iiyh.inatrading.core.ui.theme.ManropeFontFamily
import id.iiyh.inatrading.core.ui.theme.OnSurface
import id.iiyh.inatrading.core.ui.theme.OnSurfaceVariant
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerHighest
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerLow
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerLowest
@Composable
fun AddShippingAddressScreen(
onBack: () -> Unit,
onSaveSuccess: () -> Unit,
viewModel: AddShippingAddressViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current
val locationPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) {
viewModel.onLocationRequestStarted()
requestCurrentLocation(
context = context,
onSuccess = viewModel::onLocationCaptured,
onError = viewModel::onLocationError,
)
} else {
viewModel.onLocationError(context.getString(R.string.address_pin_permission_required))
}
}
LaunchedEffect(uiState.infoMessage) {
uiState.infoMessage?.let {
snackbarHostState.showSnackbar(it)
viewModel.consumeInfoMessage()
}
}
LaunchedEffect(uiState.saveSuccess) {
if (uiState.saveSuccess) {
viewModel.consumeSaveSuccess()
onSaveSuccess()
}
}
Scaffold(
topBar = { InaInnerTopAppBar(onBack = onBack) },
snackbarHost = { SnackbarHost(snackbarHostState) },
containerColor = Background,
) { innerPadding ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.background(Background),
contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 16.dp, bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
item {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(32.dp))
.background(SurfaceContainerHighest.copy(alpha = 0.35f))
.padding(horizontal = 24.dp, vertical = 28.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
text = stringResource(R.string.add_shipping_address_eyebrow),
style = MaterialTheme.typography.labelSmall,
letterSpacing = 1.6.sp,
color = AccentPurple,
)
Text(
text = stringResource(R.string.add_shipping_address_title),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.ExtraBold,
fontSize = 34.sp,
lineHeight = 38.sp,
color = OnSurface,
)
Text(
text = stringResource(R.string.add_shipping_address_body),
style = MaterialTheme.typography.bodyMedium,
color = OnSurfaceVariant,
)
}
}
item {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(24.dp))
.background(SurfaceContainerLowest)
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
Text(
text = stringResource(R.string.add_shipping_address_label_title),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 15.sp,
color = OnSurface,
)
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
AddressLabelChip(
text = stringResource(R.string.add_shipping_address_label_home),
selected = uiState.label.equals("rumah", ignoreCase = true) || uiState.label.equals("home", ignoreCase = true),
onClick = { viewModel.onLabelSelected("rumah") },
)
AddressLabelChip(
text = stringResource(R.string.add_shipping_address_label_office),
selected = uiState.label.equals("kantor", ignoreCase = true) || uiState.label.equals("office", ignoreCase = true),
onClick = { viewModel.onLabelSelected("kantor") },
)
AddressLabelChip(
text = stringResource(R.string.add_shipping_address_label_warehouse),
selected = uiState.label.equals("gudang", ignoreCase = true) || uiState.label.equals("warehouse", ignoreCase = true),
onClick = { viewModel.onLabelSelected("gudang") },
)
AddressLabelChip(
text = stringResource(R.string.add_shipping_address_label_other),
selected = uiState.label == AddShippingAddressViewModel.CUSTOM_LABEL_KEY,
icon = Icons.Outlined.Add,
onClick = viewModel::onCustomLabelSelected,
)
}
if (uiState.label == AddShippingAddressViewModel.CUSTOM_LABEL_KEY) {
InaTextField(
value = uiState.customLabel,
onValueChange = viewModel::onCustomLabelChange,
label = stringResource(R.string.add_shipping_address_custom_label_title),
placeholder = stringResource(R.string.add_shipping_address_custom_label_placeholder),
)
}
InaTextField(
value = uiState.recipient,
onValueChange = viewModel::onRecipientChange,
label = stringResource(R.string.add_shipping_address_recipient_label),
placeholder = stringResource(R.string.add_shipping_address_recipient_placeholder),
)
InaTextField(
value = uiState.phone,
onValueChange = viewModel::onPhoneChange,
label = stringResource(R.string.add_shipping_address_phone_label),
placeholder = stringResource(R.string.add_shipping_address_phone_placeholder),
)
AddressDropdownField(
value = if (uiState.countryKey == ADDRESS_COUNTRY_INDONESIA) {
stringResource(R.string.add_shipping_address_country_indonesia)
} else {
stringResource(R.string.add_shipping_address_country_other)
},
label = stringResource(R.string.add_shipping_address_country_label),
placeholder = stringResource(R.string.add_shipping_address_country_placeholder),
options = listOf(
DropdownOption(ADDRESS_COUNTRY_INDONESIA, stringResource(R.string.add_shipping_address_country_indonesia)),
DropdownOption(ADDRESS_COUNTRY_OTHER, stringResource(R.string.add_shipping_address_country_other)),
),
onOptionSelected = { viewModel.onCountrySelected(it.id) },
)
if (uiState.countryKey == ADDRESS_COUNTRY_OTHER) {
InaTextField(
value = uiState.customCountry,
onValueChange = viewModel::onCustomCountryChange,
label = stringResource(R.string.add_shipping_address_country_custom_label),
placeholder = stringResource(R.string.add_shipping_address_country_custom_placeholder),
)
InaTextField(
value = uiState.province,
onValueChange = viewModel::onProvinceChange,
label = stringResource(R.string.add_shipping_address_province_label),
placeholder = stringResource(R.string.add_shipping_address_province_placeholder),
)
InaTextField(
value = uiState.city,
onValueChange = viewModel::onCityChange,
label = stringResource(R.string.add_shipping_address_city_label),
placeholder = stringResource(R.string.add_shipping_address_city_placeholder),
)
} else {
AddressDropdownField(
value = uiState.province,
label = stringResource(R.string.add_shipping_address_province_label),
placeholder = if (uiState.isLoadingProvinces) {
stringResource(R.string.address_loading_provinces)
} else {
stringResource(R.string.add_shipping_address_province_placeholder)
},
options = uiState.provinces.mapNotNull {
val id = it.id ?: return@mapNotNull null
val label = it.name ?: return@mapNotNull null
DropdownOption(id, label)
},
enabled = !uiState.isLoadingProvinces && uiState.provinces.isNotEmpty(),
onOptionSelected = { viewModel.onProvinceSelected(it.id) },
)
AddressDropdownField(
value = uiState.city,
label = stringResource(R.string.add_shipping_address_city_label),
placeholder = if (uiState.selectedProvinceId == null) {
stringResource(R.string.address_select_province_first)
} else if (uiState.isLoadingCities) {
stringResource(R.string.address_loading_cities)
} else {
stringResource(R.string.add_shipping_address_city_placeholder)
},
options = uiState.cities.mapNotNull {
val id = it.id ?: return@mapNotNull null
val label = it.name ?: return@mapNotNull null
DropdownOption(id, label)
},
enabled = uiState.selectedProvinceId != null && !uiState.isLoadingCities,
onOptionSelected = { viewModel.onCitySelected(it.id) },
)
}
InaTextField(
value = uiState.postalCode,
onValueChange = viewModel::onPostalCodeChange,
label = stringResource(R.string.add_shipping_address_postal_label),
placeholder = stringResource(R.string.add_shipping_address_postal_placeholder),
)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = stringResource(R.string.add_shipping_address_full_label),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 15.sp,
color = OnSurface,
)
androidx.compose.material3.OutlinedTextField(
value = uiState.address,
onValueChange = viewModel::onAddressChange,
modifier = Modifier.fillMaxWidth(),
minLines = 4,
shape = RoundedCornerShape(16.dp),
textStyle = MaterialTheme.typography.bodyLarge.copy(color = OnSurface),
placeholder = {
Text(
text = stringResource(R.string.add_shipping_address_full_placeholder),
color = OnSurfaceVariant.copy(alpha = 0.6f),
)
},
colors = TextFieldDefaults.colors(
focusedTextColor = OnSurface,
unfocusedTextColor = OnSurface,
focusedContainerColor = Color.White,
unfocusedContainerColor = Color.White,
disabledContainerColor = Color.White,
focusedIndicatorColor = BrandRed.copy(alpha = 0.45f),
unfocusedIndicatorColor = OnSurfaceVariant.copy(alpha = 0.28f),
focusedPlaceholderColor = OnSurfaceVariant.copy(alpha = 0.6f),
unfocusedPlaceholderColor = OnSurfaceVariant.copy(alpha = 0.6f),
),
)
}
AddressPinTeaser(
latitude = uiState.latitude,
longitude = uiState.longitude,
isLoading = uiState.isResolvingLocation,
onClick = {
if (hasLocationPermission(context)) {
viewModel.onLocationRequestStarted()
requestCurrentLocation(
context = context,
onSuccess = viewModel::onLocationCaptured,
onError = viewModel::onLocationError,
)
} else {
locationPermissionLauncher.launch(android.Manifest.permission.ACCESS_FINE_LOCATION)
}
},
)
CurrentLocationPermissionMessage()
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = stringResource(R.string.add_shipping_address_primary_label),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 15.sp,
color = OnSurface,
)
Text(
text = stringResource(R.string.add_shipping_address_primary_body),
style = MaterialTheme.typography.bodySmall,
color = OnSurfaceVariant,
)
}
Switch(
checked = uiState.isPrimary,
onCheckedChange = viewModel::onPrimaryChange,
)
}
InaPrimaryButton(
text = stringResource(R.string.add_shipping_address_save),
onClick = viewModel::saveAddress,
isLoading = uiState.isSaving,
enabled = !uiState.isResolvingLocation,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}
}
@Composable
private fun AddressLabelChip(
text: String,
selected: Boolean,
icon: androidx.compose.ui.graphics.vector.ImageVector? = null,
onClick: () -> Unit,
) {
Box(
modifier = Modifier
.clip(CircleShape)
.background(if (selected) BrandRedContainer else SurfaceContainerLow)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(),
onClick = onClick,
)
.padding(horizontal = 16.dp, vertical = 10.dp),
contentAlignment = Alignment.Center,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (icon != null) {
Icon(
imageVector = icon,
contentDescription = null,
tint = if (selected) BrandRed else OnSurfaceVariant,
modifier = Modifier.size(16.dp),
)
}
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
color = if (selected) BrandRed else OnSurfaceVariant,
)
}
}
}

View File

@ -0,0 +1,337 @@
package id.iiyh.inatrading.feature.profile.presentation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import id.iiyh.inatrading.feature.auth.domain.repository.AuthRepository
import id.iiyh.inatrading.feature.profile.data.model.CityItem
import id.iiyh.inatrading.feature.profile.data.model.CreateShippingAddressRequest
import id.iiyh.inatrading.feature.profile.data.model.ProvinceItem
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
const val ADDRESS_COUNTRY_INDONESIA = "indonesia"
const val ADDRESS_COUNTRY_OTHER = "other"
data class AddShippingAddressUiState(
val label: String = "rumah",
val customLabel: String = "",
val recipient: String = "",
val phone: String = "",
val countryKey: String = ADDRESS_COUNTRY_INDONESIA,
val customCountry: String = "",
val provinces: List<ProvinceItem> = emptyList(),
val cities: List<CityItem> = emptyList(),
val selectedProvinceId: String? = null,
val selectedCityId: String? = null,
val isLoadingProvinces: Boolean = false,
val isLoadingCities: Boolean = false,
val province: String = "",
val city: String = "",
val postalCode: String = "",
val address: String = "",
val isPrimary: Boolean = true,
val latitude: Double? = null,
val longitude: Double? = null,
val isResolvingLocation: Boolean = false,
val isSaving: Boolean = false,
val infoMessage: String? = null,
val saveSuccess: Boolean = false,
)
@HiltViewModel
class AddShippingAddressViewModel @Inject constructor(
private val authRepository: AuthRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(AddShippingAddressUiState())
val uiState: StateFlow<AddShippingAddressUiState> = _uiState.asStateFlow()
init {
loadProvinces()
}
fun onLabelSelected(value: String) {
_uiState.update { it.copy(label = value, customLabel = "") }
}
fun onCustomLabelSelected() {
_uiState.update {
it.copy(
label = CUSTOM_LABEL_KEY,
customLabel = it.customLabel,
)
}
}
fun onCustomLabelChange(value: String) {
_uiState.update {
it.copy(
label = CUSTOM_LABEL_KEY,
customLabel = value,
)
}
}
fun onRecipientChange(value: String) {
_uiState.update { it.copy(recipient = value) }
}
fun onPhoneChange(value: String) {
_uiState.update { it.copy(phone = value.filter(Char::isDigit)) }
}
fun onCountrySelected(value: String) {
_uiState.update {
it.copy(
countryKey = value,
customCountry = if (value == ADDRESS_COUNTRY_OTHER) it.customCountry else "",
province = "",
city = "",
selectedProvinceId = null,
selectedCityId = null,
cities = emptyList(),
)
}
if (value == ADDRESS_COUNTRY_INDONESIA) {
loadProvinces()
}
}
fun onCustomCountryChange(value: String) {
_uiState.update { it.copy(customCountry = value) }
}
fun onProvinceSelected(id: String) {
val province = _uiState.value.provinces.firstOrNull { it.id == id } ?: return
_uiState.update {
it.copy(
selectedProvinceId = province.id,
province = province.name.orEmpty(),
selectedCityId = null,
city = "",
cities = emptyList(),
)
}
province.id?.let(::loadCities)
}
fun onProvinceChange(value: String) {
_uiState.update { it.copy(province = value) }
}
fun onCitySelected(id: String) {
val city = _uiState.value.cities.firstOrNull { it.id == id } ?: return
_uiState.update {
it.copy(
selectedCityId = city.id,
city = city.name.orEmpty(),
)
}
}
fun onCityChange(value: String) {
_uiState.update { it.copy(city = value) }
}
fun onPostalCodeChange(value: String) {
_uiState.update { it.copy(postalCode = value.filter(Char::isDigit)) }
}
fun onAddressChange(value: String) {
_uiState.update { it.copy(address = value) }
}
fun onPrimaryChange(value: Boolean) {
_uiState.update { it.copy(isPrimary = value) }
}
fun onLocationCaptured(latitude: Double, longitude: Double) {
_uiState.update {
it.copy(
latitude = latitude,
longitude = longitude,
isResolvingLocation = false,
infoMessage = "Lokasi saat ini berhasil digunakan.",
)
}
}
fun onLocationError(message: String) {
_uiState.update { it.copy(isResolvingLocation = false, infoMessage = message) }
}
fun onLocationRequestStarted() {
_uiState.update {
it.copy(
isResolvingLocation = true,
infoMessage = "Mengambil koordinat lokasi saat ini...",
)
}
}
fun onPinPointClick() {
_uiState.update {
it.copy(infoMessage = "Pemilihan pin point map akan segera tersedia.")
}
}
fun saveAddress() {
val state = _uiState.value
if (state.recipient.isBlank()) {
_uiState.update { it.copy(infoMessage = "Nama penerima wajib diisi.") }
return
}
if (state.phone.isBlank()) {
_uiState.update { it.copy(infoMessage = "Nomor telepon wajib diisi.") }
return
}
if (state.province.isBlank()) {
_uiState.update { it.copy(infoMessage = "Provinsi wajib diisi.") }
return
}
if (state.city.isBlank()) {
_uiState.update { it.copy(infoMessage = "Kota wajib diisi.") }
return
}
if (state.postalCode.isBlank()) {
_uiState.update { it.copy(infoMessage = "Kode pos wajib diisi.") }
return
}
if (state.address.isBlank()) {
_uiState.update { it.copy(infoMessage = "Detail alamat wajib diisi.") }
return
}
if (state.label == CUSTOM_LABEL_KEY && state.customLabel.isBlank()) {
_uiState.update { it.copy(infoMessage = "Label alamat wajib diisi.") }
return
}
if (state.resolvedCountry().isBlank()) {
_uiState.update { it.copy(infoMessage = "Negara wajib dipilih.") }
return
}
if (state.countryKey == ADDRESS_COUNTRY_INDONESIA && state.selectedProvinceId == null) {
_uiState.update { it.copy(infoMessage = "Provinsi wajib dipilih.") }
return
}
if (state.countryKey == ADDRESS_COUNTRY_INDONESIA && state.selectedCityId == null) {
_uiState.update { it.copy(infoMessage = "Kota/Kabupaten wajib dipilih.") }
return
}
if (state.isResolvingLocation) {
_uiState.update { it.copy(infoMessage = "Tunggu sampai koordinat lokasi selesai diambil.") }
return
}
viewModelScope.launch {
_uiState.update { it.copy(isSaving = true, infoMessage = null) }
authRepository.createAddress(
CreateShippingAddressRequest(
label = state.submittedLabel(),
recipient = state.recipient.trim(),
mobile = normalizePhone(state.phone),
address = state.address.trim(),
country = state.resolvedCountry(),
province = state.province.trim(),
city = state.city.trim(),
postalCode = state.postalCode.trim(),
isPrimary = state.isPrimary,
latitude = state.latitude,
longitude = state.longitude,
)
).onSuccess {
_uiState.update {
it.copy(
isSaving = false,
saveSuccess = true,
infoMessage = "Alamat berhasil disimpan.",
)
}
}.onFailure { error ->
_uiState.update {
it.copy(
isSaving = false,
infoMessage = error.message ?: "Gagal menyimpan alamat",
)
}
}
}
}
fun consumeInfoMessage() {
_uiState.update { it.copy(infoMessage = null) }
}
fun consumeSaveSuccess() {
_uiState.update { it.copy(saveSuccess = false) }
}
private fun loadProvinces() {
viewModelScope.launch {
_uiState.update { it.copy(isLoadingProvinces = true) }
authRepository.getProvinces()
.onSuccess { provinces ->
_uiState.update {
it.copy(
isLoadingProvinces = false,
provinces = provinces.sortedBy { item -> item.name.orEmpty() },
)
}
}
.onFailure { error ->
_uiState.update {
it.copy(
isLoadingProvinces = false,
infoMessage = error.message ?: "Gagal memuat provinsi",
)
}
}
}
}
private fun loadCities(provinceId: String) {
viewModelScope.launch {
_uiState.update { it.copy(isLoadingCities = true) }
authRepository.getCities(provinceId)
.onSuccess { cities ->
_uiState.update {
it.copy(
isLoadingCities = false,
cities = cities.sortedBy { item -> item.name.orEmpty() },
)
}
}
.onFailure { error ->
_uiState.update {
it.copy(
isLoadingCities = false,
infoMessage = error.message ?: "Gagal memuat kota",
)
}
}
}
}
private fun normalizePhone(raw: String): String {
val digits = raw.filter(Char::isDigit)
if (digits.startsWith("0")) return digits
if (digits.startsWith("62")) return "0${digits.removePrefix("62")}"
return "0$digits"
}
private fun AddShippingAddressUiState.submittedLabel(): String {
return if (label == CUSTOM_LABEL_KEY) customLabel.trim() else label
}
private fun AddShippingAddressUiState.resolvedCountry(): String {
return if (countryKey == ADDRESS_COUNTRY_OTHER) customCountry.trim() else "Indonesia"
}
companion object {
const val CUSTOM_LABEL_KEY = "__custom__"
}
}

View File

@ -0,0 +1,329 @@
package id.iiyh.inatrading.feature.profile.presentation
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationManager
import android.os.Build
import android.os.CancellationSignal
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.KeyboardArrowDown
import androidx.compose.material.icons.outlined.LocationOn
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import id.iiyh.inatrading.R
import id.iiyh.inatrading.core.ui.theme.BrandRed
import id.iiyh.inatrading.core.ui.theme.OnSurface
import id.iiyh.inatrading.core.ui.theme.OnSurfaceVariant
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerHighest
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerLow
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerLowest
import androidx.compose.ui.window.Dialog
data class DropdownOption(
val id: String,
val label: String,
)
@Composable
fun AddressDropdownField(
value: String,
label: String,
placeholder: String,
options: List<DropdownOption>,
enabled: Boolean = true,
onOptionSelected: (DropdownOption) -> Unit,
) {
var dialogOpen by remember { mutableStateOf(false) }
Box(
modifier = Modifier
.fillMaxWidth()
.clickable(
enabled = enabled,
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = { dialogOpen = true },
),
) {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = OnSurfaceVariant,
modifier = Modifier.padding(start = 16.dp, bottom = 6.dp),
)
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
color = SurfaceContainerLow,
tonalElevation = 0.dp,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp),
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
) {
Text(
text = value.ifBlank { placeholder },
style = MaterialTheme.typography.bodyLarge,
color = if (value.isBlank()) {
OnSurfaceVariant.copy(alpha = 0.65f)
} else {
OnSurface
},
modifier = Modifier.weight(1f),
)
Spacer(modifier = Modifier.width(12.dp))
Icon(
imageVector = Icons.Outlined.KeyboardArrowDown,
contentDescription = null,
tint = OnSurfaceVariant,
)
}
}
}
}
if (dialogOpen) {
Dialog(onDismissRequest = { dialogOpen = false }) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp),
color = SurfaceContainerLowest,
tonalElevation = 4.dp,
shadowElevation = 12.dp,
) {
Column(
modifier = Modifier.padding(vertical = 12.dp),
) {
Text(
text = label,
style = MaterialTheme.typography.titleMedium,
color = OnSurface,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
)
Column(
modifier = Modifier
.heightIn(max = 360.dp)
.verticalScroll(rememberScrollState()),
) {
options.forEach { option ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
dialogOpen = false
onOptionSelected(option)
}
.padding(horizontal = 20.dp, vertical = 16.dp),
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
) {
Text(
text = option.label,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodyLarge,
color = OnSurface,
)
}
}
}
}
}
}
}
}
@Composable
fun CurrentLocationPermissionMessage() {
val context = LocalContext.current
Box(
modifier = Modifier.padding(top = 6.dp),
) {
Text(
text = if (hasLocationPermission(context)) {
stringResource(R.string.address_pin_helper_ready)
} else {
stringResource(R.string.address_pin_helper_permission)
},
style = MaterialTheme.typography.bodySmall,
color = OnSurfaceVariant,
)
}
}
@Composable
fun AddressPinTeaser(
latitude: Double?,
longitude: Double?,
isLoading: Boolean = false,
onClick: () -> Unit,
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(132.dp)
.background(
brush = androidx.compose.ui.graphics.Brush.linearGradient(
colors = listOf(
SurfaceContainerHighest,
SurfaceContainerLow,
SurfaceContainerLowest,
)
),
shape = RoundedCornerShape(20.dp),
)
.clickable(
enabled = !isLoading,
interactionSource = remember { MutableInteractionSource() },
indication = androidx.compose.material3.ripple(),
onClick = onClick,
),
) {
Row(
modifier = Modifier
.align(androidx.compose.ui.Alignment.Center)
.background(Color.White.copy(alpha = 0.92f), CircleShape)
.padding(horizontal = 18.dp, vertical = 10.dp),
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp),
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
color = BrandRed,
strokeWidth = 2.dp,
)
} else {
Icon(
imageVector = Icons.Outlined.LocationOn,
contentDescription = null,
tint = BrandRed,
modifier = Modifier.size(20.dp),
)
}
Text(
text = if (isLoading) {
stringResource(R.string.address_pin_loading)
} else if (latitude != null && longitude != null) {
stringResource(R.string.address_pin_coordinates, latitude, longitude)
} else {
stringResource(R.string.add_shipping_address_pin)
},
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold,
color = OnSurface,
)
}
}
}
fun hasLocationPermission(context: Context): Boolean {
val fine = ContextCompat.checkSelfPermission(
context,
Manifest.permission.ACCESS_FINE_LOCATION,
) == PackageManager.PERMISSION_GRANTED
val coarse = ContextCompat.checkSelfPermission(
context,
Manifest.permission.ACCESS_COARSE_LOCATION,
) == PackageManager.PERMISSION_GRANTED
return fine || coarse
}
@SuppressLint("MissingPermission")
fun requestCurrentLocation(
context: Context,
onSuccess: (latitude: Double, longitude: Double) -> Unit,
onError: (String) -> Unit,
) {
if (!hasLocationPermission(context)) {
onError(context.getString(R.string.address_pin_permission_required))
return
}
val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as? LocationManager
?: run {
onError(context.getString(R.string.address_pin_unavailable))
return
}
val providers = locationManager.getProviders(true)
if (providers.isEmpty()) {
onError(context.getString(R.string.address_pin_enable_location))
return
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val provider = when {
providers.contains(LocationManager.GPS_PROVIDER) -> LocationManager.GPS_PROVIDER
providers.contains(LocationManager.NETWORK_PROVIDER) -> LocationManager.NETWORK_PROVIDER
else -> providers.first()
}
locationManager.getCurrentLocation(
provider,
CancellationSignal(),
ContextCompat.getMainExecutor(context),
) { location ->
if (location != null) {
onSuccess(location.latitude, location.longitude)
} else {
emitLastKnownLocation(locationManager, providers, onSuccess, onError, context)
}
}
} else {
emitLastKnownLocation(locationManager, providers, onSuccess, onError, context)
}
}
private fun emitLastKnownLocation(
locationManager: LocationManager,
providers: List<String>,
onSuccess: (latitude: Double, longitude: Double) -> Unit,
onError: (String) -> Unit,
context: Context,
) {
val location = providers
.mapNotNull { provider -> runCatching { locationManager.getLastKnownLocation(provider) }.getOrNull() }
.maxByOrNull(Location::getTime)
if (location != null) {
onSuccess(location.latitude, location.longitude)
} else {
onError(context.getString(R.string.address_pin_location_not_found))
}
}

View File

@ -0,0 +1,532 @@
package id.iiyh.inatrading.feature.profile.presentation
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material.icons.outlined.Shield
import androidx.compose.material.icons.outlined.Visibility
import androidx.compose.material.icons.outlined.VisibilityOff
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import id.iiyh.inatrading.R
import id.iiyh.inatrading.core.ui.components.InaPrimaryButton
import id.iiyh.inatrading.core.ui.components.InaTextField
import id.iiyh.inatrading.core.ui.theme.AccentPurple
import id.iiyh.inatrading.core.ui.theme.AccentPurpleContainer
import id.iiyh.inatrading.core.ui.theme.Background
import id.iiyh.inatrading.core.ui.theme.BrandRed
import id.iiyh.inatrading.core.ui.theme.ManropeFontFamily
import id.iiyh.inatrading.core.ui.theme.OnSurface
import id.iiyh.inatrading.core.ui.theme.OnSurfaceVariant
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerHighest
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerLow
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerLowest
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable
fun ChangePasswordScreen(
onBack: () -> Unit,
onLoggedOut: () -> Unit,
viewModel: ChangePasswordViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
val successMessage = stringResource(R.string.change_password_success)
val coroutineScope = rememberCoroutineScope()
val listState = rememberLazyListState()
val density = LocalDensity.current
val desiredFieldTopPx = with(density) { 96.dp.toPx() }
var oldPasswordTopPx by remember { mutableFloatStateOf(0f) }
var newPasswordTopPx by remember { mutableFloatStateOf(0f) }
var confirmPasswordTopPx by remember { mutableFloatStateOf(0f) }
LaunchedEffect(uiState.infoMessage) {
uiState.infoMessage?.let { message ->
snackbarHostState.showSnackbar(message)
viewModel.consumeInfoMessage()
}
}
LaunchedEffect(uiState.isLoggedOut) {
if (uiState.isLoggedOut) {
onLoggedOut()
}
}
Scaffold(
topBar = { ChangePasswordTopBar(onBack = onBack) },
snackbarHost = { SnackbarHost(snackbarHostState) },
containerColor = Background,
bottomBar = {
Surface(
shadowElevation = 8.dp,
color = Color.White.copy(alpha = 0.92f),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp),
contentAlignment = Alignment.Center,
) {
InaPrimaryButton(
text = stringResource(R.string.change_password_save),
onClick = { viewModel.onSaveClick(successMessage) },
isLoading = uiState.isSaving,
)
}
}
},
) { innerPadding ->
ChangePasswordContent(
uiState = uiState,
onOldPasswordChange = viewModel::onOldPasswordChange,
onNewPasswordChange = viewModel::onNewPasswordChange,
onConfirmPasswordChange = viewModel::onConfirmPasswordChange,
onToggleOldPasswordVisibility = viewModel::toggleOldPasswordVisibility,
onToggleNewPasswordVisibility = viewModel::toggleNewPasswordVisibility,
onToggleConfirmPasswordVisibility = viewModel::toggleConfirmPasswordVisibility,
listState = listState,
oldPasswordModifier = Modifier.onGloballyPositioned {
oldPasswordTopPx = it.positionInRoot().y
},
newPasswordModifier = Modifier.onGloballyPositioned {
newPasswordTopPx = it.positionInRoot().y
},
confirmPasswordModifier = Modifier.onGloballyPositioned {
confirmPasswordTopPx = it.positionInRoot().y
},
onOldPasswordFocused = {
coroutineScope.launch {
delay(150)
scrollFieldNearTop(
listState = listState,
fieldTopPx = oldPasswordTopPx,
desiredTopPx = desiredFieldTopPx,
)
}
},
onNewPasswordFocused = {
coroutineScope.launch {
delay(150)
scrollFieldNearTop(
listState = listState,
fieldTopPx = newPasswordTopPx,
desiredTopPx = desiredFieldTopPx,
)
}
},
onConfirmPasswordFocused = {
coroutineScope.launch {
delay(150)
scrollFieldNearTop(
listState = listState,
fieldTopPx = confirmPasswordTopPx,
desiredTopPx = desiredFieldTopPx,
)
}
},
modifier = Modifier.padding(innerPadding),
)
}
}
@Composable
private fun ChangePasswordTopBar(
onBack: () -> Unit,
) {
Surface(
shadowElevation = 2.dp,
color = Color.White.copy(alpha = 0.88f),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
contentDescription = stringResource(R.string.favorite_back),
tint = BrandRed,
)
}
Text(
text = stringResource(R.string.app_name),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Black,
fontSize = 20.sp,
color = BrandRed,
)
}
IconButton(onClick = {}) {
Icon(
imageVector = Icons.Outlined.MoreVert,
contentDescription = null,
tint = OnSurfaceVariant,
)
}
}
}
}
@Composable
private fun ChangePasswordContent(
uiState: ChangePasswordUiState,
onOldPasswordChange: (String) -> Unit,
onNewPasswordChange: (String) -> Unit,
onConfirmPasswordChange: (String) -> Unit,
onToggleOldPasswordVisibility: () -> Unit,
onToggleNewPasswordVisibility: () -> Unit,
onToggleConfirmPasswordVisibility: () -> Unit,
listState: androidx.compose.foundation.lazy.LazyListState,
oldPasswordModifier: Modifier = Modifier,
newPasswordModifier: Modifier = Modifier,
confirmPasswordModifier: Modifier = Modifier,
onOldPasswordFocused: () -> Unit = {},
onNewPasswordFocused: () -> Unit = {},
onConfirmPasswordFocused: () -> Unit = {},
modifier: Modifier = Modifier,
) {
val strength = rememberPasswordStrength(uiState.newPassword)
LazyColumn(
state = listState,
modifier = modifier
.fillMaxSize()
.background(Background),
contentPadding = PaddingValues(start = 24.dp, end = 24.dp, top = 20.dp, bottom = 120.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.background(Color.Transparent),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(220.dp)
.background(SurfaceContainerHighest.copy(alpha = 0.42f), RoundedCornerShape(bottomStart = 36.dp))
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Text(
text = stringResource(R.string.change_password_eyebrow),
style = MaterialTheme.typography.labelSmall,
color = AccentPurple,
fontWeight = FontWeight.Bold,
)
Text(
text = stringResource(R.string.change_password_title),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.ExtraBold,
fontSize = 40.sp,
color = OnSurface,
lineHeight = 44.sp,
)
Text(
text = stringResource(R.string.change_password_body),
style = MaterialTheme.typography.bodyMedium,
color = OnSurfaceVariant,
)
}
}
}
item {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
ChangePasswordField(
label = stringResource(R.string.change_password_old_label),
value = uiState.oldPassword,
onValueChange = onOldPasswordChange,
placeholder = stringResource(R.string.change_password_old_placeholder),
modifier = oldPasswordModifier,
isPasswordHidden = !uiState.oldPasswordVisible,
isError = uiState.errors.oldPassword != null,
errorMessage = when (uiState.errors.oldPassword) {
"required" -> stringResource(R.string.change_password_error_old_required)
else -> ""
},
onToggleVisibility = onToggleOldPasswordVisibility,
isSaving = uiState.isSaving,
onFocused = onOldPasswordFocused,
)
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
ChangePasswordField(
label = stringResource(R.string.change_password_new_label),
value = uiState.newPassword,
onValueChange = onNewPasswordChange,
placeholder = stringResource(R.string.change_password_new_placeholder),
modifier = newPasswordModifier,
isPasswordHidden = !uiState.newPasswordVisible,
isError = uiState.errors.newPassword != null,
errorMessage = when (uiState.errors.newPassword) {
"required" -> stringResource(R.string.change_password_error_new_required)
"min_length" -> stringResource(R.string.change_password_error_min_length)
"must_differ" -> stringResource(R.string.change_password_error_must_differ)
else -> ""
},
onToggleVisibility = onToggleNewPasswordVisibility,
isSaving = uiState.isSaving,
onFocused = onNewPasswordFocused,
)
PasswordStrengthMeter(
score = strength.score,
label = strength.label,
)
}
ChangePasswordField(
label = stringResource(R.string.change_password_confirm_label),
value = uiState.confirmPassword,
onValueChange = onConfirmPasswordChange,
placeholder = stringResource(R.string.change_password_confirm_placeholder),
modifier = confirmPasswordModifier,
isPasswordHidden = !uiState.confirmPasswordVisible,
isError = uiState.errors.confirmPassword != null,
errorMessage = when (uiState.errors.confirmPassword) {
"mismatch" -> stringResource(R.string.change_password_error_confirmation)
else -> ""
},
onToggleVisibility = onToggleConfirmPasswordVisibility,
isSaving = uiState.isSaving,
onFocused = onConfirmPasswordFocused,
)
}
}
item {
Surface(
shape = RoundedCornerShape(24.dp),
color = SurfaceContainerLowest,
shadowElevation = 2.dp,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(18.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.Top,
) {
Box(
modifier = Modifier
.size(42.dp)
.background(AccentPurpleContainer, CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Outlined.Shield,
contentDescription = null,
tint = AccentPurple,
modifier = Modifier.size(20.dp),
)
}
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = stringResource(R.string.change_password_tip_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = OnSurface,
)
Text(
text = stringResource(R.string.change_password_tip_body),
style = MaterialTheme.typography.bodySmall,
color = OnSurfaceVariant,
)
}
}
}
}
}
}
@Composable
private fun ChangePasswordField(
label: String,
value: String,
onValueChange: (String) -> Unit,
placeholder: String,
modifier: Modifier = Modifier,
isPasswordHidden: Boolean,
isError: Boolean,
errorMessage: String,
onToggleVisibility: () -> Unit,
isSaving: Boolean,
onFocused: () -> Unit = {},
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = OnSurfaceVariant,
fontWeight = FontWeight.Bold,
)
InaTextField(
value = value,
onValueChange = onValueChange,
modifier = modifier,
placeholder = placeholder,
isPassword = isPasswordHidden,
isError = isError,
errorMessage = errorMessage,
onFocusChanged = {
if (it.isFocused) onFocused()
},
trailingIcon = {
Icon(
imageVector = if (isPasswordHidden) {
Icons.Outlined.Visibility
} else {
Icons.Outlined.VisibilityOff
},
contentDescription = null,
tint = OnSurfaceVariant,
modifier = Modifier
.size(20.dp)
.clickable(enabled = !isSaving, onClick = onToggleVisibility),
)
},
)
}
}
private suspend fun scrollFieldNearTop(
listState: androidx.compose.foundation.lazy.LazyListState,
fieldTopPx: Float,
desiredTopPx: Float,
) {
if (fieldTopPx <= 0f) return
val scrollDelta = fieldTopPx - desiredTopPx
if (scrollDelta > 0f) {
listState.animateScrollBy(scrollDelta)
}
}
@Composable
private fun PasswordStrengthMeter(
score: Int,
label: String,
) {
val activeColor = when (score) {
1 -> BrandRed
2 -> Color(0xFFD97706)
3 -> AccentPurple
else -> Color(0xFF2E7D32)
}
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
repeat(4) { index ->
Box(
modifier = Modifier
.weight(1f)
.height(4.dp)
.background(
color = if (index < score) activeColor else SurfaceContainerLow,
shape = RoundedCornerShape(999.dp),
),
)
}
Text(
text = label.uppercase(),
style = MaterialTheme.typography.labelSmall,
color = if (score == 0) OnSurfaceVariant else activeColor,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.End,
)
}
}
}
@Composable
private fun rememberPasswordStrength(password: String): PasswordStrengthUi {
val score = buildList {
if (password.length >= 8) add(Unit)
if (password.any(Char::isUpperCase) || password.any(Char::isLowerCase)) add(Unit)
if (password.any(Char::isDigit)) add(Unit)
if (password.any { !it.isLetterOrDigit() } || password.length >= 12) add(Unit)
}.size.coerceIn(0, 4)
val labelRes = when (score) {
0, 1 -> R.string.change_password_strength_weak
2 -> R.string.change_password_strength_fair
3 -> R.string.change_password_strength_good
else -> R.string.change_password_strength_strong
}
return PasswordStrengthUi(
score = score,
label = stringResource(labelRes),
)
}
private data class PasswordStrengthUi(
val score: Int,
val label: String,
)

View File

@ -0,0 +1,151 @@
package id.iiyh.inatrading.feature.profile.presentation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import id.iiyh.inatrading.feature.auth.domain.repository.AuthRepository
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class ChangePasswordErrors(
val oldPassword: String? = null,
val newPassword: String? = null,
val confirmPassword: String? = null,
)
data class ChangePasswordUiState(
val oldPassword: String = "",
val newPassword: String = "",
val confirmPassword: String = "",
val oldPasswordVisible: Boolean = false,
val newPasswordVisible: Boolean = false,
val confirmPasswordVisible: Boolean = false,
val isSaving: Boolean = false,
val isLoggedOut: Boolean = false,
val infoMessage: String? = null,
val errors: ChangePasswordErrors = ChangePasswordErrors(),
)
@HiltViewModel
class ChangePasswordViewModel @Inject constructor(
private val authRepository: AuthRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(ChangePasswordUiState())
val uiState: StateFlow<ChangePasswordUiState> = _uiState.asStateFlow()
fun onOldPasswordChange(value: String) {
_uiState.update {
it.copy(
oldPassword = value,
errors = it.errors.copy(oldPassword = null),
)
}
}
fun onNewPasswordChange(value: String) {
_uiState.update {
it.copy(
newPassword = value,
errors = it.errors.copy(newPassword = null, confirmPassword = null),
)
}
}
fun onConfirmPasswordChange(value: String) {
_uiState.update {
it.copy(
confirmPassword = value,
errors = it.errors.copy(confirmPassword = null),
)
}
}
fun toggleOldPasswordVisibility() {
_uiState.update { it.copy(oldPasswordVisible = !it.oldPasswordVisible) }
}
fun toggleNewPasswordVisibility() {
_uiState.update { it.copy(newPasswordVisible = !it.newPasswordVisible) }
}
fun toggleConfirmPasswordVisibility() {
_uiState.update { it.copy(confirmPasswordVisible = !it.confirmPasswordVisible) }
}
fun onSaveClick(successMessage: String) {
val state = _uiState.value
val errors = validate(state)
if (errors != ChangePasswordErrors()) {
_uiState.update { it.copy(errors = errors) }
return
}
viewModelScope.launch {
_uiState.update {
it.copy(
isSaving = true,
infoMessage = null,
errors = ChangePasswordErrors(),
)
}
authRepository.changePassword(
oldPassword = state.oldPassword,
newPassword = state.newPassword,
).onSuccess {
authRepository.logout()
_uiState.update {
it.copy(
oldPassword = "",
newPassword = "",
confirmPassword = "",
isSaving = false,
isLoggedOut = true,
infoMessage = successMessage,
)
}
}.onFailure { error ->
_uiState.update {
it.copy(
isSaving = false,
infoMessage = error.message ?: "Gagal mengubah kata sandi",
)
}
}
}
}
fun consumeInfoMessage() {
_uiState.update { it.copy(infoMessage = null) }
}
private fun validate(state: ChangePasswordUiState): ChangePasswordErrors {
var oldPasswordError: String? = null
var newPasswordError: String? = null
var confirmPasswordError: String? = null
if (state.oldPassword.isBlank()) {
oldPasswordError = "required"
}
if (state.newPassword.isBlank()) {
newPasswordError = "required"
} else if (state.newPassword.length < 8) {
newPasswordError = "min_length"
} else if (state.newPassword == state.oldPassword) {
newPasswordError = "must_differ"
}
if (state.confirmPassword != state.newPassword) {
confirmPasswordError = "mismatch"
}
return ChangePasswordErrors(
oldPassword = oldPasswordError,
newPassword = newPasswordError,
confirmPassword = confirmPasswordError,
)
}
}

View File

@ -0,0 +1,602 @@
package id.iiyh.inatrading.feature.profile.presentation
import android.content.Context
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.outlined.Collections
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material.icons.outlined.PhotoCamera
import androidx.compose.material.icons.outlined.VerifiedUser
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.core.content.FileProvider
import coil.compose.AsyncImage
import id.iiyh.inatrading.BuildConfig
import id.iiyh.inatrading.R
import id.iiyh.inatrading.core.ui.components.InaPrimaryButton
import id.iiyh.inatrading.core.ui.components.InaTextField
import id.iiyh.inatrading.core.ui.theme.AccentPurple
import id.iiyh.inatrading.core.ui.theme.AccentPurpleContainer
import id.iiyh.inatrading.core.ui.theme.Background
import id.iiyh.inatrading.core.ui.theme.BrandRed
import id.iiyh.inatrading.core.ui.theme.ManropeFontFamily
import id.iiyh.inatrading.core.ui.theme.OnSurface
import id.iiyh.inatrading.core.ui.theme.OnSurfaceVariant
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerHighest
@Composable
fun EditProfileScreen(
onBack: () -> Unit,
viewModel: EditProfileViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current
var sourcePickerOpen by rememberSaveable { mutableStateOf(false) }
var cameraImageUri by remember { mutableStateOf<Uri?>(null) }
val galleryLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent(),
) { uri ->
if (uri != null) {
viewModel.uploadProfileImage(uri)
}
}
val cameraLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.TakePicture(),
) { success ->
if (success) {
cameraImageUri?.let(viewModel::uploadProfileImage)
}
}
LaunchedEffect(uiState.infoMessage) {
uiState.infoMessage?.let {
snackbarHostState.showSnackbar(it)
viewModel.consumeInfoMessage()
}
}
Scaffold(
topBar = { EditProfileTopBar(onBack = onBack) },
snackbarHost = { SnackbarHost(snackbarHostState) },
containerColor = Background,
) { innerPadding ->
when {
uiState.isLoading -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(color = BrandRed)
}
}
uiState.errorMessage != null -> {
EditProfileState(
title = stringResource(R.string.edit_profile_error_title),
body = uiState.errorMessage.orEmpty(),
actionLabel = stringResource(R.string.explore_retry),
onAction = viewModel::loadProfile,
modifier = Modifier.padding(innerPadding),
)
}
else -> {
EditProfileContent(
uiState = uiState,
onNameChange = viewModel::onNameChange,
onPhoneChange = viewModel::onPhoneChange,
onSaveClick = viewModel::onSaveClick,
onPhotoClick = { sourcePickerOpen = true },
modifier = Modifier.padding(innerPadding),
)
}
}
}
if (sourcePickerOpen) {
PhotoSourceDialog(
onDismiss = { sourcePickerOpen = false },
onGalleryClick = {
sourcePickerOpen = false
galleryLauncher.launch("image/*")
},
onCameraClick = {
sourcePickerOpen = false
val uri = createCameraImageUri(context)
cameraImageUri = uri
cameraLauncher.launch(uri)
},
)
}
}
@Composable
private fun EditProfileTopBar(
onBack: () -> Unit,
) {
Surface(
shadowElevation = 2.dp,
color = Color.White.copy(alpha = 0.88f),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
contentDescription = stringResource(R.string.favorite_back),
tint = BrandRed,
)
}
Text(
text = stringResource(R.string.app_name),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Black,
fontSize = 20.sp,
color = BrandRed,
)
}
IconButton(onClick = {}) {
Icon(
imageVector = Icons.Outlined.MoreVert,
contentDescription = null,
tint = OnSurfaceVariant,
)
}
}
}
}
@Composable
private fun EditProfileContent(
uiState: EditProfileUiState,
onNameChange: (String) -> Unit,
onPhoneChange: (String) -> Unit,
onSaveClick: () -> Unit,
onPhotoClick: () -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier
.fillMaxSize()
.background(Background),
contentPadding = PaddingValues(start = 24.dp, end = 24.dp, top = 20.dp, bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
item {
Column(verticalArrangement = Arrangement.spacedBy(18.dp)) {
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(36.dp))
.background(SurfaceContainerHighest.copy(alpha = 0.55f))
.padding(24.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(18.dp)) {
Text(
text = stringResource(R.string.edit_profile_eyebrow),
style = MaterialTheme.typography.labelSmall,
color = AccentPurple,
fontWeight = FontWeight.Bold,
)
Text(
text = stringResource(R.string.edit_profile_title),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.ExtraBold,
fontSize = 42.sp,
color = OnSurface,
lineHeight = 44.sp,
)
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
contentAlignment = Alignment.Center,
) {
Box {
ProfileAvatar(
imageUrl = uiState.imageUrl,
modifier = Modifier.size(128.dp),
)
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.size(38.dp)
.clip(CircleShape)
.background(BrandRed),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = if (uiState.isUploadingImage) {
Icons.Outlined.Collections
} else {
Icons.Outlined.PhotoCamera
},
contentDescription = null,
tint = Color.White,
modifier = Modifier
.size(18.dp)
.clickable(enabled = !uiState.isUploadingImage) {
onPhotoClick()
},
)
}
if (uiState.isUploadingImage) {
Box(
modifier = Modifier
.matchParentSize()
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.24f)),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
modifier = Modifier.size(28.dp),
color = Color.White,
strokeWidth = 2.5.dp,
)
}
}
}
}
}
}
item {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
LabeledField(
label = stringResource(R.string.edit_profile_name_label),
content = {
InaTextField(
value = uiState.name,
onValueChange = onNameChange,
placeholder = stringResource(R.string.edit_profile_name_placeholder),
)
},
)
LabeledField(
label = stringResource(R.string.edit_profile_email_label),
trailing = {
Icon(
imageVector = Icons.Outlined.Lock,
contentDescription = null,
tint = OnSurfaceVariant.copy(alpha = 0.65f),
modifier = Modifier.size(16.dp),
)
},
content = {
Surface(
shape = RoundedCornerShape(16.dp),
color = SurfaceContainerHighest,
) {
Text(
text = uiState.email,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp),
style = MaterialTheme.typography.bodyLarge,
color = OnSurfaceVariant,
)
}
},
)
LabeledField(
label = stringResource(R.string.edit_profile_phone_label),
content = {
InaTextField(
value = uiState.phone,
onValueChange = onPhoneChange,
placeholder = stringResource(R.string.edit_profile_phone_placeholder),
)
},
)
}
}
item {
Surface(
shape = RoundedCornerShape(24.dp),
color = AccentPurpleContainer.copy(alpha = 0.18f),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(18.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.Top,
) {
Box(
modifier = Modifier
.size(42.dp)
.clip(CircleShape)
.background(AccentPurple),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Outlined.VerifiedUser,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(20.dp),
)
}
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = stringResource(R.string.edit_profile_security_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = OnSurface,
)
Text(
text = stringResource(R.string.edit_profile_security_body),
style = MaterialTheme.typography.bodyMedium,
color = OnSurfaceVariant,
)
}
}
}
}
item {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
InaPrimaryButton(
text = stringResource(R.string.edit_profile_save),
onClick = onSaveClick,
isLoading = uiState.isSaving,
enabled = !uiState.isUploadingImage,
)
Text(
text = stringResource(R.string.edit_profile_footer),
modifier = Modifier.fillMaxWidth(),
style = MaterialTheme.typography.bodySmall,
color = OnSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
}
}
@Composable
private fun LabeledField(
label: String,
trailing: @Composable (() -> Unit)? = null,
content: @Composable () -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = OnSurfaceVariant,
fontWeight = FontWeight.Bold,
)
trailing?.invoke()
}
content()
}
}
@Composable
private fun ProfileAvatar(
imageUrl: String?,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.clip(CircleShape)
.background(SurfaceContainerHighest),
) {
if (!imageUrl.isNullOrBlank()) {
AsyncImage(
model = imageUrl.toImageModel(),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
} else {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Outlined.Person,
contentDescription = null,
tint = OnSurfaceVariant,
modifier = Modifier.size(52.dp),
)
}
}
}
}
@Composable
private fun PhotoSourceDialog(
onDismiss: () -> Unit,
onGalleryClick: () -> Unit,
onCameraClick: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
text = stringResource(R.string.edit_profile_photo_dialog_title),
fontWeight = FontWeight.Bold,
)
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
text = stringResource(R.string.edit_profile_photo_dialog_body),
color = OnSurfaceVariant,
)
SourceActionRow(
icon = Icons.Outlined.Collections,
label = stringResource(R.string.edit_profile_photo_gallery),
onClick = onGalleryClick,
)
SourceActionRow(
icon = Icons.Outlined.PhotoCamera,
label = stringResource(R.string.edit_profile_photo_camera),
onClick = onCameraClick,
)
}
},
confirmButton = {},
dismissButton = {},
containerColor = Color.White,
)
}
@Composable
private fun SourceActionRow(
icon: androidx.compose.ui.graphics.vector.ImageVector,
label: String,
onClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(SurfaceContainerHighest.copy(alpha = 0.55f))
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 14.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(imageVector = icon, contentDescription = null, tint = BrandRed)
Text(text = label, color = OnSurface, fontWeight = FontWeight.SemiBold)
}
}
@Composable
private fun EditProfileState(
title: String,
body: String,
actionLabel: String,
onAction: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxSize()
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = title,
style = MaterialTheme.typography.headlineSmall,
color = OnSurface,
fontWeight = FontWeight.Bold,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = body,
style = MaterialTheme.typography.bodyMedium,
color = OnSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(20.dp))
InaPrimaryButton(
text = actionLabel,
onClick = onAction,
modifier = Modifier.width(180.dp),
)
}
}
private fun createCameraImageUri(context: Context): Uri {
val directory = java.io.File(context.cacheDir, "images").apply { mkdirs() }
val file = java.io.File.createTempFile("camera_", ".jpg", directory)
return FileProvider.getUriForFile(
context,
"${BuildConfig.APPLICATION_ID}.fileprovider",
file,
)
}
private fun String.toImageModel(): String {
return when {
startsWith("content://") || startsWith("file://") || startsWith("http") -> this
else -> "${BuildConfig.BASE_URL}api/v1.0/file/image/$this"
}
}

View File

@ -0,0 +1,157 @@
package id.iiyh.inatrading.feature.profile.presentation
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import id.iiyh.inatrading.feature.auth.domain.repository.AuthRepository
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class EditProfileUiState(
val isLoading: Boolean = false,
val isSaving: Boolean = false,
val isUploadingImage: Boolean = false,
val errorMessage: String? = null,
val infoMessage: String? = null,
val name: String = "",
val email: String = "",
val phone: String = "",
val imageUrl: String? = null,
val imageId: String? = null,
)
@HiltViewModel
class EditProfileViewModel @Inject constructor(
private val authRepository: AuthRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(EditProfileUiState())
val uiState: StateFlow<EditProfileUiState> = _uiState.asStateFlow()
init {
loadProfile()
}
fun loadProfile() {
viewModelScope.launch {
_uiState.update {
it.copy(
isLoading = true,
errorMessage = null,
)
}
authRepository.getBuyerProfile()
.onSuccess { profile ->
_uiState.update {
it.copy(
isLoading = false,
errorMessage = null,
name = profile.name.orEmpty(),
email = profile.email.orEmpty(),
phone = profile.mobile.orEmpty(),
imageUrl = profile.imageId,
imageId = profile.imageId,
)
}
}
.onFailure { error ->
_uiState.update {
it.copy(
isLoading = false,
errorMessage = error.message ?: "Gagal memuat profil",
)
}
}
}
}
fun onNameChange(value: String) {
_uiState.update { it.copy(name = value) }
}
fun onPhoneChange(value: String) {
_uiState.update { it.copy(phone = value.filter(Char::isDigit)) }
}
fun uploadProfileImage(uri: Uri) {
viewModelScope.launch {
_uiState.update {
it.copy(
imageUrl = uri.toString(),
isUploadingImage = true,
infoMessage = null,
errorMessage = null,
)
}
authRepository.uploadFile(uri)
.onSuccess { fileId ->
_uiState.update {
it.copy(
isUploadingImage = false,
imageId = fileId,
infoMessage = "Foto profil berhasil diunggah.",
)
}
}
.onFailure { error ->
_uiState.update {
it.copy(
isUploadingImage = false,
infoMessage = error.message ?: "Gagal mengunggah foto profil",
)
}
}
}
}
fun onSaveClick() {
val state = _uiState.value
if (state.name.isBlank()) {
_uiState.update { it.copy(infoMessage = "Nama lengkap wajib diisi.") }
return
}
if (state.phone.isBlank()) {
_uiState.update { it.copy(infoMessage = "Nomor telepon wajib diisi.") }
return
}
if (state.isUploadingImage) {
_uiState.update { it.copy(infoMessage = "Tunggu hingga upload foto selesai.") }
return
}
viewModelScope.launch {
_uiState.update { it.copy(isSaving = true, errorMessage = null, infoMessage = null) }
authRepository.updateBuyerProfile(
name = state.name,
mobile = state.phone,
imageId = state.imageId,
email = state.email,
).onSuccess {
_uiState.update {
it.copy(
isSaving = false,
infoMessage = "Profil berhasil diperbarui.",
)
}
}.onFailure { error ->
_uiState.update {
it.copy(
isSaving = false,
infoMessage = error.message ?: "Gagal menyimpan profil",
)
}
}
}
}
fun consumeInfoMessage() {
_uiState.update { it.copy(infoMessage = null) }
}
}

View File

@ -0,0 +1,532 @@
package id.iiyh.inatrading.feature.profile.presentation
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.LocationOn
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material.icons.outlined.Star
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import id.iiyh.inatrading.R
import id.iiyh.inatrading.core.ui.components.InaInnerTopAppBar
import id.iiyh.inatrading.core.ui.components.InaPrimaryButton
import id.iiyh.inatrading.core.ui.components.InaTextField
import id.iiyh.inatrading.core.ui.theme.AccentPurple
import id.iiyh.inatrading.core.ui.theme.Background
import id.iiyh.inatrading.core.ui.theme.BrandRed
import id.iiyh.inatrading.core.ui.theme.BrandRedContainer
import id.iiyh.inatrading.core.ui.theme.ManropeFontFamily
import id.iiyh.inatrading.core.ui.theme.OnSurface
import id.iiyh.inatrading.core.ui.theme.OnSurfaceVariant
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerHighest
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerLowest
@Composable
fun EditShippingAddressScreen(
onBack: () -> Unit,
onFinished: () -> Unit,
viewModel: EditShippingAddressViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current
val locationPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) {
viewModel.onLocationRequestStarted()
requestCurrentLocation(
context = context,
onSuccess = viewModel::onLocationCaptured,
onError = viewModel::onLocationError,
)
} else {
viewModel.onLocationError(context.getString(R.string.address_pin_permission_required))
}
}
LaunchedEffect(uiState.infoMessage) {
uiState.infoMessage?.let {
snackbarHostState.showSnackbar(it)
viewModel.consumeInfoMessage()
}
}
LaunchedEffect(uiState.saveSuccess) {
if (uiState.saveSuccess) {
viewModel.consumeSaveSuccess()
onFinished()
}
}
LaunchedEffect(uiState.deleteSuccess) {
if (uiState.deleteSuccess) {
viewModel.consumeDeleteSuccess()
onFinished()
}
}
Scaffold(
topBar = { InaInnerTopAppBar(onBack = onBack) },
snackbarHost = { SnackbarHost(snackbarHostState) },
containerColor = Background,
) { innerPadding ->
when {
uiState.isLoading -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(color = BrandRed)
}
}
uiState.errorMessage != null -> {
EditAddressState(
title = stringResource(R.string.shipping_addresses_error_title),
body = uiState.errorMessage.orEmpty(),
actionLabel = stringResource(R.string.explore_retry),
onAction = viewModel::loadAddress,
modifier = Modifier.padding(innerPadding),
)
}
else -> {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.background(Background),
contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 16.dp, bottom = 120.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
item {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(32.dp))
.background(SurfaceContainerHighest.copy(alpha = 0.35f))
.padding(horizontal = 24.dp, vertical = 28.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
text = stringResource(R.string.edit_shipping_address_eyebrow),
style = MaterialTheme.typography.labelSmall,
letterSpacing = 1.6.sp,
color = AccentPurple,
)
Text(
text = stringResource(R.string.edit_shipping_address_title),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.ExtraBold,
fontSize = 34.sp,
color = OnSurface,
)
Text(
text = stringResource(R.string.edit_shipping_address_body),
style = MaterialTheme.typography.bodyMedium,
color = OnSurfaceVariant,
)
}
}
item {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(24.dp))
.background(SurfaceContainerLowest)
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
SectionHeaderRow(Icons.Outlined.Person, stringResource(R.string.edit_shipping_address_recipient_section))
InaTextField(
value = uiState.recipient,
onValueChange = viewModel::onRecipientChange,
label = stringResource(R.string.add_shipping_address_recipient_label),
placeholder = stringResource(R.string.add_shipping_address_recipient_placeholder),
)
InaTextField(
value = uiState.phone,
onValueChange = viewModel::onPhoneChange,
label = stringResource(R.string.add_shipping_address_phone_label),
placeholder = stringResource(R.string.add_shipping_address_phone_placeholder),
)
}
}
item {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(24.dp))
.background(SurfaceContainerLowest)
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
SectionHeaderRow(Icons.Outlined.LocationOn, stringResource(R.string.edit_shipping_address_location_section))
AddressDropdownField(
value = if (uiState.countryKey == ADDRESS_COUNTRY_INDONESIA) {
stringResource(R.string.add_shipping_address_country_indonesia)
} else {
stringResource(R.string.add_shipping_address_country_other)
},
label = stringResource(R.string.add_shipping_address_country_label),
placeholder = stringResource(R.string.add_shipping_address_country_placeholder),
options = listOf(
DropdownOption(ADDRESS_COUNTRY_INDONESIA, stringResource(R.string.add_shipping_address_country_indonesia)),
DropdownOption(ADDRESS_COUNTRY_OTHER, stringResource(R.string.add_shipping_address_country_other)),
),
onOptionSelected = { viewModel.onCountrySelected(it.id) },
)
if (uiState.countryKey == ADDRESS_COUNTRY_OTHER) {
InaTextField(
value = uiState.customCountry,
onValueChange = viewModel::onCustomCountryChange,
label = stringResource(R.string.add_shipping_address_country_custom_label),
placeholder = stringResource(R.string.add_shipping_address_country_custom_placeholder),
)
InaTextField(
value = uiState.province,
onValueChange = viewModel::onProvinceChange,
label = stringResource(R.string.add_shipping_address_province_label),
)
InaTextField(
value = uiState.city,
onValueChange = viewModel::onCityChange,
label = stringResource(R.string.add_shipping_address_city_label),
)
} else {
AddressDropdownField(
value = uiState.province,
label = stringResource(R.string.add_shipping_address_province_label),
placeholder = if (uiState.isLoadingProvinces) {
stringResource(R.string.address_loading_provinces)
} else {
stringResource(R.string.add_shipping_address_province_placeholder)
},
options = uiState.provinces.mapNotNull {
val id = it.id ?: return@mapNotNull null
val label = it.name ?: return@mapNotNull null
DropdownOption(id, label)
},
enabled = !uiState.isLoadingProvinces && uiState.provinces.isNotEmpty(),
onOptionSelected = { viewModel.onProvinceSelected(it.id) },
)
AddressDropdownField(
value = uiState.city,
label = stringResource(R.string.add_shipping_address_city_label),
placeholder = if (uiState.selectedProvinceId == null) {
stringResource(R.string.address_select_province_first)
} else if (uiState.isLoadingCities) {
stringResource(R.string.address_loading_cities)
} else {
stringResource(R.string.add_shipping_address_city_placeholder)
},
options = uiState.cities.mapNotNull {
val id = it.id ?: return@mapNotNull null
val label = it.name ?: return@mapNotNull null
DropdownOption(id, label)
},
enabled = uiState.selectedProvinceId != null && !uiState.isLoadingCities,
onOptionSelected = { viewModel.onCitySelected(it.id) },
)
}
InaTextField(
value = uiState.postalCode,
onValueChange = viewModel::onPostalCodeChange,
label = stringResource(R.string.add_shipping_address_postal_label),
)
androidx.compose.material3.OutlinedTextField(
value = uiState.address,
onValueChange = viewModel::onAddressChange,
modifier = Modifier.fillMaxWidth(),
minLines = 3,
shape = RoundedCornerShape(16.dp),
label = { Text(stringResource(R.string.add_shipping_address_full_label)) },
textStyle = MaterialTheme.typography.bodyLarge.copy(color = OnSurface),
colors = TextFieldDefaults.colors(
focusedTextColor = OnSurface,
unfocusedTextColor = OnSurface,
focusedContainerColor = Color.White,
unfocusedContainerColor = Color.White,
disabledContainerColor = Color.White,
focusedIndicatorColor = BrandRed.copy(alpha = 0.45f),
unfocusedIndicatorColor = OnSurfaceVariant.copy(alpha = 0.28f),
focusedLabelColor = BrandRed,
unfocusedLabelColor = OnSurfaceVariant,
),
)
Text(
text = stringResource(
R.string.edit_shipping_address_coordinates,
uiState.latitude ?: 0.0,
uiState.longitude ?: 0.0,
),
style = MaterialTheme.typography.bodySmall,
color = OnSurfaceVariant,
)
AddressPinTeaser(
latitude = uiState.latitude,
longitude = uiState.longitude,
isLoading = uiState.isResolvingLocation,
onClick = {
if (hasLocationPermission(context)) {
viewModel.onLocationRequestStarted()
requestCurrentLocation(
context = context,
onSuccess = viewModel::onLocationCaptured,
onError = viewModel::onLocationError,
)
} else {
locationPermissionLauncher.launch(android.Manifest.permission.ACCESS_FINE_LOCATION)
}
},
)
CurrentLocationPermissionMessage()
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
EditAddressLabelChip(
text = stringResource(R.string.add_shipping_address_label_home),
selected = uiState.label.equals("rumah", true) || uiState.label.equals("home", true),
onClick = { viewModel.onLabelSelected("rumah") },
)
EditAddressLabelChip(
text = stringResource(R.string.add_shipping_address_label_office),
selected = uiState.label.equals("kantor", true) || uiState.label.equals("office", true),
onClick = { viewModel.onLabelSelected("kantor") },
)
EditAddressLabelChip(
text = stringResource(R.string.add_shipping_address_label_warehouse),
selected = uiState.label.equals("gudang", true) || uiState.label.equals("warehouse", true),
onClick = { viewModel.onLabelSelected("gudang") },
)
EditAddressLabelChip(
text = stringResource(R.string.add_shipping_address_label_other),
selected = uiState.label == EditShippingAddressViewModel.CUSTOM_LABEL_KEY,
icon = Icons.Outlined.Add,
onClick = viewModel::onCustomLabelSelected,
)
}
if (uiState.label == EditShippingAddressViewModel.CUSTOM_LABEL_KEY) {
InaTextField(
value = uiState.customLabel,
onValueChange = viewModel::onCustomLabelChange,
label = stringResource(R.string.add_shipping_address_custom_label_title),
placeholder = stringResource(R.string.add_shipping_address_custom_label_placeholder),
)
}
}
}
item {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(20.dp))
.background(SurfaceContainerLowest)
.padding(20.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(44.dp)
.clip(RoundedCornerShape(14.dp))
.background(BrandRedContainer.copy(alpha = 0.18f)),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Outlined.Star, contentDescription = null, tint = BrandRed)
}
Column {
Text(
text = stringResource(R.string.add_shipping_address_primary_label),
fontWeight = FontWeight.Bold,
color = OnSurface,
)
Text(
text = stringResource(R.string.add_shipping_address_primary_body),
style = MaterialTheme.typography.bodySmall,
color = OnSurfaceVariant,
)
}
}
Switch(
checked = uiState.isPrimary,
onCheckedChange = viewModel::onPrimaryChange,
)
}
}
item {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
) {
TextButton(
onClick = viewModel::onDeleteClick,
enabled = !uiState.isDeleting && !uiState.isSaving,
) {
Text(
text = stringResource(R.string.edit_shipping_address_delete),
color = BrandRed,
fontWeight = FontWeight.Bold,
)
}
}
}
}
Box(
modifier = Modifier
.fillMaxSize()
.padding(bottom = 24.dp),
contentAlignment = Alignment.BottomCenter,
) {
InaPrimaryButton(
text = stringResource(R.string.edit_shipping_address_save),
onClick = viewModel::onSaveClick,
isLoading = uiState.isSaving,
enabled = !uiState.isResolvingLocation,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp),
)
}
}
}
}
}
@Composable
private fun EditAddressLabelChip(
text: String,
selected: Boolean,
icon: androidx.compose.ui.graphics.vector.ImageVector? = null,
onClick: () -> Unit,
) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(999.dp))
.background(if (selected) BrandRed else SurfaceContainerHighest)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(),
onClick = onClick,
)
.padding(horizontal = 16.dp, vertical = 10.dp),
contentAlignment = Alignment.Center,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (icon != null) {
Icon(
imageVector = icon,
contentDescription = null,
tint = if (selected) Color.White else OnSurfaceVariant,
modifier = Modifier.size(16.dp),
)
}
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
color = if (selected) Color.White else OnSurfaceVariant,
)
}
}
}
@Composable
private fun SectionHeaderRow(
icon: androidx.compose.ui.graphics.vector.ImageVector,
title: String,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(icon, contentDescription = null, tint = BrandRed)
Text(
text = title,
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 18.sp,
color = OnSurface,
)
}
}
@Composable
private fun EditAddressState(
title: String,
body: String,
actionLabel: String,
onAction: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.fillMaxSize()
.background(Background)
.padding(24.dp),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Text(text = title, fontFamily = ManropeFontFamily, fontWeight = FontWeight.ExtraBold, fontSize = 24.sp)
Text(text = body, color = OnSurfaceVariant)
InaPrimaryButton(
text = actionLabel,
onClick = onAction,
modifier = Modifier.fillMaxWidth(),
)
}
}
}

View File

@ -0,0 +1,467 @@
package id.iiyh.inatrading.feature.profile.presentation
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import id.iiyh.inatrading.feature.auth.domain.repository.AuthRepository
import id.iiyh.inatrading.feature.profile.data.model.CityItem
import id.iiyh.inatrading.feature.profile.data.model.CreateShippingAddressRequest
import id.iiyh.inatrading.feature.profile.data.model.ProvinceItem
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class EditShippingAddressUiState(
val isLoading: Boolean = false,
val isSaving: Boolean = false,
val isDeleting: Boolean = false,
val addressId: String = "",
val label: String = "",
val customLabel: String = "",
val recipient: String = "",
val phone: String = "",
val countryKey: String = ADDRESS_COUNTRY_INDONESIA,
val customCountry: String = "",
val provinces: List<ProvinceItem> = emptyList(),
val cities: List<CityItem> = emptyList(),
val selectedProvinceId: String? = null,
val selectedCityId: String? = null,
val isLoadingProvinces: Boolean = false,
val isLoadingCities: Boolean = false,
val country: String = "",
val province: String = "",
val city: String = "",
val postalCode: String = "",
val address: String = "",
val isPrimary: Boolean = false,
val latitude: Double? = null,
val longitude: Double? = null,
val isResolvingLocation: Boolean = false,
val errorMessage: String? = null,
val infoMessage: String? = null,
val saveSuccess: Boolean = false,
val deleteSuccess: Boolean = false,
)
@HiltViewModel
class EditShippingAddressViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val authRepository: AuthRepository,
) : ViewModel() {
private val addressId: String = savedStateHandle["addressId"] ?: ""
private val _uiState = MutableStateFlow(EditShippingAddressUiState(addressId = addressId))
val uiState: StateFlow<EditShippingAddressUiState> = _uiState.asStateFlow()
init {
loadAddress()
}
fun loadAddress() {
if (addressId.isBlank()) {
_uiState.update { it.copy(errorMessage = "Alamat tidak ditemukan.") }
return
}
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
authRepository.getAddresses()
.onSuccess { addresses ->
val address = addresses.firstOrNull { it.id == addressId }
if (address == null) {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Alamat tidak ditemukan.",
)
}
} else {
val resolvedLabel = address.label.orEmpty()
val isPreset = isPresetLabel(resolvedLabel)
val isIndonesia = address.country.orEmpty().equals("indonesia", true)
_uiState.update {
it.copy(
isLoading = false,
label = if (isPreset) resolvedLabel else CUSTOM_LABEL_KEY,
customLabel = if (isPreset) "" else resolvedLabel,
recipient = address.recipient.orEmpty(),
phone = address.mobile.orEmpty(),
countryKey = if (isIndonesia) ADDRESS_COUNTRY_INDONESIA else ADDRESS_COUNTRY_OTHER,
customCountry = if (isIndonesia) "" else address.country.orEmpty(),
country = address.country.orEmpty(),
province = address.province.orEmpty(),
city = address.city.orEmpty(),
postalCode = address.postalCode.orEmpty(),
address = address.address.orEmpty(),
isPrimary = address.isPrimary,
latitude = address.latitude,
longitude = address.longitude,
)
}
if (isIndonesia) {
loadProvinces(
initialProvinceName = address.province.orEmpty(),
initialCityName = address.city.orEmpty(),
)
}
}
}
.onFailure { error ->
_uiState.update {
it.copy(
isLoading = false,
errorMessage = error.message ?: "Gagal memuat alamat",
)
}
}
}
}
fun onLabelSelected(value: String) {
_uiState.update { it.copy(label = value, customLabel = "") }
}
fun onCustomLabelSelected() {
_uiState.update {
it.copy(
label = CUSTOM_LABEL_KEY,
customLabel = it.customLabel,
)
}
}
fun onCustomLabelChange(value: String) {
_uiState.update {
it.copy(
label = CUSTOM_LABEL_KEY,
customLabel = value,
)
}
}
fun onRecipientChange(value: String) {
_uiState.update { it.copy(recipient = value) }
}
fun onPhoneChange(value: String) {
_uiState.update { it.copy(phone = value.filter(Char::isDigit)) }
}
fun onCountryChange(value: String) {
_uiState.update { it.copy(country = value) }
}
fun onCountrySelected(value: String) {
_uiState.update {
it.copy(
countryKey = value,
customCountry = if (value == ADDRESS_COUNTRY_OTHER) it.customCountry else "",
country = if (value == ADDRESS_COUNTRY_INDONESIA) "Indonesia" else it.country,
province = "",
city = "",
selectedProvinceId = null,
selectedCityId = null,
cities = emptyList(),
)
}
if (value == ADDRESS_COUNTRY_INDONESIA) {
loadProvinces()
}
}
fun onCustomCountryChange(value: String) {
_uiState.update { it.copy(customCountry = value, country = value) }
}
fun onProvinceChange(value: String) {
_uiState.update { it.copy(province = value) }
}
fun onProvinceSelected(id: String) {
val province = _uiState.value.provinces.firstOrNull { it.id == id } ?: return
_uiState.update {
it.copy(
selectedProvinceId = province.id,
province = province.name.orEmpty(),
selectedCityId = null,
city = "",
cities = emptyList(),
)
}
province.id?.let { loadCities(it) }
}
fun onCityChange(value: String) {
_uiState.update { it.copy(city = value) }
}
fun onCitySelected(id: String) {
val city = _uiState.value.cities.firstOrNull { it.id == id } ?: return
_uiState.update {
it.copy(
selectedCityId = city.id,
city = city.name.orEmpty(),
)
}
}
fun onPostalCodeChange(value: String) {
_uiState.update { it.copy(postalCode = value.filter(Char::isDigit)) }
}
fun onAddressChange(value: String) {
_uiState.update { it.copy(address = value) }
}
fun onPrimaryChange(value: Boolean) {
_uiState.update { it.copy(isPrimary = value) }
}
fun onLocationCaptured(latitude: Double, longitude: Double) {
_uiState.update {
it.copy(
latitude = latitude,
longitude = longitude,
isResolvingLocation = false,
infoMessage = "Lokasi saat ini berhasil digunakan.",
)
}
}
fun onLocationError(message: String) {
_uiState.update { it.copy(isResolvingLocation = false, infoMessage = message) }
}
fun onLocationRequestStarted() {
_uiState.update {
it.copy(
isResolvingLocation = true,
infoMessage = "Mengambil koordinat lokasi saat ini...",
)
}
}
fun onSaveClick() {
val state = _uiState.value
if (state.recipient.isBlank()) {
_uiState.update { it.copy(infoMessage = "Nama penerima wajib diisi.") }
return
}
if (state.phone.isBlank()) {
_uiState.update { it.copy(infoMessage = "Nomor telepon wajib diisi.") }
return
}
if (state.province.isBlank()) {
_uiState.update { it.copy(infoMessage = "Provinsi wajib diisi.") }
return
}
if (state.city.isBlank()) {
_uiState.update { it.copy(infoMessage = "Kota wajib diisi.") }
return
}
if (state.postalCode.isBlank()) {
_uiState.update { it.copy(infoMessage = "Kode pos wajib diisi.") }
return
}
if (state.address.isBlank()) {
_uiState.update { it.copy(infoMessage = "Detail alamat wajib diisi.") }
return
}
if (state.label == CUSTOM_LABEL_KEY && state.customLabel.isBlank()) {
_uiState.update { it.copy(infoMessage = "Label alamat wajib diisi.") }
return
}
if (state.resolvedCountry().isBlank()) {
_uiState.update { it.copy(infoMessage = "Negara wajib dipilih.") }
return
}
if (state.countryKey == ADDRESS_COUNTRY_INDONESIA && state.selectedProvinceId == null) {
_uiState.update { it.copy(infoMessage = "Provinsi wajib dipilih.") }
return
}
if (state.countryKey == ADDRESS_COUNTRY_INDONESIA && state.selectedCityId == null) {
_uiState.update { it.copy(infoMessage = "Kota/Kabupaten wajib dipilih.") }
return
}
if (state.isResolvingLocation) {
_uiState.update { it.copy(infoMessage = "Tunggu sampai koordinat lokasi selesai diambil.") }
return
}
viewModelScope.launch {
_uiState.update { it.copy(isSaving = true, infoMessage = null) }
authRepository.updateAddress(
addressId = state.addressId,
request = CreateShippingAddressRequest(
label = state.submittedLabel(),
recipient = state.recipient.trim(),
mobile = normalizePhone(state.phone),
address = state.address.trim(),
country = state.resolvedCountry(),
province = state.province.trim(),
city = state.city.trim(),
postalCode = state.postalCode.trim(),
isPrimary = state.isPrimary,
latitude = state.latitude,
longitude = state.longitude,
),
).onSuccess {
_uiState.update {
it.copy(
isSaving = false,
saveSuccess = true,
infoMessage = "Alamat berhasil diperbarui.",
)
}
}.onFailure { error ->
_uiState.update {
it.copy(
isSaving = false,
infoMessage = error.message ?: "Gagal memperbarui alamat",
)
}
}
}
}
fun onDeleteClick() {
val state = _uiState.value
if (state.addressId.isBlank()) {
_uiState.update { it.copy(infoMessage = "Alamat tidak ditemukan.") }
return
}
viewModelScope.launch {
_uiState.update { it.copy(isDeleting = true, infoMessage = null) }
authRepository.deleteAddress(state.addressId)
.onSuccess {
_uiState.update {
it.copy(
isDeleting = false,
deleteSuccess = true,
infoMessage = "Alamat berhasil dihapus.",
)
}
}
.onFailure { error ->
_uiState.update {
it.copy(
isDeleting = false,
infoMessage = error.message ?: "Gagal menghapus alamat",
)
}
}
}
}
fun consumeInfoMessage() {
_uiState.update { it.copy(infoMessage = null) }
}
fun consumeSaveSuccess() {
_uiState.update { it.copy(saveSuccess = false) }
}
fun consumeDeleteSuccess() {
_uiState.update { it.copy(deleteSuccess = false) }
}
private fun loadProvinces(
initialProvinceName: String? = null,
initialCityName: String? = null,
) {
viewModelScope.launch {
_uiState.update { it.copy(isLoadingProvinces = true) }
authRepository.getProvinces()
.onSuccess { provinces ->
val sorted = provinces.sortedBy { item -> item.name.orEmpty() }
val matchedProvince = initialProvinceName?.let { provinceName ->
sorted.firstOrNull { it.name.orEmpty().equals(provinceName, true) }
}
_uiState.update {
it.copy(
isLoadingProvinces = false,
provinces = sorted,
selectedProvinceId = matchedProvince?.id ?: it.selectedProvinceId,
province = matchedProvince?.name.orEmpty().ifBlank { it.province },
)
}
matchedProvince?.id?.let { provinceId ->
loadCities(provinceId, initialCityName)
}
}
.onFailure { error ->
_uiState.update {
it.copy(
isLoadingProvinces = false,
infoMessage = error.message ?: "Gagal memuat provinsi",
)
}
}
}
}
private fun loadCities(
provinceId: String,
initialCityName: String? = null,
) {
viewModelScope.launch {
_uiState.update { it.copy(isLoadingCities = true) }
authRepository.getCities(provinceId)
.onSuccess { cities ->
val sorted = cities.sortedBy { item -> item.name.orEmpty() }
val matchedCity = initialCityName?.let { cityName ->
sorted.firstOrNull { it.name.orEmpty().equals(cityName, true) }
}
_uiState.update {
it.copy(
isLoadingCities = false,
cities = sorted,
selectedCityId = matchedCity?.id ?: it.selectedCityId,
city = matchedCity?.name.orEmpty().ifBlank { it.city },
)
}
}
.onFailure { error ->
_uiState.update {
it.copy(
isLoadingCities = false,
infoMessage = error.message ?: "Gagal memuat kota",
)
}
}
}
}
private fun normalizePhone(raw: String): String {
val digits = raw.filter(Char::isDigit)
if (digits.startsWith("0")) return digits
if (digits.startsWith("62")) return "0${digits.removePrefix("62")}"
return "0$digits"
}
private fun EditShippingAddressUiState.submittedLabel(): String {
return if (label == CUSTOM_LABEL_KEY) customLabel.trim() else label.ifBlank { "rumah" }
}
private fun EditShippingAddressUiState.resolvedCountry(): String {
return if (countryKey == ADDRESS_COUNTRY_OTHER) customCountry.trim() else "Indonesia"
}
private fun isPresetLabel(value: String): Boolean {
return value.equals("rumah", true) ||
value.equals("home", true) ||
value.equals("kantor", true) ||
value.equals("office", true) ||
value.equals("gudang", true) ||
value.equals("warehouse", true)
}
companion object {
const val CUSTOM_LABEL_KEY = "__custom__"
}
}

View File

@ -0,0 +1,447 @@
package id.iiyh.inatrading.feature.profile.presentation
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.automirrored.outlined.ArrowForward
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Inventory2
import androidx.compose.material.icons.outlined.Storefront
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import coil.compose.AsyncImage
import id.iiyh.inatrading.R
import id.iiyh.inatrading.core.ui.components.InaPrimaryButton
import id.iiyh.inatrading.core.ui.theme.Background
import id.iiyh.inatrading.core.ui.theme.BrandRed
import id.iiyh.inatrading.core.ui.theme.ManropeFontFamily
import id.iiyh.inatrading.core.ui.theme.OnSurface
import id.iiyh.inatrading.core.ui.theme.OnSurfaceVariant
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerHighest
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerLow
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerLowest
import id.iiyh.inatrading.feature.favorite.domain.FavoriteGroupProduct
import id.iiyh.inatrading.feature.favorite.presentation.FavoriteGroupDetailViewModel
@Composable
fun FavoriteGroupDetailScreen(
onBack: () -> Unit,
onProductClick: (String) -> Unit,
onSessionExpired: () -> Unit,
viewModel: FavoriteGroupDetailViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(uiState.errorMessage) {
uiState.errorMessage?.let {
snackbarHostState.showSnackbar(it)
viewModel.consumeError()
}
}
LaunchedEffect(uiState.sessionExpired) {
if (uiState.sessionExpired) {
viewModel.consumeSessionExpired()
onSessionExpired()
}
}
Scaffold(
topBar = {
FavoriteGroupDetailTopBar(
title = uiState.groupName.ifBlank { stringResource(R.string.profile_my_favorites) },
onBack = onBack,
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
containerColor = Background,
) { innerPadding ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.background(Background)
.padding(innerPadding),
contentPadding = PaddingValues(start = 24.dp, end = 24.dp, top = 20.dp, bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
item {
FavoriteGroupHero(groupName = uiState.groupName)
}
item {
GroupSummaryCard(itemCount = uiState.items.size)
}
when {
uiState.isLoading -> {
item { DetailLoadingState() }
}
uiState.items.isEmpty() -> {
item { FavoriteGroupEmptyState() }
}
else -> {
items(uiState.items, key = { it.id }) { item ->
FavoriteProductCard(
item = item,
isRemoving = uiState.removingProductId == item.productId,
onDelete = { viewModel.removeFavorite(item.productId) },
onProductClick = { onProductClick(item.productId) },
)
}
}
}
}
}
}
@Composable
private fun FavoriteGroupDetailTopBar(
title: String,
onBack: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(Color.White.copy(alpha = 0.88f))
.padding(horizontal = 20.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconAction(
icon = Icons.AutoMirrored.Outlined.ArrowBack,
contentDescription = stringResource(R.string.favorite_back),
onClick = onBack,
)
Text(
text = title,
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
color = BrandRed,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = 8.dp),
)
}
}
@Composable
private fun FavoriteGroupHero(groupName: String) {
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(34.dp))
.background(SurfaceContainerHighest.copy(alpha = 0.28f))
.padding(horizontal = 24.dp, vertical = 28.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = stringResource(R.string.favorite_group_eyebrow),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = BrandRed,
letterSpacing = 2.sp,
)
Text(
text = groupName.ifBlank { stringResource(R.string.profile_my_favorites) },
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.ExtraBold,
fontSize = 32.sp,
lineHeight = 36.sp,
color = OnSurface,
)
}
}
}
@Composable
private fun GroupSummaryCard(itemCount: Int) {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(18.dp))
.background(SurfaceContainerLow)
.padding(18.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
text = stringResource(R.string.favorite_group_summary),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 18.sp,
color = OnSurface,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.favorite_group_total_items),
style = MaterialTheme.typography.bodyMedium,
color = OnSurfaceVariant,
)
Text(
text = pluralStringResource(R.plurals.favorite_products_count, itemCount, itemCount),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
color = OnSurface,
)
}
}
}
@Composable
private fun FavoriteProductCard(
item: FavoriteGroupProduct,
isRemoving: Boolean,
onDelete: () -> Unit,
onProductClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(20.dp))
.background(SurfaceContainerLowest)
.padding(18.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Box(
modifier = Modifier
.size(112.dp)
.clip(RoundedCornerShape(14.dp))
.background(SurfaceContainerLow),
contentAlignment = Alignment.Center,
) {
if (!item.productImage.isNullOrBlank()) {
AsyncImage(
model = item.productImage,
contentDescription = item.productName,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
} else {
Icon(
imageVector = Icons.Outlined.Inventory2,
contentDescription = null,
tint = OnSurfaceVariant,
modifier = Modifier.size(36.dp),
)
}
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.SpaceBetween,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top,
) {
Text(
text = item.productName,
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.ExtraBold,
fontSize = 20.sp,
lineHeight = 24.sp,
color = OnSurface,
modifier = Modifier.weight(1f),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.width(8.dp))
Box(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(20.dp))
.clickable(
enabled = !isRemoving,
interactionSource = remember { MutableInteractionSource() },
indication = androidx.compose.material3.ripple(),
onClick = onDelete,
),
contentAlignment = Alignment.Center,
) {
if (isRemoving) {
CircularProgressIndicator(
color = BrandRed,
strokeWidth = 2.dp,
modifier = Modifier.size(18.dp),
)
} else {
Icon(
imageVector = Icons.Outlined.Delete,
contentDescription = stringResource(R.string.favorite_delete_item),
tint = BrandRed,
modifier = Modifier.size(22.dp),
)
}
}
}
item.sellerName?.takeIf { it.isNotBlank() }?.let { sellerName ->
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(
imageVector = Icons.Outlined.Storefront,
contentDescription = null,
tint = BrandRed,
modifier = Modifier.size(16.dp),
)
Text(
text = sellerName,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
color = BrandRed,
)
}
}
item.productDescription?.takeIf { it.isNotBlank() }?.let { description ->
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
color = OnSurfaceVariant,
maxLines = 4,
overflow = TextOverflow.Ellipsis,
)
}
}
Row(
modifier = Modifier.padding(top = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
InaPrimaryButton(
text = stringResource(R.string.favorite_group_product_detail),
onClick = onProductClick,
)
Text(
text = item.productId.take(8) + "...",
style = MaterialTheme.typography.labelMedium,
color = OnSurfaceVariant,
fontStyle = FontStyle.Italic,
)
}
}
}
}
@Composable
private fun FavoriteGroupEmptyState() {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(24.dp))
.background(SurfaceContainerLowest)
.padding(horizontal = 24.dp, vertical = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(
imageVector = Icons.Outlined.Inventory2,
contentDescription = null,
tint = OnSurfaceVariant,
modifier = Modifier.size(42.dp),
)
Text(
text = stringResource(R.string.favorite_group_empty_title),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
color = OnSurface,
)
Text(
text = stringResource(R.string.favorite_group_empty_desc),
style = MaterialTheme.typography.bodyMedium,
color = OnSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
@Composable
private fun DetailLoadingState() {
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(24.dp))
.background(SurfaceContainerLowest)
.padding(vertical = 42.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(color = BrandRed)
}
}
@Composable
private fun IconAction(
icon: androidx.compose.ui.graphics.vector.ImageVector,
contentDescription: String,
onClick: () -> Unit,
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(20.dp))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = androidx.compose.material3.ripple(bounded = false, radius = 20.dp),
onClick = onClick,
),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
tint = BrandRed,
modifier = Modifier.size(22.dp),
)
}
}

View File

@ -0,0 +1,679 @@
package id.iiyh.inatrading.feature.profile.presentation
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.filled.CreateNewFolder
import androidx.compose.material.icons.outlined.CardGiftcard
import androidx.compose.material.icons.outlined.DeleteOutline
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.HomeWork
import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material.icons.outlined.Restaurant
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import id.iiyh.inatrading.R
import id.iiyh.inatrading.core.ui.components.InaPrimaryButton
import id.iiyh.inatrading.core.ui.components.InaTextField
import id.iiyh.inatrading.core.ui.theme.AccentBlue
import id.iiyh.inatrading.core.ui.theme.AccentBlueContainer
import id.iiyh.inatrading.core.ui.theme.AccentPurple
import id.iiyh.inatrading.core.ui.theme.AccentPurpleContainer
import id.iiyh.inatrading.core.ui.theme.Background
import id.iiyh.inatrading.core.ui.theme.BrandRed
import id.iiyh.inatrading.core.ui.theme.BrandRedLight
import id.iiyh.inatrading.core.ui.theme.ManropeFontFamily
import id.iiyh.inatrading.core.ui.theme.OnSurface
import id.iiyh.inatrading.core.ui.theme.OnSurfaceVariant
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerHighest
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerLow
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerLowest
import id.iiyh.inatrading.feature.favorite.domain.FavoriteGroupSummary
import id.iiyh.inatrading.feature.favorite.presentation.FavoritesViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FavoritesScreen(
onBack: () -> Unit,
onBrowseProducts: () -> Unit,
onGroupClick: (FavoriteGroupSummary) -> Unit,
onSessionExpired: () -> Unit,
viewModel: FavoritesViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
var createDialogOpen by remember { mutableStateOf(false) }
var editingGroup by remember { mutableStateOf<FavoriteGroupSummary?>(null) }
var deletingGroup by remember { mutableStateOf<FavoriteGroupSummary?>(null) }
val gridState = rememberLazyGridState()
LaunchedEffect(uiState.errorMessage) {
uiState.errorMessage?.let { snackbarHostState.showSnackbar(it) }
}
LaunchedEffect(uiState.sessionExpired) {
if (uiState.sessionExpired) {
viewModel.consumeSessionExpired()
onSessionExpired()
}
}
Scaffold(
topBar = {
FavoritesTopBar(
onBack = onBack,
onCreateClick = { createDialogOpen = true },
)
},
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
floatingActionButton = {
Box(
modifier = Modifier
.size(56.dp)
.clip(RoundedCornerShape(18.dp))
.background(BrandRed)
.clickable(onClick = { createDialogOpen = true }),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Filled.CreateNewFolder,
contentDescription = stringResource(R.string.favorite_create_collection),
tint = Color.White,
modifier = Modifier.size(28.dp),
)
}
},
containerColor = Background,
) { innerPadding ->
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 160.dp),
state = gridState,
modifier = Modifier
.fillMaxSize()
.background(Background)
.padding(innerPadding),
contentPadding = androidx.compose.foundation.layout.PaddingValues(
start = 24.dp,
end = 24.dp,
top = 20.dp,
bottom = 104.dp,
),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
item(span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) {
FavoritesHero()
}
if (uiState.isLoading) {
item(span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) {
LoadingState()
}
} else if (uiState.groups.isEmpty()) {
item(span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) {
EmptyState(onBrowseProducts = onBrowseProducts, onCreateClick = { createDialogOpen = true })
}
} else {
itemsIndexed(uiState.groups, key = { _, item -> item.id }) { index, group ->
FavoriteGroupCard(
group = group,
accent = accentFor(index),
onClick = { onGroupClick(group) },
onEdit = { editingGroup = group },
onDelete = { deletingGroup = group },
)
}
item {
CreateCollectionCard(onClick = { createDialogOpen = true })
}
}
if (!uiState.isLoading && uiState.groups.isNotEmpty()) {
item(span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp),
horizontalArrangement = Arrangement.Center,
) {
TextButton(onClick = viewModel::loadFavorites) {
Icon(
imageVector = Icons.Outlined.Refresh,
contentDescription = null,
tint = BrandRed,
modifier = Modifier.size(18.dp),
)
Text(
text = stringResource(R.string.favorite_refresh),
color = BrandRed,
modifier = Modifier.padding(start = 8.dp),
)
}
}
}
}
}
}
if (createDialogOpen) {
FavoriteNameDialog(
title = stringResource(R.string.favorite_create_collection),
confirmLabel = stringResource(R.string.favorite_create_action),
initialValue = "",
isSubmitting = uiState.isSubmitting,
onDismiss = { createDialogOpen = false },
onConfirm = { name ->
createDialogOpen = false
viewModel.createGroup(name)
},
)
}
editingGroup?.let { group ->
FavoriteNameDialog(
title = stringResource(R.string.favorite_edit_collection),
confirmLabel = stringResource(R.string.favorite_save_action),
initialValue = group.name,
isSubmitting = uiState.isSubmitting,
onDismiss = { editingGroup = null },
onConfirm = { name ->
editingGroup = null
viewModel.renameGroup(group.id, name)
},
)
}
deletingGroup?.let { group ->
DeleteFavoriteDialog(
name = group.name,
isSubmitting = uiState.isSubmitting,
onDismiss = { deletingGroup = null },
onConfirm = {
deletingGroup = null
viewModel.deleteGroup(group.id)
},
)
}
}
@Composable
private fun FavoritesTopBar(
onBack: () -> Unit,
onCreateClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(Color.White.copy(alpha = 0.88f))
.padding(horizontal = 20.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconAction(
icon = Icons.AutoMirrored.Outlined.ArrowBack,
contentDescription = stringResource(R.string.favorite_back),
onClick = onBack,
)
Text(
text = stringResource(R.string.profile_my_favorites),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
color = BrandRed,
modifier = Modifier.padding(start = 8.dp),
)
}
IconAction(
icon = Icons.Filled.CreateNewFolder,
contentDescription = stringResource(R.string.favorite_create_collection),
onClick = onCreateClick,
)
}
}
@Composable
private fun FavoritesHero() {
Box(
modifier = Modifier
.fillMaxWidth()
.height(170.dp)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(154.dp)
.clip(RoundedCornerShape(40.dp))
.background(SurfaceContainerHighest.copy(alpha = 0.42f))
)
Column(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.CenterStart)
.padding(horizontal = 24.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = stringResource(R.string.profile_favorites_eyebrow),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = AccentBlue,
letterSpacing = 2.sp,
)
Text(
text = stringResource(R.string.favorite_hero_title),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.ExtraBold,
fontSize = 32.sp,
lineHeight = 36.sp,
color = OnSurface,
)
}
}
}
@Composable
private fun FavoriteGroupCard(
group: FavoriteGroupSummary,
accent: FavoriteAccent,
onClick: () -> Unit,
onEdit: () -> Unit,
onDelete: () -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
Box(
modifier = Modifier
.fillMaxWidth()
.height(188.dp)
.clip(RoundedCornerShape(18.dp))
.background(SurfaceContainerLowest)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(),
onClick = onClick,
)
.padding(18.dp),
) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.SpaceBetween,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top,
) {
Box(
modifier = Modifier
.size(48.dp)
.clip(RoundedCornerShape(14.dp))
.background(accent.container),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = accent.icon,
contentDescription = null,
tint = accent.tint,
modifier = Modifier.size(24.dp),
)
}
Box {
IconAction(
icon = Icons.Outlined.MoreVert,
contentDescription = stringResource(R.string.favorite_more_options),
onClick = { expanded = true },
)
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.favorite_edit_action)) },
leadingIcon = {
Icon(Icons.Outlined.Edit, contentDescription = null)
},
onClick = {
expanded = false
onEdit()
},
)
DropdownMenuItem(
text = { Text(stringResource(R.string.favorite_delete_action)) },
leadingIcon = {
Icon(Icons.Outlined.DeleteOutline, contentDescription = null)
},
onClick = {
expanded = false
onDelete()
},
)
}
}
}
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = group.name,
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
color = OnSurface,
maxLines = 2,
)
Text(
text = pluralStringResource(
R.plurals.favorite_products_count,
group.itemCount,
group.itemCount,
),
style = MaterialTheme.typography.bodyMedium,
color = OnSurfaceVariant,
)
if (group.isDefault) {
Text(
text = stringResource(R.string.favorite_default_collection),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = BrandRed,
letterSpacing = 1.sp,
)
}
}
}
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.size(72.dp)
.clip(RoundedCornerShape(16.dp))
.background(accent.container.copy(alpha = 0.55f)),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = accent.icon,
contentDescription = null,
tint = accent.tint.copy(alpha = 0.85f),
modifier = Modifier.size(32.dp),
)
}
}
}
@Composable
private fun CreateCollectionCard(
onClick: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.height(188.dp)
.clip(RoundedCornerShape(18.dp))
.background(Color.Transparent)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(),
onClick = onClick,
)
.padding(2.dp)
.clip(RoundedCornerShape(18.dp))
.background(BrandRed.copy(alpha = 0.04f))
.padding(18.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Box(
modifier = Modifier
.size(52.dp)
.clip(RoundedCornerShape(16.dp))
.background(SurfaceContainerLow),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Filled.CreateNewFolder,
contentDescription = null,
tint = OnSurfaceVariant,
modifier = Modifier.size(26.dp),
)
}
Text(
text = stringResource(R.string.favorite_create_collection),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
color = OnSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 12.dp),
)
}
}
@Composable
private fun EmptyState(
onBrowseProducts: () -> Unit,
onCreateClick: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(24.dp))
.background(SurfaceContainerLowest)
.padding(horizontal = 24.dp, vertical = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Text(
text = stringResource(R.string.profile_favorites_empty_title),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.ExtraBold,
fontSize = 22.sp,
color = OnSurface,
textAlign = TextAlign.Center,
)
Text(
text = stringResource(R.string.favorite_empty_groups_desc),
style = MaterialTheme.typography.bodyMedium,
color = OnSurfaceVariant,
textAlign = TextAlign.Center,
)
InaPrimaryButton(
text = stringResource(R.string.favorite_create_collection),
onClick = onCreateClick,
modifier = Modifier.fillMaxWidth(),
)
TextButton(onClick = onBrowseProducts) {
Text(text = stringResource(R.string.profile_favorites_browse_products), color = BrandRed)
}
}
}
@Composable
private fun LoadingState() {
Box(
modifier = Modifier
.fillMaxWidth()
.height(220.dp)
.clip(RoundedCornerShape(24.dp))
.background(SurfaceContainerLowest),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(color = BrandRed)
}
}
@Composable
private fun FavoriteNameDialog(
title: String,
confirmLabel: String,
initialValue: String,
isSubmitting: Boolean,
onDismiss: () -> Unit,
onConfirm: (String) -> Unit,
) {
var value by remember(initialValue) { mutableStateOf(initialValue) }
AlertDialog(
onDismissRequest = {
if (!isSubmitting) onDismiss()
},
title = { Text(title, fontFamily = ManropeFontFamily, fontWeight = FontWeight.Bold) },
text = {
InaTextField(
value = value,
onValueChange = { value = it },
placeholder = stringResource(R.string.favorite_name_placeholder),
)
},
confirmButton = {
TextButton(
enabled = value.trim().isNotEmpty() && !isSubmitting,
onClick = { onConfirm(value.trim()) },
) {
Text(confirmLabel)
}
},
dismissButton = {
TextButton(
enabled = !isSubmitting,
onClick = onDismiss,
) {
Text(stringResource(R.string.dialog_cancel))
}
},
)
}
@Composable
private fun DeleteFavoriteDialog(
name: String,
isSubmitting: Boolean,
onDismiss: () -> Unit,
onConfirm: () -> Unit,
) {
AlertDialog(
onDismissRequest = {
if (!isSubmitting) onDismiss()
},
title = { Text(stringResource(R.string.favorite_delete_action), fontFamily = ManropeFontFamily, fontWeight = FontWeight.Bold) },
text = {
Text(stringResource(R.string.favorite_delete_confirmation, name))
},
confirmButton = {
TextButton(
enabled = !isSubmitting,
onClick = onConfirm,
) {
Text(stringResource(R.string.favorite_delete_action), color = BrandRed)
}
},
dismissButton = {
TextButton(
enabled = !isSubmitting,
onClick = onDismiss,
) {
Text(stringResource(R.string.dialog_cancel))
}
},
)
}
@Composable
private fun IconAction(
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(20.dp))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(bounded = false, radius = 20.dp),
onClick = onClick,
),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
tint = BrandRed,
modifier = Modifier.size(22.dp),
)
}
}
private data class FavoriteAccent(
val icon: ImageVector,
val tint: Color,
val container: Color,
)
private fun accentFor(index: Int): FavoriteAccent = when (index % 3) {
0 -> FavoriteAccent(
icon = Icons.Outlined.HomeWork,
tint = BrandRed,
container = BrandRedLight.copy(alpha = 0.18f),
)
1 -> FavoriteAccent(
icon = Icons.Outlined.Restaurant,
tint = AccentBlue,
container = AccentBlueContainer.copy(alpha = 0.45f),
)
else -> FavoriteAccent(
icon = Icons.Outlined.CardGiftcard,
tint = AccentPurple,
container = AccentPurpleContainer.copy(alpha = 0.45f),
)
}

View File

@ -0,0 +1,699 @@
package id.iiyh.inatrading.feature.profile.presentation
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Verified
import androidx.compose.material.icons.outlined.AccountBalanceWallet
import androidx.compose.material.icons.outlined.Description
import androidx.compose.material.icons.outlined.Favorite
import androidx.compose.material.icons.outlined.Grade
import androidx.compose.material.icons.outlined.HelpCenter
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material.icons.outlined.Inventory2
import androidx.compose.material.icons.outlined.LocalShipping
import androidx.compose.material.icons.outlined.LocationOn
import androidx.compose.material.icons.outlined.Logout
import androidx.compose.material.icons.outlined.Payments
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.PrivacyTip
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.ChevronRight
import androidx.compose.material.icons.outlined.Shield
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.VerifiedUser
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import coil.compose.AsyncImage
import id.iiyh.inatrading.R
import id.iiyh.inatrading.core.ui.components.InaCard
import id.iiyh.inatrading.core.ui.components.InaChip
import id.iiyh.inatrading.core.ui.components.InaChipVariant
import id.iiyh.inatrading.core.ui.components.InaPrimaryButton
import id.iiyh.inatrading.core.ui.theme.AccentBlue
import id.iiyh.inatrading.core.ui.theme.AccentPurple
import id.iiyh.inatrading.core.ui.theme.AccentPurpleContainer
import id.iiyh.inatrading.core.ui.theme.Background
import id.iiyh.inatrading.core.ui.theme.BrandRed
import id.iiyh.inatrading.core.ui.theme.ManropeFontFamily
import id.iiyh.inatrading.core.ui.theme.OnSurface
import id.iiyh.inatrading.core.ui.theme.OnSurfaceVariant
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerHighest
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerLow
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerLowest
@Composable
fun ProfileScreen(
onLoginClick: () -> Unit,
onEditProfile: () -> Unit,
onChangePassword: () -> Unit,
onShippingAddresses: () -> Unit,
onFavoritesClick: () -> Unit,
onPaymentMethods: () -> Unit,
onHelpCenterClick: () -> Unit,
onTermsClick: () -> Unit,
onPrivacyPolicyClick: () -> Unit,
onAboutClick: () -> Unit,
viewModel: ProfileViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsState()
if (uiState.isLoggedIn && uiState.user != null) {
ProfileLoggedInContent(
user = uiState.user!!,
orderCounts = uiState.orderCounts,
onEditProfile = onEditProfile,
onChangePassword = onChangePassword,
onShippingAddresses = onShippingAddresses,
onFavoritesClick = onFavoritesClick,
onPaymentMethods = onPaymentMethods,
onHelpCenterClick = onHelpCenterClick,
onTermsClick = onTermsClick,
onPrivacyPolicyClick = onPrivacyPolicyClick,
onAboutClick = onAboutClick,
onLogout = viewModel::logout,
)
} else {
ProfileGuestContent(
onLoginClick = onLoginClick,
onHelpCenterClick = onHelpCenterClick,
onTermsClick = onTermsClick,
onPrivacyPolicyClick = onPrivacyPolicyClick,
onAboutClick = onAboutClick,
)
}
}
// ─── Logged In ────────────────────────────────────────────────────────────────
@Composable
private fun ProfileLoggedInContent(
user: UserProfile,
orderCounts: OrderCounts,
onEditProfile: () -> Unit,
onChangePassword: () -> Unit,
onShippingAddresses: () -> Unit,
onFavoritesClick: () -> Unit,
onPaymentMethods: () -> Unit,
onHelpCenterClick: () -> Unit,
onTermsClick: () -> Unit,
onPrivacyPolicyClick: () -> Unit,
onAboutClick: () -> Unit,
onLogout: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(Background)
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp)
.padding(bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
// ── Profile Header dengan Brand Slope ─────────────────────────────
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp),
) {
// Brand Slope background
Box(
modifier = Modifier
.fillMaxWidth()
.offset(x = (-16).dp)
.width(420.dp)
.height(128.dp)
.rotate(-2f)
.clip(RoundedCornerShape(40.dp))
.background(SurfaceContainerHighest)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
// Avatar + verified badge
Box {
AsyncImage(
model = user.avatarUrl.ifEmpty { null },
contentDescription = user.name,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(72.dp)
.clip(RoundedCornerShape(12.dp))
.background(SurfaceContainerLow),
)
// Verified badge
Box(
modifier = Modifier
.size(22.dp)
.align(Alignment.BottomEnd)
.offset(x = 4.dp, y = 4.dp)
.background(AccentPurple, CircleShape)
.clip(CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Filled.Verified,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(14.dp),
)
}
}
// Name, email, chip + settings button
Row(
modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = user.name,
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.ExtraBold,
fontSize = 21.sp,
color = OnSurface,
)
Text(
text = user.email,
style = MaterialTheme.typography.bodySmall,
color = OnSurfaceVariant,
)
}
// Settings shortcut
Box(
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(8.dp))
.background(SurfaceContainerLow)
.clickable(onClick = onEditProfile),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Outlined.Settings,
contentDescription = null,
tint = BrandRed,
modifier = Modifier.size(20.dp),
)
}
}
}
}
// ── My Orders ─────────────────────────────────────────────────────
InaCard {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(R.string.profile_my_orders),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 17.sp,
color = OnSurface,
)
Text(
text = stringResource(R.string.profile_view_all),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = BrandRed,
)
}
Spacer(modifier = Modifier.height(20.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceAround,
) {
OrderTab(icon = Icons.Outlined.AccountBalanceWallet, label = stringResource(R.string.profile_to_pay))
OrderTab(icon = Icons.Outlined.LocalShipping, label = stringResource(R.string.profile_to_ship), badge = orderCounts.toShip)
OrderTab(icon = Icons.Outlined.Inventory2, label = stringResource(R.string.profile_to_receive))
OrderTab(icon = Icons.Outlined.Grade, label = stringResource(R.string.profile_review))
}
}
// ── Settings & Info ───────────────────────────────────────────────
Text(
text = stringResource(R.string.profile_settings_section),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = OnSurfaceVariant.copy(alpha = 0.7f),
letterSpacing = 2.sp,
)
// Bento Grid: Edit Profile + Shipping Addresses
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
BentoCard(
modifier = Modifier.weight(1f),
icon = Icons.Outlined.Edit,
iconTint = AccentBlue,
label = stringResource(R.string.profile_edit_profile),
onClick = onEditProfile,
)
BentoCard(
modifier = Modifier.weight(1f),
icon = Icons.Outlined.LocationOn,
iconTint = AccentPurple,
label = stringResource(R.string.profile_shipping_addresses),
onClick = onShippingAddresses,
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
BentoCard(
modifier = Modifier.weight(1f),
icon = Icons.Outlined.Lock,
iconTint = BrandRed,
label = stringResource(R.string.profile_change_password),
onClick = onChangePassword,
)
Spacer(modifier = Modifier.weight(1f))
}
// List Settings
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(SurfaceContainerLowest),
) {
SettingsListItem(icon = Icons.Outlined.Favorite, label = stringResource(R.string.profile_my_favorites), onClick = onFavoritesClick)
SettingsListItem(icon = Icons.Outlined.Payments, label = stringResource(R.string.profile_payment_methods), onClick = onPaymentMethods)
SettingsListItem(icon = Icons.Outlined.Info, label = stringResource(R.string.settings_about), onClick = onAboutClick)
SettingsListItem(icon = Icons.Outlined.Description, label = stringResource(R.string.settings_terms), onClick = onTermsClick)
SettingsListItem(icon = Icons.Outlined.Shield, label = stringResource(R.string.settings_privacy_policy), onClick = onPrivacyPolicyClick)
SettingsListItem(icon = Icons.Outlined.HelpCenter, label = stringResource(R.string.settings_help_center), onClick = onHelpCenterClick, showDivider = false)
}
// ── Logout ────────────────────────────────────────────────────────
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(BrandRed.copy(alpha = 0.06f))
.clickable(onClick = onLogout)
.padding(vertical = 16.dp),
contentAlignment = Alignment.Center,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Outlined.Logout, contentDescription = null, tint = BrandRed, modifier = Modifier.size(20.dp))
Text(
text = stringResource(R.string.profile_logout),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
color = BrandRed,
)
}
}
// ── Footer ────────────────────────────────────────────────────────
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = stringResource(R.string.app_version).uppercase(),
style = MaterialTheme.typography.labelSmall,
color = OnSurfaceVariant.copy(alpha = 0.5f),
letterSpacing = 2.sp,
)
Text(
text = stringResource(R.string.app_tagline),
style = MaterialTheme.typography.bodySmall,
color = OnSurfaceVariant.copy(alpha = 0.4f),
)
}
}
}
// ─── Guest ────────────────────────────────────────────────────────────────────
@Composable
private fun ProfileGuestContent(
onLoginClick: () -> Unit,
onHelpCenterClick: () -> Unit,
onTermsClick: () -> Unit,
onPrivacyPolicyClick: () -> Unit,
onAboutClick: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(Background)
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp)
.padding(bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
// ── Hero Section ──────────────────────────────────────────────────
Box(modifier = Modifier.fillMaxWidth()) {
// Identity Accent
Box(
modifier = Modifier
.fillMaxWidth()
.offset(x = (-24).dp)
.width(500.dp)
.height(280.dp)
.rotate(-2f)
.background(SurfaceContainerHighest.copy(alpha = 0.35f))
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = 40.dp, bottom = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = stringResource(R.string.profile_guest_title),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.ExtraBold,
fontSize = 26.sp,
color = OnSurface,
textAlign = TextAlign.Center,
)
Text(
text = stringResource(R.string.profile_guest_desc),
style = MaterialTheme.typography.bodyMedium,
color = OnSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(8.dp))
InaPrimaryButton(
text = stringResource(R.string.profile_guest_cta),
onClick = onLoginClick,
)
}
}
// ── Benefits ──────────────────────────────────────────────────────
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Bottom,
) {
Text(
text = stringResource(R.string.profile_why_join),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
color = OnSurface,
)
Text(
text = stringResource(R.string.profile_benefits_label).uppercase(),
style = MaterialTheme.typography.labelSmall,
color = OnSurfaceVariant.copy(alpha = 0.5f),
letterSpacing = 2.sp,
)
}
// Benefit cards
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
BenefitCard(
modifier = Modifier.weight(1f),
icon = Icons.Outlined.LocalShipping,
iconBg = BrandRed.copy(alpha = 0.10f),
iconTint = BrandRed,
title = stringResource(R.string.profile_benefit_track_title),
desc = stringResource(R.string.profile_benefit_track_desc),
)
BenefitCard(
modifier = Modifier.weight(1f),
icon = Icons.Outlined.Favorite,
iconBg = AccentBlue.copy(alpha = 0.10f),
iconTint = AccentBlue,
title = stringResource(R.string.profile_benefit_save_title),
desc = stringResource(R.string.profile_benefit_save_desc),
)
}
// Wide benefit card
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(AccentPurpleContainer.copy(alpha = 0.15f))
.padding(20.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(20.dp),
) {
Box(
modifier = Modifier
.size(56.dp)
.clip(CircleShape)
.background(AccentPurple.copy(alpha = 0.15f)),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Outlined.VerifiedUser, contentDescription = null, tint = AccentPurple, modifier = Modifier.size(28.dp))
}
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = stringResource(R.string.profile_benefit_secure_title),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
color = OnSurface,
)
Text(
text = stringResource(R.string.profile_benefit_secure_desc),
style = MaterialTheme.typography.bodySmall,
color = OnSurfaceVariant,
)
}
}
// ── Info Links ────────────────────────────────────────────────────
Text(
text = stringResource(R.string.profile_info_section).uppercase(),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = OnSurfaceVariant.copy(alpha = 0.5f),
letterSpacing = 2.sp,
)
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(SurfaceContainerLowest),
) {
SettingsListItem(icon = Icons.Outlined.Info, label = stringResource(R.string.settings_about), onClick = onAboutClick)
SettingsListItem(icon = Icons.Outlined.HelpCenter, label = stringResource(R.string.settings_help_center), onClick = onHelpCenterClick)
SettingsListItem(icon = Icons.Outlined.PrivacyTip, label = stringResource(R.string.settings_privacy_policy), onClick = onPrivacyPolicyClick)
SettingsListItem(icon = Icons.Outlined.Description, label = stringResource(R.string.settings_terms), onClick = onTermsClick, showDivider = false)
}
// ── Footer ────────────────────────────────────────────────────────
Text(
text = "${stringResource(R.string.app_version)}${stringResource(R.string.app_tagline)}",
style = MaterialTheme.typography.labelSmall,
color = OnSurfaceVariant.copy(alpha = 0.4f),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
letterSpacing = 1.5.sp,
)
}
}
// ─── Shared sub-composables ───────────────────────────────────────────────────
@Composable
private fun OrderTab(
icon: ImageVector,
label: String,
badge: Int = 0,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(bounded = false, radius = 32.dp),
onClick = {},
),
) {
Box {
Box(
modifier = Modifier
.size(48.dp)
.clip(RoundedCornerShape(12.dp))
.background(SurfaceContainerLow),
contentAlignment = Alignment.Center,
) {
Icon(icon, contentDescription = label, tint = OnSurfaceVariant, modifier = Modifier.size(22.dp))
}
if (badge > 0) {
Box(
modifier = Modifier
.size(16.dp)
.align(Alignment.TopEnd)
.offset(x = 4.dp, y = (-4).dp)
.background(BrandRed, CircleShape),
contentAlignment = Alignment.Center,
) {
Text(badge.toString(), style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp), color = Color.White)
}
}
}
Text(
text = label.uppercase(),
style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp),
color = OnSurfaceVariant,
fontWeight = FontWeight.Bold,
letterSpacing = 0.5.sp,
)
}
}
@Composable
private fun BentoCard(
icon: ImageVector,
iconTint: Color,
label: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.height(128.dp)
.clip(RoundedCornerShape(12.dp))
.background(SurfaceContainerLowest)
.clickable(onClick = onClick)
.padding(20.dp),
verticalArrangement = Arrangement.SpaceBetween,
) {
Icon(icon, contentDescription = null, tint = iconTint, modifier = Modifier.size(24.dp))
Text(
text = label,
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 13.sp,
color = OnSurface,
)
}
}
@Composable
private fun BenefitCard(
icon: ImageVector,
iconBg: Color,
iconTint: Color,
title: String,
desc: String,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.clip(RoundedCornerShape(12.dp))
.background(SurfaceContainerLowest)
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
modifier = Modifier
.size(44.dp)
.clip(RoundedCornerShape(12.dp))
.background(iconBg),
contentAlignment = Alignment.Center,
) {
Icon(icon, contentDescription = null, tint = iconTint, modifier = Modifier.size(22.dp))
}
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(title, fontFamily = ManropeFontFamily, fontWeight = FontWeight.Bold, fontSize = 14.sp, color = OnSurface)
Text(desc, style = MaterialTheme.typography.bodySmall, color = OnSurfaceVariant)
}
}
}
@Composable
private fun SettingsListItem(
icon: ImageVector,
label: String,
onClick: () -> Unit = {},
showDivider: Boolean = true,
) {
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(color = OnSurface.copy(alpha = 0.06f)),
onClick = onClick,
)
.padding(horizontal = 20.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Icon(icon, contentDescription = null, tint = OnSurfaceVariant.copy(alpha = 0.6f), modifier = Modifier.size(22.dp))
Text(label, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, color = OnSurface, modifier = Modifier.weight(1f))
Icon(
Icons.Outlined.ChevronRight,
contentDescription = null,
tint = OnSurfaceVariant.copy(alpha = 0.25f),
modifier = Modifier.size(18.dp),
)
}
if (showDivider) {
Box(modifier = Modifier.fillMaxWidth().height(4.dp).background(SurfaceContainerLow))
}
}
}

View File

@ -0,0 +1,90 @@
package id.iiyh.inatrading.feature.profile.presentation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import id.iiyh.inatrading.core.data.local.SessionManager
import id.iiyh.inatrading.feature.auth.domain.repository.AuthRepository
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
data class UserProfile(
val name: String,
val email: String,
val avatarUrl: String,
val isPremium: Boolean,
)
data class OrderCounts(
val toShip: Int = 0,
)
data class ProfileUiState(
val isLoggedIn: Boolean = false,
val user: UserProfile? = null,
val orderCounts: OrderCounts = OrderCounts(),
)
@HiltViewModel
class ProfileViewModel @Inject constructor(
private val sessionManager: SessionManager,
private val authRepository: AuthRepository,
) : ViewModel() {
val uiState: StateFlow<ProfileUiState> = combine(
sessionManager.token,
sessionManager.email,
sessionManager.name,
sessionManager.avatarUrl,
sessionManager.userType,
) { token, email, name, avatarUrl, userType ->
if (token.isNullOrEmpty()) {
ProfileUiState(isLoggedIn = false)
} else {
ProfileUiState(
isLoggedIn = true,
user = UserProfile(
name = name.orEmpty().ifEmpty { email.orEmpty() },
email = email.orEmpty(),
avatarUrl = avatarUrl.orEmpty(),
isPremium = userType.equals("SELLER", ignoreCase = true),
),
orderCounts = OrderCounts(),
)
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = ProfileUiState(),
)
init {
sessionManager.token
.distinctUntilChanged()
.onEach { token ->
if (!token.isNullOrBlank()) {
refreshProfile()
}
}
.launchIn(viewModelScope)
}
private fun refreshProfile() {
viewModelScope.launch {
authRepository.getBuyerProfile()
}
}
fun logout() {
viewModelScope.launch {
authRepository.logout()
}
}
}

View File

@ -0,0 +1,486 @@
package id.iiyh.inatrading.feature.profile.presentation
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.BusinessCenter
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.Explore
import androidx.compose.material.icons.outlined.Home
import androidx.compose.material.icons.outlined.LocationOn
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material.icons.outlined.Phone
import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material.icons.outlined.Warehouse
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import id.iiyh.inatrading.R
import id.iiyh.inatrading.core.ui.components.InaInnerTopAppBar
import id.iiyh.inatrading.core.ui.components.InaPrimaryButton
import id.iiyh.inatrading.core.ui.theme.AccentBlue
import id.iiyh.inatrading.core.ui.theme.AccentBlueContainer
import id.iiyh.inatrading.core.ui.theme.AccentPurple
import id.iiyh.inatrading.core.ui.theme.AccentPurpleContainer
import id.iiyh.inatrading.core.ui.theme.Background
import id.iiyh.inatrading.core.ui.theme.BrandRed
import id.iiyh.inatrading.core.ui.theme.BrandRedContainer
import id.iiyh.inatrading.core.ui.theme.ManropeFontFamily
import id.iiyh.inatrading.core.ui.theme.OnSurface
import id.iiyh.inatrading.core.ui.theme.OnSurfaceVariant
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerHighest
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerLow
import id.iiyh.inatrading.core.ui.theme.SurfaceContainerLowest
import id.iiyh.inatrading.feature.profile.data.model.ShippingAddress
@Composable
fun ShippingAddressesScreen(
onBack: () -> Unit,
onAddAddressClick: () -> Unit,
onEditAddressClick: (ShippingAddress) -> Unit,
viewModel: ShippingAddressesViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(uiState.infoMessage) {
uiState.infoMessage?.let {
snackbarHostState.showSnackbar(it)
viewModel.consumeInfoMessage()
}
}
Scaffold(
topBar = { InaInnerTopAppBar(onBack = onBack) },
snackbarHost = { SnackbarHost(snackbarHostState) },
containerColor = Background,
) { innerPadding ->
when {
uiState.isLoading -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(color = BrandRed)
}
}
uiState.errorMessage != null -> {
AddressState(
title = stringResource(R.string.shipping_addresses_error_title),
body = uiState.errorMessage.orEmpty(),
actionLabel = stringResource(R.string.explore_retry),
onAction = viewModel::loadAddresses,
modifier = Modifier.padding(innerPadding),
)
}
uiState.addresses.isEmpty() -> {
AddressState(
title = stringResource(R.string.shipping_addresses_empty_title),
body = stringResource(R.string.shipping_addresses_empty_body),
actionLabel = stringResource(R.string.shipping_addresses_add_cta),
onAction = onAddAddressClick,
modifier = Modifier.padding(innerPadding),
)
}
else -> {
ShippingAddressesContent(
addresses = uiState.addresses,
onAddAddressClick = onAddAddressClick,
onEditAddressClick = onEditAddressClick,
modifier = Modifier.padding(innerPadding),
)
}
}
}
}
@Composable
private fun ShippingAddressesContent(
addresses: List<ShippingAddress>,
onAddAddressClick: () -> Unit,
onEditAddressClick: (ShippingAddress) -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier
.fillMaxSize()
.background(Background),
contentPadding = PaddingValues(start = 24.dp, end = 24.dp, top = 20.dp, bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
item {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(32.dp))
.background(SurfaceContainerHighest.copy(alpha = 0.35f))
.padding(horizontal = 24.dp, vertical = 28.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
text = stringResource(R.string.shipping_addresses_eyebrow),
style = MaterialTheme.typography.labelSmall,
letterSpacing = 2.sp,
color = AccentPurple,
)
Text(
text = stringResource(R.string.shipping_addresses_title),
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.ExtraBold,
fontSize = 34.sp,
lineHeight = 38.sp,
color = OnSurface,
)
}
}
itemsIndexed(addresses, key = { _, item -> item.id.orEmpty() }) { index, address ->
ShippingAddressCard(
address = address,
title = resolveAddressLabel(address, index),
icon = if (index == 0 || address.isPrimary) Icons.Outlined.Home else Icons.Outlined.BusinessCenter,
accent = if (address.isPrimary) BrandRed else AccentBlue,
accentContainer = if (address.isPrimary) BrandRedContainer.copy(alpha = 0.14f) else AccentBlueContainer.copy(alpha = 0.22f),
onEditClick = { onEditAddressClick(address) },
)
}
item {
DeliveryZoneCard()
}
item {
InaPrimaryButton(
text = stringResource(R.string.shipping_addresses_add_cta),
onClick = onAddAddressClick,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
@Composable
private fun ShippingAddressCard(
address: ShippingAddress,
title: String,
icon: ImageVector,
accent: Color,
accentContainer: Color,
onEditClick: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(24.dp))
.background(SurfaceContainerLowest)
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(12.dp))
.background(accentContainer),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = accent,
)
}
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = title,
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 19.sp,
color = OnSurface,
)
if (address.isPrimary) {
Box(
modifier = Modifier
.clip(CircleShape)
.background(AccentPurpleContainer)
.padding(horizontal = 10.dp, vertical = 4.dp),
) {
Text(
text = stringResource(R.string.shipping_addresses_primary),
style = MaterialTheme.typography.labelSmall,
color = Color.White,
)
}
}
}
}
Box(
modifier = Modifier
.size(32.dp)
.clip(CircleShape)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(),
onClick = onEditClick,
),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Outlined.Edit,
contentDescription = null,
tint = OnSurfaceVariant.copy(alpha = 0.75f),
modifier = Modifier.size(20.dp),
)
}
}
AddressMetaRow(
icon = Icons.Outlined.Person,
primaryText = address.recipient?.takeIf { it.isNotBlank() }
?: stringResource(R.string.shipping_addresses_recipient_fallback),
)
AddressMetaRow(
icon = Icons.Outlined.Phone,
primaryText = address.mobile?.takeIf { it.isNotBlank() }
?: stringResource(R.string.shipping_addresses_phone_fallback),
)
AddressMetaRow(
icon = Icons.Outlined.LocationOn,
primaryText = buildAddressLine(address),
isMultiLine = true,
)
}
}
@Composable
private fun AddressMetaRow(
icon: ImageVector,
primaryText: String,
isMultiLine: Boolean = false,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = if (isMultiLine) Alignment.Top else Alignment.CenterVertically,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = OnSurfaceVariant,
modifier = Modifier
.padding(top = if (isMultiLine) 2.dp else 0.dp)
.size(18.dp),
)
Text(
text = primaryText,
style = MaterialTheme.typography.bodyMedium,
color = OnSurfaceVariant,
maxLines = if (isMultiLine) Int.MAX_VALUE else 1,
overflow = if (isMultiLine) TextOverflow.Clip else TextOverflow.Ellipsis,
)
}
}
@Composable
private fun DeliveryZoneCard() {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(24.dp))
.background(SurfaceContainerLowest)
.padding(bottom = 20.dp),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(132.dp)
.background(
brush = Brush.linearGradient(
colors = listOf(
SurfaceContainerHighest,
SurfaceContainerLow,
SurfaceContainerLowest,
)
)
),
) {
Box(
modifier = Modifier
.align(Alignment.Center)
.size(56.dp)
.clip(CircleShape)
.background(Color.White.copy(alpha = 0.88f)),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Outlined.Explore,
contentDescription = null,
tint = BrandRed,
modifier = Modifier.size(28.dp),
)
}
Row(
modifier = Modifier
.align(Alignment.BottomStart)
.padding(start = 20.dp, bottom = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(
imageVector = Icons.Outlined.Warehouse,
contentDescription = null,
tint = BrandRed,
modifier = Modifier.size(16.dp),
)
Text(
text = stringResource(R.string.shipping_addresses_zone_label),
style = MaterialTheme.typography.labelSmall,
letterSpacing = 1.4.sp,
color = OnSurfaceVariant,
)
}
}
Text(
text = stringResource(R.string.shipping_addresses_zone_quote),
style = MaterialTheme.typography.bodySmall,
color = OnSurfaceVariant,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp),
)
}
}
@Composable
private fun AddressState(
title: String,
body: String,
actionLabel: String,
onAction: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.fillMaxSize()
.background(Background)
.padding(24.dp),
contentAlignment = Alignment.Center,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(28.dp))
.background(SurfaceContainerLowest)
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Text(
text = title,
fontFamily = ManropeFontFamily,
fontWeight = FontWeight.ExtraBold,
fontSize = 26.sp,
color = OnSurface,
)
Text(
text = body,
style = MaterialTheme.typography.bodyMedium,
color = OnSurfaceVariant,
)
Row(
modifier = Modifier
.clip(RoundedCornerShape(18.dp))
.background(BrandRed)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(),
onClick = onAction,
)
.padding(horizontal = 18.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Outlined.Refresh,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(18.dp),
)
Text(
text = actionLabel,
color = Color.White,
fontWeight = FontWeight.Bold,
)
}
}
}
}
@Composable
private fun resolveAddressLabel(address: ShippingAddress, index: Int): String {
val label = address.label?.trim().orEmpty()
if (label.isNotBlank()) return label.replaceFirstChar { it.uppercase() }
if (address.isPrimary) return stringResource(R.string.shipping_addresses_home_label)
if (index == 1) return stringResource(R.string.shipping_addresses_office_label)
return stringResource(R.string.shipping_addresses_default_label, index + 1)
}
private fun buildAddressLine(address: ShippingAddress): String {
return listOfNotNull(
address.address?.takeIf { it.isNotBlank() },
address.city?.takeIf { it.isNotBlank() },
address.province?.takeIf { it.isNotBlank() },
address.country?.takeIf { it.isNotBlank() },
address.postalCode?.takeIf { it.isNotBlank() },
).joinToString(", ")
}

Some files were not shown because too many files have changed in this diff Show More