first commit
This commit is contained in:
1
app/.gitignore
vendored
Normal file
1
app/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
151
app/build.gradle.kts
Normal file
151
app/build.gradle.kts
Normal file
@@ -0,0 +1,151 @@
|
||||
import com.android.build.api.artifact.SingleArtifact
|
||||
import com.android.build.api.variant.BuiltArtifactsLoader
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import javax.xml.parsers.DocumentBuilderFactory
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.google.gms.google.services)
|
||||
}
|
||||
|
||||
fun readStringResource(projectDir: File, resName: String): String {
|
||||
val xml = File(projectDir, "src/main/res/values/strings.xml")
|
||||
if (!xml.exists()) return "app"
|
||||
|
||||
val doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(xml)
|
||||
val nodes = doc.getElementsByTagName("string")
|
||||
for (i in 0 until nodes.length) {
|
||||
val n = nodes.item(i)
|
||||
val nameAttr = n.attributes?.getNamedItem("name")?.nodeValue
|
||||
if (nameAttr == resName) {
|
||||
return n.textContent.trim()
|
||||
}
|
||||
}
|
||||
return "app"
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.amz.genie"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.amz.genie"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 57
|
||||
versionName = "0.5.7"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Task: copy APK artifact -> output folder, rename with timestamp
|
||||
*/
|
||||
abstract class CopyRenameApkTask : DefaultTask() {
|
||||
|
||||
@get:InputDirectory
|
||||
@get:PathSensitive(PathSensitivity.RELATIVE)
|
||||
abstract val inputApkFolder: DirectoryProperty
|
||||
|
||||
@get:OutputDirectory
|
||||
abstract val outputDir: DirectoryProperty
|
||||
|
||||
@get:Internal
|
||||
abstract val builtArtifactsLoader: Property<BuiltArtifactsLoader>
|
||||
|
||||
@get:Input
|
||||
abstract val appName: Property<String>
|
||||
|
||||
@TaskAction
|
||||
fun run() {
|
||||
val outDirFile = outputDir.get().asFile
|
||||
outDirFile.deleteRecursively()
|
||||
outDirFile.mkdirs()
|
||||
|
||||
val builtArtifacts = builtArtifactsLoader.get().load(inputApkFolder.get())
|
||||
?: error("Cannot load APK artifacts from ${inputApkFolder.get().asFile}")
|
||||
|
||||
val ts = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"))
|
||||
val prefix = appName.get()
|
||||
|
||||
builtArtifacts.elements.forEach { artifact ->
|
||||
val src = File(artifact.outputFile)
|
||||
|
||||
// Kalau ada splits, bisa lebih dari 1 apk. Kita bedakan pakai versionCode/variantName optional.
|
||||
val name = buildString {
|
||||
append(prefix).append("-").append(ts)
|
||||
artifact.versionCode?.let { append("-").append(it) }
|
||||
append(".apk")
|
||||
}
|
||||
|
||||
val dst = File(outDirFile, name)
|
||||
Files.copy(src.toPath(), dst.toPath(), StandardCopyOption.REPLACE_EXISTING)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
androidComponents {
|
||||
onVariants(selector().withBuildType("debug")) { variant ->
|
||||
val t = tasks.register("copyRename${variant.name.replaceFirstChar { it.uppercase() }}Apk", CopyRenameApkTask::class.java) {
|
||||
val label = readStringResource(project.projectDir, "app_name")
|
||||
appName.set(label)
|
||||
outputDir.set(layout.buildDirectory.dir(variant.name))
|
||||
builtArtifactsLoader.set(variant.artifacts.getBuiltArtifactsLoader())
|
||||
}
|
||||
|
||||
// Listen ke artifact APK: task otomatis jalan saat APK dibuat (assembleDebug, dll)
|
||||
variant.artifacts
|
||||
.use(t)
|
||||
.wiredWith { it.inputApkFolder }
|
||||
.toListenTo(SingleArtifact.APK)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.material)
|
||||
implementation(libs.androidx.activity)
|
||||
implementation(libs.androidx.constraintlayout)
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
|
||||
implementation(libs.lottie)
|
||||
implementation(libs.sdp.android)
|
||||
implementation(libs.retrofit)
|
||||
implementation(libs.converter.gson)
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.logging.interceptor)
|
||||
implementation(libs.glide)
|
||||
implementation(libs.androidx.swiperefreshlayout)
|
||||
|
||||
implementation(platform(libs.firebase.bom))
|
||||
implementation(libs.firebase.analytics)
|
||||
implementation(libs.firebase.messaging)
|
||||
implementation(libs.firebase.auth)
|
||||
|
||||
implementation(libs.androidx.emoji2)
|
||||
implementation(libs.androidx.emoji2.views.helper)
|
||||
implementation(libs.androidx.emoji2.emojipicker)
|
||||
|
||||
}
|
||||
BIN
app/debug/app-debug.apk
Normal file
BIN
app/debug/app-debug.apk
Normal file
Binary file not shown.
21
app/debug/output-metadata.json
Normal file
21
app/debug/output-metadata.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"version": 3,
|
||||
"artifactType": {
|
||||
"type": "APK",
|
||||
"kind": "Directory"
|
||||
},
|
||||
"applicationId": "com.amz.genie",
|
||||
"variantName": "debug",
|
||||
"elements": [
|
||||
{
|
||||
"type": "SINGLE",
|
||||
"filters": [],
|
||||
"attributes": [],
|
||||
"versionCode": 57,
|
||||
"versionName": "0.5.7",
|
||||
"outputFile": "app-debug.apk"
|
||||
}
|
||||
],
|
||||
"elementType": "File",
|
||||
"minSdkVersionForDexing": 26
|
||||
}
|
||||
29
app/google-services.json
Normal file
29
app/google-services.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "791153239007",
|
||||
"project_id": "genie-11548",
|
||||
"storage_bucket": "genie-11548.firebasestorage.app"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:791153239007:android:a35521285c9d4102f05a6a",
|
||||
"android_client_info": {
|
||||
"package_name": "com.amz.genie"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyBN5-kXCfqw_7o5O1dzA-oWiA2SH4wqQAY"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
21
app/proguard-rules.pro
vendored
Normal file
21
app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.amz.genie
|
||||
|
||||
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("com.amz.genie", appContext.packageName)
|
||||
}
|
||||
}
|
||||
81
app/src/main/AndroidManifest.xml
Normal file
81
app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,81 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera"
|
||||
android:required="false" />
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
<application
|
||||
android:name=".services.APIMain"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@drawable/logo_normal"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@drawable/logo_normal"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.GENIE"
|
||||
android:usesCleartextTraffic="true">
|
||||
<activity
|
||||
android:name=".activities.AttachmentPreviewActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".activities.AddTemplateActionActivity"
|
||||
android:exported="false"
|
||||
android:windowSoftInputMode="adjustResize|stateHidden" />
|
||||
<activity
|
||||
android:name=".activities.MoreActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".activities.AddCustomActionActivity"
|
||||
android:exported="false"
|
||||
android:windowSoftInputMode="adjustResize|stateHidden" />
|
||||
<activity
|
||||
android:name=".activities.GeneralSubDetailActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".activities.GeneralDetailActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".activities.LoginActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".activities.MainActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".activities.SplashActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<service
|
||||
android:name=".services.MyFirebaseMessagingService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
1
app/src/main/assets/lottie/loader_circle.json
Normal file
1
app/src/main/assets/lottie/loader_circle.json
Normal file
File diff suppressed because one or more lines are too long
1
app/src/main/assets/lottie/loader_send.json
Normal file
1
app/src/main/assets/lottie/loader_send.json
Normal file
File diff suppressed because one or more lines are too long
1
app/src/main/assets/lottie/loading.json
Normal file
1
app/src/main/assets/lottie/loading.json
Normal file
File diff suppressed because one or more lines are too long
1
app/src/main/assets/lottie/loading_alt.json
Normal file
1
app/src/main/assets/lottie/loading_alt.json
Normal file
File diff suppressed because one or more lines are too long
@@ -0,0 +1,645 @@
|
||||
package com.amz.genie.activities
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.AutoCompleteTextView
|
||||
import android.widget.ImageButton
|
||||
import android.widget.TextView
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.amz.genie.R
|
||||
import com.amz.genie.adapters.AttachmentAdapter
|
||||
import com.amz.genie.adapters.RecipientAdapter
|
||||
import com.amz.genie.adapters.RecipientPickerAdapter
|
||||
import com.amz.genie.helpers.Preferences
|
||||
import com.amz.genie.helpers.Utils.forceLogoutAndGoLogin
|
||||
import com.amz.genie.helpers.Utils.isNetworkAvailable
|
||||
import com.amz.genie.helpers.Utils.uriToMultipartPart
|
||||
import com.amz.genie.models.AttachmentItem
|
||||
import com.amz.genie.models.Message
|
||||
import com.amz.genie.models.Pegawai
|
||||
import com.amz.genie.models.Pengguna
|
||||
import com.amz.genie.models.PostAksi
|
||||
import com.amz.genie.models.Tentang
|
||||
import com.amz.genie.models.TipeKomunikasi
|
||||
import com.amz.genie.services.APIMain
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.google.android.material.textfield.TextInputEditText
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.google.gson.JsonParser
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import retrofit2.Call
|
||||
import retrofit2.Callback
|
||||
import retrofit2.Response
|
||||
import java.io.File
|
||||
|
||||
class AddCustomActionActivity : BaseActivity() {
|
||||
|
||||
private lateinit var ibBack: ImageButton
|
||||
private lateinit var actvTipeKomunikasi: AutoCompleteTextView
|
||||
private lateinit var actvTentang: AutoCompleteTextView
|
||||
|
||||
private lateinit var rvRecipient: RecyclerView
|
||||
private lateinit var tvEmpty: TextView
|
||||
private lateinit var tietDescription: TextInputEditText
|
||||
private lateinit var btAttachment: MaterialButton
|
||||
private lateinit var btSend: MaterialButton
|
||||
private lateinit var llAttachmentsContainer: View
|
||||
private lateinit var rvAttachments: RecyclerView
|
||||
private lateinit var tvAddRecipient: TextView
|
||||
private lateinit var attachmentAdapter: AttachmentAdapter
|
||||
|
||||
private lateinit var recipientAdapter: RecipientAdapter
|
||||
private val recipientOptions = mutableListOf<Pegawai>()
|
||||
private val selectedRecipients = mutableListOf<Pegawai>()
|
||||
private var idTipeKomunikasi = ""
|
||||
private var idTentang = ""
|
||||
private var cameraOutputUri: Uri? = null
|
||||
private val attachments = mutableListOf<AttachmentItem>()
|
||||
private val takePictureLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.TakePicture()) { success ->
|
||||
if (success) {
|
||||
cameraOutputUri?.let { addAttachment(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private val pickFileLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||
if (uri != null) {
|
||||
contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
addAttachment(uri)
|
||||
}
|
||||
}
|
||||
|
||||
private val requestCameraPermissionLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
if (granted) {
|
||||
openCamera()
|
||||
} else {
|
||||
showSnack("Izin kamera ditolak")
|
||||
}
|
||||
}
|
||||
|
||||
private fun openCameraWithPermission() {
|
||||
val granted = ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
|
||||
if (granted) {
|
||||
openCamera()
|
||||
} else {
|
||||
requestCameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_add_custom_action)
|
||||
|
||||
initUI(savedInstanceState)
|
||||
renderAttachments()
|
||||
setupDropdowns()
|
||||
}
|
||||
|
||||
private fun initUI(savedInstanceState: Bundle?) {
|
||||
ibBack = findViewById(R.id.ib_back_add_custom_action)
|
||||
actvTipeKomunikasi = findViewById(R.id.actv_tipe_komunikasi_add_custom_action)
|
||||
actvTentang = findViewById(R.id.actv_tentang_add_custom_action)
|
||||
rvRecipient = findViewById(R.id.rv_recipient_add_custom_action)
|
||||
tvEmpty = findViewById(R.id.tv_empty_add_custom_action)
|
||||
btAttachment = findViewById(R.id.btn_attachment_add_custom_action)
|
||||
btSend = findViewById(R.id.btn_send_add_custom_action)
|
||||
llAttachmentsContainer = findViewById(R.id.ll_attachments_container_add_custom_action)
|
||||
rvAttachments = findViewById(R.id.rv_attachments_add_custom_action)
|
||||
tvAddRecipient = findViewById(R.id.tv_lbl_recipient_add_custom_action)
|
||||
tietDescription = findViewById(R.id.et_description_add_custom_action)
|
||||
|
||||
recipientAdapter = RecipientAdapter { pegawai ->
|
||||
val idx = selectedRecipients.indexOfFirst { it.kode == pegawai.kode }
|
||||
if (idx != -1) {
|
||||
selectedRecipients.removeAt(idx)
|
||||
renderRecipients()
|
||||
showSnack("Penerima dihapus")
|
||||
}
|
||||
}
|
||||
rvRecipient.layoutManager = LinearLayoutManager(this)
|
||||
rvRecipient.adapter = recipientAdapter
|
||||
|
||||
attachmentAdapter = AttachmentAdapter { item ->
|
||||
attachments.remove(item)
|
||||
renderAttachments()
|
||||
}
|
||||
|
||||
rvAttachments.layoutManager = GridLayoutManager(this, 2)
|
||||
rvAttachments.adapter = attachmentAdapter
|
||||
|
||||
setupActions()
|
||||
}
|
||||
|
||||
private fun renderRecipients() {
|
||||
val hasData = selectedRecipients.isNotEmpty()
|
||||
rvRecipient.visibility = if (hasData) View.VISIBLE else View.GONE
|
||||
tvEmpty.visibility = if (hasData) View.GONE else View.VISIBLE
|
||||
|
||||
recipientAdapter.submitList(selectedRecipients.toList())
|
||||
}
|
||||
|
||||
private fun setupDropdowns() {
|
||||
val communicationTypeList = ArrayList<String>()
|
||||
APIMain.require().selectionServices.communicationTypes(
|
||||
Preferences.getAccessToken(this))
|
||||
.enqueue(object: Callback<ArrayList<TipeKomunikasi>> {
|
||||
override fun onResponse(
|
||||
call: Call<ArrayList<TipeKomunikasi>>,
|
||||
response: Response<ArrayList<TipeKomunikasi>>
|
||||
) {
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body().orEmpty()
|
||||
communicationTypeList.clear()
|
||||
body.forEach { communicationTypeList.add(it.tipe_komunikasi) }
|
||||
|
||||
val adapterTipe = ArrayAdapter(this@AddCustomActionActivity,
|
||||
android.R.layout.simple_list_item_1,
|
||||
communicationTypeList)
|
||||
actvTipeKomunikasi.setAdapter(adapterTipe)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
val raw = runCatching { response.errorBody()?.string().orEmpty() }.getOrDefault("")
|
||||
val expired = raw.contains("Signature has expired", true) || raw.contains("token_expired", true)
|
||||
|
||||
val message = when {
|
||||
(response.code() == 401 || response.code() == 500) && expired -> {
|
||||
forceLogoutAndGoLogin(this@AddCustomActionActivity)
|
||||
"Session expired. Please login again."
|
||||
}
|
||||
response.code() == 400 -> runCatching {
|
||||
JsonParser.parseString(raw).asJsonObject["message"].asString
|
||||
}.getOrDefault("Bad request")
|
||||
else -> "${response.code()}, ${response.message()}"
|
||||
}
|
||||
|
||||
showSnack(message)
|
||||
}
|
||||
|
||||
override fun onFailure(
|
||||
call: Call<ArrayList<TipeKomunikasi>>,
|
||||
t: Throwable
|
||||
) {
|
||||
showSnack(t.message ?: "Terjadi kesalahan")
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
private fun setupActions() {
|
||||
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
handleBackPress(0)
|
||||
}
|
||||
})
|
||||
|
||||
ibBack.setOnClickListener { handleBackPress(0) }
|
||||
|
||||
actvTipeKomunikasi.setOnItemClickListener { parent, _, position, _ ->
|
||||
val selected = parent.getItemAtPosition(position).toString()
|
||||
idTipeKomunikasi = when (selected) {
|
||||
"Bertanya" -> "A"
|
||||
"Informasi" -> "I"
|
||||
"Laporan" -> "L"
|
||||
"Approval" -> "P"
|
||||
"Pengajuan" -> "R"
|
||||
"Penugasan" -> "T"
|
||||
else -> ""
|
||||
}
|
||||
|
||||
val tentangList = ArrayList<String>()
|
||||
APIMain.require().selectionServices.tentangs(
|
||||
Preferences.getAccessToken(this), idTipeKomunikasi)
|
||||
.enqueue(object: Callback<ArrayList<Tentang>> {
|
||||
override fun onResponse(
|
||||
call: Call<ArrayList<Tentang>>,
|
||||
response: Response<ArrayList<Tentang>>
|
||||
) {
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body().orEmpty()
|
||||
body.forEach { tentangList.add(it.tentang) }
|
||||
|
||||
val adapterTentang = ArrayAdapter(this@AddCustomActionActivity,
|
||||
android.R.layout.simple_list_item_1,tentangList)
|
||||
actvTentang.setAdapter(adapterTentang)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
val raw = runCatching { response.errorBody()?.string().orEmpty() }.getOrDefault("")
|
||||
val expired = raw.contains("Signature has expired", true) || raw.contains("token_expired", true)
|
||||
|
||||
val message = when {
|
||||
(response.code() == 401 || response.code() == 500) && expired -> {
|
||||
forceLogoutAndGoLogin(this@AddCustomActionActivity)
|
||||
"Session expired. Please login again."
|
||||
}
|
||||
response.code() == 400 -> runCatching {
|
||||
JsonParser.parseString(raw).asJsonObject["message"].asString
|
||||
}.getOrDefault("Bad request")
|
||||
else -> "${response.code()}, ${response.message()}"
|
||||
}
|
||||
|
||||
showSnack(message)
|
||||
}
|
||||
|
||||
override fun onFailure(
|
||||
call: Call<ArrayList<Tentang>>,
|
||||
t: Throwable
|
||||
) {
|
||||
showSnack(t.message.toString())
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
actvTipeKomunikasi.setOnClickListener { actvTipeKomunikasi.showDropDown() }
|
||||
|
||||
actvTentang.setOnClickListener { actvTentang.showDropDown() }
|
||||
|
||||
actvTentang.setOnItemClickListener { parent, _, position, _ ->
|
||||
selectedRecipients.clear()
|
||||
renderRecipients()
|
||||
|
||||
val selectedTentang = parent.getItemAtPosition(position).toString()
|
||||
idTentang = when (selectedTentang) {
|
||||
"Aturan" -> "A"
|
||||
"Barang" -> "B"
|
||||
"Kegiatan" -> "G"
|
||||
"Jadwal" -> "J"
|
||||
"Keadaan" -> "K"
|
||||
"Layanan" -> "L"
|
||||
"Orang" -> "O"
|
||||
"Sistem" -> "S"
|
||||
"Transaksi" -> "T"
|
||||
"Uang" -> "U"
|
||||
else -> ""
|
||||
}
|
||||
|
||||
APIMain.require().selectionServices.recipients(
|
||||
Preferences.getAccessToken(this), idTipeKomunikasi, idTentang)
|
||||
.enqueue(object: Callback<ArrayList<Pegawai>> {
|
||||
override fun onResponse(
|
||||
call: Call<ArrayList<Pegawai>>,
|
||||
response: Response<ArrayList<Pegawai>>
|
||||
) {
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body().orEmpty()
|
||||
val userData = Gson().fromJson(Preferences.getUserData(this@AddCustomActionActivity),
|
||||
Pengguna::class.java)
|
||||
|
||||
val filtered = body.filter { p ->
|
||||
p.kode.trim() != userData.pegawai?.kode
|
||||
}
|
||||
|
||||
recipientOptions.clear()
|
||||
recipientOptions.addAll(filtered)
|
||||
|
||||
selectedRecipients.clear()
|
||||
selectedRecipients.addAll(filtered)
|
||||
renderRecipients()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
val raw = runCatching { response.errorBody()?.string().orEmpty() }.getOrDefault("")
|
||||
val expired = raw.contains("Signature has expired", true) || raw.contains("token_expired", true)
|
||||
|
||||
val message = when {
|
||||
(response.code() == 401 || response.code() == 500) && expired -> {
|
||||
forceLogoutAndGoLogin(this@AddCustomActionActivity)
|
||||
"Session expired. Please login again."
|
||||
}
|
||||
response.code() == 400 -> runCatching {
|
||||
JsonParser.parseString(raw).asJsonObject["message"].asString
|
||||
}.getOrDefault("Bad request")
|
||||
else -> "${response.code()}, ${response.message()}"
|
||||
}
|
||||
|
||||
showSnack(message)
|
||||
}
|
||||
|
||||
override fun onFailure(
|
||||
call: Call<ArrayList<Pegawai>>,
|
||||
t: Throwable
|
||||
) {
|
||||
showSnack(t.message.toString())
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
btAttachment.setOnClickListener {
|
||||
showAttachmentPickerDialog()
|
||||
}
|
||||
|
||||
tvAddRecipient.setOnClickListener {
|
||||
if (isNetworkAvailable(this)) {
|
||||
showProgressDialog(true)
|
||||
APIMain.require().selectionServices.allRecipient(
|
||||
Preferences.getAccessToken(this))
|
||||
.enqueue(object: Callback<ArrayList<Pegawai>> {
|
||||
override fun onResponse(
|
||||
call: Call<ArrayList<Pegawai>>,
|
||||
response: Response<ArrayList<Pegawai>>
|
||||
) {
|
||||
showProgressDialog(false)
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body().orEmpty()
|
||||
val userData = Gson().fromJson(Preferences.getUserData(this@AddCustomActionActivity),
|
||||
Pengguna::class.java)
|
||||
|
||||
val filtered = body.filter { p ->
|
||||
p.kode.trim() != userData.pegawai?.kode
|
||||
}
|
||||
showRecipientPickerDialog(ArrayList(filtered))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
val raw = runCatching { response.errorBody()?.string().orEmpty() }.getOrDefault("")
|
||||
val expired = raw.contains("Signature has expired", true) || raw.contains("token_expired", true)
|
||||
|
||||
val message = when {
|
||||
(response.code() == 401 || response.code() == 500) && expired -> {
|
||||
forceLogoutAndGoLogin(this@AddCustomActionActivity)
|
||||
"Session expired. Please login again."
|
||||
}
|
||||
response.code() == 400 -> runCatching {
|
||||
JsonParser.parseString(raw).asJsonObject["message"].asString
|
||||
}.getOrDefault("Bad request")
|
||||
else -> "${response.code()}, ${response.message()}"
|
||||
}
|
||||
|
||||
showSnack(message)
|
||||
}
|
||||
|
||||
override fun onFailure(
|
||||
call: Call<ArrayList<Pegawai>>,
|
||||
t: Throwable
|
||||
) {
|
||||
showProgressDialog(false)
|
||||
showSnack(t.message.toString())
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
} else {
|
||||
showProgressDialog(false)
|
||||
|
||||
Snackbar.make(findViewById(android.R.id.content),
|
||||
ContextCompat.getString(this@AddCustomActionActivity,
|
||||
R.string.no_internet_message),
|
||||
Snackbar.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
btSend.setOnClickListener {
|
||||
showProgressDialog(true)
|
||||
val semuaKodePegawai: List<String> = selectedRecipients.map { it.kode }
|
||||
|
||||
val newAksi = PostAksi(
|
||||
null, semuaKodePegawai, idTentang, idTipeKomunikasi,
|
||||
tietDescription.text.toString(), null
|
||||
)
|
||||
|
||||
val gson = GsonBuilder()
|
||||
.serializeNulls()
|
||||
.create()
|
||||
|
||||
val jsonData = gson.toJson(newAksi)
|
||||
val dataBody: RequestBody =
|
||||
jsonData.toRequestBody("application/json; charset=utf-8".toMediaType())
|
||||
|
||||
val filesParts: List<MultipartBody.Part> =
|
||||
attachments.map { a ->
|
||||
this.uriToMultipartPart(
|
||||
partName = "files",
|
||||
uri = a.uri,
|
||||
filenameOverride = a.name
|
||||
)
|
||||
}
|
||||
val filesPartsOrNull = filesParts.takeIf { it.isNotEmpty() }
|
||||
APIMain.require().actionServices.add(
|
||||
token = Preferences.getAccessToken(this@AddCustomActionActivity),
|
||||
data = dataBody,
|
||||
files = filesPartsOrNull
|
||||
).enqueue(object : Callback<Message> {
|
||||
override fun onResponse(call: Call<Message>, response: Response<Message>) {
|
||||
if (response.isSuccessful) {
|
||||
showProgressDialog(false)
|
||||
showSnack("Berhasil kirim aksi")
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
showProgressDialog(false)
|
||||
|
||||
val raw = runCatching { response.errorBody()?.string().orEmpty() }.getOrDefault("")
|
||||
val expired = raw.contains("Signature has expired", true) || raw.contains("token_expired", true)
|
||||
|
||||
val message = when {
|
||||
(response.code() == 401 || response.code() == 500) && expired -> {
|
||||
forceLogoutAndGoLogin(this@AddCustomActionActivity)
|
||||
"Session expired. Please login again."
|
||||
}
|
||||
response.code() == 400 -> runCatching {
|
||||
JsonParser.parseString(raw).asJsonObject["message"].asString
|
||||
}.getOrDefault("Bad request")
|
||||
else -> "${response.code()}, ${response.message()}"
|
||||
}
|
||||
|
||||
showSnack(message)
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call<Message>, t: Throwable) {
|
||||
showProgressDialog(false)
|
||||
showSnack(t.message ?: "Gagal kirim")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun showSnack(message: String) {
|
||||
Snackbar.make(findViewById(android.R.id.content), message, Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
private fun showRecipientPickerDialog(allRecipient: ArrayList<Pegawai>) {
|
||||
val userData = Gson().fromJson(Preferences.getUserData(this@AddCustomActionActivity),
|
||||
Pengguna::class.java)
|
||||
|
||||
// ✅ buang pengirim dulu
|
||||
val cleaned = allRecipient.filter { it.kode.trim() != userData.pegawai?.kode }
|
||||
|
||||
if (cleaned.isEmpty()) {
|
||||
showSnack("Daftar penerima kosong")
|
||||
return
|
||||
}
|
||||
|
||||
// Base list: hanya yang belum ada di selectedRecipients
|
||||
val baseList = cleaned.filter { ar ->
|
||||
selectedRecipients.none { it.kode == ar.kode }
|
||||
}
|
||||
|
||||
if (baseList.isEmpty()) {
|
||||
showSnack("Semua penerima sudah dipilih")
|
||||
return
|
||||
}
|
||||
|
||||
val v = layoutInflater.inflate(R.layout.dialog_recipient_picker, null)
|
||||
val rv = v.findViewById<RecyclerView>(R.id.rv_recipient_picker)
|
||||
val sv = v.findViewById<androidx.appcompat.widget.SearchView>(R.id.sv_recipient)
|
||||
|
||||
val dialog = MaterialAlertDialogBuilder(this)
|
||||
.setTitle("Pilih Penerima")
|
||||
.setView(v)
|
||||
.setNegativeButton("Tutup", null)
|
||||
.create()
|
||||
|
||||
val pickerAdapter = RecipientPickerAdapter { picked ->
|
||||
val exists = selectedRecipients.any { it.kode == picked.kode }
|
||||
if (exists) {
|
||||
showSnack("Penerima sudah dipilih")
|
||||
return@RecipientPickerAdapter
|
||||
}
|
||||
|
||||
selectedRecipients.add(picked)
|
||||
renderRecipients()
|
||||
dialog.dismiss()
|
||||
}
|
||||
|
||||
rv.layoutManager = LinearLayoutManager(this)
|
||||
rv.adapter = pickerAdapter
|
||||
rv.setHasFixedSize(true)
|
||||
|
||||
// tampil awal
|
||||
pickerAdapter.submitList(baseList)
|
||||
|
||||
// SEARCH
|
||||
fun applyFilter(query: String?) {
|
||||
val q = query.orEmpty().trim()
|
||||
if (q.isEmpty()) {
|
||||
pickerAdapter.submitList(baseList)
|
||||
return
|
||||
}
|
||||
|
||||
val filtered = baseList.filter { p ->
|
||||
val nama = p.nama
|
||||
val kode = p.kode
|
||||
nama.contains(q, ignoreCase = true) || kode.contains(q, ignoreCase = true)
|
||||
}
|
||||
|
||||
pickerAdapter.submitList(filtered)
|
||||
}
|
||||
|
||||
sv.setOnQueryTextListener(object : androidx.appcompat.widget.SearchView.OnQueryTextListener {
|
||||
override fun onQueryTextSubmit(query: String?): Boolean {
|
||||
applyFilter(query)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onQueryTextChange(newText: String?): Boolean {
|
||||
applyFilter(newText)
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
dialog.show()
|
||||
sv.requestFocus()
|
||||
}
|
||||
|
||||
private fun showAttachmentPickerDialog() {
|
||||
val items = arrayOf("Kamera", "File Manager")
|
||||
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setTitle("Pilih Attachment")
|
||||
.setItems(items) { _, which ->
|
||||
when (which) {
|
||||
0 -> openCameraWithPermission()
|
||||
1 -> openFileManager()
|
||||
}
|
||||
}
|
||||
.setNegativeButton("Batal", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun openCamera() {
|
||||
val imageFile = File.createTempFile(
|
||||
"ATTACH_",
|
||||
".jpg",
|
||||
cacheDir
|
||||
)
|
||||
|
||||
val uri = FileProvider.getUriForFile(
|
||||
this,
|
||||
"${packageName}.fileprovider",
|
||||
imageFile
|
||||
)
|
||||
|
||||
cameraOutputUri = uri
|
||||
takePictureLauncher.launch(uri)
|
||||
}
|
||||
|
||||
private fun openFileManager() {
|
||||
pickFileLauncher.launch(arrayOf("image/*", "application/pdf"))
|
||||
}
|
||||
|
||||
private fun addAttachment(uri: Uri) {
|
||||
if (attachments.any { it.uri == uri }) {
|
||||
showSnack("Lampiran sudah ditambahkan")
|
||||
return
|
||||
}
|
||||
val mime = contentResolver.getType(uri).orEmpty()
|
||||
val name = queryDisplayName(uri) ?: "Attachment"
|
||||
attachments.add(AttachmentItem(uri, name, mime))
|
||||
renderAttachments()
|
||||
}
|
||||
|
||||
private fun renderAttachments() {
|
||||
val hasData = attachments.isNotEmpty()
|
||||
llAttachmentsContainer.visibility = if (hasData) View.VISIBLE else View.GONE
|
||||
|
||||
attachmentAdapter.submitList(attachments.toList())
|
||||
rvAttachments.visibility = if (hasData) View.VISIBLE else View.GONE
|
||||
|
||||
val count = attachments.size
|
||||
btAttachment.text = if (count > 0) "Lampiran ($count)" else getString(R.string.lbl_lampiran)
|
||||
}
|
||||
|
||||
private fun queryDisplayName(uri: Uri): String? {
|
||||
val projection = arrayOf(android.provider.OpenableColumns.DISPLAY_NAME)
|
||||
return runCatching {
|
||||
contentResolver.query(uri, projection, null, null, null)?.use { cursor ->
|
||||
val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
|
||||
if (nameIndex == -1) return@use null
|
||||
cursor.moveToFirst()
|
||||
cursor.getString(nameIndex)
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,989 @@
|
||||
package com.amz.genie.activities
|
||||
|
||||
import android.app.DatePickerDialog
|
||||
import android.app.TimePickerDialog
|
||||
import android.os.Bundle
|
||||
import android.provider.OpenableColumns
|
||||
import android.text.InputType
|
||||
import android.view.Gravity
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.Button
|
||||
import android.widget.ImageButton
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.amz.genie.R
|
||||
import com.amz.genie.adapters.RecipientAdapter
|
||||
import com.amz.genie.adapters.RecipientPickerAdapter
|
||||
import com.amz.genie.helpers.Preferences
|
||||
import com.amz.genie.helpers.SimpleTextWatcher
|
||||
import com.amz.genie.helpers.Utils.forceLogoutAndGoLogin
|
||||
import com.amz.genie.helpers.Utils.isNetworkAvailable
|
||||
import com.amz.genie.models.AddActionItem
|
||||
import com.amz.genie.models.FormAttachment
|
||||
import com.amz.genie.models.KomunikasiDetail
|
||||
import com.amz.genie.models.Message
|
||||
import com.amz.genie.models.Pegawai
|
||||
import com.amz.genie.models.Pengguna
|
||||
import com.amz.genie.services.APIMain
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.google.android.material.textfield.TextInputEditText
|
||||
import com.google.android.material.textfield.TextInputLayout
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.JsonParser
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import retrofit2.Call
|
||||
import retrofit2.Callback
|
||||
import retrofit2.Response
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
import kotlin.collections.emptyList
|
||||
|
||||
class AddTemplateActionActivity : BaseActivity() {
|
||||
|
||||
private lateinit var ibBack: ImageButton
|
||||
private lateinit var tvKomunikasi: TextView
|
||||
private lateinit var rvRecipient: RecyclerView
|
||||
private lateinit var tvEmpty: TextView
|
||||
private lateinit var tvAddRecipient: TextView
|
||||
private lateinit var recipientAdapter: RecipientAdapter
|
||||
private lateinit var actionItem: AddActionItem
|
||||
private lateinit var btSend: Button
|
||||
|
||||
private val recipientOptions = mutableListOf<Pegawai>()
|
||||
private val selectedRecipients = mutableListOf<Pegawai>()
|
||||
|
||||
// value form dinamis: Int / Double / String / MutableList<String> / MutableList<FormAttachment>
|
||||
private val formValues = linkedMapOf<Int, Any?>()
|
||||
|
||||
// untuk validasi & setError field angka/pecahan/text/tanggal/waktu/jam/string
|
||||
private val fieldTilByKode = linkedMapOf<Int, TextInputLayout>()
|
||||
private val fieldEtByKode = linkedMapOf<Int, TextInputEditText>()
|
||||
|
||||
// untuk list (error wajib list)
|
||||
private val listInputEtByKode = linkedMapOf<Int, TextInputEditText>()
|
||||
|
||||
// untuk lampiran (tampilan list nama file + error text)
|
||||
private val lampiranTvFilesByKode = linkedMapOf<Int, TextView>()
|
||||
private val lampiranTvErrorByKode = linkedMapOf<Int, TextView>()
|
||||
|
||||
private val sdfDate = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
|
||||
private val sdfDateTime = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
|
||||
private val sdfTime = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
|
||||
|
||||
private var currentLampiranKode: Int? = null
|
||||
|
||||
private val pickLampiranLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris ->
|
||||
val kode = currentLampiranKode ?: return@registerForActivityResult
|
||||
currentLampiranKode = null
|
||||
|
||||
val list = (formValues[kode] as? MutableList<FormAttachment>) ?: mutableListOf()
|
||||
|
||||
for (u in uris) {
|
||||
val meta = readUriMeta(u) ?: continue
|
||||
if (list.any { it.uri == u }) continue // avoid duplicate
|
||||
list.add(meta)
|
||||
}
|
||||
|
||||
formValues[kode] = list
|
||||
|
||||
// refresh UI
|
||||
refreshLampiranUI(kode)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_add_template_action)
|
||||
initUI()
|
||||
}
|
||||
|
||||
private fun initUI() {
|
||||
ibBack = findViewById(R.id.ib_back_add_template_action)
|
||||
tvKomunikasi = findViewById(R.id.tv_komunikasi_add_template_action)
|
||||
rvRecipient = findViewById(R.id.rv_recipient_add_template_action)
|
||||
tvEmpty = findViewById(R.id.tv_empty_add_template_action)
|
||||
tvAddRecipient = findViewById(R.id.tv_lbl_recipient_add_template_action)
|
||||
btSend = findViewById(R.id.bt_send_add_template_action)
|
||||
|
||||
val intentDataJson = intent.getStringExtra("data") ?: return
|
||||
actionItem = Gson().fromJson(intentDataJson, AddActionItem::class.java)
|
||||
|
||||
tvKomunikasi.text = actionItem.title.orEmpty()
|
||||
|
||||
recipientAdapter = RecipientAdapter { pegawai ->
|
||||
val idx = selectedRecipients.indexOfFirst { it.kode == pegawai.kode }
|
||||
if (idx != -1) {
|
||||
selectedRecipients.removeAt(idx)
|
||||
renderRecipients()
|
||||
showSnack("Penerima dihapus")
|
||||
}
|
||||
}
|
||||
|
||||
rvRecipient.layoutManager = LinearLayoutManager(this)
|
||||
rvRecipient.adapter = recipientAdapter
|
||||
|
||||
val details = actionItem.komunikasi_detail.orEmpty()
|
||||
if (details.isNotEmpty()) {
|
||||
renderDynamicForm(details)
|
||||
} else {
|
||||
findViewById<LinearLayout>(R.id.ll_add_template_action).removeAllViews()
|
||||
}
|
||||
|
||||
initData()
|
||||
setupActions()
|
||||
}
|
||||
|
||||
// =========================
|
||||
// Dynamic Form Renderer
|
||||
// =========================
|
||||
private fun renderDynamicForm(details: List<KomunikasiDetail>) {
|
||||
val container = findViewById<LinearLayout>(R.id.ll_add_template_action)
|
||||
|
||||
container.removeAllViews()
|
||||
formValues.clear()
|
||||
fieldTilByKode.clear()
|
||||
fieldEtByKode.clear()
|
||||
listInputEtByKode.clear()
|
||||
lampiranTvFilesByKode.clear()
|
||||
lampiranTvErrorByKode.clear()
|
||||
|
||||
val fields = details
|
||||
.filter { it.is_aktif == 1 }
|
||||
.sortedBy { it.urutan ?: Int.MAX_VALUE }
|
||||
|
||||
val inflater = LayoutInflater.from(this)
|
||||
|
||||
for (d in fields) {
|
||||
val kode = d.kode ?: continue
|
||||
val label = d.isian?.trim().orEmpty().ifBlank { "Field" }
|
||||
val wajib = d.is_wajib == 1
|
||||
val jenisId = d.id_jenis_isian ?: 6 // fallback string
|
||||
|
||||
when (jenisId) {
|
||||
|
||||
// 4 = angka
|
||||
4 -> {
|
||||
val v = inflater.inflate(R.layout.item_dynamic_input, container, false)
|
||||
val til = v.findViewById<TextInputLayout>(R.id.til)
|
||||
val et = v.findViewById<TextInputEditText>(R.id.et)
|
||||
|
||||
til.hint = if (wajib) "$label *" else label
|
||||
et.inputType = InputType.TYPE_CLASS_NUMBER
|
||||
|
||||
fieldTilByKode[kode] = til
|
||||
fieldEtByKode[kode] = et
|
||||
|
||||
et.setText("0")
|
||||
formValues[kode] = 0
|
||||
|
||||
et.addTextChangedListener(SimpleTextWatcher { text ->
|
||||
til.error = null
|
||||
val value = text.trim()
|
||||
formValues[kode] = value.toIntOrNull() ?: 0
|
||||
})
|
||||
|
||||
container.addView(v)
|
||||
}
|
||||
|
||||
// 5 = pecahan
|
||||
5 -> {
|
||||
val v = inflater.inflate(R.layout.item_dynamic_input, container, false)
|
||||
val til = v.findViewById<TextInputLayout>(R.id.til)
|
||||
val et = v.findViewById<TextInputEditText>(R.id.et)
|
||||
|
||||
til.hint = if (wajib) "$label *" else label
|
||||
et.inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL
|
||||
|
||||
fieldTilByKode[kode] = til
|
||||
fieldEtByKode[kode] = et
|
||||
|
||||
et.setText("0.00")
|
||||
formValues[kode] = 0.00
|
||||
|
||||
et.addTextChangedListener(SimpleTextWatcher { text ->
|
||||
til.error = null
|
||||
val value = text.trim().replace(",", ".")
|
||||
formValues[kode] = value.toDoubleOrNull() ?: 0.00
|
||||
})
|
||||
|
||||
container.addView(v)
|
||||
}
|
||||
|
||||
// 2 = tanggal (yyyy-MM-dd)
|
||||
2 -> {
|
||||
val v = inflater.inflate(R.layout.item_dynamic_input, container, false)
|
||||
val til = v.findViewById<TextInputLayout>(R.id.til)
|
||||
val et = v.findViewById<TextInputEditText>(R.id.et)
|
||||
|
||||
til.hint = if (wajib) "$label *" else label
|
||||
fieldTilByKode[kode] = til
|
||||
fieldEtByKode[kode] = et
|
||||
|
||||
setupDateField(kode, et, til)
|
||||
container.addView(v)
|
||||
}
|
||||
|
||||
// 1 = waktu (yyyy-MM-dd HH:mm:ss)
|
||||
1 -> {
|
||||
val v = inflater.inflate(R.layout.item_dynamic_input, container, false)
|
||||
val til = v.findViewById<TextInputLayout>(R.id.til)
|
||||
val et = v.findViewById<TextInputEditText>(R.id.et)
|
||||
|
||||
til.hint = if (wajib) "$label *" else label
|
||||
fieldTilByKode[kode] = til
|
||||
fieldEtByKode[kode] = et
|
||||
|
||||
setupDateTimeField(kode, et, til)
|
||||
container.addView(v)
|
||||
}
|
||||
|
||||
// 3 = jam (HH:mm:ss)
|
||||
3 -> {
|
||||
val v = inflater.inflate(R.layout.item_dynamic_input, container, false)
|
||||
val til = v.findViewById<TextInputLayout>(R.id.til)
|
||||
val et = v.findViewById<TextInputEditText>(R.id.et)
|
||||
|
||||
til.hint = if (wajib) "$label *" else label
|
||||
fieldTilByKode[kode] = til
|
||||
fieldEtByKode[kode] = et
|
||||
|
||||
setupTimeField(kode, et, til)
|
||||
container.addView(v)
|
||||
}
|
||||
|
||||
// 10 = list
|
||||
10 -> {
|
||||
val v = inflater.inflate(R.layout.item_dynamic_list, container, false)
|
||||
val tvLabel = v.findViewById<TextView>(R.id.tv_label)
|
||||
val etItem = v.findViewById<TextInputEditText>(R.id.et_item)
|
||||
val btnAdd = v.findViewById<View>(R.id.btn_add)
|
||||
val tvItems = v.findViewById<TextView>(R.id.tv_items)
|
||||
|
||||
tvLabel.text = if (wajib) "$label *" else label
|
||||
|
||||
val items = mutableListOf<String>()
|
||||
formValues[kode] = items
|
||||
listInputEtByKode[kode] = etItem
|
||||
|
||||
fun refreshItems() {
|
||||
tvItems.text =
|
||||
if (items.isEmpty()) "Belum ada item"
|
||||
else items.joinToString(", ")
|
||||
}
|
||||
|
||||
btnAdd.setOnClickListener {
|
||||
etItem.error = null
|
||||
val t = etItem.text?.toString().orEmpty().trim()
|
||||
if (t.isEmpty()) {
|
||||
etItem.error = "Item tidak boleh kosong"
|
||||
return@setOnClickListener
|
||||
}
|
||||
if (items.any { it.equals(t, true) }) {
|
||||
showSnack("Item sudah ada")
|
||||
return@setOnClickListener
|
||||
}
|
||||
|
||||
items.add(t)
|
||||
etItem.setText("")
|
||||
refreshItems()
|
||||
}
|
||||
|
||||
refreshItems()
|
||||
container.addView(v)
|
||||
}
|
||||
|
||||
// 11 = lampiran (multi)
|
||||
11 -> {
|
||||
val v = inflater.inflate(R.layout.item_dynamic_attachment, container, false)
|
||||
val tvLabel = v.findViewById<TextView>(R.id.tv_label)
|
||||
val btnPick = v.findViewById<View>(R.id.btn_pick)
|
||||
val btnClear = v.findViewById<View>(R.id.btn_clear)
|
||||
val tvFiles = v.findViewById<TextView>(R.id.tv_files)
|
||||
val tvError = v.findViewById<TextView>(R.id.tv_error)
|
||||
|
||||
tvLabel.text = if (wajib) "$label *" else label
|
||||
|
||||
val list = mutableListOf<FormAttachment>()
|
||||
formValues[kode] = list
|
||||
|
||||
lampiranTvFilesByKode[kode] = tvFiles
|
||||
lampiranTvErrorByKode[kode] = tvError
|
||||
|
||||
btnPick.setOnClickListener {
|
||||
tvError.visibility = View.GONE
|
||||
currentLampiranKode = kode
|
||||
pickLampiranLauncher.launch(arrayOf("*/*"))
|
||||
}
|
||||
|
||||
btnClear.setOnClickListener {
|
||||
list.clear()
|
||||
formValues[kode] = list
|
||||
refreshLampiranUI(kode)
|
||||
}
|
||||
|
||||
refreshLampiranUI(kode)
|
||||
container.addView(v)
|
||||
}
|
||||
|
||||
// 0 = teks (multiline)
|
||||
0 -> {
|
||||
val v = inflater.inflate(R.layout.item_dynamic_input, container, false)
|
||||
val til = v.findViewById<TextInputLayout>(R.id.til)
|
||||
val et = v.findViewById<TextInputEditText>(R.id.et)
|
||||
|
||||
til.hint = if (wajib) "$label *" else label
|
||||
|
||||
et.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_MULTI_LINE
|
||||
et.minLines = 3
|
||||
et.maxLines = 8
|
||||
et.setSingleLine(false)
|
||||
et.setHorizontallyScrolling(false)
|
||||
et.gravity = Gravity.TOP
|
||||
|
||||
fieldTilByKode[kode] = til
|
||||
fieldEtByKode[kode] = et
|
||||
|
||||
et.addTextChangedListener(SimpleTextWatcher { text ->
|
||||
til.error = null
|
||||
formValues[kode] = text
|
||||
})
|
||||
|
||||
container.addView(v)
|
||||
}
|
||||
|
||||
// 6 = string (single line) + fallback default
|
||||
else -> {
|
||||
val v = inflater.inflate(R.layout.item_dynamic_input, container, false)
|
||||
val til = v.findViewById<TextInputLayout>(R.id.til)
|
||||
val et = v.findViewById<TextInputEditText>(R.id.et)
|
||||
|
||||
til.hint = if (wajib) "$label *" else label
|
||||
et.inputType = InputType.TYPE_CLASS_TEXT
|
||||
|
||||
fieldTilByKode[kode] = til
|
||||
fieldEtByKode[kode] = et
|
||||
|
||||
et.addTextChangedListener(SimpleTextWatcher { text ->
|
||||
til.error = null
|
||||
formValues[kode] = text
|
||||
})
|
||||
|
||||
container.addView(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshLampiranUI(kode: Int) {
|
||||
val tvFiles = lampiranTvFilesByKode[kode] ?: return
|
||||
val list = (formValues[kode] as? List<FormAttachment>).orEmpty()
|
||||
|
||||
tvFiles.text = if (list.isEmpty()) {
|
||||
"Belum ada file"
|
||||
} else {
|
||||
list.joinToString("\n") { "• ${it.fileName}" }
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupDateField(kode: Int, et: TextInputEditText, til: TextInputLayout) {
|
||||
et.isFocusable = false
|
||||
et.isFocusableInTouchMode = false
|
||||
et.isClickable = true
|
||||
|
||||
val today = sdfDate.format(Calendar.getInstance().time)
|
||||
if (et.text.isNullOrBlank()) et.setText(today)
|
||||
formValues[kode] = et.text?.toString().orEmpty()
|
||||
|
||||
fun openPicker() {
|
||||
val cal = Calendar.getInstance()
|
||||
|
||||
val currentText = et.text?.toString().orEmpty().trim()
|
||||
if (currentText.isNotEmpty()) {
|
||||
runCatching {
|
||||
sdfDate.isLenient = false
|
||||
cal.time = sdfDate.parse(currentText)!!
|
||||
}
|
||||
}
|
||||
|
||||
DatePickerDialog(
|
||||
this,
|
||||
{ _, y, m, d ->
|
||||
val picked = String.format(Locale.getDefault(), "%04d-%02d-%02d", y, m + 1, d)
|
||||
til.error = null
|
||||
et.setText(picked)
|
||||
formValues[kode] = picked
|
||||
},
|
||||
cal.get(Calendar.YEAR),
|
||||
cal.get(Calendar.MONTH),
|
||||
cal.get(Calendar.DAY_OF_MONTH)
|
||||
).show()
|
||||
}
|
||||
|
||||
et.setOnClickListener { openPicker() }
|
||||
et.setOnFocusChangeListener { _, hasFocus -> if (hasFocus) openPicker() }
|
||||
}
|
||||
|
||||
private fun setupTimeField(kode: Int, et: TextInputEditText, til: TextInputLayout) {
|
||||
et.isFocusable = false
|
||||
et.isFocusableInTouchMode = false
|
||||
et.isClickable = true
|
||||
|
||||
val now = Calendar.getInstance()
|
||||
val def = String.format(
|
||||
Locale.getDefault(),
|
||||
"%02d:%02d:%02d",
|
||||
now.get(Calendar.HOUR_OF_DAY),
|
||||
now.get(Calendar.MINUTE),
|
||||
0
|
||||
)
|
||||
if (et.text.isNullOrBlank()) et.setText(def)
|
||||
formValues[kode] = et.text?.toString().orEmpty()
|
||||
|
||||
fun openPicker() {
|
||||
val cal = Calendar.getInstance()
|
||||
val current = et.text?.toString().orEmpty().trim()
|
||||
if (current.isNotEmpty()) {
|
||||
val parts = current.split(":")
|
||||
if (parts.size >= 2) {
|
||||
cal.set(Calendar.HOUR_OF_DAY, parts[0].toIntOrNull() ?: cal.get(Calendar.HOUR_OF_DAY))
|
||||
cal.set(Calendar.MINUTE, parts[1].toIntOrNull() ?: cal.get(Calendar.MINUTE))
|
||||
cal.set(Calendar.SECOND, parts.getOrNull(2)?.toIntOrNull() ?: 0)
|
||||
}
|
||||
}
|
||||
|
||||
TimePickerDialog(
|
||||
this,
|
||||
{ _, h, m ->
|
||||
val picked = String.format(Locale.getDefault(), "%02d:%02d:%02d", h, m, 0)
|
||||
til.error = null
|
||||
et.setText(picked)
|
||||
formValues[kode] = picked
|
||||
},
|
||||
cal.get(Calendar.HOUR_OF_DAY),
|
||||
cal.get(Calendar.MINUTE),
|
||||
true
|
||||
).show()
|
||||
}
|
||||
|
||||
et.setOnClickListener { openPicker() }
|
||||
et.setOnFocusChangeListener { _, hasFocus -> if (hasFocus) openPicker() }
|
||||
}
|
||||
|
||||
private fun setupDateTimeField(kode: Int, et: TextInputEditText, til: TextInputLayout) {
|
||||
et.isFocusable = false
|
||||
et.isFocusableInTouchMode = false
|
||||
et.isClickable = true
|
||||
|
||||
val nowStr = sdfDateTime.format(Calendar.getInstance().time)
|
||||
if (et.text.isNullOrBlank()) et.setText(nowStr)
|
||||
formValues[kode] = et.text?.toString().orEmpty()
|
||||
|
||||
fun openDateThenTime() {
|
||||
val cal = Calendar.getInstance()
|
||||
|
||||
val currentText = et.text?.toString().orEmpty().trim()
|
||||
if (currentText.isNotEmpty()) {
|
||||
runCatching {
|
||||
sdfDateTime.isLenient = false
|
||||
cal.time = sdfDateTime.parse(currentText)!!
|
||||
}
|
||||
}
|
||||
|
||||
DatePickerDialog(
|
||||
this,
|
||||
{ _, y, m, d ->
|
||||
TimePickerDialog(
|
||||
this,
|
||||
{ _, hh, mm ->
|
||||
cal.set(Calendar.YEAR, y)
|
||||
cal.set(Calendar.MONTH, m)
|
||||
cal.set(Calendar.DAY_OF_MONTH, d)
|
||||
cal.set(Calendar.HOUR_OF_DAY, hh)
|
||||
cal.set(Calendar.MINUTE, mm)
|
||||
cal.set(Calendar.SECOND, 0)
|
||||
|
||||
val picked = sdfDateTime.format(cal.time)
|
||||
til.error = null
|
||||
et.setText(picked)
|
||||
formValues[kode] = picked
|
||||
},
|
||||
cal.get(Calendar.HOUR_OF_DAY),
|
||||
cal.get(Calendar.MINUTE),
|
||||
true
|
||||
).show()
|
||||
},
|
||||
cal.get(Calendar.YEAR),
|
||||
cal.get(Calendar.MONTH),
|
||||
cal.get(Calendar.DAY_OF_MONTH)
|
||||
).show()
|
||||
}
|
||||
|
||||
et.setOnClickListener { openDateThenTime() }
|
||||
et.setOnFocusChangeListener { _, hasFocus -> if (hasFocus) openDateThenTime() }
|
||||
}
|
||||
|
||||
// =========================
|
||||
// Validasi Form Dinamis (PAKAI ID JENIS)
|
||||
// =========================
|
||||
private fun validateDynamicForm(details: List<KomunikasiDetail>): Boolean {
|
||||
val fields = details
|
||||
.filter { it.is_aktif == 1 }
|
||||
.sortedBy { it.urutan ?: Int.MAX_VALUE }
|
||||
|
||||
// clear error
|
||||
fieldTilByKode.values.forEach { it.error = null }
|
||||
listInputEtByKode.values.forEach { it.error = null }
|
||||
lampiranTvErrorByKode.values.forEach {
|
||||
it.text = ""
|
||||
it.visibility = View.GONE
|
||||
}
|
||||
|
||||
for (d in fields) {
|
||||
if (d.is_wajib != 1) continue
|
||||
|
||||
val kode = d.kode ?: continue
|
||||
val label = d.isian?.trim().orEmpty().ifBlank { "Field" }
|
||||
val jenisId = d.id_jenis_isian ?: continue
|
||||
val value = formValues[kode]
|
||||
|
||||
val valid = when (jenisId) {
|
||||
4 -> value is Int // angka (default 0 valid)
|
||||
5 -> value is Double // pecahan (default 0.00 valid)
|
||||
2 -> (value as? String)?.isNotBlank() == true // tanggal
|
||||
1 -> { // waktu yyyy-MM-dd HH:mm:ss
|
||||
val s = (value as? String).orEmpty().trim()
|
||||
s.isNotBlank() && runCatching {
|
||||
sdfDateTime.isLenient = false
|
||||
sdfDateTime.parse(s) != null
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
3 -> { // jam HH:mm:ss
|
||||
val s = (value as? String).orEmpty().trim()
|
||||
s.isNotBlank() && Regex("""^\d{2}:\d{2}:\d{2}$""").matches(s)
|
||||
}
|
||||
10 -> (value as? List<*>)?.isNotEmpty() == true // list
|
||||
11 -> (value as? List<*>)?.isNotEmpty() == true // lampiran
|
||||
0, 6 -> (value as? String)?.isNotBlank() == true // teks/string
|
||||
else -> (value as? String)?.isNotBlank() == true
|
||||
}
|
||||
|
||||
if (!valid) {
|
||||
when (jenisId) {
|
||||
10 -> listInputEtByKode[kode]?.error = "Wajib diisi"
|
||||
11 -> {
|
||||
lampiranTvErrorByKode[kode]?.apply {
|
||||
text = "Wajib lampirkan minimal 1 file"
|
||||
visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
else -> fieldTilByKode[kode]?.error = "Wajib diisi"
|
||||
}
|
||||
showSnack("Field wajib belum diisi: $label")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// =========================
|
||||
// Build komunikasiDetail payload (sesuai ID)
|
||||
// =========================
|
||||
private fun buildKomunikasiDetailPayload(details: List<KomunikasiDetail>): List<Map<String, Any?>> {
|
||||
val fields = details
|
||||
.filter { it.is_aktif == 1 }
|
||||
.sortedBy { it.urutan ?: Int.MAX_VALUE }
|
||||
|
||||
return fields.mapNotNull { d ->
|
||||
val kode = d.kode ?: return@mapNotNull null
|
||||
val jenisId = d.id_jenis_isian ?: return@mapNotNull null
|
||||
|
||||
val rawValue = formValues[kode]
|
||||
|
||||
val isianForServer: Any? = when (jenisId) {
|
||||
4 -> ((rawValue as? Int) ?: 0).toString()
|
||||
|
||||
5 -> {
|
||||
val v = (rawValue as? Double) ?: 0.0
|
||||
String.format(Locale.US, "%.2f", v)
|
||||
}
|
||||
|
||||
2 -> (rawValue as? String).orEmpty() // yyyy-MM-dd
|
||||
|
||||
1 -> (rawValue as? String).orEmpty() // yyyy-MM-dd HH:mm:ss
|
||||
|
||||
3 -> (rawValue as? String).orEmpty() // HH:mm:ss
|
||||
|
||||
10 -> { // list => JSON [{"text":"a"},...]
|
||||
val items = rawValue as? List<*>
|
||||
val arr = items.orEmpty()
|
||||
.mapNotNull { it?.toString()?.trim() }
|
||||
.filter { it.isNotBlank() }
|
||||
.map { mapOf("text" to it) }
|
||||
Gson().toJson(arr)
|
||||
}
|
||||
|
||||
11 -> {
|
||||
// lampiran => isian metadata JSON (opsional), file fisiknya via multipart
|
||||
val atts = (rawValue as? List<FormAttachment>).orEmpty()
|
||||
val meta = atts.map { mapOf("name" to it.fileName, "mime" to it.mimeType) }
|
||||
Gson().toJson(meta)
|
||||
}
|
||||
|
||||
0, 6 -> rawValue?.toString() // teks (multiline) / string
|
||||
else -> rawValue?.toString()
|
||||
}
|
||||
|
||||
mapOf(
|
||||
"kode" to kode,
|
||||
"id_jenis_isian" to jenisId,
|
||||
"isian" to isianForServer
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildJsonDataBody(
|
||||
actionItem: AddActionItem,
|
||||
selectedRecipients: List<Pegawai>,
|
||||
komunikasiDetail: List<Map<String, Any?>>
|
||||
): RequestBody {
|
||||
val payload = mapOf(
|
||||
"kode" to actionItem.id,
|
||||
"tentang" to actionItem.idTentang,
|
||||
"topic" to actionItem.idKomunikasi,
|
||||
"uraian" to "",
|
||||
"kepada" to selectedRecipients.map { it.kode },
|
||||
"komunikasiDetail" to komunikasiDetail
|
||||
)
|
||||
|
||||
val json = Gson().toJson(payload)
|
||||
return json.toRequestBody("text/plain".toMediaType())
|
||||
}
|
||||
|
||||
// =========================
|
||||
// Lampiran -> Multipart
|
||||
// =========================
|
||||
private fun collectLampiranParts(): List<MultipartBody.Part> {
|
||||
val parts = mutableListOf<MultipartBody.Part>()
|
||||
formValues.forEach { (kodeDetail, v) ->
|
||||
val atts = (v as? List<*>)?.filterIsInstance<FormAttachment>().orEmpty()
|
||||
for (att in atts) {
|
||||
val part = uriToMultipart(
|
||||
uri = att.uri,
|
||||
fileName = att.fileName,
|
||||
mimeType = att.mimeType,
|
||||
fieldName = "isian_$kodeDetail" // ✅ penting!
|
||||
)
|
||||
if (part != null) parts.add(part)
|
||||
}
|
||||
}
|
||||
|
||||
return parts
|
||||
}
|
||||
|
||||
private fun uriToMultipart(
|
||||
uri: android.net.Uri,
|
||||
fileName: String,
|
||||
mimeType: String,
|
||||
fieldName: String
|
||||
): MultipartBody.Part? {
|
||||
return runCatching {
|
||||
val input = contentResolver.openInputStream(uri) ?: return null
|
||||
|
||||
val outFile = File(cacheDir, "${System.currentTimeMillis()}_$fileName")
|
||||
outFile.outputStream().use { out ->
|
||||
input.use { it.copyTo(out) }
|
||||
}
|
||||
|
||||
val reqBody = outFile.asRequestBody(mimeType.toMediaType())
|
||||
MultipartBody.Part.createFormData(fieldName, fileName, reqBody)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun readUriMeta(uri: android.net.Uri): FormAttachment? {
|
||||
val cr = contentResolver
|
||||
val mime = cr.getType(uri) ?: "application/octet-stream"
|
||||
|
||||
var name: String? = null
|
||||
cr.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { c ->
|
||||
if (c.moveToFirst()) {
|
||||
name = c.getString(0)
|
||||
}
|
||||
}
|
||||
val fileName = name ?: "attachment"
|
||||
|
||||
return FormAttachment(uri = uri, fileName = fileName, mimeType = mime)
|
||||
}
|
||||
|
||||
// =========================
|
||||
// Existing stuff
|
||||
// =========================
|
||||
private fun initData() {
|
||||
val idTentang = actionItem.idTentang
|
||||
val idTipeKomunikasi = actionItem.idKomunikasi
|
||||
val userData = Gson().fromJson(Preferences.getUserData(this@AddTemplateActionActivity),
|
||||
Pengguna::class.java)
|
||||
|
||||
APIMain.require().selectionServices.recipients(
|
||||
Preferences.getAccessToken(this), idTipeKomunikasi, idTentang
|
||||
).enqueue(object : Callback<ArrayList<Pegawai>> {
|
||||
override fun onResponse(
|
||||
call: Call<ArrayList<Pegawai>>,
|
||||
response: Response<ArrayList<Pegawai>>
|
||||
) {
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body().orEmpty()
|
||||
|
||||
// ✅ buang pengirim dari list
|
||||
val myKode = userData.pegawai?.kode?.trim().orEmpty()
|
||||
val filtered = body.filter { p ->
|
||||
p.kode.trim() != myKode
|
||||
}
|
||||
|
||||
recipientOptions.clear()
|
||||
recipientOptions.addAll(filtered)
|
||||
|
||||
selectedRecipients.clear()
|
||||
selectedRecipients.addAll(filtered)
|
||||
renderRecipients()
|
||||
return
|
||||
}
|
||||
|
||||
val raw = runCatching { response.errorBody()?.string().orEmpty() }.getOrDefault("")
|
||||
val expired = raw.contains("Signature has expired", true) || raw.contains("token_expired", true)
|
||||
|
||||
val message = when {
|
||||
(response.code() == 401 || response.code() == 500) && expired -> {
|
||||
forceLogoutAndGoLogin(this@AddTemplateActionActivity)
|
||||
"Session expired. Please login again."
|
||||
}
|
||||
response.code() == 400 -> runCatching {
|
||||
JsonParser.parseString(raw).asJsonObject["message"].asString
|
||||
}.getOrDefault("Bad request")
|
||||
else -> "${response.code()}, ${response.message()}"
|
||||
}
|
||||
|
||||
showSnack(message)
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call<ArrayList<Pegawai>>, t: Throwable) {
|
||||
showSnack(t.message.toString())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun setupActions() {
|
||||
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
handleBackPress(0)
|
||||
}
|
||||
})
|
||||
|
||||
ibBack.setOnClickListener { handleBackPress(0) }
|
||||
|
||||
tvAddRecipient.setOnClickListener {
|
||||
if (isNetworkAvailable(this)) {
|
||||
showProgressDialog(true)
|
||||
APIMain.require().selectionServices.allRecipient(
|
||||
Preferences.getAccessToken(this)
|
||||
).enqueue(object : Callback<ArrayList<Pegawai>> {
|
||||
override fun onResponse(
|
||||
call: Call<ArrayList<Pegawai>>,
|
||||
response: Response<ArrayList<Pegawai>>
|
||||
) {
|
||||
showProgressDialog(false)
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body().orEmpty()
|
||||
|
||||
val userData = Gson().fromJson(Preferences.getUserData(this@AddTemplateActionActivity),
|
||||
Pengguna::class.java)
|
||||
|
||||
val filtered = body.filter { p ->
|
||||
p.kode.trim() != userData.pegawai?.kode
|
||||
}
|
||||
|
||||
showRecipientPickerDialog(ArrayList(filtered))
|
||||
return
|
||||
}
|
||||
|
||||
val raw = runCatching { response.errorBody()?.string().orEmpty() }.getOrDefault("")
|
||||
val expired = raw.contains("Signature has expired", true) || raw.contains("token_expired", true)
|
||||
|
||||
val message = when {
|
||||
(response.code() == 401 || response.code() == 500) && expired -> {
|
||||
forceLogoutAndGoLogin(this@AddTemplateActionActivity)
|
||||
"Session expired. Please login again."
|
||||
}
|
||||
response.code() == 400 -> runCatching {
|
||||
JsonParser.parseString(raw).asJsonObject["message"].asString
|
||||
}.getOrDefault("Bad request")
|
||||
else -> "${response.code()}, ${response.message()}"
|
||||
}
|
||||
|
||||
showSnack(message)
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call<ArrayList<Pegawai>>, t: Throwable) {
|
||||
showProgressDialog(false)
|
||||
showSnack(t.message.toString())
|
||||
}
|
||||
})
|
||||
|
||||
} else {
|
||||
showProgressDialog(false)
|
||||
Snackbar.make(
|
||||
findViewById(android.R.id.content),
|
||||
ContextCompat.getString(this@AddTemplateActionActivity, R.string.no_internet_message),
|
||||
Snackbar.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
btSend.setOnClickListener {
|
||||
val details = actionItem.komunikasi_detail.orEmpty()
|
||||
if (details.isEmpty()) {
|
||||
showSnack("Tidak ada form yang bisa dikirim")
|
||||
return@setOnClickListener
|
||||
}
|
||||
|
||||
if (!validateDynamicForm(details)) return@setOnClickListener
|
||||
|
||||
if (selectedRecipients.isEmpty()) {
|
||||
selectedRecipients.add(Pegawai(
|
||||
kode = "0000000000000000",
|
||||
nama = "Genie",
|
||||
outlet = null,
|
||||
jabatan = null,
|
||||
mulai_bekerja = "0000-00-000",
|
||||
id_kelamin = "L",
|
||||
outlets = null
|
||||
))
|
||||
}
|
||||
|
||||
val komunikasiDetail = buildKomunikasiDetailPayload(details)
|
||||
val dataBody = buildJsonDataBody(actionItem, selectedRecipients, komunikasiDetail)
|
||||
val fileParts = collectLampiranParts()
|
||||
|
||||
APIMain.require().actionServices.add(
|
||||
token = Preferences.getAccessToken(this),
|
||||
data = dataBody,
|
||||
files = fileParts
|
||||
).enqueue(object : Callback<Message> {
|
||||
override fun onResponse(call: Call<Message>, response: Response<Message>) {
|
||||
if (response.isSuccessful) {
|
||||
showSnack(response.body()?.message ?: "Berhasil")
|
||||
finish()
|
||||
return
|
||||
}
|
||||
val raw = runCatching { response.errorBody()?.string().orEmpty() }.getOrDefault("")
|
||||
showSnack(raw.ifBlank { "${response.code()} ${response.message()}" })
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call<Message>, t: Throwable) {
|
||||
showSnack(t.message ?: "Gagal")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderRecipients() {
|
||||
val hasData = selectedRecipients.isNotEmpty()
|
||||
rvRecipient.visibility = if (hasData) View.VISIBLE else View.GONE
|
||||
tvEmpty.visibility = if (hasData) View.GONE else View.VISIBLE
|
||||
recipientAdapter.submitList(selectedRecipients.toList())
|
||||
}
|
||||
|
||||
private fun showSnack(message: String) {
|
||||
Snackbar.make(findViewById(android.R.id.content), message, Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
private fun showRecipientPickerDialog(allRecipient: ArrayList<Pegawai>) {
|
||||
val userData = Gson().fromJson(Preferences.getUserData(this@AddTemplateActionActivity),
|
||||
Pengguna::class.java)
|
||||
|
||||
val cleaned = allRecipient.filter { it.kode.trim() != userData.pegawai?.kode }
|
||||
if (cleaned.isEmpty()) {
|
||||
showSnack("Daftar penerima kosong")
|
||||
return
|
||||
}
|
||||
|
||||
val baseList = cleaned.filter { ar ->
|
||||
selectedRecipients.none { it.kode == ar.kode }
|
||||
}
|
||||
|
||||
if (baseList.isEmpty()) {
|
||||
showSnack("Semua penerima sudah dipilih")
|
||||
return
|
||||
}
|
||||
|
||||
val v = layoutInflater.inflate(R.layout.dialog_recipient_picker, null)
|
||||
val rv = v.findViewById<RecyclerView>(R.id.rv_recipient_picker)
|
||||
val sv = v.findViewById<androidx.appcompat.widget.SearchView>(R.id.sv_recipient)
|
||||
|
||||
val dialog = MaterialAlertDialogBuilder(this)
|
||||
.setTitle("Pilih Penerima")
|
||||
.setView(v)
|
||||
.setNegativeButton("Tutup", null)
|
||||
.create()
|
||||
|
||||
val pickerAdapter = RecipientPickerAdapter { picked ->
|
||||
val exists = selectedRecipients.any { it.kode == picked.kode }
|
||||
if (exists) {
|
||||
showSnack("Penerima sudah dipilih")
|
||||
return@RecipientPickerAdapter
|
||||
}
|
||||
|
||||
selectedRecipients.add(picked)
|
||||
renderRecipients()
|
||||
dialog.dismiss()
|
||||
}
|
||||
|
||||
rv.layoutManager = LinearLayoutManager(this)
|
||||
rv.adapter = pickerAdapter
|
||||
rv.setHasFixedSize(true)
|
||||
|
||||
pickerAdapter.submitList(baseList)
|
||||
|
||||
fun applyFilter(query: String?) {
|
||||
val q = query.orEmpty().trim()
|
||||
if (q.isEmpty()) {
|
||||
pickerAdapter.submitList(baseList)
|
||||
return
|
||||
}
|
||||
|
||||
val filtered = baseList.filter { p ->
|
||||
p.nama.contains(q, ignoreCase = true) || p.kode.contains(q, ignoreCase = true)
|
||||
}
|
||||
|
||||
pickerAdapter.submitList(filtered)
|
||||
}
|
||||
|
||||
sv.setOnQueryTextListener(object : androidx.appcompat.widget.SearchView.OnQueryTextListener {
|
||||
override fun onQueryTextSubmit(query: String?): Boolean {
|
||||
applyFilter(query)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onQueryTextChange(newText: String?): Boolean {
|
||||
applyFilter(newText)
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
dialog.show()
|
||||
sv.requestFocus()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.amz.genie.activities
|
||||
|
||||
import android.os.Bundle
|
||||
import android.widget.ImageButton
|
||||
import android.widget.TextView
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import com.amz.genie.R
|
||||
import com.bumptech.glide.Glide
|
||||
import com.google.android.material.imageview.ShapeableImageView
|
||||
import java.io.File
|
||||
|
||||
class AttachmentPreviewActivity : BaseActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_attachment_preview)
|
||||
|
||||
val localPath = intent.getStringExtra("local_path").orEmpty()
|
||||
val title = intent.getStringExtra("title").orEmpty()
|
||||
|
||||
val ib = findViewById<ImageButton>(R.id.ib_back_attachment_preview)
|
||||
val iv = findViewById<ShapeableImageView>(R.id.iv_attachment_preview)
|
||||
val tv = findViewById<TextView>(R.id.tv_title_attachment_preview)
|
||||
|
||||
tv.text = title.ifBlank { "Lampiran" }
|
||||
|
||||
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
handleBackPress(0)
|
||||
}
|
||||
})
|
||||
|
||||
ib.setOnClickListener { handleBackPress(0) }
|
||||
|
||||
// ✅ load dari file lokal
|
||||
Glide.with(this)
|
||||
.load(File(localPath))
|
||||
.into(iv)
|
||||
}
|
||||
}
|
||||
120
app/src/main/java/com/amz/genie/activities/BaseActivity.kt
Normal file
120
app/src/main/java/com/amz/genie/activities/BaseActivity.kt
Normal file
@@ -0,0 +1,120 @@
|
||||
package com.amz.genie.activities
|
||||
|
||||
import android.Manifest
|
||||
import android.app.ActivityOptions
|
||||
import android.app.Dialog
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import android.widget.RelativeLayout
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.airbnb.lottie.LottieAnimationView
|
||||
import com.amz.genie.R
|
||||
|
||||
open class BaseActivity: AppCompatActivity() {
|
||||
|
||||
private var showLoadingDialog: Dialog? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
if (showLoadingDialog == null) {
|
||||
showLoadingDialog = Dialog(this)
|
||||
showLoadingDialog!!.window!!.setBackgroundDrawable(0.toDrawable())
|
||||
showLoadingDialog!!.setContentView(R.layout.loading_dialog)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadFragment(fragment: Fragment) {
|
||||
val transaction = supportFragmentManager.beginTransaction()
|
||||
transaction.replace(R.id.frameLayout_Container, fragment)
|
||||
transaction.commit()
|
||||
}
|
||||
|
||||
fun showProgressDialog(show: Boolean, type: Int = 1) {
|
||||
when {
|
||||
show -> {
|
||||
if (!isFinishing) {
|
||||
|
||||
val lottie = showLoadingDialog
|
||||
?.findViewById<LottieAnimationView>(R.id.lottieLoading)
|
||||
|
||||
val fileName = when (type) {
|
||||
1 -> "lottie/loader_circle.json"
|
||||
2 -> "lottie/loader_send.json"
|
||||
else -> "lottie/loading.json"
|
||||
}
|
||||
|
||||
lottie?.apply {
|
||||
setAnimation(fileName)
|
||||
repeatCount = com.airbnb.lottie.LottieDrawable.INFINITE
|
||||
playAnimation()
|
||||
}
|
||||
|
||||
showLoadingDialog?.setCanceledOnTouchOutside(false)
|
||||
if (showLoadingDialog?.isShowing != true) {
|
||||
showLoadingDialog?.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
try {
|
||||
if (showLoadingDialog?.isShowing == true && !isFinishing) {
|
||||
showLoadingDialog?.dismiss()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun navigateTo(target: Class<*>, enterAnim: Int, exitAnim: Int) {
|
||||
startActivity(Intent(this@BaseActivity, target),
|
||||
ActivityOptions.makeCustomAnimation(this@BaseActivity,
|
||||
enterAnim, exitAnim).toBundle())
|
||||
}
|
||||
|
||||
fun arePushNotificationsEnabled(context: Context): Boolean {
|
||||
val enabledByApp = NotificationManagerCompat.from(context).areNotificationsEnabled()
|
||||
|
||||
if (!enabledByApp) return false
|
||||
|
||||
// Android 13+ also needs POST_NOTIFICATIONS permission
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.POST_NOTIFICATIONS
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fun setSearchClick(listener: View.OnClickListener?, ibSearch: ImageView) {
|
||||
ibSearch.setOnClickListener(listener)
|
||||
}
|
||||
|
||||
fun handleBackPress(stat: Int = 0) {
|
||||
if (stat == 5) {
|
||||
navigateTo(LoginActivity::class.java, R.anim.right_in, R.anim.left_out)
|
||||
}
|
||||
|
||||
finish()
|
||||
overridePendingTransition(R.anim.left_in, R.anim.right_out)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
package com.amz.genie.activities
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.amz.genie.R
|
||||
import com.amz.genie.adapters.GeneralDetailAdapter
|
||||
import com.amz.genie.helpers.Preferences
|
||||
import com.amz.genie.helpers.Utils.forceLogoutAndGoLogin
|
||||
import com.amz.genie.helpers.Utils.isFemale
|
||||
import com.amz.genie.helpers.Utils.isNetworkAvailable
|
||||
import com.amz.genie.models.GeneralDetailResponse
|
||||
import com.amz.genie.models.GeneralThreadItem
|
||||
import com.amz.genie.models.Message
|
||||
import com.amz.genie.models.Pengguna
|
||||
import com.amz.genie.services.APIMain
|
||||
import com.google.android.material.imageview.ShapeableImageView
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.JsonParser
|
||||
import retrofit2.Call
|
||||
import retrofit2.Callback
|
||||
import retrofit2.Response
|
||||
|
||||
class GeneralDetailActivity : BaseActivity() {
|
||||
|
||||
private lateinit var ivBack: ImageView
|
||||
private lateinit var sivUser: ShapeableImageView
|
||||
private lateinit var tvName: TextView
|
||||
private lateinit var tvJobDesk: TextView
|
||||
private lateinit var tvOutlet: TextView
|
||||
private lateinit var rvgeneralDetail: RecyclerView
|
||||
|
||||
private lateinit var adapter: GeneralDetailAdapter
|
||||
|
||||
private val allItems = mutableListOf<GeneralThreadItem>()
|
||||
private var currentPage = 1
|
||||
private val perPage = 30
|
||||
private var hasMore = true
|
||||
private var isLoading = false
|
||||
|
||||
private var counterpartKode: String? = null
|
||||
private var childNeedsRefresh = false
|
||||
|
||||
private val subDetailLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
val needsRefresh = result.data?.getBooleanExtra("needs_refresh", false) ?: false
|
||||
if (result.resultCode == RESULT_OK && needsRefresh) {
|
||||
childNeedsRefresh = true
|
||||
refreshListFromFirstPage()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_general_detail)
|
||||
|
||||
initUI(savedInstanceState)
|
||||
setupActions()
|
||||
}
|
||||
|
||||
private fun initUI(savedInstanceState: Bundle?) {
|
||||
ivBack = findViewById(R.id.ib_back_general_detail)
|
||||
sivUser = findViewById(R.id.siv_employee_general_detail)
|
||||
tvName = findViewById(R.id.tv_employeename_general_detail)
|
||||
tvJobDesk = findViewById(R.id.tv_jobdesk_general_detail)
|
||||
tvOutlet = findViewById(R.id.tv_outlet_general_detail)
|
||||
rvgeneralDetail = findViewById(R.id.rv_general_detail)
|
||||
|
||||
val userData = Gson().fromJson(Preferences.getUserData(this), Pengguna::class.java)
|
||||
val myKodePegawai = userData.pegawai?.kode ?: ""
|
||||
|
||||
adapter = GeneralDetailAdapter(
|
||||
kodePegawai = myKodePegawai,
|
||||
onItemClick = { item ->
|
||||
// ✅ klik item: mark as read dulu kalau unread, baru buka detail
|
||||
markReadIfNeeded(item) { openSubDetail(item) }
|
||||
},
|
||||
onDetailClick = { item ->
|
||||
// ✅ klik tombol detail juga sama
|
||||
markReadIfNeeded(item) { openSubDetail(item) }
|
||||
}
|
||||
)
|
||||
|
||||
rvgeneralDetail.layoutManager = LinearLayoutManager(this)
|
||||
rvgeneralDetail.adapter = adapter
|
||||
rvgeneralDetail.setHasFixedSize(true)
|
||||
|
||||
if (intent.hasExtra("data")) {
|
||||
initData()
|
||||
}
|
||||
}
|
||||
|
||||
private fun bearerToken(): String {
|
||||
val raw = Preferences.getAccessToken(this).orEmpty().trim()
|
||||
// ✅ aman: kalau sudah ada "Bearer " jangan double
|
||||
return if (raw.startsWith("Bearer ", true)) raw else "Bearer $raw"
|
||||
}
|
||||
|
||||
private fun openSubDetail(item: GeneralThreadItem) {
|
||||
val intent = Intent(this, GeneralSubDetailActivity::class.java)
|
||||
val dataJson = Gson().toJson(item, GeneralThreadItem::class.java)
|
||||
intent.putExtra("data", dataJson)
|
||||
intent.putExtra("counterpart", counterpartKode)
|
||||
subDetailLauncher.launch(intent)
|
||||
}
|
||||
|
||||
private fun setupActions() {
|
||||
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
if (childNeedsRefresh) {
|
||||
setResult(RESULT_OK, Intent().putExtra("needs_refresh", true))
|
||||
}
|
||||
handleBackPress(0)
|
||||
}
|
||||
})
|
||||
|
||||
ivBack.setOnClickListener {
|
||||
if (childNeedsRefresh) {
|
||||
setResult(RESULT_OK, Intent().putExtra("needs_refresh", true))
|
||||
}
|
||||
handleBackPress(0)
|
||||
}
|
||||
|
||||
rvgeneralDetail.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
||||
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
||||
super.onScrolled(recyclerView, dx, dy)
|
||||
if (dy <= 0) return
|
||||
|
||||
val lm = recyclerView.layoutManager as? LinearLayoutManager ?: return
|
||||
val totalItemCount = lm.itemCount
|
||||
val lastVisible = lm.findLastVisibleItemPosition()
|
||||
|
||||
// trigger saat tinggal 5 item lagi
|
||||
if (!isLoading && hasMore && totalItemCount > 0 && lastVisible >= totalItemCount - 5) {
|
||||
loadPage(currentPage + 1)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun initData() {
|
||||
val intentDataJson = intent.getStringExtra("data") ?: return
|
||||
val data = Gson().fromJson(intentDataJson, GeneralThreadItem::class.java)
|
||||
|
||||
counterpartKode = data.counterpart
|
||||
|
||||
val tipe = data.tipe.uppercase()
|
||||
val aksi = data.aksi
|
||||
val reaksi = data.reaksi
|
||||
|
||||
if (tipe == "REAKSI" && reaksi != null) {
|
||||
tvName.text = reaksi.pembuat.nama
|
||||
tvJobDesk.text = reaksi.pembuat.jabatan?.nama ?: "-"
|
||||
tvOutlet.text = reaksi.pembuat.outlet?.nama ?: "-"
|
||||
|
||||
val female = isFemale(reaksi.pembuat.id_kelamin)
|
||||
sivUser.setImageResource(if (female) R.drawable.ic_woman else R.drawable.ic_man)
|
||||
} else {
|
||||
tvName.text = aksi.pembuat.nama
|
||||
tvJobDesk.text = aksi.pembuat.jabatan?.nama ?: "-"
|
||||
tvOutlet.text = aksi.pembuat.outlet?.nama ?: "-"
|
||||
|
||||
val female = isFemale(aksi.pembuat.id_kelamin)
|
||||
sivUser.setImageResource(if (female) R.drawable.ic_woman else R.drawable.ic_man)
|
||||
}
|
||||
|
||||
if (!isNetworkAvailable(this)) {
|
||||
showSnack(getString(R.string.no_internet_message))
|
||||
return
|
||||
}
|
||||
|
||||
refreshListFromFirstPage()
|
||||
}
|
||||
|
||||
private fun refreshListFromFirstPage() {
|
||||
allItems.clear()
|
||||
adapter.submitRawTimeline(emptyList())
|
||||
currentPage = 1
|
||||
hasMore = true
|
||||
isLoading = false
|
||||
loadPage(1)
|
||||
}
|
||||
|
||||
private fun loadPage(page: Int) {
|
||||
val cp = counterpartKode ?: run {
|
||||
showSnack("counterpart_kode kosong")
|
||||
return
|
||||
}
|
||||
|
||||
if (isLoading) return
|
||||
if (!hasMore && page != 1) return
|
||||
if (page <= currentPage && page != 1) return
|
||||
|
||||
isLoading = true
|
||||
showProgressDialog(true)
|
||||
|
||||
APIMain.require().generalServices
|
||||
.detail(bearerToken(), cp, page, perPage)
|
||||
.enqueue(object : Callback<GeneralDetailResponse> {
|
||||
|
||||
override fun onResponse(
|
||||
call: Call<GeneralDetailResponse>,
|
||||
response: Response<GeneralDetailResponse>
|
||||
) {
|
||||
showProgressDialog(false)
|
||||
isLoading = false
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body()
|
||||
val newItems = body?.items.orEmpty()
|
||||
|
||||
val meta = body?.meta
|
||||
val metaPage = meta?.page
|
||||
val metaHasMore = meta?.has_more
|
||||
val metaNextPage = meta?.next_page
|
||||
|
||||
currentPage = metaPage ?: page
|
||||
|
||||
hasMore = when {
|
||||
metaHasMore != null -> metaHasMore
|
||||
metaNextPage != null -> true
|
||||
else -> newItems.size >= perPage
|
||||
}
|
||||
|
||||
if (page == 1) allItems.clear()
|
||||
|
||||
// ✅ anti-duplikat (penting banget karena backend union bisa double kalau ada data ganda)
|
||||
val existingKeys = allItems.map { keyOf(it) }.toHashSet()
|
||||
val filtered = newItems.filter { existingKeys.add(keyOf(it)) }
|
||||
|
||||
allItems.addAll(filtered)
|
||||
adapter.submitRawTimeline(allItems.toList())
|
||||
|
||||
if (allItems.isEmpty()) showSnack("Tidak ada pesan")
|
||||
return
|
||||
}
|
||||
|
||||
handleError(response)
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call<GeneralDetailResponse>, t: Throwable) {
|
||||
showProgressDialog(false)
|
||||
isLoading = false
|
||||
showSnack(t.message ?: "Terjadi kesalahan")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun keyOf(item: GeneralThreadItem): String {
|
||||
return if (item.tipe.equals("REAKSI", true)) {
|
||||
"R_${item.reaksi?.id ?: -1}"
|
||||
} else {
|
||||
"A_${item.aksi.id}"
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleError(response: Response<GeneralDetailResponse>) {
|
||||
val raw = runCatching { response.errorBody()?.string().orEmpty() }.getOrDefault("")
|
||||
val expired = raw.contains("Signature has expired", true) || raw.contains("token_expired", true)
|
||||
|
||||
val message = when {
|
||||
(response.code() == 401 || response.code() == 500) && expired -> {
|
||||
forceLogoutAndGoLogin(this@GeneralDetailActivity)
|
||||
"Session expired. Please login again."
|
||||
}
|
||||
response.code() == 400 -> runCatching {
|
||||
JsonParser.parseString(raw).asJsonObject["message"].asString
|
||||
}.getOrDefault("Bad request")
|
||||
else -> "${response.code()}, ${response.message()}"
|
||||
}
|
||||
|
||||
showSnack(message)
|
||||
}
|
||||
|
||||
private fun showSnack(message: String) {
|
||||
Snackbar.make(findViewById(android.R.id.content), message, Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// MARK AS READ (FIX: REAKSI pakai reaksi.id, AKSI pakai aksi.id)
|
||||
// ============================================================
|
||||
private fun getReadPayload(item: GeneralThreadItem): Pair<Int, String>? {
|
||||
return if (item.tipe.equals("REAKSI", true)) {
|
||||
val rxId = item.reaksi?.id ?: return null
|
||||
Pair(rxId, "REAKSI")
|
||||
} else {
|
||||
Pair(item.aksi.id, "AKSI")
|
||||
}
|
||||
}
|
||||
|
||||
private fun markReadIfNeeded(item: GeneralThreadItem, thenRun: () -> Unit) {
|
||||
// backend GetgeneralDetail kamu ngirim is_unread / is_aktif
|
||||
val isUnread = (item.is_unread == true) || ((item.is_aktif ?: 1) == 0)
|
||||
if (!isUnread) {
|
||||
thenRun()
|
||||
return
|
||||
}
|
||||
|
||||
val payload = getReadPayload(item)
|
||||
if (payload == null) {
|
||||
thenRun()
|
||||
return
|
||||
}
|
||||
|
||||
val (id, tipe) = payload
|
||||
|
||||
APIMain.require().generalServices
|
||||
.readed(bearerToken(), id, tipe)
|
||||
.enqueue(object : Callback<Message> {
|
||||
override fun onResponse(call: Call<Message>, response: Response<Message>) {
|
||||
if (response.isSuccessful) {
|
||||
applyLocalReadState(id, tipe)
|
||||
}
|
||||
thenRun()
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call<Message>, t: Throwable) {
|
||||
thenRun()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun applyLocalReadState(id: Int, tipe: String) {
|
||||
val upper = tipe.uppercase()
|
||||
var changed = false
|
||||
|
||||
for (i in allItems.indices) {
|
||||
val it = allItems[i]
|
||||
val match = if (upper == "REAKSI") {
|
||||
it.tipe.equals("REAKSI", true) && it.reaksi?.id == id
|
||||
} else {
|
||||
!it.tipe.equals("REAKSI", true) && it.aksi.id == id
|
||||
}
|
||||
|
||||
if (match) {
|
||||
allItems[i] = it.copy(
|
||||
is_aktif = 1,
|
||||
is_unread = false
|
||||
)
|
||||
changed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) adapter.submitRawTimeline(allItems.toList())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,750 @@
|
||||
package com.amz.genie.activities
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.text.Editable
|
||||
import android.text.TextWatcher
|
||||
import android.view.LayoutInflater
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.AutoCompleteTextView
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.amz.genie.R
|
||||
import com.amz.genie.adapters.ChatAdapter
|
||||
import com.amz.genie.helpers.EmojiPickerBottomSheet
|
||||
import com.amz.genie.helpers.Preferences
|
||||
import com.amz.genie.helpers.Utils.forceLogoutAndGoLogin
|
||||
import com.amz.genie.models.ChatItem
|
||||
import com.amz.genie.models.GeneralThreadItem
|
||||
import com.amz.genie.models.InboxThreadResponse
|
||||
import com.amz.genie.models.Message
|
||||
import com.amz.genie.models.Pengguna
|
||||
import com.amz.genie.models.RawMessage
|
||||
import com.amz.genie.models.ReaksiPost
|
||||
import com.amz.genie.models.ReaksiResponse
|
||||
import com.amz.genie.services.APIMain
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.google.android.material.textfield.TextInputEditText
|
||||
import com.google.android.material.textfield.TextInputLayout
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.google.gson.JsonElement
|
||||
import com.google.gson.JsonParser
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import retrofit2.Call
|
||||
import retrofit2.Callback
|
||||
import retrofit2.Response
|
||||
|
||||
class GeneralSubDetailActivity : BaseActivity() {
|
||||
|
||||
private lateinit var ivBack: ImageView
|
||||
private lateinit var tvDescription: TextView
|
||||
private lateinit var tvName: TextView
|
||||
private lateinit var tvJobDesk: TextView
|
||||
private lateinit var ibEmoji: ImageButton
|
||||
private lateinit var ibAttach: ImageButton
|
||||
private lateinit var ibCamera: ImageButton
|
||||
private lateinit var ibMic: ImageButton
|
||||
private lateinit var tietMessage: TextInputEditText
|
||||
private lateinit var rvChat: RecyclerView
|
||||
private lateinit var chatAdapter: ChatAdapter
|
||||
private lateinit var lm: LinearLayoutManager
|
||||
private lateinit var tvNewMsg: TextView
|
||||
|
||||
private var counterpartKode: String? = null
|
||||
private var aksiId: Int? = null
|
||||
private var reaksiId: Int? = null
|
||||
private var tentangId: String = ""
|
||||
|
||||
private var isLoading = false
|
||||
private var hasMore = true
|
||||
private var currentPage = 1
|
||||
private val perPage = 30
|
||||
private var isSendMode = false
|
||||
private var newestMessageId: String? = null
|
||||
|
||||
private var userNearBottom = true
|
||||
private var pendingNewCount = 0
|
||||
|
||||
// ✅ FIX READ PAYLOAD
|
||||
private var readedId: Int? = null
|
||||
private var readedTipe: String = "AKSI"
|
||||
private var hasScheduledMark = false
|
||||
private var hasPostedMark = false
|
||||
private val markHandler = Handler(Looper.getMainLooper())
|
||||
private var markRunnable: Runnable? = null
|
||||
|
||||
private var displayTipe: String = "AKSI"
|
||||
private var myKodePegawai: String = ""
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_inbox_sub_detail)
|
||||
|
||||
initUI()
|
||||
setupActions()
|
||||
|
||||
when {
|
||||
intent.hasExtra("data") -> initData()
|
||||
intent.hasExtra("aksi_id") && intent.hasExtra("counterpart_kode") -> initDataFromNotif()
|
||||
else -> {
|
||||
showSnack("Data notifikasi tidak lengkap")
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun initDataFromNotif() {
|
||||
counterpartKode = intent.getStringExtra("counterpart_kode")
|
||||
aksiId = intent.getStringExtra("aksi_id")?.toIntOrNull()
|
||||
tentangId = intent.getStringExtra("tentang_id").orEmpty()
|
||||
|
||||
if (counterpartKode.isNullOrBlank() || aksiId == null) {
|
||||
showSnack("Data thread tidak lengkap")
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
val userData = Gson().fromJson(Preferences.getUserData(this), Pengguna::class.java)
|
||||
myKodePegawai = userData.pegawai?.kode.orEmpty()
|
||||
|
||||
displayTipe = (intent.getStringExtra("tipe") ?: "AKSI").uppercase()
|
||||
readedTipe = "AKSI"
|
||||
readedId = aksiId
|
||||
|
||||
currentPage = 1
|
||||
hasMore = true
|
||||
newestMessageId = null
|
||||
pendingNewCount = 0
|
||||
tvNewMsg.isVisible = false
|
||||
chatAdapter.submitList(emptyList())
|
||||
|
||||
applyHeader(name = counterpartKode, jobDesk = "Memuat...", description = "")
|
||||
loadOlder(1, scrollToBottom = true)
|
||||
}
|
||||
|
||||
private fun initUI() {
|
||||
ivBack = findViewById(R.id.ib_back_inbox_sub_detail)
|
||||
tvDescription = findViewById(R.id.tv_description_inbox_sub_detail)
|
||||
tvName = findViewById(R.id.tv_name_inbox_sub_detail)
|
||||
tvJobDesk = findViewById(R.id.tv_jobdesk_inbox_sub_detail)
|
||||
ibEmoji = findViewById(R.id.ib_emoji_inbox_sub_detail)
|
||||
ibAttach = findViewById(R.id.ib_attach_inbox_sub_detail)
|
||||
ibCamera = findViewById(R.id.ib_camera_inbox_sub_detail)
|
||||
ibMic = findViewById(R.id.ib_mic_inbox_sub_detail)
|
||||
tietMessage = findViewById(R.id.tiet_message_inbox_sub_detail)
|
||||
rvChat = findViewById(R.id.rv_inbox_sub_detail)
|
||||
|
||||
tvNewMsg = findViewById(R.id.tv_new_message_indicator)
|
||||
tvNewMsg.isVisible = false
|
||||
|
||||
chatAdapter = ChatAdapter()
|
||||
lm = LinearLayoutManager(this).apply { stackFromEnd = true }
|
||||
|
||||
rvChat.layoutManager = lm
|
||||
rvChat.adapter = chatAdapter
|
||||
rvChat.setHasFixedSize(true)
|
||||
|
||||
tietMessage.addTextChangedListener(object : TextWatcher {
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
|
||||
updateMicButtonByMessage()
|
||||
}
|
||||
override fun afterTextChanged(s: Editable?) {}
|
||||
})
|
||||
|
||||
updateMicButtonByMessage()
|
||||
}
|
||||
|
||||
private fun setupActions() {
|
||||
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() = handleBackPress(0)
|
||||
})
|
||||
|
||||
ivBack.setOnClickListener { handleBackPress(0) }
|
||||
|
||||
ibEmoji.setOnClickListener {
|
||||
EmojiPickerBottomSheet { emoji -> insertEmojiToMessage(emoji) }
|
||||
.show(supportFragmentManager, "emoji_picker")
|
||||
}
|
||||
|
||||
ibAttach.setOnClickListener { /* TODO */ }
|
||||
ibCamera.setOnClickListener { /* TODO */ }
|
||||
|
||||
ibMic.setOnClickListener {
|
||||
if (isSendMode) {
|
||||
val msg = tietMessage.text?.toString()?.trim().orEmpty()
|
||||
if (msg.isNotEmpty()) showSendOptionsDialog()
|
||||
} else {
|
||||
// TODO
|
||||
}
|
||||
}
|
||||
|
||||
rvChat.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
||||
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
||||
super.onScrolled(recyclerView, dx, dy)
|
||||
|
||||
userNearBottom = isUserNearBottom()
|
||||
|
||||
if (userNearBottom && tvNewMsg.isVisible) {
|
||||
pendingNewCount = 0
|
||||
tvNewMsg.isVisible = false
|
||||
}
|
||||
|
||||
val firstVisible = lm.findFirstVisibleItemPosition()
|
||||
if (!isLoading && hasMore && firstVisible <= 3) {
|
||||
loadOlder(currentPage + 1)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
tvNewMsg.setOnClickListener {
|
||||
pendingNewCount = 0
|
||||
tvNewMsg.isVisible = false
|
||||
rvChat.scrollToPosition(chatAdapter.itemCount - 1)
|
||||
}
|
||||
}
|
||||
|
||||
private fun initData() {
|
||||
val intentDataJson = intent.getStringExtra("data") ?: return
|
||||
val data = Gson().fromJson(intentDataJson, GeneralThreadItem::class.java)
|
||||
|
||||
counterpartKode = intent.getStringExtra("counterpart")
|
||||
displayTipe = data.tipe.uppercase()
|
||||
|
||||
val aksi = data.aksi
|
||||
val reaksi = data.reaksi
|
||||
|
||||
aksiId = aksi.id
|
||||
tentangId = aksi.tentang.id
|
||||
reaksiId = if (displayTipe == "REAKSI" && reaksi != null) reaksi.id else null
|
||||
|
||||
readedTipe = displayTipe
|
||||
readedId = if (readedTipe == "REAKSI") reaksiId else aksiId
|
||||
|
||||
if (displayTipe == "REAKSI" && reaksi != null) {
|
||||
tvName.text = reaksi.pembuat.nama
|
||||
tvJobDesk.text = "${reaksi.pembuat.jabatan?.nama} - ${reaksi.pembuat.outlet?.nama}"
|
||||
tvDescription.text = reaksi.uraian
|
||||
} else {
|
||||
tvName.text = aksi.pembuat.nama
|
||||
tvJobDesk.text = "${aksi.pembuat.jabatan?.nama} - ${aksi.pembuat.outlet?.nama}"
|
||||
tvDescription.text = aksi.uraian
|
||||
}
|
||||
|
||||
val userData = Gson().fromJson(Preferences.getUserData(this), Pengguna::class.java)
|
||||
myKodePegawai = userData.pegawai?.kode.orEmpty()
|
||||
|
||||
resetThreadStateAndLoad(scrollToBottom = true)
|
||||
}
|
||||
|
||||
private fun loadOlder(page: Int, scrollToBottom: Boolean = false) {
|
||||
val cp = counterpartKode ?: return
|
||||
val idAksi = aksiId ?: return
|
||||
if (isLoading) return
|
||||
if (!hasMore && page != 1) return
|
||||
|
||||
isLoading = true
|
||||
showProgressDialog(true)
|
||||
|
||||
APIMain.require().generalServices
|
||||
.threadDetail(
|
||||
Preferences.getAccessToken(this@GeneralSubDetailActivity),
|
||||
cp, idAksi, page, perPage
|
||||
)
|
||||
.enqueue(object : Callback<InboxThreadResponse> {
|
||||
override fun onResponse(call: Call<InboxThreadResponse>, response: Response<InboxThreadResponse>) {
|
||||
showProgressDialog(false)
|
||||
isLoading = false
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
val raw = runCatching { response.errorBody()?.string().orEmpty() }.getOrDefault("")
|
||||
val expired = raw.contains("Signature has expired", true) || raw.contains("token_expired", true)
|
||||
|
||||
val message = when {
|
||||
(response.code() == 401 || response.code() == 500) && expired -> {
|
||||
forceLogoutAndGoLogin(this@GeneralSubDetailActivity)
|
||||
"Session expired. Please login again."
|
||||
}
|
||||
response.code() == 400 -> runCatching {
|
||||
JsonParser.parseString(raw).asJsonObject["message"].asString
|
||||
}.getOrDefault("Bad request")
|
||||
else -> "${response.code()}, ${response.message()}"
|
||||
}
|
||||
showSnack(message)
|
||||
hasPostedMark = false
|
||||
return
|
||||
}
|
||||
|
||||
val body = response.body() ?: return
|
||||
|
||||
currentPage = body.meta?.page ?: page
|
||||
hasMore = body.meta?.has_more ?: false
|
||||
|
||||
val raw = body.items.orEmpty() // newest -> oldest
|
||||
if (page == 1) newestMessageId = raw.firstOrNull()?.id
|
||||
|
||||
val display = raw.asReversed() // oldest -> newest
|
||||
|
||||
if (page == 1) {
|
||||
applyHeaderFromThread(display)
|
||||
}
|
||||
|
||||
val chatItems = mapToChatItems(display, myKodePegawai)
|
||||
|
||||
if (page == 1) {
|
||||
chatAdapter.submitList(chatItems)
|
||||
if (scrollToBottom) rvChat.scrollToPosition(chatAdapter.itemCount - 1)
|
||||
} else {
|
||||
val beforeCount = chatAdapter.itemCount
|
||||
val firstPos = lm.findFirstVisibleItemPosition()
|
||||
val topView = rvChat.getChildAt(0)
|
||||
val topOffset = topView?.top ?: 0
|
||||
|
||||
chatAdapter.prepend(chatItems)
|
||||
lm.scrollToPositionWithOffset(
|
||||
firstPos + (chatAdapter.itemCount - beforeCount),
|
||||
topOffset
|
||||
)
|
||||
}
|
||||
|
||||
if (!hasMore) scheduleMarkAfterFullyLoaded()
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call<InboxThreadResponse>, t: Throwable) {
|
||||
showProgressDialog(false)
|
||||
isLoading = false
|
||||
showSnack(t.message ?: "Terjadi kesalahan")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun mapToChatItems(display: List<RawMessage>, myKode: String): List<ChatItem> {
|
||||
val out = ArrayList<ChatItem>()
|
||||
var prevSender: String? = null
|
||||
|
||||
for (m in display) {
|
||||
val senderKode = m.sender_kode.trim()
|
||||
if (senderKode.isEmpty()) continue
|
||||
|
||||
val sameAsPrev = prevSender != null && prevSender == senderKode
|
||||
val isMine = senderKode == myKode
|
||||
|
||||
out.add(
|
||||
ChatItem(
|
||||
id = m.id.orEmpty(),
|
||||
senderKode = senderKode,
|
||||
senderName = m.sender?.nama,
|
||||
senderJob = m.sender?.jabatan?.nama,
|
||||
senderOutlet = m.sender?.outlet?.nama,
|
||||
message = m.message,
|
||||
timeText = m.waktu_buat.orEmpty(),
|
||||
isMine = isMine,
|
||||
isSameSenderAsPrev = sameAsPrev
|
||||
)
|
||||
)
|
||||
|
||||
prevSender = senderKode
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun updateMicButtonByMessage() {
|
||||
val hasText = !tietMessage.text.isNullOrBlank()
|
||||
isSendMode = hasText
|
||||
|
||||
if (hasText) {
|
||||
ibMic.setImageResource(R.drawable.send_24px)
|
||||
ibMic.contentDescription = "send"
|
||||
} else {
|
||||
ibMic.setImageResource(R.drawable.mic_24px)
|
||||
ibMic.contentDescription = "mic"
|
||||
}
|
||||
}
|
||||
|
||||
private fun insertEmojiToMessage(emoji: String) {
|
||||
val editable = tietMessage.text ?: return
|
||||
val start = tietMessage.selectionStart.coerceAtLeast(0)
|
||||
val end = tietMessage.selectionEnd.coerceAtLeast(0)
|
||||
|
||||
val minPos = minOf(start, end)
|
||||
val maxPos = maxOf(start, end)
|
||||
|
||||
editable.replace(minPos, maxPos, emoji)
|
||||
tietMessage.setSelection(minPos + emoji.length)
|
||||
tietMessage.requestFocus()
|
||||
}
|
||||
|
||||
private fun showSnack(message: String) {
|
||||
Snackbar.make(findViewById(android.R.id.content), message, Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
private fun isUserNearBottom(threshold: Int = 2): Boolean {
|
||||
val total = chatAdapter.itemCount
|
||||
if (total == 0) return true
|
||||
val lastVisible = lm.findLastVisibleItemPosition()
|
||||
return lastVisible >= total - 1 - threshold
|
||||
}
|
||||
|
||||
private fun scheduleMarkAfterFullyLoaded() {
|
||||
if (hasScheduledMark || hasPostedMark) return
|
||||
if (readedId == null) return
|
||||
|
||||
hasScheduledMark = true
|
||||
markRunnable = Runnable {
|
||||
postMarkToApi()
|
||||
hasScheduledMark = false
|
||||
}
|
||||
markHandler.postDelayed(markRunnable!!, 1200L)
|
||||
}
|
||||
|
||||
private fun postMarkToApi() {
|
||||
if (hasPostedMark) return
|
||||
|
||||
val id = readedId ?: return
|
||||
val tipeKirim = readedTipe
|
||||
|
||||
hasPostedMark = true
|
||||
|
||||
APIMain.require().generalServices.readed(
|
||||
Preferences.getAccessToken(this),
|
||||
id,
|
||||
tipeKirim
|
||||
).enqueue(object : Callback<Message> {
|
||||
|
||||
override fun onResponse(call: Call<Message>, response: Response<Message>) {
|
||||
if (!response.isSuccessful) {
|
||||
hasPostedMark = false
|
||||
showSnack("${response.code()} ${response.message()}")
|
||||
return
|
||||
}
|
||||
setResult(RESULT_OK, Intent().putExtra("needs_refresh", true))
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call<Message>, t: Throwable) {
|
||||
hasPostedMark = false
|
||||
showSnack(t.message ?: "Terjadi kesalahan")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
markRunnable?.let { markHandler.removeCallbacks(it) }
|
||||
hasScheduledMark = false
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
markRunnable?.let { markHandler.removeCallbacks(it) }
|
||||
hasScheduledMark = false
|
||||
}
|
||||
|
||||
private fun resetThreadStateAndLoad(scrollToBottom: Boolean = true) {
|
||||
currentPage = 1
|
||||
hasMore = true
|
||||
newestMessageId = null
|
||||
pendingNewCount = 0
|
||||
tvNewMsg.isVisible = false
|
||||
chatAdapter.submitList(emptyList())
|
||||
|
||||
hasPostedMark = false
|
||||
hasScheduledMark = false
|
||||
|
||||
loadOlder(1, scrollToBottom = scrollToBottom)
|
||||
}
|
||||
|
||||
private fun applyHeader(name: String?, jobDesk: String?, description: String?) {
|
||||
tvName.text = name ?: ""
|
||||
tvJobDesk.text = jobDesk ?: ""
|
||||
tvDescription.text = description ?: ""
|
||||
}
|
||||
|
||||
private fun applyHeaderFromThread(display: List<RawMessage>) {
|
||||
if (display.isEmpty()) return
|
||||
|
||||
val partnerMsg = display.firstOrNull { it.sender_kode.trim() != myKodePegawai }
|
||||
?: display.lastOrNull { it.sender_kode.trim() != myKodePegawai }
|
||||
|
||||
val partner = partnerMsg?.sender
|
||||
val partnerName = partner?.nama ?: (counterpartKode ?: "")
|
||||
val partnerJob = listOfNotNull(partner?.jabatan?.nama, partner?.outlet?.nama).joinToString(" - ")
|
||||
|
||||
// ✅ ambil uraian kegiatan dari payload AKSI (message json), bukan dari text header aja
|
||||
val desc = extractUraianKegiatanFromMsg(partnerMsg?.message)
|
||||
|
||||
tvName.text = partnerName
|
||||
tvJobDesk.text = partnerJob
|
||||
if (!desc.isNullOrBlank()) tvDescription.text = desc
|
||||
}
|
||||
|
||||
// =============================
|
||||
// SEND REAKSI (tetap)
|
||||
// =============================
|
||||
private fun showSendOptionsDialog() {
|
||||
val msg = tietMessage.text?.toString()?.trim().orEmpty()
|
||||
if (msg.isEmpty()) return
|
||||
|
||||
val options = listOf("Bertanya", "Informasi", "Laporan", "Pengajuan", "Penugasan")
|
||||
|
||||
val view = LayoutInflater.from(this).inflate(R.layout.dialog_send_type, null)
|
||||
val til = view.findViewById<TextInputLayout>(R.id.til_type)
|
||||
val actv = view.findViewById<AutoCompleteTextView>(R.id.actv_type_dialog_send_type)
|
||||
|
||||
val adapter = ArrayAdapter(this, android.R.layout.simple_list_item_1, options)
|
||||
actv.setAdapter(adapter)
|
||||
actv.setText(options[0], false)
|
||||
|
||||
actv.keyListener = null
|
||||
actv.isCursorVisible = false
|
||||
actv.isFocusable = true
|
||||
actv.isFocusableInTouchMode = true
|
||||
actv.showSoftInputOnFocus = false
|
||||
actv.setOnClickListener { actv.showDropDown() }
|
||||
|
||||
val dialog = MaterialAlertDialogBuilder(this)
|
||||
.setView(view)
|
||||
.setNegativeButton("Batal", null)
|
||||
.setPositiveButton("Kirim", null)
|
||||
.create()
|
||||
|
||||
dialog.setOnShowListener {
|
||||
dialog.getButton(androidx.appcompat.app.AlertDialog.BUTTON_POSITIVE).setOnClickListener {
|
||||
val selected = actv.text?.toString()?.trim().orEmpty()
|
||||
if (selected.isEmpty()) {
|
||||
til.error = "Pilih tipe komunikasi"
|
||||
return@setOnClickListener
|
||||
}
|
||||
|
||||
showProgressDialog(true)
|
||||
|
||||
val topic = when (selected) {
|
||||
"Bertanya" -> "A"
|
||||
"Informasi" -> "I"
|
||||
"Laporan" -> "L"
|
||||
"Approval" -> "P"
|
||||
"Pengajuan" -> "R"
|
||||
"Penugasan" -> "T"
|
||||
else -> ""
|
||||
}
|
||||
|
||||
val reaksi = ReaksiPost(
|
||||
aksiId!!, "R",
|
||||
reaksiId,
|
||||
tentangId, topic, tietMessage.text.toString()
|
||||
)
|
||||
|
||||
val gson = GsonBuilder().serializeNulls().create()
|
||||
val json = gson.toJson(reaksi)
|
||||
val body = json.toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull())
|
||||
|
||||
APIMain.require().reaksiServices.add(
|
||||
Preferences.getAccessToken(this@GeneralSubDetailActivity),
|
||||
body, emptyList()
|
||||
).enqueue(object : Callback<ReaksiResponse> {
|
||||
override fun onResponse(call: Call<ReaksiResponse>, response: Response<ReaksiResponse>) {
|
||||
showProgressDialog(false)
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
val raw = runCatching { response.errorBody()?.string().orEmpty() }.getOrDefault("")
|
||||
val expired = raw.contains("Signature has expired", true) || raw.contains("token_expired", true)
|
||||
|
||||
val message = when {
|
||||
(response.code() == 401 || response.code() == 500) && expired -> {
|
||||
forceLogoutAndGoLogin(this@GeneralSubDetailActivity)
|
||||
"Session expired. Please login again."
|
||||
}
|
||||
response.code() == 400 -> runCatching {
|
||||
JsonParser.parseString(raw).asJsonObject["message"].asString
|
||||
}.getOrDefault("Bad request")
|
||||
else -> "${response.code()}, ${response.message()}"
|
||||
}
|
||||
showSnack(message)
|
||||
hasPostedMark = false
|
||||
return
|
||||
}
|
||||
|
||||
val body = response.body()
|
||||
val rawItems = body?.thread?.items
|
||||
if (rawItems.isNullOrEmpty()) {
|
||||
showSnack("Reaksi terkirim. Refresh thread...")
|
||||
loadOlder(1, scrollToBottom = true)
|
||||
til.error = null
|
||||
tietMessage.setText("")
|
||||
dialog.dismiss()
|
||||
return
|
||||
}
|
||||
|
||||
val display = rawItems.asReversed()
|
||||
val chatItems = mapToChatItems(display, myKodePegawai)
|
||||
chatAdapter.submitList(chatItems)
|
||||
rvChat.scrollToPosition(chatAdapter.itemCount - 1)
|
||||
|
||||
til.error = null
|
||||
tietMessage.setText("")
|
||||
dialog.dismiss()
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call<ReaksiResponse>, t: Throwable) {
|
||||
showProgressDialog(false)
|
||||
showSnack(t.message ?: "Terjadi kesalahan")
|
||||
hasPostedMark = false
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
private fun extractUraianKegiatanFromMsg(msg: com.google.gson.JsonObject?): String? {
|
||||
if (msg == null) return null
|
||||
|
||||
// -------------------------
|
||||
// Safe helpers
|
||||
// -------------------------
|
||||
fun JsonElement?.asStringSafeNullable(): String? {
|
||||
if (this == null || this.isJsonNull) return null
|
||||
return try {
|
||||
when {
|
||||
this.isJsonPrimitive -> this.asString
|
||||
this.isJsonArray -> this.asJsonArray.joinToString("\n") { it.asStringSafeNullable().orEmpty() }
|
||||
this.isJsonObject -> {
|
||||
// common payload {nilai: "..."} / {value:"..."}
|
||||
val o = this.asJsonObject
|
||||
val pick = listOf("nilai", "value", "teks", "string", "uraian", "isi", "jawaban")
|
||||
.firstNotNullOfOrNull { k -> o.get(k)?.takeIf { !it.isJsonNull } }
|
||||
pick?.asStringSafeNullable() ?: o.toString()
|
||||
}
|
||||
else -> this.toString()
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun getObjAtPath(root: com.google.gson.JsonObject, path: String): JsonElement? {
|
||||
var cur: JsonElement = root
|
||||
for (seg in path.split(".")) {
|
||||
if (!cur.isJsonObject) return null
|
||||
val o = cur.asJsonObject
|
||||
if (!o.has(seg)) return null
|
||||
cur = o.get(seg)
|
||||
}
|
||||
return cur
|
||||
}
|
||||
|
||||
fun arrFrom(vararg paths: String): com.google.gson.JsonArray? {
|
||||
for (p in paths) {
|
||||
val el = getObjAtPath(msg, p)
|
||||
if (el != null && el.isJsonArray) return el.asJsonArray
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun rootOrValuesGet(key: String): JsonElement? {
|
||||
if (msg.has(key) && !msg.get(key).isJsonNull) return msg.get(key)
|
||||
val values = msg.get("values")?.takeIf { it.isJsonObject }?.asJsonObject
|
||||
if (values != null && values.has(key) && !values.get(key).isJsonNull) return values.get(key)
|
||||
|
||||
val dv = msg.get("detail_values")?.takeIf { it.isJsonObject }?.asJsonObject
|
||||
if (dv != null && dv.has(key) && !dv.get(key).isJsonNull) return dv.get(key)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// -------------------------
|
||||
// 1) cari definisi field "Uraian Kegiatan"
|
||||
// -------------------------
|
||||
val detailArr = arrFrom(
|
||||
"komunikasi.komunikasi_detail",
|
||||
"komunikasi_detail",
|
||||
"aksi.komunikasi.komunikasi_detail",
|
||||
"aksi.komunikasi.komunikasi_detail" // keep
|
||||
) ?: return null
|
||||
|
||||
// target field = "Uraian Kegiatan" (kode detail 143) dan jenis teks (id_jenis_isian=0)
|
||||
var kodeUraian: Int? = null
|
||||
for (i in 0 until detailArr.size()) {
|
||||
val el = detailArr[i]
|
||||
if (!el.isJsonObject) continue
|
||||
val d = el.asJsonObject
|
||||
|
||||
val kode = d.get("kode")?.takeIf { it.isJsonPrimitive }?.asInt
|
||||
val jenisId =
|
||||
d.get("id_jenis_isian")?.takeIf { it.isJsonPrimitive }?.asInt
|
||||
?: d.get("jenis_isian")?.takeIf { it.isJsonObject }?.asJsonObject?.get("id")
|
||||
?.takeIf { it.isJsonPrimitive }?.asInt
|
||||
?: 0
|
||||
|
||||
val label = d.get("isian")?.asStringSafeNullable()?.trim().orEmpty()
|
||||
|
||||
if (jenisId == ChatAdapter.JENIS_TEKS) {
|
||||
// paling ideal ketemu kode 143
|
||||
if (kode == 143) {
|
||||
kodeUraian = 143
|
||||
break
|
||||
}
|
||||
// fallback: label matching
|
||||
if (kode != null && label.contains("uraian", true) && label.contains("kegiatan", true)) {
|
||||
kodeUraian = kode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// kalau tetap gak ketemu, fallback langsung 143
|
||||
val kd = kodeUraian ?: 143
|
||||
|
||||
// -------------------------
|
||||
// 2) ambil value dari tempat yang benar
|
||||
// -------------------------
|
||||
|
||||
// A) jika backend sudah kirim map detail_values/values by kode detail
|
||||
rootOrValuesGet(kd.toString())?.asStringSafeNullable()?.trim()?.takeIf { it.isNotBlank() }?.let { return it }
|
||||
rootOrValuesGet("nilai_$kd")?.asStringSafeNullable()?.trim()?.takeIf { it.isNotBlank() }?.let { return it }
|
||||
rootOrValuesGet("isian_$kd")?.asStringSafeNullable()?.trim()?.takeIf { it.isNotBlank() }?.let { return it }
|
||||
|
||||
// B) pola sekarang: aksi_komunikasi_teks adalah object {id, aksi_id, nilai:"..."} TANPA kode_detail
|
||||
// jadi ambil "nilai" dari aksi_komunikasi_teks kalau ada (karena untuk RK Harian biasanya cuma 1 teks yaitu uraian)
|
||||
msg.get("aksi_komunikasi_teks")
|
||||
?.asStringSafeNullable()
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { return it }
|
||||
|
||||
val teksObj = msg.get("aksi_komunikasi_teks")?.takeIf { it.isJsonObject }?.asJsonObject
|
||||
teksObj?.get("nilai")?.asStringSafeNullable()?.trim()?.takeIf { it.isNotBlank() }?.let { return it }
|
||||
|
||||
// fallback lain
|
||||
msg.get("aksi_komunikasi_string")?.takeIf { it.isJsonObject }?.asJsonObject
|
||||
?.get("nilai")?.asStringSafeNullable()?.trim()?.takeIf { it.isNotBlank() }?.let { return it }
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// =========================
|
||||
// Helpers Json Safe (single source)
|
||||
// =========================
|
||||
private fun JsonElement?.asStringSafeNullable(): String? {
|
||||
if (this == null || this.isJsonNull) return null
|
||||
return try {
|
||||
when {
|
||||
this.isJsonPrimitive -> this.asString
|
||||
this.isJsonArray -> this.asJsonArray.joinToString("\n") { it.asStringSafeNullable().orEmpty() }
|
||||
this.isJsonObject -> this.toString()
|
||||
else -> this.toString()
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
325
app/src/main/java/com/amz/genie/activities/LoginActivity.kt
Normal file
325
app/src/main/java/com/amz/genie/activities/LoginActivity.kt
Normal file
@@ -0,0 +1,325 @@
|
||||
package com.amz.genie.activities
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.text.TextUtils
|
||||
import android.util.Log
|
||||
import android.widget.Button
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.amz.genie.R
|
||||
import com.amz.genie.helpers.Preferences
|
||||
import com.amz.genie.helpers.Utils.isNetworkAvailable
|
||||
import com.amz.genie.models.Login
|
||||
import com.amz.genie.services.APIMain
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.google.android.material.textfield.TextInputEditText
|
||||
import com.google.firebase.messaging.FirebaseMessaging
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.JsonParser
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONObject
|
||||
import retrofit2.Call
|
||||
import retrofit2.Callback
|
||||
import retrofit2.Response
|
||||
|
||||
class LoginActivity : BaseActivity() {
|
||||
private lateinit var tietUsername: TextInputEditText
|
||||
private lateinit var tietPassword: TextInputEditText
|
||||
private lateinit var tvForgotPassword: TextView
|
||||
private lateinit var btLogin: Button
|
||||
|
||||
private lateinit var handler: Handler
|
||||
private var backPressedTime: Long = 0
|
||||
private val notificationPermission = Manifest.permission.POST_NOTIFICATIONS
|
||||
private lateinit var notificationPermissionLauncher: ActivityResultLauncher<String>
|
||||
private var pendingUserData: Login? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_login)
|
||||
|
||||
initUI()
|
||||
}
|
||||
|
||||
private fun initUI() {
|
||||
tietUsername = findViewById(R.id.tiet_username_login)
|
||||
tietPassword = findViewById(R.id.tiet_password_login)
|
||||
tvForgotPassword = findViewById(R.id.tv_forgot_password_login)
|
||||
btLogin = findViewById(R.id.bt_login)
|
||||
|
||||
handler = Handler(Looper.getMainLooper())
|
||||
initializePermissionLaunchers()
|
||||
|
||||
setupActions()
|
||||
}
|
||||
|
||||
private fun initializePermissionLaunchers() {
|
||||
// Notification permission launcher
|
||||
notificationPermissionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { isGranted ->
|
||||
if (isGranted) {
|
||||
// Notification permission granted
|
||||
Log.d("NotificationPermission", "Push notification permission granted")
|
||||
showCustomNotificationDialog(true)
|
||||
} else {
|
||||
// Notification permission denied
|
||||
Log.w("NotificationPermission", "Push notification permission denied")
|
||||
showCustomNotificationDialog(false)
|
||||
}
|
||||
|
||||
// Proceed with login regardless of notification permission
|
||||
pendingUserData?.let {
|
||||
proceedAfterPermission(it)
|
||||
} ?: run {
|
||||
showProgressDialog(false)
|
||||
Snackbar.make(
|
||||
findViewById(android.R.id.content),
|
||||
"Authentication error, please try again",
|
||||
Snackbar.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupActions() {
|
||||
onBackPressedDispatcher.addCallback(this,
|
||||
object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
if (backPressedTime + 2000 > System.currentTimeMillis()) {
|
||||
finishAffinity()
|
||||
} else {
|
||||
Toast.makeText(
|
||||
this@LoginActivity,
|
||||
"Tekan Sekali Lagi Untuk Keluar",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
backPressedTime = System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
btLogin.setOnClickListener {
|
||||
val username: String = tietUsername.text.toString().trim()
|
||||
val password: String = tietPassword.text.toString().trim { it <= ' ' }
|
||||
if (!TextUtils.isEmpty(username) && !TextUtils.isEmpty(password)) {
|
||||
if (isNetworkAvailable(this)) {
|
||||
showProgressDialog(true)
|
||||
|
||||
val paramObject = JSONObject()
|
||||
paramObject.put("username", username)
|
||||
paramObject.put("password", password)
|
||||
|
||||
val requestBody: RequestBody = paramObject.toString().toRequestBody(
|
||||
"application/json".toMediaTypeOrNull())
|
||||
|
||||
val call: Call<Login> = APIMain.require().accountServices.login(requestBody)
|
||||
call.enqueue(object: Callback<Login> {
|
||||
override fun onResponse(
|
||||
call: Call<Login?>,
|
||||
response: Response<Login?>
|
||||
) {
|
||||
if (response.isSuccessful) {
|
||||
val result: Login? = response.body()
|
||||
result?.let {
|
||||
pendingUserData = it
|
||||
checkNotificationPermission()
|
||||
}
|
||||
|
||||
} else {
|
||||
showProgressDialog(false)
|
||||
|
||||
val message = if (response.code() == 400) {
|
||||
val errorJson = response.errorBody()?.string()
|
||||
if (!errorJson.isNullOrEmpty()) {
|
||||
JsonParser.parseString(errorJson).asJsonObject["message"]?.asString ?: "Bad Request"
|
||||
} else {
|
||||
"Bad Request"
|
||||
}
|
||||
} else {
|
||||
"${response.code()}, ${response.message()}"
|
||||
}
|
||||
|
||||
Snackbar.make(findViewById(android.R.id.content),
|
||||
message,
|
||||
Snackbar.LENGTH_LONG
|
||||
).show()
|
||||
response.errorBody()?.close()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(
|
||||
call: Call<Login?>,
|
||||
t: Throwable
|
||||
) {
|
||||
showProgressDialog(false)
|
||||
|
||||
Snackbar.make(findViewById(android.R.id.content),
|
||||
t.message.toString(),
|
||||
Snackbar.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
} else {
|
||||
showProgressDialog(false)
|
||||
|
||||
Snackbar.make(findViewById(android.R.id.content),
|
||||
ContextCompat.getString(this@LoginActivity,
|
||||
R.string.no_internet_message),
|
||||
Snackbar.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkNotificationPermission() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (ContextCompat.checkSelfPermission(this, notificationPermission)
|
||||
!= PackageManager.PERMISSION_GRANTED) {
|
||||
Log.d("NotificationPermission", "Showing notification explanation dialog")
|
||||
showNotificationExplanationDialog()
|
||||
} else {
|
||||
Log.d("NotificationPermission", "Push notification permission already granted")
|
||||
showCustomNotificationDialog(true)
|
||||
pendingUserData?.let {
|
||||
proceedAfterPermission(it)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.d("NotificationPermission", "No notification permission required for Android < 13")
|
||||
pendingUserData?.let {
|
||||
proceedAfterPermission(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showNotificationExplanationDialog() {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(getString(R.string.enable_push_notifications))
|
||||
.setMessage(getString(R.string.notification_explanation))
|
||||
.setPositiveButton(getString(R.string.allow_notifications)) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
Log.d("NotificationPermission", "Requesting notification permission")
|
||||
notificationPermissionLauncher.launch(notificationPermission)
|
||||
}
|
||||
.setNegativeButton(getString(R.string.not_now)) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
Log.w("NotificationPermission", "User declined notification permission")
|
||||
showCustomNotificationDialog(false)
|
||||
pendingUserData?.let { proceedAfterPermission(it) }
|
||||
}
|
||||
.setCancelable(false)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun showCustomNotificationDialog(isGranted: Boolean) {
|
||||
if (isGranted) {
|
||||
handler.post {
|
||||
Snackbar.make(
|
||||
findViewById(android.R.id.content),
|
||||
"Notifikasi push sudah diaktifkan! Anda akan menerima pembaruan penting.",
|
||||
Snackbar.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
} else {
|
||||
handler.post {
|
||||
Snackbar.make(
|
||||
findViewById(android.R.id.content),
|
||||
"Notifikasi dinonaktifkan. Anda bisa mengaktifkannya nanti di Pengaturan.",
|
||||
Snackbar.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun proceedAfterPermission(userData: Login) {
|
||||
showProgressDialog(false)
|
||||
|
||||
Preferences.setAccessToken(this@LoginActivity, "Bearer ${userData.access_token}")
|
||||
Preferences.setRefreshToken(this@LoginActivity, "Bearer ${userData.refresh_token}")
|
||||
|
||||
val gson = Gson()
|
||||
val data = gson.toJson(userData.user)
|
||||
Preferences.setUserData(this@LoginActivity, data)
|
||||
|
||||
setupFCMAndLogin(userData)
|
||||
|
||||
pendingUserData = null
|
||||
}
|
||||
|
||||
private fun setupFCMAndLogin(userData: Login) {
|
||||
val newTopic = userData.user.kode.toString().trim()
|
||||
if (newTopic.isBlank()) {
|
||||
Log.w("FCM", "newTopic blank, skip subscribe")
|
||||
completeLoginProcess()
|
||||
return
|
||||
}
|
||||
|
||||
val oldTopic = Preferences.getLastFcmTopic(this).trim()
|
||||
|
||||
FirebaseMessaging.getInstance().token.addOnCompleteListener { tokenTask ->
|
||||
if (tokenTask.isSuccessful) {
|
||||
Log.d("FCM", "Token: ${tokenTask.result}")
|
||||
} else {
|
||||
Log.w("FCM", "Failed to get token", tokenTask.exception)
|
||||
}
|
||||
|
||||
// 1) Unsubscribe old topic jika beda
|
||||
val doSubscribeNew = {
|
||||
FirebaseMessaging.getInstance().subscribeToTopic(newTopic)
|
||||
.addOnCompleteListener { subTask ->
|
||||
Log.d(
|
||||
"FCM",
|
||||
"subscribe topic=$newTopic success=${subTask.isSuccessful} err=${subTask.exception}"
|
||||
)
|
||||
|
||||
if (subTask.isSuccessful) {
|
||||
Preferences.setLastFcmTopic(this, newTopic)
|
||||
}
|
||||
completeLoginProcess()
|
||||
}
|
||||
}
|
||||
|
||||
if (oldTopic.isNotBlank() && oldTopic != newTopic) {
|
||||
FirebaseMessaging.getInstance().unsubscribeFromTopic(oldTopic)
|
||||
.addOnCompleteListener { unSubTask ->
|
||||
Log.d(
|
||||
"FCM",
|
||||
"unsubscribe oldTopic=$oldTopic success=${unSubTask.isSuccessful} err=${unSubTask.exception}"
|
||||
)
|
||||
doSubscribeNew()
|
||||
}
|
||||
} else {
|
||||
// old kosong atau sama -> langsung subscribe
|
||||
doSubscribeNew()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun completeLoginProcess() {
|
||||
showProgressDialog(false)
|
||||
navigateTo(MainActivity::class.java, R.anim.right_in,
|
||||
R.anim.left_out)
|
||||
finish()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
}
|
||||
}
|
||||
321
app/src/main/java/com/amz/genie/activities/MainActivity.kt
Normal file
321
app/src/main/java/com/amz/genie/activities/MainActivity.kt
Normal file
@@ -0,0 +1,321 @@
|
||||
package com.amz.genie.activities
|
||||
|
||||
import android.content.Intent
|
||||
import android.graphics.PorterDuff
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.amz.genie.R
|
||||
import com.amz.genie.adapters.AddActionAdapter
|
||||
import com.amz.genie.fragments.ArchiveFragment
|
||||
import com.amz.genie.fragments.AssignedFragment
|
||||
import com.amz.genie.fragments.DashboardFragment
|
||||
import com.amz.genie.fragments.InboxFragment
|
||||
import com.amz.genie.fragments.TODOFragment
|
||||
import com.amz.genie.helpers.Preferences
|
||||
import com.amz.genie.helpers.Utils.forceLogoutAndGoLogin
|
||||
import com.amz.genie.models.AddActionItem
|
||||
import com.amz.genie.models.Komunikasi
|
||||
import com.amz.genie.services.APIMain
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.JsonParser
|
||||
import retrofit2.Call
|
||||
import retrofit2.Callback
|
||||
import retrofit2.Response
|
||||
|
||||
class MainActivity : BaseActivity(), View.OnClickListener {
|
||||
|
||||
private var dashboardFragment = DashboardFragment()
|
||||
private var inboxFragment = InboxFragment()
|
||||
private var todoFragment = TODOFragment()
|
||||
private var assignedFragment = AssignedFragment()
|
||||
private var archiveFragment = ArchiveFragment()
|
||||
|
||||
private lateinit var llDashboard: LinearLayout
|
||||
private lateinit var ivDashboard: ImageView
|
||||
private lateinit var tvDashboard: TextView
|
||||
private lateinit var llInbox: LinearLayout
|
||||
private lateinit var ivInbox: ImageView
|
||||
private lateinit var tvInbox: TextView
|
||||
private lateinit var llTodo: LinearLayout
|
||||
private lateinit var ivTodo: ImageView
|
||||
private lateinit var tvTodo: TextView
|
||||
private lateinit var llAssigned: LinearLayout
|
||||
private lateinit var ivAssigned: ImageView
|
||||
private lateinit var tvAssigned: TextView
|
||||
private lateinit var llArchive: LinearLayout
|
||||
private lateinit var ivArchive: ImageView
|
||||
private lateinit var tvArchive: TextView
|
||||
lateinit var ibSearch: ImageButton
|
||||
lateinit var ibAdd: ImageButton
|
||||
lateinit var ibMenu: ImageButton
|
||||
|
||||
private var layouts: Array<LinearLayout>? = null
|
||||
private var imageViews: Array<ImageView>? = null
|
||||
private var textViews: Array<TextView>? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_main)
|
||||
|
||||
initUI(savedInstanceState)
|
||||
}
|
||||
|
||||
private fun initUI(savedInstanceState: Bundle?) {
|
||||
llDashboard = findViewById(R.id.ll_dashboard_bottom_navigation)
|
||||
ivDashboard = findViewById(R.id.iv_dashboard_bottom_navigation)
|
||||
tvDashboard = findViewById(R.id.tv_dashboard_bottom_navigation)
|
||||
llInbox = findViewById(R.id.ll_inbox_bottom_navigation)
|
||||
ivInbox = findViewById(R.id.iv_inbox_bottom_navigation)
|
||||
tvInbox = findViewById(R.id.tv_inbox_bottom_navigation)
|
||||
llTodo = findViewById(R.id.ll_todo_bottom_navigation)
|
||||
ivTodo = findViewById(R.id.iv_todo_bottom_navigation)
|
||||
tvTodo = findViewById(R.id.tv_todo_bottom_navigation)
|
||||
llAssigned = findViewById(R.id.ll_assigned_bottom_navigation)
|
||||
ivAssigned = findViewById(R.id.iv_assigned_bottom_navigation)
|
||||
tvAssigned = findViewById(R.id.tv_assigned_bottom_navigation)
|
||||
llArchive = findViewById(R.id.ll_archive_bottom_navigation)
|
||||
ivArchive = findViewById(R.id.iv_archive_bottom_navigation)
|
||||
tvArchive = findViewById(R.id.tv_archive_bottom_navigation)
|
||||
ibSearch = findViewById(R.id.ib_search_main)
|
||||
ibAdd = findViewById(R.id.ib_add_aksi_main)
|
||||
ibMenu = findViewById(R.id.ib_settings_main)
|
||||
|
||||
layouts = arrayOf(llDashboard, llInbox, llTodo, llAssigned, llArchive)
|
||||
imageViews = arrayOf(ivDashboard, ivInbox, ivTodo, ivAssigned, ivArchive)
|
||||
textViews = arrayOf(tvDashboard, tvInbox, tvTodo, tvAssigned, tvArchive)
|
||||
|
||||
layouts?.forEach {
|
||||
it.setOnClickListener(this)
|
||||
}
|
||||
|
||||
if (savedInstanceState == null) {
|
||||
dashboardFragment = DashboardFragment()
|
||||
inboxFragment = InboxFragment()
|
||||
todoFragment = TODOFragment()
|
||||
assignedFragment = AssignedFragment()
|
||||
archiveFragment = ArchiveFragment()
|
||||
loadFragment(inboxFragment)
|
||||
ibSearch.visibility = View.VISIBLE
|
||||
setSelected(llInbox)
|
||||
}
|
||||
|
||||
setupActions()
|
||||
}
|
||||
|
||||
private fun setupActions() {
|
||||
ibAdd.setOnClickListener { showAddActionDialog() }
|
||||
ibMenu.setOnClickListener {
|
||||
val intent = Intent(this, MoreActivity::class.java)
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
private fun disableTab(linearLayout: LinearLayout, imageView: ImageView, textView: TextView) {
|
||||
val params = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
LinearLayout.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
params.weight = 0.8f
|
||||
params.gravity= Gravity.CENTER
|
||||
linearLayout.layoutParams = params
|
||||
linearLayout.background = null
|
||||
textView.visibility = View.GONE
|
||||
imageView.background=null
|
||||
|
||||
imageView.setColorFilter(
|
||||
ContextCompat.getColor(this, R.color.textColorSecondary),
|
||||
PorterDuff.Mode.MULTIPLY
|
||||
)
|
||||
}
|
||||
|
||||
private fun enable(linearLayout: LinearLayout, imageView: ImageView, textView: TextView) {
|
||||
linearLayout.background = ContextCompat.getDrawable(this, R.drawable.bg_bottom)
|
||||
val params = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
LinearLayout.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
params.weight = 0.8f
|
||||
params.gravity= Gravity.CENTER
|
||||
linearLayout.layoutParams = params
|
||||
imageView.setColorFilter(
|
||||
ContextCompat.getColor(this,
|
||||
R.color.colorBlack
|
||||
))
|
||||
textView.visibility = View.VISIBLE
|
||||
textView.setTextColor(ContextCompat.getColor(this, R.color.colorPrimaryDark))
|
||||
}
|
||||
|
||||
private fun setSelected(mBarImg: LinearLayout) {
|
||||
for ((index, linearLayout) in this.layouts!!.withIndex()) {
|
||||
if (linearLayout === mBarImg) {
|
||||
enable(linearLayout, this.imageViews?.get(index)!!, textViews?.get(index)!!)
|
||||
} else {
|
||||
disableTab(linearLayout, imageViews?.get(index)!!, textViews?.get(index)!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClick(p0: View?) {
|
||||
when (p0!!.id) {
|
||||
R.id.ll_dashboard_bottom_navigation -> {
|
||||
loadFragment(dashboardFragment)
|
||||
setSelected(llDashboard)
|
||||
|
||||
ibSearch.visibility = View.GONE
|
||||
}
|
||||
R.id.ll_inbox_bottom_navigation -> {
|
||||
loadFragment(inboxFragment)
|
||||
setSelected(llInbox)
|
||||
|
||||
ibSearch.visibility = View.VISIBLE
|
||||
}
|
||||
R.id.ll_todo_bottom_navigation -> {
|
||||
loadFragment(todoFragment)
|
||||
setSelected(llTodo)
|
||||
|
||||
ibSearch.visibility = View.GONE
|
||||
}
|
||||
R.id.ll_assigned_bottom_navigation -> {
|
||||
loadFragment(assignedFragment)
|
||||
setSelected(llAssigned)
|
||||
|
||||
ibSearch.visibility = View.GONE
|
||||
}
|
||||
R.id.ll_archive_bottom_navigation -> {
|
||||
loadFragment(archiveFragment)
|
||||
setSelected(llArchive)
|
||||
|
||||
ibSearch.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
if (Preferences.getUserData(this) != null) { // hanya kalau sudah login
|
||||
if (!arePushNotificationsEnabled(this)) {
|
||||
showNotificationDisabledPrompt()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showNotificationDisabledPrompt() {
|
||||
Snackbar.make(findViewById(android.R.id.content),
|
||||
"Notifikasi sedang nonaktif. Aktifkan agar dapat pembaruan penting.",
|
||||
Snackbar.LENGTH_LONG
|
||||
).setAction("Pengaturan") {
|
||||
openAppNotificationSettings()
|
||||
}.show()
|
||||
}
|
||||
|
||||
private fun openAppNotificationSettings() {
|
||||
val intent = Intent().apply {
|
||||
action = Settings.ACTION_APP_NOTIFICATION_SETTINGS
|
||||
putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
|
||||
}
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
private fun showAddActionDialog() {
|
||||
val dialogView = layoutInflater.inflate(R.layout.dialog_add_action, null)
|
||||
val rv = dialogView.findViewById<RecyclerView>(R.id.rvActions)
|
||||
|
||||
val alertDialog = androidx.appcompat.app.AlertDialog.Builder(this)
|
||||
.setView(dialogView)
|
||||
.create()
|
||||
|
||||
// 1) Siapkan list mutable, isi dulu id=0
|
||||
val items = mutableListOf(
|
||||
AddActionItem(0, "Custom")
|
||||
)
|
||||
|
||||
// 2) Pasang adapter dari awal biar dialog bisa tampil, lalu nanti update saat API selesai
|
||||
val adapter = AddActionAdapter(items) { item ->
|
||||
alertDialog.dismiss()
|
||||
when (item.id) {
|
||||
0 -> { // Custom
|
||||
startActivity(Intent(this@MainActivity,
|
||||
AddCustomActionActivity::class.java))
|
||||
}
|
||||
else -> {
|
||||
val intent = Intent(this@MainActivity,
|
||||
AddTemplateActionActivity::class.java)
|
||||
val data = Gson().toJson(item)
|
||||
intent.putExtra("data", data)
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rv.layoutManager = LinearLayoutManager(this)
|
||||
rv.adapter = adapter
|
||||
|
||||
alertDialog.show()
|
||||
|
||||
// 3) Ambil data dari API, tambahkan ke list, lalu urutkan dari id=0
|
||||
APIMain.require().selectionServices
|
||||
.komunikasi(Preferences.getAccessToken(this))
|
||||
.enqueue(object : Callback<ArrayList<Komunikasi>> {
|
||||
override fun onResponse(
|
||||
call: Call<ArrayList<Komunikasi>>,
|
||||
response: Response<ArrayList<Komunikasi>>
|
||||
) {
|
||||
if (!response.isSuccessful) {
|
||||
val raw = runCatching { response.errorBody()?.string().orEmpty() }.getOrDefault("")
|
||||
val expired = raw.contains("Signature has expired", true) || raw.contains("token_expired", true)
|
||||
|
||||
val message = when {
|
||||
(response.code() == 401 || response.code() == 500) && expired -> {
|
||||
forceLogoutAndGoLogin(this@MainActivity)
|
||||
"Session expired. Please login again."
|
||||
}
|
||||
response.code() == 400 -> runCatching {
|
||||
JsonParser.parseString(raw).asJsonObject["message"].asString
|
||||
}.getOrDefault("Bad request")
|
||||
else -> "${response.code()}, ${response.message()}"
|
||||
}
|
||||
|
||||
showSnack(message)
|
||||
response.errorBody()?.close()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
val body = response.body().orEmpty()
|
||||
|
||||
// tambahkan hasil API ke list
|
||||
body.forEach { k ->
|
||||
items.add(AddActionItem(k.kode, k.komunikasi,
|
||||
k.id_tentang, k.id_tipe_komunikasi,
|
||||
k.komunikasi_detail))
|
||||
}
|
||||
|
||||
// urutkan dari id paling kecil (custom id=0 otomatis di atas)
|
||||
items.sortBy { it.id }
|
||||
|
||||
// refresh adapter (kalau adapter kamu belum punya method update, lihat catatan di bawah)
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call<ArrayList<Komunikasi>>, t: Throwable) {
|
||||
showSnack(t.message.toString())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun showSnack(message: String) {
|
||||
Snackbar.make(findViewById(android.R.id.content), message, Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
}
|
||||
98
app/src/main/java/com/amz/genie/activities/MoreActivity.kt
Normal file
98
app/src/main/java/com/amz/genie/activities/MoreActivity.kt
Normal file
@@ -0,0 +1,98 @@
|
||||
package com.amz.genie.activities
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.widget.Button
|
||||
import android.widget.ImageButton
|
||||
import android.widget.TextView
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import com.amz.genie.R
|
||||
import com.amz.genie.helpers.Preferences
|
||||
import com.amz.genie.helpers.Utils.forceLogoutAndGoLogin
|
||||
import com.amz.genie.helpers.Utils.isFemale
|
||||
import com.amz.genie.helpers.Utils.uriToBase64
|
||||
import com.amz.genie.models.Pengguna
|
||||
import com.google.android.material.imageview.ShapeableImageView
|
||||
import com.google.gson.Gson
|
||||
|
||||
class MoreActivity : BaseActivity() {
|
||||
|
||||
private lateinit var ibBack: ImageButton
|
||||
private lateinit var ibBrowsePicture: ImageButton
|
||||
private lateinit var btLogout: Button
|
||||
private lateinit var sivEmployee: ShapeableImageView
|
||||
private lateinit var tvName: TextView
|
||||
private lateinit var tvJobDesk: TextView
|
||||
private lateinit var pickImageLauncher: ActivityResultLauncher<String>
|
||||
private var base64Picture = ""
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_more)
|
||||
|
||||
initUI()
|
||||
|
||||
}
|
||||
|
||||
private fun initUI() {
|
||||
ibBack = findViewById(R.id.ib_back_more)
|
||||
ibBrowsePicture = findViewById(R.id.ib_edit_picture_more)
|
||||
btLogout = findViewById(R.id.bt_logout_more)
|
||||
sivEmployee = findViewById(R.id.siv_employee_more)
|
||||
tvJobDesk = findViewById(R.id.tv_jobdesk_more)
|
||||
tvName = findViewById(R.id.tv_name_more)
|
||||
|
||||
initData()
|
||||
setupActions()
|
||||
}
|
||||
|
||||
private fun setupActions() {
|
||||
ibBack.setOnClickListener { handleBackPress(0) }
|
||||
|
||||
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() = handleBackPress(0)
|
||||
})
|
||||
|
||||
pickImageLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
|
||||
uri?.let {
|
||||
sivEmployee.setImageURI(it)
|
||||
base64Picture = uriToBase64(this, it).toString()
|
||||
}
|
||||
}
|
||||
|
||||
btLogout.setOnClickListener {
|
||||
showLogoutDialog()
|
||||
}
|
||||
|
||||
ibBrowsePicture.setOnClickListener {
|
||||
pickImageLauncher.launch("image/*")
|
||||
}
|
||||
}
|
||||
|
||||
private fun initData() {
|
||||
val data = Gson().fromJson(Preferences.getUserData(this),
|
||||
Pengguna::class.java)
|
||||
|
||||
tvName.text = data.pegawai?.nama
|
||||
tvJobDesk.text = data.pegawai?.jabatan?.nama
|
||||
val female = isFemale(data.pegawai?.id_kelamin)
|
||||
sivEmployee.setImageResource(if (female) R.drawable.ic_woman else R.drawable.ic_man)
|
||||
}
|
||||
|
||||
private fun showLogoutDialog() {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle("Logout")
|
||||
.setMessage("Yakin ingin keluar dari akun ini?")
|
||||
.setNegativeButton("Batal") { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
}
|
||||
.setPositiveButton("Logout") { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
forceLogoutAndGoLogin(this@MoreActivity)
|
||||
}
|
||||
.show()
|
||||
}
|
||||
}
|
||||
39
app/src/main/java/com/amz/genie/activities/SplashActivity.kt
Normal file
39
app/src/main/java/com/amz/genie/activities/SplashActivity.kt
Normal file
@@ -0,0 +1,39 @@
|
||||
package com.amz.genie.activities
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import com.amz.genie.R
|
||||
import com.amz.genie.helpers.Preferences
|
||||
import com.amz.genie.helpers.Utils.isNetworkAvailable
|
||||
|
||||
|
||||
@SuppressLint("CustomSplashScreen")
|
||||
class SplashActivity : BaseActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_splash)
|
||||
|
||||
Handler(Looper.getMainLooper()).postDelayed({
|
||||
if (isNetworkAvailable(this)) {
|
||||
if (Preferences.getUserData(this) == null) {
|
||||
navigateTo(LoginActivity::class.java,
|
||||
R.anim.right_in, R.anim.left_out)
|
||||
finish()
|
||||
|
||||
} else {
|
||||
navigateTo(MainActivity::class.java,
|
||||
R.anim.right_in, R.anim.left_out)
|
||||
finish()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}, 800)
|
||||
}
|
||||
}
|
||||
55
app/src/main/java/com/amz/genie/adapters/ActivityAdapter.kt
Normal file
55
app/src/main/java/com/amz/genie/adapters/ActivityAdapter.kt
Normal file
@@ -0,0 +1,55 @@
|
||||
package com.amz.genie.adapters
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageButton
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.amz.genie.R
|
||||
import com.amz.genie.models.ActivityItem
|
||||
|
||||
class ActivityAdapter(
|
||||
private val onDelete: (Int) -> Unit
|
||||
) : RecyclerView.Adapter<ActivityAdapter.VH>() {
|
||||
|
||||
private val items = mutableListOf<ActivityItem>()
|
||||
|
||||
fun submitList(newItems: List<ActivityItem>) {
|
||||
items.clear()
|
||||
items.addAll(newItems)
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
fun addItem(item: ActivityItem) {
|
||||
items.add(item)
|
||||
notifyItemInserted(items.lastIndex)
|
||||
}
|
||||
|
||||
fun removeAt(position: Int) {
|
||||
if (position < 0 || position >= items.size) return
|
||||
items.removeAt(position)
|
||||
notifyItemRemoved(position)
|
||||
}
|
||||
|
||||
fun getItems(): List<ActivityItem> = items.toList()
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
|
||||
val v = LayoutInflater.from(parent.context).inflate(R.layout.item_activity, parent, false)
|
||||
return VH(v)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: VH, position: Int) = holder.bind(items[position], position)
|
||||
|
||||
override fun getItemCount(): Int = items.size
|
||||
|
||||
inner class VH(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
private val tv = itemView.findViewById<TextView>(R.id.tv_activity)
|
||||
private val btnDelete = itemView.findViewById<ImageButton>(R.id.btn_delete_activity)
|
||||
|
||||
fun bind(item: ActivityItem, pos: Int) {
|
||||
tv.text = item.text
|
||||
btnDelete.setOnClickListener { onDelete(pos) }
|
||||
}
|
||||
}
|
||||
}
|
||||
33
app/src/main/java/com/amz/genie/adapters/AddActionAdapter.kt
Normal file
33
app/src/main/java/com/amz/genie/adapters/AddActionAdapter.kt
Normal file
@@ -0,0 +1,33 @@
|
||||
package com.amz.genie.adapters
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import com.amz.genie.R
|
||||
import com.amz.genie.models.AddActionItem
|
||||
|
||||
class AddActionAdapter(
|
||||
private val items: List<AddActionItem>,
|
||||
private val onClick: (AddActionItem) -> Unit
|
||||
) : androidx.recyclerview.widget.RecyclerView.Adapter<AddActionAdapter.VH>() {
|
||||
|
||||
inner class VH(itemView: View) : androidx.recyclerview.widget.RecyclerView.ViewHolder(itemView) {
|
||||
val tvName: TextView = itemView.findViewById(R.id.tv_name_item_add_action)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
|
||||
val v = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.item_add_action, parent, false)
|
||||
return VH(v)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: VH, position: Int) {
|
||||
val item = items[position]
|
||||
holder.tvName.text = item.title
|
||||
|
||||
holder.itemView.setOnClickListener { onClick(item) }
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = items.size
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.amz.genie.adapters
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageButton
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.amz.genie.R
|
||||
import com.amz.genie.models.AttachmentItem
|
||||
|
||||
class AttachmentAdapter(
|
||||
private val onRemove: (AttachmentItem) -> Unit
|
||||
) : RecyclerView.Adapter<AttachmentAdapter.VH>() {
|
||||
|
||||
private val items = mutableListOf<AttachmentItem>()
|
||||
|
||||
fun submitList(newItems: List<AttachmentItem>) {
|
||||
items.clear()
|
||||
items.addAll(newItems)
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
|
||||
val view = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.item_attachment_chip, parent, false)
|
||||
return VH(view)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: VH, position: Int) {
|
||||
holder.bind(items[position])
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = items.size
|
||||
|
||||
inner class VH(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
private val tvName: TextView = itemView.findViewById(R.id.tv_name_attachment_chip)
|
||||
private val ibRemove: ImageButton = itemView.findViewById(R.id.ib_remove_attachment_chip)
|
||||
|
||||
fun bind(item: AttachmentItem) {
|
||||
tvName.text = item.name
|
||||
ibRemove.setOnClickListener { onRemove(item) }
|
||||
}
|
||||
}
|
||||
}
|
||||
637
app/src/main/java/com/amz/genie/adapters/ChatAdapter.kt
Normal file
637
app/src/main/java/com/amz/genie/adapters/ChatAdapter.kt
Normal file
@@ -0,0 +1,637 @@
|
||||
package com.amz.genie.adapters
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.MimeTypeMap
|
||||
import android.widget.ImageButton
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.amz.genie.R
|
||||
import com.amz.genie.activities.AttachmentPreviewActivity
|
||||
import com.amz.genie.helpers.AttachmentDownloader
|
||||
import com.amz.genie.helpers.AttachmentExtractor
|
||||
import com.amz.genie.helpers.ChatMessageRenderer
|
||||
import com.amz.genie.helpers.Preferences
|
||||
import com.amz.genie.helpers.Preferences.API_URL
|
||||
import com.amz.genie.models.ChatItem
|
||||
import com.amz.genie.services.APIMain
|
||||
import com.google.android.material.imageview.ShapeableImageView
|
||||
import com.google.gson.JsonArray
|
||||
import com.google.gson.JsonElement
|
||||
import com.google.gson.JsonObject
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
class ChatAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
|
||||
companion object {
|
||||
private const val VT_LEFT = 1
|
||||
private const val VT_RIGHT = 2
|
||||
|
||||
const val JENIS_TEKS = 0
|
||||
const val JENIS_WAKTU = 1
|
||||
const val JENIS_TANGGAL = 2
|
||||
const val JENIS_JAM = 3
|
||||
const val JENIS_ANGKA = 4
|
||||
const val JENIS_PECAHAN = 5
|
||||
const val JENIS_STRING = 6
|
||||
const val JENIS_LIST = 10
|
||||
const val JENIS_LAMPIRAN = 11
|
||||
}
|
||||
|
||||
private data class SimpleAtt(val url: String, val fileName: String, val label: String = "Lampiran")
|
||||
private val items = mutableListOf<ChatItem>()
|
||||
|
||||
fun submitList(newItems: List<ChatItem>) {
|
||||
items.clear()
|
||||
items.addAll(newItems)
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
fun prepend(oldItems: List<ChatItem>) {
|
||||
if (oldItems.isEmpty()) return
|
||||
items.addAll(0, oldItems)
|
||||
notifyItemRangeInserted(0, oldItems.size)
|
||||
}
|
||||
|
||||
fun getFirstVisibleId(): String? = items.firstOrNull()?.id
|
||||
|
||||
override fun getItemViewType(position: Int): Int =
|
||||
if (items[position].isMine) VT_RIGHT else VT_LEFT
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
val inf = LayoutInflater.from(parent.context)
|
||||
return if (viewType == VT_RIGHT) {
|
||||
RightVH(inf.inflate(R.layout.item_chat_right, parent, false))
|
||||
} else {
|
||||
LeftVH(inf.inflate(R.layout.item_chat_left, parent, false))
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount() = items.size
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
val item = items[position]
|
||||
when (holder) {
|
||||
is LeftVH -> holder.bind(item)
|
||||
is RightVH -> holder.bind(item)
|
||||
}
|
||||
}
|
||||
|
||||
// =========================
|
||||
// ViewHolders
|
||||
// =========================
|
||||
inner class LeftVH(v: View) : RecyclerView.ViewHolder(v) {
|
||||
private val tvHeader = v.findViewById<TextView>(R.id.tv_header_item_chat_left)
|
||||
private val tvMessage = v.findViewById<TextView>(R.id.tv_message_item_chat_left)
|
||||
private val tvTime = v.findViewById<TextView>(R.id.tv_time_item_chat_left)
|
||||
private val ivAvatar = v.findViewById<ShapeableImageView>(R.id.iv_avatar_item_chat_left)
|
||||
private val llAttachments = v.findViewById<LinearLayout>(R.id.ll_attachments_item_chat_left)
|
||||
private val llDetail = v.findViewById<LinearLayout>(R.id.ll_detail_item_chat_left)
|
||||
|
||||
fun bind(item: ChatItem) {
|
||||
tvMessage.text = ChatMessageRenderer.render(item.message)
|
||||
tvTime.text = item.timeText ?: ""
|
||||
|
||||
bindKomunikasiDetail(llDetail, item.message)
|
||||
|
||||
val headerText = buildString {
|
||||
append(item.senderName ?: "")
|
||||
val jobOutlet = listOfNotNull(item.senderJob, item.senderOutlet).joinToString(" - ")
|
||||
if (jobOutlet.isNotBlank()) {
|
||||
append(" • ")
|
||||
append(jobOutlet)
|
||||
}
|
||||
}.trim()
|
||||
|
||||
val showMeta = !item.isSameSenderAsPrev
|
||||
tvHeader.isVisible = showMeta
|
||||
ivAvatar.isVisible = showMeta
|
||||
tvHeader.text = headerText.ifBlank { "-" }
|
||||
|
||||
bindAttachments(llAttachments, item.message)
|
||||
}
|
||||
}
|
||||
|
||||
inner class RightVH(v: View) : RecyclerView.ViewHolder(v) {
|
||||
private val tvMessage = v.findViewById<TextView>(R.id.tv_message_item_chat_right)
|
||||
private val tvTime = v.findViewById<TextView>(R.id.tv_time_item_chat_right)
|
||||
private val llAttachments = v.findViewById<LinearLayout>(R.id.ll_attachments_item_chat_right)
|
||||
private val llDetail = v.findViewById<LinearLayout>(R.id.ll_detail_item_chat_right)
|
||||
|
||||
fun bind(item: ChatItem) {
|
||||
tvMessage.text = ChatMessageRenderer.render(item.message)
|
||||
tvTime.text = item.timeText ?: ""
|
||||
bindKomunikasiDetail(llDetail, item.message)
|
||||
bindAttachments(llAttachments, item.message)
|
||||
}
|
||||
}
|
||||
|
||||
// =========================
|
||||
// Attachments
|
||||
// =========================
|
||||
private fun fallbackExtractAttachments(msg: JsonObject?): List<SimpleAtt> {
|
||||
if (msg == null) return emptyList()
|
||||
|
||||
fun arr(vararg keys: String) = keys.firstNotNullOfOrNull { k ->
|
||||
msg.get(k)?.takeIf { it.isJsonArray }?.asJsonArray
|
||||
}
|
||||
|
||||
val a = arr("attachments", "lampiran", "files", "berkas") ?: return emptyList()
|
||||
|
||||
val out = ArrayList<SimpleAtt>()
|
||||
for (i in 0 until a.size()) {
|
||||
val it = a[i]
|
||||
if (!it.isJsonObject) continue
|
||||
val o = it.asJsonObject
|
||||
|
||||
val url =
|
||||
o.get("url")?.asStringSafe()
|
||||
?: o.get("file_url")?.asStringSafe()
|
||||
?: o.get("path")?.asStringSafe()
|
||||
?: ""
|
||||
|
||||
val name =
|
||||
o.get("fileName")?.asStringSafe()
|
||||
?: o.get("filename")?.asStringSafe()
|
||||
?: o.get("name")?.asStringSafe()
|
||||
?: o.get("nama_file")?.asStringSafe()
|
||||
?: "Attachment"
|
||||
|
||||
if (url.isNotBlank()) out.add(SimpleAtt(url = url, fileName = name))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun extractAksiKomunikasiLampiran(msg: JsonObject?): List<SimpleAtt> {
|
||||
if (msg == null) return emptyList()
|
||||
val el = msg.get("aksi_komunikasi_lampiran") ?: return emptyList()
|
||||
|
||||
val urls = mutableListOf<String>()
|
||||
|
||||
fun push(u: String) {
|
||||
val s = u.trim()
|
||||
if (s.isBlank()) return
|
||||
urls.add(s)
|
||||
}
|
||||
|
||||
try {
|
||||
when {
|
||||
// ✅ format kamu sekarang: object { "weirdKey": [ "path1", ... ], "weirdKey2": [..] }
|
||||
el.isJsonObject -> {
|
||||
val o = el.asJsonObject
|
||||
|
||||
// case normal: {nilai:[...]} (kalau suatu saat kamu rapihin backend)
|
||||
val nilai = o.get("nilai")
|
||||
if (nilai != null) {
|
||||
if (nilai.isJsonArray) {
|
||||
for (i in 0 until nilai.asJsonArray.size()) push(nilai.asJsonArray[i].asStringSafe())
|
||||
} else if (nilai.isJsonPrimitive) push(nilai.asString)
|
||||
} else {
|
||||
// case weird: ambil semua entry yang value-nya JsonArray
|
||||
for ((_, v) in o.entrySet()) {
|
||||
if (!v.isJsonArray) continue
|
||||
val arr = v.asJsonArray
|
||||
for (i in 0 until arr.size()) {
|
||||
push(arr[i].asStringSafe())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fallback kalau jadi array
|
||||
el.isJsonArray -> {
|
||||
val arr = el.asJsonArray
|
||||
for (i in 0 until arr.size()) {
|
||||
val it = arr[i]
|
||||
if (it.isJsonPrimitive) push(it.asString)
|
||||
else if (it.isJsonObject) {
|
||||
val o = it.asJsonObject
|
||||
val u = o.get("isian")?.asStringSafe()
|
||||
?: o.get("url")?.asStringSafe()
|
||||
?: o.get("path")?.asStringSafe()
|
||||
?: ""
|
||||
if (u.isNotBlank()) push(u)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
|
||||
// dedup + mapping ke chip
|
||||
return urls.distinct().map { u ->
|
||||
SimpleAtt(url = u, fileName = fileNameFromUrl(u), label = "Lampiran")
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindAttachments(container: LinearLayout, msg: JsonObject?) {
|
||||
container.removeAllViews()
|
||||
|
||||
val list = AttachmentExtractor.extractAll(msg)
|
||||
|
||||
// ✅ ambil lampiran dari aksi_komunikasi_lampiran (komunikasi detail jenis 11)
|
||||
val aksiLampiran = extractAksiKomunikasiLampiran(msg)
|
||||
|
||||
val fallback = if (list.isEmpty() && aksiLampiran.isEmpty()) fallbackExtractAttachments(msg) else emptyList()
|
||||
|
||||
val merged = ArrayList<SimpleAtt>()
|
||||
|
||||
// list dari extractor utama (kalau ada)
|
||||
for (att in list) {
|
||||
merged.add(SimpleAtt(url = att.url, fileName = att.fileName, label = att.label))
|
||||
}
|
||||
|
||||
// lampiran komunikasi detail
|
||||
merged.addAll(aksiLampiran)
|
||||
|
||||
// fallback legacy
|
||||
merged.addAll(fallback)
|
||||
|
||||
if (merged.isEmpty()) {
|
||||
container.isVisible = false
|
||||
return
|
||||
}
|
||||
|
||||
container.isVisible = true
|
||||
val inf = LayoutInflater.from(container.context)
|
||||
|
||||
for (att in merged) {
|
||||
val chip = inf.inflate(R.layout.item_attachment_chip, container, false)
|
||||
val tvName = chip.findViewById<TextView>(R.id.tv_name_attachment_chip)
|
||||
val ibRemove = chip.findViewById<ImageButton>(R.id.ib_remove_attachment_chip)
|
||||
ibRemove.isVisible = false
|
||||
|
||||
tvName.text = "${att.label} • ${att.fileName}"
|
||||
chip.setOnClickListener {
|
||||
openProtectedAttachment(container.context, att.url, att.fileName)
|
||||
}
|
||||
container.addView(chip)
|
||||
}
|
||||
}
|
||||
|
||||
private fun fileNameFromUrl(url: String): String {
|
||||
val clean = url.substringBefore("?").substringBefore("#")
|
||||
return clean.substringAfterLast("/", clean)
|
||||
}
|
||||
|
||||
private fun openProtectedAttachment(ctx: Context, url: String, fileName: String) {
|
||||
val rawToken = Preferences.getAccessToken(ctx).orEmpty()
|
||||
if (rawToken.isBlank()) {
|
||||
Toast.makeText(ctx, "Token kosong. Tidak bisa buka lampiran.", Toast.LENGTH_LONG).show()
|
||||
return
|
||||
}
|
||||
|
||||
Toast.makeText(ctx, "Membuka lampiran...", Toast.LENGTH_SHORT).show()
|
||||
|
||||
val fixedUrl = if (url.startsWith("http", true)) url else {
|
||||
API_URL.trimEnd('/') + "/uploads/" + url.trimStart('/')
|
||||
}
|
||||
|
||||
thread {
|
||||
try {
|
||||
val file = AttachmentDownloader.downloadToCache(
|
||||
ctx = ctx,
|
||||
url = fixedUrl,
|
||||
fileName = fileName,
|
||||
token = Preferences.getAccessToken(ctx)!!
|
||||
)
|
||||
|
||||
(ctx as? Activity)?.runOnUiThread {
|
||||
openDownloadedFile(ctx, file, fileName)
|
||||
} ?: run {
|
||||
openDownloadedFile(ctx, file, fileName, forceNewTask = true)
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
(ctx as? Activity)?.runOnUiThread {
|
||||
Toast.makeText(ctx, e.message ?: "Gagal buka lampiran", Toast.LENGTH_LONG).show()
|
||||
} ?: run {
|
||||
Toast.makeText(ctx, e.message ?: "Gagal buka lampiran", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun openDownloadedFile(ctx: Context, file: File, fileName: String, forceNewTask: Boolean = false) {
|
||||
val isImage = runCatching { BitmapFactory.decodeFile(file.absolutePath) != null }.getOrDefault(false)
|
||||
|
||||
if (isImage) {
|
||||
val i = Intent(ctx, AttachmentPreviewActivity::class.java).apply {
|
||||
putExtra("local_path", file.absolutePath)
|
||||
putExtra("title", fileName.ifBlank { file.name })
|
||||
if (forceNewTask) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
ctx.startActivity(i)
|
||||
} else {
|
||||
openLocalFile(ctx, file, fileName, forceNewTask)
|
||||
}
|
||||
}
|
||||
|
||||
private fun openLocalFile(ctx: Context, file: File, fileName: String, forceNewTask: Boolean) {
|
||||
val uri: Uri = FileProvider.getUriForFile(ctx, "${ctx.packageName}.fileprovider", file)
|
||||
val mime = guessMime(fileName)
|
||||
|
||||
val i = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, mime)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
if (forceNewTask) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
|
||||
ctx.startActivity(Intent.createChooser(i, "Buka lampiran"))
|
||||
}
|
||||
|
||||
private fun guessMime(fileName: String): String {
|
||||
val ext = fileName.substringAfterLast('.', "").lowercase(Locale.getDefault())
|
||||
val mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext)
|
||||
return mime ?: "application/octet-stream"
|
||||
}
|
||||
|
||||
// =========================
|
||||
// ✅ Komunikasi Detail (FIX)
|
||||
// =========================
|
||||
private data class DetailField(val label: String, val value: String)
|
||||
|
||||
private fun bindKomunikasiDetail(container: LinearLayout, msg: JsonObject?) {
|
||||
container.removeAllViews()
|
||||
if (msg == null) {
|
||||
container.isVisible = false
|
||||
return
|
||||
}
|
||||
|
||||
val details = extractKomunikasiDetailFields(msg)
|
||||
if (details.isEmpty()) {
|
||||
container.isVisible = false
|
||||
return
|
||||
}
|
||||
|
||||
container.isVisible = true
|
||||
val inf = LayoutInflater.from(container.context)
|
||||
|
||||
for (d in details) {
|
||||
val row = inf.inflate(R.layout.item_komunikasi_detail_row, container, false)
|
||||
val tvLabel = row.findViewById<TextView>(R.id.tv_label)
|
||||
val tvValue = row.findViewById<TextView>(R.id.tv_value)
|
||||
|
||||
tvLabel.text = d.label
|
||||
tvValue.text = d.value.ifBlank { "-" }
|
||||
|
||||
container.addView(row)
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractKomunikasiDetailFields(msg: JsonObject): List<DetailField> {
|
||||
|
||||
fun arrFrom(vararg paths: String): JsonArray? {
|
||||
for (p in paths) {
|
||||
var cur: JsonElement = msg
|
||||
var ok = true
|
||||
for (seg in p.split(".")) {
|
||||
if (!cur.isJsonObject) { ok = false; break }
|
||||
val o = cur.asJsonObject
|
||||
if (!o.has(seg)) { ok = false; break }
|
||||
cur = o.get(seg)
|
||||
}
|
||||
if (ok && cur.isJsonArray) return cur.asJsonArray
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun JsonElement?.asStringSafeNullable(): String? {
|
||||
if (this == null || this.isJsonNull) return null
|
||||
return try {
|
||||
when {
|
||||
this.isJsonPrimitive -> this.asString
|
||||
this.isJsonArray -> this.asJsonArray.joinToString("\n") { it.asStringSafeNullable().orEmpty() }
|
||||
this.isJsonObject -> {
|
||||
val o = this.asJsonObject
|
||||
val pick = listOf("nilai", "value", "teks", "string", "uraian", "isi")
|
||||
.firstNotNullOfOrNull { k -> o.get(k)?.takeIf { !it.isJsonNull } }
|
||||
pick?.asStringSafeNullable() ?: o.toString()
|
||||
}
|
||||
else -> this.toString()
|
||||
}
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
fun elementToListText(el: JsonElement?): String? {
|
||||
if (el == null || el.isJsonNull) return null
|
||||
return try {
|
||||
when {
|
||||
el.isJsonArray -> el.asJsonArray.joinToString("\n") { "• " + it.asStringSafe() }
|
||||
el.isJsonPrimitive -> el.asString
|
||||
el.isJsonObject -> {
|
||||
val o = el.asJsonObject
|
||||
val v = o.get("nilai")
|
||||
if (v != null && v.isJsonArray)
|
||||
v.asJsonArray.joinToString("\n") { "• " + it.asStringSafe() }
|
||||
else el.toString()
|
||||
}
|
||||
else -> el.toString()
|
||||
}
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
fun readFromDetailValues(kodeDetail: Int, jenisId: Int): String? {
|
||||
val dv = msg.get("detail_values")?.takeIf { it.isJsonObject }?.asJsonObject ?: return null
|
||||
val el = dv.get(kodeDetail.toString()) ?: return null
|
||||
return if (jenisId == JENIS_LIST) elementToListText(el)
|
||||
else el.asStringSafeNullable()
|
||||
}
|
||||
|
||||
fun readTypedSingle(jenisId: Int): String? {
|
||||
return when (jenisId) {
|
||||
|
||||
JENIS_TANGGAL -> {
|
||||
msg.get("aksi_komunikasi_tanggal")
|
||||
?.takeIf { it.isJsonObject }
|
||||
?.asJsonObject
|
||||
?.get("nilai")
|
||||
.asStringSafeNullable()
|
||||
}
|
||||
|
||||
JENIS_WAKTU -> {
|
||||
msg.get("aksi_komunikasi_waktu")
|
||||
?.takeIf { it.isJsonObject }
|
||||
?.asJsonObject
|
||||
?.get("nilai")
|
||||
.asStringSafeNullable()
|
||||
}
|
||||
|
||||
JENIS_JAM -> {
|
||||
msg.get("aksi_komunikasi_jam")
|
||||
?.takeIf { it.isJsonObject }
|
||||
?.asJsonObject
|
||||
?.get("nilai")
|
||||
.asStringSafeNullable()
|
||||
}
|
||||
|
||||
JENIS_ANGKA -> {
|
||||
msg.get("aksi_komunikasi_angka")
|
||||
?.takeIf { it.isJsonObject }
|
||||
?.asJsonObject
|
||||
?.get("nilai")
|
||||
.asStringSafeNullable()
|
||||
}
|
||||
|
||||
JENIS_PECAHAN -> {
|
||||
msg.get("aksi_komunikasi_pecahan")
|
||||
?.takeIf { it.isJsonObject }
|
||||
?.asJsonObject
|
||||
?.get("nilai")
|
||||
.asStringSafeNullable()
|
||||
}
|
||||
|
||||
JENIS_TEKS, JENIS_STRING -> {
|
||||
msg.get("aksi_komunikasi_teks")
|
||||
?.takeIf { it.isJsonObject }
|
||||
?.asJsonObject
|
||||
?.get("nilai")
|
||||
.asStringSafeNullable()
|
||||
?: msg.get("aksi_komunikasi_string")
|
||||
?.takeIf { it.isJsonObject }
|
||||
?.asJsonObject
|
||||
?.get("nilai")
|
||||
.asStringSafeNullable()
|
||||
}
|
||||
|
||||
JENIS_LIST -> {
|
||||
val el = msg.get("aksi_komunikasi_list") ?: return null
|
||||
extractListFromWeirdPayload(el)
|
||||
}
|
||||
|
||||
JENIS_LAMPIRAN -> {
|
||||
val atts = extractAksiKomunikasiLampiran(msg)
|
||||
if (atts.isEmpty()) return null
|
||||
atts.joinToString("\n") { "• ${it.fileName}" }
|
||||
}
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
val detailArr = arrFrom(
|
||||
"komunikasi.komunikasi_detail",
|
||||
"komunikasi_detail",
|
||||
"aksi.komunikasi.komunikasi_detail"
|
||||
) ?: return emptyList()
|
||||
|
||||
val out = ArrayList<DetailField>()
|
||||
|
||||
for (i in 0 until detailArr.size()) {
|
||||
val d = detailArr[i]
|
||||
if (!d.isJsonObject) continue
|
||||
val dobj = d.asJsonObject
|
||||
|
||||
val label = dobj.get("isian").asStringSafe().ifBlank { "Field" }
|
||||
|
||||
val kodeDetail =
|
||||
dobj.get("kode")?.asIntSafe()
|
||||
?: dobj.get("kode_komunikasi_detail")?.asIntSafe()
|
||||
?: continue
|
||||
|
||||
val jenisObj = dobj.get("jenis_isian")
|
||||
?.takeIf { it.isJsonObject }
|
||||
?.asJsonObject
|
||||
|
||||
val jenisId =
|
||||
jenisObj?.get("id")?.asIntSafe()
|
||||
?: dobj.get("id_jenis_isian")?.asIntSafe()
|
||||
?: 0
|
||||
|
||||
val value =
|
||||
readFromDetailValues(kodeDetail, jenisId)
|
||||
?: readTypedSingle(jenisId)
|
||||
?: ""
|
||||
|
||||
if (value.isNotBlank()) {
|
||||
out.add(DetailField(label, value.trim()))
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
private fun extractListFromWeirdPayload(el: JsonElement): String? {
|
||||
try {
|
||||
// Case 1: langsung array
|
||||
if (el.isJsonArray) {
|
||||
val arr = el.asJsonArray
|
||||
val values = mutableListOf<String>()
|
||||
for (i in 0 until arr.size()) {
|
||||
val it = arr[i]
|
||||
when {
|
||||
it.isJsonPrimitive -> values.add(it.asString)
|
||||
it.isJsonObject -> {
|
||||
val o = it.asJsonObject
|
||||
val v = o.get("nilai")?.let { x ->
|
||||
if (x.isJsonPrimitive) x.asString else x.toString()
|
||||
}
|
||||
if (!v.isNullOrBlank()) values.add(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
return values.takeIf { it.isNotEmpty() }?.joinToString("\n") { "• $it" }
|
||||
}
|
||||
|
||||
// Case 2: object normal {nilai:[...]} atau {baris:[...], nilai:[...]}
|
||||
if (el.isJsonObject) {
|
||||
val o = el.asJsonObject
|
||||
|
||||
// 2a) standar: ada key "nilai"
|
||||
val nilai = o.get("nilai")
|
||||
if (nilai != null) {
|
||||
return extractListFromWeirdPayload(nilai)
|
||||
}
|
||||
|
||||
// 2b) payload kamu: key nya string dict -> value nya JsonArray
|
||||
// ambil semua value yang JsonArray, gabung
|
||||
val merged = mutableListOf<String>()
|
||||
for ((_, v) in o.entrySet()) {
|
||||
if (!v.isJsonArray) continue
|
||||
val arr = v.asJsonArray
|
||||
for (i in 0 until arr.size()) {
|
||||
val it = arr[i]
|
||||
if (it.isJsonNull) continue
|
||||
val s = if (it.isJsonPrimitive) it.asString else it.toString()
|
||||
if (s.isNotBlank()) merged.add(s)
|
||||
}
|
||||
}
|
||||
return merged.takeIf { it.isNotEmpty() }?.joinToString("\n") { "• $it" }
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (_: Exception) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// =========================
|
||||
// Helpers Json Safe
|
||||
// =========================
|
||||
private fun JsonElement.asStringSafe(): String {
|
||||
return try {
|
||||
if (isJsonNull) "" else if (isJsonPrimitive) asString else toString()
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonElement.asIntSafe(): Int? {
|
||||
return try {
|
||||
if (isJsonNull) null else if (isJsonPrimitive) asInt else null
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
94
app/src/main/java/com/amz/genie/adapters/GeneralAdapter.kt
Normal file
94
app/src/main/java/com/amz/genie/adapters/GeneralAdapter.kt
Normal file
@@ -0,0 +1,94 @@
|
||||
package com.amz.genie.adapters
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.amz.genie.R
|
||||
import com.amz.genie.helpers.Utils.formatDateTime
|
||||
import com.amz.genie.helpers.Utils.isFemale
|
||||
import com.amz.genie.models.GeneralThreadItem
|
||||
import com.amz.genie.models.OfficeTrnAksiKepada
|
||||
import com.amz.genie.models.OfficeTrnReaksiKepada
|
||||
import com.google.android.material.imageview.ShapeableImageView
|
||||
|
||||
class GeneralAdapter(
|
||||
private val kodePegawai: String,
|
||||
private val items: MutableList<GeneralThreadItem> = mutableListOf(),
|
||||
private val onItemClick: ((GeneralThreadItem) -> Unit)? = null
|
||||
) : RecyclerView.Adapter<GeneralAdapter.VH>() {
|
||||
|
||||
fun submitList(newItems: List<GeneralThreadItem>) {
|
||||
items.clear()
|
||||
items.addAll(newItems)
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
inner class VH(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
private val img: ShapeableImageView = itemView.findViewById(R.id.siv_employee_item_inbox)
|
||||
private val tvName: TextView = itemView.findViewById(R.id.tv_employeename_item_inbox)
|
||||
private val tvDateTime: TextView = itemView.findViewById(R.id.tv_datetime_item_inbox)
|
||||
private val tvJobDesk: TextView = itemView.findViewById(R.id.tv_jobdesk_item_inbox)
|
||||
private val tvDesc: TextView = itemView.findViewById(R.id.tv_description_item_inbox)
|
||||
private val tvUnread: TextView = itemView.findViewById(R.id.tv_unread_item_inbox)
|
||||
|
||||
fun bind(row: GeneralThreadItem) {
|
||||
val tipe = row.tipe.uppercase()
|
||||
val aksi = row.aksi
|
||||
val reaksi = row.reaksi
|
||||
|
||||
fun countUnreadAksi(kepada: List<OfficeTrnAksiKepada>?): Int {
|
||||
return kepada?.count { it.is_aktif == 0 && it.kode_kepada == kodePegawai } ?: 0
|
||||
}
|
||||
|
||||
fun countUnreadReaksi(kepada: List<OfficeTrnReaksiKepada>?): Int {
|
||||
return kepada?.count { it.is_aktif == 0 && it.kode_kepada == kodePegawai } ?: 0
|
||||
}
|
||||
|
||||
if (tipe == "REAKSI" && reaksi != null) {
|
||||
tvName.text = reaksi.pembuat.nama
|
||||
tvDateTime.text = formatDateTime(reaksi.waktu_buat)
|
||||
tvJobDesk.text = reaksi.pembuat.jabatan?.nama
|
||||
tvDesc.text = reaksi.uraian?.takeIf { it.isNotBlank() } ?: "-"
|
||||
|
||||
val female = isFemale(reaksi.pembuat.id_kelamin)
|
||||
img.setImageResource(if (female) R.drawable.ic_woman else R.drawable.ic_man)
|
||||
|
||||
} else {
|
||||
tvName.text = aksi.pembuat.nama
|
||||
tvDateTime.text = formatDateTime(aksi.waktu_buat)
|
||||
tvJobDesk.text = "${aksi.pembuat.jabatan?.nama} - ${aksi.pembuat.outlet?.nama}"
|
||||
tvDesc.text = aksi.aksi_komunikasi_teks
|
||||
?.nilai
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: aksi.uraian
|
||||
|
||||
val female = isFemale(aksi.pembuat.id_kelamin)
|
||||
img.setImageResource(if (female) R.drawable.ic_woman else R.drawable.ic_man)
|
||||
}
|
||||
|
||||
val totalUnread = row.unread_count
|
||||
if (totalUnread > 0) {
|
||||
tvUnread.visibility = View.VISIBLE
|
||||
tvUnread.text = totalUnread.toString()
|
||||
} else {
|
||||
tvUnread.visibility = View.GONE
|
||||
tvUnread.text = ""
|
||||
}
|
||||
|
||||
itemView.setOnClickListener { onItemClick?.invoke(row) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
|
||||
val v = LayoutInflater.from(parent.context).inflate(R.layout.item_inbox, parent, false)
|
||||
return VH(v)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: VH, position: Int) {
|
||||
holder.bind(items[position])
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = items.size
|
||||
}
|
||||
171
app/src/main/java/com/amz/genie/adapters/GeneralDetailAdapter.kt
Normal file
171
app/src/main/java/com/amz/genie/adapters/GeneralDetailAdapter.kt
Normal file
@@ -0,0 +1,171 @@
|
||||
package com.amz.genie.adapters
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.amz.genie.R
|
||||
import com.amz.genie.helpers.Utils.formatDateTime
|
||||
import com.amz.genie.models.GeneralThreadItem
|
||||
import com.amz.genie.models.OfficeTrnAksiKepada
|
||||
|
||||
class GeneralDetailAdapter(
|
||||
private val kodePegawai: String,
|
||||
private val onItemClick: (GeneralThreadItem) -> Unit,
|
||||
private val onDetailClick: (GeneralThreadItem) -> Unit
|
||||
) : ListAdapter<GeneralThreadItem, GeneralDetailAdapter.UnifiedVH>(DIFF) {
|
||||
|
||||
companion object {
|
||||
private val DIFF = object : DiffUtil.ItemCallback<GeneralThreadItem>() {
|
||||
override fun areItemsTheSame(oldItem: GeneralThreadItem, newItem: GeneralThreadItem): Boolean {
|
||||
val oldKey = if (oldItem.tipe.equals("REAKSI", true))
|
||||
"R_${oldItem.reaksi?.id ?: -1}"
|
||||
else
|
||||
"A_${oldItem.aksi.id}"
|
||||
|
||||
val newKey = if (newItem.tipe.equals("REAKSI", true))
|
||||
"R_${newItem.reaksi?.id ?: -1}"
|
||||
else
|
||||
"A_${newItem.aksi.id}"
|
||||
|
||||
return oldKey == newKey
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: GeneralThreadItem, newItem: GeneralThreadItem): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun submitRawTimeline(raw: List<GeneralThreadItem>?) {
|
||||
val dedup = raw.orEmpty()
|
||||
.distinctBy {
|
||||
if (it.tipe.equals("REAKSI", true)) "R_${it.reaksi?.id}" else "A_${it.aksi.id}"
|
||||
}
|
||||
submitList(dedup)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UnifiedVH {
|
||||
val v = LayoutInflater.from(parent.context).inflate(R.layout.item_inbox_detail, parent, false)
|
||||
return UnifiedVH(v, kodePegawai, onItemClick, onDetailClick)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: UnifiedVH, position: Int) {
|
||||
holder.bind(getItem(position))
|
||||
}
|
||||
|
||||
class UnifiedVH(
|
||||
itemView: View,
|
||||
private val kodePegawai: String,
|
||||
private val onItemClick: (GeneralThreadItem) -> Unit,
|
||||
private val onDetailClick: (GeneralThreadItem) -> Unit
|
||||
) : RecyclerView.ViewHolder(itemView) {
|
||||
|
||||
private val tvTitle: TextView = itemView.findViewById(R.id.tv_title_item_inbox_detail)
|
||||
private val tvTime: TextView = itemView.findViewById(R.id.tv_time_item_inbox_detail)
|
||||
private val tvMessage: TextView = itemView.findViewById(R.id.tv_message_item_inbox_detail) // ✅ baru
|
||||
|
||||
private val rvPenerima: RecyclerView = itemView.findViewById(R.id.rv_penerima_item_inbox_detail)
|
||||
private val rvSubject: RecyclerView = itemView.findViewById(R.id.rv_subject_item_inbox_detail)
|
||||
private val llContainer: View = itemView.findViewById(R.id.ll_container_item_inbox_detail)
|
||||
private val ibDetail: View = itemView.findViewById(R.id.ib_detail_item_inbox_detail)
|
||||
|
||||
private var currentItem: GeneralThreadItem? = null
|
||||
|
||||
private val penerimaAdapter = GeneralDetailPenerimaAdapter()
|
||||
private val subjectAdapter = SubjectAdapter()
|
||||
|
||||
private val glm = GridLayoutManager(itemView.context, 3)
|
||||
|
||||
init {
|
||||
// ✅ 1 chip = full row, multi = 3 kolom
|
||||
glm.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
|
||||
override fun getSpanSize(position: Int): Int {
|
||||
return if (penerimaAdapter.itemCount == 1) 3 else 1
|
||||
}
|
||||
}
|
||||
|
||||
rvPenerima.layoutManager = glm
|
||||
rvPenerima.adapter = penerimaAdapter
|
||||
rvPenerima.isNestedScrollingEnabled = false
|
||||
|
||||
rvSubject.layoutManager = LinearLayoutManager(itemView.context, LinearLayoutManager.HORIZONTAL, false)
|
||||
rvSubject.adapter = subjectAdapter
|
||||
rvSubject.setHasFixedSize(true)
|
||||
rvSubject.isNestedScrollingEnabled = false
|
||||
|
||||
ibDetail.setOnClickListener { currentItem?.let { onDetailClick(it) } }
|
||||
itemView.setOnClickListener { currentItem?.let { onItemClick(it) } }
|
||||
}
|
||||
|
||||
fun bind(item: GeneralThreadItem) {
|
||||
currentItem = item
|
||||
|
||||
val isReaksi = item.tipe.equals("REAKSI", true)
|
||||
val aksi = item.aksi
|
||||
|
||||
// ✅ WARNA berdasar is_aktif untuk saya
|
||||
val inactiveForMe = aksi.kepada.orEmpty().any {
|
||||
it.kode_kepada == kodePegawai && it.is_aktif == 0
|
||||
}
|
||||
llContainer.setBackgroundResource(
|
||||
if (inactiveForMe) R.color.colorGreenVeryLight else R.color.colorWhitePure
|
||||
)
|
||||
|
||||
// ✅ Title selalu judul/uraian AKSI
|
||||
tvTitle.text = aksi.uraian
|
||||
|
||||
if (!isReaksi) {
|
||||
// AKSI
|
||||
tvTime.text = formatDateTime(aksi.waktu_buat)
|
||||
|
||||
penerimaAdapter.submitList(aksi.kepada.orEmpty())
|
||||
|
||||
val listString = arrayListOf(
|
||||
aksi.tipe_komunikasi?.tipe_komunikasi.toString(),
|
||||
aksi.tentang.tentang
|
||||
)
|
||||
subjectAdapter.submitList(listString)
|
||||
|
||||
// ✅ tidak ada pesan reaksi
|
||||
tvMessage.visibility = View.GONE
|
||||
tvMessage.text = ""
|
||||
|
||||
ibDetail.visibility = View.VISIBLE
|
||||
|
||||
} else {
|
||||
// REAKSI
|
||||
val rx = item.reaksi
|
||||
|
||||
tvTitle.text = aksi.uraian
|
||||
tvTime.text = formatDateTime(rx?.waktu_buat ?: aksi.waktu_buat)
|
||||
|
||||
val pembuat = rx?.pembuat
|
||||
val chip: List<OfficeTrnAksiKepada> = pembuat?.let { peg ->
|
||||
listOf(
|
||||
OfficeTrnAksiKepada(
|
||||
id = -(rx?.id ?: 1),
|
||||
aksi_id = aksi.id,
|
||||
kode_kepada = peg.kode,
|
||||
kepada = peg,
|
||||
is_aktif = 1,
|
||||
waktu_buat = rx?.waktu_buat ?: aksi.waktu_buat,
|
||||
waktu_ubah = rx?.waktu_buat ?: aksi.waktu_buat
|
||||
)
|
||||
)
|
||||
} ?: emptyList()
|
||||
|
||||
penerimaAdapter.submitList(chip)
|
||||
subjectAdapter.submitList(arrayListOf("REAKSI"))
|
||||
tvMessage.visibility = View.VISIBLE
|
||||
tvMessage.text = rx?.uraian?.takeIf { it.isNotBlank() } ?: "(Reaksi)"
|
||||
ibDetail.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.amz.genie.adapters
|
||||
|
||||
import android.util.TypedValue
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.amz.genie.R
|
||||
import com.amz.genie.models.OfficeTrnAksiKepada
|
||||
|
||||
class GeneralDetailPenerimaAdapter :
|
||||
ListAdapter<OfficeTrnAksiKepada, GeneralDetailPenerimaAdapter.VH>(DIFF) {
|
||||
|
||||
companion object {
|
||||
private val DIFF = object : DiffUtil.ItemCallback<OfficeTrnAksiKepada>() {
|
||||
override fun areItemsTheSame(oldItem: OfficeTrnAksiKepada, newItem: OfficeTrnAksiKepada): Boolean {
|
||||
return oldItem.id == newItem.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: OfficeTrnAksiKepada, newItem: OfficeTrnAksiKepada): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
|
||||
val v = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.item_inbox_detail_penerima, parent, false)
|
||||
return VH(v)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: VH, position: Int) {
|
||||
holder.bind(getItem(position))
|
||||
}
|
||||
|
||||
class VH(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
private val tv: TextView = itemView.findViewById(R.id.tv_penerima_item)
|
||||
|
||||
fun bind(item: OfficeTrnAksiKepada) {
|
||||
val nama = item.kepada.nama
|
||||
tv.text = nama
|
||||
tv.setBackgroundResource(R.drawable.bg_chip_penerima_dark_green)
|
||||
tv.setTextColor(itemView.context.getColor(R.color.colorWhitePure))
|
||||
|
||||
val px = itemView.resources.getDimension(R.dimen.font_size_nano) // sudah px
|
||||
tv.setTextSize(TypedValue.COMPLEX_UNIT_PX, px)
|
||||
}
|
||||
}
|
||||
}
|
||||
56
app/src/main/java/com/amz/genie/adapters/RecipientAdapter.kt
Normal file
56
app/src/main/java/com/amz/genie/adapters/RecipientAdapter.kt
Normal file
@@ -0,0 +1,56 @@
|
||||
package com.amz.genie.adapters
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.amz.genie.R
|
||||
import com.amz.genie.helpers.Utils.isFemale
|
||||
import com.amz.genie.models.Pegawai
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.google.android.material.imageview.ShapeableImageView
|
||||
|
||||
class RecipientAdapter(
|
||||
private val onRemove: (Pegawai) -> Unit
|
||||
) : RecyclerView.Adapter<RecipientAdapter.VH>() {
|
||||
|
||||
private val items = mutableListOf<Pegawai>()
|
||||
|
||||
fun submitList(newItems: List<Pegawai>) {
|
||||
items.clear()
|
||||
items.addAll(newItems)
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
class VH(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
val ivAvatar: ShapeableImageView = itemView.findViewById(R.id.iv_avatar_recipient)
|
||||
val tvName: TextView = itemView.findViewById(R.id.tv_name_recipient)
|
||||
val btnRemove: MaterialButton = itemView.findViewById(R.id.btn_remove_recipient)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
|
||||
val v = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.item_recipient, parent, false)
|
||||
return VH(v)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: VH, position: Int) {
|
||||
val item = items[position]
|
||||
holder.tvName.text = item.nama
|
||||
|
||||
// Avatar:
|
||||
// Kalau Pegawai kamu punya field fotoUrl, nanti bisa pakai Glide/Coil.
|
||||
// Untuk sekarang pakai default icon sudah cukup.
|
||||
// holder.ivAvatar.setImageResource(R.drawable.ic_person_24)
|
||||
|
||||
val female = isFemale(item.id_kelamin)
|
||||
holder.ivAvatar.setImageResource(if (female) R.drawable.ic_woman else R.drawable.ic_man)
|
||||
|
||||
holder.btnRemove.setOnClickListener {
|
||||
onRemove(item)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = items.size
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.amz.genie.adapters
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.amz.genie.R
|
||||
import com.amz.genie.helpers.Utils.isFemale
|
||||
import com.amz.genie.models.Pegawai
|
||||
import com.google.android.material.imageview.ShapeableImageView
|
||||
|
||||
class RecipientPickerAdapter(
|
||||
private val onPick: (Pegawai) -> Unit
|
||||
) : RecyclerView.Adapter<RecipientPickerAdapter.VH>() {
|
||||
|
||||
private val items = mutableListOf<Pegawai>()
|
||||
|
||||
fun submitList(newItems: List<Pegawai>) {
|
||||
items.clear()
|
||||
items.addAll(newItems)
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
class VH(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
val ivAvatar: ShapeableImageView = itemView.findViewById(R.id.iv_avatar_recipient)
|
||||
val tvName: TextView = itemView.findViewById(R.id.tv_name_recipient)
|
||||
val btnRemove: View = itemView.findViewById(R.id.btn_remove_recipient)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
|
||||
val v = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.item_recipient, parent, false) // item XML yang kamu kasih
|
||||
return VH(v)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: VH, position: Int) {
|
||||
val item = items[position]
|
||||
holder.tvName.text = item.nama
|
||||
|
||||
// di dialog, tombol remove tidak dipakai
|
||||
holder.btnRemove.visibility = View.GONE
|
||||
|
||||
val female = isFemale(item.id_kelamin)
|
||||
holder.ivAvatar.setImageResource(if (female) R.drawable.ic_woman else R.drawable.ic_man)
|
||||
|
||||
holder.itemView.setOnClickListener { onPick(item) }
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = items.size
|
||||
}
|
||||
53
app/src/main/java/com/amz/genie/adapters/SubjectAdapter.kt
Normal file
53
app/src/main/java/com/amz/genie/adapters/SubjectAdapter.kt
Normal file
@@ -0,0 +1,53 @@
|
||||
package com.amz.genie.adapters
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.amz.genie.R
|
||||
|
||||
class SubjectAdapter(
|
||||
private val items: ArrayList<String> = arrayListOf()
|
||||
) : RecyclerView.Adapter<SubjectAdapter.VH>() {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
|
||||
val v = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.item_inbox_detail_penerima, parent, false)
|
||||
return VH(v)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: VH, position: Int) {
|
||||
holder.bind(items[position], position)
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = items.size
|
||||
|
||||
class VH(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
private val tv: TextView = itemView.findViewById(R.id.tv_penerima_item)
|
||||
|
||||
fun bind(item: String, position: Int) {
|
||||
tv.text = item
|
||||
|
||||
val isGreen = position % 2 == 0
|
||||
|
||||
tv.setBackgroundResource(
|
||||
if (isGreen) R.drawable.bg_chip_penerima else R.drawable.bg_chip_penerima_gray
|
||||
)
|
||||
|
||||
tv.setTextColor(
|
||||
ContextCompat.getColor(
|
||||
itemView.context,
|
||||
if (isGreen) R.color.colorBlack else R.color.colorWhitePure
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun submitList(newItems: List<String>) {
|
||||
items.clear()
|
||||
items.addAll(newItems)
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
183
app/src/main/java/com/amz/genie/fragments/ArchiveFragment.kt
Normal file
183
app/src/main/java/com/amz/genie/fragments/ArchiveFragment.kt
Normal file
@@ -0,0 +1,183 @@
|
||||
package com.amz.genie.fragments
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.fragment.app.Fragment
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import com.amz.genie.R
|
||||
import com.amz.genie.activities.GeneralDetailActivity
|
||||
import com.amz.genie.activities.MainActivity
|
||||
import com.amz.genie.adapters.GeneralAdapter
|
||||
import com.amz.genie.helpers.Preferences
|
||||
import com.amz.genie.helpers.Utils.forceLogoutAndGoLogin
|
||||
import com.amz.genie.helpers.Utils.isNetworkAvailable
|
||||
import com.amz.genie.helpers.Utils.showEmpty
|
||||
import com.amz.genie.models.GeneralResponse
|
||||
import com.amz.genie.models.GeneralThreadItem
|
||||
import com.amz.genie.models.Pengguna
|
||||
import com.amz.genie.services.APIMain
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.JsonParser
|
||||
import retrofit2.Call
|
||||
import retrofit2.Callback
|
||||
import retrofit2.Response
|
||||
import kotlin.collections.orEmpty
|
||||
|
||||
|
||||
class ArchiveFragment : Fragment() {
|
||||
|
||||
private lateinit var srlArchive: SwipeRefreshLayout
|
||||
private lateinit var tvEmpty: TextView
|
||||
private lateinit var rvArchive: RecyclerView
|
||||
private lateinit var adapter: GeneralAdapter
|
||||
private lateinit var detailLauncher: androidx.activity.result.ActivityResultLauncher<Intent>
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
detailLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
val needsRefresh = result.data?.getBooleanExtra("needs_refresh", false) ?: false
|
||||
if (result.resultCode == android.app.Activity.RESULT_OK && needsRefresh) {
|
||||
initData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
val view = inflater.inflate(R.layout.fragment_archive, container, false)
|
||||
initUI(view)
|
||||
return view
|
||||
}
|
||||
|
||||
private fun initUI(view: View) {
|
||||
srlArchive = view.findViewById(R.id.srl_archive)
|
||||
tvEmpty = view.findViewById(R.id.tv_empty_archive)
|
||||
rvArchive = view.findViewById(R.id.rl_archive)
|
||||
|
||||
val userData = Gson().fromJson(Preferences.getUserData(requireContext()),
|
||||
Pengguna::class.java)
|
||||
adapter = GeneralAdapter(userData.pegawai?.kode!!) { item ->
|
||||
val intent = Intent(requireContext(), GeneralDetailActivity::class.java)
|
||||
|
||||
val gson = Gson()
|
||||
val dataJson = gson.toJson(item, GeneralThreadItem::class.java)
|
||||
intent.putExtra("data", dataJson)
|
||||
detailLauncher.launch(intent)
|
||||
}
|
||||
|
||||
rvArchive.layoutManager = LinearLayoutManager(requireContext())
|
||||
rvArchive.adapter = adapter
|
||||
rvArchive.setHasFixedSize(true)
|
||||
|
||||
srlArchive.setOnRefreshListener { initData() }
|
||||
|
||||
initData()
|
||||
}
|
||||
|
||||
private fun initData() {
|
||||
if (!isNetworkAvailable(requireContext())) {
|
||||
showSnack(getString(R.string.no_internet_message))
|
||||
srlArchive.isRefreshing = false
|
||||
return
|
||||
}
|
||||
|
||||
showLoading(true)
|
||||
|
||||
APIMain.require().generalServices
|
||||
.list(
|
||||
Preferences.getAccessToken(requireContext()),
|
||||
4
|
||||
)
|
||||
.enqueue(object : Callback<GeneralResponse> {
|
||||
|
||||
override fun onResponse(call: Call<GeneralResponse>, response: Response<GeneralResponse>) {
|
||||
showLoading(false)
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val list = response.body()?.items.orEmpty()
|
||||
|
||||
if (list.isEmpty()) {
|
||||
showEmpty(true, tvEmpty, rvArchive)
|
||||
adapter.submitList(emptyList())
|
||||
} else {
|
||||
showEmpty(false, tvEmpty, rvArchive)
|
||||
adapter.submitList(list)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
handleError(response)
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call<GeneralResponse>, t: Throwable) {
|
||||
showLoading(false)
|
||||
showSnack(t.message ?: "Terjadi kesalahan")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun handleError(response: Response<GeneralResponse>) {
|
||||
showLoading(false)
|
||||
val raw = runCatching { response.errorBody()?.string().orEmpty() }.getOrDefault("")
|
||||
val expired = raw.contains("Signature has expired", true) || raw.contains("token_expired", true)
|
||||
|
||||
val message = when {
|
||||
(response.code() == 401 || response.code() == 500) && expired -> {
|
||||
forceLogoutAndGoLogin(requireActivity())
|
||||
"Session expired. Please login again."
|
||||
}
|
||||
response.code() == 400 -> runCatching {
|
||||
JsonParser.parseString(raw).asJsonObject["message"].asString
|
||||
}.getOrDefault("Bad request")
|
||||
else -> "${response.code()}, ${response.message()}"
|
||||
}
|
||||
|
||||
showSnack(message)
|
||||
}
|
||||
|
||||
private fun showLoading(show: Boolean) {
|
||||
srlArchive.isRefreshing = false
|
||||
(activity as? MainActivity)?.showProgressDialog(show)
|
||||
}
|
||||
|
||||
private fun showSnack(message: String) {
|
||||
(activity as? MainActivity)?.let { act ->
|
||||
Snackbar.make(act.findViewById(android.R.id.content), message, Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupActions() {
|
||||
(activity as? MainActivity)?.setSearchClick({
|
||||
Toast.makeText(requireContext(), "TEST RUN", Toast.LENGTH_LONG).show()
|
||||
}, (activity as MainActivity).ibSearch)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
setupActions()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
(activity as? MainActivity)?.ibSearch?.setOnClickListener(null)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
(activity as? MainActivity)?.ibSearch?.setOnClickListener(null)
|
||||
}
|
||||
|
||||
}
|
||||
183
app/src/main/java/com/amz/genie/fragments/AssignedFragment.kt
Normal file
183
app/src/main/java/com/amz/genie/fragments/AssignedFragment.kt
Normal file
@@ -0,0 +1,183 @@
|
||||
package com.amz.genie.fragments
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.fragment.app.Fragment
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import com.amz.genie.R
|
||||
import com.amz.genie.activities.GeneralDetailActivity
|
||||
import com.amz.genie.activities.MainActivity
|
||||
import com.amz.genie.adapters.GeneralAdapter
|
||||
import com.amz.genie.helpers.Preferences
|
||||
import com.amz.genie.helpers.Utils.forceLogoutAndGoLogin
|
||||
import com.amz.genie.helpers.Utils.isNetworkAvailable
|
||||
import com.amz.genie.helpers.Utils.showEmpty
|
||||
import com.amz.genie.models.GeneralResponse
|
||||
import com.amz.genie.models.GeneralThreadItem
|
||||
import com.amz.genie.models.Pengguna
|
||||
import com.amz.genie.services.APIMain
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.JsonParser
|
||||
import retrofit2.Call
|
||||
import retrofit2.Callback
|
||||
import retrofit2.Response
|
||||
import kotlin.collections.orEmpty
|
||||
|
||||
|
||||
class AssignedFragment : Fragment() {
|
||||
|
||||
private lateinit var srlAssigned: SwipeRefreshLayout
|
||||
private lateinit var tvEmpty: TextView
|
||||
private lateinit var rvAssigned: RecyclerView
|
||||
private lateinit var adapter: GeneralAdapter
|
||||
private lateinit var detailLauncher: androidx.activity.result.ActivityResultLauncher<Intent>
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
detailLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
val needsRefresh = result.data?.getBooleanExtra("needs_refresh", false) ?: false
|
||||
if (result.resultCode == android.app.Activity.RESULT_OK && needsRefresh) {
|
||||
initData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
val view = inflater.inflate(R.layout.fragment_assigned, container, false)
|
||||
initUI(view)
|
||||
return view
|
||||
}
|
||||
|
||||
private fun initUI(view: View) {
|
||||
srlAssigned = view.findViewById(R.id.srl_assigned)
|
||||
tvEmpty = view.findViewById(R.id.tv_empty_assigned)
|
||||
rvAssigned = view.findViewById(R.id.rl_assigned)
|
||||
|
||||
val userData = Gson().fromJson(Preferences.getUserData(requireContext()),
|
||||
Pengguna::class.java)
|
||||
adapter = GeneralAdapter(userData.pegawai?.kode!!) { item ->
|
||||
val intent = Intent(requireContext(), GeneralDetailActivity::class.java)
|
||||
|
||||
val gson = Gson()
|
||||
val dataJson = gson.toJson(item, GeneralThreadItem::class.java)
|
||||
intent.putExtra("data", dataJson)
|
||||
detailLauncher.launch(intent)
|
||||
}
|
||||
|
||||
rvAssigned.layoutManager = LinearLayoutManager(requireContext())
|
||||
rvAssigned.adapter = adapter
|
||||
rvAssigned.setHasFixedSize(true)
|
||||
|
||||
srlAssigned.setOnRefreshListener { initData() }
|
||||
|
||||
initData()
|
||||
}
|
||||
|
||||
private fun initData() {
|
||||
if (!isNetworkAvailable(requireContext())) {
|
||||
showSnack(getString(R.string.no_internet_message))
|
||||
srlAssigned.isRefreshing = false
|
||||
return
|
||||
}
|
||||
|
||||
showLoading(true)
|
||||
|
||||
APIMain.require().generalServices
|
||||
.list(
|
||||
Preferences.getAccessToken(requireContext()),
|
||||
3
|
||||
)
|
||||
.enqueue(object : Callback<GeneralResponse> {
|
||||
|
||||
override fun onResponse(call: Call<GeneralResponse>, response: Response<GeneralResponse>) {
|
||||
showLoading(false)
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val list = response.body()?.items.orEmpty()
|
||||
|
||||
if (list.isEmpty()) {
|
||||
showEmpty(true, tvEmpty, rvAssigned)
|
||||
adapter.submitList(emptyList())
|
||||
} else {
|
||||
showEmpty(false, tvEmpty, rvAssigned)
|
||||
adapter.submitList(list)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
handleError(response)
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call<GeneralResponse>, t: Throwable) {
|
||||
showLoading(false)
|
||||
showSnack(t.message ?: "Terjadi kesalahan")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun handleError(response: Response<GeneralResponse>) {
|
||||
showLoading(false)
|
||||
val raw = runCatching { response.errorBody()?.string().orEmpty() }.getOrDefault("")
|
||||
val expired = raw.contains("Signature has expired", true) || raw.contains("token_expired", true)
|
||||
|
||||
val message = when {
|
||||
(response.code() == 401 || response.code() == 500) && expired -> {
|
||||
forceLogoutAndGoLogin(requireActivity())
|
||||
"Session expired. Please login again."
|
||||
}
|
||||
response.code() == 400 -> runCatching {
|
||||
JsonParser.parseString(raw).asJsonObject["message"].asString
|
||||
}.getOrDefault("Bad request")
|
||||
else -> "${response.code()}, ${response.message()}"
|
||||
}
|
||||
|
||||
showSnack(message)
|
||||
}
|
||||
|
||||
private fun showLoading(show: Boolean) {
|
||||
srlAssigned.isRefreshing = false
|
||||
(activity as? MainActivity)?.showProgressDialog(show)
|
||||
}
|
||||
|
||||
private fun showSnack(message: String) {
|
||||
(activity as? MainActivity)?.let { act ->
|
||||
Snackbar.make(act.findViewById(android.R.id.content), message, Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupActions() {
|
||||
(activity as? MainActivity)?.setSearchClick({
|
||||
Toast.makeText(requireContext(), "TEST RUN", Toast.LENGTH_LONG).show()
|
||||
}, (activity as MainActivity).ibSearch)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
setupActions()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
(activity as? MainActivity)?.ibSearch?.setOnClickListener(null)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
(activity as? MainActivity)?.ibSearch?.setOnClickListener(null)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.amz.genie.fragments
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.fragment.app.Fragment
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import com.amz.genie.R
|
||||
|
||||
// TODO: Rename parameter arguments, choose names that match
|
||||
// the fragment initialization parameters, e.g. ARG_ITEM_NUMBER
|
||||
private const val ARG_PARAM1 = "param1"
|
||||
private const val ARG_PARAM2 = "param2"
|
||||
|
||||
/**
|
||||
* A simple [Fragment] subclass.
|
||||
* Use the [DashboardFragment.newInstance] factory method to
|
||||
* create an instance of this fragment.
|
||||
*/
|
||||
class DashboardFragment : Fragment() {
|
||||
// TODO: Rename and change types of parameters
|
||||
private var param1: String? = null
|
||||
private var param2: String? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
arguments?.let {
|
||||
param1 = it.getString(ARG_PARAM1)
|
||||
param2 = it.getString(ARG_PARAM2)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
// Inflate the layout for this fragment
|
||||
return inflater.inflate(R.layout.fragment_dashboard, container, false)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Use this factory method to create a new instance of
|
||||
* this fragment using the provided parameters.
|
||||
*
|
||||
* @param param1 Parameter 1.
|
||||
* @param param2 Parameter 2.
|
||||
* @return A new instance of fragment DashboardFragment.
|
||||
*/
|
||||
// TODO: Rename and change types and number of parameters
|
||||
@JvmStatic
|
||||
fun newInstance(param1: String, param2: String) =
|
||||
DashboardFragment().apply {
|
||||
arguments = Bundle().apply {
|
||||
putString(ARG_PARAM1, param1)
|
||||
putString(ARG_PARAM2, param2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
180
app/src/main/java/com/amz/genie/fragments/InboxFragment.kt
Normal file
180
app/src/main/java/com/amz/genie/fragments/InboxFragment.kt
Normal file
@@ -0,0 +1,180 @@
|
||||
package com.amz.genie.fragments
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import com.amz.genie.R
|
||||
import com.amz.genie.activities.GeneralDetailActivity
|
||||
import com.amz.genie.activities.MainActivity
|
||||
import com.amz.genie.adapters.GeneralAdapter
|
||||
import com.amz.genie.helpers.Preferences
|
||||
import com.amz.genie.helpers.Utils.forceLogoutAndGoLogin
|
||||
import com.amz.genie.helpers.Utils.isNetworkAvailable
|
||||
import com.amz.genie.helpers.Utils.showEmpty
|
||||
import com.amz.genie.models.GeneralResponse
|
||||
import com.amz.genie.models.GeneralThreadItem
|
||||
import com.amz.genie.models.Pengguna
|
||||
import com.amz.genie.services.APIMain
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.JsonParser
|
||||
import retrofit2.Call
|
||||
import retrofit2.Callback
|
||||
import retrofit2.Response
|
||||
|
||||
class InboxFragment : Fragment() {
|
||||
|
||||
private lateinit var srlInbox: SwipeRefreshLayout
|
||||
private lateinit var tvEmpty: TextView
|
||||
private lateinit var rvInbox: RecyclerView
|
||||
private lateinit var adapter: GeneralAdapter
|
||||
private lateinit var detailLauncher: androidx.activity.result.ActivityResultLauncher<Intent>
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
detailLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
val needsRefresh = result.data?.getBooleanExtra("needs_refresh", false) ?: false
|
||||
if (result.resultCode == android.app.Activity.RESULT_OK && needsRefresh) {
|
||||
initData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
val view = inflater.inflate(R.layout.fragment_inbox, container, false)
|
||||
initUI(view)
|
||||
return view
|
||||
}
|
||||
|
||||
private fun initUI(view: View) {
|
||||
srlInbox = view.findViewById(R.id.srl_inbox)
|
||||
tvEmpty = view.findViewById(R.id.tv_empty_inbox)
|
||||
rvInbox = view.findViewById(R.id.rl_inbox)
|
||||
|
||||
val userData = Gson().fromJson(Preferences.getUserData(requireContext()),
|
||||
Pengguna::class.java)
|
||||
adapter = GeneralAdapter(userData.pegawai?.kode!!) { item ->
|
||||
val intent = Intent(requireContext(), GeneralDetailActivity::class.java)
|
||||
|
||||
val gson = Gson()
|
||||
val dataJson = gson.toJson(item, GeneralThreadItem::class.java)
|
||||
intent.putExtra("data", dataJson)
|
||||
detailLauncher.launch(intent)
|
||||
}
|
||||
|
||||
rvInbox.layoutManager = LinearLayoutManager(requireContext())
|
||||
rvInbox.adapter = adapter
|
||||
rvInbox.setHasFixedSize(true)
|
||||
|
||||
srlInbox.setOnRefreshListener { initData() }
|
||||
|
||||
initData()
|
||||
}
|
||||
|
||||
private fun initData() {
|
||||
if (!isNetworkAvailable(requireContext())) {
|
||||
showSnack(getString(R.string.no_internet_message))
|
||||
srlInbox.isRefreshing = false
|
||||
return
|
||||
}
|
||||
|
||||
showLoading(true)
|
||||
|
||||
APIMain.require().generalServices
|
||||
.list(
|
||||
Preferences.getAccessToken(requireContext()),
|
||||
1
|
||||
)
|
||||
.enqueue(object : Callback<GeneralResponse> {
|
||||
|
||||
override fun onResponse(call: Call<GeneralResponse>, response: Response<GeneralResponse>) {
|
||||
showLoading(false)
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val list = response.body()?.items.orEmpty()
|
||||
|
||||
if (list.isEmpty()) {
|
||||
showEmpty(true, tvEmpty, rvInbox)
|
||||
adapter.submitList(emptyList())
|
||||
} else {
|
||||
showEmpty(false, tvEmpty, rvInbox)
|
||||
adapter.submitList(list)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
handleError(response)
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call<GeneralResponse>, t: Throwable) {
|
||||
showLoading(false)
|
||||
showSnack(t.message ?: "Terjadi kesalahan")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun handleError(response: Response<GeneralResponse>) {
|
||||
showLoading(false)
|
||||
val raw = runCatching { response.errorBody()?.string().orEmpty() }.getOrDefault("")
|
||||
val expired = raw.contains("Signature has expired", true) || raw.contains("token_expired", true)
|
||||
|
||||
val message = when {
|
||||
(response.code() == 401 || response.code() == 500) && expired -> {
|
||||
forceLogoutAndGoLogin(requireActivity())
|
||||
"Session expired. Please login again."
|
||||
}
|
||||
response.code() == 400 -> runCatching {
|
||||
JsonParser.parseString(raw).asJsonObject["message"].asString
|
||||
}.getOrDefault("Bad request")
|
||||
else -> "${response.code()}, ${response.message()}"
|
||||
}
|
||||
|
||||
showSnack(message)
|
||||
}
|
||||
|
||||
private fun showLoading(show: Boolean) {
|
||||
srlInbox.isRefreshing = false
|
||||
(activity as? MainActivity)?.showProgressDialog(show)
|
||||
}
|
||||
|
||||
private fun showSnack(message: String) {
|
||||
(activity as? MainActivity)?.let { act ->
|
||||
Snackbar.make(act.findViewById(android.R.id.content), message, Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupActions() {
|
||||
(activity as? MainActivity)?.setSearchClick({
|
||||
Toast.makeText(requireContext(), "TEST RUN", Toast.LENGTH_LONG).show()
|
||||
}, (activity as MainActivity).ibSearch)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
setupActions()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
(activity as? MainActivity)?.ibSearch?.setOnClickListener(null)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
(activity as? MainActivity)?.ibSearch?.setOnClickListener(null)
|
||||
}
|
||||
}
|
||||
182
app/src/main/java/com/amz/genie/fragments/TODOFragment.kt
Normal file
182
app/src/main/java/com/amz/genie/fragments/TODOFragment.kt
Normal file
@@ -0,0 +1,182 @@
|
||||
package com.amz.genie.fragments
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.fragment.app.Fragment
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import com.amz.genie.R
|
||||
import com.amz.genie.activities.GeneralDetailActivity
|
||||
import com.amz.genie.activities.MainActivity
|
||||
import com.amz.genie.adapters.GeneralAdapter
|
||||
import com.amz.genie.helpers.Preferences
|
||||
import com.amz.genie.helpers.Utils.forceLogoutAndGoLogin
|
||||
import com.amz.genie.helpers.Utils.isNetworkAvailable
|
||||
import com.amz.genie.helpers.Utils.showEmpty
|
||||
import com.amz.genie.models.GeneralResponse
|
||||
import com.amz.genie.models.GeneralThreadItem
|
||||
import com.amz.genie.models.Pengguna
|
||||
import com.amz.genie.services.APIMain
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.JsonParser
|
||||
import retrofit2.Call
|
||||
import retrofit2.Callback
|
||||
import retrofit2.Response
|
||||
import kotlin.collections.orEmpty
|
||||
|
||||
|
||||
class TODOFragment : Fragment() {
|
||||
|
||||
private lateinit var srlTODO: SwipeRefreshLayout
|
||||
private lateinit var tvEmpty: TextView
|
||||
private lateinit var rvTODO: RecyclerView
|
||||
private lateinit var adapter: GeneralAdapter
|
||||
private lateinit var detailLauncher: androidx.activity.result.ActivityResultLauncher<Intent>
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
detailLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
val needsRefresh = result.data?.getBooleanExtra("needs_refresh", false) ?: false
|
||||
if (result.resultCode == android.app.Activity.RESULT_OK && needsRefresh) {
|
||||
initData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
val view = inflater.inflate(R.layout.fragment_todo, container, false)
|
||||
initUI(view)
|
||||
return view
|
||||
}
|
||||
|
||||
private fun initUI(view: View) {
|
||||
srlTODO = view.findViewById(R.id.srl_todo)
|
||||
tvEmpty = view.findViewById(R.id.tv_empty_todo)
|
||||
rvTODO = view.findViewById(R.id.rl_todo)
|
||||
|
||||
val userData = Gson().fromJson(Preferences.getUserData(requireContext()),
|
||||
Pengguna::class.java)
|
||||
adapter = GeneralAdapter(userData.pegawai?.kode!!) { item ->
|
||||
val intent = Intent(requireContext(), GeneralDetailActivity::class.java)
|
||||
|
||||
val gson = Gson()
|
||||
val dataJson = gson.toJson(item, GeneralThreadItem::class.java)
|
||||
intent.putExtra("data", dataJson)
|
||||
detailLauncher.launch(intent)
|
||||
}
|
||||
|
||||
rvTODO.layoutManager = LinearLayoutManager(requireContext())
|
||||
rvTODO.adapter = adapter
|
||||
rvTODO.setHasFixedSize(true)
|
||||
|
||||
srlTODO.setOnRefreshListener { initData() }
|
||||
|
||||
initData()
|
||||
}
|
||||
|
||||
private fun initData() {
|
||||
if (!isNetworkAvailable(requireContext())) {
|
||||
showSnack(getString(R.string.no_internet_message))
|
||||
srlTODO.isRefreshing = false
|
||||
return
|
||||
}
|
||||
|
||||
showLoading(true)
|
||||
|
||||
APIMain.require().generalServices
|
||||
.list(
|
||||
Preferences.getAccessToken(requireContext()),
|
||||
2
|
||||
)
|
||||
.enqueue(object : Callback<GeneralResponse> {
|
||||
|
||||
override fun onResponse(call: Call<GeneralResponse>, response: Response<GeneralResponse>) {
|
||||
showLoading(false)
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val list = response.body()?.items.orEmpty()
|
||||
|
||||
if (list.isEmpty()) {
|
||||
showEmpty(true, tvEmpty, rvTODO)
|
||||
adapter.submitList(emptyList())
|
||||
} else {
|
||||
showEmpty(false, tvEmpty, rvTODO)
|
||||
adapter.submitList(list)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
handleError(response)
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call<GeneralResponse>, t: Throwable) {
|
||||
showLoading(false)
|
||||
showSnack(t.message ?: "Terjadi kesalahan")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun handleError(response: Response<GeneralResponse>) {
|
||||
showLoading(false)
|
||||
val raw = runCatching { response.errorBody()?.string().orEmpty() }.getOrDefault("")
|
||||
val expired = raw.contains("Signature has expired", true) || raw.contains("token_expired", true)
|
||||
|
||||
val message = when {
|
||||
(response.code() == 401 || response.code() == 500) && expired -> {
|
||||
forceLogoutAndGoLogin(requireActivity())
|
||||
"Session expired. Please login again."
|
||||
}
|
||||
response.code() == 400 -> runCatching {
|
||||
JsonParser.parseString(raw).asJsonObject["message"].asString
|
||||
}.getOrDefault("Bad request")
|
||||
else -> "${response.code()}, ${response.message()}"
|
||||
}
|
||||
|
||||
showSnack(message)
|
||||
}
|
||||
|
||||
private fun showLoading(show: Boolean) {
|
||||
srlTODO.isRefreshing = false
|
||||
(activity as? MainActivity)?.showProgressDialog(show)
|
||||
}
|
||||
|
||||
private fun showSnack(message: String) {
|
||||
(activity as? MainActivity)?.let { act ->
|
||||
Snackbar.make(act.findViewById(android.R.id.content), message, Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupActions() {
|
||||
(activity as? MainActivity)?.setSearchClick({
|
||||
Toast.makeText(requireContext(), "TEST RUN", Toast.LENGTH_LONG).show()
|
||||
}, (activity as MainActivity).ibSearch)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
setupActions()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
(activity as? MainActivity)?.ibSearch?.setOnClickListener(null)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
(activity as? MainActivity)?.ibSearch?.setOnClickListener(null)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.amz.genie.helpers
|
||||
|
||||
import android.content.Context
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.File
|
||||
|
||||
object AttachmentDownloader {
|
||||
|
||||
fun downloadToCache(
|
||||
ctx: Context,
|
||||
url: String,
|
||||
fileName: String,
|
||||
token: String
|
||||
): File {
|
||||
val safeName = fileName.ifBlank { "attachment_${System.currentTimeMillis()}" }
|
||||
val outFile = File(ctx.cacheDir, safeName)
|
||||
|
||||
val client = OkHttpClient.Builder().build()
|
||||
|
||||
val req = Request.Builder()
|
||||
.url(url)
|
||||
.addHeader("Authorization", token)
|
||||
.build()
|
||||
|
||||
client.newCall(req).execute().use { resp ->
|
||||
if (!resp.isSuccessful) {
|
||||
throw RuntimeException("Download gagal: ${resp.code} ${resp.message}")
|
||||
}
|
||||
val body = resp.body ?: throw RuntimeException("Body kosong")
|
||||
outFile.outputStream().use { os ->
|
||||
body.byteStream().use { input ->
|
||||
input.copyTo(os)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return outFile
|
||||
}
|
||||
}
|
||||
137
app/src/main/java/com/amz/genie/helpers/AttachmentExtractor.kt
Normal file
137
app/src/main/java/com/amz/genie/helpers/AttachmentExtractor.kt
Normal file
@@ -0,0 +1,137 @@
|
||||
package com.amz.genie.helpers
|
||||
|
||||
import com.google.gson.JsonArray
|
||||
import com.google.gson.JsonObject
|
||||
import java.util.Locale
|
||||
|
||||
data class AttachmentItem(
|
||||
val label: String, // "PDF", "JPG"
|
||||
val fileName: String, // "xxx.jpg"
|
||||
val relativePath: String, // "aksi/30030/xxx.jpg" / "aksi_lampiran/16/xxx.jpg" / "reaksi/xxx.jpg"
|
||||
val url: String // full url
|
||||
)
|
||||
|
||||
private enum class Source { AKSI, REAKSI, AKSI_LAMPIRAN, UNKNOWN }
|
||||
|
||||
object AttachmentExtractor {
|
||||
fun extractAll(msg: JsonObject?): List<AttachmentItem> {
|
||||
if (msg == null) return emptyList()
|
||||
|
||||
val base = Preferences.BASE_UPLOAD_URL.trimEnd('/') // contoh: https://api.../uploads
|
||||
val out = mutableListOf<AttachmentItem>()
|
||||
|
||||
// A) lampiran umum (aksi) -> path_to_arsip: "aksi/30030/xxx.jpg"
|
||||
out += extractFromArray(
|
||||
arr = msg.getArray("lampiran"),
|
||||
baseUrl = base,
|
||||
defaultSource = Source.AKSI,
|
||||
possiblePathKeys = listOf("path_to_arsip", "path", "isian", "file", "filename")
|
||||
)
|
||||
|
||||
// B) lampiran reaksi -> kadang cuma filename "Screenshot_....jpg"
|
||||
out += extractFromArray(
|
||||
arr = msg.getArray("lampiran_reaksi"),
|
||||
baseUrl = base,
|
||||
defaultSource = Source.REAKSI,
|
||||
possiblePathKeys = listOf("path_to_arsip", "path", "isian", "file", "filename")
|
||||
)
|
||||
|
||||
// C) komunikasi detail lampiran -> isian/nilai/value: "aksi_lampiran/16/xxx.jpg"
|
||||
out += extractFromArray(
|
||||
arr = msg.getArray("aksi_komunikasi_lampiran"),
|
||||
baseUrl = base,
|
||||
defaultSource = Source.AKSI_LAMPIRAN,
|
||||
possiblePathKeys = listOf("nilai", "value", "isian", "path", "path_to_arsip", "filename")
|
||||
)
|
||||
|
||||
// D) fallback (optional)
|
||||
val fallbackKeys = listOf("aksi_komunikasi_teks", "aksi_komunikasi_string")
|
||||
for (k in fallbackKeys) {
|
||||
out += extractFromArray(
|
||||
arr = msg.getArray(k),
|
||||
baseUrl = base,
|
||||
defaultSource = Source.UNKNOWN,
|
||||
possiblePathKeys = listOf("nilai", "value", "isian", "path", "path_to_arsip", "filename")
|
||||
)
|
||||
}
|
||||
|
||||
return out.distinctBy { it.url }
|
||||
}
|
||||
|
||||
private fun extractFromArray(
|
||||
arr: JsonArray?,
|
||||
baseUrl: String,
|
||||
defaultSource: Source,
|
||||
possiblePathKeys: List<String>
|
||||
): List<AttachmentItem> {
|
||||
if (arr == null || arr.size() == 0) return emptyList()
|
||||
|
||||
val out = mutableListOf<AttachmentItem>()
|
||||
|
||||
for (el in arr) {
|
||||
if (!el.isJsonObject) continue
|
||||
val obj = el.asJsonObject
|
||||
|
||||
val rawPath = pickFirstString(obj, possiblePathKeys)?.trim().orEmpty()
|
||||
if (rawPath.isBlank()) continue
|
||||
|
||||
val normalized = normalizeRelativePath(rawPath, defaultSource) ?: continue
|
||||
|
||||
val fileName = normalized.substringAfterLast('/')
|
||||
if (fileName.isBlank()) continue
|
||||
|
||||
val ext = fileName.substringAfterLast('.', missingDelimiterValue = "")
|
||||
.lowercase(Locale.getDefault())
|
||||
|
||||
val label = if (ext.isBlank()) "FILE" else ext.uppercase(Locale.getDefault())
|
||||
val url = "$baseUrl/$normalized"
|
||||
|
||||
out += AttachmentItem(
|
||||
label = label,
|
||||
fileName = fileName,
|
||||
relativePath = normalized,
|
||||
url = url
|
||||
)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
private fun pickFirstString(obj: JsonObject, keys: List<String>): String? {
|
||||
for (k in keys) {
|
||||
val el = obj.get(k) ?: continue
|
||||
if (el.isJsonNull) continue
|
||||
val s = runCatching { el.asString }.getOrNull()
|
||||
if (!s.isNullOrBlank()) return s
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Sesuai data DB kamu:
|
||||
* - aksi: "aksi/30030/...."
|
||||
* - aksi_lampiran: "aksi_lampiran/16/...."
|
||||
* - reaksi: kadang cuma filename -> jadi "reaksi/<filename>"
|
||||
*/
|
||||
private fun normalizeRelativePath(raw: String, defaultSource: Source): String? {
|
||||
val p = raw.replace("\\", "/").trim().trimStart('/')
|
||||
if (p.isBlank()) return null
|
||||
|
||||
val lower = p.lowercase(Locale.getDefault())
|
||||
if (lower.startsWith("aksi/") || lower.startsWith("aksi_lampiran/") || lower.startsWith("reaksi/")) {
|
||||
return p
|
||||
}
|
||||
|
||||
// cuma filename
|
||||
return when (defaultSource) {
|
||||
Source.REAKSI -> "reaksi/$p"
|
||||
Source.AKSI -> "aksi/$p"
|
||||
Source.AKSI_LAMPIRAN -> "aksi_lampiran/$p"
|
||||
Source.UNKNOWN -> p
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// extension helper
|
||||
private fun JsonObject.getArray(key: String) =
|
||||
get(key)?.takeIf { it.isJsonArray }?.asJsonArray
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.amz.genie.helpers
|
||||
|
||||
import com.google.gson.JsonObject
|
||||
|
||||
object ChatMessageRenderer {
|
||||
fun render(message: JsonObject?): String {
|
||||
if (message == null) return ""
|
||||
|
||||
val sb = StringBuilder()
|
||||
|
||||
// 1) uraian (kalau ada)
|
||||
val uraian = message.getString("uraian")
|
||||
if (!uraian.isNullOrBlank()) {
|
||||
sb.append(uraian.trim())
|
||||
}
|
||||
|
||||
// 2) komunikasi / tipe_komunikasi / tentang (optional)
|
||||
// message.getObj("komunikasi")?.getString("komunikasi")?.let { addLine(sb, "Komunikasi", it) }
|
||||
// message.getObj("tipe_komunikasi")?.getString("tipe_komunikasi")?.let { addLine(sb, "Tipe", it) }
|
||||
// message.getObj("tentang")?.getString("tentang")?.let { addLine(sb, "Tentang", it) }
|
||||
|
||||
// 3) Render field komunikasi dinamis (list, angka, teks, tanggal, waktu, jam, pecahan, string)
|
||||
// Semua ini umumnya array of object yang punya "isian" dan "nilai" (atau variasinya).
|
||||
addSectionFromArray(sb, "aksi_komunikasi_teks", message)
|
||||
addSectionFromArray(sb, "aksi_komunikasi_string", message)
|
||||
addSectionFromArray(sb, "aksi_komunikasi_angka", message)
|
||||
addSectionFromArray(sb, "aksi_komunikasi_pecahan", message)
|
||||
addSectionFromArray(sb, "aksi_komunikasi_tanggal", message)
|
||||
addSectionFromArray(sb, "aksi_komunikasi_waktu", message)
|
||||
addSectionFromArray(sb, "aksi_komunikasi_jam", message)
|
||||
addSectionFromArray(sb, "aksi_komunikasi_list", message)
|
||||
|
||||
// 4) Lampiran (jika ingin tampil sebagai teks)
|
||||
val lampiranArr = message.getArray("lampiran")
|
||||
if (lampiranArr != null && lampiranArr.size() > 0) {
|
||||
// tampilkan ringkas saja
|
||||
addLine(sb, "Lampiran", "${lampiranArr.size()} file")
|
||||
}
|
||||
|
||||
// ✅ 5) Lampiran REAKSI (taruh di sini)
|
||||
val lampReaksi = message.getArray("lampiran_reaksi")
|
||||
if (lampReaksi != null && lampReaksi.size() > 0) {
|
||||
addLine(sb, "Lampiran", "${lampReaksi.size()} file")
|
||||
}
|
||||
|
||||
// Kalau kosong total, fallback tampilkan JSON singkat
|
||||
return sb.toString().ifBlank { message.toString() }
|
||||
}
|
||||
|
||||
private fun addSectionFromArray(sb: StringBuilder, key: String, msg: JsonObject) {
|
||||
val arr = msg.getArray(key) ?: return
|
||||
if (arr.size() == 0) return
|
||||
|
||||
for (el in arr) {
|
||||
if (!el.isJsonObject) continue
|
||||
val obj = el.asJsonObject
|
||||
|
||||
// Label pertanyaan/isian
|
||||
val label = obj.getString("isian")
|
||||
?: obj.getString("label")
|
||||
?: obj.getString("nama")
|
||||
?: key
|
||||
|
||||
// Nilai jawaban (banyak variasi field di backend)
|
||||
val value = obj.getString("nilai")
|
||||
?: obj.getString("value")
|
||||
?: obj.getString("uraian")
|
||||
?: obj.getString("isi")
|
||||
?: obj.getString("jawaban")
|
||||
|
||||
if (!value.isNullOrBlank()) {
|
||||
addLine(sb, label, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun addLine(sb: StringBuilder, label: String, value: String) {
|
||||
if (sb.isNotEmpty()) sb.append("\n")
|
||||
sb.append("• ").append(label).append(": ").append(value)
|
||||
}
|
||||
|
||||
// ---- Json helpers ----
|
||||
private fun JsonObject.getString(key: String): String? {
|
||||
val el = get(key) ?: return null
|
||||
if (el.isJsonNull) return null
|
||||
return runCatching { el.asString }.getOrNull()
|
||||
}
|
||||
|
||||
private fun JsonObject.getObj(key: String): JsonObject? {
|
||||
val el = get(key) ?: return null
|
||||
if (el.isJsonNull || !el.isJsonObject) return null
|
||||
return el.asJsonObject
|
||||
}
|
||||
|
||||
private fun JsonObject.getArray(key: String) =
|
||||
get(key)?.takeIf { it.isJsonArray }?.asJsonArray
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.amz.genie.helpers
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||
import androidx.emoji2.emojipicker.EmojiPickerView
|
||||
|
||||
class EmojiPickerBottomSheet(
|
||||
private val onEmojiPicked: (String) -> Unit
|
||||
) : BottomSheetDialogFragment() {
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
val picker = EmojiPickerView(requireContext())
|
||||
|
||||
picker.setOnEmojiPickedListener { emojiViewItem ->
|
||||
onEmojiPicked(emojiViewItem.emoji)
|
||||
dismiss()
|
||||
}
|
||||
|
||||
return picker
|
||||
}
|
||||
}
|
||||
61
app/src/main/java/com/amz/genie/helpers/Preferences.kt
Normal file
61
app/src/main/java/com/amz/genie/helpers/Preferences.kt
Normal file
@@ -0,0 +1,61 @@
|
||||
package com.amz.genie.helpers
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
|
||||
object Preferences {
|
||||
const val PREFS_NAME = "genieData"
|
||||
// const val API_URL = "https://api-genie.naminaniar.com/api/"
|
||||
// const val API_URL = "http://192.168.21.102:5000/api/"
|
||||
// const val API_URL = "http://192.160.3.17:5000/api/"
|
||||
// const val API_URL = "http://192.168.18.19:5000/api/"
|
||||
const val API_URL = "http://192.168.1.22:5000/api/"
|
||||
const val BASE_UPLOAD_URL = "https://api-genie.naminaniar.com/uploads"
|
||||
const val PROFILE_PICTURE_URL = "https://api-genie.naminaniar.com/profile_picture"
|
||||
private const val USER = "user"
|
||||
private const val REFRESH_TOKEN = "refreshToken"
|
||||
private const val ACCESS_TOKEN = "accessToken"
|
||||
private const val KEY_LAST_FCM_TOPIC = "last_fcm_topic"
|
||||
|
||||
fun getLastFcmTopic(ctx: Context): String {
|
||||
val sp = ctx.getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||
return sp.getString(KEY_LAST_FCM_TOPIC, "") ?: ""
|
||||
}
|
||||
|
||||
fun setLastFcmTopic(ctx: Context, topic: String) {
|
||||
val sp = ctx.getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||
sp.edit { putString(KEY_LAST_FCM_TOPIC, topic) }
|
||||
}
|
||||
|
||||
fun preferences(context: Context): SharedPreferences =
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
fun setRefreshToken(context: Context, value: String?) {
|
||||
preferences(context).edit { putString(REFRESH_TOKEN, value) }
|
||||
}
|
||||
|
||||
fun setAccessToken(context: Context, value: String?) {
|
||||
preferences(context).edit { putString(ACCESS_TOKEN, value) }
|
||||
}
|
||||
|
||||
fun getAccessToken(context: Context): String? =
|
||||
preferences(context).getString(ACCESS_TOKEN, null)
|
||||
|
||||
fun getRefreshToken(context: Context): String? =
|
||||
preferences(context)
|
||||
.getString(REFRESH_TOKEN, null)
|
||||
|
||||
fun setUserData(context: Context, value: String?) {
|
||||
preferences(context).edit { putString(USER, value) }
|
||||
}
|
||||
|
||||
fun getUserData(context: Context): String? =
|
||||
preferences(context).getString(USER, null)
|
||||
|
||||
fun clearAll(context: Context) {
|
||||
preferences(context).edit {
|
||||
clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
12
app/src/main/java/com/amz/genie/helpers/SimpleTextWatcher.kt
Normal file
12
app/src/main/java/com/amz/genie/helpers/SimpleTextWatcher.kt
Normal file
@@ -0,0 +1,12 @@
|
||||
package com.amz.genie.helpers
|
||||
|
||||
import android.text.Editable
|
||||
import android.text.TextWatcher
|
||||
|
||||
class SimpleTextWatcher(private val onChanged: (String) -> Unit) : TextWatcher {
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
|
||||
onChanged(s?.toString().orEmpty())
|
||||
}
|
||||
override fun afterTextChanged(s: Editable?) {}
|
||||
}
|
||||
221
app/src/main/java/com/amz/genie/helpers/Utils.kt
Normal file
221
app/src/main/java/com/amz/genie/helpers/Utils.kt
Normal file
@@ -0,0 +1,221 @@
|
||||
package com.amz.genie.helpers
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.database.Cursor
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.webkit.MimeTypeMap
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.amz.genie.activities.LoginActivity
|
||||
import com.google.firebase.FirebaseApp
|
||||
import com.google.firebase.auth.FirebaseAuth
|
||||
import com.google.firebase.messaging.FirebaseMessaging
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
object Utils {
|
||||
fun isNetworkAvailable(context: Context): Boolean {
|
||||
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val network = connectivityManager.activeNetwork ?: return false
|
||||
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
|
||||
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
}
|
||||
|
||||
fun formatDateTime(raw: String): String {
|
||||
return try {
|
||||
val locale = Locale("id", "ID")
|
||||
val inFmt = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", locale)
|
||||
val date = inFmt.parse(raw) ?: return raw
|
||||
|
||||
fun isSameDay(a: Calendar, b: Calendar): Boolean {
|
||||
return a.get(Calendar.YEAR) == b.get(Calendar.YEAR) &&
|
||||
a.get(Calendar.DAY_OF_YEAR) == b.get(Calendar.DAY_OF_YEAR)
|
||||
}
|
||||
|
||||
val cal = Calendar.getInstance().apply { time = date }
|
||||
val today = Calendar.getInstance()
|
||||
val yesterday = Calendar.getInstance().apply { add(Calendar.DAY_OF_YEAR, -1) }
|
||||
|
||||
val timeStr = SimpleDateFormat("HH:mm", locale).format(date)
|
||||
|
||||
when {
|
||||
isSameDay(cal, today) -> timeStr
|
||||
isSameDay(cal, yesterday) -> "Kemarin $timeStr"
|
||||
isWithinLastDays(date, 7) -> {
|
||||
val dayStr = SimpleDateFormat("EEEE", locale).format(date).replace(".", "")
|
||||
"$dayStr $timeStr"
|
||||
}
|
||||
else -> {
|
||||
val dateStr = SimpleDateFormat("dd/MM/yy", locale).format(date)
|
||||
"$dateStr $timeStr"
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
raw
|
||||
}
|
||||
}
|
||||
|
||||
private fun isWithinLastDays(date: Date, days: Int): Boolean {
|
||||
val diff = System.currentTimeMillis() - date.time
|
||||
val limit = days.toLong() * 24L * 60L * 60L * 1000L
|
||||
return diff in 0 until limit
|
||||
}
|
||||
|
||||
fun isFemale(gender: String?): Boolean {
|
||||
return gender == "P"
|
||||
}
|
||||
|
||||
fun showEmpty(isEmpty: Boolean, tvEmpty: TextView, rl: RecyclerView) {
|
||||
tvEmpty.visibility = if (isEmpty) View.VISIBLE else View.GONE
|
||||
rl.visibility = if (isEmpty) View.GONE else View.VISIBLE
|
||||
}
|
||||
|
||||
fun Context.queryDisplayName(uri: Uri): String? {
|
||||
val projection = arrayOf(OpenableColumns.DISPLAY_NAME)
|
||||
return runCatching {
|
||||
contentResolver.query(uri, projection, null, null, null)?.use { cursor: Cursor ->
|
||||
val idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
if (idx == -1) return@use null
|
||||
cursor.moveToFirst()
|
||||
cursor.getString(idx)
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
fun Context.copyUriToCacheFile(
|
||||
uri: Uri,
|
||||
prefix: String = "ATTACH_"
|
||||
): File {
|
||||
val mime = contentResolver.getType(uri).orEmpty()
|
||||
|
||||
val nameFromProvider = queryDisplayName(uri)
|
||||
val extFromMime = runCatching {
|
||||
MimeTypeMap.getSingleton().getExtensionFromMimeType(mime)
|
||||
}.getOrNull().orEmpty()
|
||||
|
||||
// Nama file aman
|
||||
val baseName = nameFromProvider?.takeIf { it.isNotBlank() }
|
||||
?: "${prefix}${System.currentTimeMillis()}"
|
||||
|
||||
val safeName = when {
|
||||
extFromMime.isNotBlank() && !baseName.endsWith(".$extFromMime", true) -> "$baseName.$extFromMime"
|
||||
else -> baseName
|
||||
}
|
||||
|
||||
val outFile = File(cacheDir, safeName)
|
||||
|
||||
contentResolver.openInputStream(uri)?.use { input ->
|
||||
FileOutputStream(outFile).use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
} ?: throw IllegalStateException("Gagal membuka file dari Uri: $uri")
|
||||
|
||||
return outFile
|
||||
}
|
||||
|
||||
fun Context.uriToMultipartPart(
|
||||
partName: String,
|
||||
uri: Uri,
|
||||
filenameOverride: String? = null
|
||||
): MultipartBody.Part {
|
||||
val mime = contentResolver.getType(uri).orEmpty()
|
||||
val file = copyUriToCacheFile(uri)
|
||||
|
||||
val mediaType = mime.toMediaTypeOrNull()
|
||||
val body = file.asRequestBody(mediaType)
|
||||
|
||||
val filename = filenameOverride ?: (queryDisplayName(uri) ?: file.name)
|
||||
return MultipartBody.Part.createFormData(partName, filename, body)
|
||||
}
|
||||
|
||||
fun forceLogoutAndGoLogin(activity: Activity) {
|
||||
val firebaseReady = FirebaseApp.getApps(activity.applicationContext).isNotEmpty()
|
||||
|
||||
// ambil topic sebelum clear prefs
|
||||
val oldTopic = Preferences.getLastFcmTopic(activity).trim()
|
||||
|
||||
fun finishLogout() {
|
||||
// clear semua data setelah unsubscribe selesai
|
||||
Preferences.clearAll(activity)
|
||||
goLoginClearTask(activity)
|
||||
}
|
||||
|
||||
// Sign out FirebaseAuth (kalau dipakai)
|
||||
runCatching {
|
||||
if (firebaseReady) FirebaseAuth.getInstance().signOut()
|
||||
}.onFailure {
|
||||
Log.w("Logout", "FirebaseAuth.signOut failed: ${it.message}", it)
|
||||
}
|
||||
|
||||
if (!firebaseReady) {
|
||||
finishLogout()
|
||||
return
|
||||
}
|
||||
|
||||
// 1) Unsubscribe dari topic terakhir (kalau ada)
|
||||
val doDeleteToken = {
|
||||
runCatching {
|
||||
FirebaseMessaging.getInstance().deleteToken()
|
||||
.addOnCompleteListener {
|
||||
finishLogout()
|
||||
}
|
||||
}.onFailure {
|
||||
Log.w("Logout", "deleteToken failed: ${it.message}", it)
|
||||
finishLogout()
|
||||
}
|
||||
}
|
||||
|
||||
if (oldTopic.isNotBlank()) {
|
||||
FirebaseMessaging.getInstance().unsubscribeFromTopic(oldTopic)
|
||||
.addOnCompleteListener { task ->
|
||||
Log.d(
|
||||
"Logout",
|
||||
"unsubscribe topic=$oldTopic success=${task.isSuccessful} err=${task.exception}"
|
||||
)
|
||||
// bersihkan last topic
|
||||
Preferences.setLastFcmTopic(activity, "")
|
||||
doDeleteToken()
|
||||
}
|
||||
} else {
|
||||
doDeleteToken()
|
||||
}
|
||||
}
|
||||
|
||||
private fun goLoginClearTask(activity: Activity) {
|
||||
val intent = Intent(activity, LoginActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
}
|
||||
activity.startActivity(intent)
|
||||
activity.finish()
|
||||
}
|
||||
|
||||
fun uriToBase64(context: Context, uri: Uri): String? {
|
||||
return try {
|
||||
val inputStream = context.contentResolver.openInputStream(uri)
|
||||
val bytes = inputStream?.readBytes()
|
||||
inputStream?.close()
|
||||
if (bytes != null) {
|
||||
Base64.encodeToString(bytes, Base64.DEFAULT)
|
||||
} else null
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
5
app/src/main/java/com/amz/genie/models/ActivityItem.kt
Normal file
5
app/src/main/java/com/amz/genie/models/ActivityItem.kt
Normal file
@@ -0,0 +1,5 @@
|
||||
package com.amz.genie.models
|
||||
|
||||
data class ActivityItem(
|
||||
val text: String
|
||||
)
|
||||
9
app/src/main/java/com/amz/genie/models/AddActionItem.kt
Normal file
9
app/src/main/java/com/amz/genie/models/AddActionItem.kt
Normal file
@@ -0,0 +1,9 @@
|
||||
package com.amz.genie.models
|
||||
|
||||
data class AddActionItem(
|
||||
val id: Int,
|
||||
val title: String,
|
||||
val idTentang: String? = null,
|
||||
val idKomunikasi: String? = null,
|
||||
val komunikasi_detail: ArrayList<KomunikasiDetail>? = null,
|
||||
)
|
||||
24
app/src/main/java/com/amz/genie/models/Aksi.kt
Normal file
24
app/src/main/java/com/amz/genie/models/Aksi.kt
Normal file
@@ -0,0 +1,24 @@
|
||||
package com.amz.genie.models
|
||||
|
||||
data class Aksi(
|
||||
val id: Int,
|
||||
val komunikasi: Komunikasi,
|
||||
val tentang: Tentang,
|
||||
val tipe_komunikasi: TipeKomunikasi? = null,
|
||||
val pembuat: Pegawai,
|
||||
val uraian: String,
|
||||
val selesai: String = "T",
|
||||
val waktu_buat: String,
|
||||
val kepada: ArrayList<OfficeTrnAksiKepada>? = null,
|
||||
val lampiran: ArrayList<OfficeTrnAksiLampiran>? = null,
|
||||
val reaksi: ArrayList<Reaksi>? = null,
|
||||
val aksi_komunikasi_angka: OfficeTrnAksiKomunikasiAngka? = null,
|
||||
val aksi_komunikasi_jam: OfficeTrnAksiKomunikasiJam? = null,
|
||||
val aksi_komunikasi_lampiran: ArrayList<OfficeTrnAksiKomunikasiLampiran>? = null,
|
||||
val aksi_komunikasi_list: ArrayList<OfficeTrnAksiKomunikasiList>? = null,
|
||||
val aksi_komunikasi_pecahan: OfficeTrnAksiKomunikasiPecahan? = null,
|
||||
val aksi_komunikasi_string: OfficeTrnAksiKomunikasiString? = null,
|
||||
val aksi_komunikasi_tanggal: OfficeTrnAksiKomunikasiTanggal? = null,
|
||||
val aksi_komunikasi_teks: OfficeTrnAksiKomunikasiTeks? = null,
|
||||
val aksi_komunikasi_waktu: OfficeTrnAksiKomunikasiWaktu? = null,
|
||||
)
|
||||
5
app/src/main/java/com/amz/genie/models/AttachmentItem.kt
Normal file
5
app/src/main/java/com/amz/genie/models/AttachmentItem.kt
Normal file
@@ -0,0 +1,5 @@
|
||||
package com.amz.genie.models
|
||||
|
||||
import android.net.Uri
|
||||
|
||||
data class AttachmentItem(val uri: Uri, val name: String, val mime: String)
|
||||
16
app/src/main/java/com/amz/genie/models/ChatItem.kt
Normal file
16
app/src/main/java/com/amz/genie/models/ChatItem.kt
Normal file
@@ -0,0 +1,16 @@
|
||||
package com.amz.genie.models
|
||||
|
||||
import com.google.gson.JsonObject
|
||||
|
||||
data class ChatItem(
|
||||
val id: String,
|
||||
val senderKode: String?,
|
||||
val senderName: String?,
|
||||
val senderJob: String?,
|
||||
val senderOutlet: String?,
|
||||
val message: JsonObject?,
|
||||
val timeText: String?,
|
||||
val isMine: Boolean,
|
||||
val isSameSenderAsPrev: Boolean
|
||||
)
|
||||
|
||||
7
app/src/main/java/com/amz/genie/models/FormAttachment.kt
Normal file
7
app/src/main/java/com/amz/genie/models/FormAttachment.kt
Normal file
@@ -0,0 +1,7 @@
|
||||
package com.amz.genie.models
|
||||
|
||||
data class FormAttachment(
|
||||
val uri: android.net.Uri,
|
||||
val fileName: String,
|
||||
val mimeType: String
|
||||
)
|
||||
61
app/src/main/java/com/amz/genie/models/GeneralResponse.kt
Normal file
61
app/src/main/java/com/amz/genie/models/GeneralResponse.kt
Normal file
@@ -0,0 +1,61 @@
|
||||
package com.amz.genie.models
|
||||
|
||||
import com.google.gson.JsonObject
|
||||
|
||||
data class GeneralResponse(
|
||||
val items: ArrayList<GeneralThreadItem>? = arrayListOf()
|
||||
)
|
||||
|
||||
data class GeneralThreadItem(
|
||||
val counterpart: String? = null,
|
||||
val tipe: String, // "AKSI" atau "REAKSI"
|
||||
val aksi: Aksi,
|
||||
val unread_count: Int = 0,
|
||||
val reaksi: Reaksi? = null,
|
||||
val is_unread: Boolean? = null,
|
||||
val is_aktif: Int? = null
|
||||
)
|
||||
|
||||
data class GeneralDetailResponse(
|
||||
val counterpart: Pegawai? = null,
|
||||
val items: List<GeneralThreadItem> = emptyList(),
|
||||
val meta: Meta? = null
|
||||
) {
|
||||
data class Meta(
|
||||
val page: Int? = null,
|
||||
val per_page: Int? = null,
|
||||
val total: Int? = null,
|
||||
val has_more: Boolean? = null,
|
||||
val next_page: Int? = null
|
||||
)
|
||||
}
|
||||
|
||||
data class InboxThreadResponse(
|
||||
val items: List<RawMessage>?,
|
||||
val meta: Meta?
|
||||
)
|
||||
|
||||
data class Meta(
|
||||
val page: Int?,
|
||||
val per_page: Int?,
|
||||
val total: Int?,
|
||||
val has_more: Boolean?,
|
||||
val next_page: Int?
|
||||
)
|
||||
|
||||
data class Sender(
|
||||
val nama: String?,
|
||||
val jabatan: Jabatan?,
|
||||
val outlet: Outlet?
|
||||
)
|
||||
|
||||
data class RawMessage(
|
||||
val id: String?,
|
||||
val sender_kode: String,
|
||||
val sender: Sender?,
|
||||
val message: JsonObject?,
|
||||
val waktu_buat: String?,
|
||||
val tipe: String?
|
||||
)
|
||||
|
||||
|
||||
6
app/src/main/java/com/amz/genie/models/Jabatan.kt
Normal file
6
app/src/main/java/com/amz/genie/models/Jabatan.kt
Normal file
@@ -0,0 +1,6 @@
|
||||
package com.amz.genie.models
|
||||
|
||||
data class Jabatan(
|
||||
val kode: Int,
|
||||
val nama: String
|
||||
)
|
||||
10
app/src/main/java/com/amz/genie/models/Komunikasi.kt
Normal file
10
app/src/main/java/com/amz/genie/models/Komunikasi.kt
Normal file
@@ -0,0 +1,10 @@
|
||||
package com.amz.genie.models
|
||||
|
||||
data class Komunikasi(
|
||||
val kode: Int,
|
||||
val id_tipe_komunikasi: String,
|
||||
val id_tentang: String,
|
||||
val komunikasi: String,
|
||||
val id_status_data: Int,
|
||||
val komunikasi_detail: ArrayList<KomunikasiDetail>?
|
||||
)
|
||||
24
app/src/main/java/com/amz/genie/models/KomunikasiDetail.kt
Normal file
24
app/src/main/java/com/amz/genie/models/KomunikasiDetail.kt
Normal file
@@ -0,0 +1,24 @@
|
||||
package com.amz.genie.models
|
||||
|
||||
data class KomunikasiDetail(
|
||||
val kode: Int? = null,
|
||||
val kode_komunikasi: Int,
|
||||
val urutan: Int? = null,
|
||||
val id_jenis_isian: Int,
|
||||
val jenis_isian: OfficeRefJenisIsian? = null,
|
||||
val isian: String,
|
||||
val petunjuk: String? = null,
|
||||
val is_wajib: Int = 1,
|
||||
val is_aktif: Int = 1,
|
||||
val nilai_pemicu: String?,
|
||||
val is_wajib_kondisional: Int = 0,
|
||||
val id_tahap_isian: String = "A",
|
||||
val operator_pemicu: String?,
|
||||
val kode_detail_induk: Int? = null,
|
||||
val is_separator: Int = 0,
|
||||
val is_pertanyaan: Int = 0,
|
||||
val is_rantai_komunikasi: Int = 0,
|
||||
val is_pemicu_tindak_lanjut_isian: Int = 0,
|
||||
val max_item: Int,
|
||||
val min_item: Int
|
||||
)
|
||||
7
app/src/main/java/com/amz/genie/models/Login.kt
Normal file
7
app/src/main/java/com/amz/genie/models/Login.kt
Normal file
@@ -0,0 +1,7 @@
|
||||
package com.amz.genie.models
|
||||
|
||||
data class Login(
|
||||
val access_token: String,
|
||||
val refresh_token: String,
|
||||
val user: Pengguna
|
||||
)
|
||||
5
app/src/main/java/com/amz/genie/models/Message.kt
Normal file
5
app/src/main/java/com/amz/genie/models/Message.kt
Normal file
@@ -0,0 +1,5 @@
|
||||
package com.amz.genie.models
|
||||
|
||||
data class Message(
|
||||
val message: String
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.amz.genie.models
|
||||
|
||||
data class OfficeRefJenisIsian(
|
||||
val id: Int,
|
||||
val nama_jenis_isian: String
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.amz.genie.models
|
||||
|
||||
data class OfficeTrnAksiKepada(
|
||||
val id: Int,
|
||||
val aksi_id: Int,
|
||||
val kode_kepada: String,
|
||||
val kepada: Pegawai,
|
||||
val id_status_komunikasi: String = "T",
|
||||
val id_kotak_pesan: Int = 1,
|
||||
val is_adhoc: Int = 0,
|
||||
val is_aktif: Int = 0,
|
||||
val is_selesai: Int = 0,
|
||||
val id_status_data: Int = 1,
|
||||
val waktu_buat: String,
|
||||
val waktu_ubah: String,
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.amz.genie.models
|
||||
|
||||
data class OfficeTrnAksiKomunikasiAngka(
|
||||
val id: Int,
|
||||
val aksi_id: Int,
|
||||
val komunikasi_detail: KomunikasiDetail? = null,
|
||||
val nilai: Int,
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.amz.genie.models
|
||||
|
||||
data class OfficeTrnAksiKomunikasiJam(
|
||||
val id: Int,
|
||||
val aksi_id: Int,
|
||||
val komunikasi_detail: KomunikasiDetail? = null,
|
||||
val nilai: String,
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.amz.genie.models
|
||||
|
||||
data class OfficeTrnAksiKomunikasiLampiran(
|
||||
val id: Int,
|
||||
val aksi_id: Int,
|
||||
val komunikasi_detail: KomunikasiDetail? = null,
|
||||
val nilai: String,
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.amz.genie.models
|
||||
|
||||
data class OfficeTrnAksiKomunikasiList(
|
||||
val id: Int,
|
||||
val aksi_id: Int,
|
||||
val komunikasi_detail: KomunikasiDetail? = null,
|
||||
val baris: Int,
|
||||
val nilai: String,
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.amz.genie.models
|
||||
|
||||
data class OfficeTrnAksiKomunikasiPecahan(
|
||||
val id: Int,
|
||||
val aksi_id: Int,
|
||||
val komunikasi_detail: KomunikasiDetail? = null,
|
||||
val nilai: Float,
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.amz.genie.models
|
||||
|
||||
data class OfficeTrnAksiKomunikasiString(
|
||||
val id: Int,
|
||||
val aksi_id: Int,
|
||||
val komunikasi_detail: KomunikasiDetail? = null,
|
||||
val nilai: String,
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.amz.genie.models
|
||||
|
||||
data class OfficeTrnAksiKomunikasiTanggal(
|
||||
val id: Int,
|
||||
val aksi_id: Int,
|
||||
val komunikasi_detail: KomunikasiDetail? = null,
|
||||
val nilai: String,
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.amz.genie.models
|
||||
|
||||
data class OfficeTrnAksiKomunikasiTeks(
|
||||
val id: Int? = null,
|
||||
val aksi_id: Int? = null,
|
||||
val komunikasi_detail: KomunikasiDetail? = null,
|
||||
val nilai: String,
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.amz.genie.models
|
||||
|
||||
data class OfficeTrnAksiKomunikasiWaktu(
|
||||
val id: Int,
|
||||
val aksi_id: Int,
|
||||
val komunikasi_detail: KomunikasiDetail? = null,
|
||||
val nilai: String,
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.amz.genie.models
|
||||
|
||||
data class OfficeTrnAksiLampiran(
|
||||
val id: Int,
|
||||
val aksi_id: String,
|
||||
val path_to_arsip: String,
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.amz.genie.models
|
||||
|
||||
data class OfficeTrnReaksiKepada(
|
||||
val id: Int,
|
||||
val reaksi_id: Int,
|
||||
val kode_kepada: String,
|
||||
val id_status_komunikasi: String = "T",
|
||||
val id_kotak_pesan: Int = 1,
|
||||
val is_adhoc: Int = 0,
|
||||
val is_aktif: Int = 0,
|
||||
val is_selesai: Int = 0,
|
||||
val waktu_buat: String,
|
||||
val waktu_ubah: String,
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.amz.genie.models
|
||||
|
||||
data class OfficeTrnReaksiLampiran(
|
||||
val id: Int,
|
||||
val reaksi_id: String,
|
||||
val path_to_arsip: String,
|
||||
)
|
||||
7
app/src/main/java/com/amz/genie/models/Outlet.kt
Normal file
7
app/src/main/java/com/amz/genie/models/Outlet.kt
Normal file
@@ -0,0 +1,7 @@
|
||||
package com.amz.genie.models
|
||||
|
||||
data class Outlet(
|
||||
val kode: Int,
|
||||
val nama: String,
|
||||
val singkatan: String,
|
||||
)
|
||||
11
app/src/main/java/com/amz/genie/models/Pegawai.kt
Normal file
11
app/src/main/java/com/amz/genie/models/Pegawai.kt
Normal file
@@ -0,0 +1,11 @@
|
||||
package com.amz.genie.models
|
||||
|
||||
data class Pegawai(
|
||||
val kode: String,
|
||||
val nama: String,
|
||||
val outlet: Outlet?,
|
||||
val jabatan: Jabatan?,
|
||||
val mulai_bekerja: String,
|
||||
val id_kelamin: String,
|
||||
val outlets: ArrayList<Outlet>?
|
||||
)
|
||||
9
app/src/main/java/com/amz/genie/models/Pengguna.kt
Normal file
9
app/src/main/java/com/amz/genie/models/Pengguna.kt
Normal file
@@ -0,0 +1,9 @@
|
||||
package com.amz.genie.models
|
||||
|
||||
data class Pengguna(
|
||||
val kode: Int,
|
||||
val pegawai: Pegawai?,
|
||||
val email: String?,
|
||||
val telephone: String?,
|
||||
val username: String
|
||||
)
|
||||
10
app/src/main/java/com/amz/genie/models/PostAksi.kt
Normal file
10
app/src/main/java/com/amz/genie/models/PostAksi.kt
Normal file
@@ -0,0 +1,10 @@
|
||||
package com.amz.genie.models
|
||||
|
||||
data class PostAksi(
|
||||
var kode: Int?,
|
||||
var kepada: List<String>,
|
||||
var tentang: String,
|
||||
var topic: String,
|
||||
var uraian: String,
|
||||
var komunikasiDetail: ArrayList<KomunikasiDetail>?
|
||||
)
|
||||
16
app/src/main/java/com/amz/genie/models/Reaksi.kt
Normal file
16
app/src/main/java/com/amz/genie/models/Reaksi.kt
Normal file
@@ -0,0 +1,16 @@
|
||||
package com.amz.genie.models
|
||||
|
||||
data class Reaksi(
|
||||
val id: Int,
|
||||
val id_aksi: Int? = null,
|
||||
val id_reaksi: Int? = null,
|
||||
val id_jenis_reaksi: String? = null,
|
||||
val kode_komunikasi: Int? = null,
|
||||
val tentang: Tentang? = null,
|
||||
val tipe_komunikasi: TipeKomunikasi? = null,
|
||||
val pembuat: Pegawai,
|
||||
val uraian: String?,
|
||||
val waktu_buat: String,
|
||||
val kepada_reaksi: ArrayList<OfficeTrnReaksiKepada>? = null,
|
||||
val lampiran_reaksi: ArrayList<OfficeTrnReaksiLampiran>? = null
|
||||
)
|
||||
10
app/src/main/java/com/amz/genie/models/ReaksiPost.kt
Normal file
10
app/src/main/java/com/amz/genie/models/ReaksiPost.kt
Normal file
@@ -0,0 +1,10 @@
|
||||
package com.amz.genie.models
|
||||
|
||||
data class ReaksiPost(
|
||||
var aksi_id: Int,
|
||||
var jenis_reaksi: String,
|
||||
var reaksi_id: Int? = null,
|
||||
var tentang: String,
|
||||
var topic: String,
|
||||
var uraian: String
|
||||
)
|
||||
7
app/src/main/java/com/amz/genie/models/ReaksiResponse.kt
Normal file
7
app/src/main/java/com/amz/genie/models/ReaksiResponse.kt
Normal file
@@ -0,0 +1,7 @@
|
||||
package com.amz.genie.models
|
||||
|
||||
data class ReaksiResponse(
|
||||
val message: String,
|
||||
val reaksi_id: Int,
|
||||
val thread: InboxThreadResponse
|
||||
)
|
||||
11
app/src/main/java/com/amz/genie/models/Tentang.kt
Normal file
11
app/src/main/java/com/amz/genie/models/Tentang.kt
Normal file
@@ -0,0 +1,11 @@
|
||||
package com.amz.genie.models
|
||||
|
||||
data class Tentang(
|
||||
val id: String,
|
||||
val tentang: String,
|
||||
val keterangan: String?,
|
||||
val is_aktif: Int = 1,
|
||||
val id_status_data: Int = 1,
|
||||
val waktu_ubah: String,
|
||||
val diubah_oleh: Int? = null
|
||||
)
|
||||
11
app/src/main/java/com/amz/genie/models/TipeKomunikasi.kt
Normal file
11
app/src/main/java/com/amz/genie/models/TipeKomunikasi.kt
Normal file
@@ -0,0 +1,11 @@
|
||||
package com.amz.genie.models
|
||||
|
||||
data class TipeKomunikasi(
|
||||
val id: String,
|
||||
val tipe_komunikasi: String,
|
||||
val keterangan: String?,
|
||||
val is_aktif: Int = 1,
|
||||
val id_status_data: Int = 1,
|
||||
val waktu_ubah: String,
|
||||
val diubah_oleh: Int? = null
|
||||
)
|
||||
100
app/src/main/java/com/amz/genie/services/APIMain.kt
Normal file
100
app/src/main/java/com/amz/genie/services/APIMain.kt
Normal file
@@ -0,0 +1,100 @@
|
||||
package com.amz.genie.services
|
||||
|
||||
import android.app.Application
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.util.Log
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import com.amz.genie.helpers.Preferences
|
||||
import com.google.firebase.FirebaseApp
|
||||
import com.google.firebase.messaging.FirebaseMessaging
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class APIMain: Application() {
|
||||
companion object {
|
||||
lateinit var instance: APIMain
|
||||
private set
|
||||
|
||||
fun require(): APIMain {
|
||||
check(::instance.isInitialized) {
|
||||
"APIMain belum diinisialisasi. Pastikan android:name='.services.APIMain' sudah ada " +
|
||||
"di AndroidManifest dan jangan panggil APIMain() secara langsung."
|
||||
}
|
||||
return instance
|
||||
}
|
||||
}
|
||||
|
||||
lateinit var accountServices: AuthServices
|
||||
lateinit var generalServices: GeneralServices
|
||||
lateinit var reaksiServices: ReaksiServices
|
||||
lateinit var selectionServices: SelectionServices
|
||||
lateinit var actionServices: ActionServices
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
// Paksa aplikasi selalu LIGHT (disable dark mode)
|
||||
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
|
||||
|
||||
instance = this
|
||||
|
||||
val firebaseApp = runCatching { FirebaseApp.initializeApp(this) }.getOrNull()
|
||||
if (firebaseApp == null) {
|
||||
Log.e("FirebaseInit", "FirebaseApp.initializeApp() returned NULL. App lanjut tanpa Firebase.")
|
||||
} else {
|
||||
runCatching {
|
||||
FirebaseMessaging.getInstance().isAutoInitEnabled = true
|
||||
Log.d("FirebaseInit", "Firebase ready, FCM autoInit enabled")
|
||||
}.onFailure {
|
||||
Log.e("FirebaseInit", "FCM init failed: ${it.message}", it)
|
||||
}
|
||||
}
|
||||
|
||||
Log.d("APIMain", "onCreate() called, initializing Retrofit...")
|
||||
|
||||
val tokenRepo = TokenRepository(this)
|
||||
|
||||
val isDebug = (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0
|
||||
val logging = HttpLoggingInterceptor().apply {
|
||||
level = if (isDebug) HttpLoggingInterceptor.Level.BODY
|
||||
else HttpLoggingInterceptor.Level.NONE
|
||||
}
|
||||
|
||||
val baseClient = OkHttpClient.Builder()
|
||||
.addInterceptor(logging)
|
||||
.connectTimeout(100, TimeUnit.SECONDS)
|
||||
.readTimeout(3600, TimeUnit.SECONDS)
|
||||
.writeTimeout(120, TimeUnit.SECONDS)
|
||||
.callTimeout(3600, TimeUnit.SECONDS)
|
||||
.retryOnConnectionFailure(true)
|
||||
.build()
|
||||
|
||||
val retrofitAuth = Retrofit.Builder()
|
||||
.baseUrl(Preferences.API_URL)
|
||||
.client(baseClient)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
val authServices = retrofitAuth.create(AuthServices::class.java)
|
||||
|
||||
val mainClient = baseClient.newBuilder()
|
||||
.addInterceptor(HeaderInterceptor(tokenRepo))
|
||||
.addInterceptor(ExpiredRetryInterceptor(tokenRepo, authServices))
|
||||
.authenticator(TokenAuthenticator(tokenRepo, authServices))
|
||||
.build()
|
||||
|
||||
val retrofit = Retrofit.Builder()
|
||||
.baseUrl(Preferences.API_URL)
|
||||
.client(mainClient)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
|
||||
accountServices = retrofit.create(AuthServices::class.java)
|
||||
generalServices = retrofit.create(GeneralServices::class.java)
|
||||
reaksiServices = retrofit.create(ReaksiServices::class.java)
|
||||
selectionServices = retrofit.create(SelectionServices::class.java)
|
||||
actionServices = retrofit.create(ActionServices::class.java)
|
||||
}
|
||||
}
|
||||
20
app/src/main/java/com/amz/genie/services/ActionServices.kt
Normal file
20
app/src/main/java/com/amz/genie/services/ActionServices.kt
Normal file
@@ -0,0 +1,20 @@
|
||||
package com.amz.genie.services
|
||||
|
||||
import com.amz.genie.models.Message
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody
|
||||
import retrofit2.Call
|
||||
import retrofit2.http.Header
|
||||
import retrofit2.http.Multipart
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Part
|
||||
|
||||
interface ActionServices {
|
||||
@Multipart
|
||||
@POST("aksi/add")
|
||||
fun add(
|
||||
@Header("Authorization") token: String?,
|
||||
@Part("data") data: RequestBody,
|
||||
@Part files: List<MultipartBody.Part>? = emptyList()
|
||||
): Call<Message>
|
||||
}
|
||||
34
app/src/main/java/com/amz/genie/services/AuthServices.kt
Normal file
34
app/src/main/java/com/amz/genie/services/AuthServices.kt
Normal file
@@ -0,0 +1,34 @@
|
||||
package com.amz.genie.services
|
||||
|
||||
import com.amz.genie.models.Login
|
||||
import com.amz.genie.models.Message
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import okhttp3.RequestBody
|
||||
import retrofit2.Call
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.Header
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Path
|
||||
|
||||
interface AuthServices {
|
||||
@POST("auth/refresh")
|
||||
fun refresh(
|
||||
@Header("Authorization") refreshAuthorization: String // "Bearer <refresh_token>"
|
||||
): Call<RefreshResponse>
|
||||
|
||||
@POST("login")
|
||||
fun login(
|
||||
@Body requestBody: RequestBody
|
||||
): Call<Login>
|
||||
|
||||
@DELETE("api/{path}")
|
||||
fun logoutDynamic(
|
||||
@Path("path") path: String,
|
||||
@Header("Authorization") token: String?
|
||||
): Call<Message>
|
||||
}
|
||||
|
||||
data class RefreshResponse(
|
||||
@SerializedName("access_token") val accessToken: String
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.amz.genie.services
|
||||
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
|
||||
class ExpiredRetryInterceptor(private val tokenRepo: TokenRepository,
|
||||
private val authServices: AuthServices
|
||||
) : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
var req = chain.request()
|
||||
var resp = chain.proceed(req)
|
||||
|
||||
if (resp.code == 500) {
|
||||
val peek = resp.peekBody(1024 * 1024).string()
|
||||
val looksExpired = peek.contains("token_expired", true) ||
|
||||
peek.contains("Signature has expired", true)
|
||||
|
||||
if (looksExpired) {
|
||||
resp.close()
|
||||
val refresh = tokenRepo.getRefreshToken()
|
||||
if (!refresh.isNullOrBlank()) {
|
||||
val r = try { authServices.refresh("Bearer $refresh").execute() } catch (_: Exception) { null }
|
||||
val newAccess = r?.body()?.accessToken
|
||||
if (r != null && r.isSuccessful && !newAccess.isNullOrBlank()) {
|
||||
tokenRepo.saveAccessToken(newAccess)
|
||||
req = req.newBuilder()
|
||||
.header("Authorization", "Bearer $newAccess")
|
||||
.build()
|
||||
return chain.proceed(req) // retry sekali
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
}
|
||||
44
app/src/main/java/com/amz/genie/services/GeneralServices.kt
Normal file
44
app/src/main/java/com/amz/genie/services/GeneralServices.kt
Normal file
@@ -0,0 +1,44 @@
|
||||
package com.amz.genie.services
|
||||
|
||||
import com.amz.genie.models.GeneralDetailResponse
|
||||
import com.amz.genie.models.GeneralResponse
|
||||
import com.amz.genie.models.InboxThreadResponse
|
||||
import com.amz.genie.models.Message
|
||||
import retrofit2.Call
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Header
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Query
|
||||
|
||||
interface GeneralServices {
|
||||
@GET("general/list")
|
||||
fun list(
|
||||
@Header("Authorization") token: String?,
|
||||
@Query("general_id") generalId: Int
|
||||
): Call<GeneralResponse>
|
||||
|
||||
@GET("inbox/detail")
|
||||
fun detail(
|
||||
@Header("Authorization") token: String?,
|
||||
@Query("counterpart_kode") counterPartKode: String?,
|
||||
@Query("page") page: Int,
|
||||
@Query("per_page") perPage: Int
|
||||
): Call<GeneralDetailResponse>
|
||||
|
||||
@GET("inbox/thread")
|
||||
fun threadDetail(
|
||||
@Header("Authorization") token: String?,
|
||||
@Query("counterpart_kode") counterpart: String,
|
||||
@Query("aksi_id") aksiId: Int,
|
||||
@Query("page") page: Int,
|
||||
@Query("per_page") perPage: Int
|
||||
): Call<InboxThreadResponse>
|
||||
|
||||
@POST("inbox/readed")
|
||||
fun readed(
|
||||
@Header("Authorization") token: String?,
|
||||
@Query("aksi_reaksi_id") aksiReaksiId: Int,
|
||||
@Query("tipe") tipe: String,
|
||||
): Call<Message>
|
||||
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.amz.genie.services
|
||||
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
|
||||
class HeaderInterceptor(
|
||||
private val tokenRepo: TokenRepository
|
||||
) : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val original = chain.request()
|
||||
val b = original.newBuilder()
|
||||
.header("Content-Type", "application/json")
|
||||
|
||||
val access = tokenRepo.getAccessToken()
|
||||
if (!access.isNullOrBlank()) {
|
||||
b.header("Authorization", access)
|
||||
}
|
||||
|
||||
return chain.proceed(b.method(original.method, original.body).build())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package com.amz.genie.services
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.media.AudioAttributes
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.net.toUri
|
||||
import com.amz.genie.R
|
||||
import com.amz.genie.activities.MainActivity
|
||||
import com.google.firebase.messaging.RemoteMessage
|
||||
import com.google.firebase.messaging.FirebaseMessagingService
|
||||
import androidx.core.app.TaskStackBuilder
|
||||
import com.amz.genie.activities.GeneralDetailActivity
|
||||
import com.amz.genie.activities.GeneralSubDetailActivity
|
||||
|
||||
|
||||
class MyFirebaseMessagingService: FirebaseMessagingService() {
|
||||
override fun onNewToken(token: String) {
|
||||
super.onNewToken(token)
|
||||
Log.d("FCM", "Refreshed token: $token")
|
||||
}
|
||||
|
||||
override fun onMessageReceived(remoteMessage: RemoteMessage) {
|
||||
super.onMessageReceived(remoteMessage)
|
||||
|
||||
val titleFromNotif = remoteMessage.notification?.title
|
||||
val bodyFromNotif = remoteMessage.notification?.body
|
||||
|
||||
val title = remoteMessage.data["title"] ?: titleFromNotif
|
||||
val body = remoteMessage.data["body"] ?: bodyFromNotif
|
||||
|
||||
Log.d("FCM", "data=${remoteMessage.data} notifTitle=$titleFromNotif")
|
||||
|
||||
if (!title.isNullOrBlank() || !body.isNullOrBlank() || remoteMessage.data.isNotEmpty()) {
|
||||
sendNotification(title, body, remoteMessage.data)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureChannels(notificationManager: NotificationManager) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
|
||||
val soundUri = "android.resource://${packageName}/${R.raw.notification}".toUri()
|
||||
val attrs = AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||
.build()
|
||||
|
||||
// DEFAULT
|
||||
val defaultChannel = NotificationChannel(
|
||||
"genie_default_v1",
|
||||
"Genie - Umum",
|
||||
NotificationManager.IMPORTANCE_DEFAULT
|
||||
)
|
||||
|
||||
// URGENT (lebih kenceng)
|
||||
val urgentChannel = NotificationChannel(
|
||||
"genie_urgent_v1",
|
||||
"Genie - Penting",
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
).apply {
|
||||
enableVibration(true)
|
||||
setSound(soundUri, attrs)
|
||||
// enableLights(true) // optional
|
||||
}
|
||||
|
||||
notificationManager.createNotificationChannel(defaultChannel)
|
||||
notificationManager.createNotificationChannel(urgentChannel)
|
||||
}
|
||||
|
||||
private fun buildClickPendingIntent(data: Map<String, String>, requestCode: Int): PendingIntent {
|
||||
val open = data["open"] ?: "main" // main | inbox_detail | inbox_subdetail
|
||||
val dataJson = data["data"] // JSON InboxThreadItem
|
||||
val counterpart = data["counterpart"] // untuk subdetail
|
||||
|
||||
// 1) MainActivity dulu (biar masuk app dan stack rapi)
|
||||
val mainIntent = Intent(this, MainActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
// optional: kasih sinyal supaya Main fokus ke inbox tab
|
||||
putExtra("open_tab", "inbox")
|
||||
}
|
||||
|
||||
val stack = TaskStackBuilder.create(this)
|
||||
stack.addNextIntent(mainIntent)
|
||||
|
||||
val aksiId = data["aksi_id"] ?: data["id_aksi"] // support dua key
|
||||
val cp = data["counterpart"] ?: data["kode_pembuat"]
|
||||
val tentangId = data["tentang_id"] ?: data["id_tentang"]
|
||||
|
||||
if (open == "inbox_subdetail") {
|
||||
// Kalau ada JSON lama, tetap support
|
||||
if (!dataJson.isNullOrBlank()) {
|
||||
val detailIntent = Intent(this, GeneralDetailActivity::class.java).apply {
|
||||
putExtra("data", dataJson)
|
||||
}
|
||||
stack.addNextIntent(detailIntent)
|
||||
|
||||
val subIntent = Intent(this, GeneralSubDetailActivity::class.java).apply {
|
||||
putExtra("data", dataJson)
|
||||
if (!counterpart.isNullOrBlank()) putExtra("counterpart", counterpart)
|
||||
}
|
||||
stack.addNextIntent(subIntent)
|
||||
} else if (!aksiId.isNullOrBlank() && !cp.isNullOrBlank()) {
|
||||
// ✅ Mode baru: buka thread pakai cp + aksi_id (tanpa JSON)
|
||||
val subIntent = Intent(this, GeneralSubDetailActivity::class.java).apply {
|
||||
putExtra("aksi_id", aksiId)
|
||||
putExtra("counterpart_kode", cp)
|
||||
if (!tentangId.isNullOrBlank()) putExtra("tentang_id", tentangId)
|
||||
// optional: putExtra("tipe", data["tipe"])
|
||||
}
|
||||
stack.addNextIntent(subIntent)
|
||||
}
|
||||
}
|
||||
|
||||
return stack.getPendingIntent(
|
||||
requestCode,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)!!
|
||||
}
|
||||
|
||||
private fun sendNotification(
|
||||
title: String?,
|
||||
message: String?,
|
||||
data: Map<String, String>
|
||||
) {
|
||||
val notificationManager =
|
||||
getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
ensureChannels(notificationManager)
|
||||
|
||||
val isUrgent = data["is_urgent"] == "yes"
|
||||
val channelId = if (isUrgent) "genie_urgent_v1" else "genie_default_v1"
|
||||
|
||||
val soundUri = "android.resource://${packageName}/${R.raw.notification}".toUri()
|
||||
|
||||
// Intent ketika notif diklik
|
||||
val notificationId = (System.currentTimeMillis() % Int.MAX_VALUE).toInt()
|
||||
val pendingIntent = buildClickPendingIntent(data, notificationId)
|
||||
|
||||
val builder = NotificationCompat.Builder(this, channelId)
|
||||
.setSmallIcon(R.drawable.logo_normal)
|
||||
.setContentTitle(title ?: "Genie")
|
||||
.setContentText(message ?: "Ada notifikasi masuk")
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(message ?: "Ada notifikasi masuk"))
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(pendingIntent) // ✅ INI KUNCI: biar klik ada aksi
|
||||
.setPriority(
|
||||
if (isUrgent) NotificationCompat.PRIORITY_HIGH
|
||||
else NotificationCompat.PRIORITY_DEFAULT
|
||||
)
|
||||
|
||||
// Android < 8 (Oreo) perlu set sound langsung
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
builder.setSound(soundUri)
|
||||
}
|
||||
|
||||
notificationManager.notify(notificationId, builder.build())
|
||||
}
|
||||
}
|
||||
20
app/src/main/java/com/amz/genie/services/ReaksiServices.kt
Normal file
20
app/src/main/java/com/amz/genie/services/ReaksiServices.kt
Normal file
@@ -0,0 +1,20 @@
|
||||
package com.amz.genie.services
|
||||
|
||||
import com.amz.genie.models.ReaksiResponse
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody
|
||||
import retrofit2.Call
|
||||
import retrofit2.http.Header
|
||||
import retrofit2.http.Multipart
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Part
|
||||
|
||||
interface ReaksiServices {
|
||||
@Multipart
|
||||
@POST("reaksi/add")
|
||||
fun add(
|
||||
@Header("Authorization") token: String?,
|
||||
@Part("data") data: RequestBody,
|
||||
@Part files: List<MultipartBody.Part>? = emptyList()
|
||||
): Call<ReaksiResponse>
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.amz.genie.services
|
||||
|
||||
import com.amz.genie.models.Komunikasi
|
||||
import com.amz.genie.models.Pegawai
|
||||
import com.amz.genie.models.Tentang
|
||||
import com.amz.genie.models.TipeKomunikasi
|
||||
import retrofit2.Call
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Header
|
||||
import retrofit2.http.Query
|
||||
|
||||
interface SelectionServices {
|
||||
@GET("topics")
|
||||
fun communicationTypes(
|
||||
@Header("Authorization") token: String?
|
||||
): Call<ArrayList<TipeKomunikasi>>
|
||||
|
||||
@GET("tentang")
|
||||
fun tentangs(
|
||||
@Header("Authorization") token: String?,
|
||||
@Query("id_tipe_komunikasi") idTipeKomunikasi: String
|
||||
): Call<ArrayList<Tentang>>
|
||||
|
||||
@GET("penerima")
|
||||
fun recipients(
|
||||
@Header("Authorization") token: String?,
|
||||
@Query("id_tipe_komunikasi") idTipeKomunikasi: String?,
|
||||
@Query("id_tentang") idTentang: String?,
|
||||
): Call<ArrayList<Pegawai>>
|
||||
|
||||
@GET("list_pegawai")
|
||||
fun allRecipient(
|
||||
@Header("Authorization") token: String?,
|
||||
): Call<ArrayList<Pegawai>>
|
||||
|
||||
@GET("komunikasi")
|
||||
fun komunikasi(
|
||||
@Header("Authorization") token: String?
|
||||
): Call<ArrayList<Komunikasi>>
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.amz.genie.services
|
||||
|
||||
import okhttp3.Authenticator
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.Route
|
||||
|
||||
class TokenAuthenticator(
|
||||
private val tokenRepo: TokenRepository,
|
||||
private val authApi: AuthServices
|
||||
) : Authenticator {
|
||||
|
||||
override fun authenticate(route: Route?, response: Response): Request? {
|
||||
// Hindari loop tak berujung
|
||||
if (responseCount(response) >= 2) return null
|
||||
|
||||
val refresh = tokenRepo.getRefreshToken() ?: return null
|
||||
|
||||
val refreshResp = try {
|
||||
authApi.refresh("Bearer $refresh").execute()
|
||||
} catch (_: Exception) { return null }
|
||||
|
||||
if (!refreshResp.isSuccessful) return null
|
||||
val newAccess = refreshResp.body()?.accessToken ?: return null
|
||||
|
||||
tokenRepo.saveAccessToken(newAccess)
|
||||
|
||||
return response.request.newBuilder()
|
||||
.header("Authorization", "Bearer $newAccess")
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun responseCount(response: Response): Int {
|
||||
var r = response; var count = 1
|
||||
while (r.priorResponse != null) { r = r.priorResponse!!; count++ }
|
||||
return count
|
||||
}
|
||||
}
|
||||
10
app/src/main/java/com/amz/genie/services/TokenRepository.kt
Normal file
10
app/src/main/java/com/amz/genie/services/TokenRepository.kt
Normal file
@@ -0,0 +1,10 @@
|
||||
package com.amz.genie.services
|
||||
|
||||
import android.content.Context
|
||||
import com.amz.genie.helpers.Preferences
|
||||
|
||||
class TokenRepository(private val ctx: Context) {
|
||||
fun getAccessToken() = Preferences.getAccessToken(ctx)
|
||||
fun getRefreshToken() = Preferences.getRefreshToken(ctx)
|
||||
fun saveAccessToken(token: String) = Preferences.setAccessToken(ctx, token)
|
||||
}
|
||||
7
app/src/main/res/anim/left_in.xml
Normal file
7
app/src/main/res/anim/left_in.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<translate xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:duration="300"
|
||||
android:fromXDelta="-100%"
|
||||
android:toXDelta="0%"
|
||||
android:interpolator="@android:anim/decelerate_interpolator"
|
||||
/>
|
||||
6
app/src/main/res/anim/left_out.xml
Normal file
6
app/src/main/res/anim/left_out.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<translate xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:duration="300"
|
||||
android:fromXDelta="0%"
|
||||
android:interpolator="@android:anim/decelerate_interpolator"
|
||||
android:toXDelta="-100%" />
|
||||
7
app/src/main/res/anim/right_in.xml
Normal file
7
app/src/main/res/anim/right_in.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<translate xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:duration="300"
|
||||
android:fromXDelta="100%"
|
||||
android:toXDelta="0%"
|
||||
android:interpolator="@android:anim/decelerate_interpolator"
|
||||
/>
|
||||
6
app/src/main/res/anim/right_out.xml
Normal file
6
app/src/main/res/anim/right_out.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<translate xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:duration="300"
|
||||
android:fromXDelta="0%"
|
||||
android:interpolator="@android:anim/decelerate_interpolator"
|
||||
android:toXDelta="100%" />
|
||||
10
app/src/main/res/drawable/add_notes_24px.xml
Normal file
10
app/src/main/res/drawable/add_notes_24px.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M200,840Q167,840 143.5,816.5Q120,793 120,760L120,200Q120,167 143.5,143.5Q167,120 200,120L760,120Q793,120 816.5,143.5Q840,167 840,200L840,468Q821,459 801,452.5Q781,446 760,443L760,200Q760,200 760,200Q760,200 760,200L200,200Q200,200 200,200Q200,200 200,200L200,760Q200,760 200,760Q200,760 200,760L442,760Q445,782 451.5,802Q458,822 467,840L200,840ZM200,720Q200,731 200,740.5Q200,750 200,760L200,760Q200,760 200,760Q200,760 200,760L200,200Q200,200 200,200Q200,200 200,200L200,200Q200,200 200,200Q200,200 200,200L200,443Q200,441 200,440.5Q200,440 200,440Q200,440 200,522Q200,604 200,720ZM280,680L443,680Q446,659 452.5,639Q459,619 467,600L280,600L280,680ZM280,520L524,520Q556,490 595.5,470Q635,450 680,443L680,440L280,440L280,520ZM280,360L680,360L680,280L280,280L280,360ZM720,920Q637,920 578.5,861.5Q520,803 520,720Q520,637 578.5,578.5Q637,520 720,520Q803,520 861.5,578.5Q920,637 920,720Q920,803 861.5,861.5Q803,920 720,920ZM700,840L740,840L740,740L840,740L840,700L740,700L740,600L700,600L700,700L600,700L600,740L700,740L700,840Z"/>
|
||||
</vector>
|
||||
10
app/src/main/res/drawable/add_reaction_24px.xml
Normal file
10
app/src/main/res/drawable/add_reaction_24px.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q523,80 563,88.5Q603,97 640,113L640,203Q605,183 564.5,171.5Q524,160 480,160Q347,160 253.5,253.5Q160,347 160,480Q160,613 253.5,706.5Q347,800 480,800Q613,800 706.5,706.5Q800,613 800,480Q800,448 793.5,418Q787,388 776,360L862,360Q871,389 875.5,418.5Q880,448 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM800,280L800,200L720,200L720,120L800,120L800,40L880,40L880,120L960,120L960,200L880,200L880,280L800,280ZM620,440Q645,440 662.5,422.5Q680,405 680,380Q680,355 662.5,337.5Q645,320 620,320Q595,320 577.5,337.5Q560,355 560,380Q560,405 577.5,422.5Q595,440 620,440ZM340,440Q365,440 382.5,422.5Q400,405 400,380Q400,355 382.5,337.5Q365,320 340,320Q315,320 297.5,337.5Q280,355 280,380Q280,405 297.5,422.5Q315,440 340,440ZM603.5,661.5Q659,623 684,560L276,560Q301,623 356.5,661.5Q412,700 480,700Q548,700 603.5,661.5Z"/>
|
||||
</vector>
|
||||
10
app/src/main/res/drawable/all_inbox_24px.xml
Normal file
10
app/src/main/res/drawable/all_inbox_24px.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M320,640L800,640Q800,640 800,640Q800,640 800,640L800,520L698,520Q677,557 640,578.5Q603,600 560,600Q518,600 481,578.5Q444,557 422,520L320,520L320,640Q320,640 320,640Q320,640 320,640ZM560,520Q594,520 617,496.5Q640,473 640,440L800,440L800,160Q800,160 800,160Q800,160 800,160L320,160Q320,160 320,160Q320,160 320,160L320,440L480,440Q480,473 503.5,496.5Q527,520 560,520ZM320,720Q287,720 263.5,696.5Q240,673 240,640L240,160Q240,127 263.5,103.5Q287,80 320,80L800,80Q833,80 856.5,103.5Q880,127 880,160L880,640Q880,673 856.5,696.5Q833,720 800,720L320,720ZM160,880Q127,880 103.5,856.5Q80,833 80,800L80,240L160,240L160,800Q160,800 160,800Q160,800 160,800L720,800L720,880L160,880ZM320,640Q320,640 320,640Q320,640 320,640L320,640L422,640Q444,640 481,640Q518,640 560,640Q603,640 640,640Q677,640 698,640L800,640L800,640Q800,640 800,640Q800,640 800,640L320,640Z"/>
|
||||
</vector>
|
||||
10
app/src/main/res/drawable/archive_24px.xml
Normal file
10
app/src/main/res/drawable/archive_24px.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M480,720L640,560L584,504L520,568L520,400L440,400L440,568L376,504L320,560L480,720ZM200,320L200,760Q200,760 200,760Q200,760 200,760L760,760Q760,760 760,760Q760,760 760,760L760,320L200,320ZM200,840Q167,840 143.5,816.5Q120,793 120,760L120,261Q120,247 124.5,234Q129,221 138,210L188,149Q199,135 215.5,127.5Q232,120 250,120L710,120Q728,120 744.5,127.5Q761,135 772,149L822,210Q831,221 835.5,234Q840,247 840,261L840,760Q840,793 816.5,816.5Q793,840 760,840L200,840ZM216,240L744,240L710,200Q710,200 710,200Q710,200 710,200L250,200Q250,200 250,200Q250,200 250,200L216,240ZM480,540L480,540L480,540Q480,540 480,540Q480,540 480,540L480,540Q480,540 480,540Q480,540 480,540Z"/>
|
||||
</vector>
|
||||
11
app/src/main/res/drawable/arrow_back_24px.xml
Normal file
11
app/src/main/res/drawable/arrow_back_24px.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:autoMirrored="true">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M313,520L537,744L480,800L160,480L480,160L537,216L313,440L800,440L800,520L313,520Z"/>
|
||||
</vector>
|
||||
10
app/src/main/res/drawable/assignment_ind_24px.xml
Normal file
10
app/src/main/res/drawable/assignment_ind_24px.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M200,840Q167,840 143.5,816.5Q120,793 120,760L120,200Q120,167 143.5,143.5Q167,120 200,120L368,120Q382,84 412,62Q442,40 480,40Q518,40 548,62Q578,84 592,120L760,120Q793,120 816.5,143.5Q840,167 840,200L840,760Q840,793 816.5,816.5Q793,840 760,840L200,840ZM501.5,161.5Q510,153 510,140Q510,127 501.5,118.5Q493,110 480,110Q467,110 458.5,118.5Q450,127 450,140Q450,153 458.5,161.5Q467,170 480,170Q493,170 501.5,161.5ZM200,714Q254,661 325.5,630.5Q397,600 480,600Q563,600 634.5,630.5Q706,661 760,714L760,200Q760,200 760,200Q760,200 760,200L200,200Q200,200 200,200Q200,200 200,200L200,714ZM579,479Q620,438 620,380Q620,322 579,281Q538,240 480,240Q422,240 381,281Q340,322 340,380Q340,438 381,479Q422,520 480,520Q538,520 579,479ZM280,760L680,760Q680,757 680,755Q680,753 680,750Q638,715 587,697.5Q536,680 480,680Q424,680 373,697.5Q322,715 280,750Q280,753 280,755Q280,757 280,760ZM437.5,422.5Q420,405 420,380Q420,355 437.5,337.5Q455,320 480,320Q505,320 522.5,337.5Q540,355 540,380Q540,405 522.5,422.5Q505,440 480,440Q455,440 437.5,422.5ZM480,457L480,457Q480,457 480,457Q480,457 480,457L480,457Q480,457 480,457Q480,457 480,457L480,457Q480,457 480,457Q480,457 480,457Q480,457 480,457Q480,457 480,457Z"/>
|
||||
</vector>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user