From dd66a91b6baf9ef6f497bdbd4ceba0f90ac7cc4e Mon Sep 17 00:00:00 2001 From: starry-shivam Date: Wed, 22 May 2024 14:21:21 +0530 Subject: [PATCH] truncate large numbers using prettyCount & decouple number related utils to seperate object Signed-off-by: starry-shivam --- .../starry/greenstash/utils/NumberUtils.kt | 102 ++++++++++++++++++ .../java/com/starry/greenstash/utils/Utils.kt | 54 +++------- .../starry/greenstash/widget/GoalWidget.kt | 85 ++++++++++----- 3 files changed, 178 insertions(+), 63 deletions(-) create mode 100644 app/src/main/java/com/starry/greenstash/utils/NumberUtils.kt diff --git a/app/src/main/java/com/starry/greenstash/utils/NumberUtils.kt b/app/src/main/java/com/starry/greenstash/utils/NumberUtils.kt new file mode 100644 index 0000000..cfba596 --- /dev/null +++ b/app/src/main/java/com/starry/greenstash/utils/NumberUtils.kt @@ -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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/starry/greenstash/utils/Utils.kt b/app/src/main/java/com/starry/greenstash/utils/Utils.kt index 97eed3f..f5da3c8 100644 --- a/app/src/main/java/com/starry/greenstash/utils/Utils.kt +++ b/app/src/main/java/com/starry/greenstash/utils/Utils.kt @@ -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 @@ -58,21 +52,11 @@ 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. @@ -80,12 +64,11 @@ object Utils { * @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. @@ -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. diff --git a/app/src/main/java/com/starry/greenstash/widget/GoalWidget.kt b/app/src/main/java/com/starry/greenstash/widget/GoalWidget.kt index 1c24fb3..83fd5ef 100644 --- a/app/src/main/java/com/starry/greenstash/widget/GoalWidget.kt +++ b/app/src/main/java/com/starry/greenstash/widget/GoalWidget.kt @@ -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 @@ -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) @@ -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. @@ -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) } @@ -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() }