commit 3236104a0fa165477a745415ec986c3adeb1ba19 Author: Wiraba Salamah Date: Fri Apr 24 08:04:20 2026 +0700 Initial project import diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..14b717c --- /dev/null +++ b/.gitignore @@ -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 diff --git a/BRIEF.md b/BRIEF.md new file mode 100644 index 0000000..c47f8fb --- /dev/null +++ b/BRIEF.md @@ -0,0 +1,2 @@ +Aplikasi ini adalah marketplace untuk UMKM di Indonesia. +pada aplikasi ini kita bisa berbelanja produk produk lokal dengan kualitas export. \ No newline at end of file diff --git a/Ina Trading Dev.postman_environment.json b/Ina Trading Dev.postman_environment.json new file mode 100644 index 0000000..15f7105 --- /dev/null +++ b/Ina Trading Dev.postman_environment.json @@ -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" +} \ No newline at end of file diff --git a/Ina Trading.postman_collection.json b/Ina Trading.postman_collection.json new file mode 100644 index 0000000..a8cb7f6 --- /dev/null +++ b/Ina Trading.postman_collection.json @@ -0,0 +1,4454 @@ +{ + "info": { + "_postman_id": "22262359-5fc1-4005-af0b-cde0aa74a042", + "name": "Ina Trading", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "103384", + "_collection_link": "https://go.postman.co/collection/103384-22262359-5fc1-4005-af0b-cde0aa74a042?source=collection_link" + }, + "item": [ + { + "name": "Seller", + "item": [ + { + "name": "Register Seller", + "request": { + "auth": { + "type": "noauth" + }, + "method": "POST", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + }, + { + "key": "Tenant-Id", + "value": "{{tenantId}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"rizki\",\n \"email\": \"rizki@rizki.com\",\n \"mobile\": \"0812345\",\n \"password\": \"password\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{hostname}}/api/v1.0/seller/register", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "seller", + "register" + ] + } + }, + "response": [] + }, + { + "name": "Create Seller", + "request": { + "auth": { + "type": "noauth" + }, + "method": "POST", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + }, + { + "key": "Tenant-Id", + "value": "{{tenantId}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"PT Contoh Seller\",\n \"address\": \"Jl. Merdeka No. 123\",\n \"country\": \"Indonesia\",\n \"province\": \"Jawa Barat\",\n \"city\": \"Bandung\",\n \"postalCode\": \"40123\",\n \"mobile\": \"+6281234567890\",\n \"bankName\": \"Bank BCA\",\n \"bankAccountNumber\": \"1234567890\",\n \"documents\": [\n {\n \"type\": \"NPWP\",\n \"documentNumber\": \"09.123.456.7-890.000\",\n \"publishedDate\": \"01/10/2024\",\n \"validDate\": \"31/12/2026\",\n \"fileId\": \"0b4e93ee-6c77-4999-876d-734dbfdad6f4.png\"\n }\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{hostname}}/api/v1.0/seller", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "seller" + ] + } + }, + "response": [] + }, + { + "name": "Update Store", + "request": { + "auth": { + "type": "noauth" + }, + "method": "PUT", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + }, + { + "key": "Tenant-Id", + "value": "{{tenantId}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"imageId\": \"img-123456\",\n \"storeName\": \"Toko Elektronik Nusantara\",\n \"storeImageId\": \"store-img-78910\",\n \"storeBiography\": \"Kami menjual berbagai produk elektronik berkualitas dengan harga terbaik dan pengiriman cepat ke seluruh Indonesia.\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{hostname}}/api/v1.0/seller/store", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "seller", + "store" + ] + } + }, + "response": [] + }, + { + "name": "Get Seller Profile", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + }, + { + "key": "Tenant-Id", + "value": "{{tenantId}}", + "type": "text" + } + ], + "url": { + "raw": "{{hostname}}/api/v1.0/seller/profile", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "seller", + "profile" + ] + } + }, + "response": [] + }, + { + "name": "Get Seller Profile Copy", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Tenant-Id", + "value": "{{tenantId}}", + "type": "text" + } + ], + "url": { + "raw": "{{hostname}}/api/v1.0/seller/promotion", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "seller", + "promotion" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Buyer", + "item": [ + { + "name": "Register Buyer", + "request": { + "auth": { + "type": "noauth" + }, + "method": "POST", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + }, + { + "key": "Tenant-Id", + "value": "{{tenantId}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"rizki\",\n \"email\": \"rizki@admin.com\",\n \"mobile\": \"0812346\",\n \"password\": \"password\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{hostname}}/api/v1.0/buyer/register", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "buyer", + "register" + ] + } + }, + "response": [] + }, + { + "name": "Update Profile Buyer", + "request": { + "auth": { + "type": "noauth" + }, + "method": "PUT", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + }, + { + "key": "Tenant-Id", + "value": "{{tenantId}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"email\": \"rizki@gmail.com\",\n \"profileDescription\": \"update profile\",\n \"imageId\": \"0b4e93ee-6c77-4999-876d-734dbfdad6f4.png\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{hostname}}/api/v1.0/buyer/profile", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "buyer", + "profile" + ] + } + }, + "response": [] + }, + { + "name": "Get Buyer Profile", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + }, + { + "key": "Tenant-Id", + "value": "{{tenantId}}", + "type": "text" + } + ], + "url": { + "raw": "{{hostname}}/api/v1.0/buyer/profile", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "buyer", + "profile" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "File", + "item": [ + { + "name": "Upload File", + "request": { + "auth": { + "type": "noauth" + }, + "method": "POST", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + }, + { + "key": "Tenant-Id", + "value": "{{tenantId}}", + "type": "text" + } + ], + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "file", + "type": "file", + "uuid": "c9ca9c9d-314e-47d0-95d4-9be3b9ca3836", + "src": "/home/rizki-mufrizal/Pictures/login.png" + } + ] + }, + "url": { + "raw": "{{hostname}}/api/v1.0/file/upload", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "file", + "upload" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Authentication", + "item": [ + { + "name": "Login Seller", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "let response = pm.response.json();", + "pm.environment.set('token', \"Bearer \" + response.data.session);" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{username}}", + "type": "string" + }, + { + "key": "password", + "value": "{{password}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Origin", + "value": "local", + "type": "text" + } + ], + "url": { + "raw": "{{hostname}}/api/v1.0/seller/login", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "seller", + "login" + ] + } + }, + "response": [] + }, + { + "name": "Login Buyer", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "let response = pm.response.json();", + "pm.environment.set('token', \"Bearer \" + response.data.session);" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{username}}", + "type": "string" + }, + { + "key": "password", + "value": "{{password}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Origin", + "value": "local", + "type": "text" + } + ], + "url": { + "raw": "{{hostname}}/api/v1.0/buyer/login", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "buyer", + "login" + ] + } + }, + "response": [] + }, + { + "name": "Change Password", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "let response = pm.response.json();", + "pm.environment.set('token', \"Bearer \" + response.data.session);" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + } + ], + "request": { + "auth": { + "type": "noauth" + }, + "method": "PUT", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + }, + { + "key": "Tenant-Id", + "value": "{{tenantId}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"newPassword\": \"12345\",\n \"oldPassword\": \"admin\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{hostname}}/api/v1.0/profile/change-password", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "profile", + "change-password" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Product", + "item": [ + { + "name": "Get Product", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + }, + { + "key": "Tenant-Id", + "value": "{{tenantId}}", + "type": "text" + } + ], + "url": { + "raw": "{{hostname}}/api/v1.0/product", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "product" + ] + } + }, + "response": [] + }, + { + "name": "Get Product Detail", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + }, + { + "key": "Tenant-Id", + "value": "{{tenantId}}", + "type": "text" + } + ], + "url": { + "raw": "{{hostname}}/api/v1.0/product/4a8eb77e-56fd-490b-a4d8-d65ef6045fa0", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "product", + "4a8eb77e-56fd-490b-a4d8-d65ef6045fa0" + ] + } + }, + "response": [] + }, + { + "name": "Get Product Review", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + }, + { + "key": "Tenant-Id", + "value": "{{tenantId}}", + "type": "text" + } + ], + "url": { + "raw": "{{hostname}}/api/v1.0/product/review", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "product", + "review" + ] + } + }, + "response": [] + }, + { + "name": "Get Product Review Detail", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + }, + { + "key": "Tenant-Id", + "value": "{{tenantId}}", + "type": "text" + } + ], + "url": { + "raw": "{{hostname}}/api/v1.0/product/review/c9506fe1-0fd4-48b8-a703-f902e3431301", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "product", + "review", + "c9506fe1-0fd4-48b8-a703-f902e3431301" + ] + } + }, + "response": [] + }, + { + "name": "Get Seller Product", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + }, + { + "key": "Tenant-Id", + "value": "{{tenantId}}", + "type": "text" + } + ], + "url": { + "raw": "{{hostname}}/api/v1.0/seller/product", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "seller", + "product" + ] + } + }, + "response": [] + }, + { + "name": "Get Seller Product Draft", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + }, + { + "key": "Tenant-Id", + "value": "{{tenantId}}", + "type": "text" + } + ], + "url": { + "raw": "{{hostname}}/api/v1.0/seller/draft/product", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "seller", + "draft", + "product" + ] + } + }, + "response": [] + }, + { + "name": "Get Seller Product Draft Detail", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + }, + { + "key": "Tenant-Id", + "value": "{{tenantId}}", + "type": "text" + } + ], + "url": { + "raw": "{{hostname}}/api/v1.0/seller/draft/product/123456", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "seller", + "draft", + "product", + "123456" + ] + } + }, + "response": [] + }, + { + "name": "Get Seller Product International", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + }, + { + "key": "Tenant-Id", + "value": "{{tenantId}}", + "type": "text" + } + ], + "url": { + "raw": "{{hostname}}/api/v1.0/seller/international/product", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "seller", + "international", + "product" + ] + } + }, + "response": [] + }, + { + "name": "Get Seller Product Local", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + }, + { + "key": "Tenant-Id", + "value": "{{tenantId}}", + "type": "text" + } + ], + "url": { + "raw": "{{hostname}}/api/v1.0/seller/local/product", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "seller", + "local", + "product" + ] + } + }, + "response": [] + }, + { + "name": "Get Seller Product Out Of Stock", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + }, + { + "key": "Tenant-Id", + "value": "{{tenantId}}", + "type": "text" + } + ], + "url": { + "raw": "{{hostname}}/api/v1.0/seller/outofstock/product", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "seller", + "outofstock", + "product" + ] + } + }, + "response": [] + }, + { + "name": "Get Seller Product Reject", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + }, + { + "key": "Tenant-Id", + "value": "{{tenantId}}", + "type": "text" + } + ], + "url": { + "raw": "{{hostname}}/api/v1.0/seller/reject/product", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "seller", + "reject", + "product" + ] + } + }, + "response": [] + }, + { + "name": "Get Seller Total Product", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + }, + { + "key": "Tenant-Id", + "value": "{{tenantId}}", + "type": "text" + } + ], + "url": { + "raw": "{{hostname}}/api/v1.0/seller/total/product", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "seller", + "total", + "product" + ] + } + }, + "response": [] + }, + { + "name": "Get Seller Sold Product", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + }, + { + "key": "Tenant-Id", + "value": "{{tenantId}}", + "type": "text" + } + ], + "url": { + "raw": "{{hostname}}/api/v1.0/seller/sold/product", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "seller", + "sold", + "product" + ] + } + }, + "response": [] + }, + { + "name": "Get Seller Refund Product", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + }, + { + "key": "Tenant-Id", + "value": "{{tenantId}}", + "type": "text" + } + ], + "url": { + "raw": "{{hostname}}/api/v1.0/seller/refund/product", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "seller", + "refund", + "product" + ] + } + }, + "response": [] + }, + { + "name": "Get Product Promotion", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Tenant-Id", + "value": "{{tenantId}}", + "type": "text" + } + ], + "url": { + "raw": "{{hostname}}/api/v1.0/product/promotion", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "product", + "promotion" + ] + } + }, + "response": [] + }, + { + "name": "Compare Product", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Tenant-Id", + "value": "{{tenantId}}", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + } + ], + "url": { + "raw": "{{hostname}}/api/v1.0/product/compare/ba70a8e3-2342-4cd3-a7c7-a0f1b58689ab", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "product", + "compare", + "ba70a8e3-2342-4cd3-a7c7-a0f1b58689ab" + ] + } + }, + "response": [] + }, + { + "name": "Create Product", + "request": { + "auth": { + "type": "noauth" + }, + "method": "POST", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"subCategory\": {\n \"id\": \"2\"\n },\n \"name\": \"Smartphone XYZ\",\n \"description\": \"Smartphone terbaru dengan fitur lengkap.\",\n \"isPreOrder\": false,\n \"preOrderDay\": null,\n \"isNew\": true,\n \"isEligibleToExport\": true,\n \"productFiles\": [\n \"product-manual.pdf\",\n \"123.pdf\"\n ],\n \"imageId\": \"contoh.jpg\",\n \"state\": \"DRAFT\",\n \"productKeyWords\": [\n \"smartphone\",\n \"Iphone\",\n \"4g\"\n ],\n \"productFeatures\": [\n \"Layar 6.5 inch\",\n \"Baterai 5000mAh\",\n \"Kamera 64MP\"\n ],\n \"productImages\": [\n {\n \"imageId\": \"img1.jpg\",\n \"sequence\": 1\n },\n {\n \"imageId\": \"img2.jpg\",\n \"sequence\": 2\n }\n ],\n \"productModels\": [\n {\n \"name\": \"XYZ - Black 128GB\",\n \"price\": 4500000,\n \"currency\": \"IDR\",\n \"weight\": 180,\n \"weightType\": \"G\",\n \"length\": 15.2,\n \"width\": 7.4,\n \"height\": 0.8,\n \"dimensionType\": \"CM\",\n \"isMeasurement\": true,\n \"imageId\": \"model-black.jpg\",\n \"sku\": \"XYZ-BLK-128\",\n \"isConfigurePromotionPrice\": true,\n \"promotionPrice\": 4200000,\n \"promotionCurrency\": \"IDR\",\n \"promotionStartDate\": \"2025-01-01\",\n \"promotionEndDate\": \"2025-01-15\",\n \"packagingWeight\": 250,\n \"packagingWeightType\": \"G\",\n \"packagingLength\": 20.0,\n \"packagingWidth\": 10.0,\n \"packagingHeight\": 5.0,\n \"packagingDimensionType\": \"CM\",\n \"warehouses\": [\n {\n \"id\": \"1a341e2a-006d-4b66-8a28-72c05a081384\",\n \"stock\": 120\n }\n ],\n \"productMeasurements\": [\n {\n \"price\": 4500000,\n \"currency\": \"IDR\",\n \"weight\": 180,\n \"weightType\": \"G\",\n \"length\": 15.2,\n \"width\": 7.4,\n \"height\": 0.8,\n \"dimensionType\": \"CM\",\n \"measurementType\": \"COLOR\",\n \"measurementValue\": \"Black\",\n \"isConfigurePromotionPrice\": false,\n \"promotionPrice\": null,\n \"promotionCurrency\": null,\n \"promotionStartDate\": null,\n \"promotionEndDate\": null,\n \"packagingWeight\": 250,\n \"packagingWeightType\": \"G\",\n \"packagingLength\": 20.0,\n \"packagingWidth\": 10.0,\n \"packagingHeight\": 5.0,\n \"packagingDimensionType\": \"CM\",\n \"warehouses\": [\n {\n \"id\": \"1a341e2a-006d-4b66-8a28-72c05a081384\",\n \"stock\": 30\n }\n ]\n }\n ]\n }\n ],\n \"productInformations\": [\n {\n \"paramName\": \"Brand\",\n \"paramValue\": \"XYZ Corp\"\n },\n {\n \"paramName\": \"Battery\",\n \"paramValue\": \"5000mAh\"\n }\n ],\n \"categoryInformations\": [\n {\n \"paramName\": \"Electronics Type\",\n \"paramValue\": \"Smartphone\"\n }\n ],\n \"complianceInformation\": {\n \"safetyWarning\": \"Jauhkan dari panas ekstrem\",\n \"countryOfOrigin\": \"China\",\n \"isDangerousGoodRegulation\": false,\n \"fileId\": \"compliance.pdf\"\n },\n \"warrantyInformation\": {\n \"type\": \"Official Warranty\",\n \"duration\": 12,\n \"durationType\": \"MONTH\"\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{hostname}}/api/v1.0/product", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "product" + ] + } + }, + "response": [] + }, + { + "name": "Submit Review Product", + "request": { + "auth": { + "type": "noauth" + }, + "method": "POST", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + } + ], + "url": { + "raw": "{{hostname}}/api/v1.0/product/submit-review/ba70a8e3-2342-4cd3-a7c7-a0f1b58689ab", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "product", + "submit-review", + "ba70a8e3-2342-4cd3-a7c7-a0f1b58689ab" + ] + } + }, + "response": [] + }, + { + "name": "Accept Create Product", + "request": { + "auth": { + "type": "noauth" + }, + "method": "POST", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + } + ], + "url": { + "raw": "{{hostname}}/api/v1.0/product/accept/d5053411-6800-4a86-a405-cca719c80950", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "product", + "accept", + "d5053411-6800-4a86-a405-cca719c80950" + ] + } + }, + "response": [] + }, + { + "name": "Reject Create Product", + "request": { + "auth": { + "type": "noauth" + }, + "method": "POST", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"reason\": \"tidak sesuai\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{hostname}}/api/v1.0/product/reject/d6443efe-9787-444b-b98a-f0c070aa54b3", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "product", + "reject", + "d6443efe-9787-444b-b98a-f0c070aa54b3" + ] + } + }, + "response": [] + }, + { + "name": "Update Product Draft", + "request": { + "auth": { + "type": "noauth" + }, + "method": "PUT", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"subCategory\": {\n \"id\": \"2\"\n },\n \"name\": \"Smartphone OK GAES\",\n \"description\": \"Smartphone terbaru dengan fitur lengkap.\",\n \"isPreOrder\": false,\n \"preOrderDay\": null,\n \"isNew\": true,\n \"isEligibleToExport\": true,\n \"productFiles\": [\n \"product-manual.pdf\",\n \"123.pdf\"\n ],\n \"imageId\": \"contoh.jpg\",\n \"state\": \"DRAFT\",\n \"productKeyWords\": [\n \"smartphone\",\n \"Android\",\n \"5g\"\n ],\n \"productFeatures\": [\n \"Layar 6.5 inch\",\n \"Baterai 5000mAh\",\n \"Kamera 64MP\"\n ],\n \"productImages\": [\n {\n \"imageId\": \"img1.jpg\",\n \"sequence\": 1\n },\n {\n \"imageId\": \"img2.jpg\",\n \"sequence\": 2\n }\n ],\n \"productModels\": [\n {\n \"name\": \"XYZ - Black 128GB\",\n \"price\": 4500000,\n \"currency\": \"IDR\",\n \"weight\": 180,\n \"weightType\": \"G\",\n \"length\": 15.2,\n \"width\": 7.4,\n \"height\": 0.8,\n \"dimensionType\": \"CM\",\n \"isMeasurement\": true,\n \"imageId\": \"model-black.jpg\",\n \"sku\": \"XYZ-BLK-128\",\n \"isConfigurePromotionPrice\": true,\n \"promotionPrice\": 4200000,\n \"promotionCurrency\": \"IDR\",\n \"promotionStartDate\": \"2025-01-01\",\n \"promotionEndDate\": \"2025-01-15\",\n \"packagingWeight\": 250,\n \"packagingWeightType\": \"G\",\n \"packagingLength\": 20.0,\n \"packagingWidth\": 10.0,\n \"packagingHeight\": 5.0,\n \"packagingDimensionType\": \"CM\",\n \"warehouses\": [\n {\n \"id\": \"1a341e2a-006d-4b66-8a28-72c05a081384\",\n \"stock\": 120\n }\n ],\n \"productMeasurements\": [\n {\n \"price\": 4500000,\n \"currency\": \"IDR\",\n \"weight\": 180,\n \"weightType\": \"G\",\n \"length\": 15.2,\n \"width\": 7.4,\n \"height\": 0.8,\n \"dimensionType\": \"CM\",\n \"measurementType\": \"COLOR\",\n \"measurementValue\": \"Black\",\n \"isConfigurePromotionPrice\": false,\n \"promotionPrice\": null,\n \"promotionCurrency\": null,\n \"promotionStartDate\": null,\n \"promotionEndDate\": null,\n \"packagingWeight\": 250,\n \"packagingWeightType\": \"G\",\n \"packagingLength\": 20.0,\n \"packagingWidth\": 10.0,\n \"packagingHeight\": 5.0,\n \"packagingDimensionType\": \"CM\",\n \"warehouses\": [\n {\n \"id\": \"1a341e2a-006d-4b66-8a28-72c05a081384\",\n \"stock\": 30\n }\n ]\n }\n ]\n }\n ],\n \"productInformations\": [\n {\n \"paramName\": \"Brand\",\n \"paramValue\": \"XYZ Corp\"\n },\n {\n \"paramName\": \"Battery\",\n \"paramValue\": \"5000mAh\"\n }\n ],\n \"categoryInformations\": [\n {\n \"paramName\": \"Electronics Type\",\n \"paramValue\": \"Smartphone\"\n }\n ],\n \"complianceInformation\": {\n \"safetyWarning\": \"Jauhkan dari panas ekstrem\",\n \"countryOfOrigin\": \"China\",\n \"isDangerousGoodRegulation\": false,\n \"fileId\": \"compliance.pdf\"\n },\n \"warrantyInformation\": {\n \"type\": \"Official Warranty\",\n \"duration\": 12,\n \"durationType\": \"MONTH\"\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{hostname}}/api/v1.0/product/draft/78ceff6e-39b9-49ef-8a05-92c30d295aac", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "product", + "draft", + "78ceff6e-39b9-49ef-8a05-92c30d295aac" + ] + } + }, + "response": [] + }, + { + "name": "Update Product", + "request": { + "auth": { + "type": "noauth" + }, + "method": "PUT", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"subCategory\": {\n \"id\": \"2\"\n },\n \"name\": \"Smartphone OK GAES\",\n \"description\": \"Smartphone terbaru dengan fitur lengkap.\",\n \"isPreOrder\": false,\n \"preOrderDay\": null,\n \"isNew\": true,\n \"isEligibleToExport\": true,\n \"productFiles\": [\n \"product-manual.pdf\",\n \"123.pdf\"\n ],\n \"imageId\": \"contoh.jpg\",\n \"state\": \"DRAFT\",\n \"productKeyWords\": [\n \"smartphone\",\n \"Android\",\n \"5g\"\n ],\n \"productFeatures\": [\n \"Layar 6.5 inch\",\n \"Baterai 5000mAh\",\n \"Kamera 64MP\"\n ],\n \"productImages\": [\n {\n \"imageId\": \"img1.jpg\",\n \"sequence\": 1\n },\n {\n \"imageId\": \"img2.jpg\",\n \"sequence\": 2\n }\n ],\n \"productModels\": [\n {\n \"name\": \"XYZ - Black 128GB\",\n \"price\": 4500000,\n \"currency\": \"IDR\",\n \"weight\": 180,\n \"weightType\": \"G\",\n \"length\": 15.2,\n \"width\": 7.4,\n \"height\": 0.8,\n \"dimensionType\": \"CM\",\n \"isMeasurement\": true,\n \"imageId\": \"model-black.jpg\",\n \"sku\": \"XYZ-BLK-128\",\n \"isConfigurePromotionPrice\": true,\n \"promotionPrice\": 4200000,\n \"promotionCurrency\": \"IDR\",\n \"promotionStartDate\": \"2025-01-01\",\n \"promotionEndDate\": \"2025-01-15\",\n \"packagingWeight\": 250,\n \"packagingWeightType\": \"G\",\n \"packagingLength\": 20.0,\n \"packagingWidth\": 10.0,\n \"packagingHeight\": 5.0,\n \"packagingDimensionType\": \"CM\",\n \"warehouses\": [\n {\n \"id\": \"1a341e2a-006d-4b66-8a28-72c05a081384\",\n \"stock\": 120\n }\n ],\n \"productMeasurements\": [\n {\n \"price\": 4500000,\n \"currency\": \"IDR\",\n \"weight\": 180,\n \"weightType\": \"G\",\n \"length\": 15.2,\n \"width\": 7.4,\n \"height\": 0.8,\n \"dimensionType\": \"CM\",\n \"measurementType\": \"COLOR\",\n \"measurementValue\": \"Black\",\n \"isConfigurePromotionPrice\": false,\n \"promotionPrice\": null,\n \"promotionCurrency\": null,\n \"promotionStartDate\": null,\n \"promotionEndDate\": null,\n \"packagingWeight\": 250,\n \"packagingWeightType\": \"G\",\n \"packagingLength\": 20.0,\n \"packagingWidth\": 10.0,\n \"packagingHeight\": 5.0,\n \"packagingDimensionType\": \"CM\",\n \"warehouses\": [\n {\n \"id\": \"1a341e2a-006d-4b66-8a28-72c05a081384\",\n \"stock\": 30\n }\n ]\n }\n ]\n }\n ],\n \"productInformations\": [\n {\n \"paramName\": \"Brand\",\n \"paramValue\": \"XYZ Corp\"\n },\n {\n \"paramName\": \"Battery\",\n \"paramValue\": \"5000mAh\"\n }\n ],\n \"categoryInformations\": [\n {\n \"paramName\": \"Electronics Type\",\n \"paramValue\": \"Smartphone\"\n }\n ],\n \"complianceInformation\": {\n \"safetyWarning\": \"Jauhkan dari panas ekstrem\",\n \"countryOfOrigin\": \"China\",\n \"isDangerousGoodRegulation\": false,\n \"fileId\": \"compliance.pdf\"\n },\n \"warrantyInformation\": {\n \"type\": \"Official Warranty\",\n \"duration\": 12,\n \"durationType\": \"MONTH\"\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{hostname}}/api/v1.0/product/c844b5b1-b995-4fde-a71c-22aa53952396", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "product", + "c844b5b1-b995-4fde-a71c-22aa53952396" + ] + } + }, + "response": [] + }, + { + "name": "Accept Update Product", + "request": { + "auth": { + "type": "noauth" + }, + "method": "PUT", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + } + ], + "url": { + "raw": "{{hostname}}/api/v1.0/product/accept/78ceff6e-39b9-49ef-8a05-92c30d295aac", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "product", + "accept", + "78ceff6e-39b9-49ef-8a05-92c30d295aac" + ] + } + }, + "response": [] + }, + { + "name": "Reject Update Product", + "request": { + "auth": { + "type": "noauth" + }, + "method": "PUT", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"reason\": \"tidak sesuai\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{hostname}}/api/v1.0/product/reject/78ceff6e-39b9-49ef-8a05-92c30d295aac", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "product", + "reject", + "78ceff6e-39b9-49ef-8a05-92c30d295aac" + ] + } + }, + "response": [] + }, + { + "name": "Delete Product", + "request": { + "auth": { + "type": "noauth" + }, + "method": "DELETE", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + } + ], + "url": { + "raw": "{{hostname}}/api/v1.0/product/f5868bd1-9e9c-4501-8af0-a711a48f909c", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "product", + "f5868bd1-9e9c-4501-8af0-a711a48f909c" + ] + } + }, + "response": [] + }, + { + "name": "Delete Product Draft", + "request": { + "auth": { + "type": "noauth" + }, + "method": "DELETE", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + } + ], + "url": { + "raw": "{{hostname}}/api/v1.0/product/draft/9fba50de-1043-4c17-a659-ee81595033d6", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "product", + "draft", + "9fba50de-1043-4c17-a659-ee81595033d6" + ] + } + }, + "response": [] + }, + { + "name": "Update Stock Or Price", + "request": { + "auth": { + "type": "noauth" + }, + "method": "PUT", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"productModelId\": \"PM123\",\n \"productMeasurementId\": \"MEAS456\",\n \"price\": 15000.50,\n \"warehouseId\": \"WH789\",\n \"stock\": 100\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{hostname}}/api/v1.0/product/stock-price", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "product", + "stock-price" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Favorite", + "item": [ + { + "name": "Get Favorite", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + }, + { + "key": "Tenant-Id", + "value": "{{tenantId}}", + "type": "text" + } + ], + "url": { + "raw": "{{hostname}}/api/v1.0/favorites", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "favorites" + ] + } + }, + "response": [] + }, + { + "name": "Get Favorite Item", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + }, + { + "key": "Tenant-Id", + "value": "{{tenantId}}", + "type": "text" + } + ], + "url": { + "raw": "{{hostname}}/api/v1.0/favorite/item/d0208cd1-ea2e-418a-bd9a-7f6553ee3272", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "favorite", + "item", + "d0208cd1-ea2e-418a-bd9a-7f6553ee3272" + ] + } + }, + "response": [] + }, + { + "name": "Create Favorite", + "request": { + "auth": { + "type": "noauth" + }, + "method": "POST", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"favorite saya\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{hostname}}/api/v1.0/favorite", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "favorite" + ] + } + }, + "response": [] + }, + { + "name": "Update Favorite", + "request": { + "auth": { + "type": "noauth" + }, + "method": "PUT", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"favorite aja\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{hostname}}/api/v1.0/favorite/ce530b30-7040-4c77-b561-5438a69c9d5c", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "favorite", + "ce530b30-7040-4c77-b561-5438a69c9d5c" + ] + } + }, + "response": [] + }, + { + "name": "Add To Favorite", + "request": { + "auth": { + "type": "noauth" + }, + "method": "POST", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"favoriteId\": \"\",\n \"productId\": \"895a8d23-869f-41f5-af9b-f0e59794f111\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{hostname}}/api/v1.0/favorite/add", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "favorite", + "add" + ] + } + }, + "response": [] + }, + { + "name": "Delete Favorite", + "request": { + "auth": { + "type": "noauth" + }, + "method": "DELETE", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + } + ], + "url": { + "raw": "{{hostname}}/api/v1.0/favorite/product/f97c388b-a0a2-4641-8e98-974d39351071", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "favorite", + "product", + "f97c388b-a0a2-4641-8e98-974d39351071" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Address", + "item": [ + { + "name": "Get Address", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + }, + { + "key": "Tenant-Id", + "value": "{{tenantId}}", + "type": "text" + } + ], + "url": { + "raw": "{{hostname}}/api/v1.0/addresses", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "addresses" + ] + } + }, + "response": [] + }, + { + "name": "Create Address", + "request": { + "auth": { + "type": "noauth" + }, + "method": "POST", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"label\": \"rumah\",\n \"recipient\": \"rizki\",\n \"mobile\": \"0811\",\n \"address\": \"jln depok\",\n \"country\": \"indonesia\",\n \"province\": \"jawa barat\",\n \"city\": \"depok2\",\n \"postalCode\": \"12345\",\n \"isPrimary\": true,\n \"latitude\": 3.5952,\n \"longitude\": 98.6722\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{hostname}}/api/v1.0/addresses", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "addresses" + ] + } + }, + "response": [] + }, + { + "name": "Update Address", + "request": { + "auth": { + "type": "noauth" + }, + "method": "PUT", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"label\": \"rumah\",\n \"recipient\": \"rizki\",\n \"mobile\": \"0811\",\n \"address\": \"jln depok\",\n \"country\": \"indonesia\",\n \"province\": \"jawa barat\",\n \"city\": \"depok2\",\n \"postalCode\": \"12345\",\n \"isPrimary\": true,\n \"latitude\": 3.5952,\n \"longitude\": 98.6722\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{hostname}}/api/v1.0/addresses/92a53086-ab77-4b6d-ae90-e92f2f00133e", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "addresses", + "92a53086-ab77-4b6d-ae90-e92f2f00133e" + ] + } + }, + "response": [] + }, + { + "name": "Delete Address", + "request": { + "auth": { + "type": "noauth" + }, + "method": "DELETE", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + } + ], + "url": { + "raw": "{{hostname}}/api/v1.0/addresses/4e0e0a33-5dc2-4bad-9b17-92ba74bc80da", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "addresses", + "4e0e0a33-5dc2-4bad-9b17-92ba74bc80da" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Group", + "item": [ + { + "name": "Get Group", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + }, + { + "key": "Tenant-Id", + "value": "{{tenantId}}", + "type": "text" + } + ], + "url": { + "raw": "{{hostname}}/api/v1.0/group", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "group" + ] + } + }, + "response": [] + }, + { + "name": "Create Group", + "request": { + "auth": { + "type": "noauth" + }, + "method": "POST", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"group 1\",\n \"description\": \"ini group\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{hostname}}/api/v1.0/group", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "group" + ] + } + }, + "response": [] + }, + { + "name": "Update Group", + "request": { + "auth": { + "type": "noauth" + }, + "method": "PUT", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"group 2\",\n \"description\": \"ini group\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{hostname}}/api/v1.0/group/92a53086-ab77-4b6d-ae90-e92f2f00133e", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "group", + "92a53086-ab77-4b6d-ae90-e92f2f00133e" + ] + } + }, + "response": [] + }, + { + "name": "Delete Group", + "request": { + "auth": { + "type": "noauth" + }, + "method": "DELETE", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + } + ], + "url": { + "raw": "{{hostname}}/api/v1.0/group/4e0e0a33-5dc2-4bad-9b17-92ba74bc80da", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "group", + "4e0e0a33-5dc2-4bad-9b17-92ba74bc80da" + ] + } + }, + "response": [] + }, + { + "name": "Add Product To Group", + "request": { + "auth": { + "type": "noauth" + }, + "method": "PUT", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"groupId\": \"4e0e0a33-5dc2-4bad-9b17-92ba74bc80da\",\n \"productId\": \"d119243f-cad0-4f17-b330-b699e1139efa\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{hostname}}/api/v1.0/group/assign", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "group", + "assign" + ] + } + }, + "response": [] + }, + { + "name": "Remove Product From Group", + "request": { + "auth": { + "type": "noauth" + }, + "method": "DELETE", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"groupId\": \"4e0e0a33-5dc2-4bad-9b17-92ba74bc80da\",\n \"productId\": \"d119243f-cad0-4f17-b330-b699e1139efa\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{hostname}}/api/v1.0/group/assign", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "group", + "assign" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Warehouse", + "item": [ + { + "name": "Get Warehouse", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + }, + { + "key": "Tenant-Id", + "value": "{{tenantId}}", + "type": "text" + } + ], + "url": { + "raw": "{{hostname}}/api/v1.0/warehouses", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "warehouses" + ] + } + }, + "response": [] + }, + { + "name": "Create Warehouse", + "request": { + "auth": { + "type": "noauth" + }, + "method": "POST", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"cabang banda aceh\", \n \"address\": \"Jl. Teuku Umar No. 123\",\n \"country\": \"Indonesia\",\n \"province\": \"Aceh\",\n \"city\": \"Banda Aceh\",\n \"postalCode\": \"23111\",\n \"latitude\": 5.548290,\n \"longitude\": 95.323753,\n \"warehouseType\": \"INA\"\n}\n", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{hostname}}/api/v1.0/warehouses", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "warehouses" + ] + } + }, + "response": [] + }, + { + "name": "Update Warehouse", + "request": { + "auth": { + "type": "noauth" + }, + "method": "PUT", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"cabang banda aceh\",\n \"address\": \"Jl. Teuku Umar No. 123\",\n \"country\": \"Indonesia\",\n \"province\": \"Aceh\",\n \"city\": \"Banda Aceh\",\n \"postalCode\": \"23111\",\n \"latitude\": 5.548290,\n \"longitude\": 95.323753,\n \"warehouseType\": \"OTHER\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{hostname}}/api/v1.0/warehouses/92a53086-ab77-4b6d-ae90-e92f2f00133e", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "warehouses", + "92a53086-ab77-4b6d-ae90-e92f2f00133e" + ] + } + }, + "response": [] + }, + { + "name": "Delete Warehouse", + "request": { + "auth": { + "type": "noauth" + }, + "method": "DELETE", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + } + ], + "url": { + "raw": "{{hostname}}/api/v1.0/warehouses/4e0e0a33-5dc2-4bad-9b17-92ba74bc80da", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "warehouses", + "4e0e0a33-5dc2-4bad-9b17-92ba74bc80da" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Cart", + "item": [ + { + "name": "Get Cart", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + }, + { + "key": "Tenant-Id", + "value": "{{tenantId}}", + "type": "text" + } + ], + "url": { + "raw": "{{hostname}}/api/v1.0/carts", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "carts" + ] + } + }, + "response": [] + }, + { + "name": "Create Cart", + "request": { + "auth": { + "type": "noauth" + }, + "method": "POST", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"sellerId\": \"fc711dae-a98f-4782-9489-d5c0de3f3ca5\",\n \"quantity\": 1,\n \"productModelId\": \"5efebc36-07ca-47dd-b37b-590f77d76ee7\",\n \"productMeasurementId\": \"\",\n \"warehouseId\": \"12345\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{hostname}}/api/v1.0/cart", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "cart" + ] + } + }, + "response": [] + }, + { + "name": "Update Cart", + "request": { + "auth": { + "type": "noauth" + }, + "method": "PUT", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"cartItemId\": \"176370af-1f97-4f03-900e-20c515b85d02\",\n \"quantity\": 3\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{hostname}}/api/v1.0/cart", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "cart" + ] + } + }, + "response": [] + }, + { + "name": "Delete Cart", + "request": { + "auth": { + "type": "noauth" + }, + "method": "DELETE", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + } + ], + "url": { + "raw": "{{hostname}}/api/v1.0/cart/1234565", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "cart", + "1234565" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Category", + "item": [ + { + "name": "Get Categories", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + }, + { + "key": "Tenant-Id", + "value": "{{tenantId}}", + "type": "text" + } + ], + "url": { + "raw": "{{hostname}}/api/v1.0/categories", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "categories" + ] + } + }, + "response": [] + }, + { + "name": "Add Categories", + "request": { + "auth": { + "type": "noauth" + }, + "method": "POST", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + }, + { + "key": "Tenant-Id", + "value": "{{tenantId}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"contoh category\",\n \"description\": \"contoh aja\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{hostname}}/api/v1.0/categories", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "categories" + ] + } + }, + "response": [] + }, + { + "name": "Get Sub Categories", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + }, + { + "key": "Tenant-Id", + "value": "{{tenantId}}", + "type": "text" + } + ], + "url": { + "raw": "{{hostname}}/api/v1.0/sub/7c8ff474-d059-42c3-b667-affcc32c7e15/categories", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "sub", + "7c8ff474-d059-42c3-b667-affcc32c7e15", + "categories" + ] + } + }, + "response": [] + }, + { + "name": "Add Sub Categories", + "request": { + "auth": { + "type": "noauth" + }, + "method": "POST", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + }, + { + "key": "Tenant-Id", + "value": "{{tenantId}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"contoh sub category\",\n \"description\": \"contoh sub aja\",\n \"subCategoryAttributes\": [\n \"abc\",\n \"cde\",\n \"xyz\"\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{hostname}}/api/v1.0/sub/1/categories", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "sub", + "1", + "categories" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Province", + "item": [ + { + "name": "Get Province", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + }, + { + "key": "Tenant-Id", + "value": "{{tenantId}}", + "type": "text" + } + ], + "url": { + "raw": "{{hostname}}/api/v1.0/provinces", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "provinces" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "City", + "item": [ + { + "name": "Get City", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + }, + { + "key": "Tenant-Id", + "value": "{{tenantId}}", + "type": "text" + } + ], + "url": { + "raw": "{{hostname}}/api/v1.0/cities?provinceId=12345", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "cities" + ], + "query": [ + { + "key": "provinceId", + "value": "12345" + } + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Region", + "item": [ + { + "name": "Get Region", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + }, + { + "key": "Tenant-Id", + "value": "{{tenantId}}", + "type": "text" + } + ], + "url": { + "raw": "{{hostname}}/api/v1.0/regions?cityId=1234", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "regions" + ], + "query": [ + { + "key": "cityId", + "value": "1234" + } + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Sub Region", + "item": [ + { + "name": "Get Sub Region", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + }, + { + "key": "Tenant-Id", + "value": "{{tenantId}}", + "type": "text" + } + ], + "url": { + "raw": "{{hostname}}/api/v1.0/subregions?regionId=1234", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "subregions" + ], + "query": [ + { + "key": "regionId", + "value": "1234" + } + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Location", + "item": [ + { + "name": "Get Location", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + }, + { + "key": "Tenant-Id", + "value": "{{tenantId}}", + "type": "text" + } + ], + "url": { + "raw": "{{hostname}}/api/v1.0/locations", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "locations" + ] + } + }, + "response": [] + }, + { + "name": "Create Location", + "request": { + "auth": { + "type": "noauth" + }, + "method": "POST", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Pantai Ujong Blang\",\n \"description\": \"Pantai wisata populer di Aceh dengan pemandangan matahari terbenam\",\n \"longitude\": 97.1425,\n \"latitude\": 5.1801,\n \"image1\": \"pantai-ujong-blang-1.jpg\",\n \"image2\": \"pantai-ujong-blang-2.jpg\",\n \"image3\": \"pantai-ujong-blang-3.jpg\",\n \"image4\": \"pantai-ujong-blang-4.jpg\",\n \"image5\": \"pantai-ujong-blang-5.jpg\",\n \"type\": \"WISATA\",\n \"address\": \"Ujong Blang, Banda Sakti\",\n \"country\": \"Indonesia\",\n \"province\": \"Aceh\",\n \"city\": \"Lhokseumawe\",\n \"contact\": \"081234567890\",\n \"userInput\": \"rizki.mufrizal\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{hostname}}/api/v1.0/locations", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "locations" + ] + } + }, + "response": [] + }, + { + "name": "Update Location", + "request": { + "auth": { + "type": "noauth" + }, + "method": "PUT", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Pantai Ujong Blang\",\n \"description\": \"Pantai wisata populer di Aceh dengan pemandangan matahari terbenam\",\n \"longitude\": 97.1425,\n \"latitude\": 5.1801,\n \"image1\": \"pantai-ujong-blang-1.jpg\",\n \"image2\": \"pantai-ujong-blang-2.jpg\",\n \"image3\": \"pantai-ujong-blang-3.jpg\",\n \"image4\": \"pantai-ujong-blang-4.jpg\",\n \"image5\": \"pantai-ujong-blang-5.jpg\",\n \"type\": \"WISATA\",\n \"address\": \"Ujong Blang, Banda Sakti\",\n \"country\": \"Indonesia\",\n \"province\": \"Aceh\",\n \"city\": \"Lhokseumawe\",\n \"contact\": \"081234567890\",\n \"userInput\": \"rizki.mufrizal\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{hostname}}/api/v1.0/addresses/92a53086-ab77-4b6d-ae90-e92f2f00133e", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "addresses", + "92a53086-ab77-4b6d-ae90-e92f2f00133e" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "News Article", + "item": [ + { + "name": "Get News Articles", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + }, + { + "key": "Tenant-Id", + "value": "{{tenantId}}", + "type": "text" + } + ], + "url": { + "raw": "{{hostname}}/api/v1.0/newsarticles", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "newsarticles" + ] + } + }, + "response": [] + }, + { + "name": "Create News Articles", + "request": { + "auth": { + "type": "noauth" + }, + "method": "POST", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"title\": \"Pembangunan Infrastruktur Aceh Meningkat\",\n \"reporter\": \"Rizki Mufrizal\",\n \"subtitle\": \"Proyek strategis nasional mulai berdampak pada ekonomi daerah\",\n \"category\": \"EKONOMI\",\n \"summary\": \"Pemerintah Aceh mencatat peningkatan signifikan pada sektor infrastruktur yang mendorong pertumbuhan ekonomi lokal.\",\n \"section1\": \"Pemerintah Aceh terus menggenjot pembangunan infrastruktur di berbagai wilayah.\",\n \"section2\": \"Beberapa proyek strategis nasional telah memasuki tahap akhir dan mulai digunakan masyarakat.\",\n \"section3\": \"Dampak positif mulai terlihat pada sektor ekonomi dan penyerapan tenaga kerja.\",\n \"image1\": \"infrastruktur-aceh-1.jpg\",\n \"image2\": \"infrastruktur-aceh-2.jpg\",\n \"image3\": \"infrastruktur-aceh-3.jpg\",\n \"image4\": \"infrastruktur-aceh-4.jpg\",\n \"image5\": \"infrastruktur-aceh-5.jpg\"\n}\n", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{hostname}}/api/v1.0/newsarticles", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "newsarticles" + ] + } + }, + "response": [] + }, + { + "name": "Update News Articles", + "request": { + "auth": { + "type": "noauth" + }, + "method": "PUT", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"title\": \"Pembangunan Infrastruktur Aceh Meningkat\",\n \"reporter\": \"Rizki Mufrizal\",\n \"subtitle\": \"Proyek strategis nasional mulai berdampak pada ekonomi daerah\",\n \"category\": \"EKONOMI\",\n \"summary\": \"Pemerintah Aceh mencatat peningkatan signifikan pada sektor infrastruktur yang mendorong pertumbuhan ekonomi lokal.\",\n \"section1\": \"Pemerintah Aceh terus menggenjot pembangunan infrastruktur di berbagai wilayah.\",\n \"section2\": \"Beberapa proyek strategis nasional telah memasuki tahap akhir dan mulai digunakan masyarakat.\",\n \"section3\": \"Dampak positif mulai terlihat pada sektor ekonomi dan penyerapan tenaga kerja.\",\n \"image1\": \"infrastruktur-aceh-1.jpg\",\n \"image2\": \"infrastruktur-aceh-2.jpg\",\n \"image3\": \"infrastruktur-aceh-3.jpg\",\n \"image4\": \"infrastruktur-aceh-4.jpg\",\n \"image5\": \"infrastruktur-aceh-5.jpg\"\n}\n", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{hostname}}/api/v1.0/newsarticles/92a53086-ab77-4b6d-ae90-e92f2f00133e", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "newsarticles", + "92a53086-ab77-4b6d-ae90-e92f2f00133e" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Tmp", + "item": [ + { + "name": "Location", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + }, + { + "key": "Tenant-Id", + "value": "{{tenantId}}", + "type": "text" + } + ], + "url": { + "raw": "{{hostname}}/test/csv/location", + "host": [ + "{{hostname}}" + ], + "path": [ + "test", + "csv", + "location" + ] + } + }, + "response": [] + }, + { + "name": "New Location", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + }, + { + "key": "Tenant-Id", + "value": "{{tenantId}}", + "type": "text" + } + ], + "url": { + "raw": "{{hostname}}/test/csv/newlocation", + "host": [ + "{{hostname}}" + ], + "path": [ + "test", + "csv", + "newlocation" + ] + } + }, + "response": [] + }, + { + "name": "News", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + }, + { + "key": "Tenant-Id", + "value": "{{tenantId}}", + "type": "text" + } + ], + "url": { + "raw": "{{hostname}}/test/csv/news", + "host": [ + "{{hostname}}" + ], + "path": [ + "test", + "csv", + "news" + ] + } + }, + "response": [] + }, + { + "name": "Product", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + }, + { + "key": "Tenant-Id", + "value": "{{tenantId}}", + "type": "text" + } + ], + "url": { + "raw": "{{hostname}}/test/csv/products", + "host": [ + "{{hostname}}" + ], + "path": [ + "test", + "csv", + "products" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Order", + "item": [ + { + "name": "Get Seller Order Earning", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + }, + { + "key": "Tenant-Id", + "value": "{{tenantId}}", + "type": "text" + } + ], + "url": { + "raw": "{{hostname}}/api/v1.0/order/seller/earning", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "order", + "seller", + "earning" + ] + } + }, + "response": [] + }, + { + "name": "Get Seller Order Analytics", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + }, + { + "key": "Tenant-Id", + "value": "{{tenantId}}", + "type": "text" + } + ], + "url": { + "raw": "{{hostname}}/api/v1.0/order/seller/analytics", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "order", + "seller", + "analytics" + ] + } + }, + "response": [] + }, + { + "name": "Get Seller Order Top 5", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + }, + { + "key": "Tenant-Id", + "value": "{{tenantId}}", + "type": "text" + } + ], + "url": { + "raw": "{{hostname}}/api/v1.0/order/seller/top5", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "order", + "seller", + "top5" + ] + } + }, + "response": [] + }, + { + "name": "Cancel Order", + "request": { + "auth": { + "type": "noauth" + }, + "method": "PUT", + "header": [ + { + "key": "Request-Time", + "value": "2023-07-08 10:00:00", + "type": "text" + }, + { + "key": "Channel-Id", + "value": "{{channel-id}}", + "type": "text" + }, + { + "key": "Reference-Number", + "value": "REF20230708100000001", + "type": "text" + }, + { + "key": "Authorization", + "value": "{{token}}", + "type": "text" + } + ], + "url": { + "raw": "{{hostname}}/api/v1.0/order/cancel/120486485", + "host": [ + "{{hostname}}" + ], + "path": [ + "api", + "v1.0", + "order", + "cancel", + "120486485" + ] + } + }, + "response": [] + } + ] + } + ] +} \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..338beae --- /dev/null +++ b/app/build.gradle.kts @@ -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) +} diff --git a/app/google-services.json b/app/google-services.json new file mode 100644 index 0000000..aad3371 --- /dev/null +++ b/app/google-services.json @@ -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" +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -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 \ No newline at end of file diff --git a/app/src/androidTest/java/id/iiyh/inatrading/ExampleInstrumentedTest.kt b/app/src/androidTest/java/id/iiyh/inatrading/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..c934f2e --- /dev/null +++ b/app/src/androidTest/java/id/iiyh/inatrading/ExampleInstrumentedTest.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c2d006c --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..9ba74d2 Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/id/iiyh/inatrading/InaApplication.kt b/app/src/main/java/id/iiyh/inatrading/InaApplication.kt new file mode 100644 index 0000000..27ae586 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/InaApplication.kt @@ -0,0 +1,7 @@ +package id.iiyh.inatrading + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class InaApplication : Application() diff --git a/app/src/main/java/id/iiyh/inatrading/MainActivity.kt b/app/src/main/java/id/iiyh/inatrading/MainActivity.kt new file mode 100644 index 0000000..4465ff2 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/MainActivity.kt @@ -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 { + 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!!!!" + } +} diff --git a/app/src/main/java/id/iiyh/inatrading/core/data/local/SessionManager.kt b/app/src/main/java/id/iiyh/inatrading/core/data/local/SessionManager.kt new file mode 100644 index 0000000..a2845eb --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/core/data/local/SessionManager.kt @@ -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 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 = context.dataStore.data.map { it[TOKEN_KEY] } + val email: Flow = context.dataStore.data.map { it[EMAIL_KEY] } + val name: Flow = context.dataStore.data.map { it[NAME_KEY] } + val avatarUrl: Flow = context.dataStore.data.map { it[AVATAR_URL_KEY] } + val userType: Flow = 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) + } + } +} diff --git a/app/src/main/java/id/iiyh/inatrading/core/data/remote/ApiService.kt b/app/src/main/java/id/iiyh/inatrading/core/data/remote/ApiService.kt new file mode 100644 index 0000000..72eed97 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/core/data/remote/ApiService.kt @@ -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 + + @POST("api/v1.0/buyer/register") + suspend fun registerBuyer( + @Body request: RegisterRequest, + ): ApiResponse + + @GET("api/v1.0/seller/profile") + suspend fun getBuyerProfile(): ApiResponse + + @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 + + @PUT("api/v1.0/addresses/{addressId}") + suspend fun updateAddress( + @Path("addressId") addressId: String, + @Body request: CreateShippingAddressRequest, + ): ApiResponse + + @DELETE("api/v1.0/addresses/{addressId}") + suspend fun deleteAddress( + @Path("addressId") addressId: String, + ): ApiResponse + + @PUT("api/v1.0/seller/profile") + suspend fun updateBuyerProfile( + @Body request: UpdateBuyerProfileRequest, + ): ApiResponse + + @PUT("api/v1.0/profile/change-password") + suspend fun changePassword( + @Body request: ChangePasswordRequest, + ): ApiResponse + + @Multipart + @POST("api/v1.0/file/upload") + suspend fun uploadFile( + @Part file: MultipartBody.Part, + ): ApiResponse + + @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 + + @PUT("api/v1.0/cart") + suspend fun updateCart( + @Body request: CartUpdateRequest, + ): ApiResponse + + @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 + + @PUT("api/v1.0/favorite/{favoriteId}") + suspend fun updateFavorite( + @Path("favoriteId") favoriteId: String, + @Body request: FavoriteCreateRequest, + ): ApiResponse + + @DELETE("api/v1.0/favorite/product/{productId}") + suspend fun deleteFavoriteItem( + @Path("productId") productId: String, + ): ApiResponse + + @POST("api/v1.0/favorite/add") + suspend fun addToFavorite( + @Body request: AddToFavoriteRequest, + ): ApiResponse +} diff --git a/app/src/main/java/id/iiyh/inatrading/core/data/remote/SessionExpiredException.kt b/app/src/main/java/id/iiyh/inatrading/core/data/remote/SessionExpiredException.kt new file mode 100644 index 0000000..32332b7 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/core/data/remote/SessionExpiredException.kt @@ -0,0 +1,3 @@ +package id.iiyh.inatrading.core.data.remote + +class SessionExpiredException : Exception("Session expired") diff --git a/app/src/main/java/id/iiyh/inatrading/core/data/remote/interceptor/HeaderInterceptor.kt b/app/src/main/java/id/iiyh/inatrading/core/data/remote/interceptor/HeaderInterceptor.kt new file mode 100644 index 0000000..562b9a5 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/core/data/remote/interceptor/HeaderInterceptor.kt @@ -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()) + } +} diff --git a/app/src/main/java/id/iiyh/inatrading/core/data/remote/model/ApiResponse.kt b/app/src/main/java/id/iiyh/inatrading/core/data/remote/model/ApiResponse.kt new file mode 100644 index 0000000..7f9c27d --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/core/data/remote/model/ApiResponse.kt @@ -0,0 +1,17 @@ +package id.iiyh.inatrading.core.data.remote.model + +data class ApiResponse( + 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, +) diff --git a/app/src/main/java/id/iiyh/inatrading/core/di/AuthModule.kt b/app/src/main/java/id/iiyh/inatrading/core/di/AuthModule.kt new file mode 100644 index 0000000..3533958 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/core/di/AuthModule.kt @@ -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 +} diff --git a/app/src/main/java/id/iiyh/inatrading/core/di/CartModule.kt b/app/src/main/java/id/iiyh/inatrading/core/di/CartModule.kt new file mode 100644 index 0000000..e8fedaa --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/core/di/CartModule.kt @@ -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 +} + diff --git a/app/src/main/java/id/iiyh/inatrading/core/di/ExploreModule.kt b/app/src/main/java/id/iiyh/inatrading/core/di/ExploreModule.kt new file mode 100644 index 0000000..e83277f --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/core/di/ExploreModule.kt @@ -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 +} diff --git a/app/src/main/java/id/iiyh/inatrading/core/di/FavoriteModule.kt b/app/src/main/java/id/iiyh/inatrading/core/di/FavoriteModule.kt new file mode 100644 index 0000000..bb214dd --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/core/di/FavoriteModule.kt @@ -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 +} diff --git a/app/src/main/java/id/iiyh/inatrading/core/di/NetworkModule.kt b/app/src/main/java/id/iiyh/inatrading/core/di/NetworkModule.kt new file mode 100644 index 0000000..efceb78 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/core/di/NetworkModule.kt @@ -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) +} diff --git a/app/src/main/java/id/iiyh/inatrading/core/di/NewsModule.kt b/app/src/main/java/id/iiyh/inatrading/core/di/NewsModule.kt new file mode 100644 index 0000000..8dcdfd7 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/core/di/NewsModule.kt @@ -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 +} diff --git a/app/src/main/java/id/iiyh/inatrading/core/di/ProductModule.kt b/app/src/main/java/id/iiyh/inatrading/core/di/ProductModule.kt new file mode 100644 index 0000000..265748e --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/core/di/ProductModule.kt @@ -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 +} diff --git a/app/src/main/java/id/iiyh/inatrading/core/ui/components/InaBottomNavBar.kt b/app/src/main/java/id/iiyh/inatrading/core/ui/components/InaBottomNavBar.kt new file mode 100644 index 0000000..3a6670f --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/core/ui/components/InaBottomNavBar.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/id/iiyh/inatrading/core/ui/components/InaButton.kt b/app/src/main/java/id/iiyh/inatrading/core/ui/components/InaButton.kt new file mode 100644 index 0000000..8173742 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/core/ui/components/InaButton.kt @@ -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 = {}) + } + } +} diff --git a/app/src/main/java/id/iiyh/inatrading/core/ui/components/InaCard.kt b/app/src/main/java/id/iiyh/inatrading/core/ui/components/InaCard.kt new file mode 100644 index 0000000..b83cd02 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/core/ui/components/InaCard.kt @@ -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)") + } + } + } +} diff --git a/app/src/main/java/id/iiyh/inatrading/core/ui/components/InaChip.kt b/app/src/main/java/id/iiyh/inatrading/core/ui/components/InaChip.kt new file mode 100644 index 0000000..7d1fc2c --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/core/ui/components/InaChip.kt @@ -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 = {}) + } + } +} diff --git a/app/src/main/java/id/iiyh/inatrading/core/ui/components/InaInnerTopAppBar.kt b/app/src/main/java/id/iiyh/inatrading/core/ui/components/InaInnerTopAppBar.kt new file mode 100644 index 0000000..f600978 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/core/ui/components/InaInnerTopAppBar.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/id/iiyh/inatrading/core/ui/components/InaLogo.kt b/app/src/main/java/id/iiyh/inatrading/core/ui/components/InaLogo.kt new file mode 100644 index 0000000..0e21834 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/core/ui/components/InaLogo.kt @@ -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)), +} diff --git a/app/src/main/java/id/iiyh/inatrading/core/ui/components/InaTextField.kt b/app/src/main/java/id/iiyh/inatrading/core/ui/components/InaTextField.kt new file mode 100644 index 0000000..9a6ebec --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/core/ui/components/InaTextField.kt @@ -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", + ) + } + } +} diff --git a/app/src/main/java/id/iiyh/inatrading/core/ui/components/InaTopAppBar.kt b/app/src/main/java/id/iiyh/inatrading/core/ui/components/InaTopAppBar.kt new file mode 100644 index 0000000..1658b16 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/core/ui/components/InaTopAppBar.kt @@ -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), + ) + } +} diff --git a/app/src/main/java/id/iiyh/inatrading/core/ui/theme/Color.kt b/app/src/main/java/id/iiyh/inatrading/core/ui/theme/Color.kt new file mode 100644 index 0000000..f04719d --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/core/ui/theme/Color.kt @@ -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) diff --git a/app/src/main/java/id/iiyh/inatrading/core/ui/theme/Theme.kt b/app/src/main/java/id/iiyh/inatrading/core/ui/theme/Theme.kt new file mode 100644 index 0000000..ac72354 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/core/ui/theme/Theme.kt @@ -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 + ) +} diff --git a/app/src/main/java/id/iiyh/inatrading/core/ui/theme/Type.kt b/app/src/main/java/id/iiyh/inatrading/core/ui/theme/Type.kt new file mode 100644 index 0000000..e2a2913 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/core/ui/theme/Type.kt @@ -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 + ), +) diff --git a/app/src/main/java/id/iiyh/inatrading/feature/auth/data/model/RegisterRequest.kt b/app/src/main/java/id/iiyh/inatrading/feature/auth/data/model/RegisterRequest.kt new file mode 100644 index 0000000..fc9f0ea --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/auth/data/model/RegisterRequest.kt @@ -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, +) diff --git a/app/src/main/java/id/iiyh/inatrading/feature/auth/data/repository/AuthRepositoryImpl.kt b/app/src/main/java/id/iiyh/inatrading/feature/auth/data/repository/AuthRepositoryImpl.kt new file mode 100644 index 0000000..808aa74 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/auth/data/repository/AuthRepositoryImpl.kt @@ -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 { + 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 { + 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 { + 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> { + 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> { + return try { + val provinces = mutableListOf() + 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> { + return try { + val cities = mutableListOf() + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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()})" + } + } +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/auth/domain/repository/AuthRepository.kt b/app/src/main/java/id/iiyh/inatrading/feature/auth/domain/repository/AuthRepository.kt new file mode 100644 index 0000000..8fa30c5 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/auth/domain/repository/AuthRepository.kt @@ -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 + suspend fun register(name: String, email: String, mobile: String, password: String): Result + suspend fun getBuyerProfile(): Result + suspend fun getAddresses(): Result> + suspend fun getProvinces(): Result> + suspend fun getCities(provinceId: String): Result> + suspend fun createAddress(request: CreateShippingAddressRequest): Result + suspend fun updateAddress(addressId: String, request: CreateShippingAddressRequest): Result + suspend fun deleteAddress(addressId: String): Result + suspend fun uploadFile(uri: Uri): Result + suspend fun updateBuyerProfile( + name: String, + mobile: String, + imageId: String?, + email: String, + profileDescription: String? = null, + ): Result + suspend fun changePassword(oldPassword: String, newPassword: String): Result + suspend fun logout() +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/auth/presentation/ForgotPasswordScreen.kt b/app/src/main/java/id/iiyh/inatrading/feature/auth/presentation/ForgotPasswordScreen.kt new file mode 100644 index 0000000..1a4ca40 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/auth/presentation/ForgotPasswordScreen.kt @@ -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(), + ) + } + } +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/auth/presentation/LoginScreen.kt b/app/src/main/java/id/iiyh/inatrading/feature/auth/presentation/LoginScreen.kt new file mode 100644 index 0000000..29de682 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/auth/presentation/LoginScreen.kt @@ -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("", "").replace("", "")) } + }, + 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) +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/auth/presentation/LoginViewModel.kt b/app/src/main/java/id/iiyh/inatrading/feature/auth/presentation/LoginViewModel.kt new file mode 100644 index 0000000..0a0ef4c --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/auth/presentation/LoginViewModel.kt @@ -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 = _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() + } +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/auth/presentation/RegisterScreen.kt b/app/src/main/java/id/iiyh/inatrading/feature/auth/presentation/RegisterScreen.kt new file mode 100644 index 0000000..d64e0ae --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/auth/presentation/RegisterScreen.kt @@ -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("", "").replace("", "")) } + }, + 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, + ) + } +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/auth/presentation/RegisterSuccessScreen.kt b/app/src/main/java/id/iiyh/inatrading/feature/auth/presentation/RegisterSuccessScreen.kt new file mode 100644 index 0000000..6471ad4 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/auth/presentation/RegisterSuccessScreen.kt @@ -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, + ) + } + } +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/auth/presentation/RegisterViewModel.kt b/app/src/main/java/id/iiyh/inatrading/feature/auth/presentation/RegisterViewModel.kt new file mode 100644 index 0000000..e15dbf7 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/auth/presentation/RegisterViewModel.kt @@ -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 = _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 + } +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/cart/data/model/CartModels.kt b/app/src/main/java/id/iiyh/inatrading/feature/cart/data/model/CartModels.kt new file mode 100644 index 0000000..b36f680 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/cart/data/model/CartModels.kt @@ -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 = emptyList(), + val id: String? = null, + val seller: CartSeller? = null, +) + +data class CartListResponse( + val responseCode: String? = null, + val responseDesc: String? = null, + val rows: List = emptyList(), + val totalItem: Int = 0, + val totalPage: Int = 0, +) { + val isSuccess: Boolean get() = responseCode == "0000" +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/cart/data/repository/CartRepositoryImpl.kt b/app/src/main/java/id/iiyh/inatrading/feature/cart/data/repository/CartRepositoryImpl.kt new file mode 100644 index 0000000..862e2b1 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/cart/data/repository/CartRepositoryImpl.kt @@ -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> { + 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 { + 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 { + 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 { + 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.sanitized(): List { + return map { group -> + group.copy( + cartItems = group.cartItems.filter { item -> (item.quantity ?: 0) > 0 } + ) + }.filter { it.cartItems.isNotEmpty() } + } +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/cart/domain/CartRepository.kt b/app/src/main/java/id/iiyh/inatrading/feature/cart/domain/CartRepository.kt new file mode 100644 index 0000000..85a469e --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/cart/domain/CartRepository.kt @@ -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> + suspend fun getCartCount(): Result + suspend fun updateCartQuantity( + cartItemId: String, + quantityDelta: Int, + ): Result + suspend fun addToCart( + sellerId: String, + productModelId: String, + warehouseId: String, + quantity: Int = 1, + productMeasurementId: String = "", + ): Result +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/cart/presentation/CartScreen.kt b/app/src/main/java/id/iiyh/inatrading/feature/cart/presentation/CartScreen.kt new file mode 100644 index 0000000..e270b8b --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/cart/presentation/CartScreen.kt @@ -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, + groups: List, + 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, + 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 ") +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/cart/presentation/CartViewModel.kt b/app/src/main/java/id/iiyh/inatrading/feature/cart/presentation/CartViewModel.kt new file mode 100644 index 0000000..2607524 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/cart/presentation/CartViewModel.kt @@ -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 = emptySet(), + val cartGroups: List = 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 = _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", + ) + } + } +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/explore/data/model/LocationItem.kt b/app/src/main/java/id/iiyh/inatrading/feature/explore/data/model/LocationItem.kt new file mode 100644 index 0000000..c42ea0c --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/explore/data/model/LocationItem.kt @@ -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 = emptyList(), + val totalItem: Int = 0, + val totalPage: Int = 0, +) { + val isSuccess: Boolean get() = responseCode == "0000" +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/explore/data/repository/LocationRepositoryImpl.kt b/app/src/main/java/id/iiyh/inatrading/feature/explore/data/repository/LocationRepositoryImpl.kt new file mode 100644 index 0000000..c4c55bf --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/explore/data/repository/LocationRepositoryImpl.kt @@ -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 { + 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) + } + } +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/explore/domain/LocationRepository.kt b/app/src/main/java/id/iiyh/inatrading/feature/explore/domain/LocationRepository.kt new file mode 100644 index 0000000..09d3989 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/explore/domain/LocationRepository.kt @@ -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, + val totalItem: Int, + val totalPage: Int, +) + +interface LocationRepository { + suspend fun getLocations(page: Int, limit: Int): Result +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/explore/presentation/ExploreDetailScreen.kt b/app/src/main/java/id/iiyh/inatrading/feature/explore/presentation/ExploreDetailScreen.kt new file mode 100644 index 0000000..28b04ad --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/explore/presentation/ExploreDetailScreen.kt @@ -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, +) { + 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" +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/explore/presentation/ExploreNavViewModel.kt b/app/src/main/java/id/iiyh/inatrading/feature/explore/presentation/ExploreNavViewModel.kt new file mode 100644 index 0000000..d8c2a13 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/explore/presentation/ExploreNavViewModel.kt @@ -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(null) + val selectedLocation: StateFlow = _selectedLocation.asStateFlow() + + fun select(location: LocationItem) { + _selectedLocation.value = location + } +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/explore/presentation/ExploreScreen.kt b/app/src/main/java/id/iiyh/inatrading/feature/explore/presentation/ExploreScreen.kt new file mode 100644 index 0000000..00b1639 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/explore/presentation/ExploreScreen.kt @@ -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" + } +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/explore/presentation/ExploreViewModel.kt b/app/src/main/java/id/iiyh/inatrading/feature/explore/presentation/ExploreViewModel.kt new file mode 100644 index 0000000..227e16c --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/explore/presentation/ExploreViewModel.kt @@ -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 = 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 + 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 = _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", + ) + } + } + } + } +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/favorite/data/model/FavoriteModels.kt b/app/src/main/java/id/iiyh/inatrading/feature/favorite/data/model/FavoriteModels.kt new file mode 100644 index 0000000..b20e690 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/favorite/data/model/FavoriteModels.kt @@ -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 = 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 = 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, +) diff --git a/app/src/main/java/id/iiyh/inatrading/feature/favorite/data/repository/FavoriteRepositoryImpl.kt b/app/src/main/java/id/iiyh/inatrading/feature/favorite/data/repository/FavoriteRepositoryImpl.kt new file mode 100644 index 0000000..dff06ac --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/favorite/data/repository/FavoriteRepositoryImpl.kt @@ -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> { + 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 { + 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> { + 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 { + 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 { + 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 { + 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 { + 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()})" + } + } +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/favorite/domain/FavoriteRepository.kt b/app/src/main/java/id/iiyh/inatrading/feature/favorite/domain/FavoriteRepository.kt new file mode 100644 index 0000000..6b63c04 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/favorite/domain/FavoriteRepository.kt @@ -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> + suspend fun getFavoriteGroupItems(favoriteId: String): Result> + suspend fun createFavoriteGroup(name: String): Result + suspend fun updateFavoriteGroup(favoriteId: String, name: String): Result + suspend fun deleteFavoriteGroup(favoriteId: String): Result + suspend fun addProductToFavorite(favoriteId: String, productId: String): Result + suspend fun removeProductFromFavorite(productId: String): Result +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/favorite/presentation/FavoriteGroupDetailViewModel.kt b/app/src/main/java/id/iiyh/inatrading/feature/favorite/presentation/FavoriteGroupDetailViewModel.kt new file mode 100644 index 0000000..b39a4e2 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/favorite/presentation/FavoriteGroupDetailViewModel.kt @@ -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 = 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("groupId").orEmpty() + private val groupName: String = savedStateHandle.get("groupName").orEmpty() + + private val _uiState = MutableStateFlow( + FavoriteGroupDetailUiState( + groupId = groupId, + groupName = groupName, + ) + ) + val uiState: StateFlow = _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, + ) + } + } +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/favorite/presentation/FavoritesViewModel.kt b/app/src/main/java/id/iiyh/inatrading/feature/favorite/presentation/FavoritesViewModel.kt new file mode 100644 index 0000000..bb0cea2 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/favorite/presentation/FavoritesViewModel.kt @@ -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 = 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 = _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) { + 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, + ) + } + } +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/home/presentation/HomeScreen.kt b/app/src/main/java/id/iiyh/inatrading/feature/home/presentation/HomeScreen.kt new file mode 100644 index 0000000..7751a04 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/home/presentation/HomeScreen.kt @@ -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, + 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), + ) + } + } + } +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/home/presentation/HomeViewModel.kt b/app/src/main/java/id/iiyh/inatrading/feature/home/presentation/HomeViewModel.kt new file mode 100644 index 0000000..11ce1b4 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/home/presentation/HomeViewModel.kt @@ -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 = 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 = _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) } + } + } + } +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/info/presentation/AboutScreen.kt b/app/src/main/java/id/iiyh/inatrading/feature/info/presentation/AboutScreen.kt new file mode 100644 index 0000000..ae8e34b --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/info/presentation/AboutScreen.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/info/presentation/HelpCenterScreen.kt b/app/src/main/java/id/iiyh/inatrading/feature/info/presentation/HelpCenterScreen.kt new file mode 100644 index 0000000..a127876 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/info/presentation/HelpCenterScreen.kt @@ -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>, +) { + 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, +) { + 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, + ) + } +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/info/presentation/PrivacyPolicyScreen.kt b/app/src/main/java/id/iiyh/inatrading/feature/info/presentation/PrivacyPolicyScreen.kt new file mode 100644 index 0000000..b4e2283 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/info/presentation/PrivacyPolicyScreen.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/info/presentation/TermsConditionsScreen.kt b/app/src/main/java/id/iiyh/inatrading/feature/info/presentation/TermsConditionsScreen.kt new file mode 100644 index 0000000..f7eb51f --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/info/presentation/TermsConditionsScreen.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/news/data/model/NewsArticle.kt b/app/src/main/java/id/iiyh/inatrading/feature/news/data/model/NewsArticle.kt new file mode 100644 index 0000000..a84f6bd --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/news/data/model/NewsArticle.kt @@ -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 get() = listOfNotNull(image1, image2, image3, image4, image5) +} + +data class NewsListResponse( + val responseCode: String? = null, + val responseDesc: String? = null, + val rows: List = emptyList(), + val totalItem: Int = 0, + val totalPage: Int = 0, +) { + val isSuccess: Boolean get() = responseCode == "0000" +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/news/data/repository/NewsRepositoryImpl.kt b/app/src/main/java/id/iiyh/inatrading/feature/news/data/repository/NewsRepositoryImpl.kt new file mode 100644 index 0000000..5031ba4 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/news/data/repository/NewsRepositoryImpl.kt @@ -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> { + 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) + } + } +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/news/domain/NewsRepository.kt b/app/src/main/java/id/iiyh/inatrading/feature/news/domain/NewsRepository.kt new file mode 100644 index 0000000..f33b672 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/news/domain/NewsRepository.kt @@ -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> +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/news/presentation/NewsDetailScreen.kt b/app/src/main/java/id/iiyh/inatrading/feature/news/presentation/NewsDetailScreen.kt new file mode 100644 index 0000000..2f8c2d2 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/news/presentation/NewsDetailScreen.kt @@ -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)), + ) + } + } + } + } + } + } +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/news/presentation/NewsListScreen.kt b/app/src/main/java/id/iiyh/inatrading/feature/news/presentation/NewsListScreen.kt new file mode 100644 index 0000000..d2e2680 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/news/presentation/NewsListScreen.kt @@ -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), + ) + } + } +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/news/presentation/NewsListViewModel.kt b/app/src/main/java/id/iiyh/inatrading/feature/news/presentation/NewsListViewModel.kt new file mode 100644 index 0000000..2bbbc59 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/news/presentation/NewsListViewModel.kt @@ -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 = 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 = _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.", + ) + } + } + } + } +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/news/presentation/NewsNavViewModel.kt b/app/src/main/java/id/iiyh/inatrading/feature/news/presentation/NewsNavViewModel.kt new file mode 100644 index 0000000..51d3040 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/news/presentation/NewsNavViewModel.kt @@ -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(null) + val selectedArticle: StateFlow = _selectedArticle.asStateFlow() + + fun select(article: NewsArticle) { + _selectedArticle.value = article + } +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/product/data/model/ProductItem.kt b/app/src/main/java/id/iiyh/inatrading/feature/product/data/model/ProductItem.kt new file mode 100644 index 0000000..4a04b9c --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/product/data/model/ProductItem.kt @@ -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 = 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 = emptyList(), + val productFeatures: List = emptyList(), + val productImages: List = emptyList(), + val productInformations: List = emptyList(), + val categoryInformations: List = emptyList(), + val productKeyWords: List = emptyList(), + val productModels: List = 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 = 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 = 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 = 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, +) diff --git a/app/src/main/java/id/iiyh/inatrading/feature/product/data/repository/ProductRepositoryImpl.kt b/app/src/main/java/id/iiyh/inatrading/feature/product/data/repository/ProductRepositoryImpl.kt new file mode 100644 index 0000000..e7d61d4 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/product/data/repository/ProductRepositoryImpl.kt @@ -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 { + 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 { + 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) + } + } +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/product/domain/ProductRepository.kt b/app/src/main/java/id/iiyh/inatrading/feature/product/domain/ProductRepository.kt new file mode 100644 index 0000000..5fbf8c9 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/product/domain/ProductRepository.kt @@ -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, + val totalItem: Int, + val totalPage: Int, +) + +interface ProductRepository { + suspend fun getProducts(page: Int, limit: Int): Result + suspend fun getProductDetail(productId: String): Result +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/product/presentation/ProductDetailScreen.kt b/app/src/main/java/id/iiyh/inatrading/feature/product/presentation/ProductDetailScreen.kt new file mode 100644 index 0000000..e4ad8bc --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/product/presentation/ProductDetailScreen.kt @@ -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, + 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, +) { + 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, +) { + 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 { + 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> { + val items = mutableListOf>() + + 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 ") +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/product/presentation/ProductDetailViewModel.kt b/app/src/main/java/id/iiyh/inatrading/feature/product/presentation/ProductDetailViewModel.kt new file mode 100644 index 0000000..f798996 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/product/presentation/ProductDetailViewModel.kt @@ -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 = 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 +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/product/presentation/ProductNavViewModel.kt b/app/src/main/java/id/iiyh/inatrading/feature/product/presentation/ProductNavViewModel.kt new file mode 100644 index 0000000..67cfe06 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/product/presentation/ProductNavViewModel.kt @@ -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(null) + val selectedProduct: StateFlow = _selectedProduct.asStateFlow() + + fun select(product: ProductItem) { + _selectedProduct.value = product + } +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/product/presentation/ProductsScreen.kt b/app/src/main/java/id/iiyh/inatrading/feature/product/presentation/ProductsScreen.kt new file mode 100644 index 0000000..fdba554 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/product/presentation/ProductsScreen.kt @@ -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, + 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 ") +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/product/presentation/ProductsViewModel.kt b/app/src/main/java/id/iiyh/inatrading/feature/product/presentation/ProductsViewModel.kt new file mode 100644 index 0000000..7123ed0 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/product/presentation/ProductsViewModel.kt @@ -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 = 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 = emptySet(), + val isLoggedIn: Boolean = false, + val favoriteGroups: List = 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 + 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 = 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" diff --git a/app/src/main/java/id/iiyh/inatrading/feature/profile/data/model/BuyerProfileModels.kt b/app/src/main/java/id/iiyh/inatrading/feature/profile/data/model/BuyerProfileModels.kt new file mode 100644 index 0000000..a0fd8f1 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/profile/data/model/BuyerProfileModels.kt @@ -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, +) diff --git a/app/src/main/java/id/iiyh/inatrading/feature/profile/data/model/RegionModels.kt b/app/src/main/java/id/iiyh/inatrading/feature/profile/data/model/RegionModels.kt new file mode 100644 index 0000000..8f3daa0 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/profile/data/model/RegionModels.kt @@ -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 = 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 = emptyList(), + val totalItem: Int = 0, + val totalPage: Int = 0, +) { + val isSuccess: Boolean get() = responseCode == "0000" +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/profile/data/model/ShippingAddressModels.kt b/app/src/main/java/id/iiyh/inatrading/feature/profile/data/model/ShippingAddressModels.kt new file mode 100644 index 0000000..e17b29b --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/profile/data/model/ShippingAddressModels.kt @@ -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 = emptyList(), + val totalItem: Int = 0, + val totalPage: Int = 0, +) { + val isSuccess: Boolean get() = responseCode == "0000" +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/profile/presentation/AddShippingAddressScreen.kt b/app/src/main/java/id/iiyh/inatrading/feature/profile/presentation/AddShippingAddressScreen.kt new file mode 100644 index 0000000..2419870 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/profile/presentation/AddShippingAddressScreen.kt @@ -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, + ) + } + } +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/profile/presentation/AddShippingAddressViewModel.kt b/app/src/main/java/id/iiyh/inatrading/feature/profile/presentation/AddShippingAddressViewModel.kt new file mode 100644 index 0000000..84d367c --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/profile/presentation/AddShippingAddressViewModel.kt @@ -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 = emptyList(), + val cities: List = 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 = _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__" + } +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/profile/presentation/AddressFormComponents.kt b/app/src/main/java/id/iiyh/inatrading/feature/profile/presentation/AddressFormComponents.kt new file mode 100644 index 0000000..c2b4963 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/profile/presentation/AddressFormComponents.kt @@ -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, + 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, + 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)) + } +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/profile/presentation/ChangePasswordScreen.kt b/app/src/main/java/id/iiyh/inatrading/feature/profile/presentation/ChangePasswordScreen.kt new file mode 100644 index 0000000..8a5e75c --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/profile/presentation/ChangePasswordScreen.kt @@ -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, +) diff --git a/app/src/main/java/id/iiyh/inatrading/feature/profile/presentation/ChangePasswordViewModel.kt b/app/src/main/java/id/iiyh/inatrading/feature/profile/presentation/ChangePasswordViewModel.kt new file mode 100644 index 0000000..c3bfeb7 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/profile/presentation/ChangePasswordViewModel.kt @@ -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 = _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, + ) + } +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/profile/presentation/EditProfileScreen.kt b/app/src/main/java/id/iiyh/inatrading/feature/profile/presentation/EditProfileScreen.kt new file mode 100644 index 0000000..55d77a4 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/profile/presentation/EditProfileScreen.kt @@ -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(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" + } +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/profile/presentation/EditProfileViewModel.kt b/app/src/main/java/id/iiyh/inatrading/feature/profile/presentation/EditProfileViewModel.kt new file mode 100644 index 0000000..1d1f1b7 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/profile/presentation/EditProfileViewModel.kt @@ -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 = _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) } + } +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/profile/presentation/EditShippingAddressScreen.kt b/app/src/main/java/id/iiyh/inatrading/feature/profile/presentation/EditShippingAddressScreen.kt new file mode 100644 index 0000000..32b0fd1 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/profile/presentation/EditShippingAddressScreen.kt @@ -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(), + ) + } + } +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/profile/presentation/EditShippingAddressViewModel.kt b/app/src/main/java/id/iiyh/inatrading/feature/profile/presentation/EditShippingAddressViewModel.kt new file mode 100644 index 0000000..29ca005 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/profile/presentation/EditShippingAddressViewModel.kt @@ -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 = emptyList(), + val cities: List = 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 = _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__" + } +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/profile/presentation/FavoriteGroupDetailScreen.kt b/app/src/main/java/id/iiyh/inatrading/feature/profile/presentation/FavoriteGroupDetailScreen.kt new file mode 100644 index 0000000..145a92b --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/profile/presentation/FavoriteGroupDetailScreen.kt @@ -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), + ) + } +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/profile/presentation/FavoritesScreen.kt b/app/src/main/java/id/iiyh/inatrading/feature/profile/presentation/FavoritesScreen.kt new file mode 100644 index 0000000..8b6dafd --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/profile/presentation/FavoritesScreen.kt @@ -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(null) } + var deletingGroup by remember { mutableStateOf(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), + ) +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/profile/presentation/ProfileScreen.kt b/app/src/main/java/id/iiyh/inatrading/feature/profile/presentation/ProfileScreen.kt new file mode 100644 index 0000000..f659f2f --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/profile/presentation/ProfileScreen.kt @@ -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)) + } + } +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/profile/presentation/ProfileViewModel.kt b/app/src/main/java/id/iiyh/inatrading/feature/profile/presentation/ProfileViewModel.kt new file mode 100644 index 0000000..54bf93f --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/profile/presentation/ProfileViewModel.kt @@ -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 = 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() + } + } +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/profile/presentation/ShippingAddressesScreen.kt b/app/src/main/java/id/iiyh/inatrading/feature/profile/presentation/ShippingAddressesScreen.kt new file mode 100644 index 0000000..c5582b2 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/profile/presentation/ShippingAddressesScreen.kt @@ -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, + 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(", ") +} diff --git a/app/src/main/java/id/iiyh/inatrading/feature/profile/presentation/ShippingAddressesViewModel.kt b/app/src/main/java/id/iiyh/inatrading/feature/profile/presentation/ShippingAddressesViewModel.kt new file mode 100644 index 0000000..c3ed4b2 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/feature/profile/presentation/ShippingAddressesViewModel.kt @@ -0,0 +1,73 @@ +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.ShippingAddress +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 ShippingAddressesUiState( + val isLoading: Boolean = false, + val addresses: List = emptyList(), + val errorMessage: String? = null, + val infoMessage: String? = null, +) + +@HiltViewModel +class ShippingAddressesViewModel @Inject constructor( + private val authRepository: AuthRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(ShippingAddressesUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadAddresses() + } + + fun loadAddresses() { + viewModelScope.launch { + _uiState.update { + it.copy( + isLoading = true, + errorMessage = null, + ) + } + + authRepository.getAddresses() + .onSuccess { addresses -> + _uiState.update { + it.copy( + isLoading = false, + addresses = addresses, + errorMessage = null, + ) + } + } + .onFailure { error -> + _uiState.update { + it.copy( + isLoading = false, + errorMessage = error.message ?: "Gagal memuat alamat pengiriman", + ) + } + } + } + } + + fun onAddAddressClick() { + _uiState.update { + it.copy(infoMessage = "Fitur tambah alamat akan segera tersedia.") + } + } + + fun consumeInfoMessage() { + _uiState.update { it.copy(infoMessage = null) } + } +} diff --git a/app/src/main/java/id/iiyh/inatrading/navigation/AppNavigation.kt b/app/src/main/java/id/iiyh/inatrading/navigation/AppNavigation.kt new file mode 100644 index 0000000..637130b --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/navigation/AppNavigation.kt @@ -0,0 +1,589 @@ +package id.iiyh.inatrading.navigation + +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Cancel +import androidx.compose.material.icons.outlined.WifiTethering +import androidx.compose.material3.Icon +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.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.Alignment +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.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import id.iiyh.inatrading.MainActivity +import id.iiyh.inatrading.R +import id.iiyh.inatrading.core.ui.components.InaBottomNavBar +import id.iiyh.inatrading.core.ui.components.InaTopAppBar +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.feature.auth.presentation.ForgotPasswordScreen +import id.iiyh.inatrading.feature.auth.presentation.LoginScreen +import id.iiyh.inatrading.feature.auth.presentation.RegisterScreen +import id.iiyh.inatrading.feature.auth.presentation.RegisterSuccessScreen +import androidx.hilt.navigation.compose.hiltViewModel +import id.iiyh.inatrading.feature.cart.presentation.CartScreen +import id.iiyh.inatrading.feature.cart.presentation.CartViewModel +import id.iiyh.inatrading.feature.info.presentation.AboutScreen +import id.iiyh.inatrading.feature.info.presentation.HelpCenterScreen +import id.iiyh.inatrading.feature.info.presentation.PrivacyPolicyScreen +import id.iiyh.inatrading.feature.info.presentation.TermsConditionsScreen +import id.iiyh.inatrading.feature.explore.presentation.ExploreDetailScreen +import id.iiyh.inatrading.feature.explore.presentation.ExploreNavViewModel +import id.iiyh.inatrading.feature.explore.presentation.ExploreScreen +import id.iiyh.inatrading.feature.home.presentation.HomeScreen +import id.iiyh.inatrading.feature.news.presentation.NewsDetailScreen +import id.iiyh.inatrading.feature.news.presentation.NewsListScreen +import id.iiyh.inatrading.feature.news.presentation.NewsNavViewModel +import id.iiyh.inatrading.feature.product.presentation.ProductDetailScreen +import id.iiyh.inatrading.feature.product.presentation.ProductNavViewModel +import id.iiyh.inatrading.feature.product.presentation.ProductsScreen +import id.iiyh.inatrading.feature.profile.presentation.EditProfileScreen +import id.iiyh.inatrading.feature.profile.presentation.FavoriteGroupDetailScreen +import id.iiyh.inatrading.feature.profile.presentation.FavoritesScreen +import id.iiyh.inatrading.feature.profile.presentation.AddShippingAddressScreen +import id.iiyh.inatrading.feature.profile.presentation.ChangePasswordScreen +import id.iiyh.inatrading.feature.profile.presentation.EditShippingAddressScreen +import id.iiyh.inatrading.feature.profile.presentation.ProfileScreen +import id.iiyh.inatrading.feature.profile.presentation.ShippingAddressesViewModel +import id.iiyh.inatrading.feature.profile.presentation.ShippingAddressesScreen + +@Composable +fun AppNavigation( + navController: NavHostController = rememberNavController(), + cartViewModel: CartViewModel = hiltViewModel(), + newsNavViewModel: NewsNavViewModel = hiltViewModel(), + exploreNavViewModel: ExploreNavViewModel = hiltViewModel(), + productNavViewModel: ProductNavViewModel = hiltViewModel(), +) { + val cartUiState by cartViewModel.uiState.collectAsState() + val backStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = backStackEntry?.destination?.route + val isMainScreen = currentRoute in mainRoutes + val context = LocalContext.current + val activity = context as? MainActivity + val snackbarHostState = remember { SnackbarHostState() } + var isRfidDialogVisible by rememberSaveable { mutableStateOf(false) } + + Scaffold( + topBar = { + if (isMainScreen) { + InaTopAppBar( + cartItemCount = cartUiState.itemCount, + onMenuClick = { /* TODO: open drawer */ }, + onRfidClick = { isRfidDialogVisible = true }, + onCartClick = { navController.navigate(Screen.Cart.route) }, + onNotifClick = { /* TODO: navigate to notifications */ }, + ) + } + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + bottomBar = { + if (isMainScreen) { + InaBottomNavBar( + currentRoute = currentRoute, + onTabSelected = { screen -> + navController.navigate(screen.route) { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + }, + ) + } + }, + ) { innerPadding -> + NavHost( + navController = navController, + startDestination = Screen.Home.route, + modifier = Modifier.padding(innerPadding), + ) { + // ── Main tabs ───────────────────────────────────────────────── + composable(Screen.Home.route) { + HomeScreen( + onNewsClick = { article -> + newsNavViewModel.select(article) + navController.navigate(Screen.NewsDetail.createRoute(article.id)) + }, + onViewAllNews = { navController.navigate(Screen.NewsList.route) }, + ) + } + composable(Screen.Explore.route) { + ExploreScreen( + onLocationClick = { location -> + exploreNavViewModel.select(location) + navController.navigate(Screen.ExploreDetail.createRoute(location.id)) + }, + ) + } + composable(Screen.Products.route) { + ProductsScreen( + onProductClick = { product -> + productNavViewModel.select(product) + navController.navigate(Screen.ProductDetail.createRoute(product.id)) + }, + onLoginRequired = { navController.navigate(Screen.Login.route) }, + onSessionExpired = { + navController.navigate(Screen.Login.route) { + popUpTo(Screen.Products.route) { inclusive = false } + launchSingleTop = true + } + }, + ) + } + composable(Screen.Profile.route) { + ProfileScreen( + onLoginClick = { navController.navigate(Screen.Login.route) }, + onEditProfile = { navController.navigate(Screen.EditProfile.route) }, + onChangePassword = { navController.navigate(Screen.ChangePassword.route) }, + onShippingAddresses = { navController.navigate(Screen.ShippingAddresses.route) }, + onFavoritesClick = { navController.navigate(Screen.Favorites.route) }, + onPaymentMethods = { navController.navigate(Screen.PaymentMethods.route) }, + onHelpCenterClick = { navController.navigate(Screen.HelpCenter.route) }, + onTermsClick = { navController.navigate(Screen.TermsConditions.route) }, + onPrivacyPolicyClick = { navController.navigate(Screen.PrivacyPolicy.route) }, + onAboutClick = { navController.navigate(Screen.About.route) }, + ) + } + + // ── Auth ────────────────────────────────────────────────────── + composable(Screen.Login.route) { + LoginScreen( + onBack = { navController.popBackStack() }, + onLoginSuccess = { + navController.navigate(Screen.Profile.route) { + popUpTo(Screen.Login.route) { inclusive = true } + } + }, + onForgotPassword = { navController.navigate(Screen.ForgotPassword.route) }, + onRegister = { navController.navigate(Screen.Register.route) }, + ) + } + composable(Screen.ForgotPassword.route) { + ForgotPasswordScreen(onBack = { navController.popBackStack() }) + } + composable(Screen.Register.route) { + RegisterScreen( + onBack = { navController.popBackStack() }, + onRegisterSuccess = { + navController.navigate(Screen.RegisterSuccess.route) { + popUpTo(Screen.Register.route) { inclusive = true } + } + }, + onLoginClick = { + navController.navigate(Screen.Login.route) { + popUpTo(Screen.Register.route) { inclusive = true } + } + }, + ) + } + composable(Screen.RegisterSuccess.route) { + RegisterSuccessScreen( + onLoginClick = { + navController.navigate(Screen.Login.route) { + popUpTo(Screen.RegisterSuccess.route) { inclusive = true } + } + }, + ) + } + + // ── Inner screens ───────────────────────────────────────────── + composable(Screen.About.route) { + AboutScreen(onBack = { navController.popBackStack() }) + } + composable(Screen.HelpCenter.route) { + HelpCenterScreen(onBack = { navController.popBackStack() }) + } + composable(Screen.TermsConditions.route) { + TermsConditionsScreen(onBack = { navController.popBackStack() }) + } + composable(Screen.PrivacyPolicy.route) { + PrivacyPolicyScreen( + onBack = { navController.popBackStack() }, + onFullTermsClick = { navController.navigate(Screen.TermsConditions.route) }, + ) + } + composable(Screen.NewsDetail.route) { + val article = newsNavViewModel.selectedArticle.collectAsState().value + if (article != null) { + NewsDetailScreen( + article = article, + onBack = { navController.popBackStack() }, + ) + } + } + composable(Screen.NewsList.route) { + NewsListScreen( + onBack = { navController.popBackStack() }, + onArticleClick = { article -> + newsNavViewModel.select(article) + navController.navigate(Screen.NewsDetail.createRoute(article.id)) + }, + ) + } + composable(Screen.ExploreDetail.route) { + val location = exploreNavViewModel.selectedLocation.collectAsState().value + if (location != null) { + ExploreDetailScreen( + location = location, + onBack = { navController.popBackStack() }, + ) + } else { + PlaceholderDetailFallback( + title = stringResource(R.string.explore_error_title), + body = stringResource(R.string.explore_detail_missing_body), + onBack = { navController.popBackStack() }, + ) + } + } + composable(Screen.ProductDetail.route) { + ProductDetailScreen( + onBack = { navController.popBackStack() }, + cartItemCount = cartUiState.itemCount, + onCartClick = { navController.navigate(Screen.Cart.route) }, + onLoginRequired = { navController.navigate(Screen.Login.route) }, + onCartAdded = { cartViewModel.onItemAdded() }, + onSessionExpired = { + navController.navigate(Screen.Login.route) { + popUpTo(Screen.ProductDetail.route) { inclusive = false } + launchSingleTop = true + } + }, + ) + } + composable(Screen.Cart.route) { + CartScreen( + onBack = { navController.popBackStack() }, + onLoginRequired = { navController.navigate(Screen.Login.route) }, + viewModel = cartViewModel, + ) + } + composable(Screen.Favorites.route) { + FavoritesScreen( + onBack = { navController.popBackStack() }, + onBrowseProducts = { + navController.navigate(Screen.Products.route) { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + }, + onGroupClick = { group -> + navController.navigate( + Screen.FavoriteGroupDetail.createRoute(group.id, group.name) + ) + }, + onSessionExpired = { + navController.navigate(Screen.Login.route) { + popUpTo(Screen.Favorites.route) { inclusive = true } + launchSingleTop = true + } + }, + ) + } + composable(Screen.FavoriteGroupDetail.route) { + FavoriteGroupDetailScreen( + onBack = { navController.popBackStack() }, + onProductClick = { productId -> + navController.navigate(Screen.ProductDetail.createRoute(productId)) + }, + onSessionExpired = { + navController.navigate(Screen.Login.route) { + popUpTo(Screen.FavoriteGroupDetail.route) { inclusive = true } + launchSingleTop = true + } + }, + ) + } + composable(Screen.EditProfile.route) { + EditProfileScreen(onBack = { navController.popBackStack() }) + } + composable(Screen.ChangePassword.route) { + ChangePasswordScreen( + onBack = { navController.popBackStack() }, + onLoggedOut = { + navController.navigate(Screen.Login.route) { + popUpTo(Screen.Profile.route) { inclusive = false } + launchSingleTop = true + } + }, + ) + } + composable(Screen.ShippingAddresses.route) { backStackEntry -> + val refreshAddresses by backStackEntry.savedStateHandle + .getStateFlow("refresh_addresses", false) + .collectAsState() + val viewModel: ShippingAddressesViewModel = hiltViewModel() + + LaunchedEffect(refreshAddresses) { + if (refreshAddresses) { + viewModel.loadAddresses() + backStackEntry.savedStateHandle["refresh_addresses"] = false + } + } + + ShippingAddressesScreen( + onBack = { navController.popBackStack() }, + onAddAddressClick = { navController.navigate(Screen.AddShippingAddress.route) }, + onEditAddressClick = { address -> + address.id?.let { + navController.navigate(Screen.EditShippingAddress.createRoute(it)) + } + }, + viewModel = viewModel, + ) + } + composable(Screen.AddShippingAddress.route) { + AddShippingAddressScreen( + onBack = { navController.popBackStack() }, + onSaveSuccess = { + navController.previousBackStackEntry + ?.savedStateHandle + ?.set("refresh_addresses", true) + navController.popBackStack() + }, + ) + } + composable(Screen.EditShippingAddress.route) { + EditShippingAddressScreen( + onBack = { navController.popBackStack() }, + onFinished = { + navController.previousBackStackEntry + ?.savedStateHandle + ?.set("refresh_addresses", true) + navController.popBackStack() + }, + ) + } + composable(Screen.PaymentMethods.route) { /* TODO */ } + } + } + + var snackbarMessage by remember { mutableStateOf(null) } + + LaunchedEffect(snackbarMessage) { + snackbarMessage?.let { + snackbarHostState.showSnackbar(it) + snackbarMessage = null + } + } + + LaunchedEffect(isRfidDialogVisible, activity) { + if (!isRfidDialogVisible) return@LaunchedEffect + val scanActivity = activity ?: run { + snackbarMessage = context.getString(R.string.rfid_not_supported) + isRfidDialogVisible = false + return@LaunchedEffect + } + + val started = scanActivity.startRfidScan( + onValueDetected = { value -> + isRfidDialogVisible = false + navController.navigate(Screen.ProductDetail.createRoute(value)) + }, + onError = { message -> + snackbarMessage = message + }, + ) + + if (!started) { + snackbarMessage = context.getString(R.string.rfid_not_supported) + isRfidDialogVisible = false + } + } + + if (isRfidDialogVisible) { + RfidScanDialog( + onDismiss = { + isRfidDialogVisible = false + activity?.stopRfidScan() + }, + ) + } +} + +@Composable +private fun PlaceholderDetailFallback( + title: String, + body: String, + onBack: () -> Unit, +) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Background) + .padding(24.dp), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = title, + fontFamily = ManropeFontFamily, + fontWeight = FontWeight.ExtraBold, + fontSize = 22.sp, + color = OnSurface, + textAlign = TextAlign.Center, + ) + Text( + text = body, + color = OnSurfaceVariant, + textAlign = TextAlign.Center, + ) + Row( + modifier = Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(), + onClick = onBack, + ) + .padding(horizontal = 18.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Outlined.Cancel, + contentDescription = null, + tint = BrandRed, + modifier = Modifier.size(18.dp), + ) + Text( + text = stringResource(R.string.favorite_back), + color = BrandRed, + fontWeight = FontWeight.Bold, + ) + } + } + } +} + +@Composable +private fun RfidScanDialog( + onDismiss: () -> Unit, +) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.56f)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = {}, + ), + contentAlignment = Alignment.Center, + ) { + Column( + modifier = Modifier + .padding(horizontal = 32.dp) + .fillMaxWidth() + .background(Background, RoundedCornerShape(28.dp)) + .padding(horizontal = 28.dp, vertical = 36.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(18.dp), + ) { + Box( + modifier = Modifier + .size(100.dp) + .background(BrandRed.copy(alpha = 0.10f), RoundedCornerShape(50.dp)), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .size(48.dp) + .background(BrandRed, RoundedCornerShape(24.dp)), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Outlined.WifiTethering, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(24.dp), + ) + } + } + + Text( + text = stringResource(R.string.rfid_waiting_title), + fontFamily = ManropeFontFamily, + fontWeight = FontWeight.ExtraBold, + fontSize = 22.sp, + color = OnSurface, + textAlign = TextAlign.Center, + ) + + Text( + text = stringResource(R.string.rfid_waiting_body), + color = OnSurfaceVariant, + textAlign = TextAlign.Center, + lineHeight = 24.sp, + ) + + Row( + modifier = Modifier + .padding(top = 8.dp) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(), + onClick = onDismiss, + ) + .padding(horizontal = 18.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Outlined.Cancel, + contentDescription = null, + tint = OnSurfaceVariant, + modifier = Modifier.size(18.dp), + ) + Text( + text = stringResource(R.string.rfid_cancel), + color = OnSurfaceVariant, + fontWeight = FontWeight.Bold, + ) + } + } + } +} diff --git a/app/src/main/java/id/iiyh/inatrading/navigation/Screen.kt b/app/src/main/java/id/iiyh/inatrading/navigation/Screen.kt new file mode 100644 index 0000000..f360d92 --- /dev/null +++ b/app/src/main/java/id/iiyh/inatrading/navigation/Screen.kt @@ -0,0 +1,100 @@ +package id.iiyh.inatrading.navigation + +import android.net.Uri +import androidx.annotation.StringRes +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.outlined.Explore +import androidx.compose.material.icons.outlined.Home +import androidx.compose.material.icons.outlined.Inventory2 +import androidx.compose.material.icons.outlined.Person +import androidx.compose.ui.graphics.vector.ImageVector +import id.iiyh.inatrading.R + +sealed class Screen(val route: String) { + // ── Main tabs (dengan BottomNav) ───────────────────────────────────────── + data object Home : Screen("home") + data object Explore : Screen("explore") + data object Products: Screen("products") + data object Profile : Screen("profile") + + // ── Inner screens (tanpa BottomNav) ───────────────────────────────────── + data object Login : Screen("login") + data object Register : Screen("register") + data object RegisterSuccess : Screen("register_success") + data object ForgotPassword : Screen("forgot_password") + data object NewsList : Screen("news_list") + data object About : Screen("about") + data object HelpCenter : Screen("help_center") + data object TermsConditions : Screen("terms_conditions") + data object PrivacyPolicy : Screen("privacy_policy") + data object Favorites : Screen("favorites") + data object FavoriteGroupDetail: Screen("favorite_group_detail/{groupId}?name={groupName}") { + fun createRoute(groupId: String, groupName: String) = + "favorite_group_detail/$groupId?name=${Uri.encode(groupName)}" + } + data object NewsDetail : Screen("news_detail/{newsId}") { + fun createRoute(newsId: String) = "news_detail/$newsId" + } + data object ExploreDetail : Screen("explore_detail/{locationId}") { + fun createRoute(locationId: String) = "explore_detail/$locationId" + } + data object ProductDetail : Screen("product_detail/{productId}") { + fun createRoute(productId: String) = "product_detail/$productId" + } + data object Cart : Screen("cart") + data object EditProfile : Screen("edit_profile") + data object ChangePassword : Screen("change_password") + data object ShippingAddresses : Screen("shipping_addresses") + data object AddShippingAddress : Screen("shipping_addresses/add") + data object EditShippingAddress : Screen("shipping_addresses/edit/{addressId}") { + fun createRoute(addressId: String) = "shipping_addresses/edit/$addressId" + } + data object PaymentMethods : Screen("payment_methods") + data object OrderDetail : Screen("order_detail/{orderId}") { + fun createRoute(orderId: String) = "order_detail/$orderId" + } +} + +/** Tab-tab yang tampil di BottomNav */ +val bottomNavItems = listOf( + BottomNavItem( + screen = Screen.Home, + labelRes = R.string.nav_home, + icon = Icons.Outlined.Home, + iconSelected = Icons.Outlined.Home, + ), + BottomNavItem( + screen = Screen.Explore, + labelRes = R.string.nav_explore, + icon = Icons.Outlined.Explore, + iconSelected = Icons.Outlined.Explore, + ), + BottomNavItem( + screen = Screen.Products, + labelRes = R.string.nav_products, + icon = Icons.Outlined.Inventory2, + iconSelected = Icons.Outlined.Inventory2, + ), + BottomNavItem( + screen = Screen.Profile, + labelRes = R.string.nav_profile, + icon = Icons.Outlined.Person, + iconSelected = Icons.Filled.Person, + ), +) + +data class BottomNavItem( + val screen: Screen, + @StringRes val labelRes: Int, + val icon: ImageVector, + val iconSelected: ImageVector, +) + +/** Route yang termasuk main screen (tampilkan BottomNav) */ +val mainRoutes = setOf( + Screen.Home.route, + Screen.Explore.route, + Screen.Products.route, + Screen.Profile.route, +) diff --git a/app/src/main/res/drawable/coffee.jpeg b/app/src/main/res/drawable/coffee.jpeg new file mode 100644 index 0000000..4a71742 Binary files /dev/null and b/app/src/main/res/drawable/coffee.jpeg differ diff --git a/app/src/main/res/drawable/header_new.png b/app/src/main/res/drawable/header_new.png new file mode 100644 index 0000000..688de2f Binary files /dev/null and b/app/src/main/res/drawable/header_new.png differ diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ina_logo.png b/app/src/main/res/drawable/ina_logo.png new file mode 100755 index 0000000..688de2f Binary files /dev/null and b/app/src/main/res/drawable/ina_logo.png differ diff --git a/app/src/main/res/drawable/kerajinan.jpeg b/app/src/main/res/drawable/kerajinan.jpeg new file mode 100644 index 0000000..bcfbbf7 Binary files /dev/null and b/app/src/main/res/drawable/kerajinan.jpeg differ diff --git a/app/src/main/res/drawable/logistic.jpeg b/app/src/main/res/drawable/logistic.jpeg new file mode 100644 index 0000000..6df48f3 Binary files /dev/null and b/app/src/main/res/drawable/logistic.jpeg differ diff --git a/app/src/main/res/drawable/restoran.jpeg b/app/src/main/res/drawable/restoran.jpeg new file mode 100644 index 0000000..6d8b007 Binary files /dev/null and b/app/src/main/res/drawable/restoran.jpeg differ diff --git a/app/src/main/res/drawable/tech.jpeg b/app/src/main/res/drawable/tech.jpeg new file mode 100644 index 0000000..1cb501e Binary files /dev/null and b/app/src/main/res/drawable/tech.jpeg differ diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..9a77d49 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,5 @@ + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..65291b9 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..65291b9 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..9b78a6c Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..c9bfa18 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..c5aa8f3 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..c3be0c0 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..7b9251f Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..e8ad75a Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..a6e869f Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..8f725fa Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b86db66 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..f8522ef Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..08ecf88 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..ae4d817 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..775cc61 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..c97eb19 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..16fb479 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values-in/plurals.xml b/app/src/main/res/values-in/plurals.xml new file mode 100644 index 0000000..bfbb431 --- /dev/null +++ b/app/src/main/res/values-in/plurals.xml @@ -0,0 +1,6 @@ + + + %d produk favorit + %d produk favorit + + diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml new file mode 100644 index 0000000..09bcc83 --- /dev/null +++ b/app/src/main/res/values-in/strings.xml @@ -0,0 +1,529 @@ + + INA Trading + INA Trading v2.4.0 + Karya Anak Bangsa + + + Beranda + Jelajahi + Produk + Profil + + + Menu + Scan RFID + Keranjang + Notifikasi + Menunggu Scan RFID + Dekatkan tag RFID produk ke bagian belakang perangkat Anda untuk memulai pemindaian. + Batal + Perangkat ini tidak mendukung NFC. + NFC sedang nonaktif. Aktifkan terlebih dahulu. + Tag RFID tidak valid untuk INA Trading. + RFID terdeteksi: %1$s + + + Selamat Datang di INA Trading + Akses penuh ke fitur belanja terbaik untuk UMKM dan produk lokal pilihan. + Masuk atau Daftar + Kenapa Bergabung? + Keuntungan + Pantau pesanan Anda + Pantau status pengiriman pesanan Anda secara real-time dari Sabang sampai Merauke. + Simpan produk favorit + Simpan produk UMKM favorit Anda dan dapatkan notifikasi saat ada promo menarik. + Transaksi aman + Setiap transaksi dilindungi oleh sistem keamanan berlapis untuk kenyamanan berbelanja Anda. + Informasi & Bantuan + + + Member Premium + Pesanan Saya + Lihat Semua + Bayar + Dikirim + Diterima + Ulasan + Pengaturan & Info + Edit\nProfil + Alamat\nPengiriman + Ganti Kata\nSandi + Favorit Saya + Metode Pembayaran + Keluar + Koleksi Tersimpan + Simpan produk yang Anda sukai di satu tempat dan buka kembali kapan saja. + Belum ada produk favorit + Ketuk ikon hati pada produk untuk menyimpannya di sini agar mudah diakses nanti. + Lihat Produk + Pengaturan Personal + Edit Profile + Nama Lengkap + Masukkan nama lengkap + Email + Nomor Telepon + 08123456789 + Keamanan Akun + Gunakan informasi asli untuk memudahkan verifikasi transaksi UMKM Anda. + Simpan Perubahan + Pembaruan profil akan segera diterapkan di seluruh ekosistem INA Trading. + Gagal memuat profil + Ubah Foto Profil + Pilih sumber gambar yang ingin digunakan untuk foto profil Anda. + Pilih dari Galeri + Ambil dari Kamera + Keamanan Akun + Ganti Kata Sandi + Demi keamanan akun Anda, gunakan kombinasi kata sandi yang kuat dan tidak dipakai di layanan lain. + Kata Sandi Lama + Masukkan kata sandi saat ini + Kata Sandi Baru + Min. 8 karakter, kombinasi huruf dan angka + Konfirmasi Kata Sandi Baru + Ulangi kata sandi baru Anda + Lemah + Cukup + Baik + Kuat + Tips Keamanan + Hindari menggunakan nama, tanggal lahir, atau informasi pribadi yang mudah ditebak untuk kata sandi Anda. + Simpan Perubahan + Kata sandi berhasil diperbarui. + Kata sandi lama wajib diisi. + Kata sandi baru wajib diisi. + Kata sandi baru minimal 8 karakter. + Kata sandi baru harus berbeda dari kata sandi lama. + Konfirmasi kata sandi tidak cocok. + Tujuan Pengiriman + Kelola Hub\nPengiriman Anda + Utama + Rumah + Kantor + Alamat %1$d + Detail penerima akan segera tersedia. + Detail telepon akan segera tersedia. + Zona Cakupan Aktif + "Mengantarkan karya terbaik Nusantara langsung ke tujuan Anda dengan pelacakan prioritas." + Tambah Alamat Baru + Gagal memuat alamat + Belum ada alamat pengiriman + Simpan tujuan pengiriman utama Anda di sini agar checkout lebih cepat dan pengiriman lebih rapi. + Lokasi Baru + Tambah Alamat + Lengkapi informasi pengiriman Anda secara presisi agar proses kirim produk artisan Indonesia berjalan lancar. + Label Alamat + Rumah + Kantor + Gudang + Lainnya + Label Kustom + Masukkan nama label + Nama Penerima + Nama lengkap + Nomor Telepon + 81234567890 + Negara + Indonesia + Indonesia + Lainnya + Nama Negara + Masukkan nama negara + Provinsi + Masukkan provinsi + Kota / Kabupaten + Pilih kota + Kode Pos + 5 digit kode + Detail Alamat Lengkap + Nama jalan, nomor bangunan, unit, dan catatan pengiriman lainnya + Tandai di Peta + Jadikan Alamat Utama + Alamat default untuk checkout berikutnya + Simpan Alamat + Memuat provinsi... + Memuat kota... + Pilih provinsi terlebih dahulu + Gunakan lokasi saat ini: %1$.5f, %2$.5f + Mengambil lokasi saat ini... + Ketuk area peta untuk memakai lokasi perangkat saat ini. + Izin lokasi diperlukan untuk mengisi pin point otomatis. + Izin lokasi dibutuhkan untuk memakai posisi saat ini. + Layanan lokasi tidak tersedia di perangkat ini. + Aktifkan lokasi perangkat terlebih dahulu. + Lokasi saat ini belum berhasil didapatkan. + Pengaturan Akun + Edit Alamat + Perbarui detail pengiriman Anda untuk memastikan paket sampai dengan aman ke tangan yang tepat. + Informasi Penerima + Detail Lokasi + Koordinat: %1$.4f, %2$.4f + Simpan Perubahan + Hapus Alamat + Inspirasi Produk\nTerbaik + Kembali + Buat Koleksi Baru + Buat + Ubah Nama Koleksi + Edit + Simpan + Hapus + Hapus \"%1$s\" dari koleksi Anda? + Opsi lainnya + DEFAULT + Nama koleksi + Buat koleksi pertama untuk mengelompokkan produk favorit berdasarkan tema, ruangan, atau rencana belanja. + Muat Ulang Koleksi + Koleksi Terpilih + Ringkasan Grup + Total Item + Detail Produk + Belum ada produk di koleksi ini + Simpan produk ke koleksi ini untuk dilihat kembali nanti. + Hapus dari favorit + Batal + + + Tentang INA Trading + Pusat Bantuan + Kebijakan Privasi + Syarat & Ketentuan + + + Registrasi + Daftar Akun + Bergabunglah dengan ekosistem perdagangan UMKM Indonesia. + Nama Lengkap + Nama Lengkap + Nomor Handphone + +62 8xx xxxx xxxx + Kata Sandi + Konfirmasi Kata Sandi + Konfirmasi Kata Sandi + Daftar Sekarang + Atau daftar dengan Google + Sudah punya akun? Masuk di sini + Dengan mendaftar, Anda menyetujui Syarat & Ketentuan serta Kebijakan Privasi INA Trading untuk mendukung pertumbuhan UMKM Nasional. + Kata sandi tidak cocok + Kata sandi minimal 8 karakter + Format email tidak valid + Nama lengkap wajib diisi + Nomor handphone wajib diisi + + + Selamat Datang Kembali + Eksplorasi\nProduk Unggulan. + Alamat Email + nama@email.com + Kata Sandi + •••••••• + Lupa Password? + Masuk Sekarang + Atau masuk dengan + Google + Belum punya akun? Daftar di sini + Keamanan Terjamin oleh INA Trading + Tampilkan kata sandi + Sembunyikan kata sandi + + + Lupa Password + Masukkan email terdaftar dan kami akan mengirimkan tautan reset. + Kirim Tautan Reset + Kembali ke Login + + + © 2024 INA Trading. Editorial Excellence for Indonesian MSMEs. + Bantuan + Syarat & Ketentuan + + + Tentang Visi Kami + Memberdayakan + Pengrajin Indonesia + ke Panggung Global. + INA Trading lebih dari sekadar platform; ini adalah jembatan yang menghubungkan UMKM lokal ke pasar internasional melalui integrasi yang aman dan keunggulan logistik premium. + Transaksi Aman + Enkripsi end-to-end dan gateway pembayaran terverifikasi memastikan setiap perdagangan terlindungi oleh standar keamanan internasional. + Integrasi Digital + Infrastruktur cloud-native untuk manajemen inventaris dan pesanan yang mulus. + Jangkauan Global + Memperluas warisan Indonesia ke lebih dari 40+ negara di Eropa dan Amerika Utara. + "Menjembatani kerajinan tradisional dengan kecepatan digital." + Misi Kami + Kami percaya UMKM Indonesia adalah tulang punggung perekonomian kita. Misi kami adalah menghilangkan kompleksitas ekspor, memberikan pengrajin lokal alat, modal, dan jaringan yang mereka butuhkan untuk bersaing di skala global sambil mempertahankan identitas budaya unik mereka. + Pelajari Lebih Lanjut + UMKM Aktif + Pengiriman Global + Bermitra dengan kami + Siap membawa bisnis kamu ke level global? + Bergabunglah dengan ribuan bisnis Indonesia yang sudah mengekspor produk berkualitas tinggi mereka dengan INA Trading. + Daftar sebagai Seller + Hubungi Support + + + Dokumen Hukum + Syarat & + Ketentuan + Selamat datang di INA Trading. Ketentuan ini mengatur penggunaan marketplace kami dan mendefinisikan standar profesional ekosistem pengrajin modern kami. + Perjanjian Pengguna + Dengan mengakses atau menggunakan marketplace INA Trading, kamu setuju untuk terikat dengan Syarat dan Ketentuan ini. Platform kami dirancang untuk memberdayakan UMKM Indonesia melalui lingkungan digital yang aman dan transparan. + Pembuatan akun memerlukan dokumentasi bisnis yang autentik untuk penjual dan identitas terverifikasi untuk pelanggan demi menjaga integritas jaringan pengrajin kami. + Aturan Transaksi + Semua transaksi yang diproses melalui INA Trading bersifat final setelah pemenuhan. Pembayaran ditahan dalam escrow yang aman untuk memastikan kualitas pengiriman sebelum pembayaran ke merchant. + Komisi tetap 2,5% untuk pemeliharaan platform. + Jendela sengketa 24 jam setelah pengiriman. + Tanggung Jawab & Batasan + INA Trading bertindak sebagai fasilitator perdagangan antara UMKM dan pembeli. Meskipun kami memberikan keamanan dan vetting tingkat tinggi, kami tidak bertanggung jawab atas kegagalan logistik langsung merchant-ke-pelanggan di luar kebijakan perlindungan yang ditentukan. + Pengguna bertanggung jawab untuk menjaga kerahasiaan kredensial mereka dan semua aktivitas yang terjadi di bawah identitas digital unik mereka. + Ada pertanyaan? + Hubungi tim kepatuhan hukum kami untuk klarifikasi. + Hubungi Support + TERAKHIR DIPERBARUI: 24 OKTOBER 2023 • VERSI 2.4.0 + + + Registrasi Berhasil! + Akun kamu telah berhasil dibuat.\nSilakan login menggunakan email dan password kamu. + Login Sekarang + + + Privasi + Privasi Anda Adalah Prioritas Kami + INA Trading berkomitmen untuk melindungi data pribadi Anda dan transparan tentang cara kami mengumpulkan serta menggunakannya. + Terakhir diperbarui: 24 Oktober 2023 + Data Apa yang Kami Kumpulkan + Kami hanya mengumpulkan data yang diperlukan untuk menyediakan layanan marketplace kami, termasuk detail akun, riwayat transaksi, dan informasi pengiriman. + Informasi akun + nama, email, dan nomor telepon yang digunakan untuk autentikasi dan komunikasi. + Data transaksi + riwayat pesanan dan detail pembayaran yang diproses melalui sistem escrow aman kami. + Infrastruktur Keamanan + Data Anda dilindungi dengan enkripsi end-to-end dan disimpan sesuai dengan standar perlindungan data internasional. + Enkripsi AES-256 • Sesuai ISO 27001 + Berbagi Data ke Pihak Ketiga + Kami hanya berbagi data Anda dengan mitra terpercaya yang diperlukan untuk memenuhi pesanan Anda. Kami tidak pernah menjual data pribadi Anda. + Logistik + Pembayaran + Kepatuhan + Hak Privasi Anda + Anda berhak mengakses, mengoreksi, atau menghapus data pribadi Anda kapan saja. Hubungi kami atau kelola preferensi langsung di aplikasi. + Kelola Preferensi Privasi + Ada pertanyaan? + Tim privasi kami siap menjawab pertanyaan tentang cara kami menangani data Anda. + Email Tim Privasi + Baca Syarat & Ketentuan Lengkap + Support Center + Ada yang bisa kami bantu? + Cari bantuan, pengiriman, pengembalian dana, atau pembayaran + Support Categories + Orders + Payment + Shipping + Account + Butuh Bantuan Lebih? + Tim dukungan kami siap membantu sepanjang waktu untuk kendala pesanan, pembayaran, dan akun Anda. + WhatsApp Support + Email Support + Kategori Utama + Pesanan & Pengiriman + Lacak pesanan, cek proses seller, atau tinjau riwayat transaksi aktif Anda. + Pembayaran + Metode pembayaran, transaksi gagal, waktu settlement, dan pengembalian dana. + Pengiriman + Status delivery, estimasi kirim, masalah alamat, dan koordinasi kurir. + Akun & Keamanan + Reset kata sandi, verifikasi, sesi login, dan pengaturan privasi akun. + Panduan Merchant + Manajemen katalog, kesiapan inventaris, dan praktik terbaik untuk seller. + Pertanyaan Sering Diajukan + Bagaimana cara melacak pesanan? + Buka riwayat pesanan Anda lalu pilih transaksi aktif. Halaman detail pesanan akan menampilkan status proses seller dan pembaruan pengiriman jika sudah tersedia. + Metode pembayaran apa saja yang tersedia? + Metode yang tersedia bergantung pada merchant dan alur transaksi aktif, namun umumnya mencakup transfer bank dan kanal pembayaran digital yang didukung. + Berapa lama proses pengembalian dana? + Durasi refund bergantung pada metode pembayaran dan validasi merchant, namun biasanya memerlukan beberapa hari kerja setelah permintaan refund disetujui. + Bagaimana cara mengubah alamat pengiriman? + Untuk pesanan yang belum masuk tahap persiapan kirim, Anda bisa segera menghubungi support atau merchant untuk meminta penyesuaian alamat. + Masih butuh jawaban yang lebih spesifik? + Jelajahi pusat panduan lengkap atau eskalasi kendala Anda langsung ke tim support. + Pusat Panduan Lengkap + © 2024 INA Trading. Solusi Digital untuk UMKM Indonesia. + + + Segera Hadir + + + Kurasi Heritage + Jelajahi + Nusantara. + Temukan destinasi, merchant, dan pengalaman pilihan dari berbagai kota di Indonesia. + %1$d dimuat dari %2$d lokasi + Cari pengalaman... + Filter + Pilihan Premium + Sorotan Utama + Unggulan + Rekomendasi Terdekat + Daftar berikut akan terus termuat otomatis saat Anda scroll ke bawah. + Memuat lokasi + Menyiapkan daftar eksplorasi pilihan untuk Anda. + Gagal memuat lokasi + Coba Lagi + Lokasi tidak ditemukan + Coba ubah kata kunci pencarian atau muat ulang halaman untuk melihat lokasi lain. + Deskripsi lokasi belum tersedia. + Lokasi + Lokasi terverifikasi + Aksi tas belanja + Warisan Heritage Indonesia + Eksplor Koleksi + Kontak + Status + Buka setiap hari + Tidak tersedia + Merchant ID + Kunjungi Lokasi + Buka di Maps + Signature Prints + Galeri %1$d + Data lokasi belum tersedia. Buka kembali dari halaman Explore. + + + Detail Produk + Total Harga + Tambah ke Keranjang + Beli Sekarang + Produk + Penjual + Stok + %1$d tersedia + Lokasi gudang tidak tersedia + Merek + Berat + Dimensi + SKU + Garansi + Kapasitas Baterai + Kategori + Asal Wilayah + Asal: %1$s + Nama Model + Detail Produk + Spesifikasi Lengkap + Deskripsi produk belum tersedia. + Produk memenuhi syarat untuk pasar ekspor. + Produk baru di katalog saat ini. + Produk pre-order dengan waktu persiapan %1$d hari. + Produk tersedia sebagai pre-order. + Alamat gudang: %1$s + Kata Kunci + Gagal memuat produk + Muat Ulang + Memuat produk + BARU + EKSPOR + PREORDER + Peringatan Keamanan + Produk berhasil ditambahkan ke keranjang + Ringkasan Pesanan + Keranjang Belanja + Gagal memuat keranjang + Coba Lagi + Keranjang Anda masih kosong + Produk yang Anda tambahkan dari halaman detail akan muncul di sini dan dikelompokkan per seller. + Lihat Produk + Masuk untuk melihat keranjang + Masuk terlebih dahulu agar produk pilihan Anda tersimpan dan bisa ditinjau sebelum checkout. + Merchant + Merchant Terverifikasi + Produk + Stok tersedia + Hapus item + Kurangi jumlah + Tambah jumlah + Total Pembayaran + Checkout + Checkout siap untuk tahap berikutnya + Update jumlah dan aksi hapus akan diaktifkan setelah flow cart yang memperhitungkan stok selesai disambungkan. + + + Apa itu + INA Trading? + Ekosistem khusus yang dirancang untuk menjawab kompleksitas perdagangan lintas negara bagi bisnis lokal Indonesia. + Platform Hybrid B2B & D2C + Kami menghilangkan peran perantara dengan strategi dua kanal. Baik untuk penjualan grosir dalam jumlah besar maupun produk spesial satuan, platform ini tumbuh bersama kebutuhan bisnis Anda. + Dashboard Logistik + RFID + BLOCKCHAIN + Memastikan keaslian produk Indonesia dengan RFID & Blockchain untuk kepatuhan perdagangan Eropa, didukung oleh: PERURI Digital Security, PERURI Smart Card, PUNDI Tech & EQBR + MERCHANT TERVERIFIKASI + Jangkauan Global + Akses instan ke pembeli internasional tanpa setup lokal yang rumit. Kami menangani pintu masuk pasar untuk Anda. + Integrasi Digital + Sinkronisasi inventaris yang mulus di berbagai marketplace dengan pembayaran lokal dan pelacakan logistik. + Transaksi Aman + Sistem escrow berlapis dan verifikasi blockchain untuk memastikan keamanan pembayaran bagi penjual dan pembeli. + Kerajinan + Antarmuka Teknologi + Siapa yang menggunakan + UMKM + Agregator + Eksportir + Pemilik Usaha + STUDI KASUS + Contoh Nyata + Pendopo Indonesia + Rumah bagi UMKM lokal, Pendopo telah berkolaborasi dengan lebih dari 300 mitra. Kami mengkurasi lebih dari 12.000 produk dalam empat kategori: Kerajinan, Fesyen, Kuliner, serta Kecantikan & Kebugaran. + Pendopo Indonesia + Startup Hub + Pusat kolaborasi, inovasi, dan akselerasi bagi startup, UKM, dan komunitas di seluruh Indonesia + Startup Hub + Berita Terkini + Lihat Semua + Belum ada berita tersedia. + + + Gagal memuat pembaruan + Belum ada artikel + Pembaruan editorial terbaru akan muncul di sini setelah newsroom menerbitkan cerita baru. + NEWSROOM + Berita & Wawasan + Pembaruan editorial, kisah merchant, dan sinyal perdagangan dari ekosistem INA Trading. + Editorial + Sinyal Dagang + Kisah Merchant + Pembaruan Terbaru + Buka artikel lengkap untuk membaca pembaruan terbaru dari INA Trading. + Ketuk untuk membuka cerita lengkap. + Baca feature story terbaru dari newsroom. + Lihat pembaruan + Baca detail lengkap pada artikel berita ini. + Pembaruan Terbaru + Lihat detail + PELIPUT + + + Jelajahi — %1$s + Memuat produk + Sedang menyiapkan katalog UMKM terbaik untuk Anda. + Katalog tidak tersedia + Produk belum ditemukan + Coba ubah kategori atau kata kunci pencarian Anda. + EDISI TERBATAS + Karya Lokal,\nKualitas Global. + Jelajahi katalog UMKM terkurasi dengan pengiriman lintas pasar dan kualitas yang siap tampil di tingkat global. + Dimuat + Katalog + Cari produk UMKM... + Filter + Harga + Marketplace + Stok %1$d + Habis + Umum + Katalog + Harga hubungi kami + Simpan ke Favorit + Pilih koleksi untuk produk ini, atau buat koleksi baru terlebih dahulu. + %1$d produk + Produk berhasil ditambahkan ke favorit + Produk berhasil dihapus dari favorit + diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..5e54687 --- /dev/null +++ b/app/src/main/res/values-night/themes.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..c8524cd --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,5 @@ + + + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/font_certs.xml b/app/src/main/res/values/font_certs.xml new file mode 100644 index 0000000..3d67572 --- /dev/null +++ b/app/src/main/res/values/font_certs.xml @@ -0,0 +1,19 @@ + + + + @array/com_google_android_gms_fonts_certs_dev + @array/com_google_android_gms_fonts_certs_prod + + + + + MIIEqDCCA5CgAwIBAgIJANWFuGx90071MA0GCSqGSIb3DQEBBAUAMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNU2FuIEZyYW5jaXNjbzETMBEGA1UEChMKR29vZ2xlIEluYzEVMBMGA1UECxMMQW5kcm9pZCBUZWFtMRwwGgYDVQQDExNBbmRyb2lkIERlYnVnIFJvb3QwHhcNMTMwMjA2MTcwMzE2WhcNMjMwMjA0MTcwMzE2WjCBlDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCkNhbGlmb3JuaWExFjAUBgNVBAcTDVNhbiBGcmFuY2lzY28xEzARBgNVBAoTCkdvb2dsZSBJbmMxFTATBgNVBAsTDEFuZHJvaWQgVGVhbTEcMBoGA1UEAxMTQW5kcm9pZCBEZWJ1ZyBSb290MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1LGGJGlE7hcBXMNe7D7OFKjU3kFriZNmcWnBrPBjjNQMoB2DPIVdSqEY + + + + + + MIIEQzCCAyugAwIBAgIJAMLgh0ZkSjCNMA0GCSqGSIb3DQEBBAUAMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDAeFw0wODA4MjEyMzEzMzRaFw0zNjAxMDcyMzEzMzRaMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBAKtWLgDYO6IIrgqWbxJOKdoR8qtW0I9Y4sypEwPpt1TTcvZApxsdyxMJsXYcGRXHuntAJhNo3QAQBO2c6PoW8xZxkJnKWDYCCBNGGLTgH3RRSekNMNDKHlLR8ZFwFKXkHQ09ZJRqMbNgcFIKjyH6EhJCRVRFGcBcO3IRtR0xb6T8IsBJCPBTjx7yzreFlgVTbXJBvlNNrQQ2HBwXOYhpTRxKpOCaqUGbzNuYcPHzNl7c0z5fNSOXMiMwBJ+SWScBWDHQfkBHOjHYWPkzLuA7a68ZhGwjM/CALhMGh2PZ4EhFQM5EbMDKFHHU61MSsxdPbYZ4F3NM0MAAIB + + + diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..c5d5899 --- /dev/null +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/plurals.xml b/app/src/main/res/values/plurals.xml new file mode 100644 index 0000000..561989a --- /dev/null +++ b/app/src/main/res/values/plurals.xml @@ -0,0 +1,6 @@ + + + %d favorite product + %d favorite products + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..44d7ee6 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,529 @@ + + INA Trading + INA Trading v2.4.0 + Karya Anak Bangsa + + + Home + Explore + Products + Profile + + + Menu + Scan RFID + Cart + Notifications + Waiting for RFID Scan + Bring the product RFID tag close to the back of your device to start scanning. + Cancel + This device does not support NFC. + NFC is disabled. Please enable it first. + RFID tag is not valid for INA Trading. + RFID detected: %1$s + + + Welcome to INA Trading + Get full access to the best shopping features for MSMEs and selected local products. + Login or Register + Why Join? + Benefits + Track your orders + Monitor your shipment status in real-time from coast to coast. + Save favorite products + Save your favorite MSME products and get notified of exciting promotions. + Secure transactions + Every transaction is protected by a multi-layered security system for your peace of mind. + Information & Help + + + Premium Member + My Orders + View All + To Pay + To Ship + To Receive + Review + Settings & Info + Edit\nProfile + Shipping\nAddresses + Change\nPassword + My Favorites + Payment Methods + Logout + Saved Collection + Keep the products you love in one place and revisit them anytime. + No favorite products yet + Tap the heart icon on any product to save it here for quick access later. + Browse Products + Personal Settings + Edit Profile + Full Name + Enter full name + Email + Phone Number + 08123456789 + Account Security + Use accurate information to simplify verification across your INA Trading transactions. + Save Changes + Profile updates will be reflected across the INA Trading ecosystem. + Unable to load profile + Change Profile Photo + Choose the image source you want to use for your profile photo. + Choose from Gallery + Take Photo + Account Security + Change Password + For your account security, use a strong password combination that you do not reuse on other services. + Current Password + Enter your current password + New Password + Min. 8 characters, mix letters and numbers + Confirm New Password + Re-enter your new password + Weak + Fair + Good + Strong + Security Tip + Avoid using names, birth dates, or other personal details that are easy to guess. + Save Changes + Password updated successfully. + Current password is required. + New password is required. + New password must be at least 8 characters. + New password must be different from the current password. + Password confirmation does not match. + Your Destinations + Manage Your\nDelivery Hubs + Primary + Home + Office + Address %1$d + Recipient details will be available soon. + Phone details will be available soon. + Live Coverage Zone + "Delivering the best of Nusantara\'s crafts directly to your doorstep with priority tracking." + Add New Address + Unable to load addresses + No shipping addresses yet + Save your primary delivery destinations here to speed up checkout and keep shipments organized. + New Location + Add Address + Provide your precise shipping information to ensure a seamless delivery experience for your Indonesian artisanal goods. + Label Address + Home + Office + Warehouse + Other + Custom Label + Enter label name + Recipient Name + Full name + Phone Number + 81234567890 + Country + Indonesia + Indonesia + Other + Country Name + Enter country name + Province + Enter province + City / District + Select city + Postal Code + 5-digit code + Full Address Details + Street name, building number, unit number, and other delivery notes + Pin Point on Map + Set as Primary Address + Default address for all future checkouts + Save Address + Loading provinces... + Loading cities... + Select province first + Use current location: %1$.5f, %2$.5f + Getting current location... + Tap the map teaser to use your current location. + Location permission is needed to set the pin point automatically. + Location permission is required to use the current position. + Location service is not available on this device. + Enable device location first. + Current location could not be determined yet. + Account Settings + Edit Address + Update your delivery details to ensure every package arrives safely at the right destination. + Recipient Information + Location Details + Coordinates: %1$.4f, %2$.4f + Save Changes + Delete Address + Best Product\nInspiration + Back + Create New Collection + Create + Edit Collection Name + Edit + Save + Delete + Delete \"%1$s\" from your collections? + More options + DEFAULT + Collection name + Create your first collection to organize favorite products by theme, room, or shopping plan. + Refresh Collections + Selected Collection + Group Summary + Total Items + Product Detail + No products in this collection yet + Save products to this collection to review them later. + Remove from favorites + Cancel + + + About INA Trading + Help Center + Privacy Policy + Terms & Conditions + + + Registration + Create Account + Join the Indonesian MSME trading ecosystem. + Full Name + Full Name + Mobile Phone + +62 8xx xxxx xxxx + Password + Confirm Password + Confirm Password + Register Now + Or register with Google + Already have an account? Login here + By registering, you agree to the Terms & Conditions and Privacy Policy of INA Trading to support National MSME growth. + Passwords do not match + Password must be at least 8 characters + Invalid email format + Full name is required + Phone number is required + + + Welcome Back + Explore\nFeatured Products. + Email Address + name@email.com + Password + •••••••• + Forgot Password? + Login Now + Or login with + Google + Don\'t have an account? Register here + Secured by INA Trading + Show password + Hide password + + + Forgot Password + Enter your registered email and we\'ll send a reset link. + Send Reset Link + Back to Login + + + © 2024 INA Trading. Editorial Excellence for Indonesian MSMEs. + Help + Terms & Conditions + + + About Our Vision + Empowering + Indonesian Artisans + for the Global Stage. + INA Trading is more than a platform; it\'s a bridge connecting local MSMEs (UMKM) to international markets through secure integration and premium logistical excellence. + Secure Transactions + End-to-end encryption and verified payment gateways ensure that every trade is protected by international standards of security. + Digital Integration + Cloud-native infrastructure for seamless inventory and order management. + Global Reach + Expanding Indonesian heritage to over 40+ countries across Europe and North America. + "Bridging traditional craftsmanship with digital speed." + Our Mission + We believe that Indonesian MSMEs are the backbone of our economy. Our mission is to eliminate the complexities of export, providing local artisans with the tools, capital, and network they need to compete on a global scale while maintaining their unique cultural identity. + Learn More + Active MSMEs + Global Shipments + Partner with us + Ready to take your business global? + Join thousands of Indonesian businesses already exporting their high-quality products with INA Trading. + Register as Seller + Contact Support + + + Legal Document + Terms & + Conditions + Welcome to INA Trading. These terms govern your use of our marketplace and define the professional standards of our modern artisan ecosystem. + User Agreements + By accessing or using the INA Trading marketplace, you agree to be bound by these Terms and Conditions. Our platform is designed to empower Indonesian MSMEs through a secure and transparent digital environment. + Account creation requires authentic business documentation for vendors and verified identity for customers to maintain the integrity of our artisanal network. + Transaction Rules + All transactions processed through INA Trading are final upon fulfillment. Payments are held in secure escrow to ensure quality delivery before merchant payout. + Fixed commission of 2.5% for platform maintenance. + 24-hour dispute window post-delivery. + Liability & Limits + INA Trading acts as a facilitator for trade between MSMEs and buyers. While we provide high-level security and vetting, we are not liable for direct merchant-to-customer logistics failures beyond our specified protection policies. + Users are responsible for maintaining the confidentiality of their credentials and all activities occurring under their unique digital identity. + Have questions? + Reach out to our legal compliance team for clarifications. + Contact Support + LAST UPDATED: OCTOBER 24, 2023 • VERSION 2.4.0 + + + Registration Successful! + Your account has been created.\nYou can now login with your email and password. + Login Now + + + Privacy + Your Privacy Matters to Us + INA Trading is committed to protecting your personal data and being transparent about how we collect and use it. + Last updated: October 24, 2023 + What Data We Collect + We collect only the data necessary to provide our marketplace services, including account details, transaction history, and shipping information. + Account information + name, email, and phone number used for authentication and communications. + Transaction data + order history and payment details processed through our secure escrow system. + Security Infrastructure + Your data is protected with end-to-end encryption and stored in compliance with international data protection standards. + AES-256 Encryption • ISO 27001 Compliant + Third-Party Sharing + We share your data only with trusted partners necessary to fulfill your orders. We never sell your personal data. + Logistics + Payment + Compliance + Your Privacy Rights + You have the right to access, correct, or delete your personal data at any time. Contact us or manage your preferences directly in the app. + Manage Privacy Preferences + Have questions? + Our privacy team is available to answer any questions about how we handle your data. + Email Privacy Team + Read Full Terms & Conditions + Support Center + How can we help? + Search help topics, shipping, refunds, or payments + Support Categories + Orders + Payment + Shipping + Account + Need More Help? + Our support team is ready to assist you around the clock for urgent order, payment, and account issues. + WhatsApp Support + Email Support + Main Categories + Orders & Delivery + Track orders, check seller processing, or review active order history. + Payments + Payment methods, failed transactions, settlement timing, and refunds. + Shipping + Delivery status, shipping windows, address issues, and courier coordination. + Account & Security + Password reset, verification, login sessions, and account privacy settings. + Merchant Guide + Catalog management, inventory readiness, and best practices for sellers. + Frequently Asked Questions + How do I track my order? + Open your order history and select the active transaction. The order detail page will show seller processing status and shipment updates when available. + What payment methods are available? + Available methods depend on the merchant and active transaction flow, but commonly include bank transfer and other supported digital payment channels. + How long does the refund process take? + Refund timing depends on the payment method and merchant validation, but we recommend allowing several business days after the refund request is approved. + How can I change my shipping address? + For orders that have not entered shipping preparation, you can contact support or the merchant as soon as possible to request an address adjustment. + Still need a clearer answer? + Browse the full guidance hub or escalate your issue directly to the support team. + Full Guide Center + © 2024 INA Trading. Digital solutions for Indonesian MSMEs. + + + Coming Soon + + + Curated Heritage + Explore + Nusantara. + Temukan destinasi, merchant, dan pengalaman pilihan dari berbagai kota di Indonesia. + %1$d loaded of %2$d locations + Search experiences... + Filter + Premium Selection + Featured Highlights + Featured + Recommended Nearby + Daftar berikut akan terus memuat otomatis saat Anda scroll ke bawah. + Loading locations + Menyiapkan daftar eksplorasi pilihan untuk Anda. + Failed to load locations + Retry + No locations found + Coba ubah kata kunci pencarian atau muat ulang halaman untuk melihat lokasi lain. + Deskripsi lokasi belum tersedia. + Location + Verified location + Explore shopping bag + The Legacy of Indonesian Heritage + Explore Collection + Contact + Status + Open Daily + Unavailable + Merchant ID + Visit The Atelier + Open in Maps + Signature Prints + Gallery Item %1$d + Data lokasi belum tersedia. Buka kembali dari halaman Explore. + + + Product Detail + Total Price + Add to Cart + Buy Now + Product + Seller + Stock + %1$d available + Warehouse location unavailable + Brand + Weight + Dimensions + SKU + Warranty + Battery Capacity + Category + Region of Origin + Origin: %1$s + Model Name + Product Details + Detailed Specifications + No product description available. + Eligible for export market. + New item in the current catalog. + Pre-order item with %1$d days preparation. + Available as pre-order item. + Warehouse address: %1$s + Keywords + Unable to load product + Retry + Loading product + NEW + EXPORT + PREORDER + Safety Warning + Product added to cart + Order Summary + Shopping Cart + Failed to load cart + Retry + Your cart is still empty + Products you add from the detail page will appear here and be grouped by seller. + Browse Products + Login to view your cart + Sign in first so your selected products can be saved and reviewed before checkout. + Merchant + Verified Merchant + Product + Available stock + Remove item + Decrease quantity + Increase quantity + Total Payment + Checkout + Checkout is ready for the next phase + Quantity update and delete action will be activated after the stock-aware cart flow is connected. + + + What is + INA Trading? + A specialized ecosystem designed to solve the complexities of cross-border commerce for local Indonesian businesses. + Hybrid B2B & D2C Platform + We eliminate the middleman by providing a dual-channel strategy. Whether you are selling bulk containers to wholesalers or single specialty items, our platform scales with your needs. + Logistics Dashboard + RFID + BLOCKCHAIN + Ensuring Indonesian Product Authenticity with RFID & Blockchain for European Trade Compliance, powered by : PERURI Digital Security, PERURI Smart Card, PUNDI Tech & EQBR + VERIFIED MERCHANTS + Global Reach + Instant access to international buyers without complex localized setups. We handle the market entry for you. + Digital Integration + Seamless inventory sync across multiple marketplaces with localized payment gateways and logistics tracking. + Secure Transaction + Multi-layered escrow systems and blockchain verification ensuring payment security for both vendors and buyers. + Craftsmanship + Tech Interface + Who uses + MSMEs + Aggregators + Exporters + Business Owners + CASE STUDIES + Real World Examples + Pendopo Indonesia + A home for local MSMEs, Pendopo has collaborated with over 300 partners. We curate more than 12,000 products across four categories: Craft, Fashion, Culinary, and Beauty & Wellness. + Pendopo Indonesia + Startup Hub + Collaborative, innovative, and acceleration hub for startups, SMEs, and communities throughout Indonesia + Startup Hub + Latest News + View All + No news available yet. + + + Unable to load updates + No articles yet + Latest editorial updates will appear here once the newsroom publishes new stories. + NEWSROOM + News & Insights + Editorial updates, merchant stories, and trade signals from the INA Trading ecosystem. + Editorial + Trade Signals + Merchant Stories + Latest Updates + Open the full article to read the latest update from INA Trading. + Tap to open the full story. + Read the latest feature story from the newsroom. + Explore the update + Read the full details in the news article. + Latest Update + View details + REPORTER + + + Explore — %1$s + Loading products + Preparing the best MSME catalog for you. + Catalog unavailable + No products found + Try changing your category or search keyword. + LIMITED EDITION + Local Creations,\nGlobal Quality. + Explore a curated MSME catalog with cross-market shipping and quality ready for the global stage. + Loaded + Catalog + Search MSME products... + Filter + Price + Marketplace + Stock %1$d + Out of stock + General + Catalog + Contact us for price + Save to Favorite + Choose a collection for this product, or create a new one first. + %1$d products + Product added to favorites + Product removed from favorites + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..731066c --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,9 @@ + + + + +