Skip to content

Commit

Permalink
truncate large numbers using prettyCount & decouple number related ut…
Browse files Browse the repository at this point in the history
…ils to seperate object

Signed-off-by: starry-shivam <[email protected]>
  • Loading branch information
starry-shivam committed May 22, 2024
1 parent 06b046c commit dd66a91
Show file tree
Hide file tree
Showing 3 changed files with 178 additions and 63 deletions.
102 changes: 102 additions & 0 deletions app/src/main/java/com/starry/greenstash/utils/NumberUtils.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package com.starry.greenstash.utils

import java.math.RoundingMode
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
import java.text.NumberFormat
import java.util.Currency
import java.util.Locale
import kotlin.math.floor
import kotlin.math.log10
import kotlin.math.pow

/**
* A collection of utility functions for numbers.
*/
object NumberUtils {

/**
* Get validated number from the text.
*
* @param text The text to validate
* @return The validated number
*/
fun getValidatedNumber(text: String): String {
val filteredChars = text.filterIndexed { index, c ->
c.isDigit() || (c == '.' && index != 0
&& text.indexOf('.') == index)
|| (c == '.' && index != 0
&& text.count { it == '.' } <= 1)
}
return if (filteredChars.count { it == '.' } == 1) {
val beforeDecimal = filteredChars.substringBefore('.')
val afterDecimal = filteredChars.substringAfter('.')
"$beforeDecimal.$afterDecimal"
} else {
filteredChars
}
}

/**
* Round the decimal number to two decimal places.
*
* @param number The number to round
* @return The rounded number
*/
fun roundDecimal(number: Double): Double {
val locale = DecimalFormatSymbols(Locale.US)
val df = DecimalFormat("#.##", locale)
df.roundingMode = RoundingMode.CEILING
return df.format(number).toDouble()
}

/**
* Format currency based on the currency code.
*
* @param amount The amount to format
* @param currencyCode The currency code
* @return The formatted currency
*/
fun formatCurrency(amount: Double, currencyCode: String): String {
val nf = NumberFormat.getCurrencyInstance().apply {
currency = Currency.getInstance(currencyCode)
maximumFractionDigits = if (currencyCode in setOf(
"JPY", "DJF", "GNF", "IDR", "KMF", "KRW", "LAK",
"PYG", "RWF", "VND", "VUV", "XAF", "XOF", "XPF"
)
) 0 else 2
}
return nf.format(amount)
}

/**
* Get currency symbol based on the currency code.
*
* @param currencyCode The currency code
* @return The currency symbol
*/
fun getCurrencySymbol(currencyCode: String): String {
return Currency.getInstance(currencyCode).symbol
}

/**
* Formats a number into a more readable format with a suffix representing its magnitude.
* For example, 1000 becomes "1k", 1000000 becomes "1M", etc.
*
* @param number The number to format.
* @return A string representation of the number with a magnitude suffix.
*/
fun prettyCount(number: Number): String {
val suffix = charArrayOf(' ', 'k', 'M', 'B', 'T', 'P', 'E')
val numValue = number.toLong()
val value = floor(log10(numValue.toDouble())).toInt()
val base = value / 3
return if (value >= 3 && base < suffix.size) {
DecimalFormat("#0.0").format(
numValue / 10.0.pow((base * 3).toDouble())
) + suffix[base]
} else {
DecimalFormat("#,##0").format(numValue)
}
}
}
54 changes: 16 additions & 38 deletions app/src/main/java/com/starry/greenstash/utils/Utils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,8 @@ import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL
import java.io.BufferedReader
import java.io.IOException
import java.io.InputStreamReader
import java.math.RoundingMode
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
import java.text.NumberFormat
import java.time.LocalDateTime
import java.time.ZoneId
import java.util.Currency
import java.util.Locale
import java.util.TimeZone


Expand All @@ -58,34 +52,23 @@ object Utils {
* @param text The text to validate
* @return The validated number
*/
fun getValidatedNumber(text: String): String {
val filteredChars = text.filterIndexed { index, c ->
c.isDigit() || (c == '.' && index != 0
&& text.indexOf('.') == index)
|| (c == '.' && index != 0
&& text.count { it == '.' } <= 1)
}
return if (filteredChars.count { it == '.' } == 1) {
val beforeDecimal = filteredChars.substringBefore('.')
val afterDecimal = filteredChars.substringAfter('.')
"$beforeDecimal.$afterDecimal"
} else {
filteredChars
}
}
@Deprecated(
"Use NumberUtils.getValidatedNumber instead",
ReplaceWith("NumberUtils.getValidatedNumber(text)")
)
fun getValidatedNumber(text: String) = NumberUtils.getValidatedNumber(text)

/**
* Round the decimal number to two decimal places.
*
* @param number The number to round
* @return The rounded number
*/
fun roundDecimal(number: Double): Double {
val locale = DecimalFormatSymbols(Locale.US)
val df = DecimalFormat("#.##", locale)
df.roundingMode = RoundingMode.CEILING
return df.format(number).toDouble()
}
@Deprecated(
"Use NumberUtils.roundDecimal instead",
ReplaceWith("NumberUtils.roundDecimal(number)")
)
fun roundDecimal(number: Double) = NumberUtils.roundDecimal(number)

/**
* Format currency based on the currency code.
Expand All @@ -94,17 +77,12 @@ object Utils {
* @param currencyCode The currency code
* @return The formatted currency
*/
fun formatCurrency(amount: Double, currencyCode: String): String {
val nf = NumberFormat.getCurrencyInstance().apply {
currency = Currency.getInstance(currencyCode)
maximumFractionDigits = if (currencyCode in setOf(
"JPY", "DJF", "GNF", "IDR", "KMF", "KRW", "LAK",
"PYG", "RWF", "VND", "VUV", "XAF", "XOF", "XPF"
)
) 0 else 2
}
return nf.format(amount)
}
@Deprecated(
"Use NumberUtils.formatCurrency instead",
ReplaceWith("NumberUtils.formatCurrency(amount, currencyCode)")
)
fun formatCurrency(amount: Double, currencyCode: String) =
NumberUtils.formatCurrency(amount, currencyCode)

/**
* Get the authenticators based on the Android version.
Expand Down
85 changes: 60 additions & 25 deletions app/src/main/java/com/starry/greenstash/widget/GoalWidget.kt
Original file line number Diff line number Diff line change
Expand Up @@ -32,19 +32,22 @@ import android.appwidget.AppWidgetProvider
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.View
import android.widget.RemoteViews
import com.starry.greenstash.R
import com.starry.greenstash.database.core.GoalWithTransactions
import com.starry.greenstash.utils.GoalTextUtils
import com.starry.greenstash.utils.NumberUtils
import com.starry.greenstash.utils.PreferenceUtil
import com.starry.greenstash.utils.Utils
import dagger.hilt.EntryPoints


private const val WIDGET_MANUAL_REFRESH = "widget_manual_refresh"
private const val MAX_AMOUNT_DIGITS = 10000

class GoalWidget : AppWidgetProvider() {
private lateinit var viewModel: WidgetViewModel
Expand Down Expand Up @@ -86,6 +89,26 @@ class GoalWidget : AppWidgetProvider() {
}
}

override fun onAppWidgetOptionsChanged(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetId: Int,
newOptions: Bundle?
) {
super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions)

initialiseVm(context) // Initialise viewmodel if not already initialised.
val minHeight = newOptions?.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, 0) ?: 0

viewModel.getGoalFromWidgetId(appWidgetId) { goalItem ->
val views = RemoteViews(context.packageName, R.layout.goal_widget)
val visibility = if (minHeight >= 60) View.VISIBLE else View.GONE
views.setViewVisibility(R.id.amountDurationGroup, visibility)
updateWidgetContents(context, appWidgetId, goalItem)
appWidgetManager.partiallyUpdateAppWidget(appWidgetId, views)
}
}

fun updateWidgetContents(context: Context, appWidgetId: Int, goalItem: GoalWithTransactions) {
val preferenceUtil = PreferenceUtil(context)
val appWidgetManager = AppWidgetManager.getInstance(context)
Expand All @@ -102,15 +125,18 @@ class GoalWidget : AppWidgetProvider() {
val defCurrency = preferenceUtil.getString(PreferenceUtil.DEFAULT_CURRENCY_STR, "")!!
val datePattern = preferenceUtil.getString(PreferenceUtil.DATE_FORMAT_STR, "")!!

val savedAmount = goalItem.getCurrentlySavedAmount().let {
if (it > MAX_AMOUNT_DIGITS) {
"${NumberUtils.getCurrencySymbol(defCurrency)}${NumberUtils.prettyCount(it)}"
} else NumberUtils.formatCurrency(it, defCurrency)
}
val targetAmount = goalItem.goal.targetAmount.let {
if (it > MAX_AMOUNT_DIGITS) {
"${NumberUtils.getCurrencySymbol(defCurrency)}${NumberUtils.prettyCount(it)}"
} else NumberUtils.formatCurrency(it, defCurrency)
}
val widgetDesc = context.getString(R.string.goal_widget_desc)
.format(
"${
Utils.formatCurrency(
goalItem.getCurrentlySavedAmount(),
defCurrency
)
} / ${Utils.formatCurrency(goalItem.goal.targetAmount, defCurrency)}"
)
.format("$savedAmount / $targetAmount")
views.setCharSequence(R.id.widgetDesc, "setText", widgetDesc)

// Calculate and display savings per day and week if applicable.
Expand Down Expand Up @@ -171,32 +197,41 @@ class GoalWidget : AppWidgetProvider() {
// Check if system locale is english to drop full stop in remaining days or weeks.
val localeEnglish = context.resources.configuration.locales[0].language == "en"

if (remainingAmount > 0f && goalItem.goal.deadline.isNotEmpty()) {
if (remainingAmount > 0f && goalItem.goal.deadline.isNotBlank()) {
val calculatedDays = GoalTextUtils.calcRemainingDays(goalItem.goal, datePattern)

if (calculatedDays.remainingDays > 2) {
val amountDays = "${
Utils.formatCurrency(
amount = Utils.roundDecimal(remainingAmount / calculatedDays.remainingDays),
currencyCode = defCurrency
)
}/${context.getString(R.string.goal_approx_saving_day)}".let {
// Calculate amount needed to save per day.
val calcPerDayAmount =
NumberUtils.roundDecimal(remainingAmount / calculatedDays.remainingDays)
// Build amount per day text by checking if the amount is greater than MAX_AMOUNT_DIGITS,
// if yes, then use prettyCount to format the amount.
val amountPerDayText = calcPerDayAmount.let {
if (it > MAX_AMOUNT_DIGITS) {
"${NumberUtils.getCurrencySymbol(defCurrency)}${NumberUtils.prettyCount(it)}"
} else NumberUtils.formatCurrency(it, defCurrency)
} + "/${context.getString(R.string.goal_approx_saving_day)}".let {
if (localeEnglish) it.dropLast(1) else it
}
views.setCharSequence(R.id.widgetAmountDay, "setText", amountDays)

views.setCharSequence(R.id.widgetAmountDay, "setText", amountPerDayText)
views.setViewVisibility(R.id.widgetAmountDay, View.VISIBLE)
}

if (calculatedDays.remainingDays > 7) {
val amountWeeks = "${
Utils.formatCurrency(
amount = Utils.roundDecimal(remainingAmount / (calculatedDays.remainingDays / 7)),
currencyCode = defCurrency
)
}/${context.getString(R.string.goal_approx_saving_week)}".let {
// Calculate amount needed to save per week.
val calcPerWeekAmount =
NumberUtils.roundDecimal(remainingAmount / (calculatedDays.remainingDays / 7))
// Build amount per week text by checking if the amount is greater than MAX_AMOUNT_DIGITS,
// if yes, then use prettyCount to format the amount.
val amountPerWeekText = calcPerWeekAmount.let {
if (it > MAX_AMOUNT_DIGITS) {
"${NumberUtils.getCurrencySymbol(defCurrency)}${NumberUtils.prettyCount(it)}"
} else NumberUtils.formatCurrency(it, defCurrency)
} + "/${context.getString(R.string.goal_approx_saving_week)}".let {
if (localeEnglish) it.dropLast(1) else it
}
views.setCharSequence(R.id.widgetAmountWeek, "setText", amountWeeks)
views.setCharSequence(R.id.widgetAmountWeek, "setText", amountPerWeekText)
views.setViewVisibility(R.id.widgetAmountWeek, View.VISIBLE)
}

Expand All @@ -221,7 +256,7 @@ class GoalWidget : AppWidgetProvider() {

private fun initialiseVm(context: Context) {
if (!this::viewModel.isInitialized) {
println("viewmodel not initialised")
Log.d("GoalWidget", "Initialising viewmodel")
viewModel = EntryPoints
.get(context.applicationContext, WidgetEntryPoint::class.java).getViewModel()
}
Expand Down

0 comments on commit dd66a91

Please sign in to comment.