diff --git a/.github/workflows/build_artifact_and_release_firebase.yml b/.github/workflows/build_artifact_and_release_firebase.yml index 6807b7d..93f0eb4 100644 --- a/.github/workflows/build_artifact_and_release_firebase.yml +++ b/.github/workflows/build_artifact_and_release_firebase.yml @@ -23,6 +23,11 @@ jobs: java-version: '17' cache: 'gradle' + - name: Update Secrets + env: + MAPS_API_KEY: ${{ secrets.MAPS_API_KEY }} + run: echo 'MAPS_API_KEY=${{ secrets.MAPS_API_KEY }}' > ./local.properties + - name: Build the Release AAB run: ./gradlew bundleRelease env: diff --git a/.github/workflows/pull_request_unit_test.yml b/.github/workflows/pull_request_unit_test.yml index 9780570..ae38c7d 100644 --- a/.github/workflows/pull_request_unit_test.yml +++ b/.github/workflows/pull_request_unit_test.yml @@ -17,5 +17,9 @@ jobs: java-version: '17' cache: 'gradle' + - name: Update Secrets + env: + MAPS_API_KEY: ${{ secrets.MAPS_API_KEY }} + run: echo 'MAPS_API_KEY=${{ secrets.MAPS_API_KEY }}' > ./local.properties - name: Unit tests run: ./gradlew testQa diff --git a/app/build.gradle b/app/build.gradle index fcd1cfe..9264847 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -5,10 +5,11 @@ plugins { id 'kotlin-kapt' id 'com.google.gms.google-services' id 'com.google.firebase.crashlytics' + id 'com.google.android.libraries.mapsplatform.secrets-gradle-plugin' } android { - compileSdk 33 + compileSdk 34 namespace 'br.com.tick.teira' defaultConfig { @@ -101,6 +102,7 @@ dependencies { implementation "androidx.hilt:hilt-work:$hiltWorkVersion" implementation "androidx.work:work-runtime-ktx:$workManagerVersion" + kapt "androidx.hilt:hilt-compiler:$hiltCompilerVersion" kapt "com.google.dagger:hilt-compiler:$hiltVersion" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9c1c41a..2927f81 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -7,12 +7,13 @@ android:allowBackup="true" android:dataExtractionRules="@xml/data_extraction_rules" android:fullBackupContent="@xml/backup_rules" - android:icon="@mipmap/ic_launcher" + android:icon="@mipmap/teira_launcher" android:label="@string/app_name" - android:roundIcon="@mipmap/ic_launcher_round" + android:roundIcon="@mipmap/teira_launcher" android:supportsRtl="true" android:theme="@style/Theme.Teira" tools:targetApi="31"> + - - \ No newline at end of file + + + + + + + + + diff --git a/app/src/main/res/drawable/teira_launcher_background.xml b/app/src/main/res/drawable/teira_launcher_background.xml new file mode 100644 index 0000000..ca3826a --- /dev/null +++ b/app/src/main/res/drawable/teira_launcher_background.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml deleted file mode 100644 index eca70cf..0000000 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ 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 deleted file mode 100644 index eca70cf..0000000 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/teira_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/teira_launcher.xml new file mode 100644 index 0000000..aeb4c0d --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/teira_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/teira_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/teira_launcher_round.xml new file mode 100644 index 0000000..aeb4c0d --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/teira_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 deleted file mode 100644 index c209e78..0000000 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.webp and /dev/null 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 deleted file mode 100644 index b2dfe3d..0000000 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-hdpi/teira_launcher.webp b/app/src/main/res/mipmap-hdpi/teira_launcher.webp new file mode 100644 index 0000000..6959ebb Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/teira_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/teira_launcher_foreground.webp b/app/src/main/res/mipmap-hdpi/teira_launcher_foreground.webp new file mode 100644 index 0000000..9ae94db Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/teira_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-hdpi/teira_launcher_round.webp b/app/src/main/res/mipmap-hdpi/teira_launcher_round.webp new file mode 100644 index 0000000..6c54dee Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/teira_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 deleted file mode 100644 index 4f0f1d6..0000000 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.webp and /dev/null 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 deleted file mode 100644 index 62b611d..0000000 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-mdpi/teira_launcher.webp b/app/src/main/res/mipmap-mdpi/teira_launcher.webp new file mode 100644 index 0000000..ff400ec Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/teira_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/teira_launcher_foreground.webp b/app/src/main/res/mipmap-mdpi/teira_launcher_foreground.webp new file mode 100644 index 0000000..68bc38c Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/teira_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-mdpi/teira_launcher_round.webp b/app/src/main/res/mipmap-mdpi/teira_launcher_round.webp new file mode 100644 index 0000000..84bb08f Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/teira_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 deleted file mode 100644 index 948a307..0000000 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp and /dev/null 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 deleted file mode 100644 index 1b9a695..0000000 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-xhdpi/teira_launcher.webp b/app/src/main/res/mipmap-xhdpi/teira_launcher.webp new file mode 100644 index 0000000..a1520bc Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/teira_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/teira_launcher_foreground.webp b/app/src/main/res/mipmap-xhdpi/teira_launcher_foreground.webp new file mode 100644 index 0000000..59af75b Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/teira_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/teira_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/teira_launcher_round.webp new file mode 100644 index 0000000..715e6ff Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/teira_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 deleted file mode 100644 index 28d4b77..0000000 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp and /dev/null 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 deleted file mode 100644 index 9287f50..0000000 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-xxhdpi/teira_launcher.webp b/app/src/main/res/mipmap-xxhdpi/teira_launcher.webp new file mode 100644 index 0000000..7880371 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/teira_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/teira_launcher_foreground.webp b/app/src/main/res/mipmap-xxhdpi/teira_launcher_foreground.webp new file mode 100644 index 0000000..b10cc90 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/teira_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/teira_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/teira_launcher_round.webp new file mode 100644 index 0000000..98add3a Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/teira_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 deleted file mode 100644 index aa7d642..0000000 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and /dev/null 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 deleted file mode 100644 index 9126ae3..0000000 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-xxxhdpi/teira_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/teira_launcher.webp new file mode 100644 index 0000000..fc29b2c Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/teira_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/teira_launcher_foreground.webp b/app/src/main/res/mipmap-xxxhdpi/teira_launcher_foreground.webp new file mode 100644 index 0000000..689cde8 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/teira_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/teira_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/teira_launcher_round.webp new file mode 100644 index 0000000..e04235e Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/teira_launcher_round.webp differ diff --git a/app/src/main/teira_launcher-playstore.png b/app/src/main/teira_launcher-playstore.png new file mode 100644 index 0000000..090e341 Binary files /dev/null and b/app/src/main/teira_launcher-playstore.png differ diff --git a/build.gradle b/build.gradle index 015308e..87a3b1b 100644 --- a/build.gradle +++ b/build.gradle @@ -5,6 +5,7 @@ buildscript { composeMaterialVersion = '1.2.0-beta03' composeFoundation = '1.4.0-alpha03' composeUi = '1.4.0-alpha03' + composeUiUtil = '1.5.0' composeMaterial3Version = '1.1.0-rc01' coroutinesVersion = '1.6.4' coreKtxVersion = '1.9.0' @@ -22,7 +23,7 @@ buildscript { hiltNavigationComposeVersion = '1.0.0' roomVersion = '2.4.3' dataStoreVersion = '1.0.0' - navigationVersion = '2.5.3' + navigationVersion = '2.7.0' vicoVersion = '1.6.4' composeColorPicker = '0.7.0' firebaseCrashlytics = '18.3.5' @@ -31,6 +32,10 @@ buildscript { hiltWorkVersion = '1.0.0' hiltCompilerVersion = '1.0.0' acompanistPermissionVersion = '0.30.0' + googleMaps = '2.14.0' + playServicesMap = '18.1.0' + playServicesLocation = '21.0.1' + coilVersion = '2.4.0' dependencies { classpath("com.google.dagger:hilt-android-gradle-plugin:$hiltVersion") @@ -47,6 +52,7 @@ plugins { id 'com.android.application' version '7.4.2' apply false id 'com.android.library' version '7.4.2' apply false id 'org.jetbrains.kotlin.android' version '1.8.0' apply false + id 'com.google.android.libraries.mapsplatform.secrets-gradle-plugin' version '2.0.1' apply false } task clean(type: Delete) { diff --git a/sdk/build.gradle b/sdk/build.gradle index 64310f1..29d9406 100644 --- a/sdk/build.gradle +++ b/sdk/build.gradle @@ -6,7 +6,7 @@ plugins { } android { - compileSdk 33 + compileSdk 34 namespace 'br.com.tick.sdk' defaultConfig { @@ -48,6 +48,7 @@ dependencies { kapt "com.google.dagger:hilt-compiler:$hiltVersion" implementation "androidx.datastore:datastore-preferences:$dataStoreVersion" + implementation "com.google.android.gms:play-services-maps:$playServicesMap" // Unit Tests dependencies testImplementation "junit:junit:$jUnitVersion" diff --git a/sdk/src/main/java/br/com/tick/sdk/database/ExpenseDao.kt b/sdk/src/main/java/br/com/tick/sdk/database/ExpenseDao.kt index eb70892..4722520 100644 --- a/sdk/src/main/java/br/com/tick/sdk/database/ExpenseDao.kt +++ b/sdk/src/main/java/br/com/tick/sdk/database/ExpenseDao.kt @@ -3,6 +3,7 @@ package br.com.tick.sdk.database import androidx.room.Dao import androidx.room.Insert import androidx.room.Query +import androidx.room.Update import br.com.tick.sdk.database.entities.Expense import kotlinx.coroutines.flow.Flow @@ -12,6 +13,9 @@ interface ExpenseDao { @Insert suspend fun addExpense(expense: Expense) + @Update + suspend fun updateExpense(expense: Expense) + @Query("DELETE FROM expense WHERE expense_id = :expenseId") suspend fun removeExpenseById(expenseId: Int) @@ -20,4 +24,7 @@ interface ExpenseDao { @Query("SELECT * FROM expense") fun getAllExpenses(): Flow> + + @Query("SELECT * FROM expense WHERE expense_id = :expenseId") + fun getExpense(expenseId: Int): Flow } diff --git a/sdk/src/main/java/br/com/tick/sdk/database/UserDao.kt b/sdk/src/main/java/br/com/tick/sdk/database/UserDao.kt index da0022f..a5b3523 100644 --- a/sdk/src/main/java/br/com/tick/sdk/database/UserDao.kt +++ b/sdk/src/main/java/br/com/tick/sdk/database/UserDao.kt @@ -14,7 +14,7 @@ import kotlinx.coroutines.flow.Flow interface UserDao { @Query("SELECT * FROM user WHERE user_id = 1") - fun getUniqueUser(): Flow + fun getUniqueUser(): Flow @Insert(onConflict = OnConflictStrategy.ABORT) fun setInitialUser(user: User) diff --git a/sdk/src/main/java/br/com/tick/sdk/database/converters/LatLngConverter.kt b/sdk/src/main/java/br/com/tick/sdk/database/converters/LatLngConverter.kt new file mode 100644 index 0000000..7bc17e8 --- /dev/null +++ b/sdk/src/main/java/br/com/tick/sdk/database/converters/LatLngConverter.kt @@ -0,0 +1,26 @@ +package br.com.tick.sdk.database.converters + +import androidx.room.TypeConverter +import com.google.android.gms.maps.model.LatLng + +class LatLngConverter { + + companion object { + const val LAT_LNG_SPLITTER = "|" + } + + @TypeConverter + fun toString(location: LatLng?): String? { + if (location == null) return null + + return "${location.latitude}$LAT_LNG_SPLITTER${location.longitude}" + } + + @TypeConverter + fun fromString(location: String?): LatLng? { + if (location == null) return null + + val locationFromString = location.split(LAT_LNG_SPLITTER) + return LatLng(locationFromString[0].toDouble(), locationFromString[1].toDouble()) + } +} diff --git a/sdk/src/main/java/br/com/tick/sdk/database/converters/LocalDateConverter.kt b/sdk/src/main/java/br/com/tick/sdk/database/converters/LocalDateConverter.kt index 8dfba85..7b08eeb 100644 --- a/sdk/src/main/java/br/com/tick/sdk/database/converters/LocalDateConverter.kt +++ b/sdk/src/main/java/br/com/tick/sdk/database/converters/LocalDateConverter.kt @@ -14,4 +14,4 @@ class LocalDateConverter { fun fromLong(value: Long): LocalDate { return LocalDate.ofEpochDay(value) } -} \ No newline at end of file +} diff --git a/sdk/src/main/java/br/com/tick/sdk/database/entities/Expense.kt b/sdk/src/main/java/br/com/tick/sdk/database/entities/Expense.kt index b576ba4..2fd59a3 100644 --- a/sdk/src/main/java/br/com/tick/sdk/database/entities/Expense.kt +++ b/sdk/src/main/java/br/com/tick/sdk/database/entities/Expense.kt @@ -4,11 +4,13 @@ import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey import androidx.room.TypeConverters +import br.com.tick.sdk.database.converters.LatLngConverter import br.com.tick.sdk.database.converters.LocalDateConverter +import com.google.android.gms.maps.model.LatLng import java.time.LocalDate @Entity -@TypeConverters(LocalDateConverter::class) +@TypeConverters(LocalDateConverter::class, LatLngConverter::class) data class Expense( @PrimaryKey(autoGenerate = true) @@ -25,5 +27,12 @@ data class Expense( val value: Double, @ColumnInfo(name = "date") - val date: LocalDate + val date: LocalDate, + + @ColumnInfo(name = "location") + val location: LatLng?, + + @ColumnInfo(name = "photoUri") + val photoUri: String? + ) diff --git a/sdk/src/main/java/br/com/tick/sdk/domain/CategorizedExpense.kt b/sdk/src/main/java/br/com/tick/sdk/domain/CategorizedExpense.kt index 02d9eb9..7c093c1 100644 --- a/sdk/src/main/java/br/com/tick/sdk/domain/CategorizedExpense.kt +++ b/sdk/src/main/java/br/com/tick/sdk/domain/CategorizedExpense.kt @@ -1,5 +1,7 @@ package br.com.tick.sdk.domain +import android.net.Uri +import com.google.android.gms.maps.model.LatLng import java.time.LocalDate data class CategorizedExpense( @@ -7,5 +9,7 @@ data class CategorizedExpense( val name: String, val expenseValue: Double, val date: LocalDate, - val category: ExpenseCategory + val category: ExpenseCategory, + val location: LatLng?, + val picture: Uri? ) diff --git a/sdk/src/main/java/br/com/tick/sdk/domain/ExpenseRisk.kt b/sdk/src/main/java/br/com/tick/sdk/domain/ExpenseRisk.kt index 217f72f..17e06ea 100644 --- a/sdk/src/main/java/br/com/tick/sdk/domain/ExpenseRisk.kt +++ b/sdk/src/main/java/br/com/tick/sdk/domain/ExpenseRisk.kt @@ -1,8 +1,5 @@ package br.com.tick.sdk.domain -/** - * This class holds business logic to determine weather an expense value is categorized as certain threshold. - */ enum class ExpenseRisk(private val percentageThreshold: Double) { HIGHEST(20.0), HIGH(10.0), @@ -12,7 +9,7 @@ enum class ExpenseRisk(private val percentageThreshold: Double) { companion object { fun getRiskFromValue(monthlyIncome: Double, expenseValue: Double): ExpenseRisk { - require(expenseValue > 0) { "There's no way to calculate risks for a negative value. What is that anyway?" } + require(expenseValue >= 0) { "There's no way to calculate risks for a negative value. What is that anyway?" } return when ((expenseValue * 100) / monthlyIncome) { in 0.0..LOWEST.percentageThreshold -> LOWEST diff --git a/sdk/src/main/java/br/com/tick/sdk/repositories/categorizedexpense/CategorizedExpenseRepository.kt b/sdk/src/main/java/br/com/tick/sdk/repositories/categorizedexpense/CategorizedExpenseRepository.kt index a56f300..fc6b9b1 100644 --- a/sdk/src/main/java/br/com/tick/sdk/repositories/categorizedexpense/CategorizedExpenseRepository.kt +++ b/sdk/src/main/java/br/com/tick/sdk/repositories/categorizedexpense/CategorizedExpenseRepository.kt @@ -1,16 +1,37 @@ package br.com.tick.sdk.repositories.categorizedexpense +import android.net.Uri import br.com.tick.sdk.domain.CategorizedExpense +import com.google.android.gms.maps.model.LatLng import kotlinx.coroutines.flow.Flow import java.time.LocalDate interface CategorizedExpenseRepository { - suspend fun addExpense(categoryId: Int, name: String, value: Double, expenseDate: LocalDate) + suspend fun addExpense( + categoryId: Int, + name: String, + value: Double, + expenseDate: LocalDate, + location: LatLng? = null, + photoUri: Uri? = null + ) + + suspend fun updateExpense( + expenseId: Int, + categoryId: Int, + name: String, + value: Double, + expenseDate: LocalDate, + location: LatLng? = null, + photoUri: Uri? = null + ) suspend fun removeExpense(expenseId: Int) fun getCategorizedExpenses(): Flow> suspend fun getAccountingCycleExpenses(): Flow> + + fun getCategorizedExpense(expenseId: Int): Flow } diff --git a/sdk/src/main/java/br/com/tick/sdk/repositories/categorizedexpense/CategorizedExpensesRepositoryImpl.kt b/sdk/src/main/java/br/com/tick/sdk/repositories/categorizedexpense/CategorizedExpensesRepositoryImpl.kt index 25889bc..6eee0e1 100644 --- a/sdk/src/main/java/br/com/tick/sdk/repositories/categorizedexpense/CategorizedExpensesRepositoryImpl.kt +++ b/sdk/src/main/java/br/com/tick/sdk/repositories/categorizedexpense/CategorizedExpensesRepositoryImpl.kt @@ -1,5 +1,6 @@ package br.com.tick.sdk.repositories.categorizedexpense +import android.net.Uri import br.com.tick.sdk.database.CategoryColorDao import br.com.tick.sdk.database.CategoryDao import br.com.tick.sdk.database.ExpenseDao @@ -8,6 +9,7 @@ import br.com.tick.sdk.database.entities.Expense import br.com.tick.sdk.domain.CategorizedExpense import br.com.tick.sdk.domain.ExpenseCategory import br.com.tick.sdk.domain.getAccountingDateDayOfMonth +import com.google.android.gms.maps.model.LatLng import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first @@ -26,14 +28,40 @@ class CategorizedExpensesRepositoryImpl @Inject constructor( categoryId: Int, name: String, value: Double, - expenseDate: LocalDate + expenseDate: LocalDate, + location: LatLng?, + photoUri: Uri? ) { expenseDao.addExpense( Expense( categoryId = categoryId, name = name, value = value, - date = expenseDate + date = expenseDate, + location = location, + photoUri = photoUri?.toString() + ) + ) + } + + override suspend fun updateExpense( + expenseId: Int, + categoryId: Int, + name: String, + value: Double, + expenseDate: LocalDate, + location: LatLng?, + photoUri: Uri? + ) { + expenseDao.updateExpense( + Expense( + expenseId = expenseId, + categoryId = categoryId, + name = name, + value = value, + date = expenseDate, + location = location, + photoUri = photoUri?.toString() ) ) } @@ -43,7 +71,7 @@ class CategorizedExpensesRepositoryImpl @Inject constructor( } override fun getCategorizedExpenses(): Flow> { - return expenseDao.getExpenses().map { expenses -> + return expenseDao.getExpenses().filterNotNull().map { expenses -> expenses.map { categorize(it) } } } @@ -63,7 +91,7 @@ class CategorizedExpensesRepositoryImpl @Inject constructor( pivot.withDayOfMonth(userAccountingDayOfMonth) } - return expenseDao.getAllExpenses().map { expenses -> + return expenseDao.getAllExpenses().filterNotNull().map { expenses -> expenses.filter { expense -> val yearDiff = nextAccountingDate.year - expense.date.year val monthDiff = nextAccountingDate.month.value - expense.date.month.value @@ -83,6 +111,10 @@ class CategorizedExpensesRepositoryImpl @Inject constructor( } } + override fun getCategorizedExpense(expenseId: Int): Flow { + return expenseDao.getExpense(expenseId).filterNotNull().map { categorize(it) } + } + private suspend fun categorize(expense: Expense): CategorizedExpense { val category = categoryDao.getCategoryById(expense.categoryId) val categoryColorId = category.categoryColorId @@ -102,7 +134,9 @@ class CategorizedExpensesRepositoryImpl @Inject constructor( name, value, date, - expenseCategory + expenseCategory, + location, + photoUri?.let { Uri.parse(it) } ) } } diff --git a/sdk/src/main/java/br/com/tick/sdk/repositories/user/UserRepositoryImpl.kt b/sdk/src/main/java/br/com/tick/sdk/repositories/user/UserRepositoryImpl.kt index 3393554..9b8110c 100644 --- a/sdk/src/main/java/br/com/tick/sdk/repositories/user/UserRepositoryImpl.kt +++ b/sdk/src/main/java/br/com/tick/sdk/repositories/user/UserRepositoryImpl.kt @@ -6,6 +6,8 @@ import br.com.tick.sdk.domain.AccountingDate import br.com.tick.sdk.domain.CurrencyFormat import br.com.tick.sdk.domain.NotificationPeriodicity import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import javax.inject.Inject class UserRepositoryImpl @Inject constructor( @@ -13,11 +15,14 @@ class UserRepositoryImpl @Inject constructor( ) : UserRepository { override fun getUser(): Flow { - return userDao.getUniqueUser() + return userDao.getUniqueUser().filterNotNull() } override suspend fun setInitialUser() { - userDao.setInitialUser(User.initial()) + val user = userDao.getUniqueUser().first() + if (user == null) { + userDao.setInitialUser(User.initial()) + } } override suspend fun setMonthlyIncome(newValue: Double) { diff --git a/sdk/src/qa/java/br/com/tick/sdk/repositories/FakeCategorizedExpenseRepository.kt b/sdk/src/qa/java/br/com/tick/sdk/repositories/FakeCategorizedExpenseRepository.kt index 6cc13d9..bf60c86 100644 --- a/sdk/src/qa/java/br/com/tick/sdk/repositories/FakeCategorizedExpenseRepository.kt +++ b/sdk/src/qa/java/br/com/tick/sdk/repositories/FakeCategorizedExpenseRepository.kt @@ -1,8 +1,10 @@ package br.com.tick.sdk.repositories +import android.net.Uri import br.com.tick.sdk.domain.CategorizedExpense import br.com.tick.sdk.domain.ExpenseCategory import br.com.tick.sdk.repositories.categorizedexpense.CategorizedExpenseRepository +import com.google.android.gms.maps.model.LatLng import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf import java.time.LocalDate @@ -11,8 +13,44 @@ class FakeCategorizedExpenseRepository : CategorizedExpenseRepository { private val expenses = mutableListOf() - override suspend fun addExpense(categoryId: Int, name: String, value: Double, expenseDate: LocalDate) { - expenses.add(CategorizedExpense(expenses.size, name, value, expenseDate, ExpenseCategory(categoryId, "", 0))) + override suspend fun addExpense( + categoryId: Int, + name: String, + value: Double, + expenseDate: LocalDate, + location: LatLng?, + photoUri: Uri? + ) { + expenses.add( + CategorizedExpense( + expenses.size, + name, + value, + expenseDate, + ExpenseCategory(categoryId, "", 0), + LatLng(0.0, 0.0), + Uri.EMPTY + ) + ) + } + + override suspend fun updateExpense( + expenseId: Int, + categoryId: Int, + name: String, + value: Double, + expenseDate: LocalDate, + location: LatLng?, + photoUri: Uri? + ) { + expenses[expenseId] = expenses[expenseId].copy( + expenseId = expenseId, + name = name, + expenseValue = value, + date = expenseDate, + location = location, + picture = photoUri + ) } override suspend fun removeExpense(expenseId: Int) { @@ -26,4 +64,8 @@ class FakeCategorizedExpenseRepository : CategorizedExpenseRepository { override suspend fun getAccountingCycleExpenses(): Flow> { return flowOf(expenses.take(30)) } + + override fun getCategorizedExpense(expenseId: Int): Flow { + return flowOf(expenses[expenseId]) + } } diff --git a/sdk/src/qa/java/br/com/tick/sdk/repositories/FakeUserRepository.kt b/sdk/src/qa/java/br/com/tick/sdk/repositories/FakeUserRepository.kt index 7bd4630..fe76b24 100644 --- a/sdk/src/qa/java/br/com/tick/sdk/repositories/FakeUserRepository.kt +++ b/sdk/src/qa/java/br/com/tick/sdk/repositories/FakeUserRepository.kt @@ -17,6 +17,7 @@ class FakeUserRepository: UserRepository { } override fun getUser() = user + override suspend fun setInitialUser() { user.tryEmit(User.initial()) } diff --git a/ui/build.gradle b/ui/build.gradle index 4cd4b31..e01ea00 100644 --- a/ui/build.gradle +++ b/ui/build.gradle @@ -6,7 +6,7 @@ plugins { } android { - compileSdk 33 + compileSdk 34 namespace 'br.com.tick.ui' defaultConfig { @@ -63,6 +63,7 @@ dependencies { implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycleRuntimeCompose" implementation "androidx.compose.ui:ui:$composeUi" + implementation "androidx.compose.ui:ui-util:$composeUiUtil" implementation "androidx.compose.foundation:foundation:$composeFoundation" implementation "androidx.compose.material3:material3:$composeMaterial3Version" @@ -72,6 +73,9 @@ dependencies { debugImplementation "androidx.compose.ui:ui-tooling:$composeVersion" debugImplementation "androidx.compose.ui:ui-test-manifest:$composeVersion" implementation "com.google.accompanist:accompanist-permissions:$acompanistPermissionVersion" + implementation "com.google.maps.android:maps-compose:$googleMaps" + implementation "com.google.android.gms:play-services-maps:$playServicesMap" + implementation "com.google.android.gms:play-services-location:$playServicesLocation" implementation "com.google.dagger:hilt-android:$hiltVersion" implementation "androidx.hilt:hilt-navigation-compose:$hiltNavigationComposeVersion" @@ -83,6 +87,7 @@ dependencies { implementation "com.patrykandpatrick.vico:compose-m3:$vicoVersion" implementation "com.godaddy.android.colorpicker:compose-color-picker:$composeColorPicker" + implementation "io.coil-kt:coil-compose:$coilVersion" // Unit Tests dependencies testImplementation "junit:junit:$jUnitVersion" diff --git a/ui/src/main/AndroidManifest.xml b/ui/src/main/AndroidManifest.xml index cce937e..b626611 100644 --- a/ui/src/main/AndroidManifest.xml +++ b/ui/src/main/AndroidManifest.xml @@ -1,4 +1,7 @@ + + + \ No newline at end of file diff --git a/ui/src/main/java/br/com/tick/ui/NavigationItem.kt b/ui/src/main/java/br/com/tick/ui/NavigationItem.kt index dceca90..e835510 100644 --- a/ui/src/main/java/br/com/tick/ui/NavigationItem.kt +++ b/ui/src/main/java/br/com/tick/ui/NavigationItem.kt @@ -1,22 +1,35 @@ package br.com.tick.ui import br.com.tick.ui.NavigationItem.Routes.ANALYSIS +import br.com.tick.ui.NavigationItem.Routes.EXPENSE +import br.com.tick.ui.NavigationItem.Routes.EXPENSE_ID_TAG import br.com.tick.ui.NavigationItem.Routes.HISTORY +import br.com.tick.ui.NavigationItem.Routes.HOME import br.com.tick.ui.NavigationItem.Routes.SETTINGS import br.com.tick.ui.NavigationItem.Routes.WALLET sealed class NavigationItem(var route: String, var iconResource: Int, var titleResource: Int) { private object Routes { + const val HOME = "home" const val SETTINGS = "configuration" const val WALLET = "wallet" const val ANALYSIS = "analysis" const val HISTORY = "history" + const val EXPENSE_ID_TAG = "{expenseId}" + const val EXPENSE = "editExpense?$EXPENSE_ID_TAG" } - + object Home : NavigationItem(HOME, R.drawable.ic_wallet, R.string.navigation_item_wallet) object Settings : NavigationItem(SETTINGS, R.drawable.ic_settings, R.string.navigation_item_settings) object Wallet : NavigationItem(WALLET, R.drawable.ic_wallet, R.string.navigation_item_wallet) object Analysis : NavigationItem(ANALYSIS, R.drawable.ic_analysis, R.string.navigation_item_analysis) object History : NavigationItem(HISTORY, R.drawable.ic_history, R.string.navigation_item_history) + object Expense : NavigationItem(EXPENSE, R.drawable.ic_expense, R.string.navigation_item_expense) { + + const val NAVIGATION_EXPENSE_ID_TAG = "expenseId" + fun show(expenseId: Int?): String { + return EXPENSE.replace(EXPENSE_ID_TAG, expenseId.toString()) + } + } } diff --git a/ui/src/main/java/br/com/tick/ui/TeiraScaffold.kt b/ui/src/main/java/br/com/tick/ui/TeiraScaffold.kt index 37b94e7..e4ba068 100644 --- a/ui/src/main/java/br/com/tick/ui/TeiraScaffold.kt +++ b/ui/src/main/java/br/com/tick/ui/TeiraScaffold.kt @@ -3,6 +3,11 @@ package br.com.tick.ui import android.Manifest import android.content.Context import android.os.Build +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.EaseInOut +import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding import androidx.compose.material3.DrawerValue @@ -31,10 +36,12 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavHostController +import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager import br.com.tick.sdk.domain.NotificationPeriodicity @@ -42,6 +49,7 @@ import br.com.tick.sdk.extensions.getPeriodicityTimeDiff import br.com.tick.ui.core.TeiraNavigationDrawer import br.com.tick.ui.extensions.collectAsEffect import br.com.tick.ui.screens.analysis.AnalysisScreen +import br.com.tick.ui.screens.expense.ExpenseScreen import br.com.tick.ui.screens.history.HistoryScreen import br.com.tick.ui.screens.settings.SettingsScreen import br.com.tick.ui.screens.wallet.WalletScreen @@ -53,17 +61,11 @@ import com.google.accompanist.permissions.rememberPermissionState import kotlinx.coroutines.launch import java.util.concurrent.TimeUnit -@OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class) +@OptIn(ExperimentalPermissionsApi::class) @Composable fun TeiraScaffold( viewModel: TeiraScaffoldViewModel = hiltViewModel() ) { - val navHostController = rememberNavController() - val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) - val coroutineScope = rememberCoroutineScope() - val navBackStackEntry by navHostController.currentBackStackEntryAsState() - - val currentRoute = navBackStackEntry?.destination?.route val context = LocalContext.current if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { @@ -87,10 +89,59 @@ fun TeiraScaffold( setDelayedPeriodicWorker(context, WorkManager.getInstance(context), it) } + val navHostController = rememberNavController() + + NavHost( + navController = navHostController, + startDestination = NavigationItem.Home.route + ) { + composable(NavigationItem.Home.route) { + HomeScreen(navHostController) + } + composable( + route = NavigationItem.Expense.route, + arguments = listOf( + navArgument(NavigationItem.Expense.NAVIGATION_EXPENSE_ID_TAG) { + type = NavType.IntType + defaultValue = -1 + } + ), + enterTransition = { + slideIntoContainer( + animationSpec = tween(400, easing = EaseInOut), + towards = AnimatedContentTransitionScope.SlideDirection.Up + ) + }, + popExitTransition = { + slideOutOfContainer( + animationSpec = tween(400, easing = EaseInOut), + towards = AnimatedContentTransitionScope.SlideDirection.Down + ) + } + ) { navBackStackEntry -> + navBackStackEntry.arguments?.getInt(NavigationItem.Expense.NAVIGATION_EXPENSE_ID_TAG)?.let { + val expenseId = if (it == -1) null else it + ExpenseScreen(navHostController = navHostController, expenseId = expenseId) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun HomeScreen(parentNavController: NavHostController) { + val navHostController = rememberNavController() + val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) + val coroutineScope = rememberCoroutineScope() + val navBackStackEntry by navHostController.currentBackStackEntryAsState() + + val currentRoute = navBackStackEntry?.destination?.route + val context = LocalContext.current TeiraNavigationDrawer( drawerState = drawerState, navBackStackEntry = navBackStackEntry, + navigateToParentRoute = { navigateTo(parentNavController, it) }, navigateToRoute = { navigateTo(navHostController, it) } ) { Scaffold( @@ -147,7 +198,7 @@ fun TeiraScaffold( ) }, selected = currentRoute == navigationItem.route, - onClick = { navigateTo(navHostController, navigationItem.route) } + onClick = { navigateTo(navHostController, navigationItem) } ) } } @@ -156,7 +207,9 @@ fun TeiraScaffold( Box(modifier = Modifier.padding(innerPadding)) { NavHost( navController = navHostController, - startDestination = NavigationItem.Wallet.route + startDestination = NavigationItem.Wallet.route, + enterTransition = { EnterTransition.None }, + exitTransition = { ExitTransition.None } ) { composable(NavigationItem.Settings.route) { SettingsScreen { @@ -164,7 +217,7 @@ fun TeiraScaffold( } } composable(NavigationItem.Wallet.route) { - WalletScreen() + WalletScreen(parentNavController) } composable(NavigationItem.Analysis.route) { AnalysisScreen() @@ -178,8 +231,8 @@ fun TeiraScaffold( } } -private fun navigateTo(navHostController: NavHostController, route: String) { - navHostController.navigate(route) { +private fun navigateTo(navHostController: NavHostController, navigationItem: NavigationItem) { + navHostController.navigate(navigationItem.route) { // Pop up to the start destination of the graph to // avoid building up a large stack of destinations // on the back stack as users select items diff --git a/ui/src/main/java/br/com/tick/ui/core/QuickExpenseCard.kt b/ui/src/main/java/br/com/tick/ui/core/QuickExpenseCard.kt index 7858984..5280d86 100644 --- a/ui/src/main/java/br/com/tick/ui/core/QuickExpenseCard.kt +++ b/ui/src/main/java/br/com/tick/ui/core/QuickExpenseCard.kt @@ -24,12 +24,12 @@ import br.com.tick.ui.screens.wallet.models.ExpenseCard import br.com.tick.ui.theme.spacing import br.com.tick.ui.theme.textStyle -@OptIn(ExperimentalAnimationApi::class) @ExperimentalFoundationApi @Composable fun QuickExpenseCard( expenseCard: ExpenseCard, modifier: Modifier = Modifier, + onQuickActionEdit: (expenseId: Int) -> Unit, onQuickActionDelete: (expenseId: Int) -> Unit ) { var expanded by remember { mutableStateOf(false) } @@ -69,10 +69,12 @@ fun QuickExpenseCard( AnimatedContent( modifier = Modifier.align(Alignment.BottomEnd), - targetState = expanded + targetState = expanded, + label = "" ) { targetExpanded -> if (targetExpanded) { ExpandedCardIcons( + onQuickActionEdit = { onQuickActionEdit(expenseCard.id) }, onQuickActionDelete = { onQuickActionDelete(expenseCard.id) } ) { expanded = !expanded @@ -106,7 +108,7 @@ fun CollapsedCardIcons(modifier: Modifier = Modifier, onExpandIcons: () -> Unit) ) { Icon( modifier = Modifier - .size(24.dp) + .size(26.dp) .clickable { onExpandIcons() }, tint = MaterialTheme.colorScheme.onSurfaceVariant, painter = painterResource(id = R.drawable.ic_slide_left), @@ -119,6 +121,7 @@ fun CollapsedCardIcons(modifier: Modifier = Modifier, onExpandIcons: () -> Unit) fun ExpandedCardIcons( modifier: Modifier = Modifier, onQuickActionDelete: () -> Unit, + onQuickActionEdit: () -> Unit, collapse: () -> Unit ) { Row( @@ -127,7 +130,7 @@ fun ExpandedCardIcons( ) { Icon( modifier = Modifier - .size(24.dp) + .size(26.dp) .clickable { collapse() }, tint = MaterialTheme.colorScheme.onSurfaceVariant, painter = painterResource(id = R.drawable.ic_slide_right), @@ -135,7 +138,9 @@ fun ExpandedCardIcons( ) Icon( - modifier = Modifier.size(24.dp), + modifier = Modifier + .size(26.dp) + .clickable { onQuickActionEdit() }, tint = MaterialTheme.colorScheme.onSurfaceVariant, painter = painterResource(id = R.drawable.ic_edit), contentDescription = "Edit expense" @@ -143,7 +148,7 @@ fun ExpandedCardIcons( Icon( modifier = Modifier - .size(24.dp) + .size(26.dp) .clickable { onQuickActionDelete() }, tint = MaterialTheme.colorScheme.onSurfaceVariant, painter = painterResource(id = R.drawable.ic_delete), @@ -163,8 +168,8 @@ fun QuickExpenseCardPreview() { value = 50.0, category = ExpenseCategory(0, "Category 1", Color.Red.value.toInt()), risk = ExpenseRisk.HIGH - ) - ) { - - } + ), + onQuickActionDelete = {}, + onQuickActionEdit = {} + ) } diff --git a/ui/src/main/java/br/com/tick/ui/core/TeiraDatePicker.kt b/ui/src/main/java/br/com/tick/ui/core/TeiraDatePicker.kt new file mode 100644 index 0000000..26ca3c9 --- /dev/null +++ b/ui/src/main/java/br/com/tick/ui/core/TeiraDatePicker.kt @@ -0,0 +1,98 @@ +package br.com.tick.ui.core + +import android.annotation.SuppressLint +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDefaults +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextDecoration +import br.com.tick.ui.R +import br.com.tick.ui.theme.textStyle +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +@SuppressLint("UnrememberedMutableState") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TeiraDatePicker( + modifier: Modifier = Modifier, + color: Color = MaterialTheme.colorScheme.primary, + localDate: LocalDate, + onDateChanged: (LocalDate) -> Unit +) { + var openDialog by remember { mutableStateOf(false) } + + if (openDialog) { + val datePickerState = rememberDatePickerState() + val confirmEnabled = derivedStateOf { datePickerState.selectedDateMillis != null } + + DatePickerDialog( + onDismissRequest = { openDialog = false }, + confirmButton = { + TextButton( + onClick = { + openDialog = false + val localDate = LocalDateTime.ofInstant( + Instant.ofEpochMilli(datePickerState.selectedDateMillis!!), + ZoneId.systemDefault() + ).toLocalDate() + onDateChanged(localDate) + }, + enabled = confirmEnabled.value + ) { + Text(text = stringResource(id = R.string.generic_ok)) + } + }, + dismissButton = { + TextButton( + onClick = { openDialog = false } + ) { + Text(text = stringResource(id = R.string.generic_cancel)) + } + } + ) { + DatePicker( + state = datePickerState, + colors = DatePickerDefaults.colors( + titleContentColor = MaterialTheme.colorScheme.tertiary, + headlineContentColor = MaterialTheme.colorScheme.tertiary, + weekdayContentColor = MaterialTheme.colorScheme.primary, + dayContentColor = MaterialTheme.colorScheme.primary, + disabledDayContentColor = MaterialTheme.colorScheme.secondary, + selectedDayContentColor = MaterialTheme.colorScheme.tertiary, + selectedDayContainerColor = MaterialTheme.colorScheme.onSecondary, + ) + ) + } + } + + Box( + modifier = modifier + ) { + Text( + modifier = Modifier.clickable { openDialog = true }, + text = localDate.format(DateTimeFormatter.ofPattern("dd/MM/yyyy")), + textDecoration = TextDecoration.Underline, + style = MaterialTheme.textStyle.h4, + color = color + ) + } +} diff --git a/ui/src/main/java/br/com/tick/ui/core/TeiraDropdown.kt b/ui/src/main/java/br/com/tick/ui/core/TeiraDropdown.kt index 5b41470..93bb87d 100644 --- a/ui/src/main/java/br/com/tick/ui/core/TeiraDropdown.kt +++ b/ui/src/main/java/br/com/tick/ui/core/TeiraDropdown.kt @@ -38,7 +38,6 @@ fun TeiraDropdown( lastItemLabel: String? = null, onLastItemSelected: (() -> Unit)? = null ) { - var isDropdownExpanded by remember { mutableStateOf(false) } var selectedItemName by remember { mutableStateOf("") } @@ -53,7 +52,7 @@ fun TeiraDropdown( text = label, modifier = Modifier.align(Alignment.Center), color = borderColor, - style = MaterialTheme.textStyle.h3 + style = MaterialTheme.textStyle.h3small ) DropdownMenu( modifier = Modifier.background(MaterialTheme.colorScheme.onSecondary), @@ -78,7 +77,7 @@ fun TeiraDropdown( text = { Text( text = label, - style = MaterialTheme.textStyle.h3extra, + style = MaterialTheme.textStyle.h3small, color = dropdownItemColor ) } diff --git a/ui/src/main/java/br/com/tick/ui/core/TeiraNavigationDrawer.kt b/ui/src/main/java/br/com/tick/ui/core/TeiraNavigationDrawer.kt index 2b5db5c..3549735 100644 --- a/ui/src/main/java/br/com/tick/ui/core/TeiraNavigationDrawer.kt +++ b/ui/src/main/java/br/com/tick/ui/core/TeiraNavigationDrawer.kt @@ -1,7 +1,13 @@ package br.com.tick.ui.core +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.material3.DismissibleDrawerSheet import androidx.compose.material3.DismissibleNavigationDrawer import androidx.compose.material3.DrawerState @@ -13,13 +19,16 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import androidx.navigation.NavBackStackEntry import br.com.tick.ui.NavigationItem import br.com.tick.ui.R import br.com.tick.ui.theme.spacing +import br.com.tick.ui.theme.textStyle import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -27,10 +36,10 @@ import kotlinx.coroutines.launch fun TeiraNavigationDrawer( drawerState: DrawerState, navBackStackEntry: NavBackStackEntry?, - navigateToRoute: (String) -> Unit, + navigateToParentRoute: (NavigationItem) -> Unit, + navigateToRoute: (NavigationItem) -> Unit, content: @Composable () -> Unit ) { - val coroutineScope = rememberCoroutineScope() val currentRoute = navBackStackEntry?.destination?.route @@ -41,51 +50,73 @@ fun TeiraNavigationDrawer( drawerContainerColor = MaterialTheme.colorScheme.surface, drawerContentColor = MaterialTheme.colorScheme.onSurface ) { - Spacer(modifier = Modifier.height(MaterialTheme.spacing.large)) - TeiraNavigationDrawerItem( - drawerState = drawerState, - painter = painterResource(id = NavigationItem.Settings.iconResource), - text = stringResource(id = NavigationItem.Settings.titleResource), - coroutineScope = coroutineScope, - isCurrentRoute = currentRoute == NavigationItem.Settings.route - ) { - navigateToRoute(NavigationItem.Settings.route) - } - TeiraNavigationDrawerItem( - drawerState = drawerState, - painter = painterResource(id = NavigationItem.Wallet.iconResource), - text = stringResource(id = NavigationItem.Wallet.titleResource), - coroutineScope = coroutineScope, - isCurrentRoute = currentRoute == NavigationItem.Wallet.route - ) { - navigateToRoute(NavigationItem.Wallet.route) - } - TeiraNavigationDrawerItem( - drawerState = drawerState, - painter = painterResource(id = NavigationItem.Analysis.iconResource), - text = stringResource(id = NavigationItem.Analysis.titleResource), - coroutineScope = coroutineScope, - isCurrentRoute = currentRoute == NavigationItem.Analysis.route + Column( + modifier = Modifier.fillMaxSize().padding(MaterialTheme.spacing.large), + verticalArrangement = Arrangement.SpaceBetween ) { - navigateToRoute(NavigationItem.Analysis.route) + Column { + Text( + modifier = Modifier.padding(MaterialTheme.spacing.large), + text = stringResource(id = R.string.app_name), + style = MaterialTheme.textStyle.h1extra, + color = MaterialTheme.colorScheme.tertiary + ) + Spacer(modifier = Modifier.height(MaterialTheme.spacing.large)) + TeiraNavigationDrawerItem( + drawerState = drawerState, + painter = painterResource(id = NavigationItem.Settings.iconResource), + text = stringResource(id = NavigationItem.Settings.titleResource), + isCurrentRoute = currentRoute == NavigationItem.Settings.route + ) { + navigateToRoute(NavigationItem.Settings) + } + TeiraNavigationDrawerItem( + drawerState = drawerState, + painter = painterResource(id = NavigationItem.Wallet.iconResource), + text = stringResource(id = NavigationItem.Wallet.titleResource), + isCurrentRoute = currentRoute == NavigationItem.Wallet.route + ) { + navigateToRoute(NavigationItem.Wallet) + } + TeiraNavigationDrawerItem( + drawerState = drawerState, + painter = painterResource(id = NavigationItem.Analysis.iconResource), + text = stringResource(id = NavigationItem.Analysis.titleResource), + isCurrentRoute = currentRoute == NavigationItem.Analysis.route + ) { + navigateToRoute(NavigationItem.Analysis) + } + Spacer( + modifier = Modifier + .padding(horizontal = MaterialTheme.spacing.medium, vertical = MaterialTheme.spacing.small) + .fillMaxWidth() + .height(0.5.dp) + .background(MaterialTheme.colorScheme.tertiary) + ) + TeiraNavigationDrawerItem( + drawerState = drawerState, + painter = painterResource(id = NavigationItem.History.iconResource), + text = stringResource(id = NavigationItem.History.titleResource), + isCurrentRoute = currentRoute == NavigationItem.History.route + ) { + navigateToRoute(NavigationItem.History) + } + TeiraNavigationDrawerItem( + drawerState = drawerState, + painter = painterResource(id = NavigationItem.Expense.iconResource), + text = stringResource(id = NavigationItem.Expense.titleResource), + isCurrentRoute = currentRoute == NavigationItem.Expense.route + ) { + navigateToParentRoute(NavigationItem.Expense) + } + } + TeiraNavigationDrawerItem( + drawerState = drawerState, + painter = painterResource(id = R.drawable.ic_clear), + text = stringResource(id = R.string.generic_close), + isCurrentRoute = false + ) } - TeiraNavigationDrawerItem( - drawerState = drawerState, - painter = painterResource(id = NavigationItem.History.iconResource), - text = stringResource(id = NavigationItem.History.titleResource), - coroutineScope = coroutineScope, - isCurrentRoute = currentRoute == NavigationItem.History.route - ) { - navigateToRoute(NavigationItem.History.route) - } - Spacer(modifier = Modifier.height(MaterialTheme.spacing.extraLarge)) - TeiraNavigationDrawerItem( - drawerState = drawerState, - painter = painterResource(id = R.drawable.ic_clear), - text = stringResource(id = R.string.generic_close), - coroutineScope = coroutineScope, - isCurrentRoute = false - ) } }, content = content @@ -97,10 +128,11 @@ private fun TeiraNavigationDrawerItem( drawerState: DrawerState, painter: Painter, text: String, - coroutineScope: CoroutineScope, isCurrentRoute: Boolean, onDrawerItemClick: (() -> Unit)? = null ) { + val coroutineScope = rememberCoroutineScope() + NavigationDrawerItem( icon = { Icon(painter = painter, contentDescription = null) diff --git a/ui/src/main/java/br/com/tick/ui/screens/analysis/AnalysisScreen.kt b/ui/src/main/java/br/com/tick/ui/screens/analysis/AnalysisScreen.kt index cc8b8c6..ccbb679 100644 --- a/ui/src/main/java/br/com/tick/ui/screens/analysis/AnalysisScreen.kt +++ b/ui/src/main/java/br/com/tick/ui/screens/analysis/AnalysisScreen.kt @@ -26,12 +26,12 @@ fun AnalysisScreen() { MostExpensiveCategory( modifier = Modifier .fillMaxWidth() - .padding(top = MaterialTheme.spacing.large) + .padding(top = MaterialTheme.spacing.small) ) FinancialHealthComposable( modifier = Modifier .fillMaxWidth() - .padding(top = MaterialTheme.spacing.large) + .padding(top = MaterialTheme.spacing.small) ) } } diff --git a/ui/src/main/java/br/com/tick/ui/screens/analysis/FinancialHealth.kt b/ui/src/main/java/br/com/tick/ui/screens/analysis/FinancialHealth.kt index 003f8ce..9dec2af 100644 --- a/ui/src/main/java/br/com/tick/ui/screens/analysis/FinancialHealth.kt +++ b/ui/src/main/java/br/com/tick/ui/screens/analysis/FinancialHealth.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel +import br.com.tick.sdk.domain.CurrencyFormat import br.com.tick.ui.R import br.com.tick.ui.core.TeiraNoAvailableDataState import br.com.tick.ui.screens.analysis.states.FinancialHealth diff --git a/ui/src/main/java/br/com/tick/ui/screens/analysis/MostExpensiveCategory.kt b/ui/src/main/java/br/com/tick/ui/screens/analysis/MostExpensiveCategory.kt index 456eac7..5ab9931 100644 --- a/ui/src/main/java/br/com/tick/ui/screens/analysis/MostExpensiveCategory.kt +++ b/ui/src/main/java/br/com/tick/ui/screens/analysis/MostExpensiveCategory.kt @@ -1,10 +1,14 @@ package br.com.tick.ui.screens.analysis +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.Card @@ -23,8 +27,10 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import br.com.tick.sdk.domain.CurrencyFormat import br.com.tick.ui.R import br.com.tick.ui.core.TeiraNoAvailableDataState +import br.com.tick.ui.extensions.getLabelResource import br.com.tick.ui.screens.analysis.models.MostExpensiveCategory import br.com.tick.ui.screens.analysis.states.MostExpensiveCategoriesStates import br.com.tick.ui.screens.analysis.viewmodels.AnalysisScreenViewModel @@ -38,10 +44,11 @@ fun MostExpensiveCategory( ) { val mostExpensiveCategoriesStates by viewModel.mostExpenseCategoryList .collectAsState(initial = MostExpensiveCategoriesStates.NoDataAvailable) + val currency by viewModel.currency.collectAsState() Column(modifier = modifier.fillMaxWidth()) { when (val state = mostExpensiveCategoriesStates) { - is MostExpensiveCategoriesStates.Full -> MostExpensiveCategoryBody(modifier, state) + is MostExpensiveCategoriesStates.Full -> MostExpensiveCategoryBody(modifier, currency, state) MostExpensiveCategoriesStates.NoDataAvailable -> TeiraNoAvailableDataState(modifier) } } @@ -50,6 +57,7 @@ fun MostExpensiveCategory( @Composable private fun MostExpensiveCategoryBody( modifier: Modifier = Modifier, + currencyFormat: CurrencyFormat, mostExpensiveCategoriesState: MostExpensiveCategoriesStates.Full ) { Column(modifier = modifier.fillMaxWidth()) { @@ -59,17 +67,21 @@ private fun MostExpensiveCategoryBody( style = MaterialTheme.textStyle.h2 ) Row( - modifier = modifier.fillMaxWidth().padding(top = MaterialTheme.spacing.medium), + modifier = modifier + .fillMaxWidth() + .padding(top = MaterialTheme.spacing.medium), horizontalArrangement = Arrangement.SpaceEvenly ) { mostExpensiveCategoriesState.mostExpensiveCategories.forEach { mostExpensiveCategory -> val categoryCardColor = mostExpensiveCategory.color?.let { Color(it) - } ?: MaterialTheme.colorScheme.secondary + } ?: MaterialTheme.colorScheme.surface + + val currencyLabel = stringResource(id = currencyFormat.getLabelResource()) CategoryCard( label = mostExpensiveCategory.categoryName, - subLabel = mostExpensiveCategory.amount.toString(), + subLabel = "$currencyLabel${mostExpensiveCategory.amount}", color = categoryCardColor ) } @@ -83,7 +95,7 @@ fun CategoryCard(modifier: Modifier = Modifier, label: String, subLabel: String, modifier = modifier .size(80.dp) .padding(MaterialTheme.spacing.smallest), - colors = CardDefaults.cardColors(containerColor = color) + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) ) { Column( modifier = Modifier.fillMaxSize(), @@ -93,15 +105,24 @@ fun CategoryCard(modifier: Modifier = Modifier, label: String, subLabel: String, Text( text = label, style = MaterialTheme.textStyle.h2bold, - color = MaterialTheme.colorScheme.onSecondary, + color = MaterialTheme.colorScheme.onTertiary, maxLines = 1, overflow = TextOverflow.Ellipsis ) Text( text = subLabel, - style = MaterialTheme.textStyle.h3small, - color = MaterialTheme.colorScheme.onSecondary + style = MaterialTheme.textStyle.h3, + color = MaterialTheme.colorScheme.onTertiary ) + Box(modifier = Modifier.fillMaxSize()) { + Spacer( + modifier = Modifier + .height(6.dp) + .background(color) + .align(Alignment.BottomCenter) + .fillMaxWidth() + ) + } } } } @@ -110,6 +131,7 @@ fun CategoryCard(modifier: Modifier = Modifier, label: String, subLabel: String, @Composable fun MostExpensiveCategoryBodyPreview() { MostExpensiveCategoryBody( + currencyFormat = CurrencyFormat.EURO, mostExpensiveCategoriesState = MostExpensiveCategoriesStates.Full( listOf(MostExpensiveCategory("Test", Color.Red.toArgb(), 56.0)) ) diff --git a/ui/src/main/java/br/com/tick/ui/screens/analysis/states/MostExpensiveCategoriesStates.kt b/ui/src/main/java/br/com/tick/ui/screens/analysis/states/MostExpensiveCategoriesStates.kt index 9b7f670..852185e 100644 --- a/ui/src/main/java/br/com/tick/ui/screens/analysis/states/MostExpensiveCategoriesStates.kt +++ b/ui/src/main/java/br/com/tick/ui/screens/analysis/states/MostExpensiveCategoriesStates.kt @@ -13,7 +13,7 @@ sealed class MostExpensiveCategoriesStates { Full( mostExpensiveCategories .sortedByDescending { it.amount } - .take(5) + .take(4) ) } } diff --git a/ui/src/main/java/br/com/tick/ui/screens/analysis/viewmodels/AnalysisScreenViewModel.kt b/ui/src/main/java/br/com/tick/ui/screens/analysis/viewmodels/AnalysisScreenViewModel.kt index f6d7c2f..44a274d 100644 --- a/ui/src/main/java/br/com/tick/ui/screens/analysis/viewmodels/AnalysisScreenViewModel.kt +++ b/ui/src/main/java/br/com/tick/ui/screens/analysis/viewmodels/AnalysisScreenViewModel.kt @@ -1,7 +1,10 @@ package br.com.tick.ui.screens.analysis.viewmodels import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import br.com.tick.sdk.dispatchers.DispatcherProvider +import br.com.tick.sdk.domain.CurrencyFormat +import br.com.tick.sdk.repositories.user.UserRepository import br.com.tick.ui.screens.analysis.states.AnalysisGraphStates import br.com.tick.ui.screens.analysis.states.FinancialHealth import br.com.tick.ui.screens.analysis.states.MostExpensiveCategoriesStates @@ -10,8 +13,16 @@ import br.com.tick.ui.screens.analysis.usecases.FetchLastMonthExpenses import br.com.tick.ui.screens.analysis.usecases.GetMostExpensiveCategories import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel @@ -19,6 +30,7 @@ class AnalysisScreenViewModel @Inject constructor( private val fetchLastMonthExpenses: FetchLastMonthExpenses, private val getMostExpensiveCategories: GetMostExpensiveCategories, private val calculateFinancialHealthSituation: CalculateFinancialHealthSituation, + userRepository: UserRepository, private val dispatcherProvider: DispatcherProvider ) : ViewModel() { @@ -36,10 +48,23 @@ class AnalysisScreenViewModel @Inject constructor( } }.flowOn(dispatcherProvider.io()) - val financialHealthSituation: Flow - get() = flow { + val currency = userRepository.getUser() + .flowOn(dispatcherProvider.io()) + .map { it.currency } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = CurrencyFormat.EURO + ) + + private val _financialHealthSituation = MutableStateFlow(FinancialHealth.NoDataAvailable) + val financialHealthSituation: StateFlow = _financialHealthSituation + + init { + viewModelScope.launch(dispatcherProvider.io()) { calculateFinancialHealthSituation().collect { - emit(it) + _financialHealthSituation.emit(it) } - }.flowOn(dispatcherProvider.io()) -} \ No newline at end of file + } + } +} diff --git a/ui/src/main/java/br/com/tick/ui/screens/expense/ExpenseScreen.kt b/ui/src/main/java/br/com/tick/ui/screens/expense/ExpenseScreen.kt new file mode 100644 index 0000000..f0be342 --- /dev/null +++ b/ui/src/main/java/br/com/tick/ui/screens/expense/ExpenseScreen.kt @@ -0,0 +1,701 @@ +@file:OptIn(ExperimentalPermissionsApi::class) + +package br.com.tick.ui.screens.expense + +import android.Manifest +import android.annotation.SuppressLint +import android.net.Uri +import android.util.Log +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.Divider +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableDoubleStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import br.com.tick.sdk.domain.CategorizedExpense +import br.com.tick.sdk.domain.CurrencyFormat +import br.com.tick.sdk.domain.ExpenseCategory +import br.com.tick.ui.R +import br.com.tick.ui.core.TeiraDatePicker +import br.com.tick.ui.core.TeiraDropdown +import br.com.tick.ui.core.TeiraEmptyState +import br.com.tick.ui.extensions.getLabelResource +import br.com.tick.ui.screens.expense.viewmodels.ExpenseViewModel +import br.com.tick.ui.screens.shared.AddCategoryDialog +import br.com.tick.ui.theme.spacing +import br.com.tick.ui.theme.textStyle +import br.com.tick.utils.ComposeFileProvider +import coil.compose.AsyncImage +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberMultiplePermissionsState +import com.google.android.gms.location.FusedLocationProviderClient +import com.google.android.gms.location.LocationServices +import com.google.android.gms.maps.model.CameraPosition +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.compose.GoogleMap +import com.google.maps.android.compose.MapProperties +import com.google.maps.android.compose.MapUiSettings +import com.google.maps.android.compose.Marker +import com.google.maps.android.compose.MarkerState +import com.google.maps.android.compose.rememberCameraPositionState +import kotlinx.coroutines.launch +import java.time.LocalDate + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ExpenseScreen( + navHostController: NavHostController, + expenseViewModel: ExpenseViewModel = hiltViewModel(), + expenseId: Int? = null +) { + var showDeletionConfirmationDialog by remember { mutableStateOf(false) } + + val expense by expenseViewModel.categorizedExpense.collectAsStateWithLifecycle() + val currency by expenseViewModel.currency.collectAsStateWithLifecycle() + val categoriesList by expenseViewModel.categories.collectAsStateWithLifecycle() + + var expenseName by remember { mutableStateOf("") } + var expenseValue by remember { mutableDoubleStateOf(0.0) } + var isInvalidValue by remember { mutableStateOf(false) } + var expenseCategoryId by remember { mutableIntStateOf(-1) } + var expenseDate by remember { mutableStateOf(LocalDate.now()) } + var location by remember { mutableStateOf(null) } + var photoUri by remember { mutableStateOf(null) } + + val snackbarHostState = remember { SnackbarHostState() } + + val scope = rememberCoroutineScope() + val context = LocalContext.current + + LaunchedEffect(key1 = Unit) { + expenseId?.let { expenseViewModel.getExpense(it) } + } + + Scaffold( + modifier = Modifier.fillMaxSize(), + containerColor = MaterialTheme.colorScheme.background, + topBar = { + TopAppBar( + navigationIcon = { + IconButton(onClick = { navHostController.navigateUp() }) { + Icon( + painter = painterResource(id = R.drawable.ic_close), + contentDescription = null + ) + } + }, + title = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + if (expense != null) { + Text( + text = stringResource(id = R.string.expense_title), + style = MaterialTheme.textStyle.h2 + ) + Text( + modifier = Modifier.clickable { + if (isInvalidValue) { + scope.launch { + snackbarHostState + .showSnackbar( + message = context.getString(R.string.expense_save_invalid_message), + duration = SnackbarDuration.Short + ) + } + } else { + expenseViewModel.handleExpense( + expenseId = expenseId, + categoryId = expenseCategoryId, + name = expenseName, + value = expenseValue, + expenseDate = expenseDate, + location = location, + photoUri = photoUri + ) + navHostController.navigateUp() + } + }, + text = stringResource(id = R.string.expense_save), + style = MaterialTheme.textStyle.h2bold + ) + } else { + Text( + text = stringResource(id = R.string.expense_add_title), + style = MaterialTheme.textStyle.h2 + ) + Text( + modifier = Modifier.clickable { + if (isInvalidValue) { + scope.launch { + snackbarHostState + .showSnackbar( + message = context.getString(R.string.expense_add_invalid_message), + duration = SnackbarDuration.Short + ) + } + } else { + expenseViewModel.handleExpense( + expenseId = expenseId, + categoryId = expenseCategoryId, + name = expenseName, + value = expenseValue, + expenseDate = expenseDate, + location = location, + photoUri = photoUri + ) + navHostController.navigateUp() + } + }, + text = stringResource(id = R.string.expense_add), + style = MaterialTheme.textStyle.h2bold + ) + } + + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.secondary, + titleContentColor = MaterialTheme.colorScheme.onSecondary, + navigationIconContentColor = MaterialTheme.colorScheme.onSecondary + ) + ) + }, + floatingActionButton = { + if (expenseId != null) { + FloatingActionButton( + containerColor = MaterialTheme.colorScheme.tertiary, + onClick = { showDeletionConfirmationDialog = true } + ) { + Image( + painter = painterResource(id = R.drawable.ic_delete), + contentDescription = null + ) + } + } + }, + snackbarHost = { SnackbarHost(hostState = snackbarHostState) } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxWidth() + .padding(paddingValues) + .padding(MaterialTheme.spacing.extraSmall) + ) { + BaseExpense( + expense = expense, + currencyFormat = currency, + categoriesList = categoriesList, + onExpenseNameChanged = { expenseName = it }, + onExpenseValueChanged = { expenseValue = it }, + onExpenseCategoryChanged = { expenseCategoryId = it }, + onExpenseDateChanged = { expenseDate = it }, + onInvalidValue = { isInvalidValue = it } + ) + Divider( + modifier = Modifier + .fillMaxWidth() + .padding(top = MaterialTheme.spacing.medium), + thickness = 1.dp, + color = MaterialTheme.colorScheme.secondary + ) + ExpenseLocation(expense?.location) { location = it } + ExpensePhoto(expense?.name, expense?.picture) { photoUri = it } + } + + + if (showDeletionConfirmationDialog) { + AlertDialog( + onDismissRequest = { showDeletionConfirmationDialog = false }, + icon = { + Icon( + painter = painterResource(id = R.drawable.ic_priority_high), + contentDescription = null + ) + }, + confirmButton = { + Text( + modifier = Modifier.clickable { + showDeletionConfirmationDialog = false + navHostController.navigateUp() + expenseId?.let { expenseViewModel.removeExpense(it) } + }, + text = stringResource(id = R.string.generic_ok).uppercase() + ) + }, + dismissButton = { + Text( + modifier = Modifier.clickable { + showDeletionConfirmationDialog = false + }, + text = stringResource(id = R.string.generic_cancel).uppercase() + ) + }, + title = { + Text(text = stringResource(id = R.string.expense_delete_title)) + }, + text = { + Text(text = stringResource(id = R.string.expense_delete_description)) + }, + containerColor = MaterialTheme.colorScheme.onSecondary, + titleContentColor = MaterialTheme.colorScheme.tertiary, + textContentColor = MaterialTheme.colorScheme.primary, + iconContentColor = MaterialTheme.colorScheme.onTertiary + ) + } + } +} + +@Composable +fun BaseExpense( + expense: CategorizedExpense?, + currencyFormat: CurrencyFormat, + categoriesList: List, + onExpenseNameChanged: (String) -> Unit, + onExpenseValueChanged: (Double) -> Unit, + onExpenseCategoryChanged: (Int) -> Unit, + onExpenseDateChanged: (LocalDate) -> Unit, + onInvalidValue: (Boolean) -> Unit +) { + var name by remember { mutableStateOf(TextFieldValue()) } + var value by remember { mutableStateOf(TextFieldValue()) } + var isInvalidValue by remember { mutableStateOf(false) } + + var selectedCategoryId by remember { mutableIntStateOf(-1) } + val initialCategoryLabel = stringResource(id = R.string.expense_empty_category_title) + var categoryLabel by remember { mutableStateOf(initialCategoryLabel) } + var showAddNewCategoryDialog by remember { mutableStateOf(false) } + + var date by remember { mutableStateOf(LocalDate.now()) } + + LaunchedEffect(key1 = expense) { + expense?.let { + name = TextFieldValue(it.name) + onExpenseNameChanged(it.name) + + value = TextFieldValue(it.expenseValue.toString()) + onExpenseValueChanged(it.expenseValue) + + categoryLabel = it.category.name + selectedCategoryId = it.category.expenseCategoryId + onExpenseCategoryChanged(it.category.expenseCategoryId) + + date = it.date + onExpenseDateChanged(it.date) + } + } + + LaunchedEffect(key1 = categoriesList) { + selectedCategoryId = categoriesList.find { categories -> + categories.name == categoryLabel + }?.expenseCategoryId ?: -1 + onExpenseCategoryChanged(selectedCategoryId) + } + + val expenseTextFieldColors = OutlinedTextFieldDefaults.colors( + unfocusedContainerColor = Color.Transparent, + focusedContainerColor = Color.Transparent, + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.primary, + focusedLabelColor = MaterialTheme.colorScheme.primary, + unfocusedLabelColor = MaterialTheme.colorScheme.primary, + focusedTextColor = MaterialTheme.colorScheme.secondary, + unfocusedTextColor = MaterialTheme.colorScheme.secondary, + errorBorderColor = MaterialTheme.colorScheme.tertiary, + errorLabelColor = MaterialTheme.colorScheme.tertiary, + errorTextColor = MaterialTheme.colorScheme.tertiary, + errorTrailingIconColor = MaterialTheme.colorScheme.secondary + ) + + if (showAddNewCategoryDialog) { + AddCategoryDialog(onAddNewCategory = { categoryLabel = it }) { + showAddNewCategoryDialog = false + } + } + + Column(modifier = Modifier.fillMaxWidth()) { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = name, + label = { + Text( + text = stringResource(id = R.string.expense_expense_expense), + style = MaterialTheme.textStyle.h2small + ) + }, + onValueChange = { + name = it + onExpenseNameChanged(it.text) + }, + colors = expenseTextFieldColors + ) + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .padding(top = MaterialTheme.spacing.smallest), + value = value, + label = { + Text( + text = stringResource(id = R.string.expense_expense_cost), + style = MaterialTheme.textStyle.h2small + ) + }, + isError = isInvalidValue, + prefix = { + Text(text = stringResource(id = currencyFormat.getLabelResource())) + }, + onValueChange = { + value = it + isInvalidValue = false + try { + val parsedValue = value.text.toDouble() + onInvalidValue(false) + onExpenseValueChanged(parsedValue) + } catch (exception: NumberFormatException) { + isInvalidValue = true + onInvalidValue(true) + } + }, + colors = expenseTextFieldColors + ) + Row(modifier = Modifier.padding(top = MaterialTheme.spacing.small)) { + TeiraDropdown( + modifier = Modifier.weight(0.5f), + label = categoryLabel, + borderColor = MaterialTheme.colorScheme.primary, + dropdownItemLabels = categoriesList.map { it.name }, + dropdownItemColors = categoriesList.map { it.color }, + onItemSelected = { + categoryLabel = categoriesList[it].name + onExpenseCategoryChanged(categoriesList[it].expenseCategoryId) + }, + lastItemLabel = stringResource(id = R.string.expense_add_category), + onLastItemSelected = { + showAddNewCategoryDialog = true + } + ) + + Column( + modifier = Modifier + .weight(0.5f) + .height(56.dp), + verticalArrangement = Arrangement.Bottom, + horizontalAlignment = Alignment.End + ) { + TeiraDatePicker(localDate = date) { + date = it + onExpenseDateChanged(date) + } + } + } + } +} + +@SuppressLint("MissingPermission") +@Composable +fun ExpenseLocation( + expenseLocation: LatLng?, + onExpenseLocationChanged: (LatLng?) -> Unit +) { + var expenseLocationState by remember { mutableStateOf(expenseLocation) } + + LaunchedEffect(key1 = expenseLocation) { + expenseLocation?.let { + expenseLocationState = it + onExpenseLocationChanged(expenseLocationState) + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = MaterialTheme.spacing.medium), + verticalAlignment = Alignment.Bottom + ) { + Icon( + modifier = Modifier.size(32.dp), + painter = painterResource(id = R.drawable.ic_location), + contentDescription = null, + tint = MaterialTheme.colorScheme.tertiary + ) + Text( + modifier = Modifier.padding(start = MaterialTheme.spacing.extraSmall), + text = stringResource(id = R.string.expense_location), + style = MaterialTheme.textStyle.h2, + color = MaterialTheme.colorScheme.onTertiary + ) + } + + val context = LocalContext.current + when (val location = expenseLocationState) { + null -> { + val locationPermissionState = rememberMultiplePermissionsState( + listOf( + Manifest.permission.ACCESS_COARSE_LOCATION, + Manifest.permission.ACCESS_FINE_LOCATION + ) + ) + val hasPermissions = locationPermissionState.permissions.all { it.status.isGranted } + val emptyStateText = if (hasPermissions) { + R.string.expense_location_empty_state_label + } else { + R.string.expense_location_empty_no_permission_state_label + } + + var capturedLocation by remember { mutableStateOf(null) } + + if (capturedLocation != null) { + capturedLocation?.let { + ExpenseLocationMap( + location = it, + onDelete = { + expenseLocationState = null + capturedLocation = null + onExpenseLocationChanged(null) + }, + onExpenseLocationChanged = onExpenseLocationChanged + ) + } + } else { + TeiraEmptyState( + modifier = Modifier + .fillMaxWidth() + .height(160.dp) + .padding(top = MaterialTheme.spacing.small) + .clickable { + if (hasPermissions) { + val locationProvider: FusedLocationProviderClient = + LocationServices.getFusedLocationProviderClient(context) + locationProvider.lastLocation.addOnCompleteListener { + val lastKnownLocation = it.result + val location = LatLng(lastKnownLocation.latitude, lastKnownLocation.longitude) + capturedLocation = location + onExpenseLocationChanged(location) + } + } else { + locationPermissionState.launchMultiplePermissionRequest() + } + }, + emptyStateLabel = emptyStateText + ) + } + } + + else -> { + ExpenseLocationMap( + location = location, + onDelete = { + expenseLocationState = null + onExpenseLocationChanged(null) + }, + onExpenseLocationChanged = onExpenseLocationChanged + ) + } + + } +} + +@Composable +fun ExpenseLocationMap( + location: LatLng, + onDelete: () -> Unit, + onExpenseLocationChanged: (LatLng) -> Unit +) { + val minZoomPossible = 12f + val cameraPositionState = rememberCameraPositionState { + position = CameraPosition.fromLatLngZoom(location, minZoomPossible) + } + val mapProperties by remember { + mutableStateOf( + MapProperties(maxZoomPreference = 21f, minZoomPreference = minZoomPossible) + ) + } + val mapUiSettings by remember { + mutableStateOf( + MapUiSettings(mapToolbarEnabled = false, zoomControlsEnabled = false) + ) + } + val markerState = MarkerState(position = location) + + Card( + modifier = Modifier + .fillMaxWidth() + .height(160.dp) + .padding(top = MaterialTheme.spacing.small) + ) { + Box { + GoogleMap( + modifier = Modifier.fillMaxSize(), + properties = mapProperties, + uiSettings = mapUiSettings, + cameraPositionState = cameraPositionState, + onMapClick = { + markerState.position = it + onExpenseLocationChanged(it) + } + ) { + Marker( + state = markerState, + title = stringResource(id = R.string.expense_expense_expense) + ) + } + IconButton( + modifier = Modifier.align(Alignment.TopEnd), + onClick = { onDelete() } + ) { + Icon( + painter = painterResource(id = R.drawable.ic_delete), + contentDescription = "", + tint = Color.White + ) + } + } + } +} + +@Composable +fun ExpensePhoto( + expenseName: String?, + expensePhotoUri: Uri?, + onPictureSelected: (Uri?) -> Unit +) { + + val context = LocalContext.current + + var imageUri by remember { mutableStateOf(expensePhotoUri) } + var hasImage by remember { mutableStateOf(expensePhotoUri != null) } + + val cameraLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.TakePicture(), + onResult = { success -> + hasImage = success + onPictureSelected(imageUri) + } + ) + + LaunchedEffect(key1 = expensePhotoUri) { + expensePhotoUri?.let { + imageUri = it + hasImage = true + onPictureSelected(imageUri) + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = MaterialTheme.spacing.extraLarge), + verticalAlignment = Alignment.Bottom + ) { + Icon( + modifier = Modifier.size(32.dp), + painter = painterResource(id = R.drawable.ic_add_photo), + contentDescription = null, + tint = MaterialTheme.colorScheme.tertiary + ) + Text( + modifier = Modifier.padding(start = MaterialTheme.spacing.extraSmall), + text = stringResource(id = R.string.expense_picture), + style = MaterialTheme.textStyle.h2, + color = MaterialTheme.colorScheme.onTertiary + ) + } + + if (hasImage) { + Card( + modifier = Modifier + .fillMaxWidth() + .height(160.dp) + .padding(top = MaterialTheme.spacing.small) + ) { + if (imageUri != null) { + Box(modifier = Modifier.fillMaxSize()) { + AsyncImage( + model = imageUri, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + contentDescription = "Selected image", + ) + IconButton( + modifier = Modifier.align(Alignment.TopEnd), + onClick = { + hasImage = false + imageUri = null + onPictureSelected(null) + } + ) { + Icon( + painter = painterResource(id = R.drawable.ic_delete), + contentDescription = "", + tint = Color.White + ) + } + } + } + } + } else { + TeiraEmptyState( + modifier = Modifier + .fillMaxWidth() + .height(160.dp) + .padding(top = MaterialTheme.spacing.small) + .clickable { + val uri = ComposeFileProvider.getImageUri( + expenseName ?: LocalDate + .now() + .toString(), context + ) + imageUri = uri + cameraLauncher.launch(uri) + }, + emptyStateLabel = R.string.expense_picture_empty_state_label + ) + } +} diff --git a/ui/src/main/java/br/com/tick/ui/screens/expense/viewmodels/ExpenseViewModel.kt b/ui/src/main/java/br/com/tick/ui/screens/expense/viewmodels/ExpenseViewModel.kt new file mode 100644 index 0000000..94b9bd7 --- /dev/null +++ b/ui/src/main/java/br/com/tick/ui/screens/expense/viewmodels/ExpenseViewModel.kt @@ -0,0 +1,109 @@ +package br.com.tick.ui.screens.expense.viewmodels + +import android.net.Uri +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import br.com.tick.sdk.dispatchers.DispatcherProvider +import br.com.tick.sdk.domain.CategorizedExpense +import br.com.tick.sdk.domain.CurrencyFormat +import br.com.tick.sdk.repositories.categorizedexpense.CategorizedExpenseRepository +import br.com.tick.sdk.repositories.expensecategory.ExpenseCategoryRepository +import br.com.tick.sdk.repositories.user.UserRepository +import com.google.android.gms.maps.model.LatLng +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import java.time.LocalDate +import javax.inject.Inject + +@HiltViewModel +class ExpenseViewModel @Inject constructor( + categoryRepository: ExpenseCategoryRepository, + userRepository: UserRepository, + private val categorizedExpenseRepository: CategorizedExpenseRepository, + private val dispatcherProvider: DispatcherProvider +) : ViewModel() { + + private val _categorizedExpense = MutableStateFlow(null) + val categorizedExpense = _categorizedExpense.asStateFlow() + + val categories = categoryRepository.getCategories() + .flowOn(dispatcherProvider.io()) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = listOf() + ) + + val currency = userRepository.getUser() + .flowOn(dispatcherProvider.io()) + .map { it.currency } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = CurrencyFormat.EURO + ) + + fun getExpense(expenseId: Int) { + viewModelScope.launch(dispatcherProvider.io()) { + categorizedExpenseRepository + .getCategorizedExpense(expenseId = expenseId) + .collect { + _categorizedExpense.emit(it) + } + } + } + + fun handleExpense( + expenseId: Int? = null, + categoryId: Int, + name: String, + value: Double, + expenseDate: LocalDate, + location: LatLng?, + photoUri: Uri? + ) { + viewModelScope.launch(dispatcherProvider.io()) { + if (expenseId != null) { + saveExpense(expenseId, categoryId, name, value, expenseDate, location, photoUri) + } else { + addExpense(categoryId, name, value, expenseDate, location, photoUri) + } + } + } + + private suspend fun saveExpense( + expenseId: Int, + categoryId: Int, + name: String, + value: Double, + expenseDate: LocalDate, + location: LatLng?, + photoUri: Uri? + ) { + categorizedExpenseRepository.updateExpense(expenseId, categoryId, name, value, expenseDate, location, photoUri) + } + + private suspend fun addExpense( + categoryId: Int, + name: String, + value: Double, + expenseDate: LocalDate, + location: LatLng?, + photoUri: Uri? + ) { + categorizedExpenseRepository.addExpense(categoryId, name, value, expenseDate, location, photoUri) + } + + fun removeExpense(expenseId: Int) { + viewModelScope.launch(dispatcherProvider.io()) { + categorizedExpenseRepository.removeExpense(expenseId) + } + } +} diff --git a/ui/src/main/java/br/com/tick/ui/screens/wallet/ExpensesGrid.kt b/ui/src/main/java/br/com/tick/ui/screens/wallet/ExpensesGrid.kt index 6a09002..e2a33a3 100644 --- a/ui/src/main/java/br/com/tick/ui/screens/wallet/ExpensesGrid.kt +++ b/ui/src/main/java/br/com/tick/ui/screens/wallet/ExpensesGrid.kt @@ -17,6 +17,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextDecoration import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavHostController +import br.com.tick.ui.NavigationItem import br.com.tick.ui.R import br.com.tick.ui.core.TeiraEmptyState import br.com.tick.ui.core.TeiraErrorState @@ -33,6 +35,7 @@ import br.com.tick.ui.theme.textStyle @Composable fun ExpensesGrid( modifier: Modifier = Modifier, + navHostController: NavHostController, expensesGridViewModel: ExpensesGridViewModel = hiltViewModel() ) { val expensesListState by remember { @@ -46,12 +49,13 @@ fun ExpensesGrid( AvailableBalanceIndicator(availableBalance) { expensesGridViewModel.toggleAvailableBalanceVisibility() } - Body(modifier, expensesListState, expensesGridViewModel) + Body(modifier, navHostController, expensesListState, expensesGridViewModel) } @Composable fun Body( modifier: Modifier, + navHostController: NavHostController, expensesListState: ExpensesGridStates, expensesGridViewModel: ExpensesGridViewModel ) { @@ -59,7 +63,12 @@ fun Body( is ExpensesGridStates.Empty -> TeiraEmptyState(modifier = modifier.fillMaxSize()) is ExpensesGridStates.Error -> TeiraErrorState(modifier.fillMaxSize()) is ExpensesGridStates.Loading -> TeiraLoadingState(modifier = modifier.fillMaxSize()) - is ExpensesGridStates.Success -> BodyGrid(modifier, expensesListState.expensesList, expensesGridViewModel) + is ExpensesGridStates.Success -> BodyGrid( + modifier = modifier, + expensesList = expensesListState.expensesList, + editExpense = { navHostController.navigate(NavigationItem.Expense.show(it)) }, + removeExpense = { expensesGridViewModel.removeCard(it) } + ) } } @@ -68,7 +77,8 @@ fun Body( fun BodyGrid( modifier: Modifier = Modifier, expensesList: List, - expensesGridViewModel: ExpensesGridViewModel + editExpense: (expenseId: Int) -> Unit, + removeExpense: (expenseId: Int) -> Unit ) { LazyVerticalGrid( modifier = modifier.padding(), @@ -79,10 +89,10 @@ fun BodyGrid( expenseCard = expense, modifier = Modifier .fillMaxWidth() - .padding(MaterialTheme.spacing.extraSmall) - ) { expenseId -> - expensesGridViewModel.removeCard(expenseId) - } + .padding(MaterialTheme.spacing.extraSmall), + onQuickActionEdit = editExpense, + onQuickActionDelete = removeExpense + ) } } } diff --git a/ui/src/main/java/br/com/tick/ui/screens/wallet/QuickExpenseBar.kt b/ui/src/main/java/br/com/tick/ui/screens/wallet/QuickExpenseBar.kt index 6471e82..53d30c1 100644 --- a/ui/src/main/java/br/com/tick/ui/screens/wallet/QuickExpenseBar.kt +++ b/ui/src/main/java/br/com/tick/ui/screens/wallet/QuickExpenseBar.kt @@ -1,23 +1,17 @@ package br.com.tick.ui.screens.wallet -import android.annotation.SuppressLint +import android.util.Log import androidx.compose.animation.AnimatedContent import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.DatePicker -import androidx.compose.material3.DatePickerDefaults -import androidx.compose.material3.DatePickerDialog -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.rememberDatePickerState import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -27,11 +21,11 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import br.com.tick.ui.R +import br.com.tick.ui.core.TeiraDatePicker import br.com.tick.ui.core.TeiraDropdown import br.com.tick.ui.core.TeiraFilledTonalButton import br.com.tick.ui.extensions.getLabelResource @@ -39,11 +33,7 @@ import br.com.tick.ui.screens.shared.AddCategoryDialog import br.com.tick.ui.screens.wallet.viewmodels.QuickExpenseBarViewModel import br.com.tick.ui.theme.spacing import br.com.tick.ui.theme.textStyle -import java.time.Instant import java.time.LocalDate -import java.time.LocalDateTime -import java.time.ZoneId -import java.time.format.DateTimeFormatter @OptIn(ExperimentalAnimationApi::class) @Composable @@ -54,7 +44,8 @@ fun QuickExpense( AnimatedContent( modifier = modifier.fillMaxWidth(), - targetState = isExpanded + targetState = isExpanded, + label = "" ) { targetState -> if (targetState) { ExpandedQuickExpense { @@ -77,7 +68,7 @@ fun ExpandedQuickExpense( var expenseName by remember { mutableStateOf(TextFieldValue()) } var expenseValue by remember { mutableStateOf(TextFieldValue()) } var isInvalidValue by remember { mutableStateOf(false) } - var selectedCategoryId by remember { mutableStateOf(-1) } + var selectedCategoryId by remember { mutableIntStateOf(-1) } var localDateTime by remember { mutableStateOf(LocalDate.now()) } var showAddNewCategoryDialog by remember { mutableStateOf(false) } val label = stringResource(id = R.string.wallet_quick_expense_select_category) @@ -199,7 +190,11 @@ fun ExpandedQuickExpense( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Bottom ) { - QuickExpenseDate(modifier = Modifier.weight(0.5f)) { + TeiraDatePicker( + modifier = Modifier.weight(0.5f), + color = MaterialTheme.colorScheme.onPrimary, + localDate = localDateTime + ) { localDateTime = it } TeiraFilledTonalButton( @@ -209,6 +204,7 @@ fun ExpandedQuickExpense( if (!isInvalidValue) { closeExpandedDialog() val selectedCategory = categoriesList.find { it.expenseCategoryId == selectedCategoryId } + if (expenseName.text.isNotEmpty() && (expenseValue.text.isNotEmpty() && expenseValue.text.toDouble() != 0.0) && selectedCategory != null @@ -248,72 +244,3 @@ fun ClosedQuickExpense(onClick: () -> Unit) { ) } } - -@SuppressLint("UnrememberedMutableState") -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun QuickExpenseDate( - modifier: Modifier = Modifier, - onDateChanged: (LocalDate) -> Unit -) { - var openDialog by remember { mutableStateOf(false) } - var localDate by remember { mutableStateOf(LocalDate.now()) } - - if (openDialog) { - val datePickerState = rememberDatePickerState() - val confirmEnabled = derivedStateOf { datePickerState.selectedDateMillis != null } - - DatePickerDialog( - onDismissRequest = { openDialog = false }, - confirmButton = { - TextButton( - onClick = { - openDialog = false - localDate = LocalDateTime.ofInstant( - Instant.ofEpochMilli(datePickerState.selectedDateMillis!!), - ZoneId.systemDefault() - ).toLocalDate() - onDateChanged(localDate) - }, - enabled = confirmEnabled.value - ) { - Text(text = stringResource(id = R.string.generic_ok)) - } - }, - dismissButton = { - TextButton( - onClick = { openDialog = false } - ) { - Text(text = stringResource(id = R.string.generic_cancel)) - } - } - ) { - DatePicker( - state = datePickerState, - colors = DatePickerDefaults.colors( - titleContentColor = MaterialTheme.colorScheme.tertiary, - headlineContentColor = MaterialTheme.colorScheme.tertiary, - weekdayContentColor = MaterialTheme.colorScheme.primary, - dayContentColor = MaterialTheme.colorScheme.primary, - disabledDayContentColor = MaterialTheme.colorScheme.secondary, - selectedDayContentColor = MaterialTheme.colorScheme.tertiary, - selectedDayContainerColor = MaterialTheme.colorScheme.onSecondary, - ) - ) - } - } - - Box( - modifier = modifier - ) { - Text( - modifier = Modifier - .align(Alignment.BottomStart) - .clickable { openDialog = true }, - text = localDate.format(DateTimeFormatter.ofPattern("dd/MM/yyyy")), - textDecoration = TextDecoration.Underline, - style = MaterialTheme.textStyle.h4, - color = MaterialTheme.colorScheme.onPrimary - ) - } -} diff --git a/ui/src/main/java/br/com/tick/ui/screens/wallet/WalletScreen.kt b/ui/src/main/java/br/com/tick/ui/screens/wallet/WalletScreen.kt index 819f2f8..a51ff9d 100644 --- a/ui/src/main/java/br/com/tick/ui/screens/wallet/WalletScreen.kt +++ b/ui/src/main/java/br/com/tick/ui/screens/wallet/WalletScreen.kt @@ -8,10 +8,12 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.navigation.NavHostController +import androidx.navigation.compose.rememberNavController import br.com.tick.ui.theme.spacing @Composable -fun WalletScreen() { +fun WalletScreen(navHostController: NavHostController) { Column( modifier = Modifier .fillMaxSize() @@ -20,6 +22,6 @@ fun WalletScreen() { verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.small) ) { QuickExpense() - ExpensesGrid() + ExpensesGrid(navHostController = navHostController) } } diff --git a/ui/src/main/java/br/com/tick/ui/theme/Color.kt b/ui/src/main/java/br/com/tick/ui/theme/Color.kt index 71f61b5..736af20 100644 --- a/ui/src/main/java/br/com/tick/ui/theme/Color.kt +++ b/ui/src/main/java/br/com/tick/ui/theme/Color.kt @@ -2,13 +2,13 @@ package br.com.tick.ui.theme import androidx.compose.ui.graphics.Color -val TeiraPrimaryColor = Color(0xFF6d7c8e) -val TeiraSecondaryColor = Color(0xFF6a6b68) -val TeiraTertiaryColor = Color(0xFFa45c3c) +val TeiraPrimaryColor = Color(0xFF6D788E) +val TeiraSecondaryColor = Color(0xFF989996) +val TeiraTertiaryColor = Color(0xFFCA5E2F) val TeiraOnPrimaryColor = Color(0xFFEFF1E9) -val TeiraOnSecondaryColor = Color(0xFFDCDDD9) -val TeiraOnTertiaryColor = Color(0xFF0c0c0c) +val TeiraOnSecondaryColor = Color(0xFFEFF1E9) +val TeiraOnTertiaryColor = Color(0xFF474444) -val TeiraSurfaceColor = Color(0xFFCFD1CA) +val TeiraSurfaceColor = Color(0xFFeeeeee) val TeiraSurfaceVariantColor = Color(0xFFA0AF7B) \ No newline at end of file diff --git a/ui/src/main/java/br/com/tick/ui/theme/Spacing.kt b/ui/src/main/java/br/com/tick/ui/theme/Spacing.kt index c827474..d8fdc03 100644 --- a/ui/src/main/java/br/com/tick/ui/theme/Spacing.kt +++ b/ui/src/main/java/br/com/tick/ui/theme/Spacing.kt @@ -37,6 +37,13 @@ data class TeiraTextStyle( lineHeight = 24.sp, letterSpacing = 3.sp ), + val h1extra: TextStyle = TextStyle( + fontFamily = Mulish, + fontWeight = FontWeight.Bold, + fontSize = 26.sp, + lineHeight = 24.sp, + letterSpacing = 3.sp + ), val h2: TextStyle = TextStyle( fontFamily = Mulish, fontWeight = FontWeight.Medium, diff --git a/ui/src/main/java/br/com/tick/utils/ComposeFileProvider.kt b/ui/src/main/java/br/com/tick/utils/ComposeFileProvider.kt new file mode 100644 index 0000000..65e72a7 --- /dev/null +++ b/ui/src/main/java/br/com/tick/utils/ComposeFileProvider.kt @@ -0,0 +1,27 @@ +package br.com.tick.utils + +import android.content.Context +import android.net.Uri +import androidx.core.content.FileProvider +import br.com.tick.ui.R +import java.io.File +import java.time.LocalDate + +class ComposeFileProvider : FileProvider(R.xml.filepaths) { + companion object { + fun getImageUri(fileName: String, context: Context): Uri { + val directory = File(context.filesDir, "images") + directory.mkdirs() + + val file = try { + File.createTempFile("${fileName}_", ".jpg", directory) + } catch (exception: IllegalArgumentException) { + File.createTempFile(LocalDate.now().toString(), ".jpg", directory) + } + + val authority = context.packageName + ".fileprovider" + + return getUriForFile(context, authority, file) + } + } +} diff --git a/ui/src/main/res/drawable/ic_add_photo.xml b/ui/src/main/res/drawable/ic_add_photo.xml new file mode 100644 index 0000000..4859261 --- /dev/null +++ b/ui/src/main/res/drawable/ic_add_photo.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/ui/src/main/res/drawable/ic_close.xml b/ui/src/main/res/drawable/ic_close.xml new file mode 100644 index 0000000..9032fa1 --- /dev/null +++ b/ui/src/main/res/drawable/ic_close.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/ui/src/main/res/drawable/ic_expense.xml b/ui/src/main/res/drawable/ic_expense.xml new file mode 100644 index 0000000..1b5c39c --- /dev/null +++ b/ui/src/main/res/drawable/ic_expense.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/ui/src/main/res/drawable/ic_location.xml b/ui/src/main/res/drawable/ic_location.xml new file mode 100644 index 0000000..aa209df --- /dev/null +++ b/ui/src/main/res/drawable/ic_location.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/ui/src/main/res/values/strings_analysis.xml b/ui/src/main/res/values/strings_analysis.xml index 6cfd3c3..428e752 100644 --- a/ui/src/main/res/values/strings_analysis.xml +++ b/ui/src/main/res/values/strings_analysis.xml @@ -5,7 +5,7 @@ Most expensive categories Financial Health - Your expenses is %1\$.2f percent of your income. %2\$s! + Your expenses is %1$.2f%% of your income. %2$s! You are safe! You need to be cautious. diff --git a/ui/src/main/res/values/strings_expense.xml b/ui/src/main/res/values/strings_expense.xml new file mode 100644 index 0000000..4f398c8 --- /dev/null +++ b/ui/src/main/res/values/strings_expense.xml @@ -0,0 +1,27 @@ + + + + Edit your expense + Expense + + Save + Can\'t save expense with invalid data + Add + Can\'t add expense with invalid data + + Expense + Cost + Select Category + Category + Add new Category + Expense Date + + Location + We need your permission to get your current location + Inform this expense location + Picture + Take a picture of your expense + + Delete expense + This will erase this expense and it will not be visible again + diff --git a/ui/src/main/res/values/strings_navigation.xml b/ui/src/main/res/values/strings_navigation.xml index 890f3a2..a80aa68 100644 --- a/ui/src/main/res/values/strings_navigation.xml +++ b/ui/src/main/res/values/strings_navigation.xml @@ -5,5 +5,6 @@ Wallet Analysis History + Expense diff --git a/ui/src/main/res/xml/filepaths.xml b/ui/src/main/res/xml/filepaths.xml new file mode 100644 index 0000000..57c458e --- /dev/null +++ b/ui/src/main/res/xml/filepaths.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/ui/src/test/java/br/com/tick/ui/viewmodels/AnalysisScreenViewModelTest.kt b/ui/src/test/java/br/com/tick/ui/viewmodels/AnalysisScreenViewModelTest.kt index 884670c..92eb82c 100644 --- a/ui/src/test/java/br/com/tick/ui/viewmodels/AnalysisScreenViewModelTest.kt +++ b/ui/src/test/java/br/com/tick/ui/viewmodels/AnalysisScreenViewModelTest.kt @@ -41,6 +41,7 @@ class AnalysisScreenViewModelTest { fetchLastMonthExpenses, getMostExpensiveCategories, calculateFinancialHealthSituation, + userRepository, FakeDispatcher() ) } @@ -49,20 +50,24 @@ class AnalysisScreenViewModelTest { fun `When user made an expense, financial health should reflect this change`() = runTest { val expenseRepository = FakeCategorizedExpenseRepository() val userRepository: UserRepository = FakeUserRepository() + + userRepository.setMonthlyIncome(1500.0) + expenseRepository.addExpense( + categoryId = 1, + name = "Something", + value = 15.0, + expenseDate = LocalDate.now(), + location = null, + photoUri = null + ) + val analysisScreenViewModel = getViewModel( categorizedExpenseRepository = expenseRepository, userRepository = userRepository ) - userRepository.setMonthlyIncome(1500.0) - expenseRepository.addExpense(0, "Name_1", 10.0, LocalDate.now()) - analysisScreenViewModel.financialHealthSituation.test { - val financialHealthState = awaitItem() - Truth.assertThat(financialHealthState).isInstanceOf(FinancialHealth.Situation::class.java) - Truth.assertThat( - (financialHealthState as FinancialHealth.Situation).percentageOfCompromisedIncome - ).isGreaterThan(0) + Truth.assertThat(awaitItem()).isInstanceOf(FinancialHealth.Situation::class.java) } } @@ -70,16 +75,14 @@ class AnalysisScreenViewModelTest { fun `When user has no expenses, financial health should have no available data`() = runTest { val expenseRepository = FakeCategorizedExpenseRepository() val userRepository: UserRepository = FakeUserRepository() + val analysisScreenViewModel = getViewModel( categorizedExpenseRepository = expenseRepository, userRepository = userRepository ) - userRepository.setMonthlyIncome(1500.0) - analysisScreenViewModel.financialHealthSituation.test { - val financialHealthState = awaitItem() - Truth.assertThat(financialHealthState).isInstanceOf(FinancialHealth.NoDataAvailable::class.java) + Truth.assertThat(awaitItem()).isInstanceOf(FinancialHealth.NoDataAvailable::class.java) } }