first commit

This commit is contained in:
2026-03-03 01:25:13 +07:00
parent 826ab3a914
commit f585f6dca9
226 changed files with 11855 additions and 0 deletions

15
.gitignore vendored Normal file
View File

@@ -0,0 +1,15 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties

3
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

1
.idea/.name generated Normal file
View File

@@ -0,0 +1 @@
Genie

6
.idea/AndroidProjectSystem.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>

26
.idea/appInsightsSettings.xml generated Normal file
View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AppInsightsSettings">
<option name="tabSettings">
<map>
<entry key="Firebase Crashlytics">
<value>
<InsightsFilterSettings>
<option name="connection">
<ConnectionSetting>
<option name="appId" value="com.amz.genie" />
<option name="mobileSdkAppId" value="1:791153239007:android:a35521285c9d4102f05a6a" />
<option name="projectId" value="genie-11548" />
<option name="projectNumber" value="791153239007" />
</ConnectionSetting>
</option>
<option name="signal" value="SIGNAL_UNSPECIFIED" />
<option name="timeIntervalDays" value="THIRTY_DAYS" />
<option name="visibilityType" value="ALL" />
</InsightsFilterSettings>
</value>
</entry>
</map>
</option>
</component>
</project>

6
.idea/compiler.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
</component>
</project>

18
.idea/deploymentTargetSelector.xml generated Normal file
View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-02-26T07:38:16.877861657Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=RR8N801RLQZ" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
</selectionStates>
</component>
</project>

13
.idea/deviceManager.xml generated Normal file
View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="columnSorters">
<list>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
</list>
</option>
</component>
</project>

12
.idea/dictionaries/project.xml generated Normal file
View File

@@ -0,0 +1,12 @@
<component name="ProjectDictionaryState">
<dictionary name="project">
<words>
<w>aksi</w>
<w>komunikasi</w>
<w>reaksi</w>
<w>tentang</w>
<w>tiet</w>
<w>tipe</w>
</words>
</dictionary>
</component>

18
.idea/gradle.xml generated Normal file
View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>

10
.idea/migrations.xml generated Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>

9
.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,9 @@
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>

17
.idea/runConfigurations.xml generated Normal file
View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set>
</option>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

1
app/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

151
app/build.gradle.kts Normal file
View 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

Binary file not shown.

View 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
View 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
View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -0,0 +1,24 @@
package 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)
}
}

View 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>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -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()
}
}

View File

@@ -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()
}
}

View File

@@ -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)
}
}

View 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)
}
}

View File

@@ -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())
}
}

View File

@@ -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
}
}
}

View 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)
}
}

View 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()
}
}

View 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()
}
}

View 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)
}
}

View 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) }
}
}
}

View 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
}

View File

@@ -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) }
}
}
}

View 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
}
}
}

View 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
}

View 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
}
}
}
}

View File

@@ -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)
}
}
}

View 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
}

View File

@@ -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
}

View 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()
}
}

View 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)
}
}

View 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)
}
}

View File

@@ -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)
}
}
}
}

View 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)
}
}

View 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)
}
}

View File

@@ -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
}
}

View 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

View File

@@ -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
}

View File

@@ -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
}
}

View 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()
}
}
}

View 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?) {}
}

View 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
}
}
}

View File

@@ -0,0 +1,5 @@
package com.amz.genie.models
data class ActivityItem(
val text: String
)

View 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,
)

View 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,
)

View 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)

View 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
)

View File

@@ -0,0 +1,7 @@
package com.amz.genie.models
data class FormAttachment(
val uri: android.net.Uri,
val fileName: String,
val mimeType: String
)

View 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?
)

View File

@@ -0,0 +1,6 @@
package com.amz.genie.models
data class Jabatan(
val kode: Int,
val nama: String
)

View 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>?
)

View 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
)

View File

@@ -0,0 +1,7 @@
package com.amz.genie.models
data class Login(
val access_token: String,
val refresh_token: String,
val user: Pengguna
)

View File

@@ -0,0 +1,5 @@
package com.amz.genie.models
data class Message(
val message: String
)

View File

@@ -0,0 +1,6 @@
package com.amz.genie.models
data class OfficeRefJenisIsian(
val id: Int,
val nama_jenis_isian: String
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -0,0 +1,7 @@
package com.amz.genie.models
data class OfficeTrnAksiLampiran(
val id: Int,
val aksi_id: String,
val path_to_arsip: String,
)

View File

@@ -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,
)

View File

@@ -0,0 +1,7 @@
package com.amz.genie.models
data class OfficeTrnReaksiLampiran(
val id: Int,
val reaksi_id: String,
val path_to_arsip: String,
)

View File

@@ -0,0 +1,7 @@
package com.amz.genie.models
data class Outlet(
val kode: Int,
val nama: String,
val singkatan: String,
)

View 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>?
)

View 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
)

View 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>?
)

View 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
)

View 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
)

View File

@@ -0,0 +1,7 @@
package com.amz.genie.models
data class ReaksiResponse(
val message: String,
val reaksi_id: Int,
val thread: InboxThreadResponse
)

View 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
)

View 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
)

View 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)
}
}

View 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>
}

View 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
)

View File

@@ -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
}
}

View 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>
}

View File

@@ -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())
}
}

View File

@@ -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())
}
}

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