Initial project import
This commit is contained in:
20
.gitignore
vendored
Normal file
20
.gitignore
vendored
Normal 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
2
BRIEF.md
Normal 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.
|
||||
52
Ina Trading Dev.postman_environment.json
Normal file
52
Ina Trading Dev.postman_environment.json
Normal 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"
|
||||
}
|
||||
4454
Ina Trading.postman_collection.json
Normal file
4454
Ina Trading.postman_collection.json
Normal file
File diff suppressed because it is too large
Load Diff
1
app/.gitignore
vendored
Normal file
1
app/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
/build
|
||||
170
app/build.gradle.kts
Normal file
170
app/build.gradle.kts
Normal 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
29
app/google-services.json
Normal 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
21
app/proguard-rules.pro
vendored
Normal 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
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
44
app/src/main/AndroidManifest.xml
Normal file
44
app/src/main/AndroidManifest.xml
Normal 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>
|
||||
BIN
app/src/main/ic_launcher-playstore.png
Normal file
BIN
app/src/main/ic_launcher-playstore.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
7
app/src/main/java/id/iiyh/inatrading/InaApplication.kt
Normal file
7
app/src/main/java/id/iiyh/inatrading/InaApplication.kt
Normal file
@ -0,0 +1,7 @@
|
||||
package id.iiyh.inatrading
|
||||
|
||||
import android.app.Application
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
|
||||
@HiltAndroidApp
|
||||
class InaApplication : Application()
|
||||
155
app/src/main/java/id/iiyh/inatrading/MainActivity.kt
Normal file
155
app/src/main/java/id/iiyh/inatrading/MainActivity.kt
Normal 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!!!!"
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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?>
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
package id.iiyh.inatrading.core.data.remote
|
||||
|
||||
class SessionExpiredException : Exception("Session expired")
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
18
app/src/main/java/id/iiyh/inatrading/core/di/AuthModule.kt
Normal file
18
app/src/main/java/id/iiyh/inatrading/core/di/AuthModule.kt
Normal 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
|
||||
}
|
||||
19
app/src/main/java/id/iiyh/inatrading/core/di/CartModule.kt
Normal file
19
app/src/main/java/id/iiyh/inatrading/core/di/CartModule.kt
Normal 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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
18
app/src/main/java/id/iiyh/inatrading/core/di/NewsModule.kt
Normal file
18
app/src/main/java/id/iiyh/inatrading/core/di/NewsModule.kt
Normal 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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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 = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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)),
|
||||
}
|
||||
@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
48
app/src/main/java/id/iiyh/inatrading/core/ui/theme/Color.kt
Normal file
48
app/src/main/java/id/iiyh/inatrading/core/ui/theme/Color.kt
Normal 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)
|
||||
114
app/src/main/java/id/iiyh/inatrading/core/ui/theme/Theme.kt
Normal file
114
app/src/main/java/id/iiyh/inatrading/core/ui/theme/Theme.kt
Normal 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
|
||||
)
|
||||
}
|
||||
155
app/src/main/java/id/iiyh/inatrading/core/ui/theme/Type.kt
Normal file
155
app/src/main/java/id/iiyh/inatrading/core/ui/theme/Type.kt
Normal 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
|
||||
),
|
||||
)
|
||||
@ -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,
|
||||
)
|
||||
@ -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()})"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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() }
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
}
|
||||
@ -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 ")
|
||||
}
|
||||
@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
@ -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()})"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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>>
|
||||
}
|
||||
@ -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 2–5 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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
}
|
||||
@ -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 ")
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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 ")
|
||||
}
|
||||
@ -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"
|
||||
@ -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,
|
||||
)
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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__"
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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) }
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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__"
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
)
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
Reference in New Issue
Block a user