commit f663b00e3ebe396c026aafbfcc328c673f391aa5 Author: Thomas Andres Gomez Date: Tue Oct 21 11:26:53 2025 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c81e610 --- /dev/null +++ b/.gitignore @@ -0,0 +1,80 @@ +# Built application files +*.apk +*.aar +*.ap_ +*.aab +# Files for the ART/Dalvik VM +*.dex +# Java class files +*.class +# Generated files +bin/ +gen/ +out/ +# App Release Files +app/release/* + +# Uncomment the following line in case you need and you don't have the release build type files in your app +# release/ +# Gradle files +.gradle/ +build/ +# Local configuration file (sdk path, etc) +local.properties +# Proguard folder generated by Eclipse +proguard/ +# Log Files +*.log +# Android Studio Navigation editor temp files +.navigation/ +# Android Studio captures folder +captures/ +# IntelliJ +*.iml +.idea/ +# .idea/workspace.xml +# .idea/tasks.xml +# .idea/gradle.xml +# .idea/assetWizardSettings.xml +# .idea/dictionaries +.idea/libraries +# Android Studio 3 in .gitignore file. +.idea/caches +.idea/modules.xml +# Comment next line if keeping position of elements in Navigation Editor is relevant for you +.idea/navEditor.xml +# Keystore files +# Uncomment the following lines if you do not want to check your keystore files in. +*.jks +*.keystore +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild +.cxx/ +# Google Services (e.g. APIs or Firebase) +google-services.json +# Freeline +freeline.py +freeline/ +freeline_project_description.json +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md +# Version control +vcs.xml +# lint +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ +# lint/reports/ +# MacOS +.DS_Store +# App Specific cases +app/release/output.json +.idea/codeStyles/ + +.kotlin/sessions +app/release \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..f1bb559 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,79 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") + id("com.google.dagger.hilt.android") + id("com.google.devtools.ksp") +} + +android { + namespace = "com.pixelized.chocolate" + compileSdk { + version = release(36) + } + + defaultConfig { + applicationId = "com.pixelized.chocolate" + minSdk = 26 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + kotlin { + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + compilerOptions { + jvmTarget = JvmTarget.JVM_11 + freeCompilerArgs = listOf("-XXLanguage:+PropertyParamAnnotationDefaultTargetMode") + } + } + + buildFeatures { + compose = true + } +} + +dependencies { + // Android + implementation("androidx.core:core-ktx:1.17.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.4") + implementation("androidx.activity:activity-compose:1.11.0") + implementation("androidx.compose.ui:ui:1.9.3") + implementation("androidx.compose.ui:ui-graphics:1.9.3") + implementation("androidx.compose.ui:ui-tooling:1.9.3") + implementation("androidx.compose.ui:ui-tooling-preview:1.9.3") + + // Material + implementation("androidx.compose.material3:material3:1.4.0") + implementation("androidx.compose.material:material-icons-extended:1.7.8") + implementation("androidx.compose.material3:material3-window-size-class:1.4.0") + implementation("androidx.compose.material3.adaptive:adaptive-layout:1.1.0") + + // Navigation + implementation("androidx.navigation3:navigation3-runtime:1.0.0-alpha11") + implementation("androidx.navigation3:navigation3-ui:1.0.0-alpha11") + implementation("androidx.compose.material3.adaptive:adaptive-navigation3:1.0.0-SNAPSHOT") + implementation("androidx.lifecycle:lifecycle-viewmodel-navigation3:1.0.0-SNAPSHOT") + + // Injection + implementation("androidx.hilt:hilt-navigation-compose:1.3.0") + implementation("com.google.dagger:hilt-android:2.57.2") + ksp("com.google.dagger:hilt-compiler:2.57.2") +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/com/pixelized/chocolate/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/pixelized/chocolate/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..6398e59 --- /dev/null +++ b/app/src/androidTest/java/com/pixelized/chocolate/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.pixelized.chocolate + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.pixelized.chocolat", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..5c2a40d --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..62ef8f8 Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/com/pixelized/chocolate/ChocolateApplication.kt b/app/src/main/java/com/pixelized/chocolate/ChocolateApplication.kt new file mode 100644 index 0000000..5e9b807 --- /dev/null +++ b/app/src/main/java/com/pixelized/chocolate/ChocolateApplication.kt @@ -0,0 +1,7 @@ +package com.pixelized.chocolate + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class ChocolateApplication: Application() \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/chocolate/MainActivity.kt b/app/src/main/java/com/pixelized/chocolate/MainActivity.kt new file mode 100644 index 0000000..14a929c --- /dev/null +++ b/app/src/main/java/com/pixelized/chocolate/MainActivity.kt @@ -0,0 +1,22 @@ +package com.pixelized.chocolate + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import com.pixelized.chocolate.ui.screen.MainScreen +import com.pixelized.chocolate.ui.theme.ChocolatTheme +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + ChocolatTheme { + MainScreen() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/chocolate/ui/composable/textfield/CustomTextField.kt b/app/src/main/java/com/pixelized/chocolate/ui/composable/textfield/CustomTextField.kt new file mode 100644 index 0000000..3a1d540 --- /dev/null +++ b/app/src/main/java/com/pixelized/chocolate/ui/composable/textfield/CustomTextField.kt @@ -0,0 +1,85 @@ +package com.pixelized.chocolate.ui.composable.textfield + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldColors +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.VisualTransformation +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.flow.StateFlow + +@Stable +data class CustomTextFieldUio( + val id: String? = null, + val enableFlow: StateFlow, + val errorFlow: StateFlow, + val valueFlow: StateFlow, + val labelFlow: StateFlow, + val placeHolderFlow: StateFlow, + val onValueChange: (String) -> Unit, +) + +@Composable +fun CustomTextField( + modifier: Modifier = Modifier, + readOnly: Boolean = false, + textStyle: TextStyle = LocalTextStyle.current, + label: @Composable ((String) -> Unit)? = null, + placeholder: @Composable ((String) -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + prefix: @Composable (() -> Unit)? = null, + suffix: @Composable (() -> Unit)? = null, + supportingText: @Composable (() -> Unit)? = null, + visualTransformation: VisualTransformation = VisualTransformation.None, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + singleLine: Boolean = true, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, + minLines: Int = 1, + interactionSource: MutableInteractionSource? = null, + shape: Shape = TextFieldDefaults.shape, + colors: TextFieldColors = TextFieldDefaults.colors(), + field: CustomTextFieldUio, +) { + val enabledState = field.enableFlow.collectAsStateWithLifecycle() + val labelState = field.labelFlow.collectAsStateWithLifecycle() + val placeholderState = field.placeHolderFlow.collectAsStateWithLifecycle() + val valueState: State = field.valueFlow.collectAsStateWithLifecycle() + val errorState = field.errorFlow.collectAsStateWithLifecycle() + + TextField( + value = valueState.value, + onValueChange = { field.onValueChange(it) }, + modifier = modifier, + enabled = enabledState.value, + readOnly = readOnly, + textStyle = textStyle, + label = labelState.value?.let { label?.let { composable -> { composable(it) } } }, + placeholder = placeholderState.value?.let { placeholder?.let { composable -> { composable(it) } } }, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + prefix = prefix, + suffix = suffix, + supportingText = supportingText, + isError = errorState.value, + visualTransformation = visualTransformation, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + singleLine = singleLine, + maxLines = maxLines, + minLines = minLines, + interactionSource = interactionSource, + shape = shape, + colors = colors, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/chocolate/ui/composable/textfield/CustomTextFieldHelper.kt b/app/src/main/java/com/pixelized/chocolate/ui/composable/textfield/CustomTextFieldHelper.kt new file mode 100644 index 0000000..cc49857 --- /dev/null +++ b/app/src/main/java/com/pixelized/chocolate/ui/composable/textfield/CustomTextFieldHelper.kt @@ -0,0 +1,64 @@ +package com.pixelized.chocolate.ui.composable.textfield + +import androidx.compose.runtime.Stable +import kotlinx.coroutines.flow.MutableStateFlow + +@Stable +data class CustomTextFieldFlows( + val enableFlow: MutableStateFlow, + val placeHolderFlow: MutableStateFlow, + val labelFlow: MutableStateFlow, + val valueFlow: MutableStateFlow, + val errorFlow: MutableStateFlow, +) + +fun createCustomTextFieldFlows( + enable: Boolean = true, + label: String? = null, + placeHolder: String? = null, + error: Boolean = false, + value: String = "", +): CustomTextFieldFlows { + return createCustomTextFieldFlows( + enableFlow = MutableStateFlow(enable), + placeHolderFlow = MutableStateFlow(placeHolder), + labelFlow = MutableStateFlow(label), + valueFlow = MutableStateFlow(value), + errorFlow = MutableStateFlow(error), + ) +} + +fun createCustomTextFieldFlows( + enableFlow: MutableStateFlow = MutableStateFlow(true), + placeHolderFlow: MutableStateFlow = MutableStateFlow(null), + labelFlow: MutableStateFlow, + valueFlow: MutableStateFlow, + errorFlow: MutableStateFlow = MutableStateFlow(false), +): CustomTextFieldFlows { + return CustomTextFieldFlows( + enableFlow = enableFlow, + errorFlow = errorFlow, + placeHolderFlow = placeHolderFlow, + labelFlow = labelFlow, + valueFlow = valueFlow, + ) +} + +fun CustomTextFieldFlows.createCustomTextFieldUio( + id: String? = null, + checkForError: ((String) -> Boolean)? = null, + onValueChange: (String) -> Unit = { + errorFlow.value = checkForError?.invoke(it) ?: errorFlow.value + valueFlow.value = it + }, +): CustomTextFieldUio { + return CustomTextFieldUio( + id = id, + enableFlow = enableFlow, + errorFlow = errorFlow, + valueFlow = valueFlow, + labelFlow = labelFlow, + placeHolderFlow = placeHolderFlow, + onValueChange = onValueChange, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/chocolate/ui/composable/textfield/options/CurrencyMaskTransformation.kt b/app/src/main/java/com/pixelized/chocolate/ui/composable/textfield/options/CurrencyMaskTransformation.kt new file mode 100644 index 0000000..ea7a071 --- /dev/null +++ b/app/src/main/java/com/pixelized/chocolate/ui/composable/textfield/options/CurrencyMaskTransformation.kt @@ -0,0 +1,30 @@ +package com.pixelized.chocolate.ui.composable.textfield.options + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation + +class CurrencyMaskTransformation : VisualTransformation { + override fun filter(text: AnnotatedString): TransformedText { + + val newText = buildAnnotatedString { + append(text) + if (text.isNotEmpty()) { + append(" €") + } + } + val numberOffsetTranslator = object : OffsetMapping { + override fun originalToTransformed(offset: Int): Int { + return offset + } + + override fun transformedToOriginal(offset: Int): Int { + return offset.coerceIn(minimumValue = 0, maximumValue = text.length) + } + } + + return TransformedText(newText, numberOffsetTranslator) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/chocolate/ui/screen/MainScreen.kt b/app/src/main/java/com/pixelized/chocolate/ui/screen/MainScreen.kt new file mode 100644 index 0000000..b769f25 --- /dev/null +++ b/app/src/main/java/com/pixelized/chocolate/ui/screen/MainScreen.kt @@ -0,0 +1,450 @@ +package com.pixelized.chocolate.ui.screen + +import android.icu.text.DecimalFormat +import android.icu.text.DecimalFormatSymbols +import android.icu.text.NumberFormat +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.keepScreenOn +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.pixelized.chocolate.R +import com.pixelized.chocolate.ui.composable.textfield.CustomTextField +import com.pixelized.chocolate.ui.composable.textfield.CustomTextFieldUio +import com.pixelized.chocolate.ui.composable.textfield.createCustomTextFieldFlows +import com.pixelized.chocolate.ui.composable.textfield.createCustomTextFieldUio +import com.pixelized.chocolate.ui.composable.textfield.options.CurrencyMaskTransformation +import com.pixelized.chocolate.ui.theme.ChocolatTheme +import com.pixelized.chocolate.ui.utils.extention.calculate + +@Stable +data class MainScreenInputs( + val packageIS: CustomTextFieldUio, + val package2B: CustomTextFieldUio, + val packageFA: CustomTextFieldUio, + val expected: CustomTextFieldUio, +) + +@Stable +data class MainScreenResult( + val id: String, + val packageISInput: String, + val packageISValue: Int, + val package2BInput: String, + val package2BValue: Int, + val packageFAInput: String, + val packageFAValue: Int, + val result: Double, + val delta: Double, +) + +@Stable +object MainScreenDefault { + @Stable + val paddingValues: PaddingValues = PaddingValues(all = 16.dp) + + @Stable + val spacing: Dp = 16.dp + + @Stable + val visualTransformation: VisualTransformation = CurrencyMaskTransformation() + + @Stable + val formatter: NumberFormat = + DecimalFormat("###,###", DecimalFormatSymbols().apply { groupingSeparator = ' ' }) +} + +@Composable +fun MainScreen( + viewModel: MainViewModel = hiltViewModel(), +) { + val inputs = viewModel.inputs.collectAsStateWithLifecycle() + val results = viewModel.results.collectAsStateWithLifecycle() + val amount = viewModel.amount.collectAsStateWithLifecycle() + val progress = viewModel.progress.collectAsStateWithLifecycle() + val isRunning = viewModel.isRunning.collectAsStateWithLifecycle(initialValue = false) + + MainContent( + modifier = Modifier + .fillMaxSize() + .keepScreenOn() + .imePadding(), + loading = isRunning, + progress = progress, + amount = amount, + inputs = inputs, + results = results, + onCancelRequest = viewModel::cancelCompute, + onComputeRequest = viewModel::startCompute, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun MainContent( + modifier: Modifier = Modifier, + paddingValues: PaddingValues = MainScreenDefault.paddingValues, + spacing: Dp = MainScreenDefault.spacing, + visualTransformation: VisualTransformation = MainScreenDefault.visualTransformation, + formatter: NumberFormat = MainScreenDefault.formatter, + loading: State, + progress: State, + amount: State, + inputs: State, + results: State>, + onCancelRequest: () -> Unit, + onComputeRequest: () -> Unit, +) { + val (start, _, end, bottom) = paddingValues.calculate() + + val typography = MaterialTheme.typography + val packageStyleSpan = remember(typography) { + typography.bodySmall.toSpanStyle().copy(fontWeight = FontWeight.Light) + } + val amountStyleSpan = remember(typography) { + typography.bodyLarge.toSpanStyle() + } + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { + Text( + text = stringResource(R.string.app_name), + ) + }, + ) + } + ) { scaffoldPaddingValues -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues = scaffoldPaddingValues), + contentPadding = remember { PaddingValues(start = start, end = end, bottom = bottom) }, + verticalArrangement = Arrangement.spacedBy(space = spacing), + ) { + item( + key = "title", + ) { + Card { + Column( + verticalArrangement = Arrangement.spacedBy(space = spacing), + ) { + Column { + CustomTextField( + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Decimal, + imeAction = ImeAction.Next, + ), + visualTransformation = visualTransformation, + textStyle = MaterialTheme.typography.bodyLarge, + label = { label -> + Text(text = label) + }, + field = inputs.value.packageIS, + ) + CustomTextField( + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Decimal, + imeAction = ImeAction.Next, + ), + visualTransformation = visualTransformation, + textStyle = MaterialTheme.typography.bodyLarge, + label = { label -> + Text(text = label) + }, + field = inputs.value.package2B, + ) + CustomTextField( + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Decimal, + imeAction = ImeAction.Next, + ), + visualTransformation = visualTransformation, + textStyle = MaterialTheme.typography.bodyLarge, + label = { label -> + Text(text = label) + }, + field = inputs.value.packageFA, + ) + } + CustomTextField( + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Decimal, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions { onComputeRequest() }, + visualTransformation = visualTransformation, + label = { label -> + Text(text = label) + }, + textStyle = MaterialTheme.typography.headlineSmall, + field = inputs.value.expected, + ) + Row( + modifier = Modifier + .align(alignment = Alignment.End) + .padding(end = bottom / 2), + ) { + AnimatedVisibility( + visible = loading.value, + enter = fadeIn(), + exit = fadeOut(), + ) { + TextButton( + onClick = onCancelRequest, + ) { + Text( + text = stringResource(android.R.string.cancel) + ) + } + } + TextButton( + onClick = onComputeRequest, + ) { + Text( + text = stringResource(R.string.action_compute) + ) + } + } + } + } + } + + if (amount.value > 0) { + item( + key = "progress", + ) { + Card( + modifier = Modifier + .animateItem() + .fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(paddingValues = paddingValues), + verticalArrangement = Arrangement.spacedBy(spacing / 2) + ) { + Text( + style = MaterialTheme.typography.labelSmall, + text = remember(formatter, amount.value) { + derivedStateOf { + "Nombre de possibilité : ${formatter.format(progress.value * amount.value)} / ${ + formatter.format( + amount.value + ) + }" + } + }.value, + ) + Loading( + modifier = Modifier.fillMaxWidth(), + progress = progress, + ) + } + } + } + } + + items( + items = results.value, + key = { it.id }, + ) { + Card( + modifier = Modifier + .animateItem() + .fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(paddingValues = paddingValues), + ) { + Text( + color = MaterialTheme.colorScheme.onSurface, + text = buildAnnotatedString { + withStyle(packageStyleSpan) { + append("Forfait IS (") + append(it.packageISInput) + append(") : ") + } + withStyle(amountStyleSpan) { append("${it.packageISValue}") } + }, + ) + Text( + color = MaterialTheme.colorScheme.onSurface, + text = buildAnnotatedString { + withStyle(packageStyleSpan) { + append("Forfait 2B (") + append(it.package2BInput) + append(") : ") + } + withStyle(amountStyleSpan) { append("${it.package2BValue}") } + }, + ) + Text( + color = MaterialTheme.colorScheme.onSurface, + text = buildAnnotatedString { + withStyle(packageStyleSpan) { + append("Forfait IS (") + append(it.packageISInput) + append(") : ") + } + withStyle(amountStyleSpan) { append("${it.packageISValue}") } + }, + ) + Text( + color = MaterialTheme.colorScheme.onSurface, + text = buildAnnotatedString { + withStyle(packageStyleSpan) { append("Résultats : ") } + withStyle(amountStyleSpan) { append("${it.result}€") } + }, + ) + Text( + color = MaterialTheme.colorScheme.error, + text = buildAnnotatedString { + withStyle(packageStyleSpan) { append("Reste : ") } + withStyle(amountStyleSpan) { append("${it.delta}€") } + }, + ) + } + } + } + } + } +} + +@Composable +fun Loading( + modifier: Modifier = Modifier, + progress: State, +) { + val animatedProgress = animateFloatAsState( + targetValue = progress.value, + animationSpec = spring( + stiffness = Spring.StiffnessVeryLow, + ) + ) + LinearProgressIndicator( + modifier = modifier, + progress = { + animatedProgress.value + }, + ) +} + +@Composable +@Preview +private fun MainContentPreview() { + ChocolatTheme { + Surface { + val inputs = remember { + mutableStateOf( + MainScreenInputs( + packageIS = createCustomTextFieldFlows( + label = "Forfait IS", + value = "77.29", + ).createCustomTextFieldUio(), + package2B = createCustomTextFieldFlows( + label = "Forfait 2B", + value = "96.26", + ).createCustomTextFieldUio(), + packageFA = createCustomTextFieldFlows( + label = "Forfait FA", + value = "107.97", + ).createCustomTextFieldUio(), + expected = createCustomTextFieldFlows( + label = "Résultat attendu", + value = "842,75" + ).createCustomTextFieldUio(), + ) + ) + } + val results = remember { + mutableStateOf( + listOf( + MainScreenResult( + id = "0-1", + packageISInput = "77.29€", + packageISValue = 3, + package2BInput = "96.26€", + package2BValue = 80, + packageFAInput = "107.97€", + packageFAValue = 64, + result = 14841.52, + delta = 0.0, + ), + MainScreenResult( + id = "0-2", + packageISInput = "77.29", + packageISValue = 22, + package2BInput = "96.26", + package2BValue = 21, + packageFAInput = "107.97", + packageFAValue = 64, + result = 14841.52, + delta = 0.0, + ) + ) + ) + } + MainContent( + modifier = Modifier.fillMaxSize(), + loading = remember { mutableStateOf(true) }, + progress = remember { mutableFloatStateOf(0.75f) }, + amount = remember { mutableIntStateOf(4_128_270) }, + inputs = inputs, + results = results, + onCancelRequest = { }, + onComputeRequest = { }, + ) + } + } +} diff --git a/app/src/main/java/com/pixelized/chocolate/ui/screen/MainViewModel.kt b/app/src/main/java/com/pixelized/chocolate/ui/screen/MainViewModel.kt new file mode 100644 index 0000000..89cd2d7 --- /dev/null +++ b/app/src/main/java/com/pixelized/chocolate/ui/screen/MainViewModel.kt @@ -0,0 +1,227 @@ +package com.pixelized.chocolate.ui.screen + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.pixelized.chocolate.ui.composable.textfield.CustomTextFieldFlows +import com.pixelized.chocolate.ui.composable.textfield.createCustomTextFieldFlows +import com.pixelized.chocolate.ui.composable.textfield.createCustomTextFieldUio +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject +import kotlin.math.abs +import kotlin.math.pow + +@HiltViewModel +class MainViewModel @Inject constructor() : ViewModel() { + + private var computeJob: Job? = null + + private val textFieldStateFlow = MutableStateFlow(true) + val isRunning: Flow = textFieldStateFlow.map { it.not() } + + private val packageIS = createCustomTextFieldFlows( + enableFlow = textFieldStateFlow, + labelFlow = MutableStateFlow("Forfait IS"), + valueFlow = MutableStateFlow(PACKAGE_IS_DEFAULT), + ) + private val package2B = createCustomTextFieldFlows( + enableFlow = textFieldStateFlow, + labelFlow = MutableStateFlow("Forfait 2B"), + valueFlow = MutableStateFlow(PACKAGE_2B_DEFAULT), + ) + private val packageFA = createCustomTextFieldFlows( + enableFlow = textFieldStateFlow, + labelFlow = MutableStateFlow("Forfait FA"), + valueFlow = MutableStateFlow(PACKAGE_FA_DEFAULT), + ) + private val expected = createCustomTextFieldFlows( + enableFlow = textFieldStateFlow, + labelFlow = MutableStateFlow("Résultat attendu"), + valueFlow = MutableStateFlow(EXPECTED_DEFAULT), + ) + + val inputs: StateFlow = MutableStateFlow( + MainScreenInputs( + expected = expected.createCustomTextFieldUio( + checkForError = { it.isBlank() }, + ), + packageIS = packageIS.createCustomTextFieldUio( + checkForError = { it.isBlank() }, + ), + package2B = package2B.createCustomTextFieldUio( + checkForError = { it.isBlank() }, + ), + packageFA = packageFA.createCustomTextFieldUio( + checkForError = { it.isBlank() }, + ), + ) + ) + + private val _amount = MutableStateFlow(0) + val amount: StateFlow = _amount + + private val _progress = MutableStateFlow(0) + val progress: StateFlow = combine( + _progress, + _amount, + ) { progress, amount -> + (progress.toDouble() / amount.toDouble()).toFloat() + }.stateIn( + viewModelScope, + SharingStarted.Lazily, + 0f + ) + + private val _results = MutableStateFlow>(emptyList()) + val results: StateFlow> = _results + + fun startCompute( + precision: Double = 10.0.pow(DECIMALS), + ) { + val input = expected.value(precision) + if (input == null) { + expected.errorFlow.value = true + return + } + + val valueIS = packageIS.value(precision) + if (valueIS == null) { + packageIS.errorFlow.value = true + return + } + val maxIS = (input / valueIS) + 1 + + val value2B = package2B.value(precision) + if (value2B == null) { + package2B.errorFlow.value = true + return + } + val max2B = (input / value2B) + 1 + + val valueFA = packageFA.value(precision) + if (valueFA == null) { + packageFA.errorFlow.value = true + return + } + val maxFA = (input / valueFA) + 1 + val max = ((maxIS + 1) * (max2B + 1) * (maxFA + 1)).toDouble() + + computeJob?.cancel() + computeJob = viewModelScope.launch(Dispatchers.Default) { + var previousResult = 0 + + _progress.value = 0 + _amount.value = max.toInt() + textFieldStateFlow.value = false + + for (indexIS in 0..maxIS) { + if (isActive.not()) break + + for (index2B in 0..max2B) { + if (isActive.not()) break + + // skip the next values + if (input < valueIS * indexIS + value2B * index2B) { + _progress.value += ((max2B + 1) - index2B) * (maxFA + 1) + break + } + + for (indexFA in 0..maxFA) { + if (isActive.not()) break + + val currentResult = + valueIS * indexIS + value2B * index2B + valueFA * indexFA + val deltaCurrent = abs(input - currentResult) + val deltaPrevious = abs(input - previousResult) + + // skip the next values + if (deltaPrevious < deltaCurrent && input < currentResult) { + _progress.value += (maxFA + 1) - indexFA + break + } else { + _progress.value = _progress.value + 1 + } + + if (deltaCurrent < deltaPrevious) { + previousResult = currentResult + withContext(Dispatchers.Main) { + val delta = (input - previousResult) / precision + _results.value = listOf( + MainScreenResult( + id = "$delta-0", + packageISInput = inputs.value.packageIS.labelFlow.value ?: "", + packageISValue = indexIS, + package2BInput = inputs.value.package2B.labelFlow.value ?: "", + package2BValue = index2B, + packageFAInput = inputs.value.packageFA.labelFlow.value ?: "", + packageFAValue = indexFA, + result = previousResult / precision, + delta = delta, + ), + ) + } + } else if (deltaCurrent == deltaPrevious) { + withContext(Dispatchers.Main) { + _results.value = _results.value.toMutableList().also { list -> + val delta = (input - previousResult) / precision + list.add( + MainScreenResult( + id = "$delta-${list.size}", + packageISInput = inputs.value.packageIS.labelFlow.value ?: "", + packageISValue = indexIS, + package2BInput = inputs.value.package2B.labelFlow.value ?: "", + package2BValue = index2B, + packageFAInput = inputs.value.packageFA.labelFlow.value ?: "", + packageFAValue = indexFA, + result = previousResult / precision, + delta = delta, + ) + ) + } + } + } + } + + delay(1) + } + } + } + + viewModelScope.launch { + computeJob?.join() + textFieldStateFlow.value = true + computeJob = null + } + } + + fun cancelCompute() { + computeJob?.cancel() + computeJob = null + } + + private fun CustomTextFieldFlows.value( + precision: Double, + ): Int? { + return valueFlow.value.toDoubleOrNull()?.times(precision)?.toInt() + } + + companion object { + private const val DECIMALS = 2 + private const val PACKAGE_IS_DEFAULT = "77.29" + private const val PACKAGE_2B_DEFAULT = "96.26" + private const val PACKAGE_FA_DEFAULT = "107.97" + private const val EXPECTED_DEFAULT = "" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/chocolate/ui/theme/Color.kt b/app/src/main/java/com/pixelized/chocolate/ui/theme/Color.kt new file mode 100644 index 0000000..b012a1a --- /dev/null +++ b/app/src/main/java/com/pixelized/chocolate/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package com.pixelized.chocolate.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/chocolate/ui/theme/Theme.kt b/app/src/main/java/com/pixelized/chocolate/ui/theme/Theme.kt new file mode 100644 index 0000000..901d721 --- /dev/null +++ b/app/src/main/java/com/pixelized/chocolate/ui/theme/Theme.kt @@ -0,0 +1,57 @@ +package com.pixelized.chocolate.ui.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun ChocolatTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/chocolate/ui/theme/Type.kt b/app/src/main/java/com/pixelized/chocolate/ui/theme/Type.kt new file mode 100644 index 0000000..2dfe3cc --- /dev/null +++ b/app/src/main/java/com/pixelized/chocolate/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package com.pixelized.chocolate.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/chocolate/ui/utils/extention/PaddingValueEx+calculate.kt b/app/src/main/java/com/pixelized/chocolate/ui/utils/extention/PaddingValueEx+calculate.kt new file mode 100644 index 0000000..00dcef8 --- /dev/null +++ b/app/src/main/java/com/pixelized/chocolate/ui/utils/extention/PaddingValueEx+calculate.kt @@ -0,0 +1,46 @@ +package com.pixelized.chocolate.ui.utils.extention + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.Stable +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection + +@ReadOnlyComposable +@Composable +fun PaddingValues.calculate( + direction: LayoutDirection = LocalLayoutDirection.current, +): ComputedPaddingValue { + return ComputedPaddingValue( + start = calculateStartPadding(layoutDirection = direction), + top = calculateTopPadding(), + end = calculateEndPadding(layoutDirection = direction), + bottom = calculateBottomPadding(), + ) +} + +@Stable +@Immutable +data class ComputedPaddingValue( + @Stable + val start: Dp, + @Stable + val top: Dp, + @Stable + val end: Dp, + @Stable + val bottom: Dp, +) : PaddingValues { + override fun calculateLeftPadding(layoutDirection: LayoutDirection): Dp = start + + override fun calculateTopPadding(): Dp = top + + override fun calculateRightPadding(layoutDirection: LayoutDirection): Dp = end + + override fun calculateBottomPadding(): Dp = bottom +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_calculate_24px.xml b/app/src/main/res/drawable/ic_calculate_24px.xml new file mode 100644 index 0000000..00b0b88 --- /dev/null +++ b/app/src/main/res/drawable/ic_calculate_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..4a09b3b --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..7353dbd --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..7353dbd --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c4bd7ac Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..96588b3 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..29c08df Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..08e6ea5 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..f962e73 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..711ab04 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..c278e9e Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..fd2c778 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..e378448 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..71a7355 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml new file mode 100644 index 0000000..c6c80f1 --- /dev/null +++ b/app/src/main/res/values-fr/strings.xml @@ -0,0 +1,5 @@ + + Chocolat + + Calculer + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..daab8e9 --- /dev/null +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #7B3F00 + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..b47a147 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + Chocolate + + Compute + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..a35fcc8 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +