Initial import
74
app/build.gradle.kts
Normal file
@ -0,0 +1,74 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.iiyh.emoneyinfo"
|
||||
compileSdk = 36
|
||||
compileSdkMinor = 1
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.iiyh.emoneyinfo"
|
||||
minSdk = 28
|
||||
targetSdk = 36
|
||||
versionCode = 2
|
||||
versionName = "1.0.0"
|
||||
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
val composeBom = platform("androidx.compose:compose-bom:2024.06.00")
|
||||
|
||||
implementation("androidx.core:core-ktx:1.18.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
|
||||
implementation("androidx.activity:activity-compose:1.13.0")
|
||||
implementation("androidx.navigation:navigation-compose:2.9.7")
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("com.google.android.material:material:1.13.0")
|
||||
implementation(composeBom)
|
||||
implementation("com.google.android.gms:play-services-ads:25.2.0")
|
||||
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
||||
}
|
||||
1
app/proguard-rules.pro
vendored
Normal file
@ -0,0 +1 @@
|
||||
# Intentionally empty for now.
|
||||
53
app/src/main/AndroidManifest.xml
Normal file
@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.NFC" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.nfc"
|
||||
android:required="false" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.EmoneyInfo">
|
||||
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.ads.APPLICATION_ID"
|
||||
android:value="ca-app-pub-3389368171983845~3596282656" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:theme="@style/Theme.EmoneyInfo.Launch">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.nfc.action.TECH_DISCOVERED" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="android.nfc.action.TECH_DISCOVERED"
|
||||
android:resource="@xml/nfc_tech_filter" />
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
BIN
app/src/main/ic_launcher-playstore.png
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
83
app/src/main/java/com/iiyh/emoneyinfo/MainActivity.kt
Normal file
@ -0,0 +1,83 @@
|
||||
package com.iiyh.emoneyinfo
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.gms.ads.MobileAds
|
||||
import com.google.android.gms.ads.RequestConfiguration
|
||||
import com.iiyh.emoneyinfo.BuildConfig
|
||||
import com.iiyh.emoneyinfo.ads.AdMobConfig
|
||||
import com.iiyh.emoneyinfo.nfc.UnifiedNfcReader
|
||||
import com.iiyh.emoneyinfo.ui.EmoneyInfoApp
|
||||
import com.iiyh.emoneyinfo.ui.theme.EmoneyInfoTheme
|
||||
import com.iiyh.emoneyinfo.util.AppLog
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private lateinit var nfcReader: UnifiedNfcReader
|
||||
private val adsEnabled = mutableStateOf(false)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
setTheme(R.style.Theme_EmoneyInfo)
|
||||
super.onCreate(savedInstanceState)
|
||||
nfcReader = UnifiedNfcReader(this)
|
||||
nfcReader.refreshStatus()
|
||||
|
||||
setContent {
|
||||
EmoneyInfoTheme {
|
||||
EmoneyInfoApp(
|
||||
nfcReader = nfcReader,
|
||||
adsEnabled = adsEnabled.value
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Give Compose a chance to draw the first frame before the ads SDK starts spinning up WebView.
|
||||
window.decorView.post {
|
||||
lifecycleScope.launch {
|
||||
delay(1200)
|
||||
initializeAds()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
nfcReader.onNewIntent(intent)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
nfcReader.refreshStatus()
|
||||
nfcReader.enableForegroundDispatch(this)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
nfcReader.disableForegroundDispatch(this)
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
private fun initializeAds() {
|
||||
if (adsEnabled.value) return
|
||||
|
||||
val requestConfigurationBuilder = RequestConfiguration.Builder()
|
||||
if (BuildConfig.DEBUG && AdMobConfig.TEST_DEVICE_IDS.isNotEmpty()) {
|
||||
requestConfigurationBuilder.setTestDeviceIds(AdMobConfig.TEST_DEVICE_IDS)
|
||||
AppLog.d("EmoneyInfoAds", "Configured debug test devices=${AdMobConfig.TEST_DEVICE_IDS.joinToString()}")
|
||||
}
|
||||
MobileAds.setRequestConfiguration(requestConfigurationBuilder.build())
|
||||
MobileAds.initialize(this) { initializationStatus ->
|
||||
initializationStatus.adapterStatusMap.forEach { (name, status) ->
|
||||
AppLog.d(
|
||||
"EmoneyInfoAds",
|
||||
"Adapter=$name state=${status.initializationState} latency=${status.latency} desc=${status.description}"
|
||||
)
|
||||
}
|
||||
adsEnabled.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
13
app/src/main/java/com/iiyh/emoneyinfo/ads/AdMobConfig.kt
Normal file
@ -0,0 +1,13 @@
|
||||
package com.iiyh.emoneyinfo.ads
|
||||
|
||||
object AdMobConfig {
|
||||
const val APP_ID = "ca-app-pub-3389368171983845~3596282656"
|
||||
const val BANNER_HOME = "ca-app-pub-3389368171983845/3971687176"
|
||||
const val BANNER_SETTINGS = "ca-app-pub-3389368171983845/1794140038"
|
||||
const val BANNER_HISTORY = "ca-app-pub-3389368171983845/7102307034"
|
||||
const val INTERSTITIAL_PDF = "ca-app-pub-3389368171983845/7236992223"
|
||||
val TEST_DEVICE_IDS = listOf(
|
||||
"33BE2250B43518CCDA7DE426D04EE231",
|
||||
"463419B65276BB5AEDB52AC2A947CA1C"
|
||||
)
|
||||
}
|
||||
48
app/src/main/java/com/iiyh/emoneyinfo/data/FaqData.kt
Normal file
@ -0,0 +1,48 @@
|
||||
package com.iiyh.emoneyinfo.data
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import com.iiyh.emoneyinfo.R
|
||||
|
||||
data class FaqItem(
|
||||
@param:StringRes val questionRes: Int,
|
||||
@param:StringRes val answerRes: Int
|
||||
)
|
||||
|
||||
data class FaqCategory(
|
||||
@param:StringRes val titleRes: Int,
|
||||
val items: List<FaqItem>
|
||||
)
|
||||
|
||||
object FaqData {
|
||||
val all = listOf(
|
||||
FaqCategory(
|
||||
titleRes = R.string.faq_category_cards,
|
||||
items = listOf(
|
||||
FaqItem(R.string.faq_q_supported_cards, R.string.faq_a_supported_cards),
|
||||
FaqItem(R.string.faq_q_card_not_detected, R.string.faq_a_card_not_detected),
|
||||
FaqItem(R.string.faq_q_card_read_failed, R.string.faq_a_card_read_failed)
|
||||
)
|
||||
),
|
||||
FaqCategory(
|
||||
titleRes = R.string.faq_category_transactions,
|
||||
items = listOf(
|
||||
FaqItem(R.string.faq_q_transactions_not_shown, R.string.faq_a_transactions_not_shown),
|
||||
FaqItem(R.string.faq_q_export_pdf, R.string.faq_a_export_pdf)
|
||||
)
|
||||
),
|
||||
FaqCategory(
|
||||
titleRes = R.string.faq_category_balance,
|
||||
items = listOf(
|
||||
FaqItem(R.string.faq_q_balance_wrong, R.string.faq_a_balance_wrong),
|
||||
FaqItem(R.string.faq_q_balance_topup, R.string.faq_a_balance_topup)
|
||||
)
|
||||
),
|
||||
FaqCategory(
|
||||
titleRes = R.string.faq_category_app,
|
||||
items = listOf(
|
||||
FaqItem(R.string.faq_q_app_language, R.string.faq_a_app_language),
|
||||
FaqItem(R.string.faq_q_hide_card_number, R.string.faq_a_hide_card_number)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
78
app/src/main/java/com/iiyh/emoneyinfo/data/Models.kt
Normal file
@ -0,0 +1,78 @@
|
||||
package com.iiyh.emoneyinfo.data
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import com.iiyh.emoneyinfo.R
|
||||
import java.text.NumberFormat
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import kotlin.math.abs
|
||||
|
||||
enum class CardType(@param:StringRes val labelRes: Int) {
|
||||
UNKNOWN(R.string.card_unknown),
|
||||
MANDIRI(R.string.card_mandiri),
|
||||
FLAZZ(R.string.card_flazz),
|
||||
BRIZZI(R.string.card_brizzi),
|
||||
TAPCASH(R.string.card_tapcash),
|
||||
JACKCARD(R.string.card_jackcard),
|
||||
MEGACASH(R.string.card_megacash),
|
||||
KMT(R.string.card_kmt)
|
||||
}
|
||||
|
||||
data class TransactionItem(
|
||||
val title: String,
|
||||
val date: Date,
|
||||
val amount: Long,
|
||||
val isCredit: Boolean,
|
||||
val locationName: String = ""
|
||||
) {
|
||||
fun formattedAmount(): String {
|
||||
val formatter = NumberFormat.getCurrencyInstance(Locale.forLanguageTag("id-ID")).apply {
|
||||
maximumFractionDigits = 0
|
||||
currency = java.util.Currency.getInstance("IDR")
|
||||
}
|
||||
val raw = formatter.format(abs(amount)).replace("IDR", "Rp")
|
||||
return if (isCredit) "+$raw" else "-$raw"
|
||||
}
|
||||
|
||||
fun formattedDate(): String = SimpleDateFormat("dd MMM yyyy · HH:mm", Locale.forLanguageTag("id-ID")).format(date)
|
||||
|
||||
fun subtitle(): String = buildString {
|
||||
append(formattedDate())
|
||||
if (locationName.isNotBlank()) {
|
||||
append(" · ")
|
||||
append(locationName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class EmoneyUiState(
|
||||
val cardType: CardType = CardType.UNKNOWN,
|
||||
val balance: Long = 0,
|
||||
val cardNumber: String = "",
|
||||
val transactions: List<TransactionItem> = emptyList(),
|
||||
val scanMessage: String = "",
|
||||
val isNfcSupported: Boolean = true
|
||||
) {
|
||||
fun formattedBalance(): String {
|
||||
val formatter = NumberFormat.getCurrencyInstance(Locale.forLanguageTag("id-ID")).apply {
|
||||
maximumFractionDigits = 0
|
||||
currency = java.util.Currency.getInstance("IDR")
|
||||
}
|
||||
return formatter.format(balance).replace("IDR", "Rp")
|
||||
}
|
||||
|
||||
fun hasCardData(): Boolean = cardType != CardType.UNKNOWN || cardNumber.isNotBlank() || balance > 0 || transactions.isNotEmpty()
|
||||
}
|
||||
|
||||
fun String.formatCardNumber(): String {
|
||||
val digits = filter { it.isDigit() }
|
||||
if (digits.isEmpty()) return this
|
||||
return digits.chunked(4).joinToString(" ")
|
||||
}
|
||||
|
||||
fun String.maskFirst12(): String {
|
||||
val digits = filter { it.isDigit() }
|
||||
if (digits.length != 16) return formatCardNumber()
|
||||
return "**** **** **** ${digits.takeLast(4)}"
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
package com.iiyh.emoneyinfo.nfc
|
||||
|
||||
import android.content.Context
|
||||
import com.iiyh.emoneyinfo.R
|
||||
|
||||
internal class AndroidStrings(private val context: Context) {
|
||||
fun get(id: Int, vararg args: Any): String = context.getString(id, *args)
|
||||
}
|
||||
85
app/src/main/java/com/iiyh/emoneyinfo/nfc/BrizziCrypto.kt
Normal file
@ -0,0 +1,85 @@
|
||||
package com.iiyh.emoneyinfo.nfc
|
||||
|
||||
import java.security.GeneralSecurityException
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
internal object BrizziCrypto {
|
||||
private const val AUTH_KEY = "0000030080000000"
|
||||
private const val MASTER_3DES_KEY = "C152153D5807784C721A433B5B59636DC152153D5807784C"
|
||||
|
||||
fun decryptDeSeDe(data: ByteArray): ByteArray =
|
||||
tripleDesCbc(data, MASTER_3DES_KEY.hexToBytes(), ByteArray(8), Cipher.DECRYPT_MODE)
|
||||
|
||||
fun encryptDeSeDe(inputHex: String, keyHex: String, ivHex: String): ByteArray {
|
||||
val normalizedKey = when (keyHex.length) {
|
||||
48 -> keyHex
|
||||
32 -> keyHex + keyHex.take(16)
|
||||
16 -> keyHex + keyHex + keyHex
|
||||
else -> "00000000000000000000000000000000"
|
||||
}
|
||||
return tripleDesCbc(
|
||||
inputHex.hexToBytes(),
|
||||
normalizedKey.hexToBytes(),
|
||||
ivHex.hexToBytes(),
|
||||
Cipher.ENCRYPT_MODE
|
||||
)
|
||||
}
|
||||
|
||||
fun encrypt(hex: String, keyHex: String): ByteArray {
|
||||
val left = keyHex.take(16)
|
||||
val right = keyHex.drop(16).take(16)
|
||||
val firstDecrypt = decrypt(hex, left).toHex()
|
||||
val secondEncrypt = desCbc(firstDecrypt.hexToBytes(), right.hexToBytes(), Cipher.ENCRYPT_MODE).toHex()
|
||||
return decrypt(secondEncrypt, left)
|
||||
}
|
||||
|
||||
fun decrypt(hex: String, keyHex: String): ByteArray =
|
||||
desCbc(hex.hexToBytes(), keyHex.hexToBytes(), Cipher.DECRYPT_MODE)
|
||||
|
||||
fun mix(left: ByteArray, right: ByteArray): ByteArray {
|
||||
require(right.isNotEmpty()) { "empty security key" }
|
||||
return ByteArray(left.size) { index ->
|
||||
(left[index].toInt() xor right[index % right.size].toInt()).toByte()
|
||||
}
|
||||
}
|
||||
|
||||
fun generateSamRandom(keyCardHex: String, randomHex: String): String {
|
||||
val mixed = mix(encrypt(keyCardHex, randomHex), "0000000000000000".hexToBytes())
|
||||
val sam = mixed.toHex().take(16)
|
||||
val rotated = sam.hexToBytes().rotateLeftBytes(1).toHex()
|
||||
val result = encrypt(
|
||||
mix("1122334455667788".hexToBytes(), keyCardHex.hexToBytes()).toHex(),
|
||||
randomHex
|
||||
).toHex().take(16)
|
||||
|
||||
val tail = encrypt(
|
||||
mix(rotated.hexToBytes(), result.hexToBytes()).toHex(),
|
||||
randomHex
|
||||
).toHex()
|
||||
|
||||
return result + tail
|
||||
}
|
||||
|
||||
fun authKey(): String = AUTH_KEY
|
||||
|
||||
@Throws(GeneralSecurityException::class)
|
||||
private fun tripleDesCbc(
|
||||
input: ByteArray,
|
||||
key: ByteArray,
|
||||
iv: ByteArray,
|
||||
mode: Int
|
||||
): ByteArray {
|
||||
val cipher = Cipher.getInstance("DESede/CBC/NoPadding")
|
||||
cipher.init(mode, SecretKeySpec(key, "DESede"), IvParameterSpec(iv))
|
||||
return cipher.doFinal(input)
|
||||
}
|
||||
|
||||
@Throws(GeneralSecurityException::class)
|
||||
private fun desCbc(input: ByteArray, key: ByteArray, mode: Int): ByteArray {
|
||||
val cipher = Cipher.getInstance("DES/CBC/NoPadding")
|
||||
cipher.init(mode, SecretKeySpec(key, "DES"), IvParameterSpec(ByteArray(8)))
|
||||
return cipher.doFinal(input)
|
||||
}
|
||||
}
|
||||
861
app/src/main/java/com/iiyh/emoneyinfo/nfc/CardReaders.kt
Normal file
@ -0,0 +1,861 @@
|
||||
package com.iiyh.emoneyinfo.nfc
|
||||
|
||||
import android.nfc.Tag
|
||||
import android.nfc.tech.IsoDep
|
||||
import android.nfc.tech.NfcF
|
||||
import com.iiyh.emoneyinfo.R
|
||||
import com.iiyh.emoneyinfo.data.CardType
|
||||
import com.iiyh.emoneyinfo.data.EmoneyUiState
|
||||
import com.iiyh.emoneyinfo.data.TransactionItem
|
||||
import com.iiyh.emoneyinfo.util.AppLog
|
||||
import java.nio.charset.Charset
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
internal interface CardReader {
|
||||
fun canHandle(tag: Tag): Boolean
|
||||
fun read(tag: Tag, strings: AndroidStrings): EmoneyUiState
|
||||
}
|
||||
|
||||
internal class IsoDepCardRouter : CardReader {
|
||||
override fun canHandle(tag: Tag): Boolean = IsoDep.get(tag) != null
|
||||
|
||||
override fun read(tag: Tag, strings: AndroidStrings): EmoneyUiState {
|
||||
val isoDep = IsoDep.get(tag) ?: error("IsoDep not available")
|
||||
isoDep.connect()
|
||||
isoDep.timeout = 5000
|
||||
return try {
|
||||
BrizziReader.read(isoDep, strings)
|
||||
?: MandiriReader.read(isoDep, strings)
|
||||
?: FlazzReader.read(isoDep, strings)
|
||||
?: TapCashReader.read(isoDep, strings)
|
||||
?: JackCardReader.read(isoDep, strings)
|
||||
?: MegaCashReader.read(isoDep, strings)
|
||||
?: error(strings.get(R.string.card_not_supported))
|
||||
} finally {
|
||||
runCatching { isoDep.close() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class FelicaCardReader : CardReader {
|
||||
override fun canHandle(tag: Tag): Boolean = NfcF.get(tag) != null
|
||||
|
||||
override fun read(tag: Tag, strings: AndroidStrings): EmoneyUiState {
|
||||
val nfcF = NfcF.get(tag) ?: error("NfcF not available")
|
||||
nfcF.connect()
|
||||
nfcF.timeout = 5000
|
||||
return try {
|
||||
KmtReader.read(nfcF, strings)
|
||||
} finally {
|
||||
runCatching { nfcF.close() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private object BrizziReader {
|
||||
fun read(isoDep: IsoDep, strings: AndroidStrings): EmoneyUiState? {
|
||||
AppLog.d("EmoneyInfoBrizzi", "Starting Brizzi detection")
|
||||
val init = isoDep.transceiveApdu(
|
||||
cla = 0x90,
|
||||
ins = 0x5A,
|
||||
p1 = 0x00,
|
||||
p2 = 0x00,
|
||||
data = byteArrayOf(0x01, 0x00, 0x00),
|
||||
le = 0x00
|
||||
)
|
||||
AppLog.d("EmoneyInfoBrizzi", "Init status=${"%02X%02X".format(init.sw1, init.sw2)}")
|
||||
if (!(init.hasStatus(0x91, 0xAF) || init.hasStatus(0x91, 0x00))) return null
|
||||
|
||||
val uid = readBrizziUid(isoDep)
|
||||
AppLog.d("EmoneyInfoBrizzi", "UID read complete")
|
||||
val cardNumberResp = listOf(
|
||||
byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x17, 0x00, 0x00),
|
||||
byteArrayOf(0x01, 0x00, 0x00, 0x00, 0x0A, 0x00, 0x00)
|
||||
).asSequence().map { payload ->
|
||||
isoDep.transceiveApdu(
|
||||
cla = 0x90,
|
||||
ins = 0xBD,
|
||||
p1 = 0x00,
|
||||
p2 = 0x00,
|
||||
data = payload,
|
||||
le = 0x00
|
||||
)
|
||||
}.firstOrNull { it.hasStatus(0x91, 0x00) } ?: isoDep.transceiveApdu(
|
||||
cla = 0x90,
|
||||
ins = 0xBD,
|
||||
p1 = 0x00,
|
||||
p2 = 0x00,
|
||||
data = byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x17, 0x00, 0x00),
|
||||
le = 0x00
|
||||
)
|
||||
AppLog.d("EmoneyInfoBrizzi", "Card number status=${"%02X%02X".format(cardNumberResp.sw1, cardNumberResp.sw2)}")
|
||||
require(cardNumberResp.hasStatus(0x91, 0x00)) { strings.get(R.string.error_brizzi_card_number) }
|
||||
val cardNumber = cardNumberResp.data.toHex().safeSlice(6, 22).formatCardNumber()
|
||||
AppLog.d("EmoneyInfoBrizzi", "Card number parsed")
|
||||
|
||||
val process01 = isoDep.transceiveApdu(
|
||||
cla = 0x90,
|
||||
ins = 0x5A,
|
||||
p1 = 0x00,
|
||||
p2 = 0x00,
|
||||
data = byteArrayOf(0x03, 0x00, 0x00),
|
||||
le = 0x00
|
||||
)
|
||||
AppLog.d("EmoneyInfoBrizzi", "Process01 status=${"%02X%02X".format(process01.sw1, process01.sw2)}")
|
||||
require(process01.hasStatus(0x91, 0x00)) { strings.get(R.string.error_brizzi_step1) }
|
||||
|
||||
val process02 = isoDep.transceiveApdu(
|
||||
cla = 0x90,
|
||||
ins = 0x0A,
|
||||
p1 = 0x00,
|
||||
p2 = 0x00,
|
||||
data = byteArrayOf(0x00),
|
||||
le = 0x00
|
||||
)
|
||||
AppLog.d("EmoneyInfoBrizzi", "Process02 status=${"%02X%02X".format(process02.sw1, process02.sw2)}")
|
||||
require(process02.hasStatus(0x91, 0x00) || process02.hasStatus(0x91, 0xAF)) {
|
||||
strings.get(R.string.error_brizzi_step2)
|
||||
}
|
||||
|
||||
val keyCard = process02.data.toHex()
|
||||
val decrypted = BrizziCrypto.decryptDeSeDe("8DC0DC40FE1DC582CF7099E2AACFBC10".hexToBytes()).toHex().take(32)
|
||||
val encryptedKey = BrizziCrypto.encryptDeSeDe(
|
||||
inputHex = cardNumber.replace(" ", "") + uid + "FF",
|
||||
keyHex = decrypted,
|
||||
ivHex = "0000000000000000"
|
||||
).toHex().take(32)
|
||||
|
||||
val random = BrizziCrypto.encryptDeSeDe(
|
||||
inputHex = encryptedKey,
|
||||
keyHex = BrizziCrypto.decryptDeSeDe("3C37029CA595FE4E7E62FCB2F7909B2C".hexToBytes()).toHex().take(32),
|
||||
ivHex = BrizziCrypto.authKey()
|
||||
).toHex().take(32)
|
||||
|
||||
val samChallenge = BrizziCrypto.generateSamRandom(keyCard, random).take(32)
|
||||
AppLog.d("EmoneyInfoBrizzi", "Generated Brizzi challenge")
|
||||
val process03 = isoDep.transceiveApdu(
|
||||
cla = 0x90,
|
||||
ins = 0xAF,
|
||||
p1 = 0x00,
|
||||
p2 = 0x00,
|
||||
data = samChallenge.hexToBytes(),
|
||||
le = 0x00
|
||||
)
|
||||
AppLog.d("EmoneyInfoBrizzi", "Process03 status=${"%02X%02X".format(process03.sw1, process03.sw2)}")
|
||||
require(process03.hasStatus(0x91, 0x00) || process03.hasStatus(0x91, 0xAF)) {
|
||||
strings.get(R.string.error_brizzi_step3)
|
||||
}
|
||||
|
||||
val balanceResp = isoDep.transceiveApdu(
|
||||
cla = 0x90,
|
||||
ins = 0x6C,
|
||||
p1 = 0x00,
|
||||
p2 = 0x00,
|
||||
data = byteArrayOf(0x00),
|
||||
le = 0x00
|
||||
)
|
||||
AppLog.d("EmoneyInfoBrizzi", "Balance status=${"%02X%02X".format(balanceResp.sw1, balanceResp.sw2)}")
|
||||
require(balanceResp.hasStatus(0x91, 0x00) || balanceResp.hasStatus(0x91, 0xAF)) {
|
||||
strings.get(R.string.error_brizzi_balance)
|
||||
}
|
||||
val balance = balanceResp.data.toHex().safeSlice(0, 8).reverseByteOrderHex().hexToLong()
|
||||
AppLog.d("EmoneyInfoBrizzi", "Balance parsed")
|
||||
|
||||
val logStart = isoDep.transceiveApdu(
|
||||
cla = 0x90,
|
||||
ins = 0xBB,
|
||||
p1 = 0x00,
|
||||
p2 = 0x00,
|
||||
data = byteArrayOf(0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00),
|
||||
le = 0x00
|
||||
)
|
||||
AppLog.d("EmoneyInfoBrizzi", "Log start status=${"%02X%02X".format(logStart.sw1, logStart.sw2)}")
|
||||
|
||||
val rawLog = StringBuilder()
|
||||
if (logStart.hasStatus(0x91, 0x00) || logStart.hasStatus(0x91, 0xAF)) {
|
||||
rawLog.append(logStart.data.toHex())
|
||||
while (true) {
|
||||
val more = isoDep.transceiveApdu(cla = 0x90, ins = 0xAF, p1 = 0x00, p2 = 0x00, le = 0x00)
|
||||
rawLog.append(more.data.toHex())
|
||||
if (!more.hasStatus(0x91, 0xAF)) break
|
||||
}
|
||||
}
|
||||
|
||||
val transactions = parseBrizziLogs(rawLog.toString(), strings)
|
||||
AppLog.d("EmoneyInfoBrizzi", "Parsed transactions=${transactions.size}")
|
||||
return EmoneyUiState(
|
||||
cardType = CardType.BRIZZI,
|
||||
cardNumber = cardNumber,
|
||||
balance = balance,
|
||||
transactions = transactions.sortedByDescending { it.date },
|
||||
scanMessage = if (transactions.isEmpty()) {
|
||||
strings.get(R.string.scan_brizzi_success)
|
||||
} else {
|
||||
strings.get(R.string.scan_brizzi_history_success)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun readBrizziUid(isoDep: IsoDep): String {
|
||||
val first = isoDep.transceiveApdu(cla = 0x90, ins = 0x60, p1 = 0x00, p2 = 0x00, le = 0x00)
|
||||
AppLog.d("EmoneyInfoBrizzi", "UID step1 status=${"%02X%02X".format(first.sw1, first.sw2)}")
|
||||
require(first.hasStatus(0x91, 0xAF)) { "Failed to start Brizzi UID read" }
|
||||
while (true) {
|
||||
val next = isoDep.transceiveApdu(cla = 0x90, ins = 0xAF, p1 = 0x00, p2 = 0x00, le = 0x00)
|
||||
AppLog.d("EmoneyInfoBrizzi", "UID next step status=${"%02X%02X".format(next.sw1, next.sw2)}")
|
||||
if (next.hasStatus(0x91, 0xAF)) continue
|
||||
return next.data.toHex().take(14)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseBrizziLogs(logs: String, strings: AndroidStrings): List<TransactionItem> {
|
||||
if (logs.isBlank() || logs.length % 64 != 0) return emptyList()
|
||||
return buildList {
|
||||
for (offset in logs.indices step 64) {
|
||||
val chunk = logs.substring(offset, offset + 64)
|
||||
val date = parseDdmmyyHhmmss(
|
||||
datePart = chunk.substring(32, 38),
|
||||
timePart = chunk.substring(38, 44)
|
||||
) ?: continue
|
||||
val code = chunk.substring(44, 46).uppercase(Locale.US)
|
||||
val title = when (code) {
|
||||
"5F" -> strings.get(R.string.tx_reactivation)
|
||||
"EB" -> strings.get(R.string.payment)
|
||||
"EC" -> strings.get(R.string.topup)
|
||||
"ED" -> strings.get(R.string.tx_void)
|
||||
"EF" -> strings.get(R.string.tx_update_balance)
|
||||
else -> strings.get(R.string.tx_transaction)
|
||||
}
|
||||
val isCredit = code == "EC" || code == "ED"
|
||||
add(
|
||||
TransactionItem(
|
||||
title = title,
|
||||
date = date,
|
||||
amount = chunk.substring(46, 52).reverseByteOrderHex().hexToLong(),
|
||||
isCredit = isCredit
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private object FlazzReader {
|
||||
private val selectDfAid = "A0000000180F0000018001".hexToBytes()
|
||||
|
||||
fun read(isoDep: IsoDep, strings: AndroidStrings): EmoneyUiState? {
|
||||
AppLog.d("EmoneyInfoFlazz", "Starting IsoDep Flazz detection")
|
||||
val select = isoDep.transceiveSelect(selectDfAid)
|
||||
AppLog.d("EmoneyInfoFlazz", "Select DF status=${"%02X%02X".format(select.sw1, select.sw2)}")
|
||||
val fallbackSelect = if (!select.isSuccess()) {
|
||||
isoDep.transceiveApdu(
|
||||
cla = 0x00,
|
||||
ins = 0xA4,
|
||||
p1 = 0x01,
|
||||
p2 = 0x00,
|
||||
data = byteArrayOf(0x02, 0x00),
|
||||
le = 0x00
|
||||
)
|
||||
} else {
|
||||
select
|
||||
}
|
||||
AppLog.d("EmoneyInfoFlazz", "Fallback select status=${"%02X%02X".format(fallbackSelect.sw1, fallbackSelect.sw2)}")
|
||||
if (!fallbackSelect.isSuccess()) return null
|
||||
|
||||
val cardInfo = isoDep.transceiveApdu(
|
||||
cla = 0x00,
|
||||
ins = 0xB0,
|
||||
p1 = 0x81,
|
||||
p2 = 0x00,
|
||||
le = 0x8E
|
||||
)
|
||||
AppLog.d("EmoneyInfoFlazz", "Card info status=${"%02X%02X".format(cardInfo.sw1, cardInfo.sw2)}")
|
||||
val cardNumber = if (cardInfo.isSuccess()) {
|
||||
cardInfo.data.toString(Charset.forName("ISO-8859-1"))
|
||||
.substringAfter(';', "")
|
||||
.substringBefore('=', "")
|
||||
.trim()
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
val balanceResp = isoDep.transceiveApdu(
|
||||
cla = 0x80,
|
||||
ins = 0x32,
|
||||
p1 = 0x00,
|
||||
p2 = 0x03,
|
||||
data = byteArrayOf(0x00, 0x00, 0x00, 0x00),
|
||||
le = 0x00
|
||||
)
|
||||
AppLog.d("EmoneyInfoFlazz", "Balance status=${"%02X%02X".format(balanceResp.sw1, balanceResp.sw2)}")
|
||||
val balance = if (balanceResp.isSuccess() && balanceResp.data.size >= 4) {
|
||||
balanceResp.data.toHex().substring(2, 8).hexToLong()
|
||||
} else {
|
||||
0L
|
||||
}
|
||||
|
||||
val transactions = if (cardInfo.isSuccess()) {
|
||||
readFlazzHistory(isoDep, strings)
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
return EmoneyUiState(
|
||||
cardType = CardType.FLAZZ,
|
||||
cardNumber = cardNumber.formatCardNumber(),
|
||||
balance = balance,
|
||||
transactions = transactions.sortedByDescending { it.date },
|
||||
scanMessage = if (transactions.isEmpty()) {
|
||||
strings.get(R.string.scan_flazz_success)
|
||||
} else {
|
||||
strings.get(R.string.scan_flazz_history_success)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun readFlazzHistory(isoDep: IsoDep, strings: AndroidStrings): List<TransactionItem> {
|
||||
val logCheck = isoDep.transceiveApdu(cla = 0x00, ins = 0xB0, p1 = 0x81, p2 = 0x00, le = 0x00)
|
||||
return if (logCheck.isSuccess()) {
|
||||
readV2History(isoDep, strings)
|
||||
} else {
|
||||
readV1History(isoDep, strings)
|
||||
}
|
||||
}
|
||||
|
||||
private fun readV1History(isoDep: IsoDep, strings: AndroidStrings): List<TransactionItem> {
|
||||
val part1 = StringBuilder()
|
||||
|
||||
for (index in 0 until 16) {
|
||||
val offset = index * 15
|
||||
val first = isoDep.transceiveApdu(cla = 0x00, ins = 0xB0, p1 = 0x84, p2 = offset, le = 60)
|
||||
if (!first.isSuccess()) break
|
||||
part1.append(first.data.toHex())
|
||||
}
|
||||
for (index in 0 until 16) {
|
||||
val offset = index * 15
|
||||
val second = isoDep.transceiveApdu(cla = 0x00, ins = 0xB0, p1 = 0x85, p2 = offset, le = 60)
|
||||
if (!second.isSuccess()) break
|
||||
part1.append(second.data.toHex())
|
||||
}
|
||||
return parseFlazz120Logs(part1.toString(), strings)
|
||||
}
|
||||
|
||||
private fun readV2History(isoDep: IsoDep, strings: AndroidStrings): List<TransactionItem> {
|
||||
val mapV1 = StringBuilder()
|
||||
val mapV2 = StringBuilder()
|
||||
|
||||
for (index in 0 until 5) {
|
||||
val offset = index * 60
|
||||
val resp = isoDep.transceiveApdu(cla = 0x00, ins = 0xB0, p1 = 0x85, p2 = offset, le = 120)
|
||||
if (!resp.isSuccess()) break
|
||||
mapV1.append(resp.data.toHex())
|
||||
}
|
||||
for (index in 0 until 5) {
|
||||
val offset = index * 60
|
||||
val resp = isoDep.transceiveApdu(cla = 0x00, ins = 0xB0, p1 = 0x84, p2 = offset, le = 240)
|
||||
if (!resp.isSuccess()) break
|
||||
mapV1.append(resp.data.toHex())
|
||||
}
|
||||
|
||||
val random = isoDep.transceiveApdu(cla = 0x00, ins = 0x84, p1 = 0x00, p2 = 0x00, le = 8)
|
||||
if (random.isSuccess()) {
|
||||
val auth = isoDep.transceiveApdu(
|
||||
cla = 0x90,
|
||||
ins = 0x32,
|
||||
p1 = 0x03,
|
||||
p2 = 0x00,
|
||||
data = ("0801" + random.data.toHex()).hexToBytes(),
|
||||
le = 41
|
||||
)
|
||||
if (auth.isSuccess()) {
|
||||
for (index in 0 until 256) {
|
||||
val resp = isoDep.transceiveApdu(cla = 0x00, ins = 0xB0, p1 = 0x89, p2 = index, le = 64)
|
||||
if (!resp.isSuccess()) break
|
||||
mapV2.append(resp.data.toHex())
|
||||
}
|
||||
for (index in 0 until 256) {
|
||||
val resp = isoDep.transceiveApdu(
|
||||
cla = 0x90,
|
||||
ins = 0x32,
|
||||
p1 = 0x03,
|
||||
p2 = 0x00,
|
||||
data = byteArrayOf(index.toByte()),
|
||||
le = 32
|
||||
)
|
||||
if (!resp.isSuccess()) break
|
||||
mapV2.append(resp.data.toHex())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (parseFlazz120Logs(mapV1.toString(), strings) + parseFlazz64Logs(mapV2.toString(), strings))
|
||||
}
|
||||
|
||||
private fun parseFlazz120Logs(logs: String, strings: AndroidStrings): List<TransactionItem> {
|
||||
if (logs.isBlank() || logs.length % 120 != 0) return emptyList()
|
||||
return buildList {
|
||||
for (offset in logs.indices step 120) {
|
||||
val chunk = logs.substring(offset, offset + 120)
|
||||
val transactionTime = chunk.substring(76, 84).hexToLong()
|
||||
if (transactionTime <= 0) continue
|
||||
val amount = chunk.substring(12, 18).hexToLong()
|
||||
val type = chunk.substring(0, 4).hexToLong()
|
||||
add(
|
||||
TransactionItem(
|
||||
title = if (type == 1024L) strings.get(R.string.payment) else strings.get(R.string.topup),
|
||||
date = flazzSecondsFrom1980(transactionTime),
|
||||
amount = amount,
|
||||
isCredit = type != 1024L
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseFlazz64Logs(logs: String, strings: AndroidStrings): List<TransactionItem> {
|
||||
if (logs.isBlank() || logs.length % 64 != 0) return emptyList()
|
||||
return buildList {
|
||||
for (offset in logs.indices step 64) {
|
||||
val chunk = logs.substring(offset, offset + 64)
|
||||
val transactionTime = chunk.substring(8, 16).hexToLong()
|
||||
if (transactionTime <= 0) continue
|
||||
val type = chunk.substring(0, 2).hexToLong()
|
||||
val rawAmount = chunk.substring(2, 8).hexToLong()
|
||||
val amount = if (type == 4L) 16777216L - rawAmount else rawAmount
|
||||
add(
|
||||
TransactionItem(
|
||||
title = if (type == 4L) strings.get(R.string.payment) else strings.get(R.string.topup),
|
||||
date = flazzSecondsFrom1980(transactionTime),
|
||||
amount = amount,
|
||||
isCredit = type != 4L
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private object TapCashReader {
|
||||
private val aid = "A000424E49100001".hexToBytes()
|
||||
|
||||
fun read(isoDep: IsoDep, strings: AndroidStrings): EmoneyUiState? {
|
||||
val select = isoDep.transceiveSelect(aid)
|
||||
if (!select.isSuccess()) return null
|
||||
|
||||
val purse = isoDep.transceiveApdu(
|
||||
cla = 0x90,
|
||||
ins = 0x32,
|
||||
p1 = 0x03,
|
||||
p2 = 0x00,
|
||||
le = 0x00
|
||||
)
|
||||
if (!purse.isSuccess() || purse.data.size < 64) {
|
||||
return EmoneyUiState(
|
||||
cardType = CardType.TAPCASH,
|
||||
scanMessage = strings.get(R.string.scan_tapcash_detected_partial)
|
||||
)
|
||||
}
|
||||
|
||||
val balance = purse.data.copyOfRange(2, 5).toHex().hexToLong()
|
||||
val cardNumber = purse.data.copyOfRange(8, 16).toHex().formatCardNumber()
|
||||
val totalRecords = purse.data.getOrNull(40)?.toInt()?.and(0xFF)?.coerceAtMost(10) ?: 0
|
||||
val history = mutableListOf<TransactionItem>()
|
||||
|
||||
for (index in 0 until totalRecords) {
|
||||
val resp = isoDep.transceiveApdu(
|
||||
cla = 0x90,
|
||||
ins = 0x32,
|
||||
p1 = 0x03,
|
||||
p2 = 0x00,
|
||||
data = byteArrayOf(index.toByte()),
|
||||
le = 0x10
|
||||
)
|
||||
if (!resp.isSuccess() || resp.data.size < 8) continue
|
||||
parseTapCashRecord(resp.data, strings)?.let(history::add)
|
||||
}
|
||||
|
||||
return EmoneyUiState(
|
||||
cardType = CardType.TAPCASH,
|
||||
cardNumber = cardNumber,
|
||||
balance = balance,
|
||||
transactions = history.sortedByDescending { it.date },
|
||||
scanMessage = if (history.isEmpty()) {
|
||||
strings.get(R.string.scan_tapcash_success)
|
||||
} else {
|
||||
strings.get(R.string.scan_tapcash_history_success)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseTapCashRecord(data: ByteArray, strings: AndroidStrings): TransactionItem? {
|
||||
if (data.size < 8) return null
|
||||
val header = data.copyOfRange(0, 1).toHex().uppercase(Locale.US)
|
||||
val amountBytes = data.copyOfRange(1, 4).toHex()
|
||||
val amount = when (header) {
|
||||
"01", "05", "07", "10", "20" -> amountBytes.twosComplementHexToLong()
|
||||
else -> amountBytes.hexToLong()
|
||||
}
|
||||
val title = when (header) {
|
||||
"01" -> strings.get(R.string.payment)
|
||||
"02" -> strings.get(R.string.tx_black_list_card)
|
||||
"03", "04" -> strings.get(R.string.topup)
|
||||
"05" -> strings.get(R.string.tx_statement_fee)
|
||||
"06" -> strings.get(R.string.tx_update_balance)
|
||||
"07" -> strings.get(R.string.tx_grace_period)
|
||||
"10", "20" -> strings.get(R.string.tx_refund)
|
||||
"22" -> strings.get(R.string.tx_close)
|
||||
"F0" -> strings.get(R.string.tx_atu)
|
||||
else -> strings.get(R.string.tx_transaction)
|
||||
}
|
||||
val processType = when (header) {
|
||||
"03", "04" -> true
|
||||
else -> false
|
||||
}
|
||||
val date = julianSecondsFrom1995(data.copyOfRange(4, 8).toHex().hexToLong())
|
||||
|
||||
return TransactionItem(
|
||||
title = title,
|
||||
date = date,
|
||||
amount = amount,
|
||||
isCredit = processType
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private object MandiriReader {
|
||||
private val aid = "0000000000000001".hexToBytes()
|
||||
|
||||
fun read(isoDep: IsoDep, strings: AndroidStrings): EmoneyUiState? {
|
||||
AppLog.d("EmoneyInfoMandiri", "Starting Mandiri detection")
|
||||
val select = isoDep.transceiveSelect(aid)
|
||||
AppLog.d("EmoneyInfoMandiri", "Select status=${"%02X%02X".format(select.sw1, select.sw2)}")
|
||||
if (!select.isSuccess()) return null
|
||||
|
||||
val cardResp = isoDep.transceiveApdu(0x00, 0xB3, 0x00, 0x00, le = 0x3F)
|
||||
AppLog.d("EmoneyInfoMandiri", "Card response status=${"%02X%02X".format(cardResp.sw1, cardResp.sw2)}")
|
||||
if (!cardResp.isSuccess()) error(strings.get(R.string.error_mandiri_card_number))
|
||||
val cardHex = cardResp.data.toHex()
|
||||
val cardNumber = cardHex.safeSlice(0, 16).formatCardNumber()
|
||||
val cardType = cardHex.safeSlice(36, 38).hexToIntOrZero()
|
||||
AppLog.d("EmoneyInfoMandiri", "Card data parsed type=$cardType")
|
||||
|
||||
val balanceResp = isoDep.transceiveApdu(0x00, 0xB5, 0x00, 0x00, le = 0x0A)
|
||||
AppLog.d("EmoneyInfoMandiri", "Balance status=${"%02X%02X".format(balanceResp.sw1, balanceResp.sw2)}")
|
||||
if (!balanceResp.isSuccess()) error(strings.get(R.string.error_mandiri_balance))
|
||||
val balance = balanceResp.data.toHex().safeSlice(0, 8).reverseByteOrderHex().hexToLong()
|
||||
|
||||
val transactions = if (cardType == 131) {
|
||||
readNewLogs(isoDep, strings)
|
||||
} else {
|
||||
readOldLogs(isoDep, strings)
|
||||
}
|
||||
AppLog.d("EmoneyInfoMandiri", "Transactions=${transactions.size}")
|
||||
|
||||
return EmoneyUiState(
|
||||
cardType = CardType.MANDIRI,
|
||||
cardNumber = cardNumber,
|
||||
balance = balance,
|
||||
transactions = transactions.sortedByDescending { it.date },
|
||||
scanMessage = if (cardType == 131) {
|
||||
strings.get(R.string.scan_mandiri_history_success)
|
||||
} else if (transactions.isNotEmpty()) {
|
||||
strings.get(R.string.scan_mandiri_history_success)
|
||||
} else {
|
||||
strings.get(R.string.scan_mandiri_success)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun readNewLogs(isoDep: IsoDep, strings: AndroidStrings): List<TransactionItem> {
|
||||
val raw = StringBuilder()
|
||||
for (index in 0 until 256) {
|
||||
val resp = isoDep.transceiveApdu(
|
||||
cla = 0x00,
|
||||
ins = 0xD1,
|
||||
p1 = index and 0xFF,
|
||||
p2 = 0x00,
|
||||
le = 0x00
|
||||
)
|
||||
if (!resp.isSuccess()) break
|
||||
raw.append(resp.data.toHex())
|
||||
}
|
||||
|
||||
val logs = raw.toString()
|
||||
if (logs.isEmpty() || logs.length % 48 != 0) return emptyList()
|
||||
|
||||
return buildList {
|
||||
for (offset in logs.indices step 48) {
|
||||
val chunk = logs.substring(offset, offset + 48)
|
||||
val date = parseDdmmyyHhmmss(
|
||||
datePart = chunk.substring(0, 6),
|
||||
timePart = chunk.substring(6, 12)
|
||||
) ?: continue
|
||||
val processType = chunk.substring(28, 32).toIntOrNull() ?: 0
|
||||
val amount = chunk.substring(32, 40).reverseByteOrderHex().hexToLong()
|
||||
add(
|
||||
TransactionItem(
|
||||
title = if (processType == 100) strings.get(R.string.topup) else strings.get(R.string.payment),
|
||||
date = date,
|
||||
amount = amount,
|
||||
isCredit = processType == 100
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun readOldLogs(isoDep: IsoDep, strings: AndroidStrings): List<TransactionItem> {
|
||||
val raw = StringBuilder()
|
||||
for (index in 0 until 10) {
|
||||
val resp = isoDep.transceiveApdu(
|
||||
cla = 0x00,
|
||||
ins = 0xB2,
|
||||
p1 = index and 0xFF,
|
||||
p2 = 0x00,
|
||||
le = 0x1E
|
||||
)
|
||||
AppLog.d(
|
||||
"EmoneyInfoMandiri",
|
||||
"Old log index=$index status=${"%02X%02X".format(resp.sw1, resp.sw2)}"
|
||||
)
|
||||
if (!resp.isSuccess()) break
|
||||
raw.append(resp.data.toHex())
|
||||
}
|
||||
|
||||
val logs = raw.toString()
|
||||
if (logs.isEmpty() || logs.length % 60 != 0) return emptyList()
|
||||
|
||||
return buildList {
|
||||
for (offset in logs.indices step 60) {
|
||||
val chunk = logs.substring(offset, offset + 60)
|
||||
parseOldMandiriLog(chunk, strings)?.let(::add)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseOldMandiriLog(chunk: String, strings: AndroidStrings): TransactionItem? {
|
||||
if (chunk.length < 60) return null
|
||||
val bytes = chunk.hexToBytes()
|
||||
if (bytes.size < 30) return null
|
||||
|
||||
val timestampBytes = bytes.copyOfRange(0, 6)
|
||||
val amountBytes = bytes.copyOfRange(10, 14)
|
||||
val detailBytes = bytes.copyOfRange(18, 30)
|
||||
|
||||
val calendar = Calendar.getInstance(TimeZone.getTimeZone("Asia/Jakarta")).apply {
|
||||
set(
|
||||
2000 + (timestampBytes[0].toInt() and 0xFF),
|
||||
((timestampBytes[1].toInt() and 0xFF) - 1).coerceAtLeast(0),
|
||||
(timestampBytes[2].toInt() and 0xFF).coerceAtLeast(1),
|
||||
timestampBytes[3].toInt() and 0xFF,
|
||||
timestampBytes[4].toInt() and 0xFF,
|
||||
timestampBytes[5].toInt() and 0xFF
|
||||
)
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
}
|
||||
|
||||
val amount = amountBytes.littleEndianLong()
|
||||
val descriptor = detailBytes.toHex()
|
||||
val isCredit = descriptor.contains("64")
|
||||
val title = if (isCredit) strings.get(R.string.topup) else strings.get(R.string.payment)
|
||||
|
||||
return TransactionItem(
|
||||
title = title,
|
||||
date = calendar.time,
|
||||
amount = amount,
|
||||
isCredit = isCredit
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private object JackCardReader {
|
||||
private val aid = "A0000005714E4A43".hexToBytes()
|
||||
|
||||
fun read(isoDep: IsoDep, strings: AndroidStrings): EmoneyUiState? {
|
||||
val select = isoDep.transceiveSelect(aid)
|
||||
if (!select.isSuccess()) return null
|
||||
|
||||
val cardNumber = select.data.toHex().safeSlice(16, 32).formatCardNumber()
|
||||
val balanceResp = isoDep.transceiveApdu(0x90, 0x4C, 0x00, 0x00, le = 0x04)
|
||||
val balance = if (balanceResp.isSuccess()) balanceResp.data.toHex().hexToLong() else 0L
|
||||
|
||||
return EmoneyUiState(
|
||||
cardType = CardType.JACKCARD,
|
||||
cardNumber = cardNumber,
|
||||
balance = balance,
|
||||
scanMessage = strings.get(R.string.scan_jackcard_success)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private object MegaCashReader {
|
||||
fun read(isoDep: IsoDep, strings: AndroidStrings): EmoneyUiState? {
|
||||
val init = isoDep.transceiveApdu(
|
||||
cla = 0x90,
|
||||
ins = 0xBD,
|
||||
p1 = 0x00,
|
||||
p2 = 0x00,
|
||||
data = byteArrayOf(0x01, 0x00, 0x00, 0x00, 0x0A, 0x00, 0x00),
|
||||
le = 0x00
|
||||
)
|
||||
if (!init.isSuccess()) return null
|
||||
|
||||
val cardNumber = init.data.toHex().drop(4).formatCardNumber()
|
||||
val balanceResp = isoDep.transceiveApdu(
|
||||
cla = 0x90,
|
||||
ins = 0x6C,
|
||||
p1 = 0x00,
|
||||
p2 = 0x00,
|
||||
data = byteArrayOf(0x02),
|
||||
le = 0x00
|
||||
)
|
||||
val balance = if (balanceResp.isSuccess()) {
|
||||
balanceResp.data.toHex().reverseByteOrderHex().hexToLong()
|
||||
} else {
|
||||
0L
|
||||
}
|
||||
|
||||
return EmoneyUiState(
|
||||
cardType = CardType.MEGACASH,
|
||||
cardNumber = cardNumber,
|
||||
balance = balance,
|
||||
scanMessage = strings.get(R.string.scan_megacash_success)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private object KmtReader {
|
||||
private val stationMap = mapOf(
|
||||
0 to "PARKIR RESKA",
|
||||
1 to "Tanah Abang",
|
||||
67 to "C-Access",
|
||||
257 to "Bogor",
|
||||
258 to "Cilebut",
|
||||
259 to "Bojonggede",
|
||||
260 to "Citayam",
|
||||
261 to "Depok",
|
||||
262 to "Depok Baru",
|
||||
263 to "Univ. Indonesia",
|
||||
264 to "Univ. Indonesia",
|
||||
265 to "Univ. Pancasila",
|
||||
272 to "Lenteng Agung",
|
||||
273 to "Tanjung Barat",
|
||||
274 to "Pasar Minggu",
|
||||
275 to "Pasar Minggu Baru",
|
||||
276 to "Duren Kalibata",
|
||||
277 to "Cawang",
|
||||
278 to "Tebet",
|
||||
279 to "Manggarai",
|
||||
280 to "Cikini",
|
||||
281 to "Gondangdia",
|
||||
288 to "Juanda",
|
||||
289 to "Sawah Besar",
|
||||
290 to "Mangga Besar",
|
||||
291 to "Jayakarta",
|
||||
292 to "Jakarta Kota",
|
||||
293 to "Bekasi",
|
||||
294 to "Kranji",
|
||||
295 to "Cakung",
|
||||
296 to "Klender Baru",
|
||||
297 to "Buaran",
|
||||
304 to "Klender",
|
||||
305 to "Jatinegara",
|
||||
313 to "Tangerang",
|
||||
327 to "Karet",
|
||||
328 to "Sudirman",
|
||||
329 to "Tanah Abang",
|
||||
336 to "Palmerah",
|
||||
337 to "Kebayoran",
|
||||
338 to "Pondok Ranji",
|
||||
339 to "Jurang Mangu",
|
||||
340 to "Sudimara",
|
||||
341 to "Rawabuntu",
|
||||
342 to "Serpong",
|
||||
343 to "Cisauk",
|
||||
344 to "Cicayur",
|
||||
345 to "Parung Panjang",
|
||||
352 to "Cilejit",
|
||||
353 to "Daru",
|
||||
354 to "Tenjo",
|
||||
355 to "Tigaraksa",
|
||||
356 to "Maja",
|
||||
357 to "Citeras",
|
||||
358 to "Rangkasbitung",
|
||||
374 to "Bekasi Timur",
|
||||
376 to "Cikarang"
|
||||
)
|
||||
|
||||
fun read(nfcF: NfcF, strings: AndroidStrings): EmoneyUiState {
|
||||
val cardBlock = nfcF.readWithoutEncryption(
|
||||
serviceCode = byteArrayOf(0x0B, 0x30),
|
||||
blockNumbers = listOf(0)
|
||||
).firstOrNull() ?: error("Failed to read KMT card number")
|
||||
val cardNumber = cardBlock.toString(Charsets.UTF_8).trim('\u0000', ' ').ifBlank { "KMT" }
|
||||
|
||||
val balanceBlock = nfcF.readWithoutEncryption(
|
||||
serviceCode = byteArrayOf(0x17, 0x10),
|
||||
blockNumbers = listOf(0)
|
||||
).firstOrNull() ?: error("Failed to read KMT balance")
|
||||
val balance = balanceBlock.copyOfRange(0, 4).littleEndianLong()
|
||||
|
||||
val historyBlocks = nfcF.readWithoutEncryption(
|
||||
serviceCode = byteArrayOf(0x0F, 0x20),
|
||||
blockNumbers = (0 until 15).toList()
|
||||
)
|
||||
val transactions = historyBlocks.mapNotNull { parseHistoryBlock(it, strings) }.sortedByDescending { it.date }
|
||||
|
||||
return EmoneyUiState(
|
||||
cardType = CardType.KMT,
|
||||
cardNumber = cardNumber,
|
||||
balance = balance,
|
||||
transactions = transactions,
|
||||
scanMessage = strings.get(R.string.scan_kmt_history_success)
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseHistoryBlock(data: ByteArray, strings: AndroidStrings): TransactionItem? {
|
||||
if (data.size < 16) return null
|
||||
val stationId = data.copyOfRange(8, 10).bigEndianLong().toInt()
|
||||
val type = data[10].toInt() and 0xFF
|
||||
val isParking = stationId == 0
|
||||
var isCredit = type == 0x00
|
||||
val title = when (type) {
|
||||
0x00, 0x03 -> strings.get(R.string.topup)
|
||||
0x01 -> strings.get(R.string.payment)
|
||||
else -> strings.get(R.string.payment)
|
||||
}
|
||||
if (type == 0x03){
|
||||
isCredit = true
|
||||
}
|
||||
val amount = if (isParking) {
|
||||
data.copyOfRange(8, 12).bigEndianLong()
|
||||
} else {
|
||||
data.copyOfRange(4, 8).bigEndianLong()
|
||||
}
|
||||
val date = if (isParking) {
|
||||
parseReskaDate(data)
|
||||
} else {
|
||||
kmtSecondsFrom2000(data.copyOfRange(0, 4).bigEndianLong())
|
||||
}
|
||||
val location = stationMap[stationId]?.uppercase(Locale.getDefault()).orEmpty()
|
||||
|
||||
return TransactionItem(
|
||||
title = title,
|
||||
date = date,
|
||||
amount = amount,
|
||||
isCredit = isCredit,
|
||||
locationName = location
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseReskaDate(data: ByteArray): Date {
|
||||
val first16 = data.toHex().take(16)
|
||||
return runCatching {
|
||||
SimpleDateFormat("ddMMyyyyHHmmssSS", Locale.US).parse(first16)
|
||||
}.getOrNull() ?: Date()
|
||||
}
|
||||
}
|
||||
192
app/src/main/java/com/iiyh/emoneyinfo/nfc/NfcUtils.kt
Normal file
@ -0,0 +1,192 @@
|
||||
package com.iiyh.emoneyinfo.nfc
|
||||
|
||||
import android.nfc.tech.IsoDep
|
||||
import android.nfc.tech.NfcF
|
||||
import com.iiyh.emoneyinfo.util.AppLog
|
||||
import java.util.Calendar
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
private const val NFC_LOG_TAG = "EmoneyInfoNfc"
|
||||
|
||||
internal data class ApduResponse(
|
||||
val data: ByteArray,
|
||||
val sw1: Int,
|
||||
val sw2: Int
|
||||
) {
|
||||
fun isSuccess(): Boolean = sw1 == 0x90 && sw2 == 0x00
|
||||
fun hasStatus(sw1: Int, sw2: Int): Boolean = this.sw1 == sw1 && this.sw2 == sw2
|
||||
}
|
||||
|
||||
internal fun IsoDep.transceiveSelect(aid: ByteArray): ApduResponse =
|
||||
transceiveApdu(cla = 0x00, ins = 0xA4, p1 = 0x04, p2 = 0x00, data = aid)
|
||||
|
||||
internal fun IsoDep.transceiveApdu(
|
||||
cla: Int,
|
||||
ins: Int,
|
||||
p1: Int,
|
||||
p2: Int,
|
||||
data: ByteArray? = null,
|
||||
le: Int? = null
|
||||
): ApduResponse {
|
||||
val apdu = mutableListOf(
|
||||
cla.toByte(),
|
||||
ins.toByte(),
|
||||
p1.toByte(),
|
||||
p2.toByte()
|
||||
)
|
||||
if (data != null) {
|
||||
apdu += data.size.toByte()
|
||||
apdu += data.toList()
|
||||
}
|
||||
if (le != null) {
|
||||
apdu += if (le == 256) 0x00 else (le and 0xFF).toByte()
|
||||
}
|
||||
|
||||
val requestBytes = apdu.toByteArray()
|
||||
AppLog.d(
|
||||
NFC_LOG_TAG,
|
||||
"APDU -> cla=%02X ins=%02X p1=%02X p2=%02X lc=%d le=%s data=%s".format(
|
||||
cla and 0xFF,
|
||||
ins and 0xFF,
|
||||
p1 and 0xFF,
|
||||
p2 and 0xFF,
|
||||
data?.size ?: 0,
|
||||
le?.toString() ?: "-",
|
||||
data?.toHex() ?: ""
|
||||
)
|
||||
)
|
||||
val response = try {
|
||||
transceive(requestBytes)
|
||||
} catch (error: Throwable) {
|
||||
AppLog.e(
|
||||
NFC_LOG_TAG,
|
||||
"APDU !! cla=%02X ins=%02X failed: %s".format(
|
||||
cla and 0xFF,
|
||||
ins and 0xFF,
|
||||
error.message ?: error.javaClass.simpleName
|
||||
),
|
||||
error
|
||||
)
|
||||
throw error
|
||||
}
|
||||
require(response.size >= 2) { "Invalid APDU response" }
|
||||
val parsed = ApduResponse(
|
||||
data = response.copyOf(response.size - 2),
|
||||
sw1 = response[response.size - 2].toInt() and 0xFF,
|
||||
sw2 = response[response.size - 1].toInt() and 0xFF
|
||||
)
|
||||
AppLog.d(
|
||||
NFC_LOG_TAG,
|
||||
"APDU <- sw=%02X%02X data=%s".format(parsed.sw1, parsed.sw2, parsed.data.toHex())
|
||||
)
|
||||
return parsed
|
||||
}
|
||||
|
||||
internal fun NfcF.readWithoutEncryption(
|
||||
serviceCode: ByteArray,
|
||||
blockNumbers: List<Int>
|
||||
): List<ByteArray> {
|
||||
val packet = mutableListOf<Byte>()
|
||||
packet += 0x00
|
||||
packet += 0x06
|
||||
packet += tag.id.toList()
|
||||
packet += 0x01
|
||||
packet += serviceCode.toList()
|
||||
packet += blockNumbers.size.toByte()
|
||||
blockNumbers.forEach { blockNo ->
|
||||
packet += 0x80.toByte()
|
||||
packet += blockNo.toByte()
|
||||
}
|
||||
packet[0] = packet.size.toByte()
|
||||
|
||||
val response = transceive(packet.toByteArray())
|
||||
require(response.size >= 13) { "Invalid FeliCa response" }
|
||||
val status1 = response[10].toInt() and 0xFF
|
||||
val status2 = response[11].toInt() and 0xFF
|
||||
require(status1 == 0x00 && status2 == 0x00) {
|
||||
"FeliCa status error: $status1/$status2"
|
||||
}
|
||||
val blockCount = response[12].toInt() and 0xFF
|
||||
var offset = 13
|
||||
return buildList {
|
||||
repeat(blockCount) {
|
||||
add(response.copyOfRange(offset, offset + 16))
|
||||
offset += 16
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun ByteArray.toHex(): String = joinToString("") { "%02X".format(it) }
|
||||
|
||||
internal fun String.hexToBytes(): ByteArray {
|
||||
require(length % 2 == 0) { "Hex string must have even length" }
|
||||
return chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
}
|
||||
|
||||
internal fun String.hexToLong(): Long = if (isBlank()) 0 else toLong(16)
|
||||
|
||||
internal fun String.hexToIntOrZero(): Int = toIntOrNull(16) ?: 0
|
||||
|
||||
internal fun String.reverseByteOrderHex(): String =
|
||||
chunked(2).reversed().joinToString("")
|
||||
|
||||
internal fun String.safeSlice(start: Int, end: Int): String {
|
||||
if (length <= start) return ""
|
||||
return substring(start, minOf(end, length))
|
||||
}
|
||||
|
||||
internal fun String.formatCardNumber(): String =
|
||||
chunked(4).joinToString(" ").trim()
|
||||
|
||||
internal fun ByteArray.rotateLeftBytes(count: Int): ByteArray {
|
||||
if (isEmpty()) return this
|
||||
val shift = count % size
|
||||
return copyOfRange(shift, size) + copyOfRange(0, shift)
|
||||
}
|
||||
|
||||
internal fun String.twosComplementHexToLong(): Long {
|
||||
val bits = length * 4
|
||||
val value = hexToLong()
|
||||
val signBit = 1L shl (bits - 1)
|
||||
return if ((value and signBit) == 0L) value else value - (1L shl bits)
|
||||
}
|
||||
|
||||
internal fun ByteArray.bigEndianLong(): Long =
|
||||
fold(0L) { acc, byte -> (acc shl 8) or (byte.toInt() and 0xFF).toLong() }
|
||||
|
||||
internal fun ByteArray.littleEndianLong(): Long =
|
||||
reversedArray().bigEndianLong()
|
||||
|
||||
internal fun parseDdmmyyHhmmss(datePart: String, timePart: String): Date? {
|
||||
return runCatching {
|
||||
val raw = datePart + timePart
|
||||
java.text.SimpleDateFormat("ddMMyyHHmmss", Locale.US).parse(raw)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
internal fun julianSecondsFrom1995(seconds: Long): Date {
|
||||
val calendar = Calendar.getInstance().apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
set(1995, Calendar.JANUARY, 1, 0, 0, 0)
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
}
|
||||
return Date(calendar.timeInMillis + (seconds * 1000))
|
||||
}
|
||||
|
||||
internal fun kmtSecondsFrom2000(seconds: Long): Date {
|
||||
val calendar = Calendar.getInstance(TimeZone.getTimeZone("Asia/Jakarta")).apply {
|
||||
set(2000, Calendar.JANUARY, 1, 7, 0, 0)
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
}
|
||||
return Date(calendar.timeInMillis + (seconds * 1000))
|
||||
}
|
||||
|
||||
internal fun flazzSecondsFrom1980(seconds: Long): Date {
|
||||
val calendar = Calendar.getInstance(TimeZone.getTimeZone("Asia/Jakarta")).apply {
|
||||
set(1980, Calendar.JANUARY, 1, 0, 0, 0)
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
}
|
||||
return Date(calendar.timeInMillis + (seconds * 1000))
|
||||
}
|
||||
125
app/src/main/java/com/iiyh/emoneyinfo/nfc/UnifiedNfcReader.kt
Normal file
@ -0,0 +1,125 @@
|
||||
package com.iiyh.emoneyinfo.nfc
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.Build
|
||||
import android.nfc.NfcAdapter
|
||||
import android.nfc.Tag
|
||||
import android.nfc.tech.IsoDep
|
||||
import android.nfc.tech.NfcF
|
||||
import com.iiyh.emoneyinfo.R
|
||||
import com.iiyh.emoneyinfo.data.EmoneyUiState
|
||||
import com.iiyh.emoneyinfo.util.AppLog
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class UnifiedNfcReader(private val context: Context) {
|
||||
private val adapter: NfcAdapter? = NfcAdapter.getDefaultAdapter(context)
|
||||
private val strings = AndroidStrings(context)
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
private val readers: List<CardReader> = listOf(IsoDepCardRouter(), FelicaCardReader())
|
||||
private var resetMessageJob: Job? = null
|
||||
private val _uiState = MutableStateFlow(
|
||||
EmoneyUiState(
|
||||
isNfcSupported = adapter != null,
|
||||
scanMessage = currentScanMessage()
|
||||
)
|
||||
)
|
||||
val uiState: StateFlow<EmoneyUiState> = _uiState.asStateFlow()
|
||||
|
||||
fun refreshStatus() {
|
||||
resetMessageJob?.cancel()
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isNfcSupported = adapter != null,
|
||||
scanMessage = when {
|
||||
adapter == null -> context.getString(R.string.nfc_not_supported)
|
||||
!adapter.isEnabled -> context.getString(R.string.nfc_disabled)
|
||||
_uiState.value.hasCardData() -> _uiState.value.scanMessage
|
||||
else -> currentScanMessage()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun startScan() {
|
||||
refreshStatus()
|
||||
}
|
||||
|
||||
fun onNewIntent(intent: Intent) {
|
||||
resetMessageJob?.cancel()
|
||||
val tag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
intent.getParcelableExtra(NfcAdapter.EXTRA_TAG, Tag::class.java)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
intent.getParcelableExtra(NfcAdapter.EXTRA_TAG)
|
||||
} ?: return
|
||||
AppLog.d("EmoneyInfoNfc", "onNewIntent techs=${tag.techList.joinToString()}")
|
||||
val reader = readers.firstOrNull { it.canHandle(tag) } ?: return
|
||||
runCatching {
|
||||
_uiState.value = reader.read(tag, strings)
|
||||
scheduleResetToRescanHint()
|
||||
}.onFailure {
|
||||
AppLog.e("EmoneyInfoNfc", "Scan failed: ${it.message}", it)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
scanMessage = context.getString(
|
||||
R.string.scan_failed_message,
|
||||
it.message ?: context.getString(R.string.unknown_error)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun enableForegroundDispatch(activity: Activity) {
|
||||
val adapter = adapter ?: return
|
||||
if (!adapter.isEnabled) {
|
||||
refreshStatus()
|
||||
return
|
||||
}
|
||||
val intent = Intent(activity, activity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
activity,
|
||||
0,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
|
||||
)
|
||||
val filters = arrayOf(IntentFilter(NfcAdapter.ACTION_TECH_DISCOVERED))
|
||||
val techLists = arrayOf(
|
||||
arrayOf(IsoDep::class.java.name),
|
||||
arrayOf(NfcF::class.java.name)
|
||||
)
|
||||
adapter.enableForegroundDispatch(activity, pendingIntent, filters, techLists)
|
||||
}
|
||||
|
||||
fun disableForegroundDispatch(activity: Activity) {
|
||||
adapter?.disableForegroundDispatch(activity)
|
||||
}
|
||||
|
||||
private fun scheduleResetToRescanHint() {
|
||||
resetMessageJob?.cancel()
|
||||
resetMessageJob = scope.launch {
|
||||
delay(5_000)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
scanMessage = if (adapter?.isEnabled == true) {
|
||||
context.getString(R.string.tap_again_hint)
|
||||
} else {
|
||||
currentScanMessage()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun currentScanMessage(): String = when {
|
||||
adapter == null -> context.getString(R.string.nfc_not_supported)
|
||||
!adapter.isEnabled -> context.getString(R.string.nfc_disabled)
|
||||
else -> context.getString(R.string.tap_card_hint)
|
||||
}
|
||||
}
|
||||
243
app/src/main/java/com/iiyh/emoneyinfo/pdf/HistoryPdfExporter.kt
Normal file
@ -0,0 +1,243 @@
|
||||
package com.iiyh.emoneyinfo.pdf
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.pdf.PdfDocument
|
||||
import android.net.Uri
|
||||
import androidx.core.content.FileProvider
|
||||
import com.iiyh.emoneyinfo.R
|
||||
import com.iiyh.emoneyinfo.data.EmoneyUiState
|
||||
import com.iiyh.emoneyinfo.data.TransactionItem
|
||||
import com.iiyh.emoneyinfo.data.formatCardNumber
|
||||
import com.iiyh.emoneyinfo.data.maskFirst12
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.text.NumberFormat
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
|
||||
object HistoryPdfExporter {
|
||||
private const val PAGE_WIDTH = 595
|
||||
private const val PAGE_HEIGHT = 842
|
||||
private const val MARGIN = 40f
|
||||
|
||||
fun export(context: Context, state: EmoneyUiState): File {
|
||||
val document = PdfDocument()
|
||||
var page = document.startPage(PdfDocument.PageInfo.Builder(PAGE_WIDTH, PAGE_HEIGHT, 1).create())
|
||||
var canvas = page.canvas
|
||||
var y = MARGIN
|
||||
var pageNumber = 1
|
||||
|
||||
val bodyPaint = paint(10f)
|
||||
val boldPaint = paint(10f, Typeface.BOLD)
|
||||
val titlePaint = paint(13f, Typeface.BOLD)
|
||||
val headerPaint = paint(10f, Typeface.BOLD, color = pdfGreen())
|
||||
val smallPaint = paint(9f)
|
||||
val footerPaint = paint(9f, color = Color.LTGRAY, align = Paint.Align.CENTER)
|
||||
val amountPositivePaint = paint(9f, color = Color.rgb(33, 140, 33), align = Paint.Align.RIGHT)
|
||||
val amountDefaultPaint = paint(9f, color = Color.BLACK, align = Paint.Align.RIGHT)
|
||||
val rightHeaderPaint = paint(10f, Typeface.BOLD, color = pdfGreen(), align = Paint.Align.RIGHT)
|
||||
val linePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.LTGRAY
|
||||
strokeWidth = 1f
|
||||
}
|
||||
val altRowPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.rgb(247, 247, 247)
|
||||
}
|
||||
|
||||
val subtitle = context.getString(R.string.pdf_subtitle)
|
||||
val cardLabel = context.getString(state.cardType.labelRes)
|
||||
val balanceText = state.formattedBalance()
|
||||
val cardNumber = state.cardNumber.maskFirst12()
|
||||
val list = state.transactions
|
||||
val hasLocation = list.any { it.locationName.isNotBlank() }
|
||||
val indonesianLocale = Locale.forLanguageTag("id-ID")
|
||||
val dateFormatter = SimpleDateFormat("dd MMMM yyyy, HH:mm", indonesianLocale)
|
||||
val numFormatter = NumberFormat.getCurrencyInstance(indonesianLocale).apply {
|
||||
currency = java.util.Currency.getInstance("IDR")
|
||||
maximumFractionDigits = 0
|
||||
}
|
||||
|
||||
fun newPage() {
|
||||
document.finishPage(page)
|
||||
pageNumber += 1
|
||||
page = document.startPage(PdfDocument.PageInfo.Builder(PAGE_WIDTH, PAGE_HEIGHT, pageNumber).create())
|
||||
canvas = page.canvas
|
||||
y = MARGIN
|
||||
}
|
||||
|
||||
val logo = BitmapFactory.decodeResource(context.resources, R.drawable.app_logo)
|
||||
if (logo != null) {
|
||||
val imgH = 30f
|
||||
val imgW = logo.width * (imgH / logo.height.toFloat())
|
||||
canvas.drawBitmap(logo, null, android.graphics.RectF(MARGIN, y, MARGIN + imgW, y + imgH), null)
|
||||
y += imgH + 12f
|
||||
} else {
|
||||
val fallbackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = pdfGreen() }
|
||||
canvas.drawRoundRect(MARGIN, y, MARGIN + 48f, y + 48f, 10f, 10f, fallbackPaint)
|
||||
val letterPaint = paint(20f, Typeface.BOLD, color = Color.WHITE, align = Paint.Align.CENTER)
|
||||
canvas.drawText("E", MARGIN + 24f, y + 31f, letterPaint)
|
||||
y += 60f
|
||||
}
|
||||
|
||||
y += drawWrappedText(canvas, subtitle, MARGIN, y, PAGE_WIDTH - MARGIN * 2, bodyPaint) + 16f
|
||||
canvas.drawLine(MARGIN, y, PAGE_WIDTH - MARGIN, y, linePaint)
|
||||
y += 16f
|
||||
|
||||
y = drawInfoRow(canvas, context.getString(R.string.pdf_card_label), cardLabel, y, boldPaint, bodyPaint)
|
||||
y = drawInfoRow(canvas, context.getString(R.string.pdf_balance_label), balanceText, y, boldPaint, bodyPaint)
|
||||
y = drawInfoRow(canvas, context.getString(R.string.card_number_label), cardNumber, y, boldPaint, bodyPaint)
|
||||
y += 8f
|
||||
canvas.drawLine(MARGIN, y, PAGE_WIDTH - MARGIN, y, linePaint)
|
||||
y += 16f
|
||||
|
||||
val dateColW = 130f
|
||||
val typeColW = 70f
|
||||
val locationColW = if (hasLocation) 120f else 0f
|
||||
val amountX = MARGIN + dateColW + typeColW + locationColW
|
||||
val amountColW = PAGE_WIDTH - MARGIN - amountX
|
||||
|
||||
canvas.drawText(context.getString(R.string.pdf_date_label), MARGIN, y, headerPaint)
|
||||
canvas.drawText(context.getString(R.string.pdf_transaction_label), MARGIN + dateColW, y, headerPaint)
|
||||
if (hasLocation) {
|
||||
canvas.drawText(context.getString(R.string.pdf_location_label), MARGIN + dateColW + typeColW, y, headerPaint)
|
||||
}
|
||||
canvas.drawText(context.getString(R.string.pdf_amount_label), amountX + amountColW, y, rightHeaderPaint)
|
||||
y += 14f
|
||||
|
||||
val headerUnderline = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.argb(102, 92, 125, 122)
|
||||
strokeWidth = 1f
|
||||
}
|
||||
canvas.drawLine(MARGIN, y, PAGE_WIDTH - MARGIN, y, headerUnderline)
|
||||
y += 12f
|
||||
|
||||
list.forEachIndexed { index, item ->
|
||||
val rowHeight = 16f
|
||||
if (y > PAGE_HEIGHT - MARGIN - 40f) {
|
||||
newPage()
|
||||
}
|
||||
|
||||
if (index % 2 == 0) {
|
||||
canvas.drawRect(MARGIN - 4f, y - 11f, PAGE_WIDTH - MARGIN + 4f, y + 5f, altRowPaint)
|
||||
}
|
||||
|
||||
val dateText = dateFormatter.format(item.date)
|
||||
val typeText = item.title
|
||||
val amountText = numFormatter.format(item.amount).replace("IDR", "Rp")
|
||||
val amountPaint = if (item.isCredit) amountPositivePaint else amountDefaultPaint
|
||||
|
||||
canvas.drawText(dateText, MARGIN, y, smallPaint)
|
||||
canvas.drawText(typeText, MARGIN + dateColW, y, smallPaint)
|
||||
if (hasLocation) {
|
||||
canvas.drawText(item.locationName.ifBlank { "–" }, MARGIN + dateColW + typeColW, y, smallPaint)
|
||||
}
|
||||
canvas.drawText(amountText, amountX + amountColW, y, amountPaint)
|
||||
y += rowHeight
|
||||
}
|
||||
|
||||
y += 10f
|
||||
canvas.drawLine(MARGIN, y, PAGE_WIDTH - MARGIN, y, linePaint)
|
||||
y += 14f
|
||||
canvas.drawText(
|
||||
"emoneyInfo © ${Calendar.getInstance().get(Calendar.YEAR)}",
|
||||
PAGE_WIDTH / 2f,
|
||||
y,
|
||||
footerPaint
|
||||
)
|
||||
|
||||
document.finishPage(page)
|
||||
|
||||
val file = File(context.cacheDir, "emoney_history_${System.currentTimeMillis()}.pdf")
|
||||
FileOutputStream(file).use { output ->
|
||||
document.writeTo(output)
|
||||
}
|
||||
document.close()
|
||||
return file
|
||||
}
|
||||
|
||||
fun openOrShare(context: Context, file: File) {
|
||||
val uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
file
|
||||
)
|
||||
|
||||
val shareIntent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "application/pdf"
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
|
||||
val viewIntent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, "application/pdf")
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
|
||||
val packageManager = context.packageManager
|
||||
val openIntents = packageManager.queryIntentActivities(viewIntent, 0).map { resolveInfo ->
|
||||
Intent(viewIntent).setPackage(resolveInfo.activityInfo.packageName)
|
||||
}.toTypedArray()
|
||||
|
||||
val chooser = Intent.createChooser(shareIntent, context.getString(R.string.pdf_open_or_share))
|
||||
chooser.putExtra(Intent.EXTRA_INITIAL_INTENTS, openIntents)
|
||||
chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
context.startActivity(chooser)
|
||||
}
|
||||
|
||||
private fun drawInfoRow(
|
||||
canvas: Canvas,
|
||||
label: String,
|
||||
value: String,
|
||||
y: Float,
|
||||
labelPaint: Paint,
|
||||
valuePaint: Paint
|
||||
): Float {
|
||||
val labelW = 90f
|
||||
val colonX = MARGIN + labelW
|
||||
val valueX = colonX + 14f
|
||||
canvas.drawText(label, MARGIN, y, labelPaint)
|
||||
canvas.drawText(":", colonX, y, valuePaint)
|
||||
canvas.drawText(value, valueX, y, valuePaint)
|
||||
return y + 16f
|
||||
}
|
||||
|
||||
private fun drawWrappedText(
|
||||
canvas: Canvas,
|
||||
text: String,
|
||||
x: Float,
|
||||
y: Float,
|
||||
width: Float,
|
||||
paint: Paint
|
||||
): Float {
|
||||
val textPaint = android.text.TextPaint(paint)
|
||||
val layout = android.text.StaticLayout.Builder
|
||||
.obtain(text, 0, text.length, textPaint, width.toInt())
|
||||
.build()
|
||||
canvas.save()
|
||||
canvas.translate(x, y)
|
||||
layout.draw(canvas)
|
||||
canvas.restore()
|
||||
return layout.height.toFloat()
|
||||
}
|
||||
|
||||
private fun paint(
|
||||
size: Float,
|
||||
typefaceStyle: Int = Typeface.NORMAL,
|
||||
color: Int = Color.BLACK,
|
||||
align: Paint.Align = Paint.Align.LEFT
|
||||
) = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
textSize = size
|
||||
typeface = Typeface.create(Typeface.DEFAULT, typefaceStyle)
|
||||
this.color = color
|
||||
textAlign = align
|
||||
}
|
||||
|
||||
private fun pdfGreen(): Int = Color.rgb(92, 125, 122)
|
||||
}
|
||||
139
app/src/main/java/com/iiyh/emoneyinfo/ui/EmoneyInfoApp.kt
Normal file
@ -0,0 +1,139 @@
|
||||
package com.iiyh.emoneyinfo.ui
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CreditCard
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.iiyh.emoneyinfo.R
|
||||
import androidx.navigation.NavDestination.Companion.hierarchy
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.iiyh.emoneyinfo.nfc.UnifiedNfcReader
|
||||
import com.iiyh.emoneyinfo.ui.screens.AboutScreen
|
||||
import com.iiyh.emoneyinfo.ui.screens.FaqScreen
|
||||
import com.iiyh.emoneyinfo.ui.screens.HomeScreen
|
||||
import com.iiyh.emoneyinfo.ui.screens.HistoryScreen
|
||||
import com.iiyh.emoneyinfo.ui.screens.PrivacyPolicyScreen
|
||||
import com.iiyh.emoneyinfo.ui.screens.SettingsScreen
|
||||
import com.iiyh.emoneyinfo.ui.screens.TermsScreen
|
||||
|
||||
private data class BottomDestination(val route: String, val label: String)
|
||||
|
||||
@Composable
|
||||
fun EmoneyInfoApp(
|
||||
nfcReader: UnifiedNfcReader,
|
||||
adsEnabled: Boolean
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val navController = rememberNavController()
|
||||
val uiState by nfcReader.uiState.collectAsState()
|
||||
val preferences = remember(context) {
|
||||
context.getSharedPreferences("emoney_info_prefs", Context.MODE_PRIVATE)
|
||||
}
|
||||
var showCardNumber by remember(preferences) {
|
||||
mutableStateOf(preferences.getBoolean("masked", true))
|
||||
}
|
||||
|
||||
val bottomDestinations = listOf(
|
||||
BottomDestination("home", stringResource(R.string.tab_home)),
|
||||
BottomDestination("settings", stringResource(R.string.tab_settings))
|
||||
)
|
||||
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentDestination = navBackStackEntry?.destination
|
||||
val visible = currentDestination?.route in setOf("home", "settings")
|
||||
if (visible) {
|
||||
NavigationBar {
|
||||
bottomDestinations.forEach { destination ->
|
||||
NavigationBarItem(
|
||||
selected = currentDestination?.hierarchy?.any { it.route == destination.route } == true,
|
||||
onClick = {
|
||||
if (destination.route == "home") {
|
||||
navController.popBackStack("home", false)
|
||||
} else {
|
||||
navController.navigate(destination.route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = if (destination.route == "home") Icons.Default.CreditCard else Icons.Default.Settings,
|
||||
contentDescription = destination.label
|
||||
)
|
||||
},
|
||||
label = { Text(destination.label) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
NavHost(navController = navController, startDestination = "home", modifier = Modifier.padding(innerPadding)) {
|
||||
composable("home") {
|
||||
HomeScreen(
|
||||
state = uiState,
|
||||
adsEnabled = adsEnabled,
|
||||
showCardNumber = showCardNumber,
|
||||
onScanTapped = { nfcReader.startScan() },
|
||||
onViewHistoryTapped = { navController.navigate("history") },
|
||||
onSettingsTapped = { navController.navigate("settings") }
|
||||
)
|
||||
}
|
||||
composable("settings") {
|
||||
SettingsScreen(
|
||||
adsEnabled = adsEnabled,
|
||||
showCardNumber = showCardNumber,
|
||||
onShowCardNumberChanged = {
|
||||
showCardNumber = it
|
||||
preferences.edit().putBoolean("masked", it).apply()
|
||||
},
|
||||
onHelpCenterTapped = { navController.navigate("faq") },
|
||||
onAboutTapped = { navController.navigate("about") }
|
||||
)
|
||||
}
|
||||
composable("history") {
|
||||
HistoryScreen(
|
||||
state = uiState,
|
||||
adsEnabled = adsEnabled,
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
composable("faq") {
|
||||
FaqScreen(onBack = { navController.popBackStack() })
|
||||
}
|
||||
composable("about") {
|
||||
AboutScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
onTermsTapped = { navController.navigate("terms") },
|
||||
onPrivacyTapped = { navController.navigate("privacy") }
|
||||
)
|
||||
}
|
||||
composable("terms") {
|
||||
TermsScreen(onBack = { navController.popBackStack() })
|
||||
}
|
||||
composable("privacy") {
|
||||
PrivacyPolicyScreen(onBack = { navController.popBackStack() })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,64 @@
|
||||
package com.iiyh.emoneyinfo.ui.components
|
||||
|
||||
import android.view.ViewGroup
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import com.google.android.gms.ads.AdListener
|
||||
import com.google.android.gms.ads.AdRequest
|
||||
import com.google.android.gms.ads.AdSize
|
||||
import com.google.android.gms.ads.AdView
|
||||
import com.google.android.gms.ads.LoadAdError
|
||||
import com.iiyh.emoneyinfo.util.AppLog
|
||||
|
||||
@Composable
|
||||
fun AdMobBanner(adUnitId: String, modifier: Modifier = Modifier) {
|
||||
val context = LocalContext.current
|
||||
val configuration = LocalConfiguration.current
|
||||
val density = LocalDensity.current
|
||||
val adWidthPx = with(density) { configuration.screenWidthDp.dp.roundToPx() }
|
||||
val adWidthDp = with(density) { adWidthPx.toDp().value.toInt() }
|
||||
val adSize = remember(adWidthDp) {
|
||||
AdSize.getCurrentOrientationAnchoredAdaptiveBannerAdSize(context, adWidthDp)
|
||||
}
|
||||
val adView = remember(adUnitId, adSize) {
|
||||
AdView(context).apply {
|
||||
setAdSize(adSize)
|
||||
this.adUnitId = adUnitId
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
adListener = object : AdListener() {
|
||||
override fun onAdLoaded() {
|
||||
AppLog.d("EmoneyInfoAds", "Banner loaded: $adUnitId")
|
||||
}
|
||||
|
||||
override fun onAdFailedToLoad(error: LoadAdError) {
|
||||
AppLog.w(
|
||||
"EmoneyInfoAds",
|
||||
"Banner failed: $adUnitId code=${error.code} message=${error.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
loadAd(AdRequest.Builder().build())
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(adView) {
|
||||
onDispose {
|
||||
adView.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
AndroidView(
|
||||
modifier = modifier,
|
||||
factory = { adView }
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
package com.iiyh.emoneyinfo.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.iiyh.emoneyinfo.R
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ScreenTopBar(title: String, onBack: () -> Unit) {
|
||||
TopAppBar(
|
||||
windowInsets = WindowInsets(0, 0, 0, 0),
|
||||
title = { Text(text = title) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.back)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
156
app/src/main/java/com/iiyh/emoneyinfo/ui/screens/AboutScreen.kt
Normal file
@ -0,0 +1,156 @@
|
||||
package com.iiyh.emoneyinfo.ui.screens
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CreditCard
|
||||
import androidx.compose.material.icons.filled.Description
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.iiyh.emoneyinfo.R
|
||||
import com.iiyh.emoneyinfo.ui.components.ScreenTopBar
|
||||
import com.iiyh.emoneyinfo.ui.theme.Card
|
||||
import com.iiyh.emoneyinfo.ui.theme.Primary
|
||||
import com.iiyh.emoneyinfo.ui.theme.Secondary
|
||||
import com.iiyh.emoneyinfo.ui.theme.TextSecondary
|
||||
|
||||
@Composable
|
||||
fun AboutScreen(onBack: () -> Unit, onTermsTapped: () -> Unit, onPrivacyTapped: () -> Unit) {
|
||||
Scaffold(
|
||||
contentWindowInsets = WindowInsets(0, 0, 0, 0),
|
||||
topBar = {
|
||||
ScreenTopBar(
|
||||
title = stringResource(R.string.about_app),
|
||||
onBack = onBack
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = Card),
|
||||
shape = RoundedCornerShape(24.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.app_logo),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(84.dp),
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
Text(stringResource(R.string.app_name), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
||||
Text(stringResource(R.string.settings_about_subtitle), color = TextSecondary)
|
||||
Text(
|
||||
stringResource(R.string.about_description),
|
||||
color = TextSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
LegalRow(
|
||||
title = stringResource(R.string.terms_conditions),
|
||||
icon = Icons.Default.Description,
|
||||
onClick = onTermsTapped
|
||||
)
|
||||
LegalRow(
|
||||
title = stringResource(R.string.privacy_policy),
|
||||
icon = Icons.Default.Lock,
|
||||
onClick = onPrivacyTapped
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(Brush.linearGradient(listOf(Primary, Secondary)))
|
||||
.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Icon(Icons.Default.CreditCard, contentDescription = null, tint = Color.White)
|
||||
Text(stringResource(R.string.about_architecture_title), color = Color.White, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
stringResource(R.string.about_architecture_desc),
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LegalRow(title: String, icon: ImageVector, onClick: () -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onClick
|
||||
),
|
||||
colors = CardDefaults.cardColors(containerColor = Card),
|
||||
shape = RoundedCornerShape(18.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = Primary.copy(alpha = 0.16f)),
|
||||
shape = RoundedCornerShape(14.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = Secondary,
|
||||
modifier = Modifier.padding(12.dp)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = title,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(vertical = 12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
194
app/src/main/java/com/iiyh/emoneyinfo/ui/screens/FaqScreen.kt
Normal file
@ -0,0 +1,194 @@
|
||||
package com.iiyh.emoneyinfo.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Email
|
||||
import androidx.compose.material.icons.filled.QuestionAnswer
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.iiyh.emoneyinfo.R
|
||||
import com.iiyh.emoneyinfo.data.FaqData
|
||||
import com.iiyh.emoneyinfo.data.FaqItem
|
||||
import com.iiyh.emoneyinfo.ui.components.ScreenTopBar
|
||||
import com.iiyh.emoneyinfo.ui.theme.Card
|
||||
import com.iiyh.emoneyinfo.ui.theme.Primary
|
||||
import com.iiyh.emoneyinfo.ui.theme.Secondary
|
||||
import com.iiyh.emoneyinfo.ui.theme.TextSecondary
|
||||
|
||||
@Composable
|
||||
fun FaqScreen(onBack: () -> Unit) {
|
||||
var query by remember { mutableStateOf("") }
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val filteredCategories = FaqData.all.mapNotNull { category ->
|
||||
val filteredItems = category.items.filter {
|
||||
val question = stringResource(it.questionRes)
|
||||
val answer = stringResource(it.answerRes)
|
||||
query.isBlank() || question.contains(query, true) || answer.contains(query, true)
|
||||
}
|
||||
if (filteredItems.isEmpty()) null else category.copy(items = filteredItems)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = WindowInsets(0, 0, 0, 0),
|
||||
topBar = {
|
||||
ScreenTopBar(
|
||||
title = stringResource(R.string.help_center),
|
||||
onBack = onBack
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(Brush.linearGradient(listOf(Primary, Secondary)))
|
||||
.padding(horizontal = 20.dp, vertical = 18.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.faq_header),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text(stringResource(R.string.faq_search)) }
|
||||
)
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
if (filteredCategories.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.faq_no_results),
|
||||
color = TextSecondary,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
} else {
|
||||
filteredCategories.forEach { category ->
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(category.titleRes),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = TextSecondary,
|
||||
modifier = Modifier.padding(top = 8.dp, bottom = 2.dp)
|
||||
)
|
||||
}
|
||||
items(category.items) { item ->
|
||||
FaqCard(item = item)
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = Primary),
|
||||
shape = RoundedCornerShape(20.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.faq_help_card_title),
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.faq_help_card_desc),
|
||||
color = Color.White.copy(alpha = 0.88f),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Button(
|
||||
onClick = {
|
||||
uriHandler.openUri("mailto:support@iptek.co?subject=Ask%20Support")
|
||||
}
|
||||
) {
|
||||
Icon(Icons.Default.Email, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.faq_email_support))
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FaqCard(item: FaqItem) {
|
||||
Card(
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = Card),
|
||||
shape = RoundedCornerShape(18.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = Primary.copy(alpha = 0.16f)),
|
||||
shape = RoundedCornerShape(14.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.QuestionAnswer,
|
||||
contentDescription = null,
|
||||
tint = Secondary,
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(stringResource(item.questionRes), fontWeight = FontWeight.SemiBold)
|
||||
Text(stringResource(item.answerRes), color = TextSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,228 @@
|
||||
package com.iiyh.emoneyinfo.ui.screens
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ContextWrapper
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.PictureAsPdf
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.google.android.gms.ads.AdRequest
|
||||
import com.google.android.gms.ads.FullScreenContentCallback
|
||||
import com.google.android.gms.ads.LoadAdError
|
||||
import com.google.android.gms.ads.interstitial.InterstitialAd
|
||||
import com.google.android.gms.ads.interstitial.InterstitialAdLoadCallback
|
||||
import com.iiyh.emoneyinfo.R
|
||||
import com.iiyh.emoneyinfo.data.EmoneyUiState
|
||||
import com.iiyh.emoneyinfo.data.TransactionItem
|
||||
import com.iiyh.emoneyinfo.ui.theme.Danger
|
||||
import com.iiyh.emoneyinfo.ads.AdMobConfig
|
||||
import com.iiyh.emoneyinfo.pdf.HistoryPdfExporter
|
||||
import com.iiyh.emoneyinfo.ui.components.AdMobBanner
|
||||
import com.iiyh.emoneyinfo.ui.components.ScreenTopBar
|
||||
import com.iiyh.emoneyinfo.ui.theme.Card
|
||||
import com.iiyh.emoneyinfo.ui.theme.Primary
|
||||
import com.iiyh.emoneyinfo.ui.theme.Success
|
||||
import com.iiyh.emoneyinfo.ui.theme.TextSecondary
|
||||
|
||||
@Composable
|
||||
fun HistoryScreen(state: EmoneyUiState, adsEnabled: Boolean, onBack: () -> Unit) {
|
||||
val exportButtonHeight = 56.dp
|
||||
val context = LocalContext.current
|
||||
val activity = context.findActivity()
|
||||
var interstitialAd by remember { mutableStateOf<InterstitialAd?>(null) }
|
||||
|
||||
fun loadInterstitial() {
|
||||
InterstitialAd.load(
|
||||
context,
|
||||
AdMobConfig.INTERSTITIAL_PDF,
|
||||
AdRequest.Builder().build(),
|
||||
object : InterstitialAdLoadCallback() {
|
||||
override fun onAdLoaded(ad: InterstitialAd) {
|
||||
interstitialAd = ad
|
||||
}
|
||||
|
||||
override fun onAdFailedToLoad(error: LoadAdError) {
|
||||
interstitialAd = null
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(adsEnabled) {
|
||||
if (adsEnabled) {
|
||||
loadInterstitial()
|
||||
} else {
|
||||
interstitialAd = null
|
||||
}
|
||||
}
|
||||
|
||||
fun exportPdfNow() {
|
||||
runCatching {
|
||||
val file = HistoryPdfExporter.export(context, state)
|
||||
HistoryPdfExporter.openOrShare(context, file)
|
||||
}.onFailure {
|
||||
Toast.makeText(context, context.getString(R.string.pdf_export_failed), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
fun exportWithAd() {
|
||||
val ad = interstitialAd
|
||||
if (adsEnabled && ad != null && activity != null) {
|
||||
interstitialAd = null
|
||||
ad.fullScreenContentCallback = object : FullScreenContentCallback() {
|
||||
override fun onAdDismissedFullScreenContent() {
|
||||
loadInterstitial()
|
||||
exportPdfNow()
|
||||
}
|
||||
|
||||
override fun onAdFailedToShowFullScreenContent(adError: com.google.android.gms.ads.AdError) {
|
||||
loadInterstitial()
|
||||
exportPdfNow()
|
||||
}
|
||||
}
|
||||
ad.show(activity)
|
||||
} else {
|
||||
exportPdfNow()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = WindowInsets(0, 0, 0, 0),
|
||||
topBar = {
|
||||
ScreenTopBar(
|
||||
title = stringResource(R.string.history_title),
|
||||
onBack = onBack
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
if (adsEnabled) {
|
||||
AdMobBanner(adUnitId = AdMobConfig.BANNER_HISTORY, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
|
||||
if (state.transactions.isEmpty()) {
|
||||
Text(stringResource(R.string.no_history), color = TextSecondary)
|
||||
} else {
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
items(state.transactions) { item ->
|
||||
HistoryTransactionCard(item = item)
|
||||
}
|
||||
item { Spacer(modifier = Modifier.padding(bottom = exportButtonHeight + 64.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = { exportWithAd() },
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp)
|
||||
) {
|
||||
Icon(Icons.Default.PictureAsPdf, contentDescription = null)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Text(stringResource(R.string.export_pdf))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun android.content.Context.findActivity(): Activity? = when (this) {
|
||||
is Activity -> this
|
||||
is ContextWrapper -> baseContext.findActivity()
|
||||
else -> null
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HistoryTransactionCard(item: TransactionItem) {
|
||||
val primaryLabel = item.locationName.ifBlank { item.title }
|
||||
val secondaryLabel = item.formattedDate()
|
||||
val trailingSubtitle = item.title.takeIf { item.locationName.isNotBlank() }
|
||||
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = Card),
|
||||
shape = RoundedCornerShape(18.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (item.isCredit) Primary.copy(alpha = 0.22f) else Danger.copy(alpha = 0.12f)
|
||||
),
|
||||
shape = RoundedCornerShape(14.dp)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(if (item.isCredit) R.drawable.ic_activity_outline else R.drawable.ic_card_outline),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(12.dp)
|
||||
.size(24.dp),
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.padding(horizontal = 8.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(primaryLabel, fontWeight = FontWeight.SemiBold)
|
||||
Text(secondaryLabel, style = MaterialTheme.typography.bodySmall, color = TextSecondary)
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text(item.formattedAmount(), color = if (item.isCredit) Success else Danger)
|
||||
trailingSubtitle?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = TextSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
334
app/src/main/java/com/iiyh/emoneyinfo/ui/screens/HomeScreen.kt
Normal file
@ -0,0 +1,334 @@
|
||||
package com.iiyh.emoneyinfo.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.CreditCard
|
||||
import androidx.compose.material.icons.filled.History
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import android.widget.Toast
|
||||
import com.iiyh.emoneyinfo.R
|
||||
import com.iiyh.emoneyinfo.data.EmoneyUiState
|
||||
import com.iiyh.emoneyinfo.data.formatCardNumber
|
||||
import com.iiyh.emoneyinfo.data.maskFirst12
|
||||
import com.iiyh.emoneyinfo.ui.theme.Primary
|
||||
import com.iiyh.emoneyinfo.ads.AdMobConfig
|
||||
import com.iiyh.emoneyinfo.ui.components.AdMobBanner
|
||||
import com.iiyh.emoneyinfo.ui.theme.Danger
|
||||
import com.iiyh.emoneyinfo.ui.theme.Secondary
|
||||
import com.iiyh.emoneyinfo.ui.theme.Success
|
||||
import com.iiyh.emoneyinfo.ui.theme.TextSecondary
|
||||
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
state: EmoneyUiState,
|
||||
adsEnabled: Boolean,
|
||||
showCardNumber: Boolean,
|
||||
onScanTapped: () -> Unit,
|
||||
onViewHistoryTapped: () -> Unit,
|
||||
onSettingsTapped: () -> Unit
|
||||
) {
|
||||
@Suppress("DEPRECATION")
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val context = LocalContext.current
|
||||
val latestTransaction = state.transactions.firstOrNull()
|
||||
val hasCardData = state.hasCardData()
|
||||
val displayCardNumber = when {
|
||||
state.cardNumber.isBlank() -> ""
|
||||
showCardNumber -> state.cardNumber.formatCardNumber()
|
||||
else -> state.cardNumber.maskFirst12()
|
||||
}
|
||||
val latestTitle = latestTransaction?.locationName?.ifBlank { latestTransaction.title }
|
||||
?: stringResource(R.string.placeholder_transaction_title)
|
||||
val latestSubtitle = latestTransaction?.formattedDate() ?: stringResource(R.string.placeholder_transaction_date)
|
||||
val latestTrailingSubtitle = latestTransaction?.title?.takeIf { latestTransaction.locationName.isNotBlank() }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.app_logo),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(42.dp),
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
Spacer(Modifier.size(10.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.app_name),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
IconButton(onClick = onSettingsTapped) {
|
||||
Icon(Icons.Default.Settings, contentDescription = "Settings")
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
Text(stringResource(R.string.available_balance), color = TextSecondary, style = MaterialTheme.typography.labelMedium)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(state.formattedBalance(), style = MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(28.dp))
|
||||
.background(Brush.linearGradient(listOf(Primary, Secondary)))
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.home_header),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(220.dp),
|
||||
contentScale = ContentScale.Crop,
|
||||
alpha = 0.18f
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Surface(
|
||||
color = Color.White.copy(alpha = 0.16f),
|
||||
shape = RoundedCornerShape(999.dp)
|
||||
) {
|
||||
Text(
|
||||
text = if (hasCardData) stringResource(R.string.scan_result_title) else stringResource(R.string.scan_ready_title),
|
||||
color = Color.White,
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
}
|
||||
Text(
|
||||
stringResource(state.cardType.labelRes),
|
||||
color = Color.White.copy(alpha = 0.95f),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
if (state.cardNumber.isNotBlank()) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
stringResource(R.string.card_number_label),
|
||||
color = Color.White.copy(alpha = 0.72f),
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Text(
|
||||
text = displayCardNumber,
|
||||
color = Color.White.copy(alpha = 0.92f),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
IconButton(
|
||||
onClick = {
|
||||
clipboard.setText(AnnotatedString(state.cardNumber))
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.copied_to_clipboard),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
},
|
||||
modifier = Modifier.size(20.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ContentCopy,
|
||||
contentDescription = stringResource(R.string.copy_card_number),
|
||||
tint = Color.White.copy(alpha = 0.78f),
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hasCardData) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
stringResource(R.string.latest_scan_message),
|
||||
color = Color.White.copy(alpha = 0.72f),
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
Text(state.scanMessage, color = Color.White, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
} else {
|
||||
Text(stringResource(R.string.tap_card_hint), color = Color.White, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (adsEnabled) {
|
||||
AdMobBanner(adUnitId = AdMobConfig.BANNER_HOME, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = stringResource(R.string.last_activity_label),
|
||||
color = TextSecondary,
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
if (latestTransaction == null) {
|
||||
Text(
|
||||
text = stringResource(R.string.history_summary_empty),
|
||||
color = TextSecondary,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = stringResource(R.string.history_summary_count, state.transactions.size),
|
||||
color = TextSecondary,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
InfoChip(
|
||||
modifier = Modifier.weight(1f),
|
||||
title = stringResource(R.string.card_type_label),
|
||||
value = stringResource(state.cardType.labelRes),
|
||||
iconRes = R.drawable.ic_card_outline
|
||||
)
|
||||
InfoChip(
|
||||
modifier = Modifier.weight(1f),
|
||||
title = stringResource(R.string.history_summary_card),
|
||||
value = if (state.transactions.isEmpty()) "0" else state.transactions.size.toString(),
|
||||
iconRes = R.drawable.ic_activity_outline
|
||||
)
|
||||
}
|
||||
|
||||
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_activity_outline),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(28.dp),
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
Spacer(Modifier.size(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = latestTitle,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Text(
|
||||
text = latestSubtitle,
|
||||
color = TextSecondary,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = latestTransaction?.formattedAmount() ?: stringResource(R.string.placeholder_transaction_amount),
|
||||
color = latestTransaction?.let { if (it.isCredit) Success else Danger }
|
||||
?: MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
latestTrailingSubtitle?.let {
|
||||
Text(
|
||||
text = it,
|
||||
color = TextSecondary,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onViewHistoryTapped
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(Icons.Default.History, contentDescription = null)
|
||||
Text(stringResource(R.string.view_full_history), fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoChip(
|
||||
modifier: Modifier = Modifier,
|
||||
title: String,
|
||||
value: String,
|
||||
iconRes: Int
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier,
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(iconRes),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
Column {
|
||||
Text(title, color = TextSecondary, style = MaterialTheme.typography.labelSmall)
|
||||
Text(value, fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
package com.iiyh.emoneyinfo.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.iiyh.emoneyinfo.R
|
||||
import com.iiyh.emoneyinfo.ui.components.ScreenTopBar
|
||||
import com.iiyh.emoneyinfo.ui.theme.TextSecondary
|
||||
|
||||
@Composable
|
||||
fun PrivacyPolicyScreen(onBack: () -> Unit) {
|
||||
Scaffold(
|
||||
contentWindowInsets = WindowInsets(0, 0, 0, 0),
|
||||
topBar = {
|
||||
ScreenTopBar(
|
||||
title = stringResource(R.string.privacy_policy),
|
||||
onBack = onBack
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(stringResource(R.string.privacy_intro), color = TextSecondary)
|
||||
Text(stringResource(R.string.privacy_point_1))
|
||||
Text(stringResource(R.string.privacy_point_2))
|
||||
Text(stringResource(R.string.privacy_point_3))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,181 @@
|
||||
package com.iiyh.emoneyinfo.ui.screens
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.iiyh.emoneyinfo.R
|
||||
import com.iiyh.emoneyinfo.ui.theme.Card
|
||||
import com.iiyh.emoneyinfo.ui.theme.Primary
|
||||
import com.iiyh.emoneyinfo.ui.theme.TextSecondary
|
||||
import com.iiyh.emoneyinfo.ads.AdMobConfig
|
||||
import com.iiyh.emoneyinfo.ui.components.AdMobBanner
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
adsEnabled: Boolean,
|
||||
showCardNumber: Boolean,
|
||||
onShowCardNumberChanged: (Boolean) -> Unit,
|
||||
onHelpCenterTapped: () -> Unit,
|
||||
onAboutTapped: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp)
|
||||
) {
|
||||
Text(stringResource(R.string.settings_title), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
||||
|
||||
if (adsEnabled) {
|
||||
AdMobBanner(adUnitId = AdMobConfig.BANNER_SETTINGS, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = Card),
|
||||
shape = RoundedCornerShape(20.dp)
|
||||
) {
|
||||
Column {
|
||||
SettingRow(
|
||||
title = stringResource(R.string.language),
|
||||
subtitle = stringResource(R.string.language_value),
|
||||
iconRes = R.drawable.ic_settings_outline
|
||||
) {}
|
||||
SettingsDivider()
|
||||
ToggleSettingRow(
|
||||
title = stringResource(R.string.show_card_number),
|
||||
subtitle = stringResource(R.string.show_card_number_desc),
|
||||
iconRes = R.drawable.ic_simcard,
|
||||
checked = showCardNumber,
|
||||
onCheckedChange = onShowCardNumberChanged
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingRow(
|
||||
title = stringResource(R.string.help_center),
|
||||
subtitle = stringResource(R.string.settings_help_subtitle),
|
||||
iconRes = R.drawable.ic_activity_outline,
|
||||
onClick = onHelpCenterTapped
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingRow(
|
||||
title = stringResource(R.string.about_app),
|
||||
subtitle = stringResource(R.string.settings_about_subtitle),
|
||||
iconRes = R.drawable.ic_card_outline,
|
||||
onClick = onAboutTapped
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.padding(bottom = 84.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingRow(title: String, subtitle: String, iconRes: Int, onClick: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onClick
|
||||
)
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = Primary.copy(alpha = 0.18f)),
|
||||
shape = RoundedCornerShape(14.dp)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(iconRes),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(title, fontWeight = FontWeight.SemiBold)
|
||||
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = TextSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsDivider() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 72.dp, end = 16.dp)
|
||||
) {
|
||||
androidx.compose.foundation.Canvas(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 0.5.dp)
|
||||
) {
|
||||
drawLine(
|
||||
color = TextSecondary.copy(alpha = 0.18f),
|
||||
start = androidx.compose.ui.geometry.Offset(0f, 0f),
|
||||
end = androidx.compose.ui.geometry.Offset(size.width, 0f),
|
||||
strokeWidth = 1.dp.toPx()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ToggleSettingRow(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
iconRes: Int,
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = Primary.copy(alpha = 0.18f)),
|
||||
shape = RoundedCornerShape(14.dp)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(iconRes),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(title, fontWeight = FontWeight.SemiBold)
|
||||
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = TextSecondary)
|
||||
}
|
||||
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
package com.iiyh.emoneyinfo.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.iiyh.emoneyinfo.R
|
||||
import com.iiyh.emoneyinfo.ui.components.ScreenTopBar
|
||||
import com.iiyh.emoneyinfo.ui.theme.TextSecondary
|
||||
|
||||
@Composable
|
||||
fun TermsScreen(onBack: () -> Unit) {
|
||||
Scaffold(
|
||||
contentWindowInsets = WindowInsets(0, 0, 0, 0),
|
||||
topBar = {
|
||||
ScreenTopBar(
|
||||
title = stringResource(R.string.terms_conditions),
|
||||
onBack = onBack
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(stringResource(R.string.terms_intro), color = TextSecondary)
|
||||
Text(stringResource(R.string.terms_point_1))
|
||||
Text(stringResource(R.string.terms_point_2))
|
||||
Text(stringResource(R.string.terms_point_3))
|
||||
}
|
||||
}
|
||||
}
|
||||
13
app/src/main/java/com/iiyh/emoneyinfo/ui/theme/Color.kt
Normal file
@ -0,0 +1,13 @@
|
||||
package com.iiyh.emoneyinfo.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val Background = Color(0xFFF3F3F8)
|
||||
val SystemBar = Color(0xFFDCE2EA)
|
||||
val Primary = Color(0xFF7AD4D1)
|
||||
val Secondary = Color(0xFF5D7D7B)
|
||||
val Card = Color(0xFFFFFFFF)
|
||||
val TextPrimary = Color(0xFF1A1A2E)
|
||||
val TextSecondary = Color(0xFF8E8E93)
|
||||
val Success = Color(0xFF34C759)
|
||||
val Danger = Color(0xFFE53935)
|
||||
31
app/src/main/java/com/iiyh/emoneyinfo/ui/theme/Theme.kt
Normal file
@ -0,0 +1,31 @@
|
||||
package com.iiyh.emoneyinfo.ui.theme
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
private val LightColors = lightColorScheme(
|
||||
primary = Primary,
|
||||
secondary = Secondary,
|
||||
background = Background,
|
||||
surface = Card,
|
||||
onPrimary = TextPrimary,
|
||||
onSecondary = Card,
|
||||
onBackground = TextPrimary,
|
||||
onSurface = TextPrimary
|
||||
)
|
||||
|
||||
private val DarkColors = darkColorScheme(
|
||||
primary = Primary,
|
||||
secondary = Secondary
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun EmoneyInfoTheme(content: @Composable () -> Unit) {
|
||||
MaterialTheme(
|
||||
colorScheme = LightColors,
|
||||
typography = Typography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
5
app/src/main/java/com/iiyh/emoneyinfo/ui/theme/Type.kt
Normal file
@ -0,0 +1,5 @@
|
||||
package com.iiyh.emoneyinfo.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
|
||||
val Typography = Typography()
|
||||
23
app/src/main/java/com/iiyh/emoneyinfo/util/AppLog.kt
Normal file
@ -0,0 +1,23 @@
|
||||
package com.iiyh.emoneyinfo.util
|
||||
|
||||
import android.util.Log
|
||||
import com.iiyh.emoneyinfo.BuildConfig
|
||||
|
||||
object AppLog {
|
||||
fun d(tag: String, message: String) {
|
||||
if (BuildConfig.DEBUG) Log.d(tag, message)
|
||||
}
|
||||
|
||||
fun w(tag: String, message: String) {
|
||||
if (BuildConfig.DEBUG) Log.w(tag, message)
|
||||
}
|
||||
|
||||
fun e(tag: String, message: String, throwable: Throwable? = null) {
|
||||
if (!BuildConfig.DEBUG) return
|
||||
if (throwable != null) {
|
||||
Log.e(tag, message, throwable)
|
||||
} else {
|
||||
Log.e(tag, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
app/src/main/res/drawable-nodpi/app_logo.png
Executable file
|
After Width: | Height: | Size: 16 KiB |
BIN
app/src/main/res/drawable-nodpi/home_header.png
Executable file
|
After Width: | Height: | Size: 12 KiB |
BIN
app/src/main/res/drawable-nodpi/ic_activity_outline.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
app/src/main/res/drawable-nodpi/ic_card_outline.png
Normal file
|
After Width: | Height: | Size: 7.8 KiB |
BIN
app/src/main/res/drawable-nodpi/ic_settings_outline.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
app/src/main/res/drawable-nodpi/ic_simcard.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
3
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
@ -0,0 +1,3 @@
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<solid android:color="#F3F3F8" />
|
||||
</shape>
|
||||
18
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
@ -0,0 +1,18 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#7AD4D1"
|
||||
android:pathData="M16,16h76a12,12 0 0 1 12,12v52a12,12 0 0 1 -12,12H16A12,12 0 0 1 4,80V28A12,12 0 0 1 16,16z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M34,38c12,0 22,10 22,22h-8c0,-7.7 -6.3,-14 -14,-14z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M34,50c5.5,0 10,4.5 10,10h-8c0,-1.1 -0.9,-2 -2,-2z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M72,32h8v44h-8z" />
|
||||
</vector>
|
||||
9
app/src/main/res/drawable/launch_background.xml
Normal file
@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
<item>
|
||||
<bitmap
|
||||
android:src="@drawable/app_logo"
|
||||
android:gravity="center" />
|
||||
</item>
|
||||
</layer-list>
|
||||
6
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
6
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 844 B |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 438 B |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 648 B |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 322 B |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 550 B |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 752 B |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 978 B |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
150
app/src/main/res/values-id/strings.xml
Normal file
@ -0,0 +1,150 @@
|
||||
<resources>
|
||||
<string name="app_name">Emoney Info</string>
|
||||
<string name="tab_home">E-Money</string>
|
||||
<string name="tab_settings">Pengaturan</string>
|
||||
<string name="available_balance">SALDO TERSEDIA</string>
|
||||
<string name="check_balance">Cek Saldo</string>
|
||||
<string name="tap_card_hint">Tempelkan kartu di bagian belakang ponsel untuk membaca saldo dan riwayat transaksi.</string>
|
||||
<string name="tap_again_hint">Tempelkan kembali kartu untuk mengecek saldo dan riwayat transaksi.</string>
|
||||
<string name="last_transaction">Transaksi Terakhir</string>
|
||||
<string name="view_full_history">Lihat Semua Riwayat</string>
|
||||
<string name="history_title">Riwayat Transaksi</string>
|
||||
<string name="recent_activity">AKTIVITAS TERBARU</string>
|
||||
<string name="export_pdf">Ekspor PDF</string>
|
||||
<string name="pdf_open_or_share">Buka atau bagikan PDF</string>
|
||||
<string name="pdf_export_failed">Gagal mengekspor PDF</string>
|
||||
<string name="pdf_subtitle">Dibuat oleh aplikasi emoney Info: cek saldo dan riwayat uang elektronik.</string>
|
||||
<string name="pdf_card_label">Kartu</string>
|
||||
<string name="pdf_balance_label">Saldo</string>
|
||||
<string name="pdf_date_label">Tanggal</string>
|
||||
<string name="pdf_transaction_label">Transaksi</string>
|
||||
<string name="pdf_location_label">Lokasi</string>
|
||||
<string name="pdf_amount_label">Jumlah</string>
|
||||
<string name="settings_title">Pengaturan</string>
|
||||
<string name="section_general">Umum</string>
|
||||
<string name="section_app">Aplikasi</string>
|
||||
<string name="language">Bahasa</string>
|
||||
<string name="language_value">Bahasa Indonesia</string>
|
||||
<string name="show_card_number">Tampilkan Nomor Kartu</string>
|
||||
<string name="show_card_number_desc">Tampilkan nomor kartu setelah proses scan berhasil.</string>
|
||||
<string name="help_center">Pusat Bantuan</string>
|
||||
<string name="about_app">Tentang Aplikasi</string>
|
||||
<string name="terms_conditions">Syarat & Ketentuan</string>
|
||||
<string name="privacy_policy">Kebijakan Privasi</string>
|
||||
<string name="faq_header">Apa yang bisa kami bantu hari ini?</string>
|
||||
<string name="faq_search_placeholder">Cari pertanyaan atau jawaban</string>
|
||||
<string name="faq_no_results">Tidak ada hasil ditemukan</string>
|
||||
<string name="faq_help_card_title">Masih butuh bantuan?</string>
|
||||
<string name="faq_help_card_desc">Kirim email ke kami dan kami akan membalas dalam 1–2 hari kerja.</string>
|
||||
<string name="faq_email_support">Email Support</string>
|
||||
<string name="about_description">Cek kartu e-money yang didukung dengan NFC, lihat saldo, dan tinjau riwayat transaksi di satu tempat.</string>
|
||||
<string name="support_cards">Mendukung Mandiri e-Money, Flazz, Brizzi, TapCash, JackCard, MegaCash, dan KMT.</string>
|
||||
<string name="footer_copyright">© Emoney Info</string>
|
||||
<string name="scan_message">Siap memindai kartu NFC</string>
|
||||
<string name="nfc_not_supported">Perangkat ini tidak mendukung NFC.</string>
|
||||
<string name="nfc_disabled">NFC sedang dimatikan. Aktifkan NFC di Pengaturan, lalu tempelkan kembali kartu Anda.</string>
|
||||
<string name="no_history">Tidak ada transaksi ditemukan.</string>
|
||||
<string name="placeholder_card_type">Kartu E-Money</string>
|
||||
<string name="placeholder_card_number">Nomor kartu disembunyikan</string>
|
||||
<string name="placeholder_transaction_title">Belum ada transaksi</string>
|
||||
<string name="placeholder_transaction_date">Scan kartu untuk memuat riwayat terbaru</string>
|
||||
<string name="placeholder_transaction_amount">Rp 0</string>
|
||||
<string name="transaction_location">Lokasi</string>
|
||||
<string name="history_summary_empty">Belum ada riwayat transaksi yang berhasil dibaca untuk kartu ini.</string>
|
||||
<string name="history_summary_count">%1$d transaksi</string>
|
||||
<string name="scan_ready_title">Siap dipindai</string>
|
||||
<string name="scan_result_title">Hasil scan</string>
|
||||
<string name="card_number_label">Nomor Kartu</string>
|
||||
<string name="copy_card_number">Salin nomor kartu</string>
|
||||
<string name="copied_to_clipboard">Disalin ke clipboard</string>
|
||||
<string name="card_type_label">Jenis Kartu</string>
|
||||
<string name="last_activity_label">AKTIVITAS TERAKHIR</string>
|
||||
<string name="latest_scan_message">Pesan pembaca terakhir</string>
|
||||
<string name="history_summary_card">Ringkasan kartu</string>
|
||||
<string name="history_section_title">TRANSAKSI TERBARU</string>
|
||||
<string name="scan_to_read">Tempelkan kartu yang didukung untuk membaca saldo dan aktivitasnya.</string>
|
||||
<string name="settings_header_title">Preferensi aplikasi dan dukungan</string>
|
||||
<string name="faq_section_title">PUSAT BANTUAN</string>
|
||||
<string name="faq_search">Cari</string>
|
||||
<string name="about_section_title">TENTANG</string>
|
||||
<string name="about_architecture_title">Arsitektur siap NFC</string>
|
||||
<string name="about_architecture_desc">Port Android disiapkan untuk alur ISO-DEP dan FeliCa, dengan parser yang mengikuti arsitektur iOS.</string>
|
||||
<string name="topup">Isi Ulang</string>
|
||||
<string name="payment">Pembayaran</string>
|
||||
<string name="card_unknown">Kartu E-Money</string>
|
||||
<string name="card_mandiri">Mandiri e-Money</string>
|
||||
<string name="card_flazz">BCA Flazz</string>
|
||||
<string name="card_brizzi">BRIZZI</string>
|
||||
<string name="card_tapcash">TapCash</string>
|
||||
<string name="card_jackcard">JackCard</string>
|
||||
<string name="card_megacash">MegaCash</string>
|
||||
<string name="card_kmt">KMT</string>
|
||||
<string name="scan_failed_message">Gagal membaca kartu: %1$s</string>
|
||||
<string name="unknown_error">Kesalahan tidak diketahui</string>
|
||||
<string name="card_not_supported">Kartu tidak didukung</string>
|
||||
<string name="error_brizzi_card_number">Gagal membaca nomor kartu Brizzi</string>
|
||||
<string name="error_brizzi_step1">Gagal pada proses Brizzi langkah 1</string>
|
||||
<string name="error_brizzi_step2">Gagal pada proses Brizzi langkah 2</string>
|
||||
<string name="error_brizzi_step3">Gagal pada proses Brizzi langkah 3</string>
|
||||
<string name="error_brizzi_balance">Gagal membaca saldo Brizzi</string>
|
||||
<string name="error_mandiri_card_number">Gagal membaca nomor kartu Mandiri</string>
|
||||
<string name="error_mandiri_balance">Gagal membaca saldo Mandiri</string>
|
||||
<string name="scan_brizzi_success">Kartu Brizzi berhasil dibaca.</string>
|
||||
<string name="scan_brizzi_history_success">Kartu Brizzi dan riwayat transaksinya berhasil dibaca.</string>
|
||||
<string name="scan_flazz_success">Kartu Flazz berhasil dibaca.</string>
|
||||
<string name="scan_flazz_history_success">Kartu Flazz dan riwayat transaksinya berhasil dibaca.</string>
|
||||
<string name="scan_flazz_classic_detected">Kartu Flazz Classic terdeteksi. Data dasar kartu berhasil dibaca; parsing saldo dan riwayat detail masih terbatas.</string>
|
||||
<string name="scan_tapcash_detected_partial">TapCash terdeteksi tetapi data purse tidak dapat dibaca.</string>
|
||||
<string name="scan_tapcash_success">Kartu TapCash berhasil dibaca.</string>
|
||||
<string name="scan_tapcash_history_success">Kartu TapCash dan riwayat terbarunya berhasil dibaca.</string>
|
||||
<string name="scan_mandiri_success">Kartu Mandiri e-Money berhasil dibaca. Format log kartu lama ini belum di-port.</string>
|
||||
<string name="scan_mandiri_history_success">Kartu Mandiri e-Money dan log transaksinya berhasil dibaca.</string>
|
||||
<string name="scan_jackcard_success">JackCard berhasil dibaca.</string>
|
||||
<string name="scan_megacash_success">MegaCash berhasil dibaca.</string>
|
||||
<string name="scan_kmt_history_success">Kartu KMT dan riwayat perjalanannya berhasil dibaca.</string>
|
||||
<string name="tx_reactivation">Reaktivasi</string>
|
||||
<string name="tx_void">Void</string>
|
||||
<string name="tx_update_balance">Pembaruan Saldo</string>
|
||||
<string name="tx_transaction">Transaksi</string>
|
||||
<string name="tx_black_list_card">Kartu Black List</string>
|
||||
<string name="tx_statement_fee">Biaya Laporan</string>
|
||||
<string name="tx_grace_period">Masa Tenggang</string>
|
||||
<string name="tx_refund">Refund</string>
|
||||
<string name="tx_close">Tutup</string>
|
||||
<string name="tx_atu">ATU</string>
|
||||
<string name="faq_category_cards">Kartu</string>
|
||||
<string name="faq_category_transactions">Transaksi</string>
|
||||
<string name="faq_category_balance">Keuangan</string>
|
||||
<string name="faq_category_app">Aplikasi</string>
|
||||
<string name="faq_q_supported_cards">Kartu apa saja yang didukung?</string>
|
||||
<string name="faq_a_supported_cards">Aplikasi mendukung Mandiri e-money, BCA Flazz, BNI TapCash, BRI Brizzi, JackCard, MegaCash, dan KMT. Pastikan ponsel Android Anda mendukung NFC.</string>
|
||||
<string name="faq_q_card_not_detected">Mengapa kartu saya tidak terdeteksi?</string>
|
||||
<string name="faq_a_card_not_detected">Pastikan NFC aktif di ponsel Android Anda. Tempelkan kartu secara rata di bagian belakang ponsel dekat antena NFC dan tahan diam selama pemindaian.</string>
|
||||
<string name="faq_q_card_read_failed">Kartu gagal dibaca terus, apa yang harus dilakukan?</string>
|
||||
<string name="faq_a_card_read_failed">Coba lepas casing tebal, bersihkan permukaan kartu, lalu coba lagi. Jika masalah berlanjut, kartu mungkin rusak.</string>
|
||||
<string name="faq_q_transactions_not_shown">Mengapa transaksi saya tidak muncul?</string>
|
||||
<string name="faq_a_transactions_not_shown">Aplikasi membaca transaksi terbaru yang tersimpan di chip kartu. Transaksi yang lebih lama mungkin tidak dapat diakses melalui NFC.</string>
|
||||
<string name="faq_q_export_pdf">Cara ekspor riwayat ke PDF?</string>
|
||||
<string name="faq_a_export_pdf">Setelah scan kartu, buka Lihat Semua Riwayat, lalu tekan tombol Ekspor PDF. Anda dapat membagikannya lewat WhatsApp, email, dan aplikasi lainnya.</string>
|
||||
<string name="faq_q_balance_wrong">Saldo yang ditampilkan tidak sesuai, kenapa?</string>
|
||||
<string name="faq_a_balance_wrong">Aplikasi membaca saldo langsung dari chip kartu secara real time. Perbedaan dapat terjadi jika top-up terbaru belum tersinkron ke chip.</string>
|
||||
<string name="faq_q_balance_topup">Apakah bisa isi ulang saldo lewat aplikasi ini?</string>
|
||||
<string name="faq_a_balance_topup">Tidak, aplikasi ini hanya bisa membaca saldo. Isi ulang harus dilakukan melalui aplikasi resmi bank, ATM, atau merchant.</string>
|
||||
<string name="faq_q_app_language">Bagaimana cara ganti bahasa aplikasi?</string>
|
||||
<string name="faq_a_app_language">Buka Pengaturan → Bahasa. Aplikasi mengikuti pilihan Anda dan langsung mengubah teks yang terlihat.</string>
|
||||
<string name="faq_q_hide_card_number">Apa fungsi Tampilkan Nomor Kartu?</string>
|
||||
<string name="faq_a_hide_card_number">Jika diaktifkan, nomor kartu penuh ditampilkan di beranda. Jika dimatikan, 12 digit pertama disamarkan untuk privasi di tempat umum.</string>
|
||||
<string name="settings_privacy_first">Privasi lebih dulu</string>
|
||||
<string name="settings_privacy_desc">Anda bisa menyamarkan nomor kartu dan membuat detail scan lebih nyaman ditinjau.</string>
|
||||
<string name="settings_help_subtitle">FAQ dan panduan dukungan</string>
|
||||
<string name="settings_about_subtitle">Versi 1.0.0</string>
|
||||
<string name="terms_intro">Port Android ini mengikuti arah produk yang sama dengan aplikasi iOS. Konten legal final sebaiknya disalin dari sumber produksi sebelum rilis.</string>
|
||||
<string name="terms_point_1">1. Aplikasi membaca kartu NFC yang didukung secara lokal di perangkat.</string>
|
||||
<string name="terms_point_2">2. Aplikasi tidak mengubah saldo kartu atau menulis data ke kartu.</string>
|
||||
<string name="terms_point_3">3. Penempatan iklan dan analitik harus ditinjau sebelum rilis.</string>
|
||||
<string name="privacy_intro">Pembacaan NFC diproses secara lokal di perangkat. Teks privasi final harus diselaraskan dengan implementasi Android akhir dan penggunaan AdMob.</string>
|
||||
<string name="privacy_point_1">1. Scan kartu dipicu oleh pengguna.</string>
|
||||
<string name="privacy_point_2">2. Tidak ada operasi tulis yang dilakukan pada kartu.</string>
|
||||
<string name="privacy_point_3">3. Perilaku SDK iklan harus ditinjau untuk kepatuhan produksi.</string>
|
||||
<string name="back">Kembali</string>
|
||||
</resources>
|
||||
9
app/src/main/res/values/colors.xml
Normal file
@ -0,0 +1,9 @@
|
||||
<resources>
|
||||
<color name="background">#F3F3F8</color>
|
||||
<color name="system_bar">#D7DEE8</color>
|
||||
<color name="primary">#7AD4D1</color>
|
||||
<color name="secondary">#5D7D7B</color>
|
||||
<color name="text_primary">#1A1A2E</color>
|
||||
<color name="text_secondary">#8E8E93</color>
|
||||
<color name="card">#FFFFFF</color>
|
||||
</resources>
|
||||
4
app/src/main/res/values/ic_launcher_background.xml
Normal file
@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FFFFFF</color>
|
||||
</resources>
|
||||
150
app/src/main/res/values/strings.xml
Normal file
@ -0,0 +1,150 @@
|
||||
<resources>
|
||||
<string name="app_name">Emoney Info</string>
|
||||
<string name="tab_home">E-Money</string>
|
||||
<string name="tab_settings">Settings</string>
|
||||
<string name="available_balance">AVAILABLE BALANCE</string>
|
||||
<string name="check_balance">Check Balance</string>
|
||||
<string name="tap_card_hint">Tap your card on the back of your phone to read balance and transaction history.</string>
|
||||
<string name="tap_again_hint">Tap your card again to check balance and transaction history.</string>
|
||||
<string name="last_transaction">Last Transaction</string>
|
||||
<string name="view_full_history">View Full History</string>
|
||||
<string name="history_title">Transaction History</string>
|
||||
<string name="recent_activity">RECENT ACTIVITY</string>
|
||||
<string name="export_pdf">Export PDF</string>
|
||||
<string name="pdf_open_or_share">Open or share PDF</string>
|
||||
<string name="pdf_export_failed">Failed to export PDF</string>
|
||||
<string name="pdf_subtitle">Generated by emoney Info: check your e-money balance and transaction history.</string>
|
||||
<string name="pdf_card_label">Card</string>
|
||||
<string name="pdf_balance_label">Balance</string>
|
||||
<string name="pdf_date_label">Date</string>
|
||||
<string name="pdf_transaction_label">Transaction</string>
|
||||
<string name="pdf_location_label">Location</string>
|
||||
<string name="pdf_amount_label">Amount</string>
|
||||
<string name="settings_title">Settings</string>
|
||||
<string name="section_general">General</string>
|
||||
<string name="section_app">App</string>
|
||||
<string name="language">Language</string>
|
||||
<string name="language_value">English</string>
|
||||
<string name="show_card_number">Show Card Number</string>
|
||||
<string name="show_card_number_desc">Display the card number after a successful scan.</string>
|
||||
<string name="help_center">Help Center</string>
|
||||
<string name="about_app">About App</string>
|
||||
<string name="terms_conditions">Terms & Conditions</string>
|
||||
<string name="privacy_policy">Privacy Policy</string>
|
||||
<string name="faq_header">How can we help you today?</string>
|
||||
<string name="faq_search_placeholder">Search questions or answers</string>
|
||||
<string name="faq_no_results">No results found</string>
|
||||
<string name="faq_help_card_title">Still need help?</string>
|
||||
<string name="faq_help_card_desc">Send us an email and we\'ll get back to you within 1–2 business days.</string>
|
||||
<string name="faq_email_support">Email Support</string>
|
||||
<string name="about_description">Check supported e-money cards with NFC, view balance, and review transaction history in one place.</string>
|
||||
<string name="support_cards">Supports Mandiri e-Money, Flazz, Brizzi, TapCash, JackCard, MegaCash, and KMT.</string>
|
||||
<string name="footer_copyright">© Emoney Info</string>
|
||||
<string name="scan_message">Ready to scan NFC card</string>
|
||||
<string name="nfc_not_supported">This device does not support NFC.</string>
|
||||
<string name="nfc_disabled">NFC is turned off. Enable NFC in Settings, then tap your card again.</string>
|
||||
<string name="no_history">No transactions found.</string>
|
||||
<string name="placeholder_card_type">E-Money Card</string>
|
||||
<string name="placeholder_card_number">Card number hidden</string>
|
||||
<string name="placeholder_transaction_title">No transaction yet</string>
|
||||
<string name="placeholder_transaction_date">Scan a card to load recent history</string>
|
||||
<string name="placeholder_transaction_amount">Rp 0</string>
|
||||
<string name="transaction_location">Location</string>
|
||||
<string name="history_summary_empty">No transaction history has been read for this card yet.</string>
|
||||
<string name="history_summary_count">%1$d transactions</string>
|
||||
<string name="scan_ready_title">Ready to scan</string>
|
||||
<string name="scan_result_title">Scan result</string>
|
||||
<string name="card_number_label">Card Number</string>
|
||||
<string name="copy_card_number">Copy card number</string>
|
||||
<string name="copied_to_clipboard">Copied to clipboard</string>
|
||||
<string name="card_type_label">Card Type</string>
|
||||
<string name="last_activity_label">LAST ACTIVITY</string>
|
||||
<string name="latest_scan_message">Latest reader message</string>
|
||||
<string name="history_summary_card">Card overview</string>
|
||||
<string name="history_section_title">RECENT TRANSACTIONS</string>
|
||||
<string name="scan_to_read">Tap a supported card to read its balance and activity.</string>
|
||||
<string name="settings_header_title">App preferences and support</string>
|
||||
<string name="faq_section_title">HELP CENTER</string>
|
||||
<string name="faq_search">Search</string>
|
||||
<string name="about_section_title">ABOUT</string>
|
||||
<string name="about_architecture_title">NFC ready architecture</string>
|
||||
<string name="about_architecture_desc">Android port prepared for ISO-DEP and FeliCa flows, with parser work following the iOS architecture.</string>
|
||||
<string name="topup">Top Up</string>
|
||||
<string name="payment">Payment</string>
|
||||
<string name="card_unknown">E-Money Card</string>
|
||||
<string name="card_mandiri">Mandiri e-Money</string>
|
||||
<string name="card_flazz">BCA Flazz</string>
|
||||
<string name="card_brizzi">BRIZZI</string>
|
||||
<string name="card_tapcash">TapCash</string>
|
||||
<string name="card_jackcard">JackCard</string>
|
||||
<string name="card_megacash">MegaCash</string>
|
||||
<string name="card_kmt">KMT</string>
|
||||
<string name="scan_failed_message">Failed to read card: %1$s</string>
|
||||
<string name="unknown_error">Unknown error</string>
|
||||
<string name="card_not_supported">Card not supported</string>
|
||||
<string name="error_brizzi_card_number">Failed to read Brizzi card number</string>
|
||||
<string name="error_brizzi_step1">Failed Brizzi process step 1</string>
|
||||
<string name="error_brizzi_step2">Failed Brizzi process step 2</string>
|
||||
<string name="error_brizzi_step3">Failed Brizzi process step 3</string>
|
||||
<string name="error_brizzi_balance">Failed Brizzi balance read</string>
|
||||
<string name="error_mandiri_card_number">Failed to read Mandiri card number</string>
|
||||
<string name="error_mandiri_balance">Failed to read Mandiri balance</string>
|
||||
<string name="scan_brizzi_success">Brizzi card read successfully.</string>
|
||||
<string name="scan_brizzi_history_success">Brizzi card and transaction history read successfully.</string>
|
||||
<string name="scan_flazz_success">Flazz card read successfully.</string>
|
||||
<string name="scan_flazz_history_success">Flazz card and transaction history read successfully.</string>
|
||||
<string name="scan_flazz_classic_detected">Flazz Classic card detected. Basic card data was read; detailed balance and history parsing is still limited.</string>
|
||||
<string name="scan_tapcash_detected_partial">TapCash detected but purse data could not be read.</string>
|
||||
<string name="scan_tapcash_success">TapCash card read successfully.</string>
|
||||
<string name="scan_tapcash_history_success">TapCash card and recent history read successfully.</string>
|
||||
<string name="scan_mandiri_success">Mandiri e-Money card read successfully. This older card log format is not ported yet.</string>
|
||||
<string name="scan_mandiri_history_success">Mandiri e-Money card and transaction log read successfully.</string>
|
||||
<string name="scan_jackcard_success">JackCard read successfully.</string>
|
||||
<string name="scan_megacash_success">MegaCash read successfully.</string>
|
||||
<string name="scan_kmt_history_success">KMT card and travel history read successfully.</string>
|
||||
<string name="tx_reactivation">Reactivation</string>
|
||||
<string name="tx_void">Void</string>
|
||||
<string name="tx_update_balance">Update Balance</string>
|
||||
<string name="tx_transaction">Transaction</string>
|
||||
<string name="tx_black_list_card">Black List Card</string>
|
||||
<string name="tx_statement_fee">Statement Fee</string>
|
||||
<string name="tx_grace_period">Grace Period</string>
|
||||
<string name="tx_refund">Refund</string>
|
||||
<string name="tx_close">Close</string>
|
||||
<string name="tx_atu">ATU</string>
|
||||
<string name="faq_category_cards">Cards</string>
|
||||
<string name="faq_category_transactions">Transactions</string>
|
||||
<string name="faq_category_balance">Balance</string>
|
||||
<string name="faq_category_app">App</string>
|
||||
<string name="faq_q_supported_cards">What cards are supported?</string>
|
||||
<string name="faq_a_supported_cards">The app supports Mandiri e-money, BCA Flazz, BNI TapCash, BRI Brizzi, JackCard, MegaCash and KMT. Make sure your Android phone supports NFC.</string>
|
||||
<string name="faq_q_card_not_detected">Why is my card not detected?</string>
|
||||
<string name="faq_a_card_not_detected">Make sure NFC is enabled on your Android phone. Hold the card flat against the back of your phone near the NFC antenna and keep it still during scanning.</string>
|
||||
<string name="faq_q_card_read_failed">Card read keeps failing — what should I do?</string>
|
||||
<string name="faq_a_card_read_failed">Try removing any thick phone case, clean the card surface, and try again. If the issue persists, the card may be damaged.</string>
|
||||
<string name="faq_q_transactions_not_shown">Why are my transactions not showing?</string>
|
||||
<string name="faq_a_transactions_not_shown">The app reads the recent transactions stored on the card chip itself. Older transactions may not be accessible via NFC.</string>
|
||||
<string name="faq_q_export_pdf">How do I export transactions to PDF?</string>
|
||||
<string name="faq_a_export_pdf">After scanning your card, open View Full History, then tap the Export PDF button. You can then share it through WhatsApp, email, or other apps.</string>
|
||||
<string name="faq_q_balance_wrong">The balance shown doesn\'t match. Why?</string>
|
||||
<string name="faq_a_balance_wrong">The app reads balance directly from the card chip in real time. Differences may occur if a recent top-up has not yet been synced to the chip.</string>
|
||||
<string name="faq_q_balance_topup">Can I top up my card through the app?</string>
|
||||
<string name="faq_a_balance_topup">No, this app is a read-only reader. Top-up must be done via your bank\'s official app, ATM, or merchant.</string>
|
||||
<string name="faq_q_app_language">How do I change the app language?</string>
|
||||
<string name="faq_a_app_language">Open Settings → Language. The app follows your selection and changes the visible text immediately.</string>
|
||||
<string name="faq_q_hide_card_number">What does Show Card Number do?</string>
|
||||
<string name="faq_a_hide_card_number">When enabled, the full card number is shown on the home screen. When disabled, the first 12 digits are masked for privacy in public spaces.</string>
|
||||
<string name="settings_privacy_first">Privacy first</string>
|
||||
<string name="settings_privacy_desc">You can mask card numbers and keep scan details easier to review.</string>
|
||||
<string name="settings_help_subtitle">FAQ and support guidance</string>
|
||||
<string name="settings_about_subtitle">Version 1.0.0</string>
|
||||
<string name="terms_intro">This Android port follows the same product direction as the iOS app. Final legal content should be copied from the production source before release.</string>
|
||||
<string name="terms_point_1">1. The app reads supported NFC cards locally on the device.</string>
|
||||
<string name="terms_point_2">2. The app does not modify card balances or write card data.</string>
|
||||
<string name="terms_point_3">3. Ad placements and analytics should be reviewed before release.</string>
|
||||
<string name="privacy_intro">NFC reads are processed locally on the device. Release privacy text should be aligned with the final Android implementation and AdMob usage.</string>
|
||||
<string name="privacy_point_1">1. Card scans are initiated by the user.</string>
|
||||
<string name="privacy_point_2">2. No write operation is performed on cards.</string>
|
||||
<string name="privacy_point_3">3. Advertising SDK behavior must be reviewed for production compliance.</string>
|
||||
<string name="back">Back</string>
|
||||
</resources>
|
||||
14
app/src/main/res/values/themes.xml
Normal file
@ -0,0 +1,14 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<style name="Theme.EmoneyInfo.Launch" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
<item name="android:windowLightStatusBar" tools:targetApi="m">true</item>
|
||||
<item name="android:statusBarColor">@color/system_bar</item>
|
||||
<item name="android:navigationBarColor">@color/system_bar</item>
|
||||
</style>
|
||||
|
||||
<style name="Theme.EmoneyInfo" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
<item name="android:statusBarColor">@color/system_bar</item>
|
||||
<item name="android:navigationBarColor">@color/system_bar</item>
|
||||
<item name="android:windowLightStatusBar" tools:targetApi="m">true</item>
|
||||
</style>
|
||||
</resources>
|
||||
6
app/src/main/res/xml/file_paths.xml
Normal file
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<cache-path
|
||||
name="exported_pdfs"
|
||||
path="." />
|
||||
</paths>
|
||||
9
app/src/main/res/xml/nfc_tech_filter.xml
Normal file
@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<tech-list>
|
||||
<tech>android.nfc.tech.IsoDep</tech>
|
||||
</tech-list>
|
||||
<tech-list>
|
||||
<tech>android.nfc.tech.NfcF</tech>
|
||||
</tech-list>
|
||||
</resources>
|
||||