diff --git a/app/build.gradle b/app/build.gradle deleted file mode 100644 index d74f20a..0000000 --- a/app/build.gradle +++ /dev/null @@ -1,62 +0,0 @@ -plugins { - id 'com.android.application' - id 'org.jetbrains.kotlin.android' - id 'kotlin-kapt' -} - -android { - compileSdk 33 - - defaultConfig { - applicationId "com.danilkinkin.buckwheat" - minSdk 26 - targetSdk 33 - versionCode 1 - versionName "1.0" - - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - } - - buildTypes { - release { - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - } - } - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - kotlinOptions { - jvmTarget = '1.8' - } - buildFeatures { - viewBinding true - } -} - -dependencies { - implementation 'androidx.legacy:legacy-support-v4:1.0.0' - def room_version = "2.4.3" - def activity_version = "1.5.1" - - implementation "androidx.room:room-runtime:$room_version" - kapt "androidx.room:room-compiler:$room_version" - implementation "androidx.room:room-ktx:$room_version" - implementation "androidx.room:room-paging:$room_version" - implementation 'androidx.paging:paging-compose:1.0.0-alpha16' - - implementation "androidx.activity:activity:$activity_version" - implementation "androidx.activity:activity-ktx:$activity_version" - implementation "androidx.fragment:fragment-ktx:$activity_version" - - implementation "androidx.recyclerview:recyclerview:1.2.1" - - implementation 'androidx.core:core-ktx:1.8.0' - implementation 'androidx.appcompat:appcompat:1.5.0' - implementation 'com.google.android.material:material:1.6.1' - implementation 'androidx.constraintlayout:constraintlayout:2.1.4' - testImplementation 'junit:junit:4.13.2' - androidTestImplementation 'androidx.test.ext:junit:1.1.3' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' -} \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..c26fc1f --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,131 @@ +@file:Suppress("UnstableApiUsage") + +plugins { + id("com.android.application") + id("kotlin-android") + id("kotlin-kapt") + id("dagger.hilt.android.plugin") + id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin") +} + +android { + compileSdk = libs.versions.compileSdk.get().toInt() + + defaultConfig { + applicationId = "com.danilkinkin.buckwheat" + minSdk = libs.versions.minSdk.get().toInt() + targetSdk = libs.versions.targetSdk.get().toInt() + versionCode = 1 + versionName = "1.0" + testInstrumentationRunner = "com.danilkinkin.buckwheat.CustomTestRunner" + javaCompileOptions { + annotationProcessorOptions { + arguments["dagger.hilt.disableModulesHaveInstallInCheck"] = "true" + } + } + } + + buildTypes { + getByName("debug") { + signingConfig = signingConfigs.getByName("debug") + } + + getByName("release") { + isMinifyEnabled = true + signingConfig = signingConfigs.getByName("debug") + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro") + } + + create("benchmark") { + initWith(getByName("release")) + signingConfig = signingConfigs.getByName("debug") + matchingFallbacks.add("release") + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-benchmark-rules.pro") + isDebuggable = false + } + } + + compileOptions { + isCoreLibraryDesugaringEnabled = true + + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + buildFeatures { + compose = true + + } + + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get() + } + + packagingOptions { + // Multiple dependency bring these files in. Exclude them to enable + // our test APK to build (has no effect on our AARs) + excludes += "/META-INF/AL2.0" + excludes += "/META-INF/LGPL2.1" + } +} + +dependencies { + implementation(libs.kotlin.stdlib) + implementation(libs.kotlinx.coroutines.android) + + implementation(libs.androidx.appcompat) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.compose.runtime) + implementation(libs.androidx.compose.foundation) + implementation(libs.androidx.compose.foundation.layout) + implementation(libs.androidx.compose.ui.util) + implementation(libs.androidx.compose.materialWindow) + implementation(libs.androidx.compose.animation) + implementation(libs.androidx.compose.material.iconsExtended) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.runtime.livedata) + debugImplementation(libs.androidx.compose.ui.tooling) + implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material) + implementation("androidx.datastore:datastore-preferences:1.0.0") + + implementation("com.google.accompanist:accompanist-systemuicontroller:0.23.1") + + implementation(libs.androidx.room.runtime) + kapt(libs.androidx.room.compiler) + implementation(libs.androidx.room.ktx) + + implementation("androidx.room:room-paging:2.4.3") + implementation("androidx.paging:paging-compose:1.0.0-alpha16") + + implementation("com.google.dagger:dagger:2.43.2") + kapt("com.google.dagger:dagger-compiler:2.43.2") + + + implementation(libs.androidx.lifecycle.viewModelCompose) + + implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.hilt.android) + kapt(libs.hilt.compiler) + kapt(libs.hilt.ext.compiler) + + implementation(libs.coil.kt.compose) + + debugImplementation(libs.androidx.compose.ui.test.manifest) + + androidTestImplementation(libs.junit) + androidTestImplementation(libs.androidx.test.core) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.androidx.test.espresso.core) + androidTestImplementation(libs.androidx.test.rules) + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.kotlinx.coroutines.test) + androidTestImplementation(libs.androidx.compose.ui.test) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + androidTestImplementation(libs.hilt.android.testing) + coreLibraryDesugaring(libs.core.jdk.desugaring) + kaptAndroidTest(libs.hilt.compiler) +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 481bb43..4cb9458 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -14,8 +14,11 @@ # Uncomment this to preserve the line number information for # debugging stack traces. -#-keepattributes SourceFile,LineNumberTable +-keepattributes SourceFile,LineNumberTable # If you keep the line number information, uncomment this to # hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +-renamesourcefileattribute SourceFile + +# Repackage classes into the top-level. +-repackageclasses diff --git a/app/src/androidTest/AndroidManifest.xml b/app/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000..437f675 --- /dev/null +++ b/app/src/androidTest/AndroidManifest.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + diff --git a/app/src/androidTest/java/com/danilkinkin/buckwheat/CustomTestRunner.kt b/app/src/androidTest/java/com/danilkinkin/buckwheat/CustomTestRunner.kt new file mode 100644 index 0000000..b106696 --- /dev/null +++ b/app/src/androidTest/java/com/danilkinkin/buckwheat/CustomTestRunner.kt @@ -0,0 +1,12 @@ +package com.danilkinkin.buckwheat + +import android.app.Application +import android.content.Context +import androidx.test.runner.AndroidJUnitRunner +import dagger.hilt.android.testing.HiltTestApplication + +class CustomTestRunner : AndroidJUnitRunner() { + override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application { + return super.newApplication(cl, HiltTestApplication::class.java.name, context) + } +} diff --git a/app/src/androidTest/java/com/danilkinkin/buckwheat/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/danilkinkin/buckwheat/ExampleInstrumentedTest.kt deleted file mode 100644 index 917334a..0000000 --- a/app/src/androidTest/java/com/danilkinkin/buckwheat/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.danilkinkin.buckwheat - -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.ext.junit.runners.AndroidJUnit4 - -import org.junit.Test -import org.junit.runner.RunWith - -import org.junit.Assert.* - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.danilkinkin.buckwheat", appContext.packageName) - } -} \ No newline at end of file diff --git a/app/src/androidTest/java/com/danilkinkin/buckwheat/calendar/CalendarTest.kt b/app/src/androidTest/java/com/danilkinkin/buckwheat/calendar/CalendarTest.kt new file mode 100644 index 0000000..10785f5 --- /dev/null +++ b/app/src/androidTest/java/com/danilkinkin/buckwheat/calendar/CalendarTest.kt @@ -0,0 +1,91 @@ +package com.danilkinkin.buckwheat.calendar + +import com.danilkinkin.buckwheat.home.MainActivity +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasScrollToKeyAction +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollToKey +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.time.LocalDate + +@HiltAndroidTest +class CalendarTest { + + @get:Rule(order = 0) + var hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) + val composeTestRule = createAndroidComposeRule() + + private val currentYear = LocalDate.now().year + + @Before + fun setUp() { + hiltRule.inject() + composeTestRule.onNodeWithText("Select Dates").performClick() + } + + @Test + fun scrollsToTheBottom() { + + composeTestRule.onNodeWithText("January 1 $currentYear").assertIsDisplayed() + composeTestRule.onNode(hasScrollToKeyAction()).performScrollToKey("$currentYear/12/4") + composeTestRule.onNodeWithText("December 31 $currentYear").performClick() + + val datesSelected = composeTestRule.onDateNodes(true) + datesSelected[0].assertTextEquals("December 31 $currentYear") + } + + @Test + fun onDaySelected() { + composeTestRule.onNodeWithText("January 1 $currentYear").assertIsDisplayed() + composeTestRule.onNodeWithText("January 2 $currentYear") + .assertIsDisplayed().performClick() + composeTestRule.onNodeWithText("January 3 $currentYear").assertIsDisplayed() + + val datesNoSelected = composeTestRule.onDateNodes(false) + datesNoSelected[0].assertTextEquals("January 1 $currentYear") + datesNoSelected[1].assertTextEquals("January 3 $currentYear") + + composeTestRule.onDateNode(true).assertTextEquals("January 2 $currentYear") + } + + @Test + fun twoDaysSelected() { + composeTestRule.onNodeWithText("January 2 $currentYear") + .assertIsDisplayed().performClick() + + val datesNoSelectedOneClick = composeTestRule.onDateNodes(false) + datesNoSelectedOneClick[0].assertTextEquals("January 1 $currentYear") + datesNoSelectedOneClick[1].assertTextEquals("January 3 $currentYear") + + composeTestRule.onNodeWithText("January 4 $currentYear") + .assertIsDisplayed().performClick() + + val selected = composeTestRule.onDateNodes(true) + selected[0].assertTextEquals("January 2 $currentYear") + selected[1].assertTextEquals("January 3 $currentYear") + selected[2].assertTextEquals("January 4 $currentYear") + + val datesNoSelected = composeTestRule.onDateNodes(false) + datesNoSelected[0].assertTextEquals("January 1 $currentYear") + datesNoSelected[1].assertTextEquals("January 5 $currentYear") + } +} + +private fun ComposeTestRule.onDateNode(selected: Boolean) = onNode( + SemanticsMatcher.expectValue(DayStatusKey, selected) +) + +private fun ComposeTestRule.onDateNodes(selected: Boolean) = onAllNodes( + SemanticsMatcher.expectValue(DayStatusKey, selected) +) diff --git a/app/src/androidTest/java/com/danilkinkin/buckwheat/calendar/model/CalendarUiStateTest.kt b/app/src/androidTest/java/com/danilkinkin/buckwheat/calendar/model/CalendarUiStateTest.kt new file mode 100644 index 0000000..50a085e --- /dev/null +++ b/app/src/androidTest/java/com/danilkinkin/buckwheat/calendar/model/CalendarUiStateTest.kt @@ -0,0 +1,240 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.danilkinkin.buckwheat.calendar.model + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.LocalDate +import java.time.YearMonth + +class CalendarUiStateTest { + + @Test + fun dateOverlapsWithWeekPeriod() { + val calendarState = CalendarUiState( + selectedStartDate = LocalDate.of(2022, 4, 14), + selectedEndDate = LocalDate.of(2022, 4, 16) + ) + val result = calendarState.hasSelectedPeriodOverlap( + LocalDate.of(2022, 4, 11), + LocalDate.of(2022, 4, 17) + ) + + assertTrue(result) + } + + @Test + fun dateOverlapsWithNoSelectedEndDate() { + val calendarState = CalendarUiState( + selectedStartDate = LocalDate.of(2022, 4, 14), + selectedEndDate = null + ) + val result = calendarState.hasSelectedPeriodOverlap( + LocalDate.of(2022, 4, 11), + LocalDate.of(2022, 4, 17) + ) + + assertTrue(result) + } + + @Test + fun datesDoNotOverlap() { + val calendarState = CalendarUiState( + selectedStartDate = LocalDate.of(2022, 4, 14), + selectedEndDate = LocalDate.of(2022, 4, 16) + ) + val result = calendarState.hasSelectedPeriodOverlap( + LocalDate.of(2022, 5, 11), + LocalDate.of(2022, 5, 17) + ) + assertFalse(result) + } + + @Test + fun testIsLeftWeekHighlighted() { + val calendarState = CalendarUiState( + selectedStartDate = LocalDate.of(2022, 2, 5), + selectedEndDate = LocalDate.of(2022, 2, 10) + ) + val resultBeginningWeek = calendarState.isLeftHighlighted( + LocalDate.of(2022, 2, 1), + YearMonth.of(2022, 2) + ) + assertFalse(resultBeginningWeek) + val resultWeekLater = calendarState.isLeftHighlighted( + LocalDate.of(2022, 2, 7), + YearMonth.of(2022, 2) + ) + assertTrue(resultWeekLater) + } + + @Test + fun testIsRightWeekHighlighted() { + val calendarState = CalendarUiState( + selectedStartDate = LocalDate.of(2022, 2, 5), + selectedEndDate = LocalDate.of(2022, 2, 10) + ) + val resultBeginningWeek = calendarState.isRightHighlighted( + LocalDate.of(2022, 2, 1), + YearMonth.of(2022, 2) + ) + assertTrue(resultBeginningWeek) + val resultWeekLater = calendarState.isRightHighlighted( + LocalDate.of(2022, 2, 7), + YearMonth.of(2022, 2) + ) + assertFalse(resultWeekLater) + } + + @Test + fun testStartAnimationOffsetCalculation_Forwards_WithinSameMonth() { + val calendarState = CalendarUiState( + selectedStartDate = LocalDate.of(2022, 2, 15), + selectedEndDate = LocalDate.of(2022, 2, 22) + ) + + val offset = calendarState.selectedStartOffset( + LocalDate.of(2022, 2, 14), + YearMonth.of(2022, 2) + ) + assertEquals(1, offset) + val offsetSecondWeek = calendarState.selectedStartOffset( + LocalDate.of(2022, 2, 21), + YearMonth.of(2022, 2) + ) + assertEquals(0, offsetSecondWeek) + } + + @Test + fun testStartAnimationOffsetCalculation_Backwards_WithinSameMonth() { + val calendarState = CalendarUiState( + selectedStartDate = LocalDate.of(2022, 2, 15), + selectedEndDate = LocalDate.of(2022, 2, 22), + ) + + val offset = calendarState.selectedStartOffset( + LocalDate.of(2022, 2, 14), + YearMonth.of(2022, 2) + ) + assertEquals(7, offset) + val offsetSecondWeek = calendarState.selectedStartOffset( + LocalDate.of(2022, 2, 21), + YearMonth.of(2022, 2) + ) + + assertEquals(2, offsetSecondWeek) + } + + @Test + fun testStartAnimationOffsetCalculation_Forwards_OverTwoMonths() { + val calendarState = CalendarUiState( + selectedStartDate = LocalDate.of(2022, 3, 23), + selectedEndDate = LocalDate.of(2022, 4, 3), + ) + + val offset = calendarState.selectedStartOffset( + LocalDate.of(2022, 3, 21), + YearMonth.of(2022, 3) + ) + assertEquals(2, offset) + val offsetSecondWeek = calendarState.selectedStartOffset( + LocalDate.of(2022, 3, 28), + YearMonth.of(2022, 3) + ) + + assertEquals(0, offsetSecondWeek) + val offsetFirstWeekSecondMonth = calendarState.selectedStartOffset( + LocalDate.of(2022, 3, 28), + YearMonth.of(2022, 4) + ) + assertEquals(4, offsetFirstWeekSecondMonth) + } + + @Test + fun testStartAnimationOffsetCalculation_Backwards_OverTwoMonths() { + val calendarState = CalendarUiState( + selectedStartDate = LocalDate.of(2022, 3, 23), + selectedEndDate = LocalDate.of(2022, 3, 31), + ) + + val offsetSecondWeek = calendarState.selectedStartOffset( + LocalDate.of(2022, 3, 21), + YearMonth.of(2022, 3) + ) + + assertEquals(7, offsetSecondWeek) + val offsetFirstWeekSecondMonth = calendarState.selectedStartOffset( + LocalDate.of(2022, 3, 28), + YearMonth.of(2022, 3) + ) + assertEquals(4, offsetFirstWeekSecondMonth) + } + @Test + fun testStartAnimationOffsetCalculation_Backwards_OverTwoMonths_June() { + val calendarState = CalendarUiState( + selectedStartDate = LocalDate.of(2022, 5, 27), + selectedEndDate = LocalDate.of(2022, 6, 4), + ) + + val offsetSecondWeek = calendarState.selectedStartOffset( + LocalDate.of(2022, 5, 23), + YearMonth.of(2022, 5) + ) + + assertEquals(7, offsetSecondWeek) // should be all the way to the end + val offsetFirstWeekSecondMonth = calendarState.selectedStartOffset( + LocalDate.of(2022, 5, 30), + YearMonth.of(2022, 5) + ) + assertEquals(2, offsetFirstWeekSecondMonth) + + val offsetThird = calendarState.selectedStartOffset( + LocalDate.of(2022, 5, 30), + YearMonth.of(2022, 6) + ) + assertEquals(6, offsetThird) + } + + @Test + fun totalNumberDaySelected() { + val calendarState = CalendarUiState( + selectedStartDate = LocalDate.of(2022, 5, 27), + selectedEndDate = LocalDate.of(2022, 6, 10), + ) + + assertEquals(15f, calendarState.numberSelectedDays) + + val calendarStateNoEnd = CalendarUiState( + selectedStartDate = LocalDate.of(2022, 5, 27), + ) + + assertEquals(1f, calendarStateNoEnd.numberSelectedDays) + + val calendarStateNoStart = CalendarUiState() + + assertEquals(0f, calendarStateNoStart.numberSelectedDays) + + val calendarStateShort = CalendarUiState( + selectedStartDate = LocalDate.of(2022, 5, 27), + selectedEndDate = LocalDate.of(2022, 5, 28), + ) + + assertEquals(2f, calendarStateShort.numberSelectedDays) + } +} diff --git a/app/src/androidTest/java/com/danilkinkin/buckwheat/home/HomeTest.kt b/app/src/androidTest/java/com/danilkinkin/buckwheat/home/HomeTest.kt new file mode 100644 index 0000000..2051f98 --- /dev/null +++ b/app/src/androidTest/java/com/danilkinkin/buckwheat/home/HomeTest.kt @@ -0,0 +1,31 @@ +package com.danilkinkin.buckwheat.home + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Rule +import org.junit.Test + +@HiltAndroidTest +class HomeTest { + + @get:Rule(order = 0) + var hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) + val composeTestRule = createAndroidComposeRule() + + @Test + fun home_navigatesToAllScreens() { + composeTestRule.onNodeWithText("Explore Flights by Destination").assertIsDisplayed() + composeTestRule.onNodeWithText("SLEEP").performClick() + composeTestRule.onNodeWithText("Explore Properties by Destination").assertIsDisplayed() + composeTestRule.onNodeWithText("EAT").performClick() + composeTestRule.onNodeWithText("Explore Restaurants by Destination").assertIsDisplayed() + composeTestRule.onNodeWithText("FLY").performClick() + composeTestRule.onNodeWithText("Explore Flights by Destination").assertIsDisplayed() + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8ea41b2..d1b34ba 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,21 +4,20 @@ package="com.danilkinkin.buckwheat"> + android:theme="@style/Theme.BuckwheatTheme"> + + + android:name="com.danilkinkin.buckwheat.home.MainActivity" + android:exported="true"> diff --git a/app/src/main/java/com/danilkinkin/buckwheat/Application.kt b/app/src/main/java/com/danilkinkin/buckwheat/Application.kt new file mode 100644 index 0000000..731241b --- /dev/null +++ b/app/src/main/java/com/danilkinkin/buckwheat/Application.kt @@ -0,0 +1,7 @@ +package com.danilkinkin.buckwheat + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class Application : Application() diff --git a/app/src/main/java/com/danilkinkin/buckwheat/BuckwheatApplication.kt b/app/src/main/java/com/danilkinkin/buckwheat/BuckwheatApplication.kt deleted file mode 100644 index 0f5715a..0000000 --- a/app/src/main/java/com/danilkinkin/buckwheat/BuckwheatApplication.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.danilkinkin.buckwheat - -import android.app.Application -import com.google.android.material.color.DynamicColors - -class BuckwheatApplication: Application() { - override fun onCreate() { - super.onCreate() - // Apply dynamic color - DynamicColors.applyToActivitiesIfAvailable(this) - } -} diff --git a/app/src/main/java/com/danilkinkin/buckwheat/MainActivity.kt b/app/src/main/java/com/danilkinkin/buckwheat/MainActivity.kt deleted file mode 100644 index 8ecff5e..0000000 --- a/app/src/main/java/com/danilkinkin/buckwheat/MainActivity.kt +++ /dev/null @@ -1,244 +0,0 @@ -package com.danilkinkin.buckwheat - -import android.content.Context -import android.content.SharedPreferences -import android.content.res.Configuration -import android.os.Build -import android.os.Bundle -import android.util.AttributeSet -import android.util.Log -import android.view.* -import android.view.ViewGroup.MarginLayoutParams -import androidx.activity.viewModels -import androidx.appcompat.app.AppCompatActivity -import androidx.appcompat.app.AppCompatDelegate -import androidx.coordinatorlayout.widget.CoordinatorLayout -import androidx.core.graphics.ColorUtils -import androidx.core.view.ViewCompat -import androidx.core.view.WindowCompat -import androidx.core.view.WindowInsetsCompat -import androidx.recyclerview.widget.* -import com.danilkinkin.buckwheat.adapters.* -import com.danilkinkin.buckwheat.decorators.SpendsDividerItemDecoration -import com.danilkinkin.buckwheat.utils.getNavigationBarHeight -import com.danilkinkin.buckwheat.utils.getThemeColor -import com.danilkinkin.buckwheat.viewmodels.SpentViewModel -import com.danilkinkin.buckwheat.widgets.topsheet.TopSheetBehavior -import com.google.android.material.color.DynamicColors -import com.google.android.material.floatingactionbutton.FloatingActionButton - - -var instance: MainActivity? = null - -class MainActivity : AppCompatActivity() { - lateinit var model: SpentViewModel - - lateinit var parentView: View - - val recyclerView: RecyclerView by lazy { - findViewById(R.id.recycle_view) - } - - private val fabHome: FloatingActionButton by lazy { - findViewById(R.id.fab_home_btn) - } - - lateinit var appSettingsPrefs: SharedPreferences - - companion object { - val TAG: String = MainActivity::class.java.simpleName - - fun getInstance(): MainActivity { - return instance!! - } - } - - init { - instance = this - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - appSettingsPrefs = getSharedPreferences("AppSettingsPrefs", 0) - - val mode = appSettingsPrefs.getInt("nightMode", AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) - - if (AppCompatDelegate.getDefaultNightMode() != mode) { - AppCompatDelegate.setDefaultNightMode(appSettingsPrefs.getInt("nightMode", AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)) - } - - val isNightMode = when (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) { - Configuration.UI_MODE_NIGHT_YES -> true - Configuration.UI_MODE_NIGHT_NO -> false - else -> false - } - - WindowCompat.setDecorFitsSystemWindows(window, false) - - setContentView(R.layout.activity_main) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - DynamicColors.applyToActivitiesIfAvailable(application) - } - - val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView) - - windowInsetsController.isAppearanceLightNavigationBars = true - - ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.root)) { view, windowInsets -> - val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) - - val mlp = view.layoutParams as MarginLayoutParams - mlp.topMargin = 0 - mlp.leftMargin = insets.left - mlp.bottomMargin = 0 - mlp.rightMargin = insets.right - view.layoutParams = mlp - - WindowInsetsCompat.CONSUMED - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - window.insetsController?.setSystemBarsAppearance( - if (isNightMode) 0 else WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS, - WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS - ) - } - - window.statusBarColor = ColorUtils.setAlphaComponent( - getThemeColor(this, com.google.android.material.R.attr.colorPrimaryContainer), - 230, - ) - - val model: SpentViewModel by viewModels() - - this.model = model - - build() - observe() - } - - override fun onCreateView( - parent: View?, - name: String, - context: Context, - attrs: AttributeSet - ): View? { - if (parent != null) { - parentView = parent - } - - return super.onCreateView(parent, name, context, attrs) - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - // Inflate the menu; this adds items to the action bar if it is present. - menuInflater.inflate(R.menu.menu_main, menu) - return true - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - // Handle action bar item clicks here. The action bar will - // automatically handle clicks on the Home/Up button, so long - // as you specify a parent activity in AndroidManifest.xml. - return when (item.itemId) { - R.id.action_settings -> true - else -> super.onOptionsItemSelected(item) - } - } - - private fun observe() { - model.requireReCalcBudget.observeForever { - if (it) { - val newDayBottomSheet = NewDayBottomSheet() - newDayBottomSheet.show(supportFragmentManager, NewDayBottomSheet.TAG) - } - } - - model.requireSetBudget.observe(this) { - if (it) { - val walletBottomSheet = WalletBottomSheet() - walletBottomSheet.show(supportFragmentManager, WalletBottomSheet.TAG) - } - } - } - - private fun build() { - var isChangeBecauseRemove = false - - val layoutManager = object : LinearLayoutManager(this) { - private var isScrollEnabled = true - - fun setScrollEnabled(flag: Boolean) { - this.isScrollEnabled = flag - } - - override fun canScrollVertically(): Boolean { - return isScrollEnabled && super.canScrollVertically(); - } - } - - val spendsDividerItemDecoration = SpendsDividerItemDecoration(recyclerView.context) - recyclerView.addItemDecoration(spendsDividerItemDecoration) - - layoutManager.stackFromEnd = true - - val topAdapter = TopAdapter(model) - val spendsAdapter = SpendsAdapter() - val contactAdapter = ConcatAdapter(topAdapter, spendsAdapter) - - recyclerView.layoutManager = layoutManager - recyclerView.adapter = contactAdapter - recyclerView.scrollToPosition(spendsAdapter.itemCount - 1) - - val swipeToDeleteCallback = SwipeToDeleteCallback(applicationContext, spendsAdapter) { - isChangeBecauseRemove = true - model.removeSpent(it) - } - - val itemTouchhelper = ItemTouchHelper(swipeToDeleteCallback) - - itemTouchhelper.attachToRecyclerView(recyclerView) - - spendsAdapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { - override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { - if (isChangeBecauseRemove) { - isChangeBecauseRemove = false - } else { - layoutManager.scrollToPosition(spendsAdapter.itemCount) - } - } - }); - - model.getSpends().observe(this) { spents -> - spendsAdapter.submitList(spents) - } - - model.budget.observe(this) { - topAdapter.notifyDataSetChanged() - } - - val params = (fabHome.layoutParams as MarginLayoutParams) - - params.bottomMargin = params.bottomMargin + getNavigationBarHeight(parentView) - - fabHome.setOnClickListener { - recyclerView.smoothScrollToPosition(contactAdapter.itemCount - 1) - - fabHome.hide() - - val topSheetBehavior = try { - ((recyclerView.layoutParams as CoordinatorLayout.LayoutParams).behavior as TopSheetBehavior) - } catch (e: Exception) { - null - } - - topSheetBehavior?.setSmartState(TopSheetBehavior.Companion.State.STATE_HIDDEN) - } - } - - override fun onSupportNavigateUp(): Boolean { - return super.onSupportNavigateUp() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/danilkinkin/buckwheat/NewDayBottomSheet.kt b/app/src/main/java/com/danilkinkin/buckwheat/NewDayBottomSheet.kt deleted file mode 100644 index 25bda85..0000000 --- a/app/src/main/java/com/danilkinkin/buckwheat/NewDayBottomSheet.kt +++ /dev/null @@ -1,149 +0,0 @@ -package com.danilkinkin.buckwheat - -import android.os.Bundle -import android.util.Log -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.activityViewModels -import com.danilkinkin.buckwheat.utils.countDays -import com.danilkinkin.buckwheat.utils.prettyCandyCanes -import com.danilkinkin.buckwheat.viewmodels.AppViewModel -import com.danilkinkin.buckwheat.viewmodels.SpentViewModel -import com.danilkinkin.buckwheat.widgets.bottomsheet.BottomSheetFragment -import com.google.android.material.card.MaterialCardView -import com.google.android.material.textview.MaterialTextView -import java.math.RoundingMode -import kotlin.math.abs - -class NewDayBottomSheet : BottomSheetFragment() { - companion object { - val TAG = NewDayBottomSheet::class.simpleName - } - - private lateinit var appModel: AppViewModel - private lateinit var spentModel: SpentViewModel - - private val restBudgetOfDayTextView: MaterialTextView by lazy { - requireView().findViewById(R.id.rest_budget_of_day) - } - - private val splitRestDaysDescriptionTextView: MaterialTextView by lazy { - requireView().findViewById(R.id.spli_rest_days_description) - } - - private val addCurrentDayDescriptionTextView: MaterialTextView by lazy { - requireView().findViewById(R.id.add_current_day_description) - } - - private val splitRestDaysCardView: MaterialCardView by lazy { - requireView().findViewById(R.id.split_rest_days) - } - - private val addCurrentDayCardView: MaterialCardView by lazy { - requireView().findViewById(R.id.add_current_day) - } - - private val debugTextView: MaterialTextView by lazy { - requireView().findViewById(R.id.debug) - } - - private val splitRestDaysDebugTextView: MaterialTextView by lazy { - requireView().findViewById(R.id.spli_rest_days_debug) - } - - private val addCurrentDayDebugTextView: MaterialTextView by lazy { - requireView().findViewById(R.id.add_current_day_debug) - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - isCancelable = false - - return inflater.inflate(R.layout.modal_bottom_sheet_new_day, container, false) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - val appModel: AppViewModel by activityViewModels() - val spentModel: SpentViewModel by activityViewModels() - - this.appModel = appModel - this.spentModel = spentModel - - build() - } - - private fun build() { - val restDays = countDays(spentModel.finishDate) - val skippedDays = abs(countDays(spentModel.lastReCalcBudgetDate!!)) - - val restBudget = - (spentModel.budget.value!! - spentModel.spent.value!!) - spentModel.dailyBudget.value!! - val perDayBudget = restBudget / (restDays + skippedDays - 1).toBigDecimal() - - val requireDistributeBudget = perDayBudget * (skippedDays - 1).coerceAtLeast(0) - .toBigDecimal() + spentModel.dailyBudget.value!! - spentModel.spentFromDailyBudget.value!! - - val budgetPerDaySplit = - ((restBudget + spentModel.dailyBudget.value!! - spentModel.spentFromDailyBudget.value!!) / restDays.toBigDecimal()).setScale( - 0, - RoundingMode.FLOOR - ) - val budgetPerDayAdd = (restBudget / restDays.toBigDecimal()).setScale(0, RoundingMode.FLOOR) - val budgetPerDayAddDailyBudget = budgetPerDayAdd + requireDistributeBudget - - restBudgetOfDayTextView.text = prettyCandyCanes(requireDistributeBudget) - - if (appModel.isDebug.value == true) { - debugTextView.visibility = View.VISIBLE - debugTextView.text = "Осталось дней = $restDays " + - "\nПрошло дней с последнего пересчета = $skippedDays " + - "\nНачало = ${spentModel.startDate} " + - "\nПоследний пересчет = ${spentModel.lastReCalcBudgetDate} " + - "\nКонец = ${spentModel.finishDate} " + - "\nВесь бюджет = ${spentModel.budget.value!!}" + - "\nПотрачено из бюджета = ${spentModel.spent.value!!}" + - "\nБюджет на сегодня = ${spentModel.dailyBudget.value!!}" + - "\nПотрачено из дневного бюджета = ${spentModel.spentFromDailyBudget.value!!}" + - "\nОставшийся бюджет = $restBudget" + - "\nОставшийся бюджет на по дням = $perDayBudget" - - - splitRestDaysDebugTextView.visibility = View.VISIBLE - splitRestDaysDebugTextView.text = - "($restBudget + ${spentModel.dailyBudget.value!!} - ${spentModel.spentFromDailyBudget.value!!}) / $restDays = $budgetPerDaySplit" - - addCurrentDayDebugTextView.visibility = View.VISIBLE - addCurrentDayDebugTextView.text = "$restBudget / $restDays = $budgetPerDayAdd " + - "\n${budgetPerDayAdd} + $requireDistributeBudget = $budgetPerDayAddDailyBudget" - } - - splitRestDaysDescriptionTextView.text = requireContext().getString( - R.string.split_rest_days_description, - prettyCandyCanes(budgetPerDaySplit), - ) - - addCurrentDayDescriptionTextView.text = requireContext().getString( - R.string.add_current_day_description, - prettyCandyCanes(requireDistributeBudget + budgetPerDayAdd), - prettyCandyCanes(budgetPerDayAdd), - ) - - splitRestDaysCardView.setOnClickListener { - spentModel.reCalcDailyBudget(budgetPerDaySplit) - - dismiss() - } - - addCurrentDayCardView.setOnClickListener { - spentModel.reCalcDailyBudget(budgetPerDayAdd + requireDistributeBudget) - - dismiss() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/danilkinkin/buckwheat/SettingsBottomSheet.kt b/app/src/main/java/com/danilkinkin/buckwheat/SettingsBottomSheet.kt deleted file mode 100644 index 9b0860c..0000000 --- a/app/src/main/java/com/danilkinkin/buckwheat/SettingsBottomSheet.kt +++ /dev/null @@ -1,140 +0,0 @@ -package com.danilkinkin.buckwheat - -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Intent -import android.net.Uri -import android.os.Bundle -import android.view.* -import android.widget.* -import androidx.appcompat.app.AppCompatDelegate -import androidx.core.content.ContextCompat.getSystemService -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.fragment.app.activityViewModels -import com.danilkinkin.buckwheat.viewmodels.AppViewModel -import com.danilkinkin.buckwheat.viewmodels.SpentViewModel -import com.danilkinkin.buckwheat.widgets.bottomsheet.BottomSheetFragment -import com.google.android.material.card.MaterialCardView -import java.util.* - - -class SettingsBottomSheet : BottomSheetFragment() { - companion object { - val TAG = SettingsBottomSheet::class.simpleName - } - - private lateinit var model: AppViewModel - private lateinit var spendsModel: SpentViewModel - - private val openSiteBtn: MaterialCardView by lazy { - requireView().findViewById(R.id.site) - } - - private val reportBugBtn: MaterialCardView by lazy { - requireView().findViewById(R.id.report_bug) - } - - private val themeSwitcher: RadioGroup by lazy { - requireView().findViewById(R.id.theme_switcher) - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.modal_bottom_sheet_settings, container, false) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - val model: AppViewModel by activityViewModels() - val spendsModel: SpentViewModel by activityViewModels() - - this.model = model - this.spendsModel = spendsModel - - ViewCompat.setOnApplyWindowInsetsListener(view) { _, windowInsets -> - val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) - - view.findViewById(R.id.content).setPadding(0, 0, 0, insets.bottom) - - WindowInsetsCompat.CONSUMED - } - - build() - } - - private fun build() { - themeSwitcher.setOnCheckedChangeListener { group, checkedId -> - val mode = when (checkedId) { - R.id.theme_switcher_light -> AppCompatDelegate.MODE_NIGHT_NO - R.id.theme_switcher_dark -> AppCompatDelegate.MODE_NIGHT_YES - else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM - } - - AppCompatDelegate.setDefaultNightMode(mode) - MainActivity.getInstance().appSettingsPrefs.edit().putInt("nightMode", mode).apply() - } - - themeSwitcher.check(when (AppCompatDelegate.getDefaultNightMode()) { - AppCompatDelegate.MODE_NIGHT_NO -> R.id.theme_switcher_light - AppCompatDelegate.MODE_NIGHT_YES -> R.id.theme_switcher_dark - else -> R.id.theme_switcher_system - }) - - openSiteBtn.setOnClickListener { - val url = "https://danilkinkin.com" - val intent = Intent(Intent.ACTION_VIEW) - - intent.data = Uri.parse(url) - - try { - startActivity(intent) - } catch (e: Exception) { - val clipboard = getSystemService( - requireContext(), - ClipboardManager::class.java - ) as ClipboardManager - - clipboard.setPrimaryClip(ClipData.newPlainText("url", url)) - - Toast - .makeText( - context, - requireContext().getString(R.string.copy_in_clipboard), - Toast.LENGTH_LONG - ) - .show() - } - } - - reportBugBtn.setOnClickListener { - val url = "https://github.com/danilkinkin/buckweat/issues" - val intent = Intent(Intent.ACTION_VIEW) - - intent.data = Uri.parse(url) - - try { - startActivity(intent) - } catch (e: Exception) { - val clipboard = getSystemService( - requireContext(), - ClipboardManager::class.java - ) as ClipboardManager - - clipboard.setPrimaryClip(ClipData.newPlainText("url", url)) - - Toast - .makeText( - context, - requireContext().getString(R.string.copy_in_clipboard), - Toast.LENGTH_LONG - ) - .show() - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/danilkinkin/buckwheat/SwipeToDeleteCallback.kt b/app/src/main/java/com/danilkinkin/buckwheat/SwipeToDeleteCallback.kt deleted file mode 100644 index 81ec21e..0000000 --- a/app/src/main/java/com/danilkinkin/buckwheat/SwipeToDeleteCallback.kt +++ /dev/null @@ -1,119 +0,0 @@ -package com.danilkinkin.buckwheat - -import android.content.Context -import android.graphics.* -import android.graphics.drawable.ColorDrawable -import android.graphics.drawable.Drawable -import android.view.View -import androidx.core.content.ContextCompat -import androidx.recyclerview.widget.ItemTouchHelper -import androidx.recyclerview.widget.RecyclerView -import com.danilkinkin.buckwheat.adapters.SpendsAdapter -import com.danilkinkin.buckwheat.entities.Spent - -class SwipeToDeleteCallback() : ItemTouchHelper.Callback() { - var mContext: Context? = null - private var mClearPaint: Paint? = null - private var mBackground: ColorDrawable? = null - private var backgroundColor = 0 - private var deleteDrawable: Drawable? = null - private var intrinsicWidth = 0 - private var intrinsicHeight = 0 - private lateinit var deleteCallback: (spent: Spent) -> Unit - private lateinit var spendsAdapter: SpendsAdapter - - - constructor(context: Context?, spendsAdapter: SpendsAdapter, deleteCallback: (spent: Spent) -> Unit) : this() { - mContext = context - this.deleteCallback = deleteCallback - this.spendsAdapter = spendsAdapter - mBackground = ColorDrawable() - backgroundColor = mContext?.let { ContextCompat.getColor(it, R.color.delete) }!! - mClearPaint = Paint() - mClearPaint!!.xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR) - deleteDrawable = mContext?.let { ContextCompat.getDrawable(it, R.drawable.ic_delete_forever) } - intrinsicWidth = deleteDrawable!!.intrinsicWidth - intrinsicHeight = deleteDrawable!!.intrinsicHeight - - - } - - override fun getMovementFlags( - recyclerView: RecyclerView, - viewHolder: RecyclerView.ViewHolder - ): Int { - return if (viewHolder is SpendsAdapter.DrawViewHolder) { - makeMovementFlags(0, ItemTouchHelper.LEFT) - } else { - 0 - } - } - - override fun isItemViewSwipeEnabled(): Boolean { - return true - } - - override fun onMove( - recyclerView: RecyclerView, - viewHolder: RecyclerView.ViewHolder, - target: RecyclerView.ViewHolder - ): Boolean { - return false - } - - override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { - if (viewHolder is SpendsAdapter.DrawViewHolder) { - viewHolder.currentSpent?.let { deleteCallback(it) } - } - } - - override fun onChildDraw( - c: Canvas, - recyclerView: RecyclerView, - viewHolder: RecyclerView.ViewHolder, - dX: Float, - dY: Float, - actionState: Int, - isCurrentlyActive: Boolean - ) { - super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive) - val itemView: View = viewHolder.itemView - val itemHeight: Int = itemView.height - val isCancelled = dX == 0f && !isCurrentlyActive - if (isCancelled) { - clearCanvas( - c, - itemView.right + dX, - itemView.top.toFloat(), - itemView.right.toFloat(), - itemView.bottom.toFloat() - ) - super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive) - return - } - mBackground!!.color = backgroundColor - mBackground!!.setBounds( - itemView.right + dX.toInt(), - itemView.top, - itemView.right, - itemView.bottom, - ) - mBackground!!.draw(c) - val deleteIconTop: Int = itemView.top + (itemHeight - intrinsicHeight) / 2 - val deleteIconMargin = (itemHeight - intrinsicHeight) / 2 - val deleteIconLeft: Int = itemView.right - deleteIconMargin - intrinsicWidth - val deleteIconRight: Int = itemView.right - deleteIconMargin - val deleteIconBottom = deleteIconTop + intrinsicHeight - deleteDrawable!!.setBounds(deleteIconLeft, deleteIconTop, deleteIconRight, deleteIconBottom) - deleteDrawable!!.draw(c) - super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive) - } - - private fun clearCanvas(c: Canvas, left: Float, top: Float, right: Float, bottom: Float) { - mClearPaint?.let { c.drawRect(left, top, right, bottom, it) } - } - - override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float { - return 0.2F - } -} \ No newline at end of file diff --git a/app/src/main/java/com/danilkinkin/buckwheat/WalletBottomSheet.kt b/app/src/main/java/com/danilkinkin/buckwheat/WalletBottomSheet.kt deleted file mode 100644 index a33a8bd..0000000 --- a/app/src/main/java/com/danilkinkin/buckwheat/WalletBottomSheet.kt +++ /dev/null @@ -1,368 +0,0 @@ -package com.danilkinkin.buckwheat - -import androidx.appcompat.app.AlertDialog -import android.content.DialogInterface -import android.os.Bundle -import android.text.Editable -import android.text.TextWatcher -import android.view.* -import android.widget.CalendarView -import android.widget.ImageView -import android.widget.LinearLayout -import android.widget.TextView -import androidx.constraintlayout.widget.ConstraintLayout -import androidx.coordinatorlayout.widget.CoordinatorLayout -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.fragment.app.activityViewModels -import com.danilkinkin.buckwheat.adapters.CurrencyAdapter -import com.danilkinkin.buckwheat.utils.* -import com.danilkinkin.buckwheat.viewmodels.AppViewModel -import com.danilkinkin.buckwheat.viewmodels.SpentViewModel -import com.danilkinkin.buckwheat.widgets.bottomsheet.BottomSheetFragment -import com.google.android.material.button.MaterialButton -import com.google.android.material.button.MaterialButtonToggleGroup -import com.google.android.material.card.MaterialCardView -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.textfield.TextInputEditText -import com.google.android.material.textview.MaterialTextView -import java.math.BigDecimal -import java.math.RoundingMode -import java.util.* - - -class WalletBottomSheet : BottomSheetFragment() { - companion object { - val TAG = WalletBottomSheet::class.simpleName - } - - private lateinit var model: AppViewModel - private lateinit var spendsModel: SpentViewModel - - private var budgetValue: BigDecimal = 0.0.toBigDecimal() - private var dateToValue: Date = Date() - private var currencyValue: ExtendCurrency = - ExtendCurrency(value = null, type = CurrencyType.NONE) - - private val budgetInput: TextInputEditText by lazy { - requireView().findViewById(R.id.budget_input) - } - - private val finishDateBtn: ConstraintLayout by lazy { - requireView().findViewById(R.id.finish_date_edit_btn) - } - - private val currencyFromListBtn: ConstraintLayout by lazy { - requireView().findViewById(R.id.currency_from_list_btn) - } - - private val currencyCustomBtn: ConstraintLayout by lazy { - requireView().findViewById(R.id.currency_custom_btn) - } - - private val currencyNoneBtn: ConstraintLayout by lazy { - requireView().findViewById(R.id.currency_none_btn) - } - - private val perDayTextView: MaterialTextView by lazy { - requireView().findViewById(R.id.per_day_description) - } - - private val applyBtn: MaterialButton by lazy { - requireView().findViewById(R.id.apply) - } - - private val dragHelperView: View by lazy { - requireView().findViewById(R.id.drag_helper) - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.modal_bottom_sheet_wallet, container, false) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - val model: AppViewModel by activityViewModels() - val spendsModel: SpentViewModel by activityViewModels() - - this.model = model - this.spendsModel = spendsModel - - ViewCompat.setOnApplyWindowInsetsListener(view) { _, windowInsets -> - val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) - - view.findViewById(R.id.content).setPadding(0, 0, 0, insets.bottom) - - WindowInsetsCompat.CONSUMED - } - - build() - } - - private fun reCalcBudget() { - val days = countDays(dateToValue) - - finishDateBtn.findViewById(R.id.finish_date_label).text = String.format( - requireContext().resources.getQuantityText(R.plurals.finish_date_label, days) - .toString(), - prettyDate(dateToValue, showTime = false, forceShowDate = true), - days, - ) - - perDayTextView.text = requireContext().getString( - R.string.per_day, - prettyCandyCanes( - if (days != 0) { - (budgetValue / days.toBigDecimal()).setScale(0, RoundingMode.FLOOR) - } else { - budgetValue - }, - currency = currencyValue, - ), - ) - - applyBtn.isEnabled = days > 0 && budgetValue > BigDecimal(0) - } - - private fun recalcLabels(newCurrency: ExtendCurrency) { - val fromListIcon = currencyFromListBtn.findViewById(R.id.currency_from_list_icon) - val fromListLabel = currencyFromListBtn.findViewById(R.id.currency_from_list_label) - val customIcon = currencyCustomBtn.findViewById(R.id.currency_custom_icon) - val customLabel = currencyCustomBtn.findViewById(R.id.currency_custom_label) - val noneIcon = currencyNoneBtn.findViewById(R.id.currency_none_icon) - - fromListIcon.visibility = View.INVISIBLE - fromListLabel.text = requireContext().getString(R.string.currency_from_list) - customIcon.visibility = View.INVISIBLE - customLabel.text = requireContext().getString(R.string.currency_custom) - noneIcon.visibility = View.INVISIBLE - - when (newCurrency.type) { - CurrencyType.FROM_LIST -> { - fromListIcon.visibility = View.VISIBLE - fromListLabel.text = requireContext().getString( - R.string.currency_from_list_selected, - Currency.getInstance(newCurrency.value).symbol, - ) - } - CurrencyType.CUSTOM -> { - customIcon.visibility = View.VISIBLE - customLabel.text = requireContext().getString( - R.string.currency_custom_selected, - newCurrency.value, - ) - } - CurrencyType.NONE -> { - noneIcon.visibility = View.VISIBLE - } - } - - reCalcBudget() - } - - private fun build() { - budgetValue = spendsModel.budget.value ?: 0.0.toBigDecimal() - dateToValue = spendsModel.finishDate - currencyValue = spendsModel.currency - - dragHelperView.visibility = if (spendsModel.requireSetBudget.value == false) { - View.VISIBLE - } else { - View.GONE - } - isCancelable = spendsModel.requireSetBudget.value == false - - budgetInput.setText( - prettyCandyCanes( - budgetValue, - currency = ExtendCurrency(type = CurrencyType.NONE) - ) - ) - - budgetInput.addTextChangedListener(CurrencyTextWatcher( - budgetInput, - currency = ExtendCurrency(type = CurrencyType.NONE), - ) { - budgetValue = try { - it.toBigDecimal() - } catch (e: Exception) { - budgetInput.setText( - prettyCandyCanes( - 0.0.toBigDecimal(), - currency = ExtendCurrency(type = CurrencyType.NONE) - ) - ) - - 0.0.toBigDecimal() - } - - reCalcBudget() - }) - - finishDateBtn.setOnClickListener { - val alertDialog: AlertDialog - - val view = LayoutInflater.from(requireContext()) - .inflate(R.layout.dialog_date_range_picker, null, false) - - val startDate = view.findViewById(R.id.start_date) - val finishDate = view.findViewById(R.id.finish_date) - val calendar = view.findViewById(R.id.calendar) - - var finishDateTemp = dateToValue - - fun recalcFinishDate() { - val days = countDays(finishDateTemp, spendsModel.startDate) - - finishDate.text = String.format( - requireContext().resources.getQuantityText(R.plurals.finish_date, days) - .toString(), - prettyDate(finishDateTemp, showTime = false, forceShowDate = true), - days, - ) - } - - startDate.text = - prettyDate(spendsModel.startDate, showTime = false, forceShowDate = true) - recalcFinishDate() - - calendar.minDate = roundToDay(Date()).time + DAY - calendar.date = dateToValue.time - calendar.setOnDateChangeListener { _, year, month, dayOfMonth -> - finishDateTemp = Calendar - .Builder() - .setTimeZone(TimeZone.getDefault()) - .setDate(year, month, dayOfMonth) - .build() - .time - - recalcFinishDate() - } - - alertDialog = MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.select_finish_date_title) - .setView(view) - .setNegativeButton(resources.getString(R.string.cancel)) { dialog, _ -> - dialog.dismiss() - } - .setPositiveButton(resources.getString(R.string.accept)) { dialog, _ -> - dateToValue = finishDateTemp - - reCalcBudget() - - dialog.dismiss() - } - .setOnCancelListener { recalcLabels(currencyValue) } - .create() - - alertDialog.show() - } - - reCalcBudget() - recalcLabels(currencyValue) - - currencyFromListBtn.setOnClickListener { - val adapter = CurrencyAdapter(requireContext()) - - var alertDialog: AlertDialog? = null - var value: String? = if (currencyValue.type === CurrencyType.FROM_LIST) { - currencyValue.value - } else { - null - } - - alertDialog = MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.select_currency_title) - .setSingleChoiceItems( - adapter, - value?.let { adapter.findItemPosition(value!!) } ?: -1, - ) { _: DialogInterface?, position: Int -> - val currency = adapter.getItem(position) - - value = currency.currencyCode - - alertDialog!!.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = true - } - .setNegativeButton(resources.getString(R.string.cancel)) { dialog, _ -> - recalcLabels(currencyValue) - - dialog.dismiss() - } - .setPositiveButton(resources.getString(R.string.accept)) { dialog, _ -> - currencyValue = ExtendCurrency(value, type = CurrencyType.FROM_LIST) - - recalcLabels(currencyValue) - - dialog.dismiss() - } - .setOnCancelListener { recalcLabels(currencyValue) } - .create() - - alertDialog!!.show() - alertDialog!!.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false - } - - currencyCustomBtn.setOnClickListener { - var alertDialog: AlertDialog? = null - - val view = LayoutInflater.from(requireContext()) - .inflate(R.layout.dialog_custom_currency, null, false) - - val input = view.findViewById(R.id.currency_input) - - input.setText(if (currencyValue.type === CurrencyType.CUSTOM) currencyValue.value else "") - - input.addTextChangedListener(object : TextWatcher { - override fun beforeTextChanged(value: CharSequence?, p1: Int, p2: Int, p3: Int) { - } - - override fun onTextChanged(value: CharSequence?, p1: Int, p2: Int, p3: Int) { - alertDialog!!.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = - !value.isNullOrEmpty() - } - - override fun afterTextChanged(p0: Editable?) { - } - }) - - alertDialog = MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.currency_custom_title) - .setView(view) - .setNegativeButton(resources.getString(R.string.cancel)) { dialog, _ -> - recalcLabels(currencyValue) - - dialog.dismiss() - } - .setPositiveButton(resources.getString(R.string.accept)) { dialog, _ -> - currencyValue = - ExtendCurrency(value = input.text.toString(), type = CurrencyType.CUSTOM) - recalcLabels(currencyValue) - dialog.dismiss() - } - .setOnCancelListener { recalcLabels(currencyValue) } - .create() - - alertDialog!!.show() - alertDialog!!.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = - currencyValue.type === CurrencyType.CUSTOM && !currencyValue.value.isNullOrEmpty() - } - - currencyNoneBtn.setOnClickListener { - currencyValue = ExtendCurrency(value = null, type = CurrencyType.NONE) - - recalcLabels(currencyValue) - } - - applyBtn.setOnClickListener { - spendsModel.changeCurrency(currencyValue) - spendsModel.changeBudget(budgetValue, dateToValue) - - dismiss() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/danilkinkin/buckwheat/adapters/CurrencyAdapter.kt b/app/src/main/java/com/danilkinkin/buckwheat/adapters/CurrencyAdapter.kt deleted file mode 100644 index 79a54c8..0000000 --- a/app/src/main/java/com/danilkinkin/buckwheat/adapters/CurrencyAdapter.kt +++ /dev/null @@ -1,59 +0,0 @@ -package com.danilkinkin.buckwheat.adapters - -import android.app.Activity -import android.content.Context -import android.view.View -import android.view.ViewGroup -import android.widget.* -import java.util.* - -fun getCurrencies(): MutableList { - val currencies = Currency.getAvailableCurrencies().toMutableList() - - currencies.sortBy { it.displayName.uppercase() } - - return currencies -} - - -class CurrencyAdapter ( - context: Context, - layoutId: Int, - private val items: MutableList, -) : ArrayAdapter(context, layoutId, items), Filterable { - - constructor(context: Context) : this(context, android.R.layout.simple_list_item_single_choice, getCurrencies()) - - override fun getCount(): Int = items.size - - override fun getItem(p0: Int): Currency { - return items[p0] - } - - override fun getItemId(p0: Int): Long { - return items[p0].numericCode.toLong() - } - - fun findItemPosition(code: String): Int { - return items.indexOfFirst { it.currencyCode == code } - } - - override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { - var view = convertView - - if (view == null) { - val inflater = (context as Activity).layoutInflater - view = inflater.inflate(android.R.layout.simple_list_item_single_choice, parent, false) - } - - try { - val currency: Currency = getItem(position) - val cityAutoCompleteView = view!!.findViewById(android.R.id.text1) - cityAutoCompleteView.text = currency.displayName - } catch (e: Exception) { - e.printStackTrace() - } - - return view!! - } -} \ No newline at end of file diff --git a/app/src/main/java/com/danilkinkin/buckwheat/adapters/SpendsAdapter.kt b/app/src/main/java/com/danilkinkin/buckwheat/adapters/SpendsAdapter.kt deleted file mode 100644 index ca21dd8..0000000 --- a/app/src/main/java/com/danilkinkin/buckwheat/adapters/SpendsAdapter.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.danilkinkin.buckwheat.adapters - -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import com.danilkinkin.buckwheat.R -import com.danilkinkin.buckwheat.entities.Spent -import com.danilkinkin.buckwheat.utils.prettyCandyCanes -import com.danilkinkin.buckwheat.utils.prettyDate -import com.google.android.material.textview.MaterialTextView - -class SpendsAdapter: ListAdapter(DrawDiffCallback) { - class DrawViewHolder(view: View) : RecyclerView.ViewHolder(view) { - private val valueTextView: MaterialTextView = itemView.findViewById(R.id.value) - private val dateTextView: MaterialTextView = itemView.findViewById(R.id.date_input) - var currentSpent: Spent? = null - - fun bind(spent: Spent) { - currentSpent = spent - - valueTextView.text = prettyCandyCanes(spent.value) - - dateTextView.text = prettyDate(spent.date) - } - } - - override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): DrawViewHolder { - val view = LayoutInflater.from(viewGroup.context) - .inflate(R.layout.item_spent, viewGroup, false) - - return DrawViewHolder(view) - } - - override fun onBindViewHolder(viewHolder: DrawViewHolder, position: Int) { - val spent = getItem(position) - viewHolder.bind(spent) - } - -} - -object DrawDiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: Spent, newItem: Spent): Boolean { - return oldItem == newItem - } - - override fun areContentsTheSame(oldItem: Spent, newItem: Spent): Boolean { - return oldItem.uid == newItem.uid - } -} \ No newline at end of file diff --git a/app/src/main/java/com/danilkinkin/buckwheat/adapters/TopAdapter.kt b/app/src/main/java/com/danilkinkin/buckwheat/adapters/TopAdapter.kt deleted file mode 100644 index 430723c..0000000 --- a/app/src/main/java/com/danilkinkin/buckwheat/adapters/TopAdapter.kt +++ /dev/null @@ -1,58 +0,0 @@ -package com.danilkinkin.buckwheat.adapters - -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import com.danilkinkin.buckwheat.R -import com.danilkinkin.buckwheat.utils.getStatusBarHeight -import com.danilkinkin.buckwheat.utils.prettyCandyCanes -import com.danilkinkin.buckwheat.utils.prettyDate -import com.danilkinkin.buckwheat.viewmodels.SpentViewModel -import com.google.android.material.textview.MaterialTextView - -class TopAdapter(private val model: SpentViewModel) : RecyclerView.Adapter() { - class ViewHolder(view: View, private val model: SpentViewModel) : RecyclerView.ViewHolder(view) { - init { - val topBarHeight = getStatusBarHeight(view) - - val helperView = view.findViewById(R.id.root) - - helperView.setPadding( - 0, - topBarHeight, - 0, - 0, - ) - - update() - } - - fun update() { - itemView.findViewById(R.id.value).text = prettyCandyCanes(model.budget.value!!) - itemView.findViewById(R.id.start_date).text = prettyDate( - model.startDate, - showTime = false, - forceShowDate = true, - ) - itemView.findViewById(R.id.finish_date).text = prettyDate( - model.finishDate, - showTime = false, - forceShowDate = true, - ) - } - } - - override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): ViewHolder { - val view = LayoutInflater.from(viewGroup.context) - .inflate(R.layout.item_top, viewGroup, false) - - return ViewHolder(view, model) - } - - override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) { - viewHolder.update() - } - - override fun getItemCount() = 1 -} \ No newline at end of file diff --git a/app/src/main/java/com/danilkinkin/buckwheat/base/AutoResizeText.kt b/app/src/main/java/com/danilkinkin/buckwheat/base/AutoResizeText.kt new file mode 100644 index 0000000..47767e3 --- /dev/null +++ b/app/src/main/java/com/danilkinkin/buckwheat/base/AutoResizeText.kt @@ -0,0 +1,89 @@ +package com.danilkinkin.buckwheat.base + + +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.sp + +@Composable +fun AutoResizeText( + text: String, + fontSizeRange: FontSizeRange, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + fontStyle: FontStyle? = null, + fontWeight: FontWeight? = null, + fontFamily: FontFamily? = null, + letterSpacing: TextUnit = TextUnit.Unspecified, + textDecoration: TextDecoration? = null, + textAlign: TextAlign? = null, + lineHeight: TextUnit = TextUnit.Unspecified, + overflow: TextOverflow = TextOverflow.Clip, + softWrap: Boolean = true, + maxLines: Int = Int.MAX_VALUE, + style: TextStyle = LocalTextStyle.current, +) { + var fontSizeValue by remember { mutableStateOf(fontSizeRange.max.value) } + var readyToDraw by remember { mutableStateOf(false) } + + Text( + text = text, + color = color, + maxLines = maxLines, + fontStyle = fontStyle, + fontWeight = fontWeight, + fontFamily = fontFamily, + letterSpacing = letterSpacing, + textDecoration = textDecoration, + textAlign = textAlign, + lineHeight = lineHeight, + overflow = overflow, + softWrap = softWrap, + style = style, + fontSize = fontSizeValue.sp, + onTextLayout = { + if (it.didOverflowHeight && !readyToDraw) { + val nextFontSizeValue = fontSizeValue - fontSizeRange.step.value + if (nextFontSizeValue <= fontSizeRange.min.value) { + // Reached minimum, set minimum font size and it's readToDraw + fontSizeValue = fontSizeRange.min.value + readyToDraw = true + } else { + // Text doesn't fit yet and haven't reached minimum text range, keep decreasing + fontSizeValue = nextFontSizeValue + } + } else { + // Text fits before reaching the minimum, it's readyToDraw + readyToDraw = true + } + }, + modifier = modifier.drawWithContent { if (readyToDraw) drawContent() } + ) +} + +data class FontSizeRange( + val min: TextUnit, + val max: TextUnit, + val step: TextUnit = DEFAULT_TEXT_STEP, +) { + init { + require(min < max) { "min should be less than max, $this" } + require(step.value > 0) { "step should be greater than 0, $this" } + } + + companion object { + private val DEFAULT_TEXT_STEP = 1.sp + } +} \ No newline at end of file diff --git a/app/src/main/java/com/danilkinkin/buckwheat/base/BigIconButton.kt b/app/src/main/java/com/danilkinkin/buckwheat/base/BigIconButton.kt new file mode 100644 index 0000000..94c02aa --- /dev/null +++ b/app/src/main/java/com/danilkinkin/buckwheat/base/BigIconButton.kt @@ -0,0 +1,76 @@ +package com.danilkinkin.buckwheat.base + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +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.semantics.Role +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.danilkinkin.buckwheat.R +import com.danilkinkin.buckwheat.ui.BuckwheatTheme + +@Composable +fun BigIconButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + colors: IconButtonColors = IconButtonDefaults.iconButtonColors( + containerColor = Color.Transparent, + contentColor = contentColorFor(MaterialTheme.colorScheme.primaryContainer) + ), + icon: Painter, + contentDescription: String?, +) { + Box( + modifier = modifier + .padding(8.dp) + .background(color = colors.containerColor(enabled).value) + .clickable( + onClick = onClick, + enabled = enabled, + role = Role.Button, + interactionSource = interactionSource, + indication = rememberRipple( + bounded = false, + ) + ) + .padding(4.dp), + contentAlignment = Alignment.Center + ) { + Icon( + modifier = Modifier.size(36.dp), + painter = icon, + tint = colors.contentColor(enabled).value, + contentDescription = contentDescription, + ) + } +} + +@Preview +@Composable +fun PreviewBigIconButton() { + BuckwheatTheme { + BigIconButton( + icon = painterResource(R.drawable.ic_balance_wallet), + contentDescription = null, + colors = IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = contentColorFor(MaterialTheme.colorScheme.primaryContainer) + ), + onClick = {}, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/danilkinkin/buckwheat/base/BottomSheetWrapper.kt b/app/src/main/java/com/danilkinkin/buckwheat/base/BottomSheetWrapper.kt new file mode 100644 index 0000000..8032604 --- /dev/null +++ b/app/src/main/java/com/danilkinkin/buckwheat/base/BottomSheetWrapper.kt @@ -0,0 +1,110 @@ +package com.danilkinkin.buckwheat.base + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.material.* +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.danilkinkin.buckwheat.ui.BuckwheatTheme +import com.danilkinkin.buckwheat.wallet.Wallet +import com.google.accompanist.systemuicontroller.rememberSystemUiController +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun BottomSheetWrapper( + cancelable: Boolean = true, + state: ModalBottomSheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden), + content: @Composable (() -> Unit) -> Unit +) { + val coroutineScope = rememberCoroutineScope() + val systemUiController = rememberSystemUiController() + + + ModalBottomSheetLayout( + cancelable = cancelable, + sheetBackgroundColor = MaterialTheme.colorScheme.surface, + sheetState = state, + sheetShape = MaterialTheme.shapes.extraLarge.copy(bottomStart = CornerSize(0.dp), bottomEnd = CornerSize(0.dp)), + sheetContent = { + SideEffect { + systemUiController.setStatusBarColor( + color = Color.Transparent, + darkIcons = true, + ) + systemUiController.setNavigationBarColor( + color = Color.Transparent, + darkIcons = true, + navigationBarContrastEnforced = false, + ) + } + + content { + coroutineScope.launch { + state.hide() + } + } + + if (cancelable) { + Box( + Modifier + .padding(8.dp) + .fillMaxWidth() + ) { + Box( + Modifier + .height(4.dp) + .width(30.dp) + .background( + color = MaterialTheme.colorScheme.primary, + shape = CircleShape + ) + .align(Alignment.Center) + ) + } + } + } + ) {} + + BackHandler { + coroutineScope.launch { + state.hide() + } + } + + LaunchedEffect(state.currentValue) { + + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Preview +@Composable +fun PreviewBottomSheetWrapper() { + val state = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden) + val coroutineScope = rememberCoroutineScope() + + BuckwheatTheme { + androidx.compose.material3.Surface(modifier = Modifier.fillMaxSize()) { + androidx.compose.material3.Button(onClick = { + coroutineScope.launch { + state.show() + } + }) { + androidx.compose.material3.Text(text = "Show") + } + + BottomSheetWrapper(state = state) { + Wallet() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/danilkinkin/buckwheat/base/ButtonRow.kt b/app/src/main/java/com/danilkinkin/buckwheat/base/ButtonRow.kt new file mode 100644 index 0000000..71ab63e --- /dev/null +++ b/app/src/main/java/com/danilkinkin/buckwheat/base/ButtonRow.kt @@ -0,0 +1,56 @@ +package com.danilkinkin.buckwheat.base + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import com.danilkinkin.buckwheat.R +import com.danilkinkin.buckwheat.ui.BuckwheatTheme + +@Composable +fun ButtonRow( + icon: Painter? = null, + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val interactionSource = remember { MutableInteractionSource() } + + TextRow( + icon = icon, + text = text, + modifier + .clickable( + interactionSource = interactionSource, + indication = rememberRipple() + ) { onClick.invoke() }, + ) +} + +@Preview +@Composable +fun PreviewButtonRowWithIcon() { + BuckwheatTheme { + ButtonRow( + icon = painterResource(R.drawable.ic_home), + text = "Text row", + onClick = {}, + ) + } +} + +@Preview +@Composable +fun PreviewButtonRowWithoutIcon() { + BuckwheatTheme { + ButtonRow( + text = "Text row", + onClick = {}, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/danilkinkin/buckwheat/base/CheckedRow.kt b/app/src/main/java/com/danilkinkin/buckwheat/base/CheckedRow.kt new file mode 100644 index 0000000..3c6e43b --- /dev/null +++ b/app/src/main/java/com/danilkinkin/buckwheat/base/CheckedRow.kt @@ -0,0 +1,59 @@ +package com.danilkinkin.buckwheat.base + +import androidx.compose.foundation.selection.toggleable +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.Preview +import com.danilkinkin.buckwheat.R +import com.danilkinkin.buckwheat.ui.BuckwheatTheme + +@Composable +fun CheckedRow( + checked: Boolean, + onValueChange: (isChecked: Boolean) -> Unit, + text: String, + modifier: Modifier = Modifier, +) { + TextRow( + modifier = modifier + .toggleable( + value = checked, + onValueChange = { onValueChange(!checked) }, + role = Role.Checkbox + ), + icon = if (checked) painterResource(R.drawable.ic_apply) else null, + text = text, + ) +} + +@Preview +@Composable +fun PreviewCheckedRow() { + val (checkedState, onStateChange) = remember { mutableStateOf(false) } + + BuckwheatTheme { + CheckedRow( + checked = checkedState, + onValueChange = { onStateChange(it) }, + text = "Option selection", + ) + } +} + +@Preview +@Composable +fun PreviewCheckedRowChekecd() { + val (checkedState, onStateChange) = remember { mutableStateOf(true) } + + BuckwheatTheme { + CheckedRow( + checked = checkedState, + onValueChange = { onStateChange(it) }, + text = "Option selection", + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/danilkinkin/buckwheat/base/Divider.kt b/app/src/main/java/com/danilkinkin/buckwheat/base/Divider.kt new file mode 100644 index 0000000..10debd8 --- /dev/null +++ b/app/src/main/java/com/danilkinkin/buckwheat/base/Divider.kt @@ -0,0 +1,11 @@ +package com.danilkinkin.buckwheat.base + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable + +@Composable +fun Divider() { + androidx.compose.material3.Divider( + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/danilkinkin/buckwheat/base/ModalBottomSheet.kt b/app/src/main/java/com/danilkinkin/buckwheat/base/ModalBottomSheet.kt new file mode 100644 index 0000000..a2f6eda --- /dev/null +++ b/app/src/main/java/com/danilkinkin/buckwheat/base/ModalBottomSheet.kt @@ -0,0 +1,287 @@ +package com.danilkinkin.buckwheat.base + +import android.util.Log +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.TweenSpec +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.isSpecified +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.semantics.* +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import kotlin.math.max +import kotlin.math.roundToInt +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import com.danilkinkin.buckwheat.topSheet.PreUpPostDownNestedScrollConnection +import com.danilkinkin.buckwheat.topSheet.SwipeableState +import com.danilkinkin.buckwheat.topSheet.swipeable + +@ExperimentalMaterialApi +enum class ModalBottomSheetValue { Hidden, Expanded, HalfExpanded } + +@ExperimentalMaterialApi +class ModalBottomSheetState( + initialValue: ModalBottomSheetValue, + animationSpec: AnimationSpec = SwipeableDefaults.AnimationSpec, + internal val isSkipHalfExpanded: Boolean, + confirmStateChange: (ModalBottomSheetValue) -> Boolean = { true } +) : SwipeableState( + initialValue = initialValue, + animationSpec = animationSpec, + confirmStateChange = confirmStateChange +) { + val isVisible: Boolean + get() = currentValue != ModalBottomSheetValue.Hidden + + private val hasHalfExpandedState: Boolean + get() = anchors.values.contains(ModalBottomSheetValue.HalfExpanded) + + constructor( + initialValue: ModalBottomSheetValue, + animationSpec: AnimationSpec = SwipeableDefaults.AnimationSpec, + confirmStateChange: (ModalBottomSheetValue) -> Boolean = { true } + ) : this(initialValue, animationSpec, isSkipHalfExpanded = false, confirmStateChange) + + init { + if (isSkipHalfExpanded) { + require(initialValue != ModalBottomSheetValue.HalfExpanded) { + "The initial value must not be set to HalfExpanded if skipHalfExpanded is set to" + + " true." + } + } + } + + suspend fun show() { + val targetValue = when { + //hasHalfExpandedState -> ModalBottomSheetValue.HalfExpanded + else -> ModalBottomSheetValue.Expanded + } + animateTo(targetValue = targetValue) + } + + internal suspend fun halfExpand() { + if (!hasHalfExpandedState) { + return + } + animateTo(ModalBottomSheetValue.HalfExpanded) + } + + internal suspend fun expand() = animateTo(ModalBottomSheetValue.Expanded) + + suspend fun hide() = animateTo(ModalBottomSheetValue.Hidden) + + internal val nestedScrollConnection = this.PreUpPostDownNestedScrollConnection + + companion object { + fun Saver( + animationSpec: AnimationSpec, + skipHalfExpanded: Boolean, + confirmStateChange: (ModalBottomSheetValue) -> Boolean + ): Saver = Saver( + save = { it.currentValue }, + restore = { + ModalBottomSheetState( + initialValue = it, + animationSpec = animationSpec, + isSkipHalfExpanded = skipHalfExpanded, + confirmStateChange = confirmStateChange + ) + } + ) + } +} + +@Composable +@ExperimentalMaterialApi +fun rememberModalBottomSheetState( + initialValue: ModalBottomSheetValue, + animationSpec: AnimationSpec = SwipeableDefaults.AnimationSpec, + skipHalfExpanded: Boolean, + confirmStateChange: (ModalBottomSheetValue) -> Boolean = { true } +): ModalBottomSheetState { + return rememberSaveable( + initialValue, animationSpec, skipHalfExpanded, confirmStateChange, + saver = ModalBottomSheetState.Saver( + animationSpec = animationSpec, + skipHalfExpanded = skipHalfExpanded, + confirmStateChange = confirmStateChange + ) + ) { + ModalBottomSheetState( + initialValue = initialValue, + animationSpec = animationSpec, + isSkipHalfExpanded = skipHalfExpanded, + confirmStateChange = confirmStateChange + ) + } +} + +@Composable +@ExperimentalMaterialApi +fun rememberModalBottomSheetState( + initialValue: ModalBottomSheetValue, + animationSpec: AnimationSpec = SwipeableDefaults.AnimationSpec, + confirmStateChange: (ModalBottomSheetValue) -> Boolean = { true }, +): ModalBottomSheetState = rememberModalBottomSheetState( + initialValue = initialValue, + animationSpec = animationSpec, + skipHalfExpanded = true, + confirmStateChange = confirmStateChange +) + +@Composable +@ExperimentalMaterialApi +fun ModalBottomSheetLayout( + sheetContent: @Composable () -> Unit, + modifier: Modifier = Modifier, + sheetState: ModalBottomSheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden), + sheetShape: Shape = MaterialTheme.shapes.large, + sheetElevation: Dp = ModalBottomSheetDefaults.Elevation, + sheetBackgroundColor: Color = MaterialTheme.colors.surface, + sheetContentColor: Color = contentColorFor(sheetBackgroundColor), + scrimColor: Color = ModalBottomSheetDefaults.scrimColor, + cancelable: Boolean = true, + content: @Composable () -> Unit +) { + val scope = rememberCoroutineScope() + BoxWithConstraints(modifier) { + val fullHeight = constraints.maxHeight.toFloat() + val sheetHeightState = remember { mutableStateOf(null) } + Box(Modifier.fillMaxSize()) { + content() + Scrim( + color = scrimColor, + onDismiss = { + if (cancelable && sheetState.confirmStateChange(ModalBottomSheetValue.Hidden)) { + scope.launch { sheetState.hide() } + } + }, + visible = sheetState.targetValue != ModalBottomSheetValue.Hidden + ) + } + Surface( + Modifier + .fillMaxWidth() + .nestedScroll(sheetState.nestedScrollConnection) + .offset { + val y = if (sheetState.anchors.isEmpty()) { + // if we don't know our anchors yet, render the sheet as hidden + fullHeight.roundToInt() + } else { + // if we do know our anchors, respect them + sheetState.offset.value.roundToInt() + } + IntOffset(0, y) + } + .bottomSheetSwipeable(sheetState, fullHeight, sheetHeightState, cancelable), + shape = sheetShape, + elevation = sheetElevation, + color = sheetBackgroundColor, + contentColor = sheetContentColor + ) { + Box( + modifier = Modifier + .onGloballyPositioned { + sheetHeightState.value = it.size.height.toFloat() + } + ) { + sheetContent() + } + } + } +} + +@Suppress("ModifierInspectorInfo") +@OptIn(ExperimentalMaterialApi::class) +private fun Modifier.bottomSheetSwipeable( + sheetState: ModalBottomSheetState, + fullHeight: Float, + sheetHeightState: State, + cancelable: Boolean = true, +): Modifier { + val sheetHeight = sheetHeightState.value + + Log.d("ModalBottomSheet", "fullHeight = $fullHeight sheetHeight = $sheetHeight") + + val modifier = if (sheetHeight != null) { + val anchors = if (sheetHeight < fullHeight / 2 || sheetState.isSkipHalfExpanded) { + mapOf( + fullHeight to ModalBottomSheetValue.Hidden, + fullHeight - sheetHeight to ModalBottomSheetValue.Expanded + ) + } else { + mapOf( + fullHeight to ModalBottomSheetValue.Hidden, + fullHeight / 2 to ModalBottomSheetValue.HalfExpanded, + max(0f, fullHeight - sheetHeight) to ModalBottomSheetValue.Expanded + ) + } + Modifier.swipeable( + state = sheetState, + anchors = anchors, + orientation = Orientation.Vertical, + enabled = cancelable && (sheetState.currentValue != ModalBottomSheetValue.Hidden), + resistance = null + ) + } else { + Modifier + } + + return this.then(modifier) +} + +@Composable +private fun Scrim( + color: Color, + onDismiss: () -> Unit, + visible: Boolean +) { + if (color.isSpecified) { + val alpha by animateFloatAsState( + targetValue = if (visible) 1f else 0f, + animationSpec = TweenSpec() + ) + + val dismissModifier = if (visible) { + Modifier + .pointerInput(onDismiss) { detectTapGestures { onDismiss() } } + .semantics(mergeDescendants = true) { + contentDescription = null.toString() + onClick { onDismiss(); true } + } + } else { + Modifier + } + + Canvas( + Modifier + .fillMaxSize() + .then(dismissModifier) + ) { + drawRect(color = color, alpha = alpha) + } + } +} + + +object ModalBottomSheetDefaults { + val Elevation = 16.dp + val scrimColor: Color + @Composable + get() = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) +} \ No newline at end of file diff --git a/app/src/main/java/com/danilkinkin/buckwheat/base/TextRow.kt b/app/src/main/java/com/danilkinkin/buckwheat/base/TextRow.kt new file mode 100644 index 0000000..84c039d --- /dev/null +++ b/app/src/main/java/com/danilkinkin/buckwheat/base/TextRow.kt @@ -0,0 +1,76 @@ +package com.danilkinkin.buckwheat.base + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.danilkinkin.buckwheat.R +import com.danilkinkin.buckwheat.ui.BuckwheatTheme + +@Composable +fun TextRow( + icon: Painter? = null, + text: String, + modifier: Modifier = Modifier, +) { + Box(modifier) { + Row( + Modifier + .fillMaxWidth() + .height(56.dp) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + + Text( + text = text, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = (24 + 16).dp) + ) + } + + if (icon !== null) { + Box( + Modifier + .height(56.dp) + .width(56.dp) + .padding(horizontal = 16.dp), + contentAlignment = Alignment.CenterStart + ) { + Icon( + painter = icon, + contentDescription = null + ) + } + } + } + +} + +@Preview() +@Composable +fun PreviewTextRowWithIcon() { + BuckwheatTheme { + TextRow( + icon = painterResource(R.drawable.ic_home), + text = "Text row", + ) + } +} + +@Preview +@Composable +fun PreviewTextRowWithoutIcon() { + BuckwheatTheme { + TextRow( + text = "Text row", + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/danilkinkin/buckwheat/calendar/Calendar.kt b/app/src/main/java/com/danilkinkin/buckwheat/calendar/Calendar.kt new file mode 100644 index 0000000..63d5de5 --- /dev/null +++ b/app/src/main/java/com/danilkinkin/buckwheat/calendar/Calendar.kt @@ -0,0 +1,123 @@ +package com.danilkinkin.buckwheat.calendar + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.windowInsetsBottomHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import com.danilkinkin.buckwheat.calendar.model.CalendarState +import com.danilkinkin.buckwheat.calendar.model.CalendarUiState +import com.danilkinkin.buckwheat.calendar.model.Month +import com.danilkinkin.buckwheat.ui.BuckwheatTheme +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.temporal.TemporalAdjusters +import java.time.temporal.WeekFields + +@Composable +fun Calendar( + calendarState: CalendarState, + onDayClicked: (date: LocalDate) -> Unit, + modifier: Modifier = Modifier, +) { + val calendarUiState = calendarState.calendarUiState.value + val dayWidth = remember { mutableStateOf(CELL_SIZE) } + + val localDensity = LocalDensity.current + + LazyColumn( + modifier = modifier + .onGloballyPositioned { + dayWidth.value = with(localDensity) { it.size.width.toDp() / 7 } + }, + ) { + calendarState.listMonths.forEach { month -> + itemsCalendarMonth(calendarUiState, onDayClicked, month, dayWidth.value) + } + + item(key = "bottomSpacer") { + Spacer( + modifier = Modifier.windowInsetsBottomHeight( + WindowInsets.navigationBars + ) + ) + } + } +} + +private fun LazyListScope.itemsCalendarMonth( + calendarUiState: CalendarUiState, + onDayClicked: (LocalDate) -> Unit, + month: Month, + dayWidth: Dp, +) { + item(month.yearMonth.month.name + month.yearMonth.year + "header") { + MonthHeader( + modifier = Modifier.padding(top = 32.dp), + yearMonth = month.yearMonth + ) + } + + // Expanding width and centering horizontally + val contentModifier = Modifier.fillMaxWidth() + + item(month.yearMonth.month.name + month.yearMonth.year + "daysOfWeek") { + DaysOfWeek(modifier = contentModifier) + } + + // A custom key needs to be given to these items so that they can be found in tests that + // need scrolling. The format of the key is ${year/month/weekNumber}. Thus, + // the key for the fourth week of December 2020 is "2020/12/4" + itemsIndexed(month.weeks, key = { index, _ -> + month.yearMonth.year.toString() + "/" + month.yearMonth.month.value + "/" + (index + 1).toString() + }) { _, week -> + val beginningWeek = week.yearMonth.atDay(1).plusWeeks(week.number.toLong()) + val currentDay = beginningWeek.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) + + if (calendarUiState.hasSelectedPeriodOverlap(currentDay, currentDay.plusDays(6))) { + WeekSelectionPill( + state = calendarUiState, + currentWeekStart = currentDay, + widthPerDay = dayWidth, + heightPerDay = CELL_SIZE, + week = week, + ) + } + Week( + calendarUiState = calendarUiState, + modifier = contentModifier, + week = week, + onDayClicked = onDayClicked + ) + Spacer(Modifier.height(8.dp)) + } +} + +internal val CALENDAR_STARTS_ON = WeekFields.ISO + +@Preview +@Composable +fun DayPreview() { + val state = remember { mutableStateOf(CalendarState()) } + + BuckwheatTheme { + Calendar( + state.value, + onDayClicked = { state.value.setSelectedDay(it) }, + ) + } +} diff --git a/app/src/main/java/com/danilkinkin/buckwheat/calendar/Day.kt b/app/src/main/java/com/danilkinkin/buckwheat/calendar/Day.kt new file mode 100644 index 0000000..72cf121 --- /dev/null +++ b/app/src/main/java/com/danilkinkin/buckwheat/calendar/Day.kt @@ -0,0 +1,135 @@ +package com.danilkinkin.buckwheat.calendar + +import android.app.usage.UsageEvents +import android.util.Log +import android.view.MotionEvent +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import com.danilkinkin.buckwheat.calendar.model.CalendarUiState +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.pointerInteropFilter +import androidx.compose.ui.semantics.* +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.capitalize +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.intl.Locale +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import java.time.LocalDate +import java.time.YearMonth + +@Composable +internal fun DayOfWeekHeading(day: String, modifier: Modifier = Modifier) { + DayContainer(modifier = modifier) { + Text( + modifier = Modifier + .fillMaxSize() + .wrapContentHeight(Alignment.CenterVertically), + textAlign = TextAlign.Center, + text = day, + style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.W700), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5F), + ) + } +} + +@Composable +private fun DayContainer( + modifier: Modifier = Modifier, + current: Boolean = false, + onSelect: () -> Unit = { }, + content: @Composable () -> Unit +) { + Box( + modifier = modifier + .height(CELL_SIZE) + .widthIn(min = CELL_SIZE) + .fillMaxWidth() + .background(Color.Transparent, + ) + .pointerInput(Any()) { + detectTapGestures { + onSelect() + } + }, + contentAlignment = Alignment.Center + ) { + if (current) { + Box( + modifier = modifier + .height(CELL_SIZE - 8.dp) + .width(CELL_SIZE - 8.dp) + .background( + color = MaterialTheme.colorScheme.primaryContainer, + shape = CircleShape, + ) + ) { + content() + } + } else { + content() + } + + } +} + +@Composable +internal fun Day( + day: LocalDate, + calendarState: CalendarUiState, + onDayClicked: (LocalDate) -> Unit, + month: YearMonth, + modifier: Modifier = Modifier +) { + val disabled = calendarState.isBeforeCurrentDay(day) + val selected = calendarState.isDateInSelectedPeriod(day) + val current = calendarState.isCurrentDay(day) + + DayContainer( + modifier = modifier + .semantics { + text = AnnotatedString( + "${ + month.month.name.lowercase().capitalize(Locale.current) + } ${day.dayOfMonth} ${month.year}" + ) + dayStatusProperty = selected + }, + current = current, + onSelect = { if (!disabled) onDayClicked(day) }, + ) { + + Text( + modifier = Modifier + .fillMaxSize() + .wrapContentSize(Alignment.Center) + // Parent will handle semantics + .clearAndSetSemantics {}, + text = day.dayOfMonth.toString(), + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.W800), + color = when (true) { + current -> MaterialTheme.colorScheme.onPrimaryContainer + selected -> MaterialTheme.colorScheme.onPrimary + else -> MaterialTheme.colorScheme.onSurface.copy(alpha = if (disabled) 0.3F else 1F) + }, + ) + } +} + +val DayStatusKey = SemanticsPropertyKey("DayStatusKey") +var SemanticsPropertyReceiver.dayStatusProperty by DayStatusKey diff --git a/app/src/main/java/com/danilkinkin/buckwheat/calendar/Month.kt b/app/src/main/java/com/danilkinkin/buckwheat/calendar/Month.kt new file mode 100644 index 0000000..39c34b0 --- /dev/null +++ b/app/src/main/java/com/danilkinkin/buckwheat/calendar/Month.kt @@ -0,0 +1,36 @@ +package com.danilkinkin.buckwheat.calendar + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.danilkinkin.buckwheat.ui.BuckwheatTheme +import com.danilkinkin.buckwheat.util.prettyYearMonth +import java.time.LocalDate +import java.time.YearMonth + +@Composable +internal fun MonthHeader(modifier: Modifier = Modifier, yearMonth: YearMonth) { + Row(modifier = modifier.clearAndSetSemantics { }) { + Text( + modifier = Modifier.padding(start = 24.dp).weight(1f), + text = prettyYearMonth(yearMonth), + style = MaterialTheme.typography.titleMedium + ) + } +} + +@Preview +@Composable +fun PreviewMonthHeader() { + BuckwheatTheme { + MonthHeader( + yearMonth = YearMonth.from(LocalDate.now().withDayOfMonth(1)), + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/danilkinkin/buckwheat/calendar/Week.kt b/app/src/main/java/com/danilkinkin/buckwheat/calendar/Week.kt new file mode 100644 index 0000000..bc45440 --- /dev/null +++ b/app/src/main/java/com/danilkinkin/buckwheat/calendar/Week.kt @@ -0,0 +1,58 @@ +package com.danilkinkin.buckwheat.calendar + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +import com.danilkinkin.buckwheat.calendar.model.CalendarUiState +import com.danilkinkin.buckwheat.calendar.model.Week +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.unit.dp +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.temporal.TemporalAdjusters + +@Composable +internal fun DaysOfWeek(modifier: Modifier = Modifier) { + Row(modifier = modifier.clearAndSetSemantics { }) { + for (day in DayOfWeek.values()) { + DayOfWeekHeading( + day = day.name.take(1), + modifier = Modifier.weight(1f) + ) + } + } +} + +@Composable +internal fun Week( + calendarUiState: CalendarUiState, + week: Week, + onDayClicked: (LocalDate) -> Unit, + modifier: Modifier = Modifier +) { + val beginningWeek = week.yearMonth.atDay(1).plusWeeks(week.number.toLong()) + var currentDay = beginningWeek.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) + + Box(Modifier.fillMaxWidth()) { + Row(modifier = modifier) { + for (i in 0..6) { + if (currentDay.month == week.yearMonth.month) { + Day( + modifier = Modifier.weight(1f), + calendarState = calendarUiState, + day = currentDay, + onDayClicked = onDayClicked, + month = week.yearMonth + ) + } else { + Box(modifier = Modifier.size(CELL_SIZE).weight(1f)) + } + currentDay = currentDay.plusDays(1) + } + } + } +} + +internal val CELL_SIZE = 48.dp diff --git a/app/src/main/java/com/danilkinkin/buckwheat/calendar/WeekSelectionPill.kt b/app/src/main/java/com/danilkinkin/buckwheat/calendar/WeekSelectionPill.kt new file mode 100644 index 0000000..acd97c4 --- /dev/null +++ b/app/src/main/java/com/danilkinkin/buckwheat/calendar/WeekSelectionPill.kt @@ -0,0 +1,91 @@ +package com.danilkinkin.buckwheat.calendar + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import com.danilkinkin.buckwheat.calendar.model.CalendarState +import com.danilkinkin.buckwheat.calendar.model.CalendarUiState +import com.danilkinkin.buckwheat.calendar.model.Week +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import java.time.LocalDate + + +@Composable +fun WeekSelectionPill( + week: Week, + currentWeekStart: LocalDate, + state: CalendarUiState, + modifier: Modifier = Modifier, + widthPerDay: Dp = 48.dp, + heightPerDay: Dp = 48.dp, + pillColor: Color = MaterialTheme.colorScheme.primary +) { + val widthPerDayPx = with(LocalDensity.current) { widthPerDay.toPx() } + val heightPerDayPx = with(LocalDensity.current) { heightPerDay.toPx() } + val cornerRadiusPx = with(LocalDensity.current) { 24.dp.toPx() } + + Canvas( + modifier = modifier.fillMaxWidth(), + onDraw = { + val (offset, size) = getOffsetAndSize( + this.size.width, + state, + currentWeekStart, + week, + widthPerDayPx, + heightPerDayPx, + cornerRadiusPx, + ) + + drawRoundRect( + color = pillColor, + topLeft = offset, + size = Size(size, heightPerDayPx), + cornerRadius = CornerRadius(cornerRadiusPx) + ) + } + ) +} + +private fun getOffsetAndSize( + width: Float, + state: CalendarUiState, + currentWeekStart: LocalDate, + week: Week, + widthPerDayPx: Float, + heightPerDayPx: Float, + cornerRadiusPx: Float, +): Pair { + val numberDaysSelected = state.getNumberSelectedDaysInWeek(currentWeekStart, week.yearMonth) + val monthOverlapDelay = state.monthOverlapSelectionDelay(currentWeekStart, week) + val dayDelay = state.dayDelay(currentWeekStart) + val edgePadding = (width - widthPerDayPx * CalendarState.DAYS_IN_WEEK + ((widthPerDayPx - heightPerDayPx)) / 2F) + 1 + + val sideSize = edgePadding + cornerRadiusPx + + val leftSize = + if (state.isLeftHighlighted(currentWeekStart, week.yearMonth)) sideSize else 0f + val rightSize = + if (state.isRightHighlighted(currentWeekStart, week.yearMonth)) sideSize else 0f + + var totalSize = (numberDaysSelected * widthPerDayPx) + (leftSize + rightSize) - ((widthPerDayPx - heightPerDayPx)) + if (dayDelay + monthOverlapDelay == 0 && numberDaysSelected >= 1) { + totalSize = totalSize.coerceAtLeast(heightPerDayPx) + } + + totalSize = totalSize.coerceAtLeast(0F) + + val startOffset = state.selectedStartOffset(currentWeekStart, week.yearMonth) * widthPerDayPx + + val offset = Offset(startOffset + edgePadding - leftSize, 0f) + + return offset to totalSize +} diff --git a/app/src/main/java/com/danilkinkin/buckwheat/calendar/model/CalendarState.kt b/app/src/main/java/com/danilkinkin/buckwheat/calendar/model/CalendarState.kt new file mode 100644 index 0000000..0e90818 --- /dev/null +++ b/app/src/main/java/com/danilkinkin/buckwheat/calendar/model/CalendarState.kt @@ -0,0 +1,55 @@ +package com.danilkinkin.buckwheat.calendar.model + +import androidx.compose.runtime.mutableStateOf +import com.danilkinkin.buckwheat.util.getNumberWeeks +import com.danilkinkin.buckwheat.util.toLocalDate +import java.time.LocalDate +import java.time.Period +import java.time.YearMonth +import java.util.* + +class CalendarState(selectDate: Date? = null) { + + val calendarUiState = mutableStateOf(CalendarUiState()) + val listMonths: List + + private val calendarStartDate: LocalDate = LocalDate.now().withDayOfMonth(1) + private val calendarEndDate: LocalDate = LocalDate.now().plusYears(2) + .withMonth(12).withDayOfMonth(31) + + private val periodBetweenCalendarStartEnd: Period = Period.between( + calendarStartDate, + calendarEndDate + ) + + init { + val tempListMonths = mutableListOf() + var startYearMonth = YearMonth.from(calendarStartDate) + for (numberMonth in 0..periodBetweenCalendarStartEnd.toTotalMonths()) { + val numberWeeks = startYearMonth.getNumberWeeks() + val listWeekItems = mutableListOf() + for (week in 0 until numberWeeks) { + listWeekItems.add( + Week( + number = week, + yearMonth = startYearMonth + ) + ) + } + val month = Month(startYearMonth, listWeekItems) + tempListMonths.add(month) + startYearMonth = startYearMonth.plusMonths(1) + } + listMonths = tempListMonths.toList() + + if (selectDate != null) setSelectedDay(selectDate.toLocalDate()) + } + + fun setSelectedDay(newDate: LocalDate) { + calendarUiState.value = calendarUiState.value.setDates(LocalDate.now(), newDate) + } + + companion object { + const val DAYS_IN_WEEK = 7 + } +} diff --git a/app/src/main/java/com/danilkinkin/buckwheat/calendar/model/CalendarUiState.kt b/app/src/main/java/com/danilkinkin/buckwheat/calendar/model/CalendarUiState.kt new file mode 100644 index 0000000..f72d601 --- /dev/null +++ b/app/src/main/java/com/danilkinkin/buckwheat/calendar/model/CalendarUiState.kt @@ -0,0 +1,203 @@ +package com.danilkinkin.buckwheat.calendar.model + +import androidx.compose.runtime.Composable +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.res.pluralStringResource +import com.danilkinkin.buckwheat.R +import com.danilkinkin.buckwheat.util.countDays +import com.danilkinkin.buckwheat.util.isSameDay +import com.danilkinkin.buckwheat.util.prettyDate +import com.danilkinkin.buckwheat.util.toDate +import java.time.LocalDate +import java.time.YearMonth +import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit +import java.util.* +import kotlin.math.abs + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun selectedDatesFormatted(state: CalendarState): String { + val uiState = state.calendarUiState.value + + if (uiState.selectedStartDate == null) return "" + + var output = prettyDate(uiState.selectedStartDate.toDate(), showTime = false, forceShowDate = true) + + output += if (uiState.selectedEndDate != null) { + val days = countDays(uiState.selectedEndDate.toDate(), uiState.selectedStartDate.toDate()) + + " - ${String.format( + pluralStringResource( + id = R.plurals.finish_date, + count = days, + ), + prettyDate(uiState.selectedEndDate.toDate(), showTime = false, forceShowDate = true), + days, + )}" + } else { + " - ?" + } + + return output +} + +data class CalendarUiState( + val selectedStartDate: LocalDate? = null, + val selectedEndDate: LocalDate? = null, +) { + + val numberSelectedDays: Float + get() { + if (selectedStartDate == null) return 0f + if (selectedEndDate == null) return 1f + return ChronoUnit.DAYS.between(selectedStartDate, selectedEndDate.plusDays(1)).toFloat() + } + + val hasSelectedDates: Boolean + get() { + return selectedStartDate != null || selectedEndDate != null + } + + fun hasSelectedPeriodOverlap(start: LocalDate, end: LocalDate): Boolean { + if (!hasSelectedDates) return false + if (selectedStartDate == null && selectedEndDate == null) return false + if (selectedStartDate == start || selectedStartDate == end) return true + if (selectedEndDate == null) { + return !selectedStartDate!!.isBefore(start) && !selectedStartDate.isAfter(end) + } + return !end.isBefore(selectedStartDate) && !start.isAfter(selectedEndDate) + } + + fun isDateInSelectedPeriod(date: LocalDate): Boolean { + if (selectedStartDate == null) return false + if (selectedStartDate == date) return true + if (selectedEndDate == null) return false + if (date.isBefore(selectedStartDate) || + date.isAfter(selectedEndDate) + ) return false + return true + } + + fun isCurrentDay(date: LocalDate): Boolean { + return isSameDay( + date.toDate().time, + Date().time, + ) + } + + fun isBeforeCurrentDay(date: LocalDate): Boolean { + return date.isBefore(LocalDate.now()) + } + + fun getNumberSelectedDaysInWeek(currentWeekStartDate: LocalDate, month: YearMonth): Int { + var countSelected = 0 + var currentDate = currentWeekStartDate + for (i in 0 until CalendarState.DAYS_IN_WEEK) { + if (isDateInSelectedPeriod(currentDate) && currentDate.month == month.month) { + countSelected++ + } + currentDate = currentDate.plusDays(1) + } + return countSelected + } + + /** + * Returns the number of selected days from the start or end of the week, depending on direction. + */ + fun selectedStartOffset(currentWeekStartDate: LocalDate, yearMonth: YearMonth): Int { + var startDate = currentWeekStartDate + var startOffset = 0 + for (i in 0 until CalendarState.DAYS_IN_WEEK) { + if (!isDateInSelectedPeriod(startDate) || startDate.month != yearMonth.month) { + startOffset++ + } else { + break + } + startDate = startDate.plusDays(1) + } + + return startOffset + } + + fun isLeftHighlighted(beginningWeek: LocalDate?, month: YearMonth): Boolean { + return if (beginningWeek != null) { + if (month.month.value != beginningWeek.month.value) { + false + } else { + val beginningWeekSelected = isDateInSelectedPeriod(beginningWeek) + val lastDayPreviousWeek = beginningWeek.minusDays(1) + isDateInSelectedPeriod(lastDayPreviousWeek) && beginningWeekSelected + } + } else { + false + } + } + + fun isRightHighlighted( + beginningWeek: LocalDate?, + month: YearMonth + ): Boolean { + val lastDayOfTheWeek = beginningWeek?.plusDays(6) + return if (lastDayOfTheWeek != null) { + if (month.month.value != lastDayOfTheWeek.month.value) { + false + } else { + val lastDayOfTheWeekSelected = isDateInSelectedPeriod(lastDayOfTheWeek) + val firstDayNextWeek = lastDayOfTheWeek.plusDays(1) + isDateInSelectedPeriod(firstDayNextWeek) && lastDayOfTheWeekSelected + } + } else { + false + } + } + + fun dayDelay(currentWeekStartDate: LocalDate): Int { + if (selectedStartDate == null && selectedEndDate == null) return 0 + // if selected week contains start date, don't have any delay + val endWeek = currentWeekStartDate.plusDays(6) + return if (selectedStartDate?.isBefore(currentWeekStartDate) == true || + selectedStartDate?.isAfter(endWeek) == true + ) { + // selected start date is not in current week + abs(ChronoUnit.DAYS.between(currentWeekStartDate, selectedStartDate)).toInt() + } else { + 0 + } + } + + fun monthOverlapSelectionDelay( + currentWeekStartDate: LocalDate, + week: Week + ): Int { + val isStartInADifferentMonth = currentWeekStartDate.month != week.yearMonth.month + + return if (isStartInADifferentMonth) { + var currentDate = currentWeekStartDate + var offset = 0 + for (i in 0 until CalendarState.DAYS_IN_WEEK) { + if (currentDate.month.value != week.yearMonth.month.value && + isDateInSelectedPeriod(currentDate) + ) { + offset++ + } + currentDate = currentDate.plusDays(1) + } + offset + } else { + 0 + } + } + + fun setDates(newFrom: LocalDate?, newTo: LocalDate?): CalendarUiState { + return if (newTo == null) { + copy(selectedStartDate = newFrom) + } else { + copy(selectedStartDate = newFrom, selectedEndDate = newTo) + } + } + + companion object { + private val SHORT_DATE_FORMAT = DateTimeFormatter.ofPattern("MMM dd") + } +} diff --git a/app/src/main/java/com/danilkinkin/buckwheat/calendar/model/Month.kt b/app/src/main/java/com/danilkinkin/buckwheat/calendar/model/Month.kt new file mode 100644 index 0000000..97afea3 --- /dev/null +++ b/app/src/main/java/com/danilkinkin/buckwheat/calendar/model/Month.kt @@ -0,0 +1,8 @@ +package com.danilkinkin.buckwheat.calendar.model + +import java.time.YearMonth + +data class Month( + val yearMonth: YearMonth, + val weeks: List +) diff --git a/app/src/main/java/com/danilkinkin/buckwheat/calendar/model/Week.kt b/app/src/main/java/com/danilkinkin/buckwheat/calendar/model/Week.kt new file mode 100644 index 0000000..d2b0241 --- /dev/null +++ b/app/src/main/java/com/danilkinkin/buckwheat/calendar/model/Week.kt @@ -0,0 +1,8 @@ +package com.danilkinkin.buckwheat.calendar.model + +import java.time.YearMonth + +data class Week( + val number: Int, + val yearMonth: YearMonth +) diff --git a/app/src/main/java/com/danilkinkin/buckwheat/data/AppViewModel.kt b/app/src/main/java/com/danilkinkin/buckwheat/data/AppViewModel.kt new file mode 100644 index 0000000..da4e506 --- /dev/null +++ b/app/src/main/java/com/danilkinkin/buckwheat/data/AppViewModel.kt @@ -0,0 +1,29 @@ +package com.danilkinkin.buckwheat.data + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import com.danilkinkin.buckwheat.data.entities.Storage +import com.danilkinkin.buckwheat.di.DatabaseRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class AppViewModel @Inject constructor( + private val savedStateHandle: SavedStateHandle, + private val db: DatabaseRepository, +) : ViewModel() { + private val storage = db.storageDao() + + var isDebug: MutableLiveData = MutableLiveData(try { + storage.get("isDebug").value.toBoolean() + } catch (e: Exception) { + false + }) + + fun setIsDebug(debug: Boolean) { + storage.set(Storage("isDebug", debug.toString())) + + isDebug.value = debug + } +} \ No newline at end of file diff --git a/app/src/main/java/com/danilkinkin/buckwheat/viewmodels/SpentViewModel.kt b/app/src/main/java/com/danilkinkin/buckwheat/data/SpendsViewModel.kt similarity index 82% rename from app/src/main/java/com/danilkinkin/buckwheat/viewmodels/SpentViewModel.kt rename to app/src/main/java/com/danilkinkin/buckwheat/data/SpendsViewModel.kt index cfd201f..ec4bdb5 100644 --- a/app/src/main/java/com/danilkinkin/buckwheat/viewmodels/SpentViewModel.kt +++ b/app/src/main/java/com/danilkinkin/buckwheat/data/SpendsViewModel.kt @@ -1,31 +1,37 @@ -package com.danilkinkin.buckwheat.viewmodels +package com.danilkinkin.buckwheat.data -import android.app.Application import android.util.Log -import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import com.danilkinkin.buckwheat.MainActivity -import com.danilkinkin.buckwheat.R -import com.danilkinkin.buckwheat.di.DatabaseModule -import com.danilkinkin.buckwheat.entities.Spent -import com.danilkinkin.buckwheat.entities.Storage -import com.danilkinkin.buckwheat.utils.* -import com.google.android.material.snackbar.Snackbar +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import com.danilkinkin.buckwheat.data.entities.Spent +import com.danilkinkin.buckwheat.data.entities.Storage +import com.danilkinkin.buckwheat.di.DatabaseRepository +import com.danilkinkin.buckwheat.util.* +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.runBlocking import java.math.BigDecimal import java.math.RoundingMode import java.util.* +import javax.inject.Inject -class SpentViewModel(application: Application) : AndroidViewModel(application) { +@HiltViewModel +class SpendsViewModel @Inject constructor( + private val savedStateHandle: SavedStateHandle, + private val db: DatabaseRepository, +) : ViewModel() { enum class Stage { IDLE, CREATING_SPENT, EDIT_SPENT, COMMITTING_SPENT } enum class Action { PUT_NUMBER, SET_DOT, REMOVE_LAST } - private val db = DatabaseModule.getInstance(application) - private val spentDao = db.spentDao() private val storageDao = db.storageDao() var stage: MutableLiveData = MutableLiveData(Stage.IDLE) + var lastRemoveSpent: MutableLiveData = MutableLiveData(null) var budget: MutableLiveData = MutableLiveData( try { @@ -138,7 +144,7 @@ class SpentViewModel(application: Application) : AndroidViewModel(application) { this.lastReCalcBudgetDate = startDate this.spentDao.deleteAll() - + requireSetBudget.value = false resetSpent() @@ -161,21 +167,21 @@ class SpentViewModel(application: Application) : AndroidViewModel(application) { storageDao.set(Storage("lastReCalcBudgetDate", lastReCalcBudgetDate!!.time.toString())) } - fun createSpent() { + private suspend fun createSpent() { Log.d("Main", "createSpent") currentSpent = 0.0.toBigDecimal() stage.value = Stage.CREATING_SPENT } - fun editSpent(value: BigDecimal) { + private suspend fun editSpent(value: BigDecimal) { Log.d("Main", "editSpent") currentSpent = value stage.value = Stage.EDIT_SPENT } - fun commitSpent() { + suspend fun commitSpent() { if (stage.value !== Stage.EDIT_SPENT) return this.spentDao.insert(Spent(currentSpent, Date())) @@ -206,7 +212,9 @@ class SpentViewModel(application: Application) : AndroidViewModel(application) { spentFromDailyBudget.value = spentFromDailyBudget.value!! - spent.value storageDao.set(Storage("spentFromDailyBudget", spentFromDailyBudget.value.toString())) - Snackbar + lastRemoveSpent.value = spent + + /* Snackbar .make( MainActivity.getInstance().parentView, R.string.remove_spent, @@ -229,7 +237,19 @@ class SpentViewModel(application: Application) : AndroidViewModel(application) { ) ) } - .show() + .show() */ + } + + fun undoRemoveSpent(spent: Spent) { + this.spentDao.insert(spent) + + spentFromDailyBudget.value = spentFromDailyBudget.value!! + spent.value + storageDao.set( + Storage( + "spentFromDailyBudget", + spentFromDailyBudget.value.toString() + ) + ) } fun executeAction(action: Action, value: Int? = null) { @@ -271,7 +291,10 @@ class SpentViewModel(application: Application) : AndroidViewModel(application) { } if ("$valueLeftDot.$valueRightDot" == ".") { - resetSpent() + runBlocking { + Log.d("ViewModel", "resetSpent") + resetSpent() + } return } @@ -279,8 +302,11 @@ class SpentViewModel(application: Application) : AndroidViewModel(application) { } if (mutateSpent) { - if (stage.value === Stage.IDLE) createSpent() - editSpent("$valueLeftDot.$valueRightDot".toBigDecimal()) + runBlocking { + Log.d("ViewModel", "create/edit") + if (stage.value === Stage.IDLE) createSpent() + editSpent("$valueLeftDot.$valueRightDot".toBigDecimal()) + } } } } diff --git a/app/src/main/java/com/danilkinkin/buckwheat/data/ThemeViewModel.kt b/app/src/main/java/com/danilkinkin/buckwheat/data/ThemeViewModel.kt new file mode 100644 index 0000000..927339d --- /dev/null +++ b/app/src/main/java/com/danilkinkin/buckwheat/data/ThemeViewModel.kt @@ -0,0 +1,35 @@ +package com.danilkinkin.buckwheat.data + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.* +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + + +enum class ThemeMode { LIGHT, NIGHT, SYSTEM } + +class ThemeViewModel( + private val dataStore: DataStore +) : ViewModel() { + private val forceDarkModeKey = stringPreferencesKey("theme") + + val state = MutableLiveData(ThemeMode.SYSTEM) + fun request() { + viewModelScope.launch { + dataStore.data.collectLatest { + state.value = ThemeMode.valueOf(it[forceDarkModeKey] ?: ThemeMode.SYSTEM.toString()) + } + } + } + + fun changeThemeMode(mode: ThemeMode) { + viewModelScope.launch { + dataStore.edit { + it[forceDarkModeKey] = mode.toString() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/danilkinkin/buckwheat/dao/SpentDao.kt b/app/src/main/java/com/danilkinkin/buckwheat/data/dao/SpentDao.kt similarity index 80% rename from app/src/main/java/com/danilkinkin/buckwheat/dao/SpentDao.kt rename to app/src/main/java/com/danilkinkin/buckwheat/data/dao/SpentDao.kt index 62b5045..9c2dc8b 100644 --- a/app/src/main/java/com/danilkinkin/buckwheat/dao/SpentDao.kt +++ b/app/src/main/java/com/danilkinkin/buckwheat/data/dao/SpentDao.kt @@ -1,11 +1,11 @@ -package com.danilkinkin.buckwheat.dao +package com.danilkinkin.buckwheat.data.dao import androidx.lifecycle.LiveData import androidx.room.Dao import androidx.room.Delete import androidx.room.Insert import androidx.room.Query -import com.danilkinkin.buckwheat.entities.Spent +import com.danilkinkin.buckwheat.data.entities.Spent @Dao interface SpentDao { diff --git a/app/src/main/java/com/danilkinkin/buckwheat/dao/StorageDao.kt b/app/src/main/java/com/danilkinkin/buckwheat/data/dao/StorageDao.kt similarity index 73% rename from app/src/main/java/com/danilkinkin/buckwheat/dao/StorageDao.kt rename to app/src/main/java/com/danilkinkin/buckwheat/data/dao/StorageDao.kt index 54f996f..80a6d2e 100644 --- a/app/src/main/java/com/danilkinkin/buckwheat/dao/StorageDao.kt +++ b/app/src/main/java/com/danilkinkin/buckwheat/data/dao/StorageDao.kt @@ -1,7 +1,7 @@ -package com.danilkinkin.buckwheat.dao +package com.danilkinkin.buckwheat.data.dao import androidx.room.* -import com.danilkinkin.buckwheat.entities.Storage +import com.danilkinkin.buckwheat.data.entities.Storage @Dao interface StorageDao { diff --git a/app/src/main/java/com/danilkinkin/buckwheat/entities/Spent.kt b/app/src/main/java/com/danilkinkin/buckwheat/data/entities/Spent.kt similarity index 87% rename from app/src/main/java/com/danilkinkin/buckwheat/entities/Spent.kt rename to app/src/main/java/com/danilkinkin/buckwheat/data/entities/Spent.kt index 6c6183e..a26eaba 100644 --- a/app/src/main/java/com/danilkinkin/buckwheat/entities/Spent.kt +++ b/app/src/main/java/com/danilkinkin/buckwheat/data/entities/Spent.kt @@ -1,4 +1,4 @@ -package com.danilkinkin.buckwheat.entities +package com.danilkinkin.buckwheat.data.entities import androidx.room.ColumnInfo import androidx.room.Entity @@ -15,4 +15,4 @@ data class Spent( val date: Date, ) { @PrimaryKey(autoGenerate = true) var uid: Int = 0 -} +} \ No newline at end of file diff --git a/app/src/main/java/com/danilkinkin/buckwheat/entities/Storage.kt b/app/src/main/java/com/danilkinkin/buckwheat/data/entities/Storage.kt similarity index 83% rename from app/src/main/java/com/danilkinkin/buckwheat/entities/Storage.kt rename to app/src/main/java/com/danilkinkin/buckwheat/data/entities/Storage.kt index 6105641..b3ea79a 100644 --- a/app/src/main/java/com/danilkinkin/buckwheat/entities/Storage.kt +++ b/app/src/main/java/com/danilkinkin/buckwheat/data/entities/Storage.kt @@ -1,4 +1,4 @@ -package com.danilkinkin.buckwheat.entities +package com.danilkinkin.buckwheat.data.entities import androidx.room.ColumnInfo import androidx.room.Entity diff --git a/app/src/main/java/com/danilkinkin/buckwheat/decorators/SpendsDividerItemDecoration.kt b/app/src/main/java/com/danilkinkin/buckwheat/decorators/SpendsDividerItemDecoration.kt deleted file mode 100644 index 4acebac..0000000 --- a/app/src/main/java/com/danilkinkin/buckwheat/decorators/SpendsDividerItemDecoration.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.danilkinkin.buckwheat.decorators - -import android.content.Context -import android.graphics.Canvas -import android.graphics.Paint -import androidx.core.content.ContextCompat -import androidx.recyclerview.widget.RecyclerView -import com.danilkinkin.buckwheat.adapters.SpendsAdapter -import com.danilkinkin.buckwheat.utils.toDP - -class SpendsDividerItemDecoration(context: Context) : RecyclerView.ItemDecoration() { - - private val dividerPaint = Paint(Paint.ANTI_ALIAS_FLAG) - - init { - dividerPaint.color = ContextCompat.getColor(context, com.google.android.material.R.color.material_divider_color) - dividerPaint.strokeWidth = 1.5.toDP().toFloat() - } - - override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) { - - val left = parent.paddingLeft + 0.toDP() - val right = parent.width - parent.paddingRight - 0.toDP() - - val childCount = parent.childCount - for (i in 0 until childCount) { - val child = parent.getChildAt(i) - val nextChild = parent.getChildAt(i + 1) - - if ( - parent.getChildViewHolder(child) is SpendsAdapter.DrawViewHolder - || (nextChild !== null && parent.getChildViewHolder(nextChild) is SpendsAdapter.DrawViewHolder) - ) { - val params = child.layoutParams as RecyclerView.LayoutParams - val top = child.bottom + params.bottomMargin - - c.drawLine( - left.toFloat(), - top.toFloat(), - right.toFloat(), - top.toFloat(), - dividerPaint, - ) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/danilkinkin/buckwheat/di/AppModule.kt b/app/src/main/java/com/danilkinkin/buckwheat/di/AppModule.kt new file mode 100644 index 0000000..ef003e9 --- /dev/null +++ b/app/src/main/java/com/danilkinkin/buckwheat/di/AppModule.kt @@ -0,0 +1,36 @@ +package com.danilkinkin.buckwheat.di + +import android.content.Context +import androidx.room.Room +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object AppModule { + @Singleton + @Provides + fun provideYourDatabase( + @ApplicationContext app: Context + ) = Room.databaseBuilder( + app.applicationContext, + DatabaseModule::class.java, + "buckwheat-db", + ) + .fallbackToDestructiveMigration() + .allowMainThreadQueries() + .build() + + @Singleton + @Provides + fun provideSpentDao(db: DatabaseModule) = db.spentDao() + + @Singleton + @Provides + fun provideStorageDao(db: DatabaseModule) = db.storageDao() + +} \ No newline at end of file diff --git a/app/src/main/java/com/danilkinkin/buckwheat/di/DatabaseModule.kt b/app/src/main/java/com/danilkinkin/buckwheat/di/DatabaseModule.kt index 7ad9a9d..c4b0c04 100644 --- a/app/src/main/java/com/danilkinkin/buckwheat/di/DatabaseModule.kt +++ b/app/src/main/java/com/danilkinkin/buckwheat/di/DatabaseModule.kt @@ -1,42 +1,16 @@ package com.danilkinkin.buckwheat.di -import android.app.Application import androidx.room.Database -import androidx.room.Room import androidx.room.RoomDatabase import androidx.room.TypeConverters -import com.danilkinkin.buckwheat.dao.SpentDao -import com.danilkinkin.buckwheat.dao.StorageDao -import com.danilkinkin.buckwheat.entities.Spent -import com.danilkinkin.buckwheat.entities.Storage - - -lateinit var instanceDB: DatabaseModule +import com.danilkinkin.buckwheat.data.dao.SpentDao +import com.danilkinkin.buckwheat.data.dao.StorageDao +import com.danilkinkin.buckwheat.data.entities.Spent +import com.danilkinkin.buckwheat.data.entities.Storage @Database(entities = [Spent::class, Storage::class], version = 1) @TypeConverters(RoomConverters::class) abstract class DatabaseModule : RoomDatabase() { - - companion object { - fun getInstance(applicationContext: Application): DatabaseModule { - if (::instanceDB.isInitialized) { - return instanceDB - } - - instanceDB = Room.databaseBuilder( - applicationContext, - DatabaseModule::class.java, - "buckwheat-db", - ) - .fallbackToDestructiveMigration() - .allowMainThreadQueries() - .build() - - - return instanceDB - } - } - abstract fun spentDao(): SpentDao abstract fun storageDao(): StorageDao diff --git a/app/src/main/java/com/danilkinkin/buckwheat/di/DatabaseRepository.kt b/app/src/main/java/com/danilkinkin/buckwheat/di/DatabaseRepository.kt new file mode 100644 index 0000000..38d091c --- /dev/null +++ b/app/src/main/java/com/danilkinkin/buckwheat/di/DatabaseRepository.kt @@ -0,0 +1,13 @@ +package com.danilkinkin.buckwheat.di + +import com.danilkinkin.buckwheat.data.dao.SpentDao +import com.danilkinkin.buckwheat.data.dao.StorageDao +import javax.inject.Inject + +class DatabaseRepository @Inject constructor( + private val spentDao: SpentDao, + private val storageDao: StorageDao +){ + fun spentDao() = spentDao + fun storageDao() = storageDao +} \ No newline at end of file diff --git a/app/src/main/java/com/danilkinkin/buckwheat/editor/Editor.kt b/app/src/main/java/com/danilkinkin/buckwheat/editor/Editor.kt new file mode 100644 index 0000000..98ebe3f --- /dev/null +++ b/app/src/main/java/com/danilkinkin/buckwheat/editor/Editor.kt @@ -0,0 +1,343 @@ +package com.danilkinkin.buckwheat.editor + +import android.animation.ValueAnimator +import android.util.Log +import android.view.animation.AccelerateDecelerateInterpolator +import androidx.compose.animation.core.Animatable +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.animation.doOnEnd +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.viewmodel.compose.viewModel +import com.danilkinkin.buckwheat.R +import com.danilkinkin.buckwheat.base.BigIconButton +import com.danilkinkin.buckwheat.base.BottomSheetWrapper +import com.danilkinkin.buckwheat.data.AppViewModel +import com.danilkinkin.buckwheat.data.SpendsViewModel +import com.danilkinkin.buckwheat.ui.BuckwheatTheme +import com.danilkinkin.buckwheat.util.combineColors +import com.danilkinkin.buckwheat.util.prettyCandyCanes +import com.danilkinkin.buckwheat.wallet.Wallet +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.collectLatest +import kotlin.math.absoluteValue +import kotlin.math.max +import kotlin.math.min + +enum class AnimState { FIRST_IDLE, EDITING, COMMIT, IDLE, RESET } + +@Composable +fun Editor( + modifier: Modifier = Modifier, + spendsViewModel: SpendsViewModel = viewModel(), + appViewModel: AppViewModel = viewModel(), + onOpenWallet: () -> Unit = {}, + onOpenSettings: () -> Unit = {}, + onReaclcBudget: () -> Unit = {}, +) { + var currState by remember { mutableStateOf(null) } + var currAnimator by remember { mutableStateOf(null) } + val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current) + + val localDensity = LocalDensity.current + + var budgetValue by remember { mutableStateOf("") } + var restBudgetValue by remember { mutableStateOf("") } + var spentValue by remember { mutableStateOf("") } + + var budgetHeight by remember { mutableStateOf(0F) } + var restBudgetHeight by remember { mutableStateOf(0F) } + var spentHeight by remember { mutableStateOf(0F) } + + var budgetOffset by remember { mutableStateOf(0F) } + var restBudgetOffset by remember { mutableStateOf(0F) } + var spentOffset by remember { mutableStateOf(0F) } + + var budgetAlpha by remember { mutableStateOf(0F) } + var restBudgetAlpha by remember { mutableStateOf(0F) } + var spentAlpha by remember { mutableStateOf(0F) } + + var budgetValueFontSize by remember { mutableStateOf(60.sp) } + var budgetLabelFontSize by remember { mutableStateOf(60.sp) } + var restBudgetValueFontSize by remember { mutableStateOf(60.sp) } + var restBudgetLabelFontSize by remember { mutableStateOf(60.sp) } + var spentValueFontSize by remember { mutableStateOf(60.sp) } + var spentLabelFontSize by remember { mutableStateOf(60.sp) } + + + fun calculateValues( + budget: Boolean = true, + restBudget: Boolean = true, + spent: Boolean = true + ) { + val spentFromDailyBudget = spendsViewModel.spentFromDailyBudget.value!! + val dailyBudget = spendsViewModel.dailyBudget.value!! + + if (budget) budgetValue = prettyCandyCanes( + dailyBudget - spentFromDailyBudget, + currency = spendsViewModel.currency, + ) + if (restBudget) restBudgetValue = + prettyCandyCanes( + dailyBudget - spentFromDailyBudget - spendsViewModel.currentSpent, + currency = spendsViewModel.currency, + ) + if (spent) spentValue = prettyCandyCanes( + spendsViewModel.currentSpent, spendsViewModel.useDot, + currency = spendsViewModel.currency, + ) + } + + fun animFrame(state: AnimState, progress: Float = 1F) { + when (state) { + AnimState.FIRST_IDLE -> { + budgetLabelFontSize = 10.sp + budgetValueFontSize = 40.sp + budgetOffset = 30 * (1F - progress) + budgetAlpha = progress + } + AnimState.EDITING -> { + var offset = 0F + + restBudgetValueFontSize = 20.sp + restBudgetLabelFontSize = 8.sp + offset += restBudgetHeight + restBudgetOffset = (offset + spentHeight) * (1F - progress) + restBudgetAlpha = 1F + + spentValueFontSize = 60.sp + spentLabelFontSize = 18.sp + spentOffset = (spentHeight + offset) * (1F - progress) - offset + spentAlpha = 1F + + offset += spentHeight + + budgetValueFontSize = (40 - 28 * progress).sp + budgetLabelFontSize = (10 - 4 * progress).sp + budgetOffset = -offset * progress + budgetAlpha = 1F + } + AnimState.COMMIT -> { + var offset = 0F + + val progressA = min(progress * 2F, 1F) + val progressB = max((progress - 0.5F) * 2F, 0F) + + restBudgetValueFontSize = (20 + 20 * progress).sp + restBudgetLabelFontSize = (8 + 2 * progress).sp + offset += restBudgetHeight + restBudgetAlpha = 1F + + spentValueFontSize = 60.sp + spentLabelFontSize = 18.sp + spentOffset = -offset - 50 * progressB + spentAlpha = 1F - progressB + offset += spentHeight + + budgetValueFontSize = 12.sp + budgetLabelFontSize = 6.sp + budgetOffset = -offset - 50 * progressA + budgetAlpha = 1F - progressA + } + AnimState.RESET -> { + var offset = 0F + + restBudgetValueFontSize = 20.sp + restBudgetLabelFontSize = 8.sp + offset += restBudgetHeight + restBudgetOffset = (offset + spentHeight) * progress + + spentValueFontSize = 60.sp + spentLabelFontSize = 18.sp + spentOffset = (spentHeight + offset) * progress - offset + offset += spentHeight + + budgetValueFontSize = (12 + 28 * progress).sp + budgetLabelFontSize = (6 + 4 * progress).sp + budgetOffset = -offset * (1F - progress) + } + AnimState.IDLE -> { + calculateValues(restBudget = false) + + budgetValueFontSize = 40.sp + budgetLabelFontSize = 10.sp + budgetOffset = 0F + budgetAlpha = 1F + + restBudgetAlpha = 0F + } + } + } + + fun animTo(state: AnimState) { + if (currState === state) return + + currState = state + + if (currAnimator !== null) { + currAnimator!!.pause() + } + + currAnimator = ValueAnimator.ofFloat(0F, 1F) + + currAnimator!!.apply { + duration = 220 + interpolator = AccelerateDecelerateInterpolator() + + addUpdateListener { valueAnimator -> + val animatedValue = valueAnimator.animatedValue as Float + + animFrame(state, animatedValue) + } + + doOnEnd { + if (state === AnimState.COMMIT) { + animFrame(AnimState.IDLE) + } + } + + start() + } + } + + LaunchedEffect(Unit) { + calculateValues() + + spendsViewModel.dailyBudget.observe(lifecycleOwner.value) { + calculateValues() + } + + spendsViewModel.spentFromDailyBudget.observe(lifecycleOwner.value) { + calculateValues(budget = currState !== AnimState.EDITING, restBudget = false) + } + + spendsViewModel.stage.observe(lifecycleOwner.value) { + when (it) { + SpendsViewModel.Stage.IDLE, null -> { + if (currState === AnimState.EDITING) animTo(AnimState.RESET) + } + SpendsViewModel.Stage.CREATING_SPENT -> { + calculateValues(budget = false) + + animTo(AnimState.EDITING) + } + SpendsViewModel.Stage.EDIT_SPENT -> { + calculateValues(budget = false) + } + SpendsViewModel.Stage.COMMITTING_SPENT -> { + animTo(AnimState.COMMIT) + + spendsViewModel.resetSpent() + } + } + } + } + + Card( + shape = RoundedCornerShape(bottomStart = 48.dp, bottomEnd = 48.dp), + colors = CardDefaults.cardColors( + containerColor = combineColors( + MaterialTheme.colorScheme.primaryContainer, + MaterialTheme.colorScheme.surfaceVariant, + angle = 0.9F, + ) + ), + modifier = modifier.fillMaxSize() + ) { + Row( + horizontalArrangement = Arrangement.End, + modifier = Modifier + .fillMaxWidth() + .padding(start = 24.dp, end = 24.dp) + .statusBarsPadding(), + ) { + BigIconButton( + icon = painterResource(R.drawable.ic_developer_mode), + contentDescription = null, + onClick = onReaclcBudget, + ) + BigIconButton( + icon = painterResource(R.drawable.ic_balance_wallet), + contentDescription = null, + onClick = onOpenWallet, + ) + BigIconButton( + icon = painterResource(R.drawable.ic_settings), + contentDescription = null, + onClick = onOpenSettings, + ) + } + Box( + contentAlignment = Alignment.BottomStart, + modifier = Modifier + .fillMaxSize() + .padding(start = 24.dp, end = 24.dp) + .onGloballyPositioned { + if (currState == null) animTo(AnimState.FIRST_IDLE) + }, + ) { + EditorRow( + value = budgetValue, + label = stringResource(id = R.string.budget_for_today), + fontSizeValue = budgetValueFontSize, + fontSizeLabel = budgetLabelFontSize, + modifier = Modifier + .offset(y = with(localDensity) { budgetOffset.toDp() }) + .alpha(budgetAlpha) + .onGloballyPositioned { + budgetHeight = it.size.height.toFloat() + } + ) + EditorRow( + value = spentValue, + label = stringResource(id = R.string.spent), + fontSizeValue = spentValueFontSize, + fontSizeLabel = spentLabelFontSize, + modifier = Modifier + .offset(y = with(localDensity) { spentOffset.toDp() }) + .alpha(spentAlpha) + .onGloballyPositioned { + spentHeight = it.size.height.toFloat() + }, + ) + EditorRow( + value = restBudgetValue, + label = stringResource(id = R.string.rest_budget_for_today), + fontSizeValue = restBudgetValueFontSize, + fontSizeLabel = restBudgetLabelFontSize, + modifier = Modifier + .offset(y = with(localDensity) { restBudgetOffset.toDp() }) + .alpha(restBudgetAlpha) + .onGloballyPositioned { + restBudgetHeight = it.size.height.toFloat() + }, + ) + } + } +} + +@Preview +@Composable +fun EditorPreview() { + BuckwheatTheme { + Editor() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/danilkinkin/buckwheat/editor/EditorRow.kt b/app/src/main/java/com/danilkinkin/buckwheat/editor/EditorRow.kt new file mode 100644 index 0000000..3a292f0 --- /dev/null +++ b/app/src/main/java/com/danilkinkin/buckwheat/editor/EditorRow.kt @@ -0,0 +1,50 @@ +package com.danilkinkin.buckwheat.editor + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import com.danilkinkin.buckwheat.R +import com.danilkinkin.buckwheat.ui.BuckwheatTheme + +@Composable +fun EditorRow( + value: String, + label: String, + modifier: Modifier = Modifier, + fontSizeValue: TextUnit = MaterialTheme.typography.displayLarge.fontSize, + fontSizeLabel: TextUnit = MaterialTheme.typography.labelMedium.fontSize, +) { + Column( + modifier = modifier + .padding(bottom = 24.dp) + ) { + Text( + text = value, + style = MaterialTheme.typography.displayLarge, + fontSize = fontSizeValue, + ) + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + fontSize = fontSizeLabel, + ) + } +} + +@Preview +@Composable +fun PreviewEditorRow() { + BuckwheatTheme { + EditorRow( + value = "1 245 P", + label = stringResource(id = R.string.budget_for_today), + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/danilkinkin/buckwheat/home/CheckPaddings.kt b/app/src/main/java/com/danilkinkin/buckwheat/home/CheckPaddings.kt new file mode 100644 index 0000000..db131f6 --- /dev/null +++ b/app/src/main/java/com/danilkinkin/buckwheat/home/CheckPaddings.kt @@ -0,0 +1,56 @@ +package com.danilkinkin.buckwheat.home + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import com.danilkinkin.buckwheat.base.Divider + +@Composable +fun CheckPaddings() { + Box(Modifier.fillMaxSize()) { + Column(Modifier.fillMaxSize()) { + Box( + Modifier + .background(Color.Red) + .fillMaxWidth() + .weight(1F)) + Box( + Modifier + .background(Color.Blue) + .fillMaxWidth() + .weight(1F)) + } + Column( + Modifier + .fillMaxSize() + .systemBarsPadding() + ) { + Divider() + Box( + Modifier + .background(Color.Red) + .fillMaxWidth() + .weight(1F)) + Box( + Modifier + .background(Color.Green) + .fillMaxWidth() + .weight(1F)) + Box( + Modifier + .background(Color.Blue) + .fillMaxWidth() + .weight(1F)) + Divider() + } + } +} + +@Preview +@Composable +fun PreviewCheckPaddings() { + CheckPaddings() +} \ No newline at end of file diff --git a/app/src/main/java/com/danilkinkin/buckwheat/home/MainActivity.kt b/app/src/main/java/com/danilkinkin/buckwheat/home/MainActivity.kt new file mode 100644 index 0000000..310e557 --- /dev/null +++ b/app/src/main/java/com/danilkinkin/buckwheat/home/MainActivity.kt @@ -0,0 +1,49 @@ +package com.danilkinkin.buckwheat.home + +import android.content.Context +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.compose.runtime.* +import androidx.compose.ui.graphics.Color +import androidx.core.view.WindowCompat +import androidx.datastore.preferences.preferencesDataStore +import com.danilkinkin.buckwheat.data.AppViewModel +import com.danilkinkin.buckwheat.data.SpendsViewModel +import com.google.accompanist.systemuicontroller.rememberSystemUiController +import dagger.hilt.android.AndroidEntryPoint +import com.danilkinkin.buckwheat.ui.BuckwheatTheme + +val Context.dataStore by preferencesDataStore("settings") + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + private val spendsViewModel: SpendsViewModel by viewModels() + private val appViewModel: AppViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + WindowCompat.setDecorFitsSystemWindows(window, false) + + setContent { + val systemUiController = rememberSystemUiController() + + DisposableEffect(systemUiController) { + systemUiController.setSystemBarsColor( + color = Color.Transparent, + darkIcons = false, + isNavigationBarContrastEnforced = false, + ) + + onDispose {} + } + + BuckwheatTheme { + MainScreen() + } + } + } +} + diff --git a/app/src/main/java/com/danilkinkin/buckwheat/home/MainScreen.kt b/app/src/main/java/com/danilkinkin/buckwheat/home/MainScreen.kt new file mode 100644 index 0000000..fc9cc66 --- /dev/null +++ b/app/src/main/java/com/danilkinkin/buckwheat/home/MainScreen.kt @@ -0,0 +1,239 @@ +package com.danilkinkin.buckwheat.home + +import android.util.Log +import androidx.compose.foundation.layout.* +import androidx.compose.material.ExperimentalMaterialApi +import com.danilkinkin.buckwheat.base.ModalBottomSheetValue +import com.danilkinkin.buckwheat.base.rememberModalBottomSheetState +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.viewmodel.compose.viewModel +import com.danilkinkin.buckwheat.R +import com.danilkinkin.buckwheat.base.BottomSheetWrapper +import com.danilkinkin.buckwheat.data.SpendsViewModel +import com.danilkinkin.buckwheat.editor.Editor +import com.danilkinkin.buckwheat.keyboard.Keyboard +import com.danilkinkin.buckwheat.recalcBudget.RecalcBudget +import com.danilkinkin.buckwheat.settings.Settings +import com.danilkinkin.buckwheat.spendsHistory.BudgetInfo +import com.danilkinkin.buckwheat.spendsHistory.Spent +import com.danilkinkin.buckwheat.topSheet.TopSheetLayout +import com.danilkinkin.buckwheat.ui.BuckwheatTheme +import com.danilkinkin.buckwheat.wallet.FinishDateSelector +import com.danilkinkin.buckwheat.wallet.Wallet +import kotlinx.coroutines.launch +import java.math.BigDecimal +import java.util.* + +@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class) +@Composable +fun MainScreen(spendsViewModel: SpendsViewModel = viewModel()) { + var contentHeight by remember { mutableStateOf(0F) } + var contentWidth by remember { mutableStateOf(0F) } + val walletSheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden) + val finishDateSheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden) + val settingsSheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden) + val recalcBudgetSheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden) + val coroutineScope = rememberCoroutineScope() + val presetFinishDate = remember { mutableStateOf(null) } + val requestFinishDateCallback = remember { mutableStateOf<((finishDate: Date) -> Unit)?>(null) } + val snackbarHostState = remember { SnackbarHostState() } + val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current) + + val localDensity = LocalDensity.current + + val spends = spendsViewModel.getSpends().observeAsState(initial = emptyList()) + val budget = spendsViewModel.budget.observeAsState() + val startDate = spendsViewModel.startDate + val finishDate = spendsViewModel.finishDate + + + val snackBarMessage = stringResource(R.string.remove_spent) + val snackBarAction = stringResource(R.string.remove_spent_undo) + + LaunchedEffect(Unit) { + spendsViewModel.lastRemoveSpent.observe(lifecycleOwner.value) { + if (it == null) return@observe + + coroutineScope.launch { + val snackbarResult = snackbarHostState.showSnackbar( + message = snackBarMessage, + actionLabel = snackBarAction + ) + + if (snackbarResult == SnackbarResult.ActionPerformed) { + spendsViewModel.undoRemoveSpent(it) + } + } + } + + spendsViewModel.requireReCalcBudget.observe(lifecycleOwner.value) { + Log.d("MainScreen", "requireReCalcBudget = $it") + if (it) { + coroutineScope.launch { + recalcBudgetSheetState.show() + } + } + } + + spendsViewModel.requireSetBudget.observe(lifecycleOwner.value) { + Log.d("MainScreen", "requireSetBudget = $it") + if (it) { + coroutineScope.launch { + walletSheetState.show() + } + } + } + } + + Scaffold( + snackbarHost = { + SnackbarHost(hostState = snackbarHostState) + }, + modifier = Modifier + .fillMaxSize() + .onGloballyPositioned { + contentWidth = it.size.width.toFloat() + contentHeight = it.size.height.toFloat() + }, + containerColor = MaterialTheme.colorScheme.background, + ) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(top = with(localDensity) { (contentHeight - contentWidth).toDp() }) + ) { + Keyboard( + modifier = Modifier + .height(with(localDensity) { contentWidth.toDp() }) + .fillMaxWidth() + .navigationBarsPadding() + ) + } + + TopSheetLayout( + halfHeight = contentHeight - contentWidth, + itemsCount = spends.value.size + 2, + ) { + item { + BudgetInfo( + budget = budget.value ?: BigDecimal(0), + startDate = startDate, + finishDate = finishDate, + currency = spendsViewModel.currency, + ) + Divider() + } + spends.value.forEach { + item(it.uid) { + Spent( + spent = it, + currency = spendsViewModel.currency, + onDelete = { + spendsViewModel.removeSpent(it) + } + ) + Divider() + } + } + item { + Editor( + modifier = Modifier + .fillMaxHeight() + .height(with(localDensity) { (contentHeight - contentWidth).toDp() }), + onOpenWallet = { + coroutineScope.launch { + walletSheetState.show() + } + }, + onOpenSettings = { + coroutineScope.launch { + settingsSheetState.show() + } + }, + onReaclcBudget = { + coroutineScope.launch { + recalcBudgetSheetState.show() + } + }, + ) + } + } + + BottomSheetWrapper( + state = walletSheetState, + cancelable = spendsViewModel.requireSetBudget.value == false, + ) { + Wallet( + requestFinishDate = { presetValue, callback -> + coroutineScope.launch { + finishDateSheetState.show() + + presetFinishDate.value = presetValue + requestFinishDateCallback.value = callback + } + }, + onClose = { + coroutineScope.launch { + walletSheetState.hide() + } + } + ) + } + + BottomSheetWrapper(state = finishDateSheetState) { + FinishDateSelector( + selectDate = presetFinishDate.value, + onBackPressed = { + coroutineScope.launch { + finishDateSheetState.hide() + } + }, + onApply = { + requestFinishDateCallback.value?.let { callback -> callback(it) } + coroutineScope.launch { + finishDateSheetState.hide() + } + }, + ) + } + + BottomSheetWrapper(state = settingsSheetState) { + Settings( + onClose = { + coroutineScope.launch { + settingsSheetState.hide() + } + } + ) + } + + /* BottomSheetWrapper( + state = recalcBudgetSheetState, + cancelable = false, + ) { + RecalcBudget( + onClose = { + coroutineScope.launch { + recalcBudgetSheetState.hide() + } + } + ) + } */ + } +} + +@Preview +@Composable +fun MainActivityPreview() { + BuckwheatTheme { + MainScreen() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/danilkinkin/buckwheat/keyboard/Keyboard.kt b/app/src/main/java/com/danilkinkin/buckwheat/keyboard/Keyboard.kt new file mode 100644 index 0000000..eb418a1 --- /dev/null +++ b/app/src/main/java/com/danilkinkin/buckwheat/keyboard/Keyboard.kt @@ -0,0 +1,165 @@ +package com.danilkinkin.buckwheat.keyboard + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.danilkinkin.buckwheat.R +import com.danilkinkin.buckwheat.data.SpendsViewModel +import com.danilkinkin.buckwheat.ui.BuckwheatTheme +import kotlinx.coroutines.runBlocking + +val BUTTON_GAP = 4.dp + +@Composable +fun Keyboard( + modifier: Modifier = Modifier, + spendsViewModel: SpendsViewModel = viewModel(), +) { + Column ( + modifier = modifier + .fillMaxSize() + .padding(top = 16.dp, start = 16.dp, end = 16.dp, bottom = 0.dp) + ) { + Row (modifier = Modifier + .fillMaxSize() + .weight(1F)) { + for (i in 7..9) { + KeyboardButton( + modifier = Modifier + .weight(1F) + .padding(BUTTON_GAP), + type = KeyboardButtonType.DEFAULT, + text = i.toString(), + onClick = { + spendsViewModel.executeAction(SpendsViewModel.Action.PUT_NUMBER, i) + } + ) + } + KeyboardButton( + modifier = Modifier + .weight(1F) + .padding(BUTTON_GAP), + type = KeyboardButtonType.SECONDARY, + icon = painterResource(R.drawable.ic_backspace), + onClick = { + spendsViewModel.executeAction(SpendsViewModel.Action.REMOVE_LAST) + } + ) + } + Row (modifier = Modifier + .fillMaxSize() + .weight(3F)) { + Column (modifier = Modifier + .fillMaxSize() + .weight(3F)) { + Row (modifier = Modifier + .fillMaxSize() + .weight(1F)) { + for (i in 4..6) { + KeyboardButton( + modifier = Modifier + .weight(1F) + .padding(BUTTON_GAP), + type = KeyboardButtonType.DEFAULT, + text = i.toString(), + onClick = { + spendsViewModel.executeAction(SpendsViewModel.Action.PUT_NUMBER, i) + } + ) + } + } + Row (modifier = Modifier + .fillMaxSize() + .weight(1F)) { + for (i in 1..3) { + KeyboardButton( + modifier = Modifier + .weight(1F) + .padding(BUTTON_GAP), + type = KeyboardButtonType.DEFAULT, + text = i.toString(), + onClick = { + spendsViewModel.executeAction(SpendsViewModel.Action.PUT_NUMBER, i) + } + ) + } + } + Row (modifier = Modifier + .fillMaxSize() + .weight(1F)) { + KeyboardButton( + modifier = Modifier + .weight(2F) + .padding(BUTTON_GAP), + type = KeyboardButtonType.DEFAULT, + text = "0", + onClick = { + spendsViewModel.executeAction(SpendsViewModel.Action.PUT_NUMBER, 0) + } + ) + KeyboardButton( + modifier = Modifier + .weight(1F) + .padding(BUTTON_GAP), + type = KeyboardButtonType.DEFAULT, + text = ".", + onClick = { + spendsViewModel.executeAction(SpendsViewModel.Action.SET_DOT) + } + ) + } + } + Column (modifier = Modifier + .fillMaxSize() + .weight(1F)) { + KeyboardButton( + modifier = Modifier + .weight(1F) + .padding(BUTTON_GAP), + type = KeyboardButtonType.PRIMARY, + icon = painterResource(R.drawable.ic_apply), + onClick = { + /* if ("${spendsViewModel.valueLeftDot}.${spendsViewModel.valueRightDot}" == "00000000.") { + spendsViewModel.resetSpent() + + appModel.setIsDebug(!appModel.isDebug.value!!) + + Snackbar + .make( + requireView(), "Debug ${ + if (appModel.isDebug.value!!) { + "ON" + } else { + "OFF" + } + }", Snackbar.LENGTH_LONG + ) + .show() + + return@setOnClickListener + } */ + + runBlocking { + spendsViewModel.commitSpent() + } + } + ) + } + } + } +} + +@Preview +@Composable +fun KeyboardPreview() { + BuckwheatTheme { + Keyboard() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/danilkinkin/buckwheat/keyboard/KeyboardButton.kt b/app/src/main/java/com/danilkinkin/buckwheat/keyboard/KeyboardButton.kt new file mode 100644 index 0000000..d1e161e --- /dev/null +++ b/app/src/main/java/com/danilkinkin/buckwheat/keyboard/KeyboardButton.kt @@ -0,0 +1,109 @@ +package com.danilkinkin.buckwheat.keyboard + +import android.view.MotionEvent +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.animateDp +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.updateTransition +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.input.pointer.pointerInteropFilter +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.danilkinkin.buckwheat.base.AutoResizeText +import com.danilkinkin.buckwheat.base.FontSizeRange +import com.danilkinkin.buckwheat.ui.BuckwheatTheme +import com.danilkinkin.buckwheat.util.combineColors +import kotlin.math.min + +enum class KeyboardButtonType { DEFAULT, PRIMARY, SECONDARY, TERTIARY } + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun KeyboardButton( + modifier: Modifier = Modifier, + type: KeyboardButtonType, + text: String? = null, + icon: Painter? = null, + onClick: (() -> Unit) = {}, +) { + var minSize by remember { mutableStateOf(0.dp) } + val interactionSource = remember { MutableInteractionSource() } + val isPressed = interactionSource.collectIsPressedAsState() + val radius = animateDpAsState(targetValue = if (isPressed.value) 20.dp else minSize / 2) + + val color = when (type) { + KeyboardButtonType.DEFAULT -> combineColors( + MaterialTheme.colorScheme.surface, + MaterialTheme.colorScheme.surfaceVariant, + angle = 0.4F, + ) + KeyboardButtonType.PRIMARY -> MaterialTheme.colorScheme.primaryContainer + KeyboardButtonType.SECONDARY -> MaterialTheme.colorScheme.secondaryContainer + KeyboardButtonType.TERTIARY -> MaterialTheme.colorScheme.tertiaryContainer + } + + Surface( + tonalElevation = 10.dp, + modifier = modifier + .fillMaxSize() + .onGloballyPositioned { + minSize = min(it.size.height, it.size.width).dp + } + .clip(RoundedCornerShape(radius.value)) + ) { + Box( + modifier = Modifier + .background(color = color) + .fillMaxSize() + .clip(RoundedCornerShape(radius.value)) + .clickable( + interactionSource = interactionSource, + indication = rememberRipple() + ) { onClick.invoke() }, + contentAlignment = Alignment.Center + ) { + if (text !== null) { + AutoResizeText( + text = text, + color = contentColorFor(color), + fontSizeRange = FontSizeRange(min = 8.sp, max = 90.sp) + ) + } + if (icon !== null) { + Icon( + painter = icon, + contentDescription = null, + ) + } + } + } +} + +@Preview +@Composable +fun KeyboardButtonPreview() { + BuckwheatTheme { + KeyboardButton( + type = KeyboardButtonType.DEFAULT, + text = "4" + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/danilkinkin/buckwheat/recalcBudget/RecalcBudget.kt b/app/src/main/java/com/danilkinkin/buckwheat/recalcBudget/RecalcBudget.kt new file mode 100644 index 0000000..222f087 --- /dev/null +++ b/app/src/main/java/com/danilkinkin/buckwheat/recalcBudget/RecalcBudget.kt @@ -0,0 +1,166 @@ +package com.danilkinkin.buckwheat.recalcBudget + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.danilkinkin.buckwheat.R +import com.danilkinkin.buckwheat.data.SpendsViewModel +import com.danilkinkin.buckwheat.ui.BuckwheatTheme +import com.danilkinkin.buckwheat.util.* +import java.math.RoundingMode +import kotlin.math.abs + +@Composable +fun RecalcBudget( + spendsViewModel: SpendsViewModel = viewModel(), + onClose: () -> Unit = {}, +) { + val restDays = countDays(spendsViewModel.finishDate) + val skippedDays = abs(countDays(spendsViewModel.lastReCalcBudgetDate!!)) + + val restBudget = + (spendsViewModel.budget.value!! - spendsViewModel.spent.value!!) - spendsViewModel.dailyBudget.value!! + val perDayBudget = restBudget / (restDays + skippedDays - 1).toBigDecimal() + + val requireDistributeBudget = perDayBudget * (skippedDays - 1).coerceAtLeast(0) + .toBigDecimal() + spendsViewModel.dailyBudget.value!! - spendsViewModel.spentFromDailyBudget.value!! + + val budgetPerDaySplit = + ((restBudget + spendsViewModel.dailyBudget.value!! - spendsViewModel.spentFromDailyBudget.value!!) / restDays.toBigDecimal()).setScale( + 0, + RoundingMode.FLOOR + ) + val budgetPerDayAdd = (restBudget / restDays.toBigDecimal()).setScale(0, RoundingMode.FLOOR) + val budgetPerDayAddDailyBudget = budgetPerDayAdd + requireDistributeBudget + + + Column( + modifier = Modifier + .padding(horizontal = 24.dp) + .navigationBarsPadding(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(Modifier.height(24.dp)) + Text( + text = stringResource(R.string.new_day_title), + style = MaterialTheme.typography.titleLarge, + ) + Spacer(Modifier.height(24.dp)) + Text( + text = prettyCandyCanes( + requireDistributeBudget, + currency = spendsViewModel.currency, + ), + style = MaterialTheme.typography.displayLarge, + ) + Spacer(Modifier.height(24.dp)) + Text( + text = stringResource(R.string.recalc_budget), + style = MaterialTheme.typography.bodyLarge, + ) + Spacer(Modifier.height(48.dp)) + ButtonWithIcon( + title = stringResource(R.string.split_rest_days_title), + description = stringResource( + R.string.split_rest_days_description, + prettyCandyCanes( + budgetPerDaySplit, + currency = spendsViewModel.currency, + ), + ), + onClick = { + spendsViewModel.reCalcDailyBudget(budgetPerDaySplit) + + onClose() + }, + ) + Spacer(Modifier.height(16.dp)) + ButtonWithIcon( + title = stringResource(R.string.add_current_day_title), + description = stringResource( + R.string.add_current_day_description, + prettyCandyCanes( + requireDistributeBudget + budgetPerDayAdd, + currency = spendsViewModel.currency, + ), + prettyCandyCanes( + budgetPerDayAdd, + currency = spendsViewModel.currency, + ), + ), + onClick = { + spendsViewModel.reCalcDailyBudget(budgetPerDayAdd + requireDistributeBudget) + + onClose() + }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ButtonWithIcon( + title: String, + description: String, + onClick: () -> Unit, +){ + Card( + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.extraLarge, + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + Modifier + .padding(16.dp) + .weight(weight = 1F, fill = true)) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + ) + } + Icon( + modifier = Modifier + .width(48.dp) + .padding(end = 8.dp), + painter = painterResource(R.drawable.ic_arrow_right), + contentDescription = null, + ) + } + } +} + +@Preview +@Composable +fun PreviewButtonWithIcon(){ + BuckwheatTheme { + ButtonWithIcon( + title = "Title", + description = "Button looooooooooooooooooooooooooooooooooong description", + onClick = {}, + ) + } +} + +@Preview +@Composable +fun PreviewRecalcBudget() { + BuckwheatTheme { + RecalcBudget() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/danilkinkin/buckwheat/settings/Settings.kt b/app/src/main/java/com/danilkinkin/buckwheat/settings/Settings.kt new file mode 100644 index 0000000..5077fad --- /dev/null +++ b/app/src/main/java/com/danilkinkin/buckwheat/settings/Settings.kt @@ -0,0 +1,163 @@ +package com.danilkinkin.buckwheat.settings + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Intent +import android.net.Uri +import android.widget.Toast +import androidx.compose.foundation.layout.* +import com.danilkinkin.buckwheat.base.Divider +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.content.ContentProviderCompat.requireContext +import androidx.core.content.ContextCompat +import androidx.core.content.ContextCompat.startActivity +import com.danilkinkin.buckwheat.R +import com.danilkinkin.buckwheat.base.CheckedRow +import com.danilkinkin.buckwheat.base.TextRow +import com.danilkinkin.buckwheat.data.ThemeMode +import com.danilkinkin.buckwheat.data.ThemeViewModel +import com.danilkinkin.buckwheat.home.dataStore +import com.danilkinkin.buckwheat.ui.BuckwheatTheme +import com.danilkinkin.buckwheat.util.copyLinkToClipboard + +@OptIn( + ExperimentalMaterial3Api::class, +) +@Composable +fun Settings(onClose: () -> Unit = {}) { + val context = LocalContext.current + + val viewModel = remember { + ThemeViewModel(context.dataStore) + } + + val theme = viewModel.state.observeAsState().value + + fun switchTheme(mode: ThemeMode) { + viewModel.changeThemeMode(mode) + } + + LaunchedEffect(viewModel) { + viewModel.request() + } + + Surface { + Column(modifier = Modifier.navigationBarsPadding()) { + CenterAlignedTopAppBar( + title = { + Text( + text = stringResource(id = R.string.settings_title), + style = MaterialTheme.typography.titleLarge, + ) + } + ) + Divider() + TextRow( + icon = painterResource(R.drawable.ic_dark_mode), + text = stringResource(R.string.theme_label), + ) + CheckedRow( + checked = theme == ThemeMode.LIGHT, + onValueChange = { switchTheme(ThemeMode.LIGHT) }, + text = stringResource(R.string.theme_light), + ) + CheckedRow( + checked = theme == ThemeMode.NIGHT, + onValueChange = { switchTheme(ThemeMode.NIGHT) }, + text = stringResource(R.string.theme_dark), + ) + CheckedRow( + checked = theme == ThemeMode.SYSTEM, + onValueChange = { switchTheme(ThemeMode.SYSTEM) }, + text = stringResource(R.string.theme_system), + ) + Divider() + Card( + modifier = Modifier.padding(16.dp), + shape = MaterialTheme.shapes.extraLarge + ) { + Column(Modifier.padding(16.dp)) { + Text( + text = stringResource(R.string.about), + style = MaterialTheme.typography.titleLarge, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.description), + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.developer), + style = MaterialTheme.typography.bodyLarge, + ) + Spacer(modifier = Modifier.height(24.dp)) + ButtonWithIcon( + title = stringResource(R.string.site), + icon = painterResource(R.drawable.ic_open_in_browser), + onClick = { + copyLinkToClipboard( + context, + "https://danilkinkin.com", + ) + }, + ) + Spacer(modifier = Modifier.height(8.dp)) + ButtonWithIcon( + title = stringResource(R.string.report_bug), + icon = painterResource(R.drawable.ic_bug_report), + onClick = { + copyLinkToClipboard( + context, + "https://github.com/danilkinkin/buckweat/issues", + ) + }, + ) + } + } + } + } +} + +@Composable +fun ButtonWithIcon( + title: String, + icon: Painter, + onClick: () -> Unit, +){ + Button( + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(start = 20.dp, top = 12.dp, bottom = 12.dp, end = 12.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + ) + Spacer( + Modifier + .fillMaxWidth() + .weight(1F)) + Icon( + painter = icon, + contentDescription = null, + ) + } +} + +@Preview +@Composable +fun PreviewSettings() { + BuckwheatTheme { + Settings() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/danilkinkin/buckwheat/spendsHistory/BudgetInfoItem.kt b/app/src/main/java/com/danilkinkin/buckwheat/spendsHistory/BudgetInfoItem.kt new file mode 100644 index 0000000..2c6e0df --- /dev/null +++ b/app/src/main/java/com/danilkinkin/buckwheat/spendsHistory/BudgetInfoItem.kt @@ -0,0 +1,109 @@ +package com.danilkinkin.buckwheat.spendsHistory + +import androidx.compose.foundation.layout.* +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.danilkinkin.buckwheat.R +import com.danilkinkin.buckwheat.ui.BuckwheatTheme +import com.danilkinkin.buckwheat.util.* +import java.math.BigDecimal +import java.util.* + +@Composable +fun BudgetInfo( + budget: BigDecimal, + startDate: Date, + finishDate: Date, + currency: ExtendCurrency, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier.fillMaxWidth(), + color = combineColors( + MaterialTheme.colorScheme.primaryContainer, + MaterialTheme.colorScheme.surfaceVariant, + angle = 0.9F, + ) + ) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 24.dp) + .statusBarsPadding(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.new_budget), + style = MaterialTheme.typography.labelLarge, + ) + Spacer(Modifier.height(16.dp)) + Text( + text = prettyCandyCanes(budget, currency = currency), + style = MaterialTheme.typography.displayLarge, + ) + Spacer(Modifier.height(24.dp)) + Row() { + Column(horizontalAlignment = Alignment.Start) { + Text( + text = stringResource(R.string.label_start_date), + style = MaterialTheme.typography.labelSmall, + ) + Text( + text = prettyDate( + startDate, + showTime = false, + forceShowDate = true, + ), + style = MaterialTheme.typography.labelMedium, + ) + } + + Spacer(Modifier.width(16.dp)) + + Icon( + painter = painterResource(R.drawable.ic_arrow_forward), + contentDescription = null, + ) + + Spacer(Modifier.width(16.dp)) + + Column(horizontalAlignment = Alignment.End) { + Text( + text = stringResource(R.string.label_finish_date), + style = MaterialTheme.typography.labelSmall, + ) + Text( + text = prettyDate( + finishDate, + showTime = false, + forceShowDate = true, + ), + style = MaterialTheme.typography.labelMedium, + ) + } + } + } + } +} + +@Preview +@Composable +fun PreviewBudgetInfo() { + BuckwheatTheme() { + BudgetInfo( + budget = BigDecimal(65000), + startDate = Date(), + finishDate = Date(), + currency = ExtendCurrency(type = CurrencyType.NONE) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/danilkinkin/buckwheat/spendsHistory/Spent.kt b/app/src/main/java/com/danilkinkin/buckwheat/spendsHistory/Spent.kt new file mode 100644 index 0000000..6647617 --- /dev/null +++ b/app/src/main/java/com/danilkinkin/buckwheat/spendsHistory/Spent.kt @@ -0,0 +1,131 @@ +package com.danilkinkin.buckwheat.spendsHistory + +import android.util.Log +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import com.danilkinkin.buckwheat.R +import com.danilkinkin.buckwheat.data.entities.Spent +import com.danilkinkin.buckwheat.ui.BuckwheatTheme +import com.danilkinkin.buckwheat.util.* +import java.math.BigDecimal +import java.util.* +import kotlin.math.roundToInt + +enum class DeleteState { IDLE, DELETE } + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun Spent( + spent: Spent, + currency: ExtendCurrency, + modifier: Modifier = Modifier, + onDelete: () -> Unit = {}, +) { + val swipeableState = rememberSwipeableState( + DeleteState.IDLE, + confirmStateChange = { + if (it == DeleteState.DELETE) onDelete() + + true + } + ) + val width = remember { mutableStateOf(0F)} + val height = remember { mutableStateOf(0F)} + + val localDensity = LocalDensity.current + + val anchors = if (width.value != 0F) { + mapOf(0f to DeleteState.IDLE, -width.value to DeleteState.DELETE) + } else { + mapOf(0f to DeleteState.IDLE) + } + + Box( + modifier = modifier + .fillMaxWidth() + .swipeable( + state = swipeableState, + anchors = anchors, + orientation = Orientation.Horizontal, + thresholds = { _, _ -> FractionalThreshold(0.3f) }, + ) + .onGloballyPositioned { + width.value = it.size.width.toFloat() + height.value = it.size.height.toFloat() + }, + ) { + if (swipeableState.direction == -1f) { + Box( + Modifier + .fillMaxWidth() + .height(with(localDensity) { height.value.toDp() }) + .background(MaterialTheme.colorScheme.errorContainer) + .padding(horizontal = 24.dp), + contentAlignment = Alignment.CenterEnd, + ) { + Icon( + painter = painterResource(R.drawable.ic_delete_forever), + tint = MaterialTheme.colorScheme.onErrorContainer, + contentDescription = null, + ) + } + } + + Surface( + modifier = modifier + .fillMaxWidth() + .offset { IntOffset(swipeableState.offset.value.roundToInt(), 0) }, + color = combineColors( + MaterialTheme.colorScheme.primaryContainer, + MaterialTheme.colorScheme.surfaceVariant, + angle = 0.9F, + ) + ) { + Row( + modifier = modifier.fillMaxWidth() + ) { + Text( + text = prettyCandyCanes(spent.value, currency = currency), + style = MaterialTheme.typography.displayMedium, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp), + ) + Spacer(Modifier.weight(1F)) + Box(Modifier) { + Text( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(horizontal = 24.dp, vertical = 16.dp), + text = prettyDate(spent.date), + style = MaterialTheme.typography.labelSmall, + ) + } + } + } + } + +} + +@Preview +@Composable +fun PreviewSpent() { + BuckwheatTheme() { + Spent( + Spent(value = BigDecimal(12340), date = Date()), + ExtendCurrency(type = CurrencyType.NONE) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/danilkinkin/buckwheat/topSheet/TopSheet.kt b/app/src/main/java/com/danilkinkin/buckwheat/topSheet/TopSheet.kt new file mode 100644 index 0000000..cf57c21 --- /dev/null +++ b/app/src/main/java/com/danilkinkin/buckwheat/topSheet/TopSheet.kt @@ -0,0 +1,212 @@ +package com.danilkinkin.buckwheat.topSheet + +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.unit.IntOffset +import kotlinx.coroutines.launch +import kotlin.math.min +import kotlin.math.roundToInt + +@ExperimentalMaterialApi +enum class TopSheetValue { + Expanded, + HalfExpanded +} + +@ExperimentalMaterialApi +class TopSheetState( + initialValue: TopSheetValue, + animationSpec: AnimationSpec = SwipeableDefaults.AnimationSpec, + internal val isSkipHalfExpanded: Boolean, + confirmStateChange: (TopSheetValue) -> Boolean = { true } +) : SwipeableState( + initialValue = initialValue, + animationSpec = animationSpec, + confirmStateChange = confirmStateChange +) { + val isExpand: Boolean + get() = currentValue != TopSheetValue.Expanded + + constructor( + initialValue: TopSheetValue, + animationSpec: AnimationSpec = SwipeableDefaults.AnimationSpec, + confirmStateChange: (TopSheetValue) -> Boolean = { true } + ) : this(initialValue, animationSpec, isSkipHalfExpanded = false, confirmStateChange) + + init { + if (isSkipHalfExpanded) { + require(initialValue != TopSheetValue.HalfExpanded) { + "The initial value must not be set to HalfExpanded if skipHalfExpanded is set to" + + " true." + } + } + } + + internal suspend fun halfExpand() { + animateTo(TopSheetValue.HalfExpanded) + } + + internal suspend fun expand() = animateTo(TopSheetValue.Expanded) + + internal val nestedScrollConnection = this.PreUpPostTopNestedScrollConnection + + companion object { + fun Saver( + animationSpec: AnimationSpec, + skipHalfExpanded: Boolean, + confirmStateChange: (TopSheetValue) -> Boolean + ): Saver = Saver( + save = { it.currentValue }, + restore = { + TopSheetState( + initialValue = it, + animationSpec = animationSpec, + isSkipHalfExpanded = skipHalfExpanded, + confirmStateChange = confirmStateChange + ) + } + ) + } +} + +@Composable +@ExperimentalMaterialApi +fun rememberTopSheetState( + initialValue: TopSheetValue, + animationSpec: AnimationSpec = SwipeableDefaults.AnimationSpec, + skipHalfExpanded: Boolean, + confirmStateChange: (TopSheetValue) -> Boolean = { true } +): TopSheetState { + return rememberSaveable( + initialValue, animationSpec, skipHalfExpanded, confirmStateChange, + saver = TopSheetState.Saver( + animationSpec = animationSpec, + skipHalfExpanded = skipHalfExpanded, + confirmStateChange = confirmStateChange + ) + ) { + TopSheetState( + initialValue = initialValue, + animationSpec = animationSpec, + isSkipHalfExpanded = skipHalfExpanded, + confirmStateChange = confirmStateChange + ) + } +} + +/** + * Create a [TopSheetState] and [remember] it. + * + * @param initialValue The initial value of the state. + * @param animationSpec The default animation that will be used to animate to a new state. + * @param confirmStateChange Optional callback invoked to confirm or veto a pending state change. + */ +@Composable +@ExperimentalMaterialApi +fun rememberTopSheetState( + initialValue: TopSheetValue, + animationSpec: AnimationSpec = SwipeableDefaults.AnimationSpec, + confirmStateChange: (TopSheetValue) -> Boolean = { true } +): TopSheetState = rememberTopSheetState( + initialValue = initialValue, + animationSpec = animationSpec, + skipHalfExpanded = false, + confirmStateChange = confirmStateChange +) + +@Composable +@ExperimentalMaterialApi +fun TopSheetLayout( + modifier: Modifier = Modifier, + sheetState: TopSheetState = rememberTopSheetState(TopSheetValue.HalfExpanded), + halfHeight: Float? = null, + scrollState: LazyListState = rememberLazyListState(), + itemsCount: Int = 1, + sheetContent: LazyListScope.() -> Unit, +) { + val coroutineScope = rememberCoroutineScope() + + BoxWithConstraints(modifier) { + val fullHeight = constraints.maxHeight.toFloat() + val halfHeight = halfHeight ?: (fullHeight / 2) + val sheetHeightState = remember { mutableStateOf(null) } + + Box( + Modifier + .fillMaxWidth() + .nestedScroll(sheetState.nestedScrollConnection) + .offset { + val y = if (sheetState.anchors.isEmpty()) { + // if we don't know our anchors yet, render the sheet as hidden + -fullHeight.roundToInt() + } else { + // if we do know our anchors, respect them + sheetState.offset.value.roundToInt() + } + IntOffset(0, y) + } + .topSheetSwipeable( + sheetState, + fullHeight, + halfHeight, + sheetHeightState, + ) + ) { + Scaffold(backgroundColor = Color.Transparent) { contentPadding -> + coroutineScope.launch { + scrollState.scrollToItem(itemsCount) + } + + LazyColumn( + state = scrollState, + content = sheetContent, + modifier = Modifier.onGloballyPositioned { + sheetHeightState.value = it.size.height.toFloat() + }, + ) + } + } + } +} + +@Suppress("ModifierInspectorInfo") +@OptIn(ExperimentalMaterialApi::class) +private fun Modifier.topSheetSwipeable( + sheetState: TopSheetState, + fullHeight: Float, + halfHeight: Float, + sheetHeightState: State +): Modifier { + val sheetHeight = sheetHeightState.value + + val modifier = if (sheetHeight != null) { + val anchors = mapOf( + -(sheetHeight - halfHeight) to TopSheetValue.HalfExpanded, + min(0f, fullHeight - (fullHeight - sheetHeight)) to TopSheetValue.Expanded + ) + + Modifier.swipeable( + state = sheetState, + anchors = anchors, + orientation = Orientation.Vertical, + resistance = null + ) + } else { + Modifier + } + + return this.then(modifier) +} \ No newline at end of file diff --git a/app/src/main/java/com/danilkinkin/buckwheat/ui/Color.kt b/app/src/main/java/com/danilkinkin/buckwheat/ui/Color.kt new file mode 100644 index 0000000..87c5747 --- /dev/null +++ b/app/src/main/java/com/danilkinkin/buckwheat/ui/Color.kt @@ -0,0 +1,11 @@ +package com.danilkinkin.buckwheat.ui + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/app/src/main/java/com/danilkinkin/buckwheat/ui/Shape.kt b/app/src/main/java/com/danilkinkin/buckwheat/ui/Shape.kt new file mode 100644 index 0000000..d6cd377 --- /dev/null +++ b/app/src/main/java/com/danilkinkin/buckwheat/ui/Shape.kt @@ -0,0 +1,13 @@ +package com.danilkinkin.buckwheat.ui + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Shapes +import androidx.compose.ui.unit.dp + +val shapes = Shapes( + extraSmall = RoundedCornerShape(4.dp), + small = RoundedCornerShape(8.dp), + medium = RoundedCornerShape(12.dp), + large = RoundedCornerShape(16.dp), + extraLarge = RoundedCornerShape(28.dp), +) \ No newline at end of file diff --git a/app/src/main/java/com/danilkinkin/buckwheat/ui/Theme.kt b/app/src/main/java/com/danilkinkin/buckwheat/ui/Theme.kt new file mode 100644 index 0000000..fee632c --- /dev/null +++ b/app/src/main/java/com/danilkinkin/buckwheat/ui/Theme.kt @@ -0,0 +1,67 @@ +package com.danilkinkin.buckwheat.ui + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.remember +import com.danilkinkin.buckwheat.data.ThemeViewModel +import androidx.compose.runtime.* +import androidx.compose.ui.platform.LocalContext +import com.danilkinkin.buckwheat.data.ThemeMode +import com.danilkinkin.buckwheat.home.dataStore + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80, +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40, +) + + + +@Composable +private fun isNightMode(): Boolean { + val context = LocalContext.current + val viewModel = remember { ThemeViewModel(context.dataStore) } + val state = viewModel.state.observeAsState() + + LaunchedEffect(viewModel) { viewModel.request() } + + return when (state.value) { + ThemeMode.LIGHT -> false + ThemeMode.NIGHT -> true + else -> isSystemInDarkTheme() + } +} + + +@Composable +fun BuckwheatTheme( + darkTheme: Boolean = isNightMode(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + shapes = shapes, + typography = typography, + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/danilkinkin/buckwheat/ui/Typography.kt b/app/src/main/java/com/danilkinkin/buckwheat/ui/Typography.kt new file mode 100644 index 0000000..0bc7408 --- /dev/null +++ b/app/src/main/java/com/danilkinkin/buckwheat/ui/Typography.kt @@ -0,0 +1,147 @@ +package com.danilkinkin.buckwheat.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.danilkinkin.buckwheat.R +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +private val light = Font(R.font.manrope_extra_light, FontWeight.W300) +private val regular = Font(R.font.manrope_regular, FontWeight.W400) +private val medium = Font(R.font.manrope_medium, FontWeight.W500) +private val semibold = Font(R.font.manrope_semi_bold, FontWeight.W600) +private val bold = Font(R.font.manrope_bold, FontWeight.W800) +private val extrabold = Font(R.font.manrope_extra_bold, FontWeight.W900) + +private val fontFamily = FontFamily(fonts = listOf(light, regular, medium, semibold, bold, extrabold)) + +val typography = Typography( + displayLarge = TextStyle( + fontFamily = fontFamily, + fontWeight = FontWeight.W900, + fontSize = 57.sp + ), + displayMedium = TextStyle( + fontFamily = fontFamily, + fontWeight = FontWeight.W800, + fontSize = 45.sp + ), + displaySmall = TextStyle( + fontFamily = fontFamily, + fontWeight = FontWeight.W700, + fontSize = 36.sp + ), + headlineLarge = TextStyle( + fontFamily = fontFamily, + fontWeight = FontWeight.W800, + fontSize = 32.sp + ), + headlineMedium = TextStyle( + fontFamily = fontFamily, + fontWeight = FontWeight.W700, + fontSize = 28.sp + ), + headlineSmall = TextStyle( + fontFamily = fontFamily, + fontWeight = FontWeight.W700, + fontSize = 24.sp + ), + titleLarge = TextStyle( + fontFamily = fontFamily, + fontWeight = FontWeight.W800, + fontSize = 22.sp + ), + titleMedium = TextStyle( + fontFamily = fontFamily, + fontWeight = FontWeight.W700, + fontSize = 16.sp + ), + titleSmall = TextStyle( + fontFamily = fontFamily, + fontWeight = FontWeight.W700, + fontSize = 14.sp + ), + bodyLarge = TextStyle( + fontFamily = fontFamily, + fontWeight = FontWeight.W700, + fontSize = 16.sp + ), + bodyMedium = TextStyle( + fontFamily = fontFamily, + fontWeight = FontWeight.W600, + fontSize = 14.sp + ), + bodySmall = TextStyle( + fontFamily = fontFamily, + fontWeight = FontWeight.W500, + fontSize = 12.sp + ), + labelLarge = TextStyle( + fontFamily = fontFamily, + fontWeight = FontWeight.W600, + fontSize = 14.sp + ), + labelMedium = TextStyle( + fontFamily = fontFamily, + fontWeight = FontWeight.W700, + fontSize = 12.sp + ), + labelSmall = TextStyle( + fontFamily = fontFamily, + fontWeight = FontWeight.W400, + fontSize = 11.sp + ) +) + +@Composable +fun FontCard(family: String, size: String, style: TextStyle) { + Card( + shape = CardDefaults.outlinedShape, + colors = CardDefaults.outlinedCardColors(), + modifier = Modifier.padding(8.dp), + ) { + Row(modifier = Modifier.padding(8.dp)) { + Text(text = family, style = style) + Text(text = size) + } + } +} + +@Preview +@Composable +fun PreviewTypography() { + BuckwheatTheme { + Surface() { + Row() { + Column() { + FontCard("Display", "L", MaterialTheme.typography.displayLarge) + FontCard("Display", "M", MaterialTheme.typography.displayMedium) + FontCard("Display", "S", MaterialTheme.typography.displaySmall) + FontCard("Headline", "L", MaterialTheme.typography.headlineLarge) + FontCard("Headline", "M", MaterialTheme.typography.headlineMedium) + FontCard("Headline", "S", MaterialTheme.typography.headlineSmall) + FontCard("Title", "L", MaterialTheme.typography.titleLarge) + FontCard("Title", "M", MaterialTheme.typography.titleMedium) + FontCard("Title", "S", MaterialTheme.typography.titleSmall) + } + Column() { + FontCard("Body", "L", MaterialTheme.typography.bodyLarge) + FontCard("Body", "M", MaterialTheme.typography.bodyMedium) + FontCard("Body", "S", MaterialTheme.typography.bodySmall) + FontCard("Label", "L", MaterialTheme.typography.labelLarge) + FontCard("Label", "M", MaterialTheme.typography.labelMedium) + FontCard("Label", "S", MaterialTheme.typography.labelSmall) + } + } + } + } +} diff --git a/app/src/main/java/com/danilkinkin/buckwheat/util/Swipeable.kt b/app/src/main/java/com/danilkinkin/buckwheat/util/Swipeable.kt new file mode 100644 index 0000000..d4aa46e --- /dev/null +++ b/app/src/main/java/com/danilkinkin/buckwheat/util/Swipeable.kt @@ -0,0 +1,919 @@ +package com.danilkinkin.buckwheat.topSheet + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.SpringSpec +import androidx.compose.foundation.gestures.DraggableState +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.SwipeableDefaults.AnimationSpec +import androidx.compose.material.SwipeableDefaults.StandardResistanceFactor +import androidx.compose.material.SwipeableDefaults.VelocityThreshold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.debugInspectorInfo +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.Velocity +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp +import com.danilkinkin.buckwheat.topSheet.SwipeableDefaults.resistanceConfig +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.launch +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.sign +import kotlin.math.sin + +/** + * State of the [swipeable] modifier. + * + * This contains necessary information about any ongoing swipe or animation and provides methods + * to change the state either immediately or by starting an animation. To create and remember a + * [SwipeableState] with the default animation clock, use [rememberSwipeableState]. + * + * @param initialValue The initial value of the state. + * @param animationSpec The default animation that will be used to animate to a new state. + * @param confirmStateChange Optional callback invoked to confirm or veto a pending state change. + */ +@Stable +@ExperimentalMaterialApi +open class SwipeableState( + initialValue: T, + internal val animationSpec: AnimationSpec = AnimationSpec, + internal val confirmStateChange: (newValue: T) -> Boolean = { true } +) { + /** + * The current value of the state. + * + * If no swipe or animation is in progress, this corresponds to the anchor at which the + * [swipeable] is currently settled. If a swipe or animation is in progress, this corresponds + * the last anchor at which the [swipeable] was settled before the swipe or animation started. + */ + var currentValue: T by mutableStateOf(initialValue) + private set + + /** + * Whether the state is currently animating. + */ + var isAnimationRunning: Boolean by mutableStateOf(false) + private set + + /** + * The current position (in pixels) of the [swipeable]. + * + * You should use this state to offset your content accordingly. The recommended way is to + * use `Modifier.offsetPx`. This includes the resistance by default, if resistance is enabled. + */ + val offset: State get() = offsetState + + /** + * The amount by which the [swipeable] has been swiped past its bounds. + */ + val overflow: State get() = overflowState + + // Use `Float.NaN` as a placeholder while the state is uninitialised. + private val offsetState = mutableStateOf(0f) + private val overflowState = mutableStateOf(0f) + + // the source of truth for the "real"(non ui) position + // basically position in bounds + overflow + private val absoluteOffset = mutableStateOf(0f) + + // current animation target, if animating, otherwise null + private val animationTarget = mutableStateOf(null) + + internal var anchors by mutableStateOf(emptyMap()) + + private val latestNonEmptyAnchorsFlow: Flow> = + snapshotFlow { anchors } + .filter { it.isNotEmpty() } + .take(1) + + internal var minBound = Float.NEGATIVE_INFINITY + internal var maxBound = Float.POSITIVE_INFINITY + + internal fun ensureInit(newAnchors: Map) { + if (anchors.isEmpty()) { + // need to do initial synchronization synchronously :( + val initialOffset = newAnchors.getOffset(currentValue) + requireNotNull(initialOffset) { + "The initial value must have an associated anchor." + } + offsetState.value = initialOffset + absoluteOffset.value = initialOffset + } + } + + internal suspend fun processNewAnchors( + oldAnchors: Map, + newAnchors: Map + ) { + if (oldAnchors.isEmpty()) { + // If this is the first time that we receive anchors, then we need to initialise + // the state so we snap to the offset associated to the initial value. + minBound = newAnchors.keys.minOrNull()!! + maxBound = newAnchors.keys.maxOrNull()!! + val initialOffset = newAnchors.getOffset(currentValue) + requireNotNull(initialOffset) { + "The initial value must have an associated anchor." + } + snapInternalToOffset(initialOffset) + } else if (newAnchors != oldAnchors) { + // If we have received new anchors, then the offset of the current value might + // have changed, so we need to animate to the new offset. If the current value + // has been removed from the anchors then we animate to the closest anchor + // instead. Note that this stops any ongoing animation. + minBound = Float.NEGATIVE_INFINITY + maxBound = Float.POSITIVE_INFINITY + val animationTargetValue = animationTarget.value + // if we're in the animation already, let's find it a new home + val targetOffset = if (animationTargetValue != null) { + // first, try to map old state to the new state + val oldState = oldAnchors[animationTargetValue] + val newState = newAnchors.getOffset(oldState) + // return new state if exists, or find the closes one among new anchors + newState ?: newAnchors.keys.minByOrNull { abs(it - animationTargetValue) }!! + } else { + // we're not animating, proceed by finding the new anchors for an old value + val actualOldValue = oldAnchors[offset.value] + val value = if (actualOldValue == currentValue) currentValue else actualOldValue + newAnchors.getOffset(value) ?: newAnchors + .keys.minByOrNull { abs(it - offset.value) }!! + } + try { + animateInternalToOffset(targetOffset, animationSpec) + } catch (c: CancellationException) { + // If the animation was interrupted for any reason, snap as a last resort. + snapInternalToOffset(targetOffset) + } finally { + currentValue = newAnchors.getValue(targetOffset) + minBound = newAnchors.keys.minOrNull()!! + maxBound = newAnchors.keys.maxOrNull()!! + } + } + } + + internal var thresholds: (Float, Float) -> Float by mutableStateOf({ _, _ -> 0f }) + + internal var velocityThreshold by mutableStateOf(0f) + + internal var resistance: ResistanceConfig? by mutableStateOf(null) + + internal val draggableState = DraggableState { + val newAbsolute = absoluteOffset.value + it + val clamped = newAbsolute.coerceIn(minBound, maxBound) + val overflow = newAbsolute - clamped + val resistanceDelta = resistance?.computeResistance(overflow) ?: 0f + offsetState.value = clamped + resistanceDelta + overflowState.value = overflow + absoluteOffset.value = newAbsolute + } + + private suspend fun snapInternalToOffset(target: Float) { + draggableState.drag { + dragBy(target - absoluteOffset.value) + } + } + + private suspend fun animateInternalToOffset(target: Float, spec: AnimationSpec) { + draggableState.drag { + var prevValue = absoluteOffset.value + animationTarget.value = target + isAnimationRunning = true + try { + Animatable(prevValue).animateTo(target, spec) { + dragBy(this.value - prevValue) + prevValue = this.value + } + } finally { + animationTarget.value = null + isAnimationRunning = false + } + } + } + + /** + * The target value of the state. + * + * If a swipe is in progress, this is the value that the [swipeable] would animate to if the + * swipe finished. If an animation is running, this is the target value of that animation. + * Finally, if no swipe or animation is in progress, this is the same as the [currentValue]. + */ + @ExperimentalMaterialApi + val targetValue: T + get() { + // TODO(calintat): Track current velocity (b/149549482) and use that here. + val target = animationTarget.value ?: computeTarget( + offset = offset.value, + lastValue = anchors.getOffset(currentValue) ?: offset.value, + anchors = anchors.keys, + thresholds = thresholds, + velocity = 0f, + velocityThreshold = Float.POSITIVE_INFINITY + ) + return anchors[target] ?: currentValue + } + + /** + * Information about the ongoing swipe or animation, if any. See [SwipeProgress] for details. + * + * If no swipe or animation is in progress, this returns `SwipeProgress(value, value, 1f)`. + */ + @ExperimentalMaterialApi + val progress: SwipeProgress + get() { + val bounds = findBounds(offset.value, anchors.keys) + val from: T + val to: T + val fraction: Float + when (bounds.size) { + 0 -> { + from = currentValue + to = currentValue + fraction = 1f + } + 1 -> { + from = anchors.getValue(bounds[0]) + to = anchors.getValue(bounds[0]) + fraction = 1f + } + else -> { + val (a, b) = + if (direction > 0f) { + bounds[0] to bounds[1] + } else { + bounds[1] to bounds[0] + } + from = anchors.getValue(a) + to = anchors.getValue(b) + fraction = (offset.value - a) / (b - a) + } + } + return SwipeProgress(from, to, fraction) + } + + /** + * The direction in which the [swipeable] is moving, relative to the current [currentValue]. + * + * This will be either 1f if it is is moving from left to right or top to bottom, -1f if it is + * moving from right to left or bottom to top, or 0f if no swipe or animation is in progress. + */ + @ExperimentalMaterialApi + val direction: Float + get() = anchors.getOffset(currentValue)?.let { sign(offset.value - it) } ?: 0f + + /** + * Set the state without any animation and suspend until it's set + * + * @param targetValue The new target value to set [currentValue] to. + */ + @ExperimentalMaterialApi + suspend fun snapTo(targetValue: T) { + latestNonEmptyAnchorsFlow.collect { anchors -> + val targetOffset = anchors.getOffset(targetValue) + requireNotNull(targetOffset) { + "The target value must have an associated anchor." + } + snapInternalToOffset(targetOffset) + currentValue = targetValue + } + } + + /** + * Set the state to the target value by starting an animation. + * + * @param targetValue The new value to animate to. + * @param anim The animation that will be used to animate to the new value. + */ + @ExperimentalMaterialApi + suspend fun animateTo(targetValue: T, anim: AnimationSpec = animationSpec) { + latestNonEmptyAnchorsFlow.collect { anchors -> + try { + val targetOffset = anchors.getOffset(targetValue) + requireNotNull(targetOffset) { + "The target value must have an associated anchor." + } + animateInternalToOffset(targetOffset, anim) + } finally { + val endOffset = absoluteOffset.value + val endValue = anchors + // fighting rounding error once again, anchor should be as close as 0.5 pixels + .filterKeys { anchorOffset -> abs(anchorOffset - endOffset) < 0.5f } + .values.firstOrNull() ?: currentValue + currentValue = endValue + } + } + } + + /** + * Perform fling with settling to one of the anchors which is determined by the given + * [velocity]. Fling with settling [swipeable] will always consume all the velocity provided + * since it will settle at the anchor. + * + * In general cases, [swipeable] flings by itself when being swiped. This method is to be + * used for nested scroll logic that wraps the [swipeable]. In nested scroll developer may + * want to trigger settling fling when the child scroll container reaches the bound. + * + * @param velocity velocity to fling and settle with + * + * @return the reason fling ended + */ + suspend fun performFling(velocity: Float) { + latestNonEmptyAnchorsFlow.collect { anchors -> + val lastAnchor = anchors.getOffset(currentValue)!! + val targetValue = computeTarget( + offset = offset.value, + lastValue = lastAnchor, + anchors = anchors.keys, + thresholds = thresholds, + velocity = velocity, + velocityThreshold = velocityThreshold + ) + val targetState = anchors[targetValue] + if (targetState != null && confirmStateChange(targetState)) animateTo(targetState) + // If the user vetoed the state change, rollback to the previous state. + else animateInternalToOffset(lastAnchor, animationSpec) + } + } + + /** + * Force [swipeable] to consume drag delta provided from outside of the regular [swipeable] + * gesture flow. + * + * Note: This method performs generic drag and it won't settle to any particular anchor, * + * leaving swipeable in between anchors. When done dragging, [performFling] must be + * called as well to ensure swipeable will settle at the anchor. + * + * In general cases, [swipeable] drags by itself when being swiped. This method is to be + * used for nested scroll logic that wraps the [swipeable]. In nested scroll developer may + * want to force drag when the child scroll container reaches the bound. + * + * @param delta delta in pixels to drag by + * + * @return the amount of [delta] consumed + */ + fun performDrag(delta: Float): Float { + val potentiallyConsumed = absoluteOffset.value + delta + val clamped = potentiallyConsumed.coerceIn(minBound, maxBound) + val deltaToConsume = clamped - absoluteOffset.value + if (abs(deltaToConsume) > 0) { + draggableState.dispatchRawDelta(deltaToConsume) + } + return deltaToConsume + } + + companion object { + /** + * The default [Saver] implementation for [SwipeableState]. + */ + fun Saver( + animationSpec: AnimationSpec, + confirmStateChange: (T) -> Boolean + ) = Saver, T>( + save = { it.currentValue }, + restore = { SwipeableState(it, animationSpec, confirmStateChange) } + ) + } +} + +/** + * Collects information about the ongoing swipe or animation in [swipeable]. + * + * To access this information, use [SwipeableState.progress]. + * + * @param from The state corresponding to the anchor we are moving away from. + * @param to The state corresponding to the anchor we are moving towards. + * @param fraction The fraction that the current position represents between [from] and [to]. + * Must be between `0` and `1`. + */ +@Immutable +@ExperimentalMaterialApi +class SwipeProgress( + val from: T, + val to: T, + /*@FloatRange(from = 0.0, to = 1.0)*/ + val fraction: Float +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is SwipeProgress<*>) return false + + if (from != other.from) return false + if (to != other.to) return false + if (fraction != other.fraction) return false + + return true + } + + override fun hashCode(): Int { + var result = from?.hashCode() ?: 0 + result = 31 * result + (to?.hashCode() ?: 0) + result = 31 * result + fraction.hashCode() + return result + } + + override fun toString(): String { + return "SwipeProgress(from=$from, to=$to, fraction=$fraction)" + } +} + +/** + * Create and [remember] a [SwipeableState] with the default animation clock. + * + * @param initialValue The initial value of the state. + * @param animationSpec The default animation that will be used to animate to a new state. + * @param confirmStateChange Optional callback invoked to confirm or veto a pending state change. + */ +@Composable +@ExperimentalMaterialApi +fun rememberSwipeableState( + initialValue: T, + animationSpec: AnimationSpec = AnimationSpec, + confirmStateChange: (newValue: T) -> Boolean = { true } +): SwipeableState { + return rememberSaveable( + saver = SwipeableState.Saver( + animationSpec = animationSpec, + confirmStateChange = confirmStateChange + ) + ) { + SwipeableState( + initialValue = initialValue, + animationSpec = animationSpec, + confirmStateChange = confirmStateChange + ) + } +} + +/** + * Create and [remember] a [SwipeableState] which is kept in sync with another state, i.e.: + * 1. Whenever the [value] changes, the [SwipeableState] will be animated to that new value. + * 2. Whenever the value of the [SwipeableState] changes (e.g. after a swipe), the owner of the + * [value] will be notified to update their state to the new value of the [SwipeableState] by + * invoking [onValueChange]. If the owner does not update their state to the provided value for + * some reason, then the [SwipeableState] will perform a rollback to the previous, correct value. + */ +@Composable +@ExperimentalMaterialApi +internal fun rememberSwipeableStateFor( + value: T, + onValueChange: (T) -> Unit, + animationSpec: AnimationSpec = AnimationSpec +): SwipeableState { + val swipeableState = remember { + SwipeableState( + initialValue = value, + animationSpec = animationSpec, + confirmStateChange = { true } + ) + } + val forceAnimationCheck = remember { mutableStateOf(false) } + LaunchedEffect(value, forceAnimationCheck.value) { + if (value != swipeableState.currentValue) { + swipeableState.animateTo(value) + } + } + DisposableEffect(swipeableState.currentValue) { + if (value != swipeableState.currentValue) { + onValueChange(swipeableState.currentValue) + forceAnimationCheck.value = !forceAnimationCheck.value + } + onDispose { } + } + return swipeableState +} + +/** + * Enable swipe gestures between a set of predefined states. + * + * To use this, you must provide a map of anchors (in pixels) to states (of type [T]). + * Note that this map cannot be empty and cannot have two anchors mapped to the same state. + * + * When a swipe is detected, the offset of the [SwipeableState] will be updated with the swipe + * delta. You should use this offset to move your content accordingly (see `Modifier.offsetPx`). + * When the swipe ends, the offset will be animated to one of the anchors and when that anchor is + * reached, the value of the [SwipeableState] will also be updated to the state corresponding to + * the new anchor. The target anchor is calculated based on the provided positional [thresholds]. + * + * Swiping is constrained between the minimum and maximum anchors. If the user attempts to swipe + * past these bounds, a resistance effect will be applied by default. The amount of resistance at + * each edge is specified by the [resistance] config. To disable all resistance, set it to `null`. + * + * For an example of a [swipeable] with three states, see: + * + * @sample androidx.compose.material.samples.SwipeableSample + * + * @param T The type of the state. + * @param state The state of the [swipeable]. + * @param anchors Pairs of anchors and states, used to map anchors to states and vice versa. + * @param thresholds Specifies where the thresholds between the states are. The thresholds will be + * used to determine which state to animate to when swiping stops. This is represented as a lambda + * that takes two states and returns the threshold between them in the form of a [ThresholdConfig]. + * Note that the order of the states corresponds to the swipe direction. + * @param orientation The orientation in which the [swipeable] can be swiped. + * @param enabled Whether this [swipeable] is enabled and should react to the user's input. + * @param reverseDirection Whether to reverse the direction of the swipe, so a top to bottom + * swipe will behave like bottom to top, and a left to right swipe will behave like right to left. + * @param interactionSource Optional [MutableInteractionSource] that will passed on to + * the internal [Modifier.draggable]. + * @param resistance Controls how much resistance will be applied when swiping past the bounds. + * @param velocityThreshold The threshold (in dp per second) that the end velocity has to exceed + * in order to animate to the next state, even if the positional [thresholds] have not been reached. + */ +@ExperimentalMaterialApi +fun Modifier.swipeable( + state: SwipeableState, + anchors: Map, + orientation: Orientation, + enabled: Boolean = true, + reverseDirection: Boolean = false, + interactionSource: MutableInteractionSource? = null, + thresholds: (from: T, to: T) -> ThresholdConfig = { _, _ -> FixedThreshold(56.dp) }, + resistance: ResistanceConfig? = resistanceConfig(anchors.keys), + velocityThreshold: Dp = VelocityThreshold +) = composed( + inspectorInfo = debugInspectorInfo { + name = "swipeable" + properties["state"] = state + properties["anchors"] = anchors + properties["orientation"] = orientation + properties["enabled"] = enabled + properties["reverseDirection"] = reverseDirection + properties["interactionSource"] = interactionSource + properties["thresholds"] = thresholds + properties["resistance"] = resistance + properties["velocityThreshold"] = velocityThreshold + } +) { + require(anchors.isNotEmpty()) { + "You must have at least one anchor." + } + require(anchors.values.distinct().count() == anchors.size) { + "You cannot have two anchors mapped to the same state." + } + val density = LocalDensity.current + state.ensureInit(anchors) + LaunchedEffect(anchors, state) { + val oldAnchors = state.anchors + state.anchors = anchors + state.resistance = resistance + state.thresholds = { a, b -> + val from = anchors.getValue(a) + val to = anchors.getValue(b) + with(thresholds(from, to)) { density.computeThreshold(a, b) } + } + with(density) { + state.velocityThreshold = velocityThreshold.toPx() + } + state.processNewAnchors(oldAnchors, anchors) + } + + Modifier.draggable( + orientation = orientation, + enabled = enabled, + reverseDirection = reverseDirection, + interactionSource = interactionSource, + startDragImmediately = state.isAnimationRunning, + onDragStopped = { velocity -> launch { state.performFling(velocity) } }, + state = state.draggableState + ) +} + +/** + * Interface to compute a threshold between two anchors/states in a [swipeable]. + * + * To define a [ThresholdConfig], consider using [FixedThreshold] and [FractionalThreshold]. + */ +@Stable +@ExperimentalMaterialApi +interface ThresholdConfig { + /** + * Compute the value of the threshold (in pixels), once the values of the anchors are known. + */ + fun Density.computeThreshold(fromValue: Float, toValue: Float): Float +} + +/** + * A fixed threshold will be at an [offset] away from the first anchor. + * + * @param offset The offset (in dp) that the threshold will be at. + */ +@Immutable +@ExperimentalMaterialApi +data class FixedThreshold(private val offset: Dp) : ThresholdConfig { + override fun Density.computeThreshold(fromValue: Float, toValue: Float): Float { + return fromValue + offset.toPx() * sign(toValue - fromValue) + } +} + +/** + * A fractional threshold will be at a [fraction] of the way between the two anchors. + * + * @param fraction The fraction (between 0 and 1) that the threshold will be at. + */ +@Immutable +@ExperimentalMaterialApi +data class FractionalThreshold( + /*@FloatRange(from = 0.0, to = 1.0)*/ + private val fraction: Float +) : ThresholdConfig { + override fun Density.computeThreshold(fromValue: Float, toValue: Float): Float { + return lerp(fromValue, toValue, fraction) + } +} + +/** + * Specifies how resistance is calculated in [swipeable]. + * + * There are two things needed to calculate resistance: the resistance basis determines how much + * overflow will be consumed to achieve maximum resistance, and the resistance factor determines + * the amount of resistance (the larger the resistance factor, the stronger the resistance). + * + * The resistance basis is usually either the size of the component which [swipeable] is applied + * to, or the distance between the minimum and maximum anchors. For a constructor in which the + * resistance basis defaults to the latter, consider using [resistanceConfig]. + * + * You may specify different resistance factors for each bound. Consider using one of the default + * resistance factors in [SwipeableDefaults]: `StandardResistanceFactor` to convey that the user + * has run out of things to see, and `StiffResistanceFactor` to convey that the user cannot swipe + * this right now. Also, you can set either factor to 0 to disable resistance at that bound. + * + * @param basis Specifies the maximum amount of overflow that will be consumed. Must be positive. + * @param factorAtMin The factor by which to scale the resistance at the minimum bound. + * Must not be negative. + * @param factorAtMax The factor by which to scale the resistance at the maximum bound. + * Must not be negative. + */ +@Immutable +class ResistanceConfig( + /*@FloatRange(from = 0.0, fromInclusive = false)*/ + val basis: Float, + /*@FloatRange(from = 0.0)*/ + val factorAtMin: Float = StandardResistanceFactor, + /*@FloatRange(from = 0.0)*/ + val factorAtMax: Float = StandardResistanceFactor +) { + fun computeResistance(overflow: Float): Float { + val factor = if (overflow < 0) factorAtMin else factorAtMax + if (factor == 0f) return 0f + val progress = (overflow / basis).coerceIn(-1f, 1f) + return basis / factor * sin(progress * PI.toFloat() / 2) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ResistanceConfig) return false + + if (basis != other.basis) return false + if (factorAtMin != other.factorAtMin) return false + if (factorAtMax != other.factorAtMax) return false + + return true + } + + override fun hashCode(): Int { + var result = basis.hashCode() + result = 31 * result + factorAtMin.hashCode() + result = 31 * result + factorAtMax.hashCode() + return result + } + + override fun toString(): String { + return "ResistanceConfig(basis=$basis, factorAtMin=$factorAtMin, factorAtMax=$factorAtMax)" + } +} + +/** + * Given an offset x and a set of anchors, return a list of anchors: + * 1. [ ] if the set of anchors is empty, + * 2. [ x' ] if x is equal to one of the anchors, accounting for a small rounding error, where x' + * is x rounded to the exact value of the matching anchor, + * 3. [ min ] if min is the minimum anchor and x < min, + * 4. [ max ] if max is the maximum anchor and x > max, or + * 5. [ a , b ] if a and b are anchors such that a < x < b and b - a is minimal. + */ +private fun findBounds( + offset: Float, + anchors: Set +): List { + // Find the anchors the target lies between with a little bit of rounding error. + val a = anchors.filter { it <= offset + 0.001 }.maxOrNull() + val b = anchors.filter { it >= offset - 0.001 }.minOrNull() + + return when { + a == null -> + // case 1 or 3 + listOfNotNull(b) + b == null -> + // case 4 + listOf(a) + a == b -> + // case 2 + // Can't return offset itself here since it might not be exactly equal + // to the anchor, despite being considered an exact match. + listOf(a) + else -> + // case 5 + listOf(a, b) + } +} + +private fun computeTarget( + offset: Float, + lastValue: Float, + anchors: Set, + thresholds: (Float, Float) -> Float, + velocity: Float, + velocityThreshold: Float +): Float { + val bounds = findBounds(offset, anchors) + return when (bounds.size) { + 0 -> lastValue + 1 -> bounds[0] + else -> { + val lower = bounds[0] + val upper = bounds[1] + if (lastValue <= offset) { + // Swiping from lower to upper (positive). + if (velocity >= velocityThreshold) { + return upper + } else { + val threshold = thresholds(lower, upper) + if (offset < threshold) lower else upper + } + } else { + // Swiping from upper to lower (negative). + if (velocity <= -velocityThreshold) { + return lower + } else { + val threshold = thresholds(upper, lower) + if (offset > threshold) upper else lower + } + } + } + } +} + +private fun Map.getOffset(state: T): Float? { + return entries.firstOrNull { it.value == state }?.key +} + +/** + * Contains useful defaults for [swipeable] and [SwipeableState]. + */ +object SwipeableDefaults { + /** + * The default animation used by [SwipeableState]. + */ + val AnimationSpec = SpringSpec() + + /** + * The default velocity threshold (1.8 dp per millisecond) used by [swipeable]. + */ + val VelocityThreshold = 125.dp + + /** + * A stiff resistance factor which indicates that swiping isn't available right now. + */ + const val StiffResistanceFactor = 20f + + /** + * A standard resistance factor which indicates that the user has run out of things to see. + */ + const val StandardResistanceFactor = 10f + + /** + * The default resistance config used by [swipeable]. + * + * This returns `null` if there is one anchor. If there are at least two anchors, it returns + * a [ResistanceConfig] with the resistance basis equal to the distance between the two bounds. + */ + fun resistanceConfig( + anchors: Set, + factorAtMin: Float = StandardResistanceFactor, + factorAtMax: Float = StandardResistanceFactor + ): ResistanceConfig? { + return if (anchors.size <= 1) { + null + } else { + val basis = anchors.maxOrNull()!! - anchors.minOrNull()!! + ResistanceConfig(basis, factorAtMin, factorAtMax) + } + } +} + +@ExperimentalMaterialApi +internal val SwipeableState.PreUpPostTopNestedScrollConnection: NestedScrollConnection + get() = object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + val delta = available.toFloat() + return if (delta > 0 && source == NestedScrollSource.Drag) { + performDrag(delta).toOffset() + } else { + Offset.Zero + } + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource + ): Offset { + return if (source == NestedScrollSource.Drag) { + performDrag(available.toFloat()).toOffset() + } else { + Offset.Zero + } + } + + override suspend fun onPreFling(available: Velocity): Velocity { + val toFling = Offset(available.x, available.y).toFloat() + return if (toFling > 0 && offset.value > minBound) { + performFling(velocity = toFling) + // since we go to the anchor with tween settling, consume all for the best UX + available + } else { + Velocity.Zero + } + } + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + performFling(velocity = Offset(available.x, available.y).toFloat()) + return available + } + + private fun Float.toOffset(): Offset = Offset(0f, this) + + private fun Offset.toFloat(): Float = this.y + } + +// temp default nested scroll connection for swipeables which desire as an opt in +// revisit in b/174756744 as all types will have their own specific connection probably +@ExperimentalMaterialApi +internal val SwipeableState.PreUpPostDownNestedScrollConnection: NestedScrollConnection + get() = object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + val delta = available.toFloat() + return if (delta < 0 && source == NestedScrollSource.Drag) { + performDrag(delta).toOffset() + } else { + Offset.Zero + } + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource + ): Offset { + return if (source == NestedScrollSource.Drag) { + performDrag(available.toFloat()).toOffset() + } else { + Offset.Zero + } + } + + override suspend fun onPreFling(available: Velocity): Velocity { + val toFling = Offset(available.x, available.y).toFloat() + return if (toFling < 0 && offset.value > minBound) { + performFling(velocity = toFling) + // since we go to the anchor with tween settling, consume all for the best UX + available + } else { + Velocity.Zero + } + } + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + performFling(velocity = Offset(available.x, available.y).toFloat()) + return available + } + + private fun Float.toOffset(): Offset = Offset(0f, this) + + private fun Offset.toFloat(): Float = this.y + } diff --git a/app/src/main/java/com/danilkinkin/buckwheat/util/YearMonthExt.kt b/app/src/main/java/com/danilkinkin/buckwheat/util/YearMonthExt.kt new file mode 100644 index 0000000..78b71b4 --- /dev/null +++ b/app/src/main/java/com/danilkinkin/buckwheat/util/YearMonthExt.kt @@ -0,0 +1,11 @@ +package com.danilkinkin.buckwheat.util + +import com.danilkinkin.buckwheat.calendar.CALENDAR_STARTS_ON +import java.time.YearMonth +import java.time.temporal.WeekFields + +fun YearMonth.getNumberWeeks(weekFields: WeekFields = CALENDAR_STARTS_ON): Int { + val firstWeekNumber = this.atDay(1)[weekFields.weekOfMonth()] + val lastWeekNumber = this.atEndOfMonth()[weekFields.weekOfMonth()] + return lastWeekNumber - firstWeekNumber + 1 // Both weeks inclusive +} diff --git a/app/src/main/java/com/danilkinkin/buckwheat/utils/candyСanes.kt b/app/src/main/java/com/danilkinkin/buckwheat/util/candyСanes.kt similarity index 95% rename from app/src/main/java/com/danilkinkin/buckwheat/utils/candyСanes.kt rename to app/src/main/java/com/danilkinkin/buckwheat/util/candyСanes.kt index 90e0ba8..4da4c6b 100644 --- a/app/src/main/java/com/danilkinkin/buckwheat/utils/candyСanes.kt +++ b/app/src/main/java/com/danilkinkin/buckwheat/util/candyСanes.kt @@ -1,10 +1,8 @@ -package com.danilkinkin.buckwheat.utils +package com.danilkinkin.buckwheat.util import android.text.Editable import android.text.TextWatcher import android.util.Log -import com.danilkinkin.buckwheat.MainActivity -import com.google.android.material.textfield.TextInputEditText import java.lang.Integer.min import java.lang.ref.WeakReference import java.math.BigDecimal @@ -49,7 +47,7 @@ fun Double.round(scale: Int): Double = fun prettyCandyCanes( value: BigDecimal, forceShowAfterDot: Boolean = false, - currency: ExtendCurrency = MainActivity.getInstance().model.currency, + currency: ExtendCurrency, ): String { val formatter = if (currency.type === CurrencyType.FROM_LIST) currencyFormat else numberFormat @@ -65,7 +63,7 @@ fun prettyCandyCanes( return formattedValue } - +/* class CurrencyTextWatcher( editText: TextInputEditText, private val forceShowAfterDot: Boolean = false, @@ -146,4 +144,4 @@ class CurrencyTextWatcher( init { editTextWeakReference = WeakReference(editText) } -} \ No newline at end of file +} */ diff --git a/app/src/main/java/com/danilkinkin/buckwheat/util/colors.kt b/app/src/main/java/com/danilkinkin/buckwheat/util/colors.kt new file mode 100644 index 0000000..166ebf4 --- /dev/null +++ b/app/src/main/java/com/danilkinkin/buckwheat/util/colors.kt @@ -0,0 +1,14 @@ +package com.danilkinkin.buckwheat.util + +import androidx.compose.ui.graphics.Color + +fun combineColors(colorA: Color, colorB: Color, angle: Float = 0.5F): Color { + val colorAPart = (1F - angle) * 2 + val colorBPart = angle * 2 + + return Color( + red = (colorA.red * colorAPart + colorB.red * colorBPart) / 2, + green = (colorA.green * colorAPart + colorB.green * colorBPart) / 2, + blue = (colorA.blue * colorAPart + colorB.blue * colorBPart) / 2, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/danilkinkin/buckwheat/util/copyToClipboard.kt b/app/src/main/java/com/danilkinkin/buckwheat/util/copyToClipboard.kt new file mode 100644 index 0000000..c705e7f --- /dev/null +++ b/app/src/main/java/com/danilkinkin/buckwheat/util/copyToClipboard.kt @@ -0,0 +1,35 @@ +package com.danilkinkin.buckwheat.util + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.widget.Toast +import androidx.core.content.ContextCompat +import com.danilkinkin.buckwheat.R + +fun copyLinkToClipboard(context: Context, link: String) { + val intent = Intent(Intent.ACTION_VIEW) + + intent.data = Uri.parse(link) + + try { + ContextCompat.startActivity(context, intent, null) + } catch (e: Exception) { + val clipboard = ContextCompat.getSystemService( + context, + ClipboardManager::class.java + ) as ClipboardManager + + clipboard.setPrimaryClip(ClipData.newPlainText("url", link)) + + Toast + .makeText( + context, + context.getString(R.string.copy_in_clipboard), + Toast.LENGTH_LONG + ) + .show() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/danilkinkin/buckwheat/utils/time.kt b/app/src/main/java/com/danilkinkin/buckwheat/util/time.kt similarity index 66% rename from app/src/main/java/com/danilkinkin/buckwheat/utils/time.kt rename to app/src/main/java/com/danilkinkin/buckwheat/util/time.kt index 48e2e7a..01c8941 100644 --- a/app/src/main/java/com/danilkinkin/buckwheat/utils/time.kt +++ b/app/src/main/java/com/danilkinkin/buckwheat/util/time.kt @@ -1,15 +1,30 @@ -package com.danilkinkin.buckwheat.utils +package com.danilkinkin.buckwheat.util import java.text.SimpleDateFormat +import java.time.LocalDate +import java.time.YearMonth +import java.time.ZoneId +import java.time.format.DateTimeFormatter import java.util.* import kotlin.math.ceil const val DAY = 24 * 60 * 60 * 1000 +val monthFormat = SimpleDateFormat("MMMM") +val monthShortFormat = SimpleDateFormat("MM") val yearFormat = SimpleDateFormat("yyyy") val dateFormat = SimpleDateFormat("dd MMMM") val timeFormat = SimpleDateFormat("HH:mm") +var yearMonthFormatterCurrYaer = DateTimeFormatter.ofPattern("MMMM") +var yearMonthFormatter = DateTimeFormatter.ofPattern("MMM yyyy") + +fun LocalDate.toDate(): Date = Date(this.atStartOfDay(ZoneId.systemDefault()).toEpochSecond() * 1000) + +fun Date.toLocalDate(): LocalDate = this.toInstant() + .atZone(ZoneId.systemDefault()) + .toLocalDate(); + fun countDays(toDate: Date, fromDate: Date = Date()): Int { val fromDateRound = roundToDay(fromDate) val toDateRound = roundToDay(toDate) @@ -49,6 +64,14 @@ fun prettyDate(date: Date, showTime: Boolean = true, forceShowDate: Boolean = fa return final } +fun prettyYearMonth(yearMonth: YearMonth): String { + return if (yearMonth.year.toString() == yearFormat.format(Date().time)) { + yearMonth.format(yearMonthFormatterCurrYaer) + } else { + yearMonth.format(yearMonthFormatter) + } +} + fun roundToDay(date: Date): Date { val calendar = Calendar.getInstance() calendar.time = date diff --git a/app/src/main/java/com/danilkinkin/buckwheat/utils/toSP.kt b/app/src/main/java/com/danilkinkin/buckwheat/util/toSP.kt similarity index 81% rename from app/src/main/java/com/danilkinkin/buckwheat/utils/toSP.kt rename to app/src/main/java/com/danilkinkin/buckwheat/util/toSP.kt index adb4560..5c31c6e 100644 --- a/app/src/main/java/com/danilkinkin/buckwheat/utils/toSP.kt +++ b/app/src/main/java/com/danilkinkin/buckwheat/util/toSP.kt @@ -1,4 +1,4 @@ -package com.danilkinkin.buckwheat.utils +package com.danilkinkin.buckwheat.util import android.content.res.Resources import kotlin.math.roundToInt diff --git a/app/src/main/java/com/danilkinkin/buckwheat/util/visualTransformationAsCurrency.kt b/app/src/main/java/com/danilkinkin/buckwheat/util/visualTransformationAsCurrency.kt new file mode 100644 index 0000000..3301fb4 --- /dev/null +++ b/app/src/main/java/com/danilkinkin/buckwheat/util/visualTransformationAsCurrency.kt @@ -0,0 +1,39 @@ +package com.danilkinkin.buckwheat.util + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText +import kotlin.math.min + +private fun visualTransformationAsCurrency( + input: AnnotatedString, + currency: ExtendCurrency, + forceShowAfterDot: Boolean = false, +): TransformedText { + val output = prettyCandyCanes(input.text.toBigDecimal(), forceShowAfterDot, currency) + + val offsetTranslator = object : OffsetMapping { + override fun originalToTransformed(offset: Int): Int { + val count = output.substring(0, min(offset, output.length - 1)).filter { !it.isDigit() }.length + + return min(offset + count, output.length) + } + + override fun transformedToOriginal(offset: Int): Int { + val count = output.substring(0, min(offset, output.length - 1)).filter { !it.isDigit() }.length + + return min(offset - count, output.length) + } + } + + return TransformedText(AnnotatedString(output), offsetTranslator) +} + +fun visualTransformationAsCurrency( + forceShowAfterDot: Boolean = false, + currency: ExtendCurrency +): ((input: AnnotatedString) -> TransformedText) { + return { + visualTransformationAsCurrency(it, currency, forceShowAfterDot) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/danilkinkin/buckwheat/utils/colors.kt b/app/src/main/java/com/danilkinkin/buckwheat/utils/colors.kt deleted file mode 100644 index 2433c4d..0000000 --- a/app/src/main/java/com/danilkinkin/buckwheat/utils/colors.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.danilkinkin.buckwheat.utils - -import android.content.Context -import android.content.res.TypedArray -import android.util.TypedValue - - -fun getThemeColor(context: Context, color: Int): Int { - val typedValue = TypedValue() - - val a: TypedArray = context.obtainStyledAttributes(typedValue.data, intArrayOf(color)) - val colorId = a.getColor(0, 0) - - a.recycle() - - return colorId -} diff --git a/app/src/main/java/com/danilkinkin/buckwheat/utils/statusBarHeight.kt b/app/src/main/java/com/danilkinkin/buckwheat/utils/statusBarHeight.kt deleted file mode 100644 index 61213de..0000000 --- a/app/src/main/java/com/danilkinkin/buckwheat/utils/statusBarHeight.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.danilkinkin.buckwheat.utils - -import android.view.View - -fun getStatusBarHeight(view: View): Int { - val resourceId = view.resources.getIdentifier("status_bar_height", "dimen", "android") - - return if (resourceId > 0) { - view.resources.getDimensionPixelSize(resourceId) - } else { - 0 - } -} - -fun getNavigationBarHeight(view: View): Int { - val resourceId = view.resources.getIdentifier("navigation_bar_height", "dimen", "android") - - return if (resourceId > 0) { - view.resources.getDimensionPixelSize(resourceId) - } else { - 0 - } -} \ No newline at end of file diff --git a/app/src/main/java/com/danilkinkin/buckwheat/utils/toDP.kt b/app/src/main/java/com/danilkinkin/buckwheat/utils/toDP.kt deleted file mode 100644 index 784f56c..0000000 --- a/app/src/main/java/com/danilkinkin/buckwheat/utils/toDP.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.danilkinkin.buckwheat.utils - -import android.content.res.Resources -import kotlin.math.roundToInt - -fun Int.toDP(): Int = (this * Resources.getSystem().displayMetrics.density).roundToInt() - -fun Float.toDP(): Int = (this * Resources.getSystem().displayMetrics.density).roundToInt() - -fun Double.toDP(): Int = (this * Resources.getSystem().displayMetrics.density).roundToInt() \ No newline at end of file diff --git a/app/src/main/java/com/danilkinkin/buckwheat/viewmodels/AppViewModel.kt b/app/src/main/java/com/danilkinkin/buckwheat/viewmodels/AppViewModel.kt deleted file mode 100644 index 797c137..0000000 --- a/app/src/main/java/com/danilkinkin/buckwheat/viewmodels/AppViewModel.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.danilkinkin.buckwheat.viewmodels - -import android.app.Application -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.MutableLiveData -import com.danilkinkin.buckwheat.di.DatabaseModule -import com.danilkinkin.buckwheat.entities.Storage - -class AppViewModel(application: Application) : AndroidViewModel(application) { - - private val db = DatabaseModule.getInstance(application) - - private val storage = db.storageDao() - - var isDebug: MutableLiveData = MutableLiveData(try { - storage.get("isDebug").value.toBoolean() - } catch (e: Exception) { - false - }) - - fun setIsDebug(debug: Boolean) { - storage.set(Storage("isDebug", debug.toString())) - - isDebug.value = debug - } -} \ No newline at end of file diff --git a/app/src/main/java/com/danilkinkin/buckwheat/wallet/CustomCurrencyEditor.kt b/app/src/main/java/com/danilkinkin/buckwheat/wallet/CustomCurrencyEditor.kt new file mode 100644 index 0000000..d313ae1 --- /dev/null +++ b/app/src/main/java/com/danilkinkin/buckwheat/wallet/CustomCurrencyEditor.kt @@ -0,0 +1,110 @@ +package com.danilkinkin.buckwheat.wallet + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.danilkinkin.buckwheat.R +import com.danilkinkin.buckwheat.ui.BuckwheatTheme + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CustomCurrencyEditorContent( + defaultCurrency: String? = "", + onChange: (currency: String) -> Unit, + onClose: () -> Unit, +) { + val selectCurrency = remember { mutableStateOf(defaultCurrency ?: "") } + + Card( + shape = CardDefaults.shape, + modifier = Modifier + .widthIn(max = 500.dp) + .padding(36.dp) + ) { + Column() { + Text( + text = stringResource(R.string.currency_custom_title), + style = MaterialTheme.typography.displayMedium, + modifier = Modifier.padding(24.dp) + ) + Divider() + Box(Modifier) { + TextField( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + value = selectCurrency.value.toString(), + onValueChange = { selectCurrency.value = it }, + shape = TextFieldDefaults.filledShape, + colors = TextFieldDefaults.textFieldColors() + ) + } + Divider() + Row( + horizontalArrangement = Arrangement.End, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp, horizontal = 16.dp), + ) { + Button( + onClick = { onClose() }, + colors = ButtonDefaults.textButtonColors(), + contentPadding = ButtonDefaults.TextButtonContentPadding, + ) { + Text(text = stringResource(R.string.cancel)) + } + Button( + onClick = { + onChange(selectCurrency.value!!) + onClose() + }, + colors = ButtonDefaults.textButtonColors(), + contentPadding = ButtonDefaults.TextButtonContentPadding, + enabled = selectCurrency.value.trim() !== "", + ) { + Text(text = stringResource(R.string.accept)) + } + } + } + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun CustomCurrencyEditor( + defaultCurrency: String? = null, + onChange: (currency: String) -> Unit, + onClose: () -> Unit, +) { + Dialog( + onDismissRequest = { onClose() }, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + CustomCurrencyEditorContent( + defaultCurrency = defaultCurrency, + onChange = onChange, + onClose = { onClose() } + ) + } +} + +@Preview +@Composable +fun PreviewCustomCurrencyEditor() { + BuckwheatTheme { + CustomCurrencyEditorContent( + defaultCurrency = "", + onChange = { }, + onClose = { } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/danilkinkin/buckwheat/wallet/FinishDateSelector.kt b/app/src/main/java/com/danilkinkin/buckwheat/wallet/FinishDateSelector.kt new file mode 100644 index 0000000..96007ef --- /dev/null +++ b/app/src/main/java/com/danilkinkin/buckwheat/wallet/FinishDateSelector.kt @@ -0,0 +1,117 @@ +package com.danilkinkin.buckwheat.wallet + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Surface +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.danilkinkin.buckwheat.R +import com.danilkinkin.buckwheat.calendar.Calendar +import com.danilkinkin.buckwheat.calendar.model.CalendarState +import com.danilkinkin.buckwheat.calendar.model.selectedDatesFormatted +import com.danilkinkin.buckwheat.ui.BuckwheatTheme +import com.danilkinkin.buckwheat.util.toDate +import java.time.LocalDate +import java.util.* + + +@Composable +fun FinishDateSelector( + selectDate: Date? = null, + onBackPressed: () -> Unit, + onApply: (finishDate: Date) -> Unit, +) { + Surface(modifier = Modifier.fillMaxSize()) { + val calendarState = remember { CalendarState(selectDate) } + + FinishDateSelectorContent( + calendarState = calendarState, + onDayClicked = { calendarState.setSelectedDay(it) }, + onBackPressed = onBackPressed, + onApply = { onApply(calendarState.calendarUiState.value.selectedEndDate!!.toDate()) } + ) + } +} + +@Composable +private fun FinishDateSelectorContent( + calendarState: CalendarState, + onDayClicked: (LocalDate) -> Unit, + onBackPressed: () -> Unit, + onApply: () -> Unit, +) { + Column() { + FinishDateSelectorTopAppBar(calendarState, onBackPressed, onApply) + Calendar( + calendarState = calendarState, + onDayClicked = onDayClicked, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun FinishDateSelectorTopAppBar( + calendarState: CalendarState, + onBackPressed: () -> Unit, + onApply: () -> Unit, +) { + Surface(modifier = Modifier.statusBarsPadding()) { + MediumTopAppBar( + navigationIcon = { + IconButton( + onClick = { onBackPressed() } + ) { + Icon( + imageVector = Icons.Filled.ArrowBack, + contentDescription = null, + ) + } + }, + title = { + Text( + text = if (!calendarState.calendarUiState.value.hasSelectedDates) { + stringResource(R.string.select_finish_date_title) + } else { + selectedDatesFormatted(calendarState) + }, + style = MaterialTheme.typography.titleLarge, + ) + }, + actions = { + Button( + onClick = { onApply() }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.padding(end = 8.dp), + enabled = calendarState.calendarUiState.value.selectedStartDate !== null, + ) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = "Localized description", + modifier = Modifier.size(ButtonDefaults.IconSize) + + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(text = stringResource(R.string.apply)) + } + } + + ) + } +} + + +@Preview(showSystemUi = true) +@Composable +fun PreviewFinishDateSelector(){ + BuckwheatTheme { + FinishDateSelector(onBackPressed = {}, onApply = {}) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/danilkinkin/buckwheat/wallet/Wallet.kt b/app/src/main/java/com/danilkinkin/buckwheat/wallet/Wallet.kt new file mode 100644 index 0000000..e5dc5ed --- /dev/null +++ b/app/src/main/java/com/danilkinkin/buckwheat/wallet/Wallet.kt @@ -0,0 +1,214 @@ +package com.danilkinkin.buckwheat.wallet + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.danilkinkin.buckwheat.R +import com.danilkinkin.buckwheat.base.ButtonRow +import com.danilkinkin.buckwheat.base.CheckedRow +import com.danilkinkin.buckwheat.base.TextRow +import com.danilkinkin.buckwheat.base.Divider +import com.danilkinkin.buckwheat.data.SpendsViewModel +import com.danilkinkin.buckwheat.ui.BuckwheatTheme +import com.danilkinkin.buckwheat.util.* +import kotlinx.coroutines.launch +import java.lang.Exception +import java.math.BigDecimal +import java.math.RoundingMode +import java.util.* + +@OptIn( + ExperimentalMaterial3Api::class, + ExperimentalComposeUiApi::class, +) +@Composable +fun Wallet( + requestFinishDate: ((presetDate: Date, callback: (finishDate: Date) -> Unit) -> Unit) = { _: Date, _: (finishDate: Date) -> Unit -> }, + spendsViewModel: SpendsViewModel = viewModel(), + onClose: () -> Unit = {}, +) { + val budget = remember { mutableStateOf(spendsViewModel.budget.value!!) } + val dateToValue = remember { mutableStateOf(spendsViewModel.finishDate) } + val currency = remember { mutableStateOf(spendsViewModel.currency) } + + val openCurrencyChooserDialog = remember { mutableStateOf(false) } + val openCustomCurrencyEditorDialog = remember { mutableStateOf(false) } + val coroutineScope = rememberCoroutineScope() + + Column(modifier = Modifier.navigationBarsPadding()) { + val days = countDays(dateToValue.value) + + CenterAlignedTopAppBar( + title = { + Text( + text = stringResource(R.string.wallet_title), + style = MaterialTheme.typography.titleLarge, + ) + } + ) + Divider() + TextRow( + icon = painterResource(R.drawable.ic_money), + text = stringResource(R.string.label_budget), + ) + TextField( + modifier = Modifier + .padding(start = 56.dp) + .fillMaxWidth(), + value = budget.value.toString(), + onValueChange = { + try { + budget.value = BigDecimal(it) + } catch (E: Exception) { + budget.value = BigDecimal(0) + } + }, + colors = TextFieldDefaults.textFieldColors( + containerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + errorIndicatorColor = Color.Transparent, + ), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + textStyle = MaterialTheme.typography.displaySmall, + visualTransformation = visualTransformationAsCurrency( + currency = ExtendCurrency(type = CurrencyType.NONE) + ) + ) + Divider() + ButtonRow( + icon = painterResource(R.drawable.ic_calendar), + text = String.format( + pluralStringResource(R.plurals.finish_date_label, 32), + prettyDate(dateToValue.value, showTime = false, forceShowDate = true), + days, + ), + onClick = { + coroutineScope.launch { + requestFinishDate(dateToValue.value) { + dateToValue.value = it + } + } + }, + ) + Divider() + TextRow( + icon = painterResource(R.drawable.ic_currency), + text = stringResource(R.string.in_currency_label), + ) + CheckedRow( + checked = currency.value.type === CurrencyType.FROM_LIST, + onValueChange = { openCurrencyChooserDialog.value = true }, + text = if (currency.value.type !== CurrencyType.FROM_LIST) { + stringResource(R.string.currency_from_list) + } else { + stringResource( + id = R.string.currency_from_list_selected, + Currency.getInstance(currency.value.value).symbol + ) + }, + ) + CheckedRow( + checked = currency.value.type === CurrencyType.CUSTOM, + onValueChange = { openCustomCurrencyEditorDialog.value = true }, + text = if (currency.value.type !== CurrencyType.CUSTOM) { + stringResource(R.string.currency_custom) + } else { + stringResource( + id = R.string.currency_custom_selected, + currency.value.value!! + ) + }, + ) + CheckedRow( + checked = currency.value.type === CurrencyType.NONE, + onValueChange = { + if (it) { + currency.value = ExtendCurrency(type = CurrencyType.NONE) + } + }, + text = stringResource(R.string.currency_none), + ) + Divider() + Spacer(Modifier.height(24.dp)) + Text( + text = stringResource( + R.string.per_day, + prettyCandyCanes( + if (days != 0) { + (budget.value / days.toBigDecimal()).setScale(0, RoundingMode.FLOOR) + } else { + budget.value + }, + currency = spendsViewModel.currency, + ), + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 56.dp) + ) + Spacer(Modifier.height(24.dp)) + Button( + onClick = { + spendsViewModel.changeCurrency(currency.value) + spendsViewModel.changeBudget(budget.value, dateToValue.value) + + onClose() + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + enabled = countDays(dateToValue.value) > 0 && budget.value > BigDecimal(0) + ) { + Text(text = stringResource(id = R.string.apply)) + } + } + + if (openCurrencyChooserDialog.value) { + WorldCurrencyChooser( + defaultCurrency = if (currency.value.type === CurrencyType.FROM_LIST) { + Currency.getInstance(currency.value.value) + } else { + null + }, + onSelect = { + currency.value = + ExtendCurrency(type = CurrencyType.FROM_LIST, value = it.currencyCode) + }, + onClose = { openCurrencyChooserDialog.value = false }, + ) + } + + if (openCustomCurrencyEditorDialog.value) { + CustomCurrencyEditor( + defaultCurrency = if (currency.value.type === CurrencyType.CUSTOM) { + currency.value.value + } else { + null + }, + onChange = { + currency.value = ExtendCurrency(type = CurrencyType.CUSTOM, value = it) + }, + onClose = { openCustomCurrencyEditorDialog.value = false }, + ) + } +} + +@Preview +@Composable +fun PreviewWallet() { + BuckwheatTheme { + Wallet() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/danilkinkin/buckwheat/wallet/WorldCurrencyChooser.kt b/app/src/main/java/com/danilkinkin/buckwheat/wallet/WorldCurrencyChooser.kt new file mode 100644 index 0000000..b170273 --- /dev/null +++ b/app/src/main/java/com/danilkinkin/buckwheat/wallet/WorldCurrencyChooser.kt @@ -0,0 +1,152 @@ +package com.danilkinkin.buckwheat.wallet + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.selection.toggleable +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.danilkinkin.buckwheat.R +import com.danilkinkin.buckwheat.ui.BuckwheatTheme +import java.util.* + +fun getCurrencies(): MutableList { + val currencies = Currency.getAvailableCurrencies().toMutableList() + + currencies.sortBy { it.displayName.uppercase() } + + return currencies +} + +@Composable +fun WorldCurrencyChooserContent( + defaultCurrency: Currency? = null, + onSelect: (currency: Currency) -> Unit, + onClose: () -> Unit, +) { + val selectCurrency = remember { mutableStateOf(defaultCurrency) } + + Card( + shape = CardDefaults.shape, + modifier = Modifier + .widthIn(max = 500.dp) + .padding(36.dp) + ) { + Column() { + Text( + text = stringResource(R.string.select_currency_title), + style = MaterialTheme.typography.displayMedium, + modifier = Modifier.padding(24.dp) + ) + Divider() + Box(Modifier.weight(1F)) { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = Modifier.fillMaxSize()//.heightIn(max = 600.dp) + ) { + getCurrencies().forEach { + itemsCurrency( + currency = it, + selected = selectCurrency.value?.currencyCode === it.currencyCode, + onClick = { + selectCurrency.value = it + }, + ) + } + } + } + Divider() + Row( + horizontalArrangement = Arrangement.End, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp, horizontal = 16.dp), + ) { + Button( + onClick = { onClose() }, + colors = ButtonDefaults.textButtonColors(), + contentPadding = ButtonDefaults.TextButtonContentPadding, + ) { + Text(text = stringResource(R.string.cancel)) + } + Button( + onClick = { + onSelect(selectCurrency.value!!) + onClose() + }, + colors = ButtonDefaults.textButtonColors(), + contentPadding = ButtonDefaults.TextButtonContentPadding, + enabled = selectCurrency.value !== null, + ) { + Text(text = stringResource(R.string.accept)) + } + } + } + } +} + +private fun LazyListScope.itemsCurrency( + currency: Currency, + selected: Boolean, + onClick: () -> Unit, +) { + item(currency.currencyCode) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .toggleable( + value = selected, + onValueChange = { onClick() }, + role = Role.Checkbox + ) + .padding(start = 24.dp, end = 16.dp, top = 8.dp, bottom = 8.dp), + ) { + Text(text = currency.displayName) + RadioButton(selected = selected, onClick = null) + } + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun WorldCurrencyChooser( + defaultCurrency: Currency? = null, + onSelect: (currency: Currency) -> Unit, + onClose: () -> Unit, +) { + Dialog( + onDismissRequest = { onClose() }, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + WorldCurrencyChooserContent( + defaultCurrency = defaultCurrency, + onSelect = onSelect, + onClose = { onClose() } + ) + } +} + +@Preview +@Composable +fun PreviewWorldCurrencyChooser() { + BuckwheatTheme { + WorldCurrencyChooserContent( + defaultCurrency = null, + onSelect = { }, + onClose = { } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/danilkinkin/buckwheat/widgets/bottomsheet/BottomSheetFragment.kt b/app/src/main/java/com/danilkinkin/buckwheat/widgets/bottomsheet/BottomSheetFragment.kt deleted file mode 100644 index 5f8932b..0000000 --- a/app/src/main/java/com/danilkinkin/buckwheat/widgets/bottomsheet/BottomSheetFragment.kt +++ /dev/null @@ -1,69 +0,0 @@ -package com.danilkinkin.buckwheat.widgets.bottomsheet - -import android.os.Build -import android.os.Bundle -import android.view.View -import android.view.WindowInsetsController -import androidx.core.content.ContextCompat -import androidx.core.graphics.drawable.toDrawable -import com.danilkinkin.buckwheat.R -import com.danilkinkin.buckwheat.utils.getStatusBarHeight -import com.google.android.material.appbar.AppBarLayout -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetDialogFragment - -open class BottomSheetFragment: BottomSheetDialogFragment() { - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - val behavior = BottomSheetBehavior.from(view.parent as View) - val topBarHeight = getStatusBarHeight(view) - - behavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() { - override fun onStateChanged(bottomSheet: View, newState: Int) { - if (newState == BottomSheetBehavior.STATE_EXPANDED && bottomSheet.top < topBarHeight) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - dialog!!.window!!.insetsController?.setSystemBarsAppearance( - WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS, - WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS - ) - } - - view.findViewById(R.id.app_bar).background = ContextCompat.getColor( - context!!, - com.google.android.material.R.color.material_dynamic_neutral95, - ).toDrawable() - - dialog!!.window!!.statusBarColor = ContextCompat.getColor( - context!!, - com.google.android.material.R.color.material_dynamic_neutral95, - ) - - } else { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - dialog!!.window!!.insetsController?.setSystemBarsAppearance( - 0, - WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS - ) - } - - view.findViewById(R.id.app_bar).background = ContextCompat.getColor( - context!!, - android.R.color.transparent, - ).toDrawable() - - dialog!!.window!!.statusBarColor = ContextCompat.getColor( - context!!, - android.R.color.transparent, - ) - } - } - - override fun onSlide(bottomSheet: View, slideOffset: Float) { - - } - - }) - - } -} \ No newline at end of file diff --git a/app/src/main/java/com/danilkinkin/buckwheat/widgets/editor/EditorBehavior.kt b/app/src/main/java/com/danilkinkin/buckwheat/widgets/editor/EditorBehavior.kt deleted file mode 100644 index dfe434b..0000000 --- a/app/src/main/java/com/danilkinkin/buckwheat/widgets/editor/EditorBehavior.kt +++ /dev/null @@ -1,133 +0,0 @@ -package com.danilkinkin.buckwheat.widgets.editor - -import android.content.Context -import android.util.AttributeSet -import android.util.Log -import android.view.MotionEvent -import android.view.View -import androidx.coordinatorlayout.widget.CoordinatorLayout -import androidx.core.view.updateLayoutParams -import androidx.core.widget.NestedScrollView -import androidx.recyclerview.widget.RecyclerView -import com.danilkinkin.buckwheat.R -import com.danilkinkin.buckwheat.utils.getNavigationBarHeight -import com.danilkinkin.buckwheat.utils.toDP -import com.danilkinkin.buckwheat.widgets.topsheet.TopSheetBehavior -import kotlin.math.abs - - -class EditorBehavior: CoordinatorLayout.Behavior { - - companion object { - val TAG = EditorBehavior::class.simpleName - } - - private var recyclerView: RecyclerView? = null - private var isBeingDragged = false - var initialX = 0 - var initialY = 0 - var lastY = 0 - - /** - * Конструктор для создания экземпляра FancyBehavior через разметку. - * - * @param context The {@link Context}. - * @param attrs The {@link AttributeSet}. - */ - constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { - } - - constructor() : super() - - override fun onLayoutChild(parent: CoordinatorLayout, child: V, layoutDirection: Int): Boolean { - child.updateLayoutParams { - height = parent.height - (parent.width - 16.toDP() + getNavigationBarHeight(child)) - } - - recyclerView = parent.findViewById(R.id.recycle_view) - - return super.onLayoutChild(parent, child, layoutDirection) - } - - override fun onInterceptTouchEvent( - parent: CoordinatorLayout, child: V, event: MotionEvent - ): Boolean { - if ( - parent.isPointInChildBounds(child, event.x.toInt(), event.y.toInt()) - && event.actionMasked == MotionEvent.ACTION_DOWN - ) { - initialX = event.x.toInt() - initialY = event.y.toInt() - lastY = event.y.toInt() - isBeingDragged = true - } - - if (isBeingDragged) { - Log.d(TAG, "onInterceptTouchEvent action = ${event.actionMasked}") - - if ( - event.actionMasked == MotionEvent.ACTION_UP - || event.actionMasked == MotionEvent.ACTION_CANCEL - ) { - isBeingDragged = false - } - - return if (recyclerView !== null) { - val topSheetBehavior = ((recyclerView!!.layoutParams as CoordinatorLayout.LayoutParams).behavior as TopSheetBehavior) - - val touchSlop = topSheetBehavior.viewDragHelper!!.touchSlop - - lastY = event.y.toInt() - - abs(initialY - event.y) > touchSlop || abs(initialX - event.x) > touchSlop - } else { - true - } - } - - return false - } - - override fun onTouchEvent( - parent: CoordinatorLayout, child: V, event: MotionEvent - ): Boolean { - if (parent.isPointInChildBounds(child, event.x.toInt(), event.y.toInt())) { - val topSheetBehavior = try { - ((recyclerView!!.layoutParams as CoordinatorLayout.LayoutParams).behavior as TopSheetBehavior) - } catch (e: Exception) { - null - } - - Log.d(TAG, "event.y = ${event.y.toInt()}") - - when (event.actionMasked) { - MotionEvent.ACTION_MOVE -> { - - topSheetBehavior?.drag(lastY - event.y.toInt()) - } - MotionEvent.ACTION_UP, - MotionEvent.ACTION_CANCEL -> { - topSheetBehavior?.finishDrag() - } - } - } - - lastY = event.y.toInt() - - return true - } - - override fun layoutDependsOn(parent: CoordinatorLayout, child: V, dependency: View): Boolean { - return dependency.id == R.id.recycle_view - } - - override fun onDependentViewChanged( - parent: CoordinatorLayout, - child: V, - dependency: View - ): Boolean { - child.translationY = dependency.bottom.toFloat() - - return true - } -} \ No newline at end of file diff --git a/app/src/main/java/com/danilkinkin/buckwheat/widgets/editor/EditorFragment.kt b/app/src/main/java/com/danilkinkin/buckwheat/widgets/editor/EditorFragment.kt deleted file mode 100644 index 65ab3bf..0000000 --- a/app/src/main/java/com/danilkinkin/buckwheat/widgets/editor/EditorFragment.kt +++ /dev/null @@ -1,297 +0,0 @@ -package com.danilkinkin.buckwheat.widgets.editor - -import android.animation.ValueAnimator -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.animation.AccelerateDecelerateInterpolator -import androidx.constraintlayout.widget.ConstraintLayout -import androidx.core.animation.doOnEnd -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import com.danilkinkin.buckwheat.* -import com.danilkinkin.buckwheat.utils.getStatusBarHeight -import com.danilkinkin.buckwheat.utils.prettyCandyCanes -import com.danilkinkin.buckwheat.utils.toDP -import com.danilkinkin.buckwheat.utils.toSP -import com.danilkinkin.buckwheat.viewmodels.AppViewModel -import com.danilkinkin.buckwheat.viewmodels.SpentViewModel -import com.google.android.material.button.MaterialButton -import com.google.android.material.textview.MaterialTextView -import kotlin.math.max -import kotlin.math.min - -class EditorFragment : Fragment() { - companion object { - val TAG: String = EditorFragment::class.java.simpleName - } - - private lateinit var model: SpentViewModel - private lateinit var appModel: AppViewModel - - private var settingsBottomSheet: SettingsBottomSheet? = null - private var walletBottomSheet: WalletBottomSheet? = null - private var newDayBottomSheet: NewDayBottomSheet? = null - - private val budgetView: ConstraintLayout by lazy { - requireView().findViewById(R.id.budget) - } - private val spentView: ConstraintLayout by lazy { - requireView().findViewById(R.id.spent) - } - private val restBudgetView: ConstraintLayout by lazy { - requireView().findViewById(R.id.rest_budget) - } - - private val budgetValue: MaterialTextView by lazy { - requireView().findViewById(R.id.budget_value) - } - private val spentValue: MaterialTextView by lazy { - requireView().findViewById(R.id.spent_value) - } - private val restBudgetValue: MaterialTextView by lazy { - requireView().findViewById(R.id.rest_budget_value) - } - - private val budgetLabel: MaterialTextView by lazy { - requireView().findViewById(R.id.budget_label) - } - private val spentLabel: MaterialTextView by lazy { - requireView().findViewById(R.id.spent_label) - } - private val restBudgetLabel: MaterialTextView by lazy { - requireView().findViewById(R.id.rest_budget_label) - } - - enum class AnimState { FIRST_IDLE, EDITING, COMMIT, IDLE, RESET } - - private var currAnimator: ValueAnimator? = null - private var currState: AnimState? = null - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - super.onCreateView(inflater, container, savedInstanceState) - - return inflater.inflate(R.layout.fragment_editor, container, false) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - val model: SpentViewModel by activityViewModels() - val appModel: AppViewModel by activityViewModels() - - this.model = model - this.appModel = appModel - - val helperView = view.findViewById(R.id.top_bar_offset_helper) - val layout = helperView.layoutParams - - layout.height = getStatusBarHeight(view) - - helperView.layoutParams = layout - - build() - observe() - } - - private fun calculateValues( - budget: Boolean = true, - restBudget: Boolean = true, - spent: Boolean = true - ) { - val spentFromDailyBudget = model.spentFromDailyBudget.value!! - val dailyBudget = model.dailyBudget.value!! - - if (budget) budgetValue.text = prettyCandyCanes(dailyBudget - spentFromDailyBudget) - if (restBudget) restBudgetValue.text = - prettyCandyCanes(dailyBudget - spentFromDailyBudget - model.currentSpent) - if (spent) spentValue.text = prettyCandyCanes(model.currentSpent, model.useDot) - } - - private fun build() { - calculateValues() - - restBudgetView.alpha = 0F - spentView.alpha = 0F - budgetView.alpha = 0F - - animTo(AnimState.FIRST_IDLE) - - requireView().findViewById(R.id.settings_btn).setOnClickListener { - if (settingsBottomSheet?.isVisible == true) return@setOnClickListener - - settingsBottomSheet = SettingsBottomSheet() - settingsBottomSheet!!.show(parentFragmentManager, SettingsBottomSheet.TAG) - } - - requireView().findViewById(R.id.wallet_btn).setOnClickListener { - if (walletBottomSheet?.isVisible == true) return@setOnClickListener - - walletBottomSheet = WalletBottomSheet() - walletBottomSheet!!.show(parentFragmentManager, WalletBottomSheet.TAG) - } - - requireView().findViewById(R.id.dev_tool_btn).setOnClickListener { - if (newDayBottomSheet?.isVisible == true) return@setOnClickListener - - newDayBottomSheet = NewDayBottomSheet() - newDayBottomSheet!!.show(parentFragmentManager, NewDayBottomSheet.TAG) - } - } - - private fun animFrame(state: AnimState, progress: Float = 1F) { - when (state) { - AnimState.FIRST_IDLE -> { - budgetLabel.textSize = 10.toSP().toFloat() - budgetValue.textSize = 40.toSP().toFloat() - budgetView.translationY = 30.toDP() * (1F - progress) - budgetView.alpha = progress - } - AnimState.EDITING -> { - var offset = 0F - - restBudgetValue.textSize = 20.toSP().toFloat() - restBudgetLabel.textSize = 8.toSP().toFloat() - offset += restBudgetView.height - restBudgetView.translationY = (offset + spentView.height) * (1F - progress) - restBudgetView.alpha = 1F - - spentValue.textSize = 60.toSP().toFloat() - spentLabel.textSize = 18.toSP().toFloat() - spentView.translationY = (spentView.height + offset) * (1F - progress) - offset - spentView.alpha = 1F - - offset += spentView.height - - budgetValue.textSize = 40.toSP().toFloat() - 28.toSP().toFloat() * progress - budgetLabel.textSize = 10.toSP().toFloat() - 4.toSP().toFloat() * progress - budgetView.translationY = -offset * progress - budgetView.alpha = 1F - } - AnimState.COMMIT -> { - var offset = 0F - - val progressA = min(progress * 2F, 1F) - val progressB = max((progress - 0.5F) * 2F, 0F) - - restBudgetValue.textSize = 20.toSP().toFloat() + 20.toSP().toFloat() * progress - restBudgetLabel.textSize = 8.toSP().toFloat() + 2.toSP().toFloat() * progress - offset += restBudgetView.height - restBudgetView.alpha = 1F - - spentValue.textSize = 60.toSP().toFloat() - spentLabel.textSize = 18.toSP().toFloat() - spentView.translationY = -offset - 50.toDP() * progressB - spentView.alpha = 1F - progressB - offset += spentView.height - - budgetValue.textSize = 12.toSP().toFloat() - budgetLabel.textSize = 6.toSP().toFloat() - budgetView.translationY = -offset - 50.toDP() * progressA - budgetView.alpha = 1F - progressA - } - AnimState.RESET -> { - var offset = 0F - - restBudgetValue.textSize = 20.toSP().toFloat() - restBudgetLabel.textSize = 8.toSP().toFloat() - offset += restBudgetView.height - restBudgetView.translationY = (offset + spentView.height) * progress - - spentValue.textSize = 60.toSP().toFloat() - spentLabel.textSize = 18.toSP().toFloat() - spentView.translationY = (spentView.height + offset) * progress - offset - offset += spentView.height - - budgetValue.textSize = 12.toSP().toFloat() + 28.toSP().toFloat() * progress - budgetLabel.textSize = 6.toSP().toFloat() + 4.toSP().toFloat() * progress - budgetView.translationY = -offset * (1F - progress) - } - AnimState.IDLE -> { - calculateValues(restBudget = false) - - budgetValue.textSize = 40.toSP().toFloat() - budgetLabel.textSize = 10.toSP().toFloat() - budgetView.translationY = 0F - budgetView.alpha = 1F - - restBudgetView.alpha = 0F - } - } - } - - private fun animTo(state: AnimState) { - if (currState === state) return - - currState = state - - if (currAnimator !== null) { - currAnimator!!.pause() - } - - currAnimator = ValueAnimator.ofFloat(0F, 1F) - - currAnimator!!.apply { - duration = 220 - interpolator = AccelerateDecelerateInterpolator() - - addUpdateListener { valueAnimator -> - val animatedValue = valueAnimator.animatedValue as Float - - animFrame(state, animatedValue) - } - - doOnEnd { - if (state === AnimState.COMMIT) { - animFrame(AnimState.IDLE) - } - } - - start() - } - } - - private fun observe() { - model.dailyBudget.observe(viewLifecycleOwner) { - calculateValues() - } - - model.spentFromDailyBudget.observe(viewLifecycleOwner) { - calculateValues(budget = currState !== AnimState.EDITING, restBudget = false) - } - - model.stage.observe(viewLifecycleOwner) { stage -> - when (stage) { - SpentViewModel.Stage.IDLE, null -> { - if (currState === AnimState.EDITING) animTo(AnimState.RESET) - } - SpentViewModel.Stage.CREATING_SPENT -> { - calculateValues(budget = false) - - animTo(AnimState.EDITING) - } - SpentViewModel.Stage.EDIT_SPENT -> { - calculateValues(budget = false) - } - SpentViewModel.Stage.COMMITTING_SPENT -> { - animTo(AnimState.COMMIT) - - model.resetSpent() - } - } - } - - appModel.isDebug.observe(viewLifecycleOwner) { - requireView().findViewById(R.id.dev_tool_btn).visibility = if (it) { - View.VISIBLE - } else { - View.GONE - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/danilkinkin/buckwheat/widgets/keyboard/KeyboardBehavior.kt b/app/src/main/java/com/danilkinkin/buckwheat/widgets/keyboard/KeyboardBehavior.kt deleted file mode 100644 index 8fd8846..0000000 --- a/app/src/main/java/com/danilkinkin/buckwheat/widgets/keyboard/KeyboardBehavior.kt +++ /dev/null @@ -1,77 +0,0 @@ -package com.danilkinkin.buckwheat.widgets.keyboard - -import android.content.Context -import android.util.AttributeSet -import android.util.Log -import android.view.View -import androidx.constraintlayout.motion.widget.MotionLayout -import androidx.coordinatorlayout.widget.CoordinatorLayout -import androidx.core.view.updateLayoutParams -import com.danilkinkin.buckwheat.R -import com.danilkinkin.buckwheat.utils.getNavigationBarHeight -import com.danilkinkin.buckwheat.utils.toDP -import kotlin.math.max -import kotlin.math.min - - -class KeyboardBehavior: CoordinatorLayout.Behavior { - - companion object { - val TAG = KeyboardBehavior::class.simpleName - } - - private var navigationBarHeight: Int? = null - - /** - * Конструктор для создания экземпляра FancyBehavior через разметку. - * - * @param context The {@link Context}. - * @param attrs The {@link AttributeSet}. - */ - constructor(context: Context, attrs: AttributeSet) : super(context, attrs) - - constructor() : super() - - override fun layoutDependsOn(parent: CoordinatorLayout, child: V, dependency: View): Boolean { - return dependency.id == R.id.editor_container - } - - override fun onLayoutChild(parent: CoordinatorLayout, child: V, layoutDirection: Int): Boolean { - navigationBarHeight = getNavigationBarHeight(child) - - child.updateLayoutParams { - height = parent.width - 16.toDP() + navigationBarHeight!! - - } - - child.setPadding( - 16.toDP(), - 16.toDP(), - 16.toDP(), - navigationBarHeight!!, - ) - - return super.onLayoutChild(parent, child, layoutDirection) - } - - override fun onDependentViewChanged( - parent: CoordinatorLayout, - child: V, - dependency: View - ): Boolean { - child.translationY = dependency.translationY - - val maxHeight = child.height - val minHeight = 226.toDP() + child.paddingTop + child.paddingBottom - - child.findViewById(R.id.root)?.progress = max( - min( - 1 - ((parent.height - (dependency.bottom + dependency.translationY) - minHeight) / (maxHeight - minHeight)), - 0.999999F, - ), - 0.000001F, - ) - - return super.onDependentViewChanged(parent, child, dependency) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/danilkinkin/buckwheat/widgets/keyboard/KeyboardFragment.kt b/app/src/main/java/com/danilkinkin/buckwheat/widgets/keyboard/KeyboardFragment.kt deleted file mode 100644 index 6b4daaa..0000000 --- a/app/src/main/java/com/danilkinkin/buckwheat/widgets/keyboard/KeyboardFragment.kt +++ /dev/null @@ -1,242 +0,0 @@ -package com.danilkinkin.buckwheat.widgets.keyboard - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.constraintlayout.motion.widget.MotionLayout -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import com.danilkinkin.buckwheat.R -import com.danilkinkin.buckwheat.utils.toDP -import com.danilkinkin.buckwheat.utils.toSP -import com.danilkinkin.buckwheat.viewmodels.AppViewModel -import com.danilkinkin.buckwheat.viewmodels.SpentViewModel -import com.google.android.material.button.MaterialButton -import com.google.android.material.snackbar.Snackbar - -class KeyboardFragment : Fragment() { - private lateinit var model: SpentViewModel - private lateinit var appModel: AppViewModel - - private val root: MotionLayout by lazy { - requireView().findViewById(R.id.root) - } - - private val n0Btn: MaterialButton by lazy { - requireView().findViewById(R.id.btn_0) - } - - private val n1Btn: MaterialButton by lazy { - requireView().findViewById(R.id.btn_1) - } - - private val n2Btn: MaterialButton by lazy { - requireView().findViewById(R.id.btn_2) - } - - private val n3Btn: MaterialButton by lazy { - requireView().findViewById(R.id.btn_3) - } - - private val n4Btn: MaterialButton by lazy { - requireView().findViewById(R.id.btn_4) - } - - private val n5Btn: MaterialButton by lazy { - requireView().findViewById(R.id.btn_5) - } - - private val n6Btn: MaterialButton by lazy { - requireView().findViewById(R.id.btn_6) - } - - private val n7Btn: MaterialButton by lazy { - requireView().findViewById(R.id.btn_7) - } - - private val n8Btn: MaterialButton by lazy { - requireView().findViewById(R.id.btn_8) - } - - private val n9Btn: MaterialButton by lazy { - requireView().findViewById(R.id.btn_9) - } - - private val backspaceBtn: MaterialButton by lazy { - requireView().findViewById(R.id.btn_backspace) - } - - private val dotBtn: MaterialButton by lazy { - requireView().findViewById(R.id.btn_dot) - } - - private val evalBtn: MaterialButton by lazy { - requireView().findViewById(R.id.btn_eval) - } - - var listBtns: ArrayList? = null - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - super.onCreateView(inflater, container, savedInstanceState) - - return inflater.inflate(R.layout.fragment_keyboard, container, false) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - val model: SpentViewModel by activityViewModels() - val appModel: AppViewModel by activityViewModels() - - this.model = model - this.appModel = appModel - - build() - } - - fun build() { - listBtns = arrayListOf( - n0Btn, - n1Btn, - n2Btn, - n3Btn, - n4Btn, - n5Btn, - n6Btn, - n7Btn, - n8Btn, - n9Btn, - dotBtn - ) - - n0Btn.setOnClickListener { - this.model.executeAction(SpentViewModel.Action.PUT_NUMBER, 0) - } - - n1Btn.setOnClickListener { - this.model.executeAction(SpentViewModel.Action.PUT_NUMBER, 1) - } - - n2Btn.setOnClickListener { - this.model.executeAction(SpentViewModel.Action.PUT_NUMBER, 2) - } - - n3Btn.setOnClickListener { - this.model.executeAction(SpentViewModel.Action.PUT_NUMBER, 3) - } - - n4Btn.setOnClickListener { - this.model.executeAction(SpentViewModel.Action.PUT_NUMBER, 4) - } - - n5Btn.setOnClickListener { - this.model.executeAction(SpentViewModel.Action.PUT_NUMBER, 5) - } - - n6Btn.setOnClickListener { - this.model.executeAction(SpentViewModel.Action.PUT_NUMBER, 6) - } - - n7Btn.setOnClickListener { - this.model.executeAction(SpentViewModel.Action.PUT_NUMBER, 7) - } - - n8Btn.setOnClickListener { - this.model.executeAction(SpentViewModel.Action.PUT_NUMBER, 8) - } - - n9Btn.setOnClickListener { - this.model.executeAction(SpentViewModel.Action.PUT_NUMBER, 9) - } - - dotBtn.setOnClickListener { - this.model.executeAction(SpentViewModel.Action.SET_DOT) - } - - backspaceBtn.setOnClickListener { - this.model.executeAction(SpentViewModel.Action.REMOVE_LAST) - } - - evalBtn.setOnClickListener { - if ("${model.valueLeftDot}.${model.valueRightDot}" == "00000000.") { - model.resetSpent() - - appModel.setIsDebug(!appModel.isDebug.value!!) - - Snackbar - .make( - requireView(), "Debug ${ - if (appModel.isDebug.value!!) { - "ON" - } else { - "OFF" - } - }", Snackbar.LENGTH_LONG - ) - .show() - - return@setOnClickListener - } - - model.commitSpent() - } - - root.addTransitionListener(object : MotionLayout.TransitionListener { - override fun onTransitionStarted( - motionLayout: MotionLayout?, - startId: Int, - endId: Int - ) { - - } - - override fun onTransitionChange( - motionLayout: MotionLayout?, - startId: Int, - endId: Int, - progress: Float - ) { - val shiftProgress: Float = if (progress < 0.5) { - 0F - } else { - (progress - 0.5F) * 2F - } - - backspaceBtn.iconSize = (36.toDP() - 12.toDP() * shiftProgress).toInt() - - listBtns?.forEach { - it.textSize = 26.toSP() - 12.toSP() * shiftProgress - } - } - - override fun onTransitionCompleted(motionLayout: MotionLayout?, currentId: Int) { - val shiftProgress = if (currentId == R.id.end) { - 1F - } else { - 0F - } - - backspaceBtn.iconSize = (36.toDP() - 12.toDP() * shiftProgress).toInt() - - listBtns?.forEach { - it.textSize = 26.toSP() - 12.toSP() * shiftProgress - } - } - - override fun onTransitionTrigger( - motionLayout: MotionLayout?, - triggerId: Int, - positive: Boolean, - progress: Float - ) { - - } - - }) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/danilkinkin/buckwheat/widgets/topsheet/TopSheetBehavior.kt b/app/src/main/java/com/danilkinkin/buckwheat/widgets/topsheet/TopSheetBehavior.kt deleted file mode 100644 index d7e81c8..0000000 --- a/app/src/main/java/com/danilkinkin/buckwheat/widgets/topsheet/TopSheetBehavior.kt +++ /dev/null @@ -1,548 +0,0 @@ -package com.danilkinkin.buckwheat.widgets.topsheet - -import android.content.Context -import android.os.Parcel -import android.os.Parcelable -import android.util.AttributeSet -import android.util.Log -import android.view.* -import androidx.annotation.VisibleForTesting -import androidx.coordinatorlayout.widget.CoordinatorLayout -import androidx.core.math.MathUtils -import androidx.core.view.ViewCompat -import androidx.customview.view.AbsSavedState -import androidx.customview.widget.ViewDragHelper -import com.danilkinkin.buckwheat.R -import com.google.android.material.floatingactionbutton.FloatingActionButton -import java.lang.ref.WeakReference -import kotlin.math.abs -import kotlin.math.min - -open class TopSheetBehavior(context: Context, attrs: AttributeSet?) : - CoordinatorLayout.Behavior(context, attrs) { - - companion object { - private val TAG = TopSheetBehavior::class.java.simpleName - - enum class State { - STATE_DRAGGING, /** The bottom sheet is dragging. */ - STATE_SETTLING, /** The bottom sheet is settling. */ - STATE_EXPANDED, /** The bottom sheet is expanded. */ - STATE_HIDDEN, /** The bottom sheet is hidden. */ - } - - private const val HIDE_THRESHOLD = 0.5f - private const val HIDE_FRICTION = 0.1f - } - - private var maximumVelocity = 0f - - private var settleRunnable: SettleRunnable? = null - - var state = State.STATE_HIDDEN - var viewDragHelper: ViewDragHelper? = null - private var ignoreEvents = false - private var lastNestedScrollDy = 0 - private var nestedScrolled = false - private var childHeight = 0 - private var parentWidth = 0 - var parentHeight = 0 - var viewRef: WeakReference? = null - var nestedScrollingChildRef: WeakReference? = null - private var velocityTracker: VelocityTracker? = null - var activePointerId = 0 - private var initialY = 0 - var touchingScrollingChild = false - - init { - val configuration = ViewConfiguration.get(context) - maximumVelocity = configuration.scaledMaximumFlingVelocity.toFloat() - } - - override fun onSaveInstanceState(parent: CoordinatorLayout, child: V): Parcelable { - return SavedState(super.onSaveInstanceState(parent, child), this) - } - - override fun onRestoreInstanceState( - parent: CoordinatorLayout, child: V, state: Parcelable - ) { - val ss = state as SavedState - super.onRestoreInstanceState(parent, child, ss.superState!!) - - // Intermediate states are restored as collapsed state - if (ss.state == State.STATE_DRAGGING || ss.state == State.STATE_SETTLING) { - this.state = State.STATE_EXPANDED - } else { - this.state = ss.state - } - } - - override fun onAttachedToLayoutParams(layoutParams: CoordinatorLayout.LayoutParams) { - super.onAttachedToLayoutParams(layoutParams) - // These may already be null, but just be safe, explicitly assign them. This lets us know the - // first time we layout with this behavior by checking (viewRef == null). - viewRef = null - viewDragHelper = null - } - - override fun onDetachedFromLayoutParams() { - super.onDetachedFromLayoutParams() - // Release references so we don't run unnecessary codepaths while not attached to a view. - viewRef = null - viewDragHelper = null - } - - override fun onLayoutChild(parent: CoordinatorLayout, child: V, layoutDirection: Int): Boolean { - if (ViewCompat.getFitsSystemWindows(parent) && !ViewCompat.getFitsSystemWindows(child as View)) { - child.fitsSystemWindows = true - } - if (viewRef == null) { - viewRef = WeakReference(child) - - if (ViewCompat.getImportantForAccessibility(child as View) - == ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_AUTO - ) { - ViewCompat.setImportantForAccessibility( - child, - ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_YES - ) - } - } - if (viewDragHelper == null) { - viewDragHelper = ViewDragHelper.create(parent, dragCallback) - } - - // First let the parent lay it out - parent.onLayoutChild(child as View, layoutDirection) - // Offset the bottom sheet - parentWidth = parent.width - parentHeight = parent.height - childHeight = child.height - - when (state) { - State.STATE_HIDDEN -> { - Log.d(TAG, "offsetTopAndBottom = $childHeight") - ViewCompat.offsetTopAndBottom(child, -childHeight) - (child.parent as View).findViewById(R.id.fab_home_btn).hide() - } - State.STATE_EXPANDED, State.STATE_DRAGGING, State.STATE_SETTLING -> { - Log.d(TAG, "offsetTopAndBottom = 0") - ViewCompat.offsetTopAndBottom(child, 0) - (child.parent as View).findViewById(R.id.fab_home_btn).show() - } - } - - nestedScrollingChildRef = WeakReference(findScrollingChild(child)) - return true - } - - override fun onInterceptTouchEvent(parent: CoordinatorLayout, child: V, event: MotionEvent): Boolean { - Log.d(TAG, "onInterceptTouchEvent action = ${event.actionMasked}") - if (!child.isShown) { - ignoreEvents = true - return false - } - val action = event.actionMasked - // Record the velocity - if (action == MotionEvent.ACTION_DOWN) { - reset() - } - if (velocityTracker == null) { - velocityTracker = VelocityTracker.obtain() - } - velocityTracker!!.addMovement(event) - when (action) { - MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { - touchingScrollingChild = false - activePointerId = MotionEvent.INVALID_POINTER_ID - // Reset the ignore flag - if (ignoreEvents) { - ignoreEvents = false - return false - } - } - MotionEvent.ACTION_DOWN -> { - val initialX = event.x.toInt() - initialY = event.y.toInt() - // Only intercept nested scrolling events here if the view not being moved by the - // ViewDragHelper. - if (state != State.STATE_SETTLING) { - val scroll = - if (nestedScrollingChildRef != null) nestedScrollingChildRef!!.get() else null - if (scroll != null && parent.isPointInChildBounds(scroll, initialX, initialY)) { - activePointerId = event.getPointerId(event.actionIndex) - touchingScrollingChild = true - } - } - ignoreEvents = ((activePointerId == MotionEvent.INVALID_POINTER_ID) - && !parent.isPointInChildBounds(child, initialX, initialY)) - } - else -> {} - } - if (!ignoreEvents - && viewDragHelper != null && viewDragHelper!!.shouldInterceptTouchEvent(event) - ) { - return true - } - // We have to handle cases that the ViewDragHelper does not capture the bottom sheet because - // it is not the top most view of its parent. This is not necessary when the touch event is - // happening over the scrolling content as nested scrolling logic handles that case. - val scroll = if (nestedScrollingChildRef != null) nestedScrollingChildRef!!.get() else null - return (action == MotionEvent.ACTION_MOVE && scroll != null && !ignoreEvents - && state != State.STATE_DRAGGING && !parent.isPointInChildBounds( - scroll, - event.x.toInt(), - event.y.toInt() - ) - && viewDragHelper != null && abs(initialY - event.y) > viewDragHelper!!.touchSlop) - } - - override fun onTouchEvent(parent: CoordinatorLayout, child: V, event: MotionEvent): Boolean { - Log.d(TAG, "onTouchEvent action = ${event.actionMasked}") - if (!child.isShown) { - return false - } - val action = event.actionMasked - if (state == State.STATE_DRAGGING && action == MotionEvent.ACTION_DOWN) { - return true - } - if (viewDragHelper != null) { - viewDragHelper!!.processTouchEvent(event) - } - // Record the velocity - if (action == MotionEvent.ACTION_DOWN) { - reset() - } - if (velocityTracker == null) { - velocityTracker = VelocityTracker.obtain() - } - velocityTracker!!.addMovement(event) - // The ViewDragHelper tries to capture only the top-most View. We have to explicitly tell it - // to capture the bottom sheet in case it is not captured and the touch slop is passed. - if (viewDragHelper != null && action == MotionEvent.ACTION_MOVE && !ignoreEvents) { - if (abs(initialY - event.y) > viewDragHelper!!.touchSlop) { - viewDragHelper!!.captureChildView(child, event.getPointerId(event.actionIndex)) - } - } - return !ignoreEvents - } - - fun drag(dy: Int) { - this.viewRef?.get()?.let { - Log.d(TAG, "drag dy = $dy end = ${it.bottom - dy}") - if (it.bottom - dy < 0) { - Log.d(TAG, "offsetTopAndBottom = ${it.bottom}") - ViewCompat.offsetTopAndBottom(it, -it.bottom) - } else { - Log.d(TAG, "offsetTopAndBottom = $dy") - ViewCompat.offsetTopAndBottom(it, min(-dy, (it.height - it.bottom))) - } - - setSmartStateInternal(State.STATE_DRAGGING) - - lastNestedScrollDy = dy - nestedScrolled = true - } - } - - fun finishDrag(target: View? = null) { - Log.d(TAG, "finishDrag") - this.viewRef?.get()?.let { child -> - if (child.top == 0) { - setSmartStateInternal(State.STATE_EXPANDED) - return - } - if ( - (target !== null && (nestedScrollingChildRef == null || - target !== nestedScrollingChildRef!!.get())) || - !nestedScrolled - ) { - return - } - val bottom: Int - val targetSmartState: State - if (lastNestedScrollDy >= 0 && shouldHide(child, yVelocity)) { - bottom = 0 - targetSmartState = State.STATE_HIDDEN - (child.parent as View).findViewById(R.id.fab_home_btn).hide() - } else { - bottom = childHeight - targetSmartState = State.STATE_EXPANDED - (child.parent as View).findViewById(R.id.fab_home_btn).show() - } - Log.d(TAG, "startSettlingAnimation 1") - - startSettlingAnimation( - child, - targetSmartState, - bottom - childHeight, - ) - nestedScrolled = false - } - } - - fun setSmartState(state: State) { - this.viewRef?.get()?.let { child -> - setSmartStateInternal(state) - - startSettlingAnimation( - child, - state, - -childHeight, - ) - nestedScrolled = false - } - } - - override fun onStartNestedScroll( - coordinatorLayout: CoordinatorLayout, - child: V, - directTargetChild: View, - target: View, - axes: Int, - type: Int - ): Boolean { - lastNestedScrollDy = 0 - nestedScrolled = false - return axes and ViewCompat.SCROLL_AXIS_VERTICAL != 0 - } - - override fun onNestedPreScroll( - coordinatorLayout: CoordinatorLayout, - child: V, - target: View, - dx: Int, - dy: Int, - consumed: IntArray, - type: Int - ) { - if (type == ViewCompat.TYPE_NON_TOUCH) { - Log.d(TAG, "onNestedPreScroll skip non touch") - // Ignore fling here. The ViewDragHelper handles it. - return - } - val scrollingChild = - if (nestedScrollingChildRef != null) nestedScrollingChildRef!!.get() else null - if (target !== scrollingChild) { - Log.d(TAG, "onNestedPreScroll skip wrong target") - return - } - val currentBottom = child.bottom - val newBottom = currentBottom - dy - Log.d(TAG, "onNestedPreScroll dy = $dy currentBottom = $currentBottom") - if (dy > 0) { // Upward - Collapsing the top sheet! - Log.d(TAG, "Upward newBottom = $newBottom childHeight = $childHeight") - if (!target.canScrollVertically(1)) { - consumed[1] = dy - Log.d(TAG, "offsetTopAndBottom = $dy") - ViewCompat.offsetTopAndBottom(child, -dy) - setSmartStateInternal(State.STATE_DRAGGING) - } - } else if (dy < 0) { // Downward - Log.d(TAG, "Downward newBottom = $newBottom `childHeight` = $childHeight") - if (newBottom > childHeight) { - consumed[1] = currentBottom - childHeight - Log.d(TAG, "offsetTopAndBottom = ${consumed[1]}") - ViewCompat.offsetTopAndBottom(child, -consumed[1]) - setSmartStateInternal(State.STATE_EXPANDED) - } else { - consumed[1] = dy - Log.d(TAG, "offsetTopAndBottom = $dy") - ViewCompat.offsetTopAndBottom(child, -dy) - setSmartStateInternal(State.STATE_DRAGGING) - } - } - - lastNestedScrollDy = dy - nestedScrolled = true - } - - override fun onStopNestedScroll( - coordinatorLayout: CoordinatorLayout, - child: V, - target: View, - type: Int - ) { - Log.d(TAG, "onStopNestedScroll") - - finishDrag(target) - } - - override fun onNestedPreFling( - coordinatorLayout: CoordinatorLayout, - child: V, - target: View, - velocityX: Float, - velocityY: Float - ): Boolean { - return if (nestedScrollingChildRef != null) { - (target === nestedScrollingChildRef!!.get() - && (state != State.STATE_EXPANDED - || super.onNestedPreFling( - coordinatorLayout, - child, - target, - velocityX, - velocityY - ))) - } else { - false - } - } - - fun setSmartStateInternal(state: State) { - if (this.state == state) { - return - } - this.state = state - if (viewRef == null) { - return - } - - viewRef!!.get() ?: return - } - - private fun reset() { - activePointerId = ViewDragHelper.INVALID_POINTER - if (velocityTracker != null) { - velocityTracker!!.recycle() - velocityTracker = null - } - } - - private fun shouldHide(child: View, yvel: Float): Boolean { - if (child.bottom > childHeight) { - // It should not hide, but collapse. - return false - } - val newBottom = child.top + yvel * HIDE_FRICTION - return abs(newBottom - childHeight) / childHeight.toFloat() > HIDE_THRESHOLD - } - - @VisibleForTesting - fun findScrollingChild(view: View?): View? { - if (ViewCompat.isNestedScrollingEnabled(view!!)) { - return view - } - if (view is ViewGroup) { - var i = 0 - val count = view.childCount - while (i < count) { - val scrollingChild = findScrollingChild(view.getChildAt(i)) - if (scrollingChild != null) { - return scrollingChild - } - i++ - } - } - return null - } - - private val yVelocity: Float - get() { - if (velocityTracker == null) { - return 0F - } - velocityTracker!!.computeCurrentVelocity(1000, maximumVelocity) - return velocityTracker!!.getYVelocity(activePointerId) - } - - private fun startSettlingAnimation( - child: View, - state: State, - top: Int, - ) { - Log.d(TAG, "startSettlingAnimation... state = $state top = $top") - val startedSettling = viewDragHelper!!.smoothSlideViewTo(child, child.left, top) - - if (startedSettling) { - setSmartStateInternal(State.STATE_SETTLING) - if (settleRunnable == null) { - // If the singleton SettleRunnable instance has not been instantiated, create it. - settleRunnable = SettleRunnable(child, state) - } - // If the SettleRunnable has not been posted, post it with the correct state. - if (!settleRunnable!!.isPosted) { - settleRunnable!!.targetSmartState = state - ViewCompat.postOnAnimation(child, settleRunnable!!) - settleRunnable!!.isPosted = true - } else { - // Otherwise, if it has been posted, just update the target state. - settleRunnable!!.targetSmartState = state - } - } else { - setSmartStateInternal(state) - } - } - - private val dragCallback: ViewDragHelper.Callback = object : ViewDragHelper.Callback() { - override fun tryCaptureView(child: View, pointerId: Int): Boolean { - Log.d(TAG, "tryCaptureView... touchingScrollingChild = $touchingScrollingChild") - if (state == State.STATE_DRAGGING) { - return false - } - if (touchingScrollingChild) { - return false - } - if (state == State.STATE_EXPANDED && activePointerId == pointerId) { - val scroll = if (nestedScrollingChildRef != null) nestedScrollingChildRef!!.get() else null - if (scroll != null && scroll.canScrollVertically(-1)) { - // Let the content scroll up - return false - } - } - - return viewRef != null && viewRef!!.get() === child - } - - override fun onViewDragStateChanged(state: Int) { - if (state == ViewDragHelper.STATE_DRAGGING) { - setSmartStateInternal(State.STATE_DRAGGING) - } - } - - override fun clampViewPositionVertical(child: View, top: Int, dy: Int): Int { - return MathUtils.clamp( - top, - childHeight, - parentHeight, - ) - } - - override fun clampViewPositionHorizontal(child: View, left: Int, dx: Int): Int { - return child.left - } - - override fun getViewVerticalDragRange(child: View): Int { - return parentHeight - } - } - - private inner class SettleRunnable( - private val view: View, - var targetSmartState: State - ) : Runnable { - var isPosted = false - - override fun run() { - Log.d(TAG, "targetSmartState = $targetSmartState") - if (viewDragHelper != null && viewDragHelper!!.continueSettling(true)) { - ViewCompat.postOnAnimation(view, this) - } else { - setSmartStateInternal(targetSmartState) - } - isPosted = false - } - } - - /** State persisted across instances */ - protected class SavedState(superState: Parcelable?, behavior: TopSheetBehavior<*>) : - AbsSavedState(superState!!) { - val state: State = behavior.state - - override fun writeToParcel(out: Parcel, flags: Int) { - super.writeToParcel(out, flags) - out.writeInt(state.ordinal) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/danilkinkin/buckwheat/widgets/topsheet/TopSheetUtils.kt b/app/src/main/java/com/danilkinkin/buckwheat/widgets/topsheet/TopSheetUtils.kt deleted file mode 100644 index d7498f8..0000000 --- a/app/src/main/java/com/danilkinkin/buckwheat/widgets/topsheet/TopSheetUtils.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.danilkinkin.buckwheat.widgets.topsheet - -import android.content.Context -import android.content.res.ColorStateList -import android.content.res.TypedArray -import androidx.annotation.StyleableRes -import androidx.appcompat.content.res.AppCompatResources - -object TopSheetUtils { - /** - * Returns the [ColorStateList] from the given [TypedArray] attributes. The resource - * can include themeable attributes, regardless of API level. - */ - fun getColorStateList( - context: Context, attributes: TypedArray, @StyleableRes index: Int - ): ColorStateList? { - if (attributes.hasValue(index)) { - val resourceId = attributes.getResourceId(index, 0) - if (resourceId != 0) { - val value = AppCompatResources.getColorStateList(context, resourceId) - if (value != null) { - return value - } - } - } - - return attributes.getColorStateList(index) - } -} \ No newline at end of file diff --git a/app/src/main/res/color/divider.xml b/app/src/main/res/color/divider.xml deleted file mode 100644 index 737b3e1..0000000 --- a/app/src/main/res/color/divider.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml deleted file mode 100644 index 2b068d1..0000000 --- a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/bottom_sheet_background.xml b/app/src/main/res/drawable/bottom_sheet_background.xml deleted file mode 100644 index 1ec44f4..0000000 --- a/app/src/main/res/drawable/bottom_sheet_background.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_empty.xml b/app/src/main/res/drawable/ic_empty.xml deleted file mode 100644 index e621a81..0000000 --- a/app/src/main/res/drawable/ic_empty.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..5968f74 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/radio.xml b/app/src/main/res/drawable/radio.xml deleted file mode 100644 index aef91be..0000000 --- a/app/src/main/res/drawable/radio.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - diff --git a/app/src/main/res/drawable/radio_checked.xml b/app/src/main/res/drawable/radio_checked.xml deleted file mode 100644 index 09b4610..0000000 --- a/app/src/main/res/drawable/radio_checked.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/scroll_handler_background.xml b/app/src/main/res/drawable/scroll_handler_background.xml deleted file mode 100644 index 3b4fb2f..0000000 --- a/app/src/main/res/drawable/scroll_handler_background.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml deleted file mode 100644 index e83bf42..0000000 --- a/app/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,46 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_custom_currency.xml b/app/src/main/res/layout/dialog_custom_currency.xml deleted file mode 100644 index 3b12eb4..0000000 --- a/app/src/main/res/layout/dialog_custom_currency.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_date_range_picker.xml b/app/src/main/res/layout/dialog_date_range_picker.xml deleted file mode 100644 index b64f2f0..0000000 --- a/app/src/main/res/layout/dialog_date_range_picker.xml +++ /dev/null @@ -1,89 +0,0 @@ - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_editor.xml b/app/src/main/res/layout/fragment_editor.xml deleted file mode 100644 index 66b0f91..0000000 --- a/app/src/main/res/layout/fragment_editor.xml +++ /dev/null @@ -1,205 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_keyboard.xml b/app/src/main/res/layout/fragment_keyboard.xml deleted file mode 100644 index bc545a7..0000000 --- a/app/src/main/res/layout/fragment_keyboard.xml +++ /dev/null @@ -1,290 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/item_spent.xml b/app/src/main/res/layout/item_spent.xml deleted file mode 100644 index 40f2ef7..0000000 --- a/app/src/main/res/layout/item_spent.xml +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/item_top.xml b/app/src/main/res/layout/item_top.xml deleted file mode 100644 index e6c9435..0000000 --- a/app/src/main/res/layout/item_top.xml +++ /dev/null @@ -1,112 +0,0 @@ - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/list_item_checkable.xml b/app/src/main/res/layout/list_item_checkable.xml deleted file mode 100644 index cf09a40..0000000 --- a/app/src/main/res/layout/list_item_checkable.xml +++ /dev/null @@ -1,41 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/modal_bottom_sheet_new_day.xml b/app/src/main/res/layout/modal_bottom_sheet_new_day.xml deleted file mode 100644 index 60a5dd9..0000000 --- a/app/src/main/res/layout/modal_bottom_sheet_new_day.xml +++ /dev/null @@ -1,206 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/modal_bottom_sheet_settings.xml b/app/src/main/res/layout/modal_bottom_sheet_settings.xml deleted file mode 100644 index b10d03c..0000000 --- a/app/src/main/res/layout/modal_bottom_sheet_settings.xml +++ /dev/null @@ -1,260 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/modal_bottom_sheet_wallet.xml b/app/src/main/res/layout/modal_bottom_sheet_wallet.xml deleted file mode 100644 index 002d8e8..0000000 --- a/app/src/main/res/layout/modal_bottom_sheet_wallet.xml +++ /dev/null @@ -1,349 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/menu_main.xml b/app/src/main/res/menu/menu_main.xml deleted file mode 100644 index 0a359bd..0000000 --- a/app/src/main/res/menu/menu_main.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - \ No newline at end of file diff --git a/app/src/main/res/values-land/dimens.xml b/app/src/main/res/values-land/dimens.xml deleted file mode 100644 index 22d7f00..0000000 --- a/app/src/main/res/values-land/dimens.xml +++ /dev/null @@ -1,3 +0,0 @@ - - 48dp - \ No newline at end of file diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml deleted file mode 100644 index 9a5fe69..0000000 --- a/app/src/main/res/values-night/themes.xml +++ /dev/null @@ -1,60 +0,0 @@ - - - - diff --git a/app/src/main/res/values-w1240dp/dimens.xml b/app/src/main/res/values-w1240dp/dimens.xml deleted file mode 100644 index d73f4a3..0000000 --- a/app/src/main/res/values-w1240dp/dimens.xml +++ /dev/null @@ -1,3 +0,0 @@ - - 200dp - \ No newline at end of file diff --git a/app/src/main/res/values-w600dp/dimens.xml b/app/src/main/res/values-w600dp/dimens.xml deleted file mode 100644 index 22d7f00..0000000 --- a/app/src/main/res/values-w600dp/dimens.xml +++ /dev/null @@ -1,3 +0,0 @@ - - 48dp - \ No newline at end of file diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml deleted file mode 100644 index 6ffcdc5..0000000 --- a/app/src/main/res/values/attrs.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index c51f064..e98a748 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,64 +1,22 @@ - + + - #ff847c - #A13D39 - #FFFFFF - #FFDAD7 - #410004 - #755B00 - #FFFFFF - #FFE08E - #241A00 - #5B6400 - #FFFFFF - #DEEB77 - #1A1E00 - #BA1A1A - #FFDAD6 - #FFFFFF - #410002 - #FFFBFF - #201A19 - #FFFBFF - #201A19 - #F5DDDB - #534342 - #857371 - #FBEEEC - #362F2E - #FFB3AD - #000000 - #A13D39 - #A13D39 - #FFB3AD - #630E10 - #822624 - #FFDAD7 - #F2C000 - #3D2E00 - #584400 - #FFE08E - #C2CE5E - #2E3300 - #444B00 - #DEEB77 - #FFB4AB - #93000A - #690005 - #FFDAD6 - #201A19 - #EDE0DE - #201A19 - #EDE0DE - #534342 - #D8C2BF - #A08C8A - #201A19 - #EDE0DE - #A13D39 - #000000 - #FFB3AD - #FFB3AD - - #ed675c - + #5D1049 + #3D0A2C + #E30425 + \ No newline at end of file diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml deleted file mode 100644 index 125df87..0000000 --- a/app/src/main/res/values/dimens.xml +++ /dev/null @@ -1,3 +0,0 @@ - - 16dp - \ No newline at end of file diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..38990cf --- /dev/null +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,20 @@ + + + + #5D1049 + \ No newline at end of file diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml new file mode 100644 index 0000000..e4be693 --- /dev/null +++ b/app/src/main/res/values/ids.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/values/shape.xml b/app/src/main/res/values/shape.xml deleted file mode 100644 index 859aa69..0000000 --- a/app/src/main/res/values/shape.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/values/text.xml b/app/src/main/res/values/text.xml deleted file mode 100644 index 669c99e..0000000 --- a/app/src/main/res/values/text.xml +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 95bd4f8..43f1422 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,140 +1,5 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - +