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