Initial import

This commit is contained in:
2026-04-22 22:31:52 +07:00
commit ef756b97a1
71 changed files with 4518 additions and 0 deletions

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

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

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

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

View File

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

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

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

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

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

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

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

View File

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

View File

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

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

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

View File

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

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

View File

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

View File

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

View File

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

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

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

View File

@ -0,0 +1,5 @@
package com.iiyh.emoneyinfo.ui.theme
import androidx.compose.material3.Typography
val Typography = Typography()

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