mirror of
https://github.com/Myzel394/NumberHub.git
synced 2025-06-19 16:55:26 +02:00
555 lines
20 KiB
Kotlin
555 lines
20 KiB
Kotlin
package com.sadellie.unitto.screens
|
|
|
|
import android.app.Application
|
|
import android.util.Log
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.mutableStateOf
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.lifecycle.ViewModel
|
|
import androidx.lifecycle.viewModelScope
|
|
import com.google.firebase.analytics.FirebaseAnalytics
|
|
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
|
import com.sadellie.unitto.data.KEY_0
|
|
import com.sadellie.unitto.data.KEY_DOT
|
|
import com.sadellie.unitto.data.KEY_MINUS
|
|
import com.sadellie.unitto.data.preferences.*
|
|
import com.sadellie.unitto.data.units.ALL_UNITS
|
|
import com.sadellie.unitto.data.units.AbstractUnit
|
|
import com.sadellie.unitto.data.units.MyUnitIDS
|
|
import com.sadellie.unitto.data.units.UnitGroup
|
|
import com.sadellie.unitto.data.units.collections.CURRENCY_COLLECTION
|
|
import com.sadellie.unitto.data.units.database.MyBasedUnit
|
|
import com.sadellie.unitto.data.units.database.MyBasedUnitsRepository
|
|
import com.sadellie.unitto.data.units.remote.CurrencyApi
|
|
import com.sadellie.unitto.data.units.remote.CurrencyUnitResponse
|
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.flow.first
|
|
import kotlinx.coroutines.launch
|
|
import kotlinx.coroutines.withContext
|
|
import java.math.BigDecimal
|
|
import javax.inject.Inject
|
|
|
|
|
|
@HiltViewModel
|
|
class MainViewModel @Inject constructor(
|
|
private val mySettingsPrefs: UserPreferences,
|
|
private val basedUnitRepository: MyBasedUnitsRepository,
|
|
private val application: Application
|
|
) : ViewModel() {
|
|
|
|
/**
|
|
* APP THEME
|
|
*/
|
|
val currentAppTheme =
|
|
mySettingsPrefs.getItem(UserPreferenceKeys.CURRENT_APP_THEME, AppTheme.AUTO)
|
|
|
|
fun saveCurrentAppTheme(value: Int) {
|
|
viewModelScope.launch {
|
|
mySettingsPrefs.saveItem(key = UserPreferenceKeys.CURRENT_APP_THEME, value)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* CONVERSION PRECISION
|
|
*/
|
|
var precision: Int by mutableStateOf(0)
|
|
private set
|
|
|
|
fun setPrecisionPref(value: Int) {
|
|
viewModelScope.launch {
|
|
precision = value
|
|
mySettingsPrefs.saveItem(UserPreferenceKeys.DIGITS_PRECISION, value)
|
|
convertValue()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* SEPARATOR
|
|
*/
|
|
var separator: Int by mutableStateOf(0)
|
|
private set
|
|
|
|
fun setSeparatorPref(value: Int) {
|
|
separator = value
|
|
viewModelScope.launch {
|
|
Formatter.setSeparator(value)
|
|
mySettingsPrefs.saveItem(UserPreferenceKeys.SEPARATOR, value)
|
|
convertValue()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* OUTPUT FORMAT
|
|
*/
|
|
var outputFormat: Int by mutableStateOf(0)
|
|
private set
|
|
|
|
/**
|
|
* Sets given output format and saves it in user preference store
|
|
* @param value [OutputFormat] to set
|
|
*/
|
|
fun setOutputFormatPref(value: Int) {
|
|
// Updating value in memory
|
|
outputFormat = value
|
|
// Updating value on disk
|
|
viewModelScope.launch {
|
|
mySettingsPrefs.saveItem(UserPreferenceKeys.OUTPUT_FORMAT, value)
|
|
convertValue()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* ANALYTICS
|
|
*/
|
|
var enableAnalytics: Boolean by mutableStateOf(false)
|
|
|
|
fun setAnalyticsPref(value: Boolean) {
|
|
enableAnalytics = value
|
|
viewModelScope.launch {
|
|
mySettingsPrefs.saveItem(UserPreferenceKeys.ENABLE_ANALYTICS, value)
|
|
FirebaseAnalytics.getInstance(application).setAnalyticsCollectionEnabled(enableAnalytics)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Unit we converting from (left side)
|
|
*/
|
|
var unitFrom: AbstractUnit by mutableStateOf(ALL_UNITS[0])
|
|
private set
|
|
|
|
/**
|
|
* Unit we are converting to (right side)
|
|
*/
|
|
var unitTo: AbstractUnit by mutableStateOf(ALL_UNITS[1])
|
|
private set
|
|
|
|
/**
|
|
* UI state
|
|
*/
|
|
var mainUIState: MainScreenUIState by mutableStateOf(MainScreenUIState())
|
|
private set
|
|
|
|
var favoritesOnly: Boolean by mutableStateOf(false)
|
|
private set
|
|
|
|
fun toggleFavoritesOnly() {
|
|
favoritesOnly = !favoritesOnly
|
|
}
|
|
|
|
// This is a grouped list of units that is used for unit selection screen
|
|
var unitsToShow: Map<UnitGroup, List<AbstractUnit>> by mutableStateOf(emptyMap())
|
|
private set
|
|
|
|
/**
|
|
* This function takes local variables, converts values and then causes the UI to update
|
|
*/
|
|
private fun convertValue() {
|
|
// We cannot convert values, as we are still user prefs from datastore (precision)
|
|
if (mainUIState.isLoadingDataStore) return
|
|
// Converting value using a specified precision
|
|
val convertedValue: BigDecimal =
|
|
unitFrom.convert(unitTo, mainUIState.inputValue.toBigDecimal(), precision)
|
|
// Setting result value using a specified OutputFormat
|
|
mainUIState = mainUIState.copy(
|
|
resultValue = when (outputFormat) {
|
|
OutputFormat.ALLOW_ENGINEERING -> convertedValue.toString()
|
|
OutputFormat.FORCE_ENGINEERING -> convertedValue.toEngineeringString()
|
|
else -> convertedValue.toPlainString()
|
|
}
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Change left side unit. Unit to convert from
|
|
*
|
|
* @param clickedUnit Unit we need to change to
|
|
*/
|
|
fun changeUnitFrom(clickedUnit: AbstractUnit) {
|
|
// First we change unit
|
|
unitFrom = clickedUnit
|
|
|
|
// Now we check for negate button
|
|
mainUIState = mainUIState.copy(negateButtonEnabled = clickedUnit.group.canNegate)
|
|
// Now we change to positive if the group we switched to supports negate
|
|
if (!clickedUnit.group.canNegate) {
|
|
mainUIState =
|
|
mainUIState.copy(inputValue = mainUIState.inputValue.removePrefix(KEY_MINUS))
|
|
}
|
|
|
|
// Now setting up right unit (pair for the left one)
|
|
unitTo = ALL_UNITS.first {
|
|
if (unitFrom.pairedUnit.isNullOrEmpty()) {
|
|
// No pair. Just getting unit from same group
|
|
it.group == unitFrom.group
|
|
} else {
|
|
// There is a paired unit
|
|
it.unitId == unitFrom.pairedUnit
|
|
}
|
|
}
|
|
|
|
viewModelScope.launch {
|
|
// We need to increment counter for the clicked unit
|
|
incrementCounter(clickedUnit)
|
|
// Currencies require us to get data from the internet
|
|
updateCurrenciesBasicUnits()
|
|
// We can't call outside of this block. It will set precision to 0 in that case
|
|
convertValue()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Change right side unit. Unit to convert to
|
|
*
|
|
* @param clickedUnit Unit we need to change to
|
|
*/
|
|
fun changeUnitTo(clickedUnit: AbstractUnit) {
|
|
// First we change unit
|
|
unitTo = clickedUnit
|
|
// Updating paired unit for left side unit in memory (same thing for database below)
|
|
unitFrom.pairedUnit = unitTo.unitId
|
|
|
|
viewModelScope.launch {
|
|
// Updating paired unit for left side unit in database
|
|
basedUnitRepository.insertUnits(
|
|
MyBasedUnit(
|
|
unitId = unitFrom.unitId,
|
|
isFavorite = unitFrom.isFavorite,
|
|
pairedUnitId = unitFrom.pairedUnit,
|
|
frequency = unitFrom.counter
|
|
)
|
|
)
|
|
// We also need to increment counter for the selected unit
|
|
incrementCounter(clickedUnit)
|
|
}
|
|
|
|
// Changed units, now we can convert
|
|
convertValue()
|
|
}
|
|
|
|
private suspend fun incrementCounter(unit: AbstractUnit) {
|
|
basedUnitRepository.insertUnits(
|
|
MyBasedUnit(
|
|
unitId = unit.unitId,
|
|
isFavorite = unit.isFavorite,
|
|
pairedUnitId = unit.pairedUnit,
|
|
// This will increment counter on unit in list too
|
|
frequency = ++unit.counter
|
|
)
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Updates basic units properties for all currencies. Uses [unitFrom]
|
|
*/
|
|
private suspend fun updateCurrenciesBasicUnits() {
|
|
// Resetting error and network loading states in case we are not gonna do anything below
|
|
mainUIState = mainUIState.copy(isLoadingNetwork = false, showError = false)
|
|
// We update currencies only when needed
|
|
if (unitFrom.group != UnitGroup.CURRENCY) return
|
|
|
|
// Starting to load stuff
|
|
mainUIState = mainUIState.copy(isLoadingNetwork = true)
|
|
|
|
try {
|
|
val pairs: CurrencyUnitResponse =
|
|
CurrencyApi.retrofitService.getCurrencyPairs(unitFrom.unitId)
|
|
CURRENCY_COLLECTION.forEach {
|
|
// Getting rates from map. We set ZERO as default so that it can be skipped
|
|
val rate = pairs.currency.getOrElse(it.unitId) { BigDecimal.ZERO }
|
|
// We make sure that we don't divide by zero
|
|
if (rate > BigDecimal.ZERO) {
|
|
it.isEnabled = true
|
|
it.basicUnit = BigDecimal.ONE.setScale(MAX_PRECISION).div(rate)
|
|
} else {
|
|
// Hiding broken currencies
|
|
it.isEnabled = false
|
|
}
|
|
}
|
|
} catch (e: Exception) {
|
|
when (e) {
|
|
// 403, Network and Adapter exceptions can be ignored
|
|
is retrofit2.HttpException, is java.net.UnknownHostException, is com.squareup.moshi.JsonDataException -> {}
|
|
else -> {
|
|
// Unexpected exception, should report it
|
|
FirebaseCrashlytics.getInstance().recordException(e)
|
|
}
|
|
}
|
|
mainUIState = mainUIState.copy(showError = true)
|
|
} finally {
|
|
// Loaded
|
|
mainUIState = mainUIState.copy(isLoadingNetwork = false)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Swaps measurement, left to right and vice versa
|
|
*/
|
|
fun swapUnits() {
|
|
unitFrom = unitTo.also {
|
|
unitTo = unitFrom
|
|
}
|
|
viewModelScope.launch { updateCurrenciesBasicUnits() }
|
|
// Swapped, can convert now
|
|
convertValue()
|
|
}
|
|
|
|
/**
|
|
* Function to process input when we click keyboard. Make sure that digits/symbols will be
|
|
* added properly
|
|
* @param[digitToAdd] Digit/Symbol we want to add, can be any digit 0..9 or a dot symbol
|
|
*/
|
|
fun processInput(digitToAdd: String) {
|
|
when (digitToAdd) {
|
|
KEY_DOT -> {
|
|
// Here we add a dot to input
|
|
// Disabling dot button to avoid multiple dots in input value
|
|
// Enabling delete button to so that we can delete this dot from input
|
|
mainUIState = mainUIState.copy(
|
|
inputValue = mainUIState.inputValue + digitToAdd,
|
|
dotButtonEnabled = false,
|
|
deleteButtonEnabled = true
|
|
)
|
|
}
|
|
KEY_0 -> {
|
|
// We shouldn't add zero to another zero in input, i.e. 00
|
|
if (mainUIState.inputValue != KEY_0) {
|
|
mainUIState = mainUIState.copy(inputValue = mainUIState.inputValue + digitToAdd)
|
|
}
|
|
}
|
|
else -> {
|
|
/*
|
|
We want to add digit to input.
|
|
When there is just a zero, we should replace it with the digit we want to add,
|
|
avoids input to be like 03 (with this check it will be just 3)
|
|
*/
|
|
mainUIState = mainUIState.copy(
|
|
inputValue = if (mainUIState.inputValue == KEY_0) digitToAdd else mainUIState.inputValue + digitToAdd,
|
|
deleteButtonEnabled = true
|
|
)
|
|
}
|
|
}
|
|
convertValue()
|
|
}
|
|
|
|
/**
|
|
* Deletes last symbol from input and handles buttons state (enabled/disabled)
|
|
*/
|
|
fun deleteDigit() {
|
|
// Last symbol is a dot
|
|
// We enable DOT button
|
|
if (mainUIState.inputValue.endsWith(KEY_DOT)) {
|
|
mainUIState = mainUIState.copy(dotButtonEnabled = true)
|
|
}
|
|
|
|
// Deleting last symbol
|
|
mainUIState = mainUIState.copy(inputValue = mainUIState.inputValue.dropLast(1))
|
|
|
|
/*
|
|
Now we check what we have left
|
|
We deleted last symbol and we got Empty string, just minus symbol, or zero
|
|
Do not allow deleting anything beyond this (disable button)
|
|
Set input to default (zero)
|
|
Skipping this block means that we are left we acceptable value, i.e. 123.03
|
|
*/
|
|
if (
|
|
mainUIState.inputValue in listOf(String(), KEY_MINUS, KEY_0)
|
|
) {
|
|
mainUIState = mainUIState.copy(deleteButtonEnabled = false, inputValue = KEY_0)
|
|
}
|
|
|
|
// We are sure that input has acceptable value, so we convert it
|
|
convertValue()
|
|
}
|
|
|
|
/**
|
|
* Clears input value and sets it to default (ZERO)
|
|
*/
|
|
fun clearInput() {
|
|
mainUIState = mainUIState.copy(
|
|
inputValue = KEY_0,
|
|
deleteButtonEnabled = false,
|
|
dotButtonEnabled = true
|
|
)
|
|
convertValue()
|
|
}
|
|
|
|
/**
|
|
* Changes input from positive to negative and vice versa
|
|
*/
|
|
fun negateInput() {
|
|
mainUIState = mainUIState.copy(
|
|
inputValue = if (mainUIState.inputValue.getOrNull(0) != KEY_MINUS.single()) {
|
|
// If input doesn't have minus at the beginning, we give it to it
|
|
KEY_MINUS + mainUIState.inputValue
|
|
} else {
|
|
// Input has minus, meaning we need to remove it
|
|
mainUIState.inputValue.removePrefix(KEY_MINUS)
|
|
}
|
|
)
|
|
convertValue()
|
|
}
|
|
|
|
/**
|
|
* Add or remove from favorites (changes to the opposite of current state)
|
|
*/
|
|
fun favoriteUnit(unit: AbstractUnit) {
|
|
viewModelScope.launch {
|
|
// Changing unit in list to the opposite
|
|
unit.isFavorite = !unit.isFavorite
|
|
// Updating it in database
|
|
basedUnitRepository.insertUnits(
|
|
MyBasedUnit(
|
|
unitId = unit.unitId,
|
|
isFavorite = unit.isFavorite,
|
|
pairedUnitId = unit.pairedUnit,
|
|
frequency = unit.counter
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Saves latest pair of units into datastore
|
|
*/
|
|
fun saveMe() {
|
|
viewModelScope.launch {
|
|
mySettingsPrefs.saveItem(UserPreferenceKeys.LATEST_LEFT_SIDE, unitFrom.unitId)
|
|
mySettingsPrefs.saveItem(UserPreferenceKeys.LATEST_RIGHT_SIDE, unitTo.unitId)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Filters and groups [ALL_UNITS] in coroutine
|
|
*
|
|
* @param query String search query
|
|
* @param chosenUnitGroup Currently selected [UnitGroup] (from chips list)
|
|
* @param leftSide Decide whether or not we are on left side. Need it because right side requires
|
|
* us to mark disabled currency units
|
|
*/
|
|
fun loadUnitsToShow(
|
|
query: String,
|
|
chosenUnitGroup: UnitGroup?,
|
|
leftSide: Boolean
|
|
) {
|
|
viewModelScope.launch {
|
|
val filterGroup: Boolean = chosenUnitGroup != null
|
|
|
|
// This is mostly not UI related stuff and viewModelScope.launch uses Dispatchers.Main
|
|
// So we switch to Default
|
|
withContext(Dispatchers.Default) {
|
|
// Basic filtering
|
|
var basicFilteredUnits = ALL_UNITS.asSequence()
|
|
basicFilteredUnits = when {
|
|
// Both sides, Chip is selected, Only favorites
|
|
(filterGroup) and (favoritesOnly) -> {
|
|
basicFilteredUnits.filter { (it.group == chosenUnitGroup) and it.isFavorite }
|
|
}
|
|
// Both sides, Chip is selected, NOT Only favorites
|
|
(filterGroup) and (!favoritesOnly) -> {
|
|
basicFilteredUnits.filter { it.group == chosenUnitGroup }
|
|
}
|
|
// Chip is NOT selected, Only favorites
|
|
(!filterGroup) and (favoritesOnly) -> {
|
|
basicFilteredUnits.filter { it.isFavorite }
|
|
}
|
|
// Chip is NOT selected, NOT Only favorites
|
|
else -> basicFilteredUnits
|
|
}
|
|
|
|
// Hiding broken currency units
|
|
if (leftSide) {
|
|
basicFilteredUnits = basicFilteredUnits.filter { it.isEnabled }
|
|
}
|
|
|
|
unitsToShow = if (query.isEmpty()) {
|
|
// Query is empty, i.e. we want to see all units and they need to be sorted by usage
|
|
basicFilteredUnits
|
|
.sortedByDescending { it.counter }
|
|
} else {
|
|
// We are searching for a specific unit, we don't care about popularity
|
|
// We need search accuracy
|
|
basicFilteredUnits.sortByLev(query)
|
|
}
|
|
// Group by unit group
|
|
.groupBy { it.group }
|
|
}
|
|
}
|
|
}
|
|
|
|
init {
|
|
viewModelScope.launch {
|
|
val latestLeftSideUnitId = mySettingsPrefs.getItem(
|
|
UserPreferenceKeys.LATEST_LEFT_SIDE,
|
|
MyUnitIDS.kilometer
|
|
).first()
|
|
|
|
val latestRightSideUnitId = mySettingsPrefs.getItem(
|
|
UserPreferenceKeys.LATEST_RIGHT_SIDE,
|
|
MyUnitIDS.mile
|
|
).first()
|
|
|
|
// First we load latest pair of units
|
|
unitFrom = try {
|
|
ALL_UNITS.first { it.unitId == latestLeftSideUnitId }
|
|
} catch (e: java.util.NoSuchElementException) {
|
|
Log.w("MainViewModel", "No unit with the given unitId")
|
|
ALL_UNITS.first { it.unitId == MyUnitIDS.kilometer }
|
|
}
|
|
|
|
unitTo = try {
|
|
ALL_UNITS
|
|
.first { it.unitId == latestRightSideUnitId }
|
|
} catch (e: java.util.NoSuchElementException) {
|
|
Log.w("MainViewModel", "No unit with the given unitId")
|
|
ALL_UNITS.first { it.unitId == MyUnitIDS.mile }
|
|
}
|
|
|
|
// Now we get the precision so we can convert values
|
|
precision = mySettingsPrefs.getItem(UserPreferenceKeys.DIGITS_PRECISION, 3).first()
|
|
// Getting separator and changing it in number formatter
|
|
separator =
|
|
mySettingsPrefs
|
|
.getItem(UserPreferenceKeys.SEPARATOR, Separator.SPACES).first()
|
|
.also { Formatter.setSeparator(it) }
|
|
// Getting output format
|
|
outputFormat =
|
|
mySettingsPrefs
|
|
.getItem(UserPreferenceKeys.OUTPUT_FORMAT, OutputFormat.PLAIN)
|
|
.first()
|
|
|
|
convertValue()
|
|
|
|
val allBasedUnits = basedUnitRepository.getAll()
|
|
|
|
ALL_UNITS.forEach {
|
|
// Loading unit names so that we can search through them
|
|
it.renderedName = application.getString(it.displayName)
|
|
val based = allBasedUnits.firstOrNull { based -> based.unitId == it.unitId }
|
|
// Loading paired units
|
|
it.pairedUnit = based?.pairedUnitId
|
|
// Loading favorite state
|
|
it.isFavorite = based?.isFavorite ?: false
|
|
it.counter = based?.frequency ?: 0
|
|
}
|
|
|
|
// User is free to convert values
|
|
// Set negate button state according to current group
|
|
mainUIState = mainUIState.copy(
|
|
isLoadingDataStore = false,
|
|
negateButtonEnabled = unitFrom.group.canNegate
|
|
)
|
|
|
|
/*
|
|
* This is at the bottom in case latest unit group was currency and user doesn't have
|
|
* network access.
|
|
* He can choose another unit group and doesn't need to wait for network to appear.
|
|
* */
|
|
updateCurrenciesBasicUnits()
|
|
|
|
enableAnalytics = mySettingsPrefs.getItem(UserPreferenceKeys.ENABLE_ANALYTICS, true).first()
|
|
FirebaseAnalytics.getInstance(application).setAnalyticsCollectionEnabled(enableAnalytics)
|
|
}
|
|
}
|
|
}
|