Skip to content
Snippets Groups Projects
Commit 0b995087 authored by Farhan Nabil Suryono's avatar Farhan Nabil Suryono
Browse files

Merge branch 'feat/export-transaction' into 'dev'

feat: implement export report

See merge request !32
parents 6271b73c 1eb3575c
2 merge requests!46Dev,!32feat: implement export report
Showing
with 290 additions and 108 deletions
......@@ -50,7 +50,9 @@ dependencies {
implementation(libs.androidx.constraintlayout)
implementation(libs.androidx.navigation.fragment.ktx)
implementation(libs.androidx.navigation.ui.ktx)
testImplementation(libs.junit)
implementation(libs.poi)
implementation(libs.poi.ooxml)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
......
......@@ -51,7 +51,7 @@ class TransactionDatabaseTest {
title = "Transaction 1",
owner = user,
category = TransactionCategory.EARNINGS,
amount = 100.0,
amount = 100L,
)
val transaction2 =
......@@ -59,7 +59,7 @@ class TransactionDatabaseTest {
title = "Transaction 2",
owner = user,
category = TransactionCategory.EARNINGS,
amount = 200.0,
amount = 200L,
)
val transaction3 =
......@@ -67,7 +67,7 @@ class TransactionDatabaseTest {
title = "Transaction 3",
owner = user,
category = TransactionCategory.EXPENSE,
amount = 100.0,
amount = 100L,
)
repository.insert(transaction1, transaction2, transaction3)
......
......@@ -6,8 +6,11 @@ import com.example.bondoman.networks.responses.TokenResponse
import com.example.bondoman.networks.services.UserService
import retrofit2.Response
class UserRepository (private val service: UserService) {
suspend fun login(email: String, password: String): Response<LoginResponse> {
class UserRepository(private val service: UserService) {
suspend fun login(
email: String,
password: String,
): Response<LoginResponse> {
return service.login(LoginRequest(email, password))
}
......
......@@ -13,67 +13,95 @@ object PreferencesManager {
}
fun getEncryptedSharedPreferences(context: Context): SharedPreferences {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
val masterKey =
MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
return EncryptedSharedPreferences.create(
context,
PREFS_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
}
fun putString(context: Context, key: String, value: String, encrypted: Boolean = false) {
val editor = if (encrypted) {
getEncryptedSharedPreferences(context).edit()
} else {
getSharedPreferences(context).edit()
}
fun putString(
context: Context,
key: String,
value: String,
encrypted: Boolean = false,
) {
val editor =
if (encrypted) {
getEncryptedSharedPreferences(context).edit()
} else {
getSharedPreferences(context).edit()
}
editor.putString(key, value)
editor.apply()
}
fun getString(context: Context, key: String, encrypted: Boolean = false): String? {
val sharedPreferences = if (encrypted) {
getEncryptedSharedPreferences(context)
} else {
getSharedPreferences(context)
}
fun getString(
context: Context,
key: String,
encrypted: Boolean = false,
): String? {
val sharedPreferences =
if (encrypted) {
getEncryptedSharedPreferences(context)
} else {
getSharedPreferences(context)
}
return sharedPreferences.getString(key, null)
}
fun putBoolean(context: Context, key: String, value: Boolean, encrypted: Boolean = false) {
val editor = if (encrypted) {
getEncryptedSharedPreferences(context).edit()
} else {
getSharedPreferences(context).edit()
}
fun putBoolean(
context: Context,
key: String,
value: Boolean,
encrypted: Boolean = false,
) {
val editor =
if (encrypted) {
getEncryptedSharedPreferences(context).edit()
} else {
getSharedPreferences(context).edit()
}
editor.putBoolean(key, value)
editor.apply()
}
fun getBoolean(context: Context, key: String, encrypted: Boolean = false): Boolean {
val sharedPreferences = if (encrypted) {
getEncryptedSharedPreferences(context)
} else {
getSharedPreferences(context)
}
fun getBoolean(
context: Context,
key: String,
encrypted: Boolean = false,
): Boolean {
val sharedPreferences =
if (encrypted) {
getEncryptedSharedPreferences(context)
} else {
getSharedPreferences(context)
}
return sharedPreferences.getBoolean(key, false)
}
fun remove(context: Context, key: String, encrypted: Boolean = false) {
val editor = if (encrypted) {
getEncryptedSharedPreferences(context).edit()
} else {
getSharedPreferences(context).edit()
}
fun remove(
context: Context,
key: String,
encrypted: Boolean = false,
) {
val editor =
if (encrypted) {
getEncryptedSharedPreferences(context).edit()
} else {
getSharedPreferences(context).edit()
}
editor.remove(key)
editor.apply()
......
......@@ -17,7 +17,10 @@ class LoginViewModel : ViewModel() {
private val _tokenResponse = MutableLiveData<TokenResponse>()
val tokenResponse = _tokenResponse
fun login(email: String, password: String) {
fun login(
email: String,
password: String,
) {
viewModelScope.launch {
val repository = UserRepository(RetrofitClient.getInstance().create(UserService::class.java))
val loginResponse = repository.login(email, password)
......
package com.example.bondoman.data.viewmodels.settings
enum class ExportTransactionStatus {
SUCCESS,
FAILED,
}
data class ExportTransactionResponseContainer(
val status: ExportTransactionStatus,
val message: String,
)
package com.example.bondoman.data.viewmodels.settings
import androidx.lifecycle.ViewModel
import android.app.Application
import android.content.ContentValues
import android.os.Environment
import android.provider.MediaStore
import android.util.Log
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import com.example.bondoman.data.databases.TransactionDatabase
import com.example.bondoman.data.models.Transaction
import com.example.bondoman.data.repositories.TransactionRepository
import com.example.bondoman.data.repositories.UserRepository
import com.example.bondoman.networks.RetrofitClient
import com.example.bondoman.networks.services.UserService
import kotlinx.coroutines.launch
import org.apache.poi.ss.usermodel.Sheet
import org.apache.poi.ss.usermodel.Workbook
import org.apache.poi.xssf.usermodel.XSSFWorkbook
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class SettingViewModel(applicationContext: Application) : AndroidViewModel(applicationContext) {
private val _exportReportResponse = MutableLiveData<ExportTransactionResponseContainer>()
val exportReportResponse = _exportReportResponse
class SettingViewModel: ViewModel() {
fun exportTransactionReport(bearerToken: String) {
viewModelScope.launch {
val userRepository =
UserRepository(
RetrofitClient.getInstanceWithAuth(bearerToken).create(
UserService::class.java,
),
)
val response = userRepository.checkToken()
if (!response.isSuccessful || response.body() == null) {
Log.d("SettingViewModel", "Response is failed")
_exportReportResponse.value =
ExportTransactionResponseContainer(
ExportTransactionStatus.FAILED,
response.errorBody()?.string() ?: "Unknown error",
)
return@launch
}
val transactionDB = TransactionDatabase.getInstance(getApplication())
val transactionRepository = TransactionRepository(transactionDB.transactionDao())
val transactions = transactionRepository.getAll(response.body()!!.nim!!)
val workbook = transactionToExcel(transactions)
try {
createExcel(workbook)
_exportReportResponse.value =
ExportTransactionResponseContainer(
ExportTransactionStatus.SUCCESS,
"Export Report Success",
)
} catch (e: Exception) {
Log.e("SettingViewModel", e.message ?: "Unknown error")
_exportReportResponse.value =
ExportTransactionResponseContainer(
ExportTransactionStatus.FAILED,
e.message ?: "Unknown error",
)
}
}
}
private fun generateFileName(): String {
// Generate File Name based on current date and time
val sdf = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault())
return sdf.format(Date())
}
private fun createExcel(workbook: Workbook) {
// Save XLSX File to Downloads/Bondoman
val filename = generateFileName()
val contentValues =
ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, "$filename.xlsx")
put(MediaStore.MediaColumns.MIME_TYPE, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS + "/Bondoman")
}
val uri = getApplication<Application>().contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
val outputStream = getApplication<Application>().contentResolver.openOutputStream(uri!!)
workbook.write(outputStream)
outputStream?.close()
workbook.close()
}
private fun transactionToExcel(transactions: List<Transaction>): Workbook {
// Write transactions to XLSX File
val workbook = XSSFWorkbook()
val sheet: Sheet = workbook.createSheet("Transaction Report")
createHeaderRow(sheet)
transactions.forEachIndexed { index, transaction ->
val row = sheet.createRow(index + 1)
row.createCell(0).setCellValue(transaction.id.toDouble())
row.createCell(1).setCellValue(transaction.title)
row.createCell(2).setCellValue(transaction.owner)
row.createCell(3).setCellValue(transaction.category.string)
row.createCell(4).setCellValue(transaction.amount.toDouble())
row.createCell(5).setCellValue(transaction.date.toString())
row.createCell(6).setCellValue(transaction.location)
}
return workbook
}
private fun createHeaderRow(sheet: Sheet) {
// Write Header Row
val headerRow = sheet.createRow(0)
val headerList = listOf("ID", "Title", "Owner", "Category", "Amount", "Date", "Location")
headerList.forEachIndexed { index, header ->
headerRow.createCell(index).setCellValue(header)
}
}
}
......@@ -10,19 +10,21 @@ import retrofit2.converter.moshi.MoshiConverterFactory
object RetrofitClient {
private const val BASE_URL = "https://pbd-backend-2024.vercel.app/"
private val moshi = Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.build()
private val moshi =
Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.build()
private var instance: Retrofit? = null
private var instanceWithAuth: Retrofit? = null
fun getInstance(): Retrofit {
if (instance == null) {
instance = Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(MoshiConverterFactory.create(moshi))
.build()
instance =
Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(MoshiConverterFactory.create(moshi))
.build()
}
return instance!!
......@@ -30,15 +32,17 @@ object RetrofitClient {
fun getInstanceWithAuth(bearerToken: String): Retrofit {
if (instanceWithAuth == null) {
val client = OkHttpClient.Builder()
.addInterceptor(AuthInterceptor(bearerToken))
.build()
instanceWithAuth = Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(MoshiConverterFactory.create(moshi))
.client(client)
.build()
val client =
OkHttpClient.Builder()
.addInterceptor(AuthInterceptor(bearerToken))
.build()
instanceWithAuth =
Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(MoshiConverterFactory.create(moshi))
.client(client)
.build()
}
return instanceWithAuth!!
......
......@@ -4,12 +4,12 @@ import okhttp3.Interceptor
import okhttp3.Response
class AuthInterceptor(private val bearerToken: String) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val newRequest = request.newBuilder()
.addHeader("Authorization", "Bearer $bearerToken")
.build()
val newRequest =
request.newBuilder()
.addHeader("Authorization", "Bearer $bearerToken")
.build()
return chain.proceed(newRequest)
}
......
......@@ -4,10 +4,9 @@ import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class LoginRequest (
data class LoginRequest(
@Json(name = "email")
var email: String? = null,
@Json(name = "password")
var password: String? = null,
)
......@@ -5,6 +5,5 @@ import com.squareup.moshi.Json
data class LoginResponse(
@Json(name = "token")
val token: String?,
val error: String?,
)
......@@ -5,12 +5,9 @@ import com.squareup.moshi.Json
data class TokenResponse(
@Json(name = "nim")
val nim: String?,
@Json(name = "iat")
val iat: Int?,
@Json(name = "exp")
val exp: Int?,
val error: String? = null
val error: String? = null,
)
......@@ -9,7 +9,9 @@ import retrofit2.http.POST
interface UserService {
@POST("/api/auth/login")
suspend fun login(@Body request: LoginRequest): Response<LoginResponse>
suspend fun login(
@Body request: LoginRequest,
): Response<LoginResponse>
@POST("/api/auth/token")
suspend fun checkToken(): Response<TokenResponse>
......
......@@ -72,16 +72,17 @@ class LoginActivity : AppCompatActivity() {
viewModel.login(email, password)
val observer = Observer<LoginResponse> { response ->
if (response.error != null) {
Toast.makeText(this, response.error, Toast.LENGTH_SHORT).show()
} else {
PreferencesManager.putString(this, "token", response.token ?: "", true)
val intent = Intent(this, MainActivity::class.java)
startActivity(intent)
finish()
val observer =
Observer<LoginResponse> { response ->
if (response.error != null) {
Toast.makeText(this, response.error, Toast.LENGTH_SHORT).show()
} else {
PreferencesManager.putString(this, "token", response.token ?: "", true)
val intent = Intent(this, MainActivity::class.java)
startActivity(intent)
finish()
}
}
}
viewModel.loginResponse.observe(this, observer)
}
......@@ -95,23 +96,24 @@ class LoginActivity : AppCompatActivity() {
viewModel.checkToken(token)
val observer = Observer<TokenResponse> { response ->
if (response.error != null) {
Log.e("LoginActivity", response.error)
return@Observer
}
val observer =
Observer<TokenResponse> { response ->
if (response.error != null) {
Log.e("LoginActivity", response.error)
return@Observer
}
val currentTime = System.currentTimeMillis() / 1000
if (response.exp != null && response.exp < currentTime) {
Log.i("LoginActivity", "Token expired")
PreferencesManager.remove(this, "token", true)
return@Observer
}
val currentTime = System.currentTimeMillis() / 1000
if (response.exp != null && response.exp < currentTime) {
Log.i("LoginActivity", "Token expired")
PreferencesManager.remove(this, "token", true)
return@Observer
}
val intent = Intent(this, MainActivity::class.java)
startActivity(intent)
finish()
}
val intent = Intent(this, MainActivity::class.java)
startActivity(intent)
finish()
}
viewModel.tokenResponse.observe(this, observer)
}
......
......@@ -2,28 +2,23 @@ package com.example.bondoman.views.fragments
import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.activity.viewModels
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider
import com.example.bondoman.R
import com.example.bondoman.data.utils.PreferencesManager
import com.example.bondoman.data.viewmodels.login.LoginViewModel
import com.example.bondoman.data.viewmodels.settings.ExportTransactionResponseContainer
import com.example.bondoman.data.viewmodels.settings.ExportTransactionStatus
import com.example.bondoman.data.viewmodels.settings.SettingViewModel
import com.example.bondoman.views.activities.LoginActivity
import com.example.bondoman.views.components.SettingButtonComponent
// TODO: Rename parameter arguments, choose names that match
// the fragment initialization parameters, e.g. ARG_ITEM_NUMBER
private const val ARG_PARAM1 = "param1"
private const val ARG_PARAM2 = "param2"
class SettingsFragment : Fragment() {
private val settingViewModel: SettingViewModel by viewModels()
private lateinit var settingViewModel: SettingViewModel
private lateinit var exportReportButton: SettingButtonComponent
private lateinit var sendReportButton: SettingButtonComponent
......@@ -37,6 +32,7 @@ class SettingsFragment : Fragment() {
): View? {
// Inflate the layout for this fragment
val view = inflater.inflate(R.layout.fragment_settings, container, false)
settingViewModel = ViewModelProvider(this)[SettingViewModel::class.java]
exportReportButton = view.findViewById(R.id.export_report_button)
sendReportButton = view.findViewById(R.id.send_report_button)
......@@ -52,12 +48,24 @@ class SettingsFragment : Fragment() {
}
private fun onExportReportButtonClick() {
Toast.makeText(context, "Export Report", Toast.LENGTH_SHORT).show()
val token = PreferencesManager.getString(requireActivity(), "token", true)
if (token != null) {
settingViewModel.exportTransactionReport(token)
val observer =
Observer<ExportTransactionResponseContainer> {
when (it.status) {
ExportTransactionStatus.SUCCESS -> {
Toast.makeText(context, "Export Report Success", Toast.LENGTH_SHORT).show()
}
ExportTransactionStatus.FAILED -> {
Toast.makeText(context, "Export Report Failed", Toast.LENGTH_SHORT).show()
}
}
}
settingViewModel.exportReportResponse.observe(requireActivity(), observer)
}
}
......@@ -70,7 +78,7 @@ class SettingsFragment : Fragment() {
}
private fun onLogoutButtonClick() {
PreferencesManager.remove(requireActivity(),"token", true)
PreferencesManager.remove(requireActivity(), "token", true)
val intent = Intent(requireActivity(), LoginActivity::class.java)
startActivity(intent)
......
......@@ -17,6 +17,8 @@ mpChart = "v3.1.0"
navigationFragmentKtx = "2.7.7"
navigationUiKtx = "2.7.7"
securityCrypto = "1.1.0-alpha06"
poi = "5.2.5"
poiOoxml = "5.2.5"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
......@@ -45,6 +47,8 @@ mpchart = { module = "com.github.PhilJay:MPAndroidChart", version.ref = "mpChart
androidx-navigation-fragment-ktx = { group = "androidx.navigation", name = "navigation-fragment-ktx", version.ref = "navigationFragmentKtx" }
androidx-navigation-ui-ktx = { group = "androidx.navigation", name = "navigation-ui-ktx", version.ref = "navigationUiKtx" }
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
poi = { group = "org.apache.poi", name = "poi", version.ref = "poi" }
poi-ooxml = { group = "org.apache.poi", name = "poi-ooxml", version.ref = "poiOoxml" }
[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }
......
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment