feat: draft migrate

This commit is contained in:
danilkinkin 2022-09-04 17:27:45 +04:00
parent 9ae12c2283
commit 9b6cc8add8
131 changed files with 6178 additions and 5086 deletions

View file

@ -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'
}

131
app/build.gradle.kts Normal file
View file

@ -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)
}

View file

@ -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
-renamesourcefileattribute SourceFile
# Repackage classes into the top-level.
-repackageclasses

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="com.danilkinkin.buckwheat">
<application>
<activity
android:name="androidx.test.core.app.InstrumentationActivityInvoker$BootstrapActivity"
android:exported="true"
tools:node="merge">
<intent-filter tools:node="removeAll" />
</activity>
<activity
android:name="androidx.test.core.app.InstrumentationActivityInvoker$EmptyActivity"
android:exported="true"
tools:node="merge">
<intent-filter tools:node="removeAll" />
</activity>
</application>
</manifest>

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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<MainActivity>()
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)
)

View file

@ -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)
}
}

View file

@ -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<MainActivity>()
@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()
}
}

View file

@ -4,21 +4,20 @@
package="com.danilkinkin.buckwheat">
<application
android:name=".BuckwheatApplication"
android:name=".Application"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Buckwheat"
tools:targetApi="33">
android:theme="@style/Theme.BuckwheatTheme">
<profileable
android:shell="true"
tools:targetApi="q" />
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.Buckwheat">
android:name="com.danilkinkin.buckwheat.home.MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

View file

@ -0,0 +1,7 @@
package com.danilkinkin.buckwheat
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class Application : Application()

View file

@ -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)
}
}

View file

@ -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()
}
}

View file

@ -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()
}
}
}

View file

@ -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<LinearLayout>(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()
}
}
}
}

View file

@ -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
}
}

View file

@ -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<LinearLayout>(R.id.content).setPadding(0, 0, 0, insets.bottom)
WindowInsetsCompat.CONSUMED
}
build()
}
private fun reCalcBudget() {
val days = countDays(dateToValue)
finishDateBtn.findViewById<TextView>(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<ImageView>(R.id.currency_from_list_icon)
val fromListLabel = currencyFromListBtn.findViewById<TextView>(R.id.currency_from_list_label)
val customIcon = currencyCustomBtn.findViewById<ImageView>(R.id.currency_custom_icon)
val customLabel = currencyCustomBtn.findViewById<TextView>(R.id.currency_custom_label)
val noneIcon = currencyNoneBtn.findViewById<ImageView>(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<MaterialTextView>(R.id.start_date)
val finishDate = view.findViewById<MaterialTextView>(R.id.finish_date)
val calendar = view.findViewById<CalendarView>(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<TextInputEditText>(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()
}
}
}

View file

@ -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<Currency> {
val currencies = Currency.getAvailableCurrencies().toMutableList()
currencies.sortBy { it.displayName.uppercase() }
return currencies
}
class CurrencyAdapter (
context: Context,
layoutId: Int,
private val items: MutableList<Currency>,
) : ArrayAdapter<Currency>(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<TextView>(android.R.id.text1)
cityAutoCompleteView.text = currency.displayName
} catch (e: Exception) {
e.printStackTrace()
}
return view!!
}
}

View file

@ -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<Spent, SpendsAdapter.DrawViewHolder>(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<Spent>() {
override fun areItemsTheSame(oldItem: Spent, newItem: Spent): Boolean {
return oldItem == newItem
}
override fun areContentsTheSame(oldItem: Spent, newItem: Spent): Boolean {
return oldItem.uid == newItem.uid
}
}

View file

@ -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<TopAdapter.ViewHolder>() {
class ViewHolder(view: View, private val model: SpentViewModel) : RecyclerView.ViewHolder(view) {
init {
val topBarHeight = getStatusBarHeight(view)
val helperView = view.findViewById<View>(R.id.root)
helperView.setPadding(
0,
topBarHeight,
0,
0,
)
update()
}
fun update() {
itemView.findViewById<MaterialTextView>(R.id.value).text = prettyCandyCanes(model.budget.value!!)
itemView.findViewById<MaterialTextView>(R.id.start_date).text = prettyDate(
model.startDate,
showTime = false,
forceShowDate = true,
)
itemView.findViewById<MaterialTextView>(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
}

View file

@ -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
}
}

View file

@ -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 = {},
)
}
}

View file

@ -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()
}
}
}
}

View file

@ -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 = {},
)
}
}

View file

@ -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",
)
}
}

View file

@ -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)
)
}

View file

@ -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<Float> = SwipeableDefaults.AnimationSpec,
internal val isSkipHalfExpanded: Boolean,
confirmStateChange: (ModalBottomSheetValue) -> Boolean = { true }
) : SwipeableState<ModalBottomSheetValue>(
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<Float> = 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<Float>,
skipHalfExpanded: Boolean,
confirmStateChange: (ModalBottomSheetValue) -> Boolean
): Saver<ModalBottomSheetState, *> = Saver(
save = { it.currentValue },
restore = {
ModalBottomSheetState(
initialValue = it,
animationSpec = animationSpec,
isSkipHalfExpanded = skipHalfExpanded,
confirmStateChange = confirmStateChange
)
}
)
}
}
@Composable
@ExperimentalMaterialApi
fun rememberModalBottomSheetState(
initialValue: ModalBottomSheetValue,
animationSpec: AnimationSpec<Float> = 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<Float> = 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<Float?>(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<Float?>,
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)
}

View file

@ -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",
)
}
}

View file

@ -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) },
)
}
}

View file

@ -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<Boolean>("DayStatusKey")
var SemanticsPropertyReceiver.dayStatusProperty by DayStatusKey

View file

@ -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)),
)
}
}

View file

@ -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

View file

@ -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<Offset, Float> {
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
}

View file

@ -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<Month>
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<Month>()
var startYearMonth = YearMonth.from(calendarStartDate)
for (numberMonth in 0..periodBetweenCalendarStartEnd.toTotalMonths()) {
val numberWeeks = startYearMonth.getNumberWeeks()
val listWeekItems = mutableListOf<Week>()
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
}
}

View file

@ -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")
}
}

View file

@ -0,0 +1,8 @@
package com.danilkinkin.buckwheat.calendar.model
import java.time.YearMonth
data class Month(
val yearMonth: YearMonth,
val weeks: List<Week>
)

View file

@ -0,0 +1,8 @@
package com.danilkinkin.buckwheat.calendar.model
import java.time.YearMonth
data class Week(
val number: Int,
val yearMonth: YearMonth
)

View file

@ -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<Boolean> = MutableLiveData(try {
storage.get("isDebug").value.toBoolean()
} catch (e: Exception) {
false
})
fun setIsDebug(debug: Boolean) {
storage.set(Storage("isDebug", debug.toString()))
isDebug.value = debug
}
}

View file

@ -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<Stage> = MutableLiveData(Stage.IDLE)
var lastRemoveSpent: MutableLiveData<Spent> = MutableLiveData(null)
var budget: MutableLiveData<BigDecimal> = 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())
}
}
}
}

View file

@ -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<Preferences>
) : 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()
}
}
}
}

View file

@ -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 {

View file

@ -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 {

View file

@ -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
}
}

View file

@ -1,4 +1,4 @@
package com.danilkinkin.buckwheat.entities
package com.danilkinkin.buckwheat.data.entities
import androidx.room.ColumnInfo
import androidx.room.Entity

View file

@ -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,
)
}
}
}
}

View file

@ -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()
}

View file

@ -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

View file

@ -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
}

View file

@ -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<AnimState?>(null) }
var currAnimator by remember { mutableStateOf<ValueAnimator?>(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()
}
}

View file

@ -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),
)
}
}

View file

@ -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()
}

View file

@ -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()
}
}
}
}

View file

@ -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<Date?>(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()
}
}

View file

@ -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()
}
}

View file

@ -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"
)
}
}

View file

@ -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()
}
}

View file

@ -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()
}
}

View file

@ -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)
)
}
}

View file

@ -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)
)
}
}

View file

@ -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<Float> = SwipeableDefaults.AnimationSpec,
internal val isSkipHalfExpanded: Boolean,
confirmStateChange: (TopSheetValue) -> Boolean = { true }
) : SwipeableState<TopSheetValue>(
initialValue = initialValue,
animationSpec = animationSpec,
confirmStateChange = confirmStateChange
) {
val isExpand: Boolean
get() = currentValue != TopSheetValue.Expanded
constructor(
initialValue: TopSheetValue,
animationSpec: AnimationSpec<Float> = 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<Float>,
skipHalfExpanded: Boolean,
confirmStateChange: (TopSheetValue) -> Boolean
): Saver<TopSheetState, *> = Saver(
save = { it.currentValue },
restore = {
TopSheetState(
initialValue = it,
animationSpec = animationSpec,
isSkipHalfExpanded = skipHalfExpanded,
confirmStateChange = confirmStateChange
)
}
)
}
}
@Composable
@ExperimentalMaterialApi
fun rememberTopSheetState(
initialValue: TopSheetValue,
animationSpec: AnimationSpec<Float> = 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<Float> = 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<Float?>(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<Float?>
): 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)
}

View file

@ -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)

View file

@ -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),
)

View file

@ -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
)
}

View file

@ -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)
}
}
}
}
}

View file

@ -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<T>(
initialValue: T,
internal val animationSpec: AnimationSpec<Float> = 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<Float> get() = offsetState
/**
* The amount by which the [swipeable] has been swiped past its bounds.
*/
val overflow: State<Float> 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<Float?>(null)
internal var anchors by mutableStateOf(emptyMap<Float, T>())
private val latestNonEmptyAnchorsFlow: Flow<Map<Float, T>> =
snapshotFlow { anchors }
.filter { it.isNotEmpty() }
.take(1)
internal var minBound = Float.NEGATIVE_INFINITY
internal var maxBound = Float.POSITIVE_INFINITY
internal fun ensureInit(newAnchors: Map<Float, T>) {
if (anchors.isEmpty()) {
// need to do initial synchronization synchronously :(
val initialOffset = newAnchors.getOffset(currentValue)
requireNotNull(initialOffset) {
"The initial value must have an associated anchor."
}
offsetState.value = initialOffset
absoluteOffset.value = initialOffset
}
}
internal suspend fun processNewAnchors(
oldAnchors: Map<Float, T>,
newAnchors: Map<Float, T>
) {
if (oldAnchors.isEmpty()) {
// If this is the first time that we receive anchors, then we need to initialise
// the state so we snap to the offset associated to the initial value.
minBound = newAnchors.keys.minOrNull()!!
maxBound = newAnchors.keys.maxOrNull()!!
val initialOffset = newAnchors.getOffset(currentValue)
requireNotNull(initialOffset) {
"The initial value must have an associated anchor."
}
snapInternalToOffset(initialOffset)
} else if (newAnchors != oldAnchors) {
// If we have received new anchors, then the offset of the current value might
// have changed, so we need to animate to the new offset. If the current value
// has been removed from the anchors then we animate to the closest anchor
// instead. Note that this stops any ongoing animation.
minBound = Float.NEGATIVE_INFINITY
maxBound = Float.POSITIVE_INFINITY
val animationTargetValue = animationTarget.value
// if we're in the animation already, let's find it a new home
val targetOffset = if (animationTargetValue != null) {
// first, try to map old state to the new state
val oldState = oldAnchors[animationTargetValue]
val newState = newAnchors.getOffset(oldState)
// return new state if exists, or find the closes one among new anchors
newState ?: newAnchors.keys.minByOrNull { abs(it - animationTargetValue) }!!
} else {
// we're not animating, proceed by finding the new anchors for an old value
val actualOldValue = oldAnchors[offset.value]
val value = if (actualOldValue == currentValue) currentValue else actualOldValue
newAnchors.getOffset(value) ?: newAnchors
.keys.minByOrNull { abs(it - offset.value) }!!
}
try {
animateInternalToOffset(targetOffset, animationSpec)
} catch (c: CancellationException) {
// If the animation was interrupted for any reason, snap as a last resort.
snapInternalToOffset(targetOffset)
} finally {
currentValue = newAnchors.getValue(targetOffset)
minBound = newAnchors.keys.minOrNull()!!
maxBound = newAnchors.keys.maxOrNull()!!
}
}
}
internal var thresholds: (Float, Float) -> Float by mutableStateOf({ _, _ -> 0f })
internal var velocityThreshold by mutableStateOf(0f)
internal var resistance: ResistanceConfig? by mutableStateOf(null)
internal val draggableState = DraggableState {
val newAbsolute = absoluteOffset.value + it
val clamped = newAbsolute.coerceIn(minBound, maxBound)
val overflow = newAbsolute - clamped
val resistanceDelta = resistance?.computeResistance(overflow) ?: 0f
offsetState.value = clamped + resistanceDelta
overflowState.value = overflow
absoluteOffset.value = newAbsolute
}
private suspend fun snapInternalToOffset(target: Float) {
draggableState.drag {
dragBy(target - absoluteOffset.value)
}
}
private suspend fun animateInternalToOffset(target: Float, spec: AnimationSpec<Float>) {
draggableState.drag {
var prevValue = absoluteOffset.value
animationTarget.value = target
isAnimationRunning = true
try {
Animatable(prevValue).animateTo(target, spec) {
dragBy(this.value - prevValue)
prevValue = this.value
}
} finally {
animationTarget.value = null
isAnimationRunning = false
}
}
}
/**
* The target value of the state.
*
* If a swipe is in progress, this is the value that the [swipeable] would animate to if the
* swipe finished. If an animation is running, this is the target value of that animation.
* Finally, if no swipe or animation is in progress, this is the same as the [currentValue].
*/
@ExperimentalMaterialApi
val targetValue: T
get() {
// TODO(calintat): Track current velocity (b/149549482) and use that here.
val target = animationTarget.value ?: computeTarget(
offset = offset.value,
lastValue = anchors.getOffset(currentValue) ?: offset.value,
anchors = anchors.keys,
thresholds = thresholds,
velocity = 0f,
velocityThreshold = Float.POSITIVE_INFINITY
)
return anchors[target] ?: currentValue
}
/**
* Information about the ongoing swipe or animation, if any. See [SwipeProgress] for details.
*
* If no swipe or animation is in progress, this returns `SwipeProgress(value, value, 1f)`.
*/
@ExperimentalMaterialApi
val progress: SwipeProgress<T>
get() {
val bounds = findBounds(offset.value, anchors.keys)
val from: T
val to: T
val fraction: Float
when (bounds.size) {
0 -> {
from = currentValue
to = currentValue
fraction = 1f
}
1 -> {
from = anchors.getValue(bounds[0])
to = anchors.getValue(bounds[0])
fraction = 1f
}
else -> {
val (a, b) =
if (direction > 0f) {
bounds[0] to bounds[1]
} else {
bounds[1] to bounds[0]
}
from = anchors.getValue(a)
to = anchors.getValue(b)
fraction = (offset.value - a) / (b - a)
}
}
return SwipeProgress(from, to, fraction)
}
/**
* The direction in which the [swipeable] is moving, relative to the current [currentValue].
*
* This will be either 1f if it is is moving from left to right or top to bottom, -1f if it is
* moving from right to left or bottom to top, or 0f if no swipe or animation is in progress.
*/
@ExperimentalMaterialApi
val direction: Float
get() = anchors.getOffset(currentValue)?.let { sign(offset.value - it) } ?: 0f
/**
* Set the state without any animation and suspend until it's set
*
* @param targetValue The new target value to set [currentValue] to.
*/
@ExperimentalMaterialApi
suspend fun snapTo(targetValue: T) {
latestNonEmptyAnchorsFlow.collect { anchors ->
val targetOffset = anchors.getOffset(targetValue)
requireNotNull(targetOffset) {
"The target value must have an associated anchor."
}
snapInternalToOffset(targetOffset)
currentValue = targetValue
}
}
/**
* Set the state to the target value by starting an animation.
*
* @param targetValue The new value to animate to.
* @param anim The animation that will be used to animate to the new value.
*/
@ExperimentalMaterialApi
suspend fun animateTo(targetValue: T, anim: AnimationSpec<Float> = animationSpec) {
latestNonEmptyAnchorsFlow.collect { anchors ->
try {
val targetOffset = anchors.getOffset(targetValue)
requireNotNull(targetOffset) {
"The target value must have an associated anchor."
}
animateInternalToOffset(targetOffset, anim)
} finally {
val endOffset = absoluteOffset.value
val endValue = anchors
// fighting rounding error once again, anchor should be as close as 0.5 pixels
.filterKeys { anchorOffset -> abs(anchorOffset - endOffset) < 0.5f }
.values.firstOrNull() ?: currentValue
currentValue = endValue
}
}
}
/**
* Perform fling with settling to one of the anchors which is determined by the given
* [velocity]. Fling with settling [swipeable] will always consume all the velocity provided
* since it will settle at the anchor.
*
* In general cases, [swipeable] flings by itself when being swiped. This method is to be
* used for nested scroll logic that wraps the [swipeable]. In nested scroll developer may
* want to trigger settling fling when the child scroll container reaches the bound.
*
* @param velocity velocity to fling and settle with
*
* @return the reason fling ended
*/
suspend fun performFling(velocity: Float) {
latestNonEmptyAnchorsFlow.collect { anchors ->
val lastAnchor = anchors.getOffset(currentValue)!!
val targetValue = computeTarget(
offset = offset.value,
lastValue = lastAnchor,
anchors = anchors.keys,
thresholds = thresholds,
velocity = velocity,
velocityThreshold = velocityThreshold
)
val targetState = anchors[targetValue]
if (targetState != null && confirmStateChange(targetState)) animateTo(targetState)
// If the user vetoed the state change, rollback to the previous state.
else animateInternalToOffset(lastAnchor, animationSpec)
}
}
/**
* Force [swipeable] to consume drag delta provided from outside of the regular [swipeable]
* gesture flow.
*
* Note: This method performs generic drag and it won't settle to any particular anchor, *
* leaving swipeable in between anchors. When done dragging, [performFling] must be
* called as well to ensure swipeable will settle at the anchor.
*
* In general cases, [swipeable] drags by itself when being swiped. This method is to be
* used for nested scroll logic that wraps the [swipeable]. In nested scroll developer may
* want to force drag when the child scroll container reaches the bound.
*
* @param delta delta in pixels to drag by
*
* @return the amount of [delta] consumed
*/
fun performDrag(delta: Float): Float {
val potentiallyConsumed = absoluteOffset.value + delta
val clamped = potentiallyConsumed.coerceIn(minBound, maxBound)
val deltaToConsume = clamped - absoluteOffset.value
if (abs(deltaToConsume) > 0) {
draggableState.dispatchRawDelta(deltaToConsume)
}
return deltaToConsume
}
companion object {
/**
* The default [Saver] implementation for [SwipeableState].
*/
fun <T : Any> Saver(
animationSpec: AnimationSpec<Float>,
confirmStateChange: (T) -> Boolean
) = Saver<SwipeableState<T>, T>(
save = { it.currentValue },
restore = { SwipeableState(it, animationSpec, confirmStateChange) }
)
}
}
/**
* Collects information about the ongoing swipe or animation in [swipeable].
*
* To access this information, use [SwipeableState.progress].
*
* @param from The state corresponding to the anchor we are moving away from.
* @param to The state corresponding to the anchor we are moving towards.
* @param fraction The fraction that the current position represents between [from] and [to].
* Must be between `0` and `1`.
*/
@Immutable
@ExperimentalMaterialApi
class SwipeProgress<T>(
val from: T,
val to: T,
/*@FloatRange(from = 0.0, to = 1.0)*/
val fraction: Float
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is SwipeProgress<*>) return false
if (from != other.from) return false
if (to != other.to) return false
if (fraction != other.fraction) return false
return true
}
override fun hashCode(): Int {
var result = from?.hashCode() ?: 0
result = 31 * result + (to?.hashCode() ?: 0)
result = 31 * result + fraction.hashCode()
return result
}
override fun toString(): String {
return "SwipeProgress(from=$from, to=$to, fraction=$fraction)"
}
}
/**
* Create and [remember] a [SwipeableState] with the default animation clock.
*
* @param initialValue The initial value of the state.
* @param animationSpec The default animation that will be used to animate to a new state.
* @param confirmStateChange Optional callback invoked to confirm or veto a pending state change.
*/
@Composable
@ExperimentalMaterialApi
fun <T : Any> rememberSwipeableState(
initialValue: T,
animationSpec: AnimationSpec<Float> = AnimationSpec,
confirmStateChange: (newValue: T) -> Boolean = { true }
): SwipeableState<T> {
return rememberSaveable(
saver = SwipeableState.Saver(
animationSpec = animationSpec,
confirmStateChange = confirmStateChange
)
) {
SwipeableState(
initialValue = initialValue,
animationSpec = animationSpec,
confirmStateChange = confirmStateChange
)
}
}
/**
* Create and [remember] a [SwipeableState] which is kept in sync with another state, i.e.:
* 1. Whenever the [value] changes, the [SwipeableState] will be animated to that new value.
* 2. Whenever the value of the [SwipeableState] changes (e.g. after a swipe), the owner of the
* [value] will be notified to update their state to the new value of the [SwipeableState] by
* invoking [onValueChange]. If the owner does not update their state to the provided value for
* some reason, then the [SwipeableState] will perform a rollback to the previous, correct value.
*/
@Composable
@ExperimentalMaterialApi
internal fun <T : Any> rememberSwipeableStateFor(
value: T,
onValueChange: (T) -> Unit,
animationSpec: AnimationSpec<Float> = AnimationSpec
): SwipeableState<T> {
val swipeableState = remember {
SwipeableState(
initialValue = value,
animationSpec = animationSpec,
confirmStateChange = { true }
)
}
val forceAnimationCheck = remember { mutableStateOf(false) }
LaunchedEffect(value, forceAnimationCheck.value) {
if (value != swipeableState.currentValue) {
swipeableState.animateTo(value)
}
}
DisposableEffect(swipeableState.currentValue) {
if (value != swipeableState.currentValue) {
onValueChange(swipeableState.currentValue)
forceAnimationCheck.value = !forceAnimationCheck.value
}
onDispose { }
}
return swipeableState
}
/**
* Enable swipe gestures between a set of predefined states.
*
* To use this, you must provide a map of anchors (in pixels) to states (of type [T]).
* Note that this map cannot be empty and cannot have two anchors mapped to the same state.
*
* When a swipe is detected, the offset of the [SwipeableState] will be updated with the swipe
* delta. You should use this offset to move your content accordingly (see `Modifier.offsetPx`).
* When the swipe ends, the offset will be animated to one of the anchors and when that anchor is
* reached, the value of the [SwipeableState] will also be updated to the state corresponding to
* the new anchor. The target anchor is calculated based on the provided positional [thresholds].
*
* Swiping is constrained between the minimum and maximum anchors. If the user attempts to swipe
* past these bounds, a resistance effect will be applied by default. The amount of resistance at
* each edge is specified by the [resistance] config. To disable all resistance, set it to `null`.
*
* For an example of a [swipeable] with three states, see:
*
* @sample androidx.compose.material.samples.SwipeableSample
*
* @param T The type of the state.
* @param state The state of the [swipeable].
* @param anchors Pairs of anchors and states, used to map anchors to states and vice versa.
* @param thresholds Specifies where the thresholds between the states are. The thresholds will be
* used to determine which state to animate to when swiping stops. This is represented as a lambda
* that takes two states and returns the threshold between them in the form of a [ThresholdConfig].
* Note that the order of the states corresponds to the swipe direction.
* @param orientation The orientation in which the [swipeable] can be swiped.
* @param enabled Whether this [swipeable] is enabled and should react to the user's input.
* @param reverseDirection Whether to reverse the direction of the swipe, so a top to bottom
* swipe will behave like bottom to top, and a left to right swipe will behave like right to left.
* @param interactionSource Optional [MutableInteractionSource] that will passed on to
* the internal [Modifier.draggable].
* @param resistance Controls how much resistance will be applied when swiping past the bounds.
* @param velocityThreshold The threshold (in dp per second) that the end velocity has to exceed
* in order to animate to the next state, even if the positional [thresholds] have not been reached.
*/
@ExperimentalMaterialApi
fun <T> Modifier.swipeable(
state: SwipeableState<T>,
anchors: Map<Float, T>,
orientation: Orientation,
enabled: Boolean = true,
reverseDirection: Boolean = false,
interactionSource: MutableInteractionSource? = null,
thresholds: (from: T, to: T) -> ThresholdConfig = { _, _ -> FixedThreshold(56.dp) },
resistance: ResistanceConfig? = resistanceConfig(anchors.keys),
velocityThreshold: Dp = VelocityThreshold
) = composed(
inspectorInfo = debugInspectorInfo {
name = "swipeable"
properties["state"] = state
properties["anchors"] = anchors
properties["orientation"] = orientation
properties["enabled"] = enabled
properties["reverseDirection"] = reverseDirection
properties["interactionSource"] = interactionSource
properties["thresholds"] = thresholds
properties["resistance"] = resistance
properties["velocityThreshold"] = velocityThreshold
}
) {
require(anchors.isNotEmpty()) {
"You must have at least one anchor."
}
require(anchors.values.distinct().count() == anchors.size) {
"You cannot have two anchors mapped to the same state."
}
val density = LocalDensity.current
state.ensureInit(anchors)
LaunchedEffect(anchors, state) {
val oldAnchors = state.anchors
state.anchors = anchors
state.resistance = resistance
state.thresholds = { a, b ->
val from = anchors.getValue(a)
val to = anchors.getValue(b)
with(thresholds(from, to)) { density.computeThreshold(a, b) }
}
with(density) {
state.velocityThreshold = velocityThreshold.toPx()
}
state.processNewAnchors(oldAnchors, anchors)
}
Modifier.draggable(
orientation = orientation,
enabled = enabled,
reverseDirection = reverseDirection,
interactionSource = interactionSource,
startDragImmediately = state.isAnimationRunning,
onDragStopped = { velocity -> launch { state.performFling(velocity) } },
state = state.draggableState
)
}
/**
* Interface to compute a threshold between two anchors/states in a [swipeable].
*
* To define a [ThresholdConfig], consider using [FixedThreshold] and [FractionalThreshold].
*/
@Stable
@ExperimentalMaterialApi
interface ThresholdConfig {
/**
* Compute the value of the threshold (in pixels), once the values of the anchors are known.
*/
fun Density.computeThreshold(fromValue: Float, toValue: Float): Float
}
/**
* A fixed threshold will be at an [offset] away from the first anchor.
*
* @param offset The offset (in dp) that the threshold will be at.
*/
@Immutable
@ExperimentalMaterialApi
data class FixedThreshold(private val offset: Dp) : ThresholdConfig {
override fun Density.computeThreshold(fromValue: Float, toValue: Float): Float {
return fromValue + offset.toPx() * sign(toValue - fromValue)
}
}
/**
* A fractional threshold will be at a [fraction] of the way between the two anchors.
*
* @param fraction The fraction (between 0 and 1) that the threshold will be at.
*/
@Immutable
@ExperimentalMaterialApi
data class FractionalThreshold(
/*@FloatRange(from = 0.0, to = 1.0)*/
private val fraction: Float
) : ThresholdConfig {
override fun Density.computeThreshold(fromValue: Float, toValue: Float): Float {
return lerp(fromValue, toValue, fraction)
}
}
/**
* Specifies how resistance is calculated in [swipeable].
*
* There are two things needed to calculate resistance: the resistance basis determines how much
* overflow will be consumed to achieve maximum resistance, and the resistance factor determines
* the amount of resistance (the larger the resistance factor, the stronger the resistance).
*
* The resistance basis is usually either the size of the component which [swipeable] is applied
* to, or the distance between the minimum and maximum anchors. For a constructor in which the
* resistance basis defaults to the latter, consider using [resistanceConfig].
*
* You may specify different resistance factors for each bound. Consider using one of the default
* resistance factors in [SwipeableDefaults]: `StandardResistanceFactor` to convey that the user
* has run out of things to see, and `StiffResistanceFactor` to convey that the user cannot swipe
* this right now. Also, you can set either factor to 0 to disable resistance at that bound.
*
* @param basis Specifies the maximum amount of overflow that will be consumed. Must be positive.
* @param factorAtMin The factor by which to scale the resistance at the minimum bound.
* Must not be negative.
* @param factorAtMax The factor by which to scale the resistance at the maximum bound.
* Must not be negative.
*/
@Immutable
class ResistanceConfig(
/*@FloatRange(from = 0.0, fromInclusive = false)*/
val basis: Float,
/*@FloatRange(from = 0.0)*/
val factorAtMin: Float = StandardResistanceFactor,
/*@FloatRange(from = 0.0)*/
val factorAtMax: Float = StandardResistanceFactor
) {
fun computeResistance(overflow: Float): Float {
val factor = if (overflow < 0) factorAtMin else factorAtMax
if (factor == 0f) return 0f
val progress = (overflow / basis).coerceIn(-1f, 1f)
return basis / factor * sin(progress * PI.toFloat() / 2)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is ResistanceConfig) return false
if (basis != other.basis) return false
if (factorAtMin != other.factorAtMin) return false
if (factorAtMax != other.factorAtMax) return false
return true
}
override fun hashCode(): Int {
var result = basis.hashCode()
result = 31 * result + factorAtMin.hashCode()
result = 31 * result + factorAtMax.hashCode()
return result
}
override fun toString(): String {
return "ResistanceConfig(basis=$basis, factorAtMin=$factorAtMin, factorAtMax=$factorAtMax)"
}
}
/**
* Given an offset x and a set of anchors, return a list of anchors:
* 1. [ ] if the set of anchors is empty,
* 2. [ x' ] if x is equal to one of the anchors, accounting for a small rounding error, where x'
* is x rounded to the exact value of the matching anchor,
* 3. [ min ] if min is the minimum anchor and x < min,
* 4. [ max ] if max is the maximum anchor and x > max, or
* 5. [ a , b ] if a and b are anchors such that a < x < b and b - a is minimal.
*/
private fun findBounds(
offset: Float,
anchors: Set<Float>
): List<Float> {
// Find the anchors the target lies between with a little bit of rounding error.
val a = anchors.filter { it <= offset + 0.001 }.maxOrNull()
val b = anchors.filter { it >= offset - 0.001 }.minOrNull()
return when {
a == null ->
// case 1 or 3
listOfNotNull(b)
b == null ->
// case 4
listOf(a)
a == b ->
// case 2
// Can't return offset itself here since it might not be exactly equal
// to the anchor, despite being considered an exact match.
listOf(a)
else ->
// case 5
listOf(a, b)
}
}
private fun computeTarget(
offset: Float,
lastValue: Float,
anchors: Set<Float>,
thresholds: (Float, Float) -> Float,
velocity: Float,
velocityThreshold: Float
): Float {
val bounds = findBounds(offset, anchors)
return when (bounds.size) {
0 -> lastValue
1 -> bounds[0]
else -> {
val lower = bounds[0]
val upper = bounds[1]
if (lastValue <= offset) {
// Swiping from lower to upper (positive).
if (velocity >= velocityThreshold) {
return upper
} else {
val threshold = thresholds(lower, upper)
if (offset < threshold) lower else upper
}
} else {
// Swiping from upper to lower (negative).
if (velocity <= -velocityThreshold) {
return lower
} else {
val threshold = thresholds(upper, lower)
if (offset > threshold) upper else lower
}
}
}
}
}
private fun <T> Map<Float, T>.getOffset(state: T): Float? {
return entries.firstOrNull { it.value == state }?.key
}
/**
* Contains useful defaults for [swipeable] and [SwipeableState].
*/
object SwipeableDefaults {
/**
* The default animation used by [SwipeableState].
*/
val AnimationSpec = SpringSpec<Float>()
/**
* The default velocity threshold (1.8 dp per millisecond) used by [swipeable].
*/
val VelocityThreshold = 125.dp
/**
* A stiff resistance factor which indicates that swiping isn't available right now.
*/
const val StiffResistanceFactor = 20f
/**
* A standard resistance factor which indicates that the user has run out of things to see.
*/
const val StandardResistanceFactor = 10f
/**
* The default resistance config used by [swipeable].
*
* This returns `null` if there is one anchor. If there are at least two anchors, it returns
* a [ResistanceConfig] with the resistance basis equal to the distance between the two bounds.
*/
fun resistanceConfig(
anchors: Set<Float>,
factorAtMin: Float = StandardResistanceFactor,
factorAtMax: Float = StandardResistanceFactor
): ResistanceConfig? {
return if (anchors.size <= 1) {
null
} else {
val basis = anchors.maxOrNull()!! - anchors.minOrNull()!!
ResistanceConfig(basis, factorAtMin, factorAtMax)
}
}
}
@ExperimentalMaterialApi
internal val <T> SwipeableState<T>.PreUpPostTopNestedScrollConnection: NestedScrollConnection
get() = object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val delta = available.toFloat()
return if (delta > 0 && source == NestedScrollSource.Drag) {
performDrag(delta).toOffset()
} else {
Offset.Zero
}
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
return if (source == NestedScrollSource.Drag) {
performDrag(available.toFloat()).toOffset()
} else {
Offset.Zero
}
}
override suspend fun onPreFling(available: Velocity): Velocity {
val toFling = Offset(available.x, available.y).toFloat()
return if (toFling > 0 && offset.value > minBound) {
performFling(velocity = toFling)
// since we go to the anchor with tween settling, consume all for the best UX
available
} else {
Velocity.Zero
}
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
performFling(velocity = Offset(available.x, available.y).toFloat())
return available
}
private fun Float.toOffset(): Offset = Offset(0f, this)
private fun Offset.toFloat(): Float = this.y
}
// temp default nested scroll connection for swipeables which desire as an opt in
// revisit in b/174756744 as all types will have their own specific connection probably
@ExperimentalMaterialApi
internal val <T> SwipeableState<T>.PreUpPostDownNestedScrollConnection: NestedScrollConnection
get() = object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val delta = available.toFloat()
return if (delta < 0 && source == NestedScrollSource.Drag) {
performDrag(delta).toOffset()
} else {
Offset.Zero
}
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
return if (source == NestedScrollSource.Drag) {
performDrag(available.toFloat()).toOffset()
} else {
Offset.Zero
}
}
override suspend fun onPreFling(available: Velocity): Velocity {
val toFling = Offset(available.x, available.y).toFloat()
return if (toFling < 0 && offset.value > minBound) {
performFling(velocity = toFling)
// since we go to the anchor with tween settling, consume all for the best UX
available
} else {
Velocity.Zero
}
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
performFling(velocity = Offset(available.x, available.y).toFloat())
return available
}
private fun Float.toOffset(): Offset = Offset(0f, this)
private fun Offset.toFloat(): Float = this.y
}

View file

@ -0,0 +1,11 @@
package com.danilkinkin.buckwheat.util
import com.danilkinkin.buckwheat.calendar.CALENDAR_STARTS_ON
import java.time.YearMonth
import java.time.temporal.WeekFields
fun YearMonth.getNumberWeeks(weekFields: WeekFields = CALENDAR_STARTS_ON): Int {
val firstWeekNumber = this.atDay(1)[weekFields.weekOfMonth()]
val lastWeekNumber = this.atEndOfMonth()[weekFields.weekOfMonth()]
return lastWeekNumber - firstWeekNumber + 1 // Both weeks inclusive
}

View file

@ -1,10 +1,8 @@
package com.danilkinkin.buckwheat.utils
package com.danilkinkin.buckwheat.util
import android.text.Editable
import android.text.TextWatcher
import android.util.Log
import com.danilkinkin.buckwheat.MainActivity
import com.google.android.material.textfield.TextInputEditText
import java.lang.Integer.min
import java.lang.ref.WeakReference
import java.math.BigDecimal
@ -49,7 +47,7 @@ fun Double.round(scale: Int): Double =
fun prettyCandyCanes(
value: BigDecimal,
forceShowAfterDot: Boolean = false,
currency: ExtendCurrency = MainActivity.getInstance().model.currency,
currency: ExtendCurrency,
): String {
val formatter = if (currency.type === CurrencyType.FROM_LIST) currencyFormat else numberFormat
@ -65,7 +63,7 @@ fun prettyCandyCanes(
return formattedValue
}
/*
class CurrencyTextWatcher(
editText: TextInputEditText,
private val forceShowAfterDot: Boolean = false,
@ -146,4 +144,4 @@ class CurrencyTextWatcher(
init {
editTextWeakReference = WeakReference(editText)
}
}
} */

View file

@ -0,0 +1,14 @@
package com.danilkinkin.buckwheat.util
import androidx.compose.ui.graphics.Color
fun combineColors(colorA: Color, colorB: Color, angle: Float = 0.5F): Color {
val colorAPart = (1F - angle) * 2
val colorBPart = angle * 2
return Color(
red = (colorA.red * colorAPart + colorB.red * colorBPart) / 2,
green = (colorA.green * colorAPart + colorB.green * colorBPart) / 2,
blue = (colorA.blue * colorAPart + colorB.blue * colorBPart) / 2,
)
}

View file

@ -0,0 +1,35 @@
package com.danilkinkin.buckwheat.util
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import androidx.core.content.ContextCompat
import com.danilkinkin.buckwheat.R
fun copyLinkToClipboard(context: Context, link: String) {
val intent = Intent(Intent.ACTION_VIEW)
intent.data = Uri.parse(link)
try {
ContextCompat.startActivity(context, intent, null)
} catch (e: Exception) {
val clipboard = ContextCompat.getSystemService(
context,
ClipboardManager::class.java
) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("url", link))
Toast
.makeText(
context,
context.getString(R.string.copy_in_clipboard),
Toast.LENGTH_LONG
)
.show()
}
}

View file

@ -1,15 +1,30 @@
package com.danilkinkin.buckwheat.utils
package com.danilkinkin.buckwheat.util
import java.text.SimpleDateFormat
import java.time.LocalDate
import java.time.YearMonth
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.*
import kotlin.math.ceil
const val DAY = 24 * 60 * 60 * 1000
val monthFormat = SimpleDateFormat("MMMM")
val monthShortFormat = SimpleDateFormat("MM")
val yearFormat = SimpleDateFormat("yyyy")
val dateFormat = SimpleDateFormat("dd MMMM")
val timeFormat = SimpleDateFormat("HH:mm")
var yearMonthFormatterCurrYaer = DateTimeFormatter.ofPattern("MMMM")
var yearMonthFormatter = DateTimeFormatter.ofPattern("MMM yyyy")
fun LocalDate.toDate(): Date = Date(this.atStartOfDay(ZoneId.systemDefault()).toEpochSecond() * 1000)
fun Date.toLocalDate(): LocalDate = this.toInstant()
.atZone(ZoneId.systemDefault())
.toLocalDate();
fun countDays(toDate: Date, fromDate: Date = Date()): Int {
val fromDateRound = roundToDay(fromDate)
val toDateRound = roundToDay(toDate)
@ -49,6 +64,14 @@ fun prettyDate(date: Date, showTime: Boolean = true, forceShowDate: Boolean = fa
return final
}
fun prettyYearMonth(yearMonth: YearMonth): String {
return if (yearMonth.year.toString() == yearFormat.format(Date().time)) {
yearMonth.format(yearMonthFormatterCurrYaer)
} else {
yearMonth.format(yearMonthFormatter)
}
}
fun roundToDay(date: Date): Date {
val calendar = Calendar.getInstance()
calendar.time = date

View file

@ -1,4 +1,4 @@
package com.danilkinkin.buckwheat.utils
package com.danilkinkin.buckwheat.util
import android.content.res.Resources
import kotlin.math.roundToInt

View file

@ -0,0 +1,39 @@
package com.danilkinkin.buckwheat.util
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TransformedText
import kotlin.math.min
private fun visualTransformationAsCurrency(
input: AnnotatedString,
currency: ExtendCurrency,
forceShowAfterDot: Boolean = false,
): TransformedText {
val output = prettyCandyCanes(input.text.toBigDecimal(), forceShowAfterDot, currency)
val offsetTranslator = object : OffsetMapping {
override fun originalToTransformed(offset: Int): Int {
val count = output.substring(0, min(offset, output.length - 1)).filter { !it.isDigit() }.length
return min(offset + count, output.length)
}
override fun transformedToOriginal(offset: Int): Int {
val count = output.substring(0, min(offset, output.length - 1)).filter { !it.isDigit() }.length
return min(offset - count, output.length)
}
}
return TransformedText(AnnotatedString(output), offsetTranslator)
}
fun visualTransformationAsCurrency(
forceShowAfterDot: Boolean = false,
currency: ExtendCurrency
): ((input: AnnotatedString) -> TransformedText) {
return {
visualTransformationAsCurrency(it, currency, forceShowAfterDot)
}
}

View file

@ -1,17 +0,0 @@
package com.danilkinkin.buckwheat.utils
import android.content.Context
import android.content.res.TypedArray
import android.util.TypedValue
fun getThemeColor(context: Context, color: Int): Int {
val typedValue = TypedValue()
val a: TypedArray = context.obtainStyledAttributes(typedValue.data, intArrayOf(color))
val colorId = a.getColor(0, 0)
a.recycle()
return colorId
}

View file

@ -1,23 +0,0 @@
package com.danilkinkin.buckwheat.utils
import android.view.View
fun getStatusBarHeight(view: View): Int {
val resourceId = view.resources.getIdentifier("status_bar_height", "dimen", "android")
return if (resourceId > 0) {
view.resources.getDimensionPixelSize(resourceId)
} else {
0
}
}
fun getNavigationBarHeight(view: View): Int {
val resourceId = view.resources.getIdentifier("navigation_bar_height", "dimen", "android")
return if (resourceId > 0) {
view.resources.getDimensionPixelSize(resourceId)
} else {
0
}
}

View file

@ -1,10 +0,0 @@
package com.danilkinkin.buckwheat.utils
import android.content.res.Resources
import kotlin.math.roundToInt
fun Int.toDP(): Int = (this * Resources.getSystem().displayMetrics.density).roundToInt()
fun Float.toDP(): Int = (this * Resources.getSystem().displayMetrics.density).roundToInt()
fun Double.toDP(): Int = (this * Resources.getSystem().displayMetrics.density).roundToInt()

View file

@ -1,26 +0,0 @@
package com.danilkinkin.buckwheat.viewmodels
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MutableLiveData
import com.danilkinkin.buckwheat.di.DatabaseModule
import com.danilkinkin.buckwheat.entities.Storage
class AppViewModel(application: Application) : AndroidViewModel(application) {
private val db = DatabaseModule.getInstance(application)
private val storage = db.storageDao()
var isDebug: MutableLiveData<Boolean> = MutableLiveData(try {
storage.get("isDebug").value.toBoolean()
} catch (e: Exception) {
false
})
fun setIsDebug(debug: Boolean) {
storage.set(Storage("isDebug", debug.toString()))
isDebug.value = debug
}
}

View file

@ -0,0 +1,110 @@
package com.danilkinkin.buckwheat.wallet
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.danilkinkin.buckwheat.R
import com.danilkinkin.buckwheat.ui.BuckwheatTheme
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CustomCurrencyEditorContent(
defaultCurrency: String? = "",
onChange: (currency: String) -> Unit,
onClose: () -> Unit,
) {
val selectCurrency = remember { mutableStateOf(defaultCurrency ?: "") }
Card(
shape = CardDefaults.shape,
modifier = Modifier
.widthIn(max = 500.dp)
.padding(36.dp)
) {
Column() {
Text(
text = stringResource(R.string.currency_custom_title),
style = MaterialTheme.typography.displayMedium,
modifier = Modifier.padding(24.dp)
)
Divider()
Box(Modifier) {
TextField(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
value = selectCurrency.value.toString(),
onValueChange = { selectCurrency.value = it },
shape = TextFieldDefaults.filledShape,
colors = TextFieldDefaults.textFieldColors()
)
}
Divider()
Row(
horizontalArrangement = Arrangement.End,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp, horizontal = 16.dp),
) {
Button(
onClick = { onClose() },
colors = ButtonDefaults.textButtonColors(),
contentPadding = ButtonDefaults.TextButtonContentPadding,
) {
Text(text = stringResource(R.string.cancel))
}
Button(
onClick = {
onChange(selectCurrency.value!!)
onClose()
},
colors = ButtonDefaults.textButtonColors(),
contentPadding = ButtonDefaults.TextButtonContentPadding,
enabled = selectCurrency.value.trim() !== "",
) {
Text(text = stringResource(R.string.accept))
}
}
}
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun CustomCurrencyEditor(
defaultCurrency: String? = null,
onChange: (currency: String) -> Unit,
onClose: () -> Unit,
) {
Dialog(
onDismissRequest = { onClose() },
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
CustomCurrencyEditorContent(
defaultCurrency = defaultCurrency,
onChange = onChange,
onClose = { onClose() }
)
}
}
@Preview
@Composable
fun PreviewCustomCurrencyEditor() {
BuckwheatTheme {
CustomCurrencyEditorContent(
defaultCurrency = "",
onChange = { },
onClose = { }
)
}
}

View file

@ -0,0 +1,117 @@
package com.danilkinkin.buckwheat.wallet
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Surface
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.danilkinkin.buckwheat.R
import com.danilkinkin.buckwheat.calendar.Calendar
import com.danilkinkin.buckwheat.calendar.model.CalendarState
import com.danilkinkin.buckwheat.calendar.model.selectedDatesFormatted
import com.danilkinkin.buckwheat.ui.BuckwheatTheme
import com.danilkinkin.buckwheat.util.toDate
import java.time.LocalDate
import java.util.*
@Composable
fun FinishDateSelector(
selectDate: Date? = null,
onBackPressed: () -> Unit,
onApply: (finishDate: Date) -> Unit,
) {
Surface(modifier = Modifier.fillMaxSize()) {
val calendarState = remember { CalendarState(selectDate) }
FinishDateSelectorContent(
calendarState = calendarState,
onDayClicked = { calendarState.setSelectedDay(it) },
onBackPressed = onBackPressed,
onApply = { onApply(calendarState.calendarUiState.value.selectedEndDate!!.toDate()) }
)
}
}
@Composable
private fun FinishDateSelectorContent(
calendarState: CalendarState,
onDayClicked: (LocalDate) -> Unit,
onBackPressed: () -> Unit,
onApply: () -> Unit,
) {
Column() {
FinishDateSelectorTopAppBar(calendarState, onBackPressed, onApply)
Calendar(
calendarState = calendarState,
onDayClicked = onDayClicked,
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun FinishDateSelectorTopAppBar(
calendarState: CalendarState,
onBackPressed: () -> Unit,
onApply: () -> Unit,
) {
Surface(modifier = Modifier.statusBarsPadding()) {
MediumTopAppBar(
navigationIcon = {
IconButton(
onClick = { onBackPressed() }
) {
Icon(
imageVector = Icons.Filled.ArrowBack,
contentDescription = null,
)
}
},
title = {
Text(
text = if (!calendarState.calendarUiState.value.hasSelectedDates) {
stringResource(R.string.select_finish_date_title)
} else {
selectedDatesFormatted(calendarState)
},
style = MaterialTheme.typography.titleLarge,
)
},
actions = {
Button(
onClick = { onApply() },
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
modifier = Modifier.padding(end = 8.dp),
enabled = calendarState.calendarUiState.value.selectedStartDate !== null,
) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = "Localized description",
modifier = Modifier.size(ButtonDefaults.IconSize)
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(text = stringResource(R.string.apply))
}
}
)
}
}
@Preview(showSystemUi = true)
@Composable
fun PreviewFinishDateSelector(){
BuckwheatTheme {
FinishDateSelector(onBackPressed = {}, onApply = {})
}
}

View file

@ -0,0 +1,214 @@
package com.danilkinkin.buckwheat.wallet
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.danilkinkin.buckwheat.R
import com.danilkinkin.buckwheat.base.ButtonRow
import com.danilkinkin.buckwheat.base.CheckedRow
import com.danilkinkin.buckwheat.base.TextRow
import com.danilkinkin.buckwheat.base.Divider
import com.danilkinkin.buckwheat.data.SpendsViewModel
import com.danilkinkin.buckwheat.ui.BuckwheatTheme
import com.danilkinkin.buckwheat.util.*
import kotlinx.coroutines.launch
import java.lang.Exception
import java.math.BigDecimal
import java.math.RoundingMode
import java.util.*
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalComposeUiApi::class,
)
@Composable
fun Wallet(
requestFinishDate: ((presetDate: Date, callback: (finishDate: Date) -> Unit) -> Unit) = { _: Date, _: (finishDate: Date) -> Unit -> },
spendsViewModel: SpendsViewModel = viewModel(),
onClose: () -> Unit = {},
) {
val budget = remember { mutableStateOf(spendsViewModel.budget.value!!) }
val dateToValue = remember { mutableStateOf(spendsViewModel.finishDate) }
val currency = remember { mutableStateOf(spendsViewModel.currency) }
val openCurrencyChooserDialog = remember { mutableStateOf(false) }
val openCustomCurrencyEditorDialog = remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
Column(modifier = Modifier.navigationBarsPadding()) {
val days = countDays(dateToValue.value)
CenterAlignedTopAppBar(
title = {
Text(
text = stringResource(R.string.wallet_title),
style = MaterialTheme.typography.titleLarge,
)
}
)
Divider()
TextRow(
icon = painterResource(R.drawable.ic_money),
text = stringResource(R.string.label_budget),
)
TextField(
modifier = Modifier
.padding(start = 56.dp)
.fillMaxWidth(),
value = budget.value.toString(),
onValueChange = {
try {
budget.value = BigDecimal(it)
} catch (E: Exception) {
budget.value = BigDecimal(0)
}
},
colors = TextFieldDefaults.textFieldColors(
containerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
errorIndicatorColor = Color.Transparent,
),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
textStyle = MaterialTheme.typography.displaySmall,
visualTransformation = visualTransformationAsCurrency(
currency = ExtendCurrency(type = CurrencyType.NONE)
)
)
Divider()
ButtonRow(
icon = painterResource(R.drawable.ic_calendar),
text = String.format(
pluralStringResource(R.plurals.finish_date_label, 32),
prettyDate(dateToValue.value, showTime = false, forceShowDate = true),
days,
),
onClick = {
coroutineScope.launch {
requestFinishDate(dateToValue.value) {
dateToValue.value = it
}
}
},
)
Divider()
TextRow(
icon = painterResource(R.drawable.ic_currency),
text = stringResource(R.string.in_currency_label),
)
CheckedRow(
checked = currency.value.type === CurrencyType.FROM_LIST,
onValueChange = { openCurrencyChooserDialog.value = true },
text = if (currency.value.type !== CurrencyType.FROM_LIST) {
stringResource(R.string.currency_from_list)
} else {
stringResource(
id = R.string.currency_from_list_selected,
Currency.getInstance(currency.value.value).symbol
)
},
)
CheckedRow(
checked = currency.value.type === CurrencyType.CUSTOM,
onValueChange = { openCustomCurrencyEditorDialog.value = true },
text = if (currency.value.type !== CurrencyType.CUSTOM) {
stringResource(R.string.currency_custom)
} else {
stringResource(
id = R.string.currency_custom_selected,
currency.value.value!!
)
},
)
CheckedRow(
checked = currency.value.type === CurrencyType.NONE,
onValueChange = {
if (it) {
currency.value = ExtendCurrency(type = CurrencyType.NONE)
}
},
text = stringResource(R.string.currency_none),
)
Divider()
Spacer(Modifier.height(24.dp))
Text(
text = stringResource(
R.string.per_day,
prettyCandyCanes(
if (days != 0) {
(budget.value / days.toBigDecimal()).setScale(0, RoundingMode.FLOOR)
} else {
budget.value
},
currency = spendsViewModel.currency,
),
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 56.dp)
)
Spacer(Modifier.height(24.dp))
Button(
onClick = {
spendsViewModel.changeCurrency(currency.value)
spendsViewModel.changeBudget(budget.value, dateToValue.value)
onClose()
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
enabled = countDays(dateToValue.value) > 0 && budget.value > BigDecimal(0)
) {
Text(text = stringResource(id = R.string.apply))
}
}
if (openCurrencyChooserDialog.value) {
WorldCurrencyChooser(
defaultCurrency = if (currency.value.type === CurrencyType.FROM_LIST) {
Currency.getInstance(currency.value.value)
} else {
null
},
onSelect = {
currency.value =
ExtendCurrency(type = CurrencyType.FROM_LIST, value = it.currencyCode)
},
onClose = { openCurrencyChooserDialog.value = false },
)
}
if (openCustomCurrencyEditorDialog.value) {
CustomCurrencyEditor(
defaultCurrency = if (currency.value.type === CurrencyType.CUSTOM) {
currency.value.value
} else {
null
},
onChange = {
currency.value = ExtendCurrency(type = CurrencyType.CUSTOM, value = it)
},
onClose = { openCustomCurrencyEditorDialog.value = false },
)
}
}
@Preview
@Composable
fun PreviewWallet() {
BuckwheatTheme {
Wallet()
}
}

View file

@ -0,0 +1,152 @@
package com.danilkinkin.buckwheat.wallet
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.selection.toggleable
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.danilkinkin.buckwheat.R
import com.danilkinkin.buckwheat.ui.BuckwheatTheme
import java.util.*
fun getCurrencies(): MutableList<Currency> {
val currencies = Currency.getAvailableCurrencies().toMutableList()
currencies.sortBy { it.displayName.uppercase() }
return currencies
}
@Composable
fun WorldCurrencyChooserContent(
defaultCurrency: Currency? = null,
onSelect: (currency: Currency) -> Unit,
onClose: () -> Unit,
) {
val selectCurrency = remember { mutableStateOf(defaultCurrency) }
Card(
shape = CardDefaults.shape,
modifier = Modifier
.widthIn(max = 500.dp)
.padding(36.dp)
) {
Column() {
Text(
text = stringResource(R.string.select_currency_title),
style = MaterialTheme.typography.displayMedium,
modifier = Modifier.padding(24.dp)
)
Divider()
Box(Modifier.weight(1F)) {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier = Modifier.fillMaxSize()//.heightIn(max = 600.dp)
) {
getCurrencies().forEach {
itemsCurrency(
currency = it,
selected = selectCurrency.value?.currencyCode === it.currencyCode,
onClick = {
selectCurrency.value = it
},
)
}
}
}
Divider()
Row(
horizontalArrangement = Arrangement.End,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp, horizontal = 16.dp),
) {
Button(
onClick = { onClose() },
colors = ButtonDefaults.textButtonColors(),
contentPadding = ButtonDefaults.TextButtonContentPadding,
) {
Text(text = stringResource(R.string.cancel))
}
Button(
onClick = {
onSelect(selectCurrency.value!!)
onClose()
},
colors = ButtonDefaults.textButtonColors(),
contentPadding = ButtonDefaults.TextButtonContentPadding,
enabled = selectCurrency.value !== null,
) {
Text(text = stringResource(R.string.accept))
}
}
}
}
}
private fun LazyListScope.itemsCurrency(
currency: Currency,
selected: Boolean,
onClick: () -> Unit,
) {
item(currency.currencyCode) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxWidth()
.toggleable(
value = selected,
onValueChange = { onClick() },
role = Role.Checkbox
)
.padding(start = 24.dp, end = 16.dp, top = 8.dp, bottom = 8.dp),
) {
Text(text = currency.displayName)
RadioButton(selected = selected, onClick = null)
}
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun WorldCurrencyChooser(
defaultCurrency: Currency? = null,
onSelect: (currency: Currency) -> Unit,
onClose: () -> Unit,
) {
Dialog(
onDismissRequest = { onClose() },
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
WorldCurrencyChooserContent(
defaultCurrency = defaultCurrency,
onSelect = onSelect,
onClose = { onClose() }
)
}
}
@Preview
@Composable
fun PreviewWorldCurrencyChooser() {
BuckwheatTheme {
WorldCurrencyChooserContent(
defaultCurrency = null,
onSelect = { },
onClose = { }
)
}
}

View file

@ -1,69 +0,0 @@
package com.danilkinkin.buckwheat.widgets.bottomsheet
import android.os.Build
import android.os.Bundle
import android.view.View
import android.view.WindowInsetsController
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toDrawable
import com.danilkinkin.buckwheat.R
import com.danilkinkin.buckwheat.utils.getStatusBarHeight
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
open class BottomSheetFragment: BottomSheetDialogFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val behavior = BottomSheetBehavior.from(view.parent as View)
val topBarHeight = getStatusBarHeight(view)
behavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
override fun onStateChanged(bottomSheet: View, newState: Int) {
if (newState == BottomSheetBehavior.STATE_EXPANDED && bottomSheet.top < topBarHeight) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
dialog!!.window!!.insetsController?.setSystemBarsAppearance(
WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS,
WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS
)
}
view.findViewById<AppBarLayout>(R.id.app_bar).background = ContextCompat.getColor(
context!!,
com.google.android.material.R.color.material_dynamic_neutral95,
).toDrawable()
dialog!!.window!!.statusBarColor = ContextCompat.getColor(
context!!,
com.google.android.material.R.color.material_dynamic_neutral95,
)
} else {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
dialog!!.window!!.insetsController?.setSystemBarsAppearance(
0,
WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS
)
}
view.findViewById<AppBarLayout>(R.id.app_bar).background = ContextCompat.getColor(
context!!,
android.R.color.transparent,
).toDrawable()
dialog!!.window!!.statusBarColor = ContextCompat.getColor(
context!!,
android.R.color.transparent,
)
}
}
override fun onSlide(bottomSheet: View, slideOffset: Float) {
}
})
}
}

View file

@ -1,133 +0,0 @@
package com.danilkinkin.buckwheat.widgets.editor
import android.content.Context
import android.util.AttributeSet
import android.util.Log
import android.view.MotionEvent
import android.view.View
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.view.updateLayoutParams
import androidx.core.widget.NestedScrollView
import androidx.recyclerview.widget.RecyclerView
import com.danilkinkin.buckwheat.R
import com.danilkinkin.buckwheat.utils.getNavigationBarHeight
import com.danilkinkin.buckwheat.utils.toDP
import com.danilkinkin.buckwheat.widgets.topsheet.TopSheetBehavior
import kotlin.math.abs
class EditorBehavior<V: View>: CoordinatorLayout.Behavior<V> {
companion object {
val TAG = EditorBehavior::class.simpleName
}
private var recyclerView: RecyclerView? = null
private var isBeingDragged = false
var initialX = 0
var initialY = 0
var lastY = 0
/**
* Конструктор для создания экземпляра FancyBehavior через разметку.
*
* @param context The {@link Context}.
* @param attrs The {@link AttributeSet}.
*/
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
}
constructor() : super()
override fun onLayoutChild(parent: CoordinatorLayout, child: V, layoutDirection: Int): Boolean {
child.updateLayoutParams {
height = parent.height - (parent.width - 16.toDP() + getNavigationBarHeight(child))
}
recyclerView = parent.findViewById(R.id.recycle_view)
return super.onLayoutChild(parent, child, layoutDirection)
}
override fun onInterceptTouchEvent(
parent: CoordinatorLayout, child: V, event: MotionEvent
): Boolean {
if (
parent.isPointInChildBounds(child, event.x.toInt(), event.y.toInt())
&& event.actionMasked == MotionEvent.ACTION_DOWN
) {
initialX = event.x.toInt()
initialY = event.y.toInt()
lastY = event.y.toInt()
isBeingDragged = true
}
if (isBeingDragged) {
Log.d(TAG, "onInterceptTouchEvent action = ${event.actionMasked}")
if (
event.actionMasked == MotionEvent.ACTION_UP
|| event.actionMasked == MotionEvent.ACTION_CANCEL
) {
isBeingDragged = false
}
return if (recyclerView !== null) {
val topSheetBehavior = ((recyclerView!!.layoutParams as CoordinatorLayout.LayoutParams).behavior as TopSheetBehavior)
val touchSlop = topSheetBehavior.viewDragHelper!!.touchSlop
lastY = event.y.toInt()
abs(initialY - event.y) > touchSlop || abs(initialX - event.x) > touchSlop
} else {
true
}
}
return false
}
override fun onTouchEvent(
parent: CoordinatorLayout, child: V, event: MotionEvent
): Boolean {
if (parent.isPointInChildBounds(child, event.x.toInt(), event.y.toInt())) {
val topSheetBehavior = try {
((recyclerView!!.layoutParams as CoordinatorLayout.LayoutParams).behavior as TopSheetBehavior)
} catch (e: Exception) {
null
}
Log.d(TAG, "event.y = ${event.y.toInt()}")
when (event.actionMasked) {
MotionEvent.ACTION_MOVE -> {
topSheetBehavior?.drag(lastY - event.y.toInt())
}
MotionEvent.ACTION_UP,
MotionEvent.ACTION_CANCEL -> {
topSheetBehavior?.finishDrag()
}
}
}
lastY = event.y.toInt()
return true
}
override fun layoutDependsOn(parent: CoordinatorLayout, child: V, dependency: View): Boolean {
return dependency.id == R.id.recycle_view
}
override fun onDependentViewChanged(
parent: CoordinatorLayout,
child: V,
dependency: View
): Boolean {
child.translationY = dependency.bottom.toFloat()
return true
}
}

View file

@ -1,297 +0,0 @@
package com.danilkinkin.buckwheat.widgets.editor
import android.animation.ValueAnimator
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.animation.AccelerateDecelerateInterpolator
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.animation.doOnEnd
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import com.danilkinkin.buckwheat.*
import com.danilkinkin.buckwheat.utils.getStatusBarHeight
import com.danilkinkin.buckwheat.utils.prettyCandyCanes
import com.danilkinkin.buckwheat.utils.toDP
import com.danilkinkin.buckwheat.utils.toSP
import com.danilkinkin.buckwheat.viewmodels.AppViewModel
import com.danilkinkin.buckwheat.viewmodels.SpentViewModel
import com.google.android.material.button.MaterialButton
import com.google.android.material.textview.MaterialTextView
import kotlin.math.max
import kotlin.math.min
class EditorFragment : Fragment() {
companion object {
val TAG: String = EditorFragment::class.java.simpleName
}
private lateinit var model: SpentViewModel
private lateinit var appModel: AppViewModel
private var settingsBottomSheet: SettingsBottomSheet? = null
private var walletBottomSheet: WalletBottomSheet? = null
private var newDayBottomSheet: NewDayBottomSheet? = null
private val budgetView: ConstraintLayout by lazy {
requireView().findViewById(R.id.budget)
}
private val spentView: ConstraintLayout by lazy {
requireView().findViewById(R.id.spent)
}
private val restBudgetView: ConstraintLayout by lazy {
requireView().findViewById(R.id.rest_budget)
}
private val budgetValue: MaterialTextView by lazy {
requireView().findViewById(R.id.budget_value)
}
private val spentValue: MaterialTextView by lazy {
requireView().findViewById(R.id.spent_value)
}
private val restBudgetValue: MaterialTextView by lazy {
requireView().findViewById(R.id.rest_budget_value)
}
private val budgetLabel: MaterialTextView by lazy {
requireView().findViewById(R.id.budget_label)
}
private val spentLabel: MaterialTextView by lazy {
requireView().findViewById(R.id.spent_label)
}
private val restBudgetLabel: MaterialTextView by lazy {
requireView().findViewById(R.id.rest_budget_label)
}
enum class AnimState { FIRST_IDLE, EDITING, COMMIT, IDLE, RESET }
private var currAnimator: ValueAnimator? = null
private var currState: AnimState? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
super.onCreateView(inflater, container, savedInstanceState)
return inflater.inflate(R.layout.fragment_editor, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val model: SpentViewModel by activityViewModels()
val appModel: AppViewModel by activityViewModels()
this.model = model
this.appModel = appModel
val helperView = view.findViewById<View>(R.id.top_bar_offset_helper)
val layout = helperView.layoutParams
layout.height = getStatusBarHeight(view)
helperView.layoutParams = layout
build()
observe()
}
private fun calculateValues(
budget: Boolean = true,
restBudget: Boolean = true,
spent: Boolean = true
) {
val spentFromDailyBudget = model.spentFromDailyBudget.value!!
val dailyBudget = model.dailyBudget.value!!
if (budget) budgetValue.text = prettyCandyCanes(dailyBudget - spentFromDailyBudget)
if (restBudget) restBudgetValue.text =
prettyCandyCanes(dailyBudget - spentFromDailyBudget - model.currentSpent)
if (spent) spentValue.text = prettyCandyCanes(model.currentSpent, model.useDot)
}
private fun build() {
calculateValues()
restBudgetView.alpha = 0F
spentView.alpha = 0F
budgetView.alpha = 0F
animTo(AnimState.FIRST_IDLE)
requireView().findViewById<MaterialButton>(R.id.settings_btn).setOnClickListener {
if (settingsBottomSheet?.isVisible == true) return@setOnClickListener
settingsBottomSheet = SettingsBottomSheet()
settingsBottomSheet!!.show(parentFragmentManager, SettingsBottomSheet.TAG)
}
requireView().findViewById<MaterialButton>(R.id.wallet_btn).setOnClickListener {
if (walletBottomSheet?.isVisible == true) return@setOnClickListener
walletBottomSheet = WalletBottomSheet()
walletBottomSheet!!.show(parentFragmentManager, WalletBottomSheet.TAG)
}
requireView().findViewById<MaterialButton>(R.id.dev_tool_btn).setOnClickListener {
if (newDayBottomSheet?.isVisible == true) return@setOnClickListener
newDayBottomSheet = NewDayBottomSheet()
newDayBottomSheet!!.show(parentFragmentManager, NewDayBottomSheet.TAG)
}
}
private fun animFrame(state: AnimState, progress: Float = 1F) {
when (state) {
AnimState.FIRST_IDLE -> {
budgetLabel.textSize = 10.toSP().toFloat()
budgetValue.textSize = 40.toSP().toFloat()
budgetView.translationY = 30.toDP() * (1F - progress)
budgetView.alpha = progress
}
AnimState.EDITING -> {
var offset = 0F
restBudgetValue.textSize = 20.toSP().toFloat()
restBudgetLabel.textSize = 8.toSP().toFloat()
offset += restBudgetView.height
restBudgetView.translationY = (offset + spentView.height) * (1F - progress)
restBudgetView.alpha = 1F
spentValue.textSize = 60.toSP().toFloat()
spentLabel.textSize = 18.toSP().toFloat()
spentView.translationY = (spentView.height + offset) * (1F - progress) - offset
spentView.alpha = 1F
offset += spentView.height
budgetValue.textSize = 40.toSP().toFloat() - 28.toSP().toFloat() * progress
budgetLabel.textSize = 10.toSP().toFloat() - 4.toSP().toFloat() * progress
budgetView.translationY = -offset * progress
budgetView.alpha = 1F
}
AnimState.COMMIT -> {
var offset = 0F
val progressA = min(progress * 2F, 1F)
val progressB = max((progress - 0.5F) * 2F, 0F)
restBudgetValue.textSize = 20.toSP().toFloat() + 20.toSP().toFloat() * progress
restBudgetLabel.textSize = 8.toSP().toFloat() + 2.toSP().toFloat() * progress
offset += restBudgetView.height
restBudgetView.alpha = 1F
spentValue.textSize = 60.toSP().toFloat()
spentLabel.textSize = 18.toSP().toFloat()
spentView.translationY = -offset - 50.toDP() * progressB
spentView.alpha = 1F - progressB
offset += spentView.height
budgetValue.textSize = 12.toSP().toFloat()
budgetLabel.textSize = 6.toSP().toFloat()
budgetView.translationY = -offset - 50.toDP() * progressA
budgetView.alpha = 1F - progressA
}
AnimState.RESET -> {
var offset = 0F
restBudgetValue.textSize = 20.toSP().toFloat()
restBudgetLabel.textSize = 8.toSP().toFloat()
offset += restBudgetView.height
restBudgetView.translationY = (offset + spentView.height) * progress
spentValue.textSize = 60.toSP().toFloat()
spentLabel.textSize = 18.toSP().toFloat()
spentView.translationY = (spentView.height + offset) * progress - offset
offset += spentView.height
budgetValue.textSize = 12.toSP().toFloat() + 28.toSP().toFloat() * progress
budgetLabel.textSize = 6.toSP().toFloat() + 4.toSP().toFloat() * progress
budgetView.translationY = -offset * (1F - progress)
}
AnimState.IDLE -> {
calculateValues(restBudget = false)
budgetValue.textSize = 40.toSP().toFloat()
budgetLabel.textSize = 10.toSP().toFloat()
budgetView.translationY = 0F
budgetView.alpha = 1F
restBudgetView.alpha = 0F
}
}
}
private fun animTo(state: AnimState) {
if (currState === state) return
currState = state
if (currAnimator !== null) {
currAnimator!!.pause()
}
currAnimator = ValueAnimator.ofFloat(0F, 1F)
currAnimator!!.apply {
duration = 220
interpolator = AccelerateDecelerateInterpolator()
addUpdateListener { valueAnimator ->
val animatedValue = valueAnimator.animatedValue as Float
animFrame(state, animatedValue)
}
doOnEnd {
if (state === AnimState.COMMIT) {
animFrame(AnimState.IDLE)
}
}
start()
}
}
private fun observe() {
model.dailyBudget.observe(viewLifecycleOwner) {
calculateValues()
}
model.spentFromDailyBudget.observe(viewLifecycleOwner) {
calculateValues(budget = currState !== AnimState.EDITING, restBudget = false)
}
model.stage.observe(viewLifecycleOwner) { stage ->
when (stage) {
SpentViewModel.Stage.IDLE, null -> {
if (currState === AnimState.EDITING) animTo(AnimState.RESET)
}
SpentViewModel.Stage.CREATING_SPENT -> {
calculateValues(budget = false)
animTo(AnimState.EDITING)
}
SpentViewModel.Stage.EDIT_SPENT -> {
calculateValues(budget = false)
}
SpentViewModel.Stage.COMMITTING_SPENT -> {
animTo(AnimState.COMMIT)
model.resetSpent()
}
}
}
appModel.isDebug.observe(viewLifecycleOwner) {
requireView().findViewById<MaterialButton>(R.id.dev_tool_btn).visibility = if (it) {
View.VISIBLE
} else {
View.GONE
}
}
}
}

View file

@ -1,77 +0,0 @@
package com.danilkinkin.buckwheat.widgets.keyboard
import android.content.Context
import android.util.AttributeSet
import android.util.Log
import android.view.View
import androidx.constraintlayout.motion.widget.MotionLayout
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.view.updateLayoutParams
import com.danilkinkin.buckwheat.R
import com.danilkinkin.buckwheat.utils.getNavigationBarHeight
import com.danilkinkin.buckwheat.utils.toDP
import kotlin.math.max
import kotlin.math.min
class KeyboardBehavior<V: View>: CoordinatorLayout.Behavior<V> {
companion object {
val TAG = KeyboardBehavior::class.simpleName
}
private var navigationBarHeight: Int? = null
/**
* Конструктор для создания экземпляра FancyBehavior через разметку.
*
* @param context The {@link Context}.
* @param attrs The {@link AttributeSet}.
*/
constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
constructor() : super()
override fun layoutDependsOn(parent: CoordinatorLayout, child: V, dependency: View): Boolean {
return dependency.id == R.id.editor_container
}
override fun onLayoutChild(parent: CoordinatorLayout, child: V, layoutDirection: Int): Boolean {
navigationBarHeight = getNavigationBarHeight(child)
child.updateLayoutParams {
height = parent.width - 16.toDP() + navigationBarHeight!!
}
child.setPadding(
16.toDP(),
16.toDP(),
16.toDP(),
navigationBarHeight!!,
)
return super.onLayoutChild(parent, child, layoutDirection)
}
override fun onDependentViewChanged(
parent: CoordinatorLayout,
child: V,
dependency: View
): Boolean {
child.translationY = dependency.translationY
val maxHeight = child.height
val minHeight = 226.toDP() + child.paddingTop + child.paddingBottom
child.findViewById<MotionLayout>(R.id.root)?.progress = max(
min(
1 - ((parent.height - (dependency.bottom + dependency.translationY) - minHeight) / (maxHeight - minHeight)),
0.999999F,
),
0.000001F,
)
return super.onDependentViewChanged(parent, child, dependency)
}
}

View file

@ -1,242 +0,0 @@
package com.danilkinkin.buckwheat.widgets.keyboard
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.constraintlayout.motion.widget.MotionLayout
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import com.danilkinkin.buckwheat.R
import com.danilkinkin.buckwheat.utils.toDP
import com.danilkinkin.buckwheat.utils.toSP
import com.danilkinkin.buckwheat.viewmodels.AppViewModel
import com.danilkinkin.buckwheat.viewmodels.SpentViewModel
import com.google.android.material.button.MaterialButton
import com.google.android.material.snackbar.Snackbar
class KeyboardFragment : Fragment() {
private lateinit var model: SpentViewModel
private lateinit var appModel: AppViewModel
private val root: MotionLayout by lazy {
requireView().findViewById(R.id.root)
}
private val n0Btn: MaterialButton by lazy {
requireView().findViewById(R.id.btn_0)
}
private val n1Btn: MaterialButton by lazy {
requireView().findViewById(R.id.btn_1)
}
private val n2Btn: MaterialButton by lazy {
requireView().findViewById(R.id.btn_2)
}
private val n3Btn: MaterialButton by lazy {
requireView().findViewById(R.id.btn_3)
}
private val n4Btn: MaterialButton by lazy {
requireView().findViewById(R.id.btn_4)
}
private val n5Btn: MaterialButton by lazy {
requireView().findViewById(R.id.btn_5)
}
private val n6Btn: MaterialButton by lazy {
requireView().findViewById(R.id.btn_6)
}
private val n7Btn: MaterialButton by lazy {
requireView().findViewById(R.id.btn_7)
}
private val n8Btn: MaterialButton by lazy {
requireView().findViewById(R.id.btn_8)
}
private val n9Btn: MaterialButton by lazy {
requireView().findViewById(R.id.btn_9)
}
private val backspaceBtn: MaterialButton by lazy {
requireView().findViewById(R.id.btn_backspace)
}
private val dotBtn: MaterialButton by lazy {
requireView().findViewById(R.id.btn_dot)
}
private val evalBtn: MaterialButton by lazy {
requireView().findViewById(R.id.btn_eval)
}
var listBtns: ArrayList<MaterialButton>? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
super.onCreateView(inflater, container, savedInstanceState)
return inflater.inflate(R.layout.fragment_keyboard, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val model: SpentViewModel by activityViewModels()
val appModel: AppViewModel by activityViewModels()
this.model = model
this.appModel = appModel
build()
}
fun build() {
listBtns = arrayListOf(
n0Btn,
n1Btn,
n2Btn,
n3Btn,
n4Btn,
n5Btn,
n6Btn,
n7Btn,
n8Btn,
n9Btn,
dotBtn
)
n0Btn.setOnClickListener {
this.model.executeAction(SpentViewModel.Action.PUT_NUMBER, 0)
}
n1Btn.setOnClickListener {
this.model.executeAction(SpentViewModel.Action.PUT_NUMBER, 1)
}
n2Btn.setOnClickListener {
this.model.executeAction(SpentViewModel.Action.PUT_NUMBER, 2)
}
n3Btn.setOnClickListener {
this.model.executeAction(SpentViewModel.Action.PUT_NUMBER, 3)
}
n4Btn.setOnClickListener {
this.model.executeAction(SpentViewModel.Action.PUT_NUMBER, 4)
}
n5Btn.setOnClickListener {
this.model.executeAction(SpentViewModel.Action.PUT_NUMBER, 5)
}
n6Btn.setOnClickListener {
this.model.executeAction(SpentViewModel.Action.PUT_NUMBER, 6)
}
n7Btn.setOnClickListener {
this.model.executeAction(SpentViewModel.Action.PUT_NUMBER, 7)
}
n8Btn.setOnClickListener {
this.model.executeAction(SpentViewModel.Action.PUT_NUMBER, 8)
}
n9Btn.setOnClickListener {
this.model.executeAction(SpentViewModel.Action.PUT_NUMBER, 9)
}
dotBtn.setOnClickListener {
this.model.executeAction(SpentViewModel.Action.SET_DOT)
}
backspaceBtn.setOnClickListener {
this.model.executeAction(SpentViewModel.Action.REMOVE_LAST)
}
evalBtn.setOnClickListener {
if ("${model.valueLeftDot}.${model.valueRightDot}" == "00000000.") {
model.resetSpent()
appModel.setIsDebug(!appModel.isDebug.value!!)
Snackbar
.make(
requireView(), "Debug ${
if (appModel.isDebug.value!!) {
"ON"
} else {
"OFF"
}
}", Snackbar.LENGTH_LONG
)
.show()
return@setOnClickListener
}
model.commitSpent()
}
root.addTransitionListener(object : MotionLayout.TransitionListener {
override fun onTransitionStarted(
motionLayout: MotionLayout?,
startId: Int,
endId: Int
) {
}
override fun onTransitionChange(
motionLayout: MotionLayout?,
startId: Int,
endId: Int,
progress: Float
) {
val shiftProgress: Float = if (progress < 0.5) {
0F
} else {
(progress - 0.5F) * 2F
}
backspaceBtn.iconSize = (36.toDP() - 12.toDP() * shiftProgress).toInt()
listBtns?.forEach {
it.textSize = 26.toSP() - 12.toSP() * shiftProgress
}
}
override fun onTransitionCompleted(motionLayout: MotionLayout?, currentId: Int) {
val shiftProgress = if (currentId == R.id.end) {
1F
} else {
0F
}
backspaceBtn.iconSize = (36.toDP() - 12.toDP() * shiftProgress).toInt()
listBtns?.forEach {
it.textSize = 26.toSP() - 12.toSP() * shiftProgress
}
}
override fun onTransitionTrigger(
motionLayout: MotionLayout?,
triggerId: Int,
positive: Boolean,
progress: Float
) {
}
})
}
}

View file

@ -1,548 +0,0 @@
package com.danilkinkin.buckwheat.widgets.topsheet
import android.content.Context
import android.os.Parcel
import android.os.Parcelable
import android.util.AttributeSet
import android.util.Log
import android.view.*
import androidx.annotation.VisibleForTesting
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.math.MathUtils
import androidx.core.view.ViewCompat
import androidx.customview.view.AbsSavedState
import androidx.customview.widget.ViewDragHelper
import com.danilkinkin.buckwheat.R
import com.google.android.material.floatingactionbutton.FloatingActionButton
import java.lang.ref.WeakReference
import kotlin.math.abs
import kotlin.math.min
open class TopSheetBehavior<V : View>(context: Context, attrs: AttributeSet?) :
CoordinatorLayout.Behavior<V>(context, attrs) {
companion object {
private val TAG = TopSheetBehavior::class.java.simpleName
enum class State {
STATE_DRAGGING, /** The bottom sheet is dragging. */
STATE_SETTLING, /** The bottom sheet is settling. */
STATE_EXPANDED, /** The bottom sheet is expanded. */
STATE_HIDDEN, /** The bottom sheet is hidden. */
}
private const val HIDE_THRESHOLD = 0.5f
private const val HIDE_FRICTION = 0.1f
}
private var maximumVelocity = 0f
private var settleRunnable: SettleRunnable? = null
var state = State.STATE_HIDDEN
var viewDragHelper: ViewDragHelper? = null
private var ignoreEvents = false
private var lastNestedScrollDy = 0
private var nestedScrolled = false
private var childHeight = 0
private var parentWidth = 0
var parentHeight = 0
var viewRef: WeakReference<View>? = null
var nestedScrollingChildRef: WeakReference<View?>? = null
private var velocityTracker: VelocityTracker? = null
var activePointerId = 0
private var initialY = 0
var touchingScrollingChild = false
init {
val configuration = ViewConfiguration.get(context)
maximumVelocity = configuration.scaledMaximumFlingVelocity.toFloat()
}
override fun onSaveInstanceState(parent: CoordinatorLayout, child: V): Parcelable {
return SavedState(super.onSaveInstanceState(parent, child), this)
}
override fun onRestoreInstanceState(
parent: CoordinatorLayout, child: V, state: Parcelable
) {
val ss = state as SavedState
super.onRestoreInstanceState(parent, child, ss.superState!!)
// Intermediate states are restored as collapsed state
if (ss.state == State.STATE_DRAGGING || ss.state == State.STATE_SETTLING) {
this.state = State.STATE_EXPANDED
} else {
this.state = ss.state
}
}
override fun onAttachedToLayoutParams(layoutParams: CoordinatorLayout.LayoutParams) {
super.onAttachedToLayoutParams(layoutParams)
// These may already be null, but just be safe, explicitly assign them. This lets us know the
// first time we layout with this behavior by checking (viewRef == null).
viewRef = null
viewDragHelper = null
}
override fun onDetachedFromLayoutParams() {
super.onDetachedFromLayoutParams()
// Release references so we don't run unnecessary codepaths while not attached to a view.
viewRef = null
viewDragHelper = null
}
override fun onLayoutChild(parent: CoordinatorLayout, child: V, layoutDirection: Int): Boolean {
if (ViewCompat.getFitsSystemWindows(parent) && !ViewCompat.getFitsSystemWindows(child as View)) {
child.fitsSystemWindows = true
}
if (viewRef == null) {
viewRef = WeakReference(child)
if (ViewCompat.getImportantForAccessibility(child as View)
== ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_AUTO
) {
ViewCompat.setImportantForAccessibility(
child,
ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_YES
)
}
}
if (viewDragHelper == null) {
viewDragHelper = ViewDragHelper.create(parent, dragCallback)
}
// First let the parent lay it out
parent.onLayoutChild(child as View, layoutDirection)
// Offset the bottom sheet
parentWidth = parent.width
parentHeight = parent.height
childHeight = child.height
when (state) {
State.STATE_HIDDEN -> {
Log.d(TAG, "offsetTopAndBottom = $childHeight")
ViewCompat.offsetTopAndBottom(child, -childHeight)
(child.parent as View).findViewById<FloatingActionButton>(R.id.fab_home_btn).hide()
}
State.STATE_EXPANDED, State.STATE_DRAGGING, State.STATE_SETTLING -> {
Log.d(TAG, "offsetTopAndBottom = 0")
ViewCompat.offsetTopAndBottom(child, 0)
(child.parent as View).findViewById<FloatingActionButton>(R.id.fab_home_btn).show()
}
}
nestedScrollingChildRef = WeakReference(findScrollingChild(child))
return true
}
override fun onInterceptTouchEvent(parent: CoordinatorLayout, child: V, event: MotionEvent): Boolean {
Log.d(TAG, "onInterceptTouchEvent action = ${event.actionMasked}")
if (!child.isShown) {
ignoreEvents = true
return false
}
val action = event.actionMasked
// Record the velocity
if (action == MotionEvent.ACTION_DOWN) {
reset()
}
if (velocityTracker == null) {
velocityTracker = VelocityTracker.obtain()
}
velocityTracker!!.addMovement(event)
when (action) {
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
touchingScrollingChild = false
activePointerId = MotionEvent.INVALID_POINTER_ID
// Reset the ignore flag
if (ignoreEvents) {
ignoreEvents = false
return false
}
}
MotionEvent.ACTION_DOWN -> {
val initialX = event.x.toInt()
initialY = event.y.toInt()
// Only intercept nested scrolling events here if the view not being moved by the
// ViewDragHelper.
if (state != State.STATE_SETTLING) {
val scroll =
if (nestedScrollingChildRef != null) nestedScrollingChildRef!!.get() else null
if (scroll != null && parent.isPointInChildBounds(scroll, initialX, initialY)) {
activePointerId = event.getPointerId(event.actionIndex)
touchingScrollingChild = true
}
}
ignoreEvents = ((activePointerId == MotionEvent.INVALID_POINTER_ID)
&& !parent.isPointInChildBounds(child, initialX, initialY))
}
else -> {}
}
if (!ignoreEvents
&& viewDragHelper != null && viewDragHelper!!.shouldInterceptTouchEvent(event)
) {
return true
}
// We have to handle cases that the ViewDragHelper does not capture the bottom sheet because
// it is not the top most view of its parent. This is not necessary when the touch event is
// happening over the scrolling content as nested scrolling logic handles that case.
val scroll = if (nestedScrollingChildRef != null) nestedScrollingChildRef!!.get() else null
return (action == MotionEvent.ACTION_MOVE && scroll != null && !ignoreEvents
&& state != State.STATE_DRAGGING && !parent.isPointInChildBounds(
scroll,
event.x.toInt(),
event.y.toInt()
)
&& viewDragHelper != null && abs(initialY - event.y) > viewDragHelper!!.touchSlop)
}
override fun onTouchEvent(parent: CoordinatorLayout, child: V, event: MotionEvent): Boolean {
Log.d(TAG, "onTouchEvent action = ${event.actionMasked}")
if (!child.isShown) {
return false
}
val action = event.actionMasked
if (state == State.STATE_DRAGGING && action == MotionEvent.ACTION_DOWN) {
return true
}
if (viewDragHelper != null) {
viewDragHelper!!.processTouchEvent(event)
}
// Record the velocity
if (action == MotionEvent.ACTION_DOWN) {
reset()
}
if (velocityTracker == null) {
velocityTracker = VelocityTracker.obtain()
}
velocityTracker!!.addMovement(event)
// The ViewDragHelper tries to capture only the top-most View. We have to explicitly tell it
// to capture the bottom sheet in case it is not captured and the touch slop is passed.
if (viewDragHelper != null && action == MotionEvent.ACTION_MOVE && !ignoreEvents) {
if (abs(initialY - event.y) > viewDragHelper!!.touchSlop) {
viewDragHelper!!.captureChildView(child, event.getPointerId(event.actionIndex))
}
}
return !ignoreEvents
}
fun drag(dy: Int) {
this.viewRef?.get()?.let {
Log.d(TAG, "drag dy = $dy end = ${it.bottom - dy}")
if (it.bottom - dy < 0) {
Log.d(TAG, "offsetTopAndBottom = ${it.bottom}")
ViewCompat.offsetTopAndBottom(it, -it.bottom)
} else {
Log.d(TAG, "offsetTopAndBottom = $dy")
ViewCompat.offsetTopAndBottom(it, min(-dy, (it.height - it.bottom)))
}
setSmartStateInternal(State.STATE_DRAGGING)
lastNestedScrollDy = dy
nestedScrolled = true
}
}
fun finishDrag(target: View? = null) {
Log.d(TAG, "finishDrag")
this.viewRef?.get()?.let { child ->
if (child.top == 0) {
setSmartStateInternal(State.STATE_EXPANDED)
return
}
if (
(target !== null && (nestedScrollingChildRef == null ||
target !== nestedScrollingChildRef!!.get())) ||
!nestedScrolled
) {
return
}
val bottom: Int
val targetSmartState: State
if (lastNestedScrollDy >= 0 && shouldHide(child, yVelocity)) {
bottom = 0
targetSmartState = State.STATE_HIDDEN
(child.parent as View).findViewById<FloatingActionButton>(R.id.fab_home_btn).hide()
} else {
bottom = childHeight
targetSmartState = State.STATE_EXPANDED
(child.parent as View).findViewById<FloatingActionButton>(R.id.fab_home_btn).show()
}
Log.d(TAG, "startSettlingAnimation 1")
startSettlingAnimation(
child,
targetSmartState,
bottom - childHeight,
)
nestedScrolled = false
}
}
fun setSmartState(state: State) {
this.viewRef?.get()?.let { child ->
setSmartStateInternal(state)
startSettlingAnimation(
child,
state,
-childHeight,
)
nestedScrolled = false
}
}
override fun onStartNestedScroll(
coordinatorLayout: CoordinatorLayout,
child: V,
directTargetChild: View,
target: View,
axes: Int,
type: Int
): Boolean {
lastNestedScrollDy = 0
nestedScrolled = false
return axes and ViewCompat.SCROLL_AXIS_VERTICAL != 0
}
override fun onNestedPreScroll(
coordinatorLayout: CoordinatorLayout,
child: V,
target: View,
dx: Int,
dy: Int,
consumed: IntArray,
type: Int
) {
if (type == ViewCompat.TYPE_NON_TOUCH) {
Log.d(TAG, "onNestedPreScroll skip non touch")
// Ignore fling here. The ViewDragHelper handles it.
return
}
val scrollingChild =
if (nestedScrollingChildRef != null) nestedScrollingChildRef!!.get() else null
if (target !== scrollingChild) {
Log.d(TAG, "onNestedPreScroll skip wrong target")
return
}
val currentBottom = child.bottom
val newBottom = currentBottom - dy
Log.d(TAG, "onNestedPreScroll dy = $dy currentBottom = $currentBottom")
if (dy > 0) { // Upward - Collapsing the top sheet!
Log.d(TAG, "Upward newBottom = $newBottom childHeight = $childHeight")
if (!target.canScrollVertically(1)) {
consumed[1] = dy
Log.d(TAG, "offsetTopAndBottom = $dy")
ViewCompat.offsetTopAndBottom(child, -dy)
setSmartStateInternal(State.STATE_DRAGGING)
}
} else if (dy < 0) { // Downward
Log.d(TAG, "Downward newBottom = $newBottom `childHeight` = $childHeight")
if (newBottom > childHeight) {
consumed[1] = currentBottom - childHeight
Log.d(TAG, "offsetTopAndBottom = ${consumed[1]}")
ViewCompat.offsetTopAndBottom(child, -consumed[1])
setSmartStateInternal(State.STATE_EXPANDED)
} else {
consumed[1] = dy
Log.d(TAG, "offsetTopAndBottom = $dy")
ViewCompat.offsetTopAndBottom(child, -dy)
setSmartStateInternal(State.STATE_DRAGGING)
}
}
lastNestedScrollDy = dy
nestedScrolled = true
}
override fun onStopNestedScroll(
coordinatorLayout: CoordinatorLayout,
child: V,
target: View,
type: Int
) {
Log.d(TAG, "onStopNestedScroll")
finishDrag(target)
}
override fun onNestedPreFling(
coordinatorLayout: CoordinatorLayout,
child: V,
target: View,
velocityX: Float,
velocityY: Float
): Boolean {
return if (nestedScrollingChildRef != null) {
(target === nestedScrollingChildRef!!.get()
&& (state != State.STATE_EXPANDED
|| super.onNestedPreFling(
coordinatorLayout,
child,
target,
velocityX,
velocityY
)))
} else {
false
}
}
fun setSmartStateInternal(state: State) {
if (this.state == state) {
return
}
this.state = state
if (viewRef == null) {
return
}
viewRef!!.get() ?: return
}
private fun reset() {
activePointerId = ViewDragHelper.INVALID_POINTER
if (velocityTracker != null) {
velocityTracker!!.recycle()
velocityTracker = null
}
}
private fun shouldHide(child: View, yvel: Float): Boolean {
if (child.bottom > childHeight) {
// It should not hide, but collapse.
return false
}
val newBottom = child.top + yvel * HIDE_FRICTION
return abs(newBottom - childHeight) / childHeight.toFloat() > HIDE_THRESHOLD
}
@VisibleForTesting
fun findScrollingChild(view: View?): View? {
if (ViewCompat.isNestedScrollingEnabled(view!!)) {
return view
}
if (view is ViewGroup) {
var i = 0
val count = view.childCount
while (i < count) {
val scrollingChild = findScrollingChild(view.getChildAt(i))
if (scrollingChild != null) {
return scrollingChild
}
i++
}
}
return null
}
private val yVelocity: Float
get() {
if (velocityTracker == null) {
return 0F
}
velocityTracker!!.computeCurrentVelocity(1000, maximumVelocity)
return velocityTracker!!.getYVelocity(activePointerId)
}
private fun startSettlingAnimation(
child: View,
state: State,
top: Int,
) {
Log.d(TAG, "startSettlingAnimation... state = $state top = $top")
val startedSettling = viewDragHelper!!.smoothSlideViewTo(child, child.left, top)
if (startedSettling) {
setSmartStateInternal(State.STATE_SETTLING)
if (settleRunnable == null) {
// If the singleton SettleRunnable instance has not been instantiated, create it.
settleRunnable = SettleRunnable(child, state)
}
// If the SettleRunnable has not been posted, post it with the correct state.
if (!settleRunnable!!.isPosted) {
settleRunnable!!.targetSmartState = state
ViewCompat.postOnAnimation(child, settleRunnable!!)
settleRunnable!!.isPosted = true
} else {
// Otherwise, if it has been posted, just update the target state.
settleRunnable!!.targetSmartState = state
}
} else {
setSmartStateInternal(state)
}
}
private val dragCallback: ViewDragHelper.Callback = object : ViewDragHelper.Callback() {
override fun tryCaptureView(child: View, pointerId: Int): Boolean {
Log.d(TAG, "tryCaptureView... touchingScrollingChild = $touchingScrollingChild")
if (state == State.STATE_DRAGGING) {
return false
}
if (touchingScrollingChild) {
return false
}
if (state == State.STATE_EXPANDED && activePointerId == pointerId) {
val scroll = if (nestedScrollingChildRef != null) nestedScrollingChildRef!!.get() else null
if (scroll != null && scroll.canScrollVertically(-1)) {
// Let the content scroll up
return false
}
}
return viewRef != null && viewRef!!.get() === child
}
override fun onViewDragStateChanged(state: Int) {
if (state == ViewDragHelper.STATE_DRAGGING) {
setSmartStateInternal(State.STATE_DRAGGING)
}
}
override fun clampViewPositionVertical(child: View, top: Int, dy: Int): Int {
return MathUtils.clamp(
top,
childHeight,
parentHeight,
)
}
override fun clampViewPositionHorizontal(child: View, left: Int, dx: Int): Int {
return child.left
}
override fun getViewVerticalDragRange(child: View): Int {
return parentHeight
}
}
private inner class SettleRunnable(
private val view: View,
var targetSmartState: State
) : Runnable {
var isPosted = false
override fun run() {
Log.d(TAG, "targetSmartState = $targetSmartState")
if (viewDragHelper != null && viewDragHelper!!.continueSettling(true)) {
ViewCompat.postOnAnimation(view, this)
} else {
setSmartStateInternal(targetSmartState)
}
isPosted = false
}
}
/** State persisted across instances */
protected class SavedState(superState: Parcelable?, behavior: TopSheetBehavior<*>) :
AbsSavedState(superState!!) {
val state: State = behavior.state
override fun writeToParcel(out: Parcel, flags: Int) {
super.writeToParcel(out, flags)
out.writeInt(state.ordinal)
}
}
}

View file

@ -1,29 +0,0 @@
package com.danilkinkin.buckwheat.widgets.topsheet
import android.content.Context
import android.content.res.ColorStateList
import android.content.res.TypedArray
import androidx.annotation.StyleableRes
import androidx.appcompat.content.res.AppCompatResources
object TopSheetUtils {
/**
* Returns the [ColorStateList] from the given [TypedArray] attributes. The resource
* can include themeable attributes, regardless of API level.
*/
fun getColorStateList(
context: Context, attributes: TypedArray, @StyleableRes index: Int
): ColorStateList? {
if (attributes.hasValue(index)) {
val resourceId = attributes.getResourceId(index, 0)
if (resourceId != 0) {
val value = AppCompatResources.getColorStateList(context, resourceId)
if (value != null) {
return value
}
}
}
return attributes.getColorStateList(index)
}
}

View file

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:alpha="0.25" android:color="?attr/colorOnSurface" />
</selector>

View file

@ -1,30 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View file

@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="?attr/colorSurface"/>
<corners android:topLeftRadius="48dp"
android:topRightRadius="48dp"/>
</shape>

View file

@ -1,5 +0,0 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@null" android:pathData="M9,16.17L4.83,12l-1.42,1.41L9,19 21,7l-1.41,-1.41z"/>
</vector>

View file

@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View file

@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<inset xmlns:android="http://schemas.android.com/apk/res/android"
android:insetLeft="16dp">
<selector>
<item android:drawable="@drawable/radio_checked" android:state_checked="true" />
<item android:drawable="@drawable/ic_empty" android:state_checked="false" />
</selector>
</inset>

View file

@ -1,5 +0,0 @@
<vector android:height="24dp" android:tint="?attr/colorOnSurface"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M9,16.17L4.83,12l-1.42,1.41L9,19 21,7l-1.41,-1.41z"/>
</vector>

View file

@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="?attr/colorAccent" />
<corners android:radius="32dp" />
</shape>

View file

@ -1,46 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:fitsSystemWindows="true"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/root">
<androidx.fragment.app.FragmentContainerView
android:id="@+id/keyboard_container"
android:name="com.danilkinkin.buckwheat.widgets.keyboard.KeyboardFragment"
android:layout_width="match_parent"
android:layout_height="300dp"
android:layout_gravity="bottom"
android:padding="16dp"
app:layout_behavior=".widgets.keyboard.KeyboardBehavior"
app:layout_behavior_dependency="@+id/editor_container"
tools:layout="@layout/fragment_keyboard" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycle_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fillViewport="false"
app:layout_behavior=".widgets.topsheet.TopSheetBehavior" />
<androidx.fragment.app.FragmentContainerView
android:id="@+id/editor_container"
android:name="com.danilkinkin.buckwheat.widgets.editor.EditorFragment"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_behavior=".widgets.editor.EditorBehavior"
tools:layout="@layout/fragment_editor" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab_home_btn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="24dp"
android:contentDescription="@string/fab_home_desc"
app:srcCompat="@drawable/ic_home" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -1,24 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/currency_input_container"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="24dp"
android:layout_marginEnd="16dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/currency_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text"
android:maxLength="5" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>

View file

@ -1,89 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<CalendarView
android:id="@+id/calendar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:dateTextAppearance="@style/TextAppearance.Buckwheat.BodyMedium"
android:showWeekNumber="true"
android:weekDayTextAppearance="@style/TextAppearance.Buckwheat.LabelMedium" />
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.textview.MaterialTextView
android:id="@+id/start_date_label"
style="@style/TextAppearance.Buckwheat.LabelLarge"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:layout_marginBottom="8dp"
android:ellipsize="end"
android:maxLines="1"
android:text="@string/start_date_label_with_dots"
android:textAlignment="textEnd"
android:textColor="?attr/colorSecondary"
android:visibility="visible"
app:layout_constraintBottom_toTopOf="@+id/finish_date_label"
app:layout_constraintEnd_toEndOf="@+id/finish_date_label"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/start_date"
style="@style/TextAppearance.Buckwheat.LabelLarge"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="24dp"
android:ellipsize="end"
android:maxLines="1"
android:textAlignment="textStart"
android:textColor="?attr/colorSecondary"
android:visibility="visible"
app:layout_constraintStart_toEndOf="@+id/start_date_label"
app:layout_constraintTop_toTopOf="parent"
tools:text="@tools:sample/date/ddmmyy" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/finish_date_label"
style="@style/TextAppearance.Buckwheat.LabelLarge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="24dp"
android:ellipsize="end"
android:maxLines="1"
android:text="@string/finish_date_label_with_dots"
android:textColor="?attr/colorSecondary"
android:visibility="visible"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/finish_date"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/start_date_label" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/finish_date"
style="@style/TextAppearance.Buckwheat.LabelLarge"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginBottom="24dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="?attr/colorSecondary"
android:visibility="visible"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@+id/finish_date_label"
tools:text="@tools:sample/date/ddmmyy[1]" />
</androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>

View file

@ -1,205 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/root"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.card.MaterialCardView
android:id="@+id/container_budget_for_today"
style="@style/Widget.Buckwheat.EditorCard"
android:layout_width="0dp"
android:layout_height="0dp"
app:cardPreventCornerOverlap="false"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/calculator"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="bottom"
android:orientation="vertical">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/rest_budget"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginEnd="32dp"
android:clipChildren="false"
android:clipToPadding="false"
android:paddingBottom="0dp"
tools:layout_constraintBottom_toTopOf="@+id/spent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHeight_default="spread"
app:layout_constraintStart_toStartOf="parent">
<com.google.android.material.textview.MaterialTextView
android:id="@+id/rest_budget_value"
style="@style/TextAppearance.Buckwheat.DisplaySmall"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textColor="?attr/colorOnPrimaryContainer"
app:layout_constraintBottom_toTopOf="@+id/rest_budget_label"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="4356 ₽" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/rest_budget_label"
style="@style/TextAppearance.Buckwheat.LabelLarge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
android:text="@string/rest_budget_for_today"
android:textColor="?attr/colorOnPrimaryContainer"
android:visibility="visible"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/spent"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginEnd="32dp"
android:clipChildren="false"
android:clipToPadding="false"
android:paddingBottom="0dp"
tools:layout_constraintBottom_toTopOf="@+id/budget"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHeight_default="spread"
app:layout_constraintStart_toStartOf="parent">
<com.google.android.material.textview.MaterialTextView
android:id="@+id/spent_value"
style="@style/TextAppearance.Buckwheat.DisplaySmall"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textColor="?attr/colorOnPrimaryContainer"
app:layout_constraintBottom_toTopOf="@+id/spent_label"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="4356 ₽" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/spent_label"
style="@style/TextAppearance.Buckwheat.LabelLarge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
android:text="@string/spent"
android:textColor="?attr/colorOnPrimaryContainer"
android:visibility="visible"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/budget"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginEnd="32dp"
android:clipChildren="false"
android:clipToPadding="false"
android:paddingBottom="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHeight_default="spread"
app:layout_constraintStart_toStartOf="parent">
<com.google.android.material.textview.MaterialTextView
android:id="@+id/budget_value"
style="@style/TextAppearance.Buckwheat.DisplaySmall"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textColor="?attr/colorOnPrimaryContainer"
app:layout_constraintBottom_toTopOf="@+id/budget_label"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="4356 ₽" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/budget_label"
style="@style/TextAppearance.Buckwheat.LabelLarge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
android:text="@string/budget_for_today"
android:textColor="?attr/colorOnPrimaryContainer"
android:visibility="visible"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>
<View
android:id="@+id/top_bar_offset_helper"
android:layout_width="0dp"
android:layout_height="30dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.button.MaterialButton
android:id="@+id/dev_tool_btn"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:icon="@drawable/ic_developer_mode"
app:iconSize="32dp"
app:layout_constraintEnd_toStartOf="@+id/wallet_btn"
app:layout_constraintTop_toBottomOf="@+id/top_bar_offset_helper" />
<com.google.android.material.button.MaterialButton
android:id="@+id/wallet_btn"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:icon="@drawable/ic_balance_wallet"
app:iconSize="32dp"
app:layout_constraintEnd_toStartOf="@+id/settings_btn"
app:layout_constraintTop_toBottomOf="@+id/top_bar_offset_helper" />
<com.google.android.material.button.MaterialButton
android:id="@+id/settings_btn"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
app:icon="@drawable/ic_settings"
app:iconSize="32dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/top_bar_offset_helper" />
<View
android:id="@+id/view"
android:layout_width="26dp"
android:layout_height="4dp"
android:layout_marginBottom="8dp"
android:background="@drawable/scroll_handler_background"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="@+id/container_budget_for_today"
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,290 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.motion.widget.MotionLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="false"
app:layoutDescription="@xml/fragment_keyboard_scene">
<androidx.constraintlayout.widget.Guideline
android:id="@+id/rule_horiz_1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintGuide_percent="0" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/rule_horiz_2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintGuide_percent="0.24" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/rule_horiz_3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintGuide_percent="0.26792452" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/rule_horiz_4"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintGuide_percent="0.49" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/rule_horiz_5"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintGuide_percent="0.51" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/rule_horiz_6"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintGuide_percent="0.74" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/rule_horiz_7"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintGuide_percent="0.76" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/rule_horiz_8"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintGuide_percent="1" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/rule_vert_1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintGuide_percent="0" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/rule_vert_2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintGuide_percent="0.24" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/rule_vert_3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintGuide_percent="0.26" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/rule_vert_4"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintGuide_percent="0.49" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/rule_vert_5"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintGuide_percent="0.50835323" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/rule_vert_6"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintGuide_percent="0.74" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/rule_vert_7"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintGuide_percent="0.76" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/rule_vert_8"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintGuide_percent="1" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_7"
style="@style/Widget.Buckwheat.Button.Keyboard.Light"
android:layout_width="0dp"
android:layout_height="0dp"
android:text="7"
app:autoSizeMaxTextSize="60sp"
app:autoSizeTextType="uniform"
app:layout_constraintBottom_toTopOf="@id/rule_horiz_2"
app:layout_constraintEnd_toStartOf="@id/rule_vert_2"
app:layout_constraintStart_toStartOf="@id/rule_vert_1"
app:layout_constraintTop_toTopOf="@id/rule_horiz_1" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_8"
style="@style/Widget.Buckwheat.Button.Keyboard.Light"
android:layout_width="0dp"
android:layout_height="0dp"
app:autoSizeTextType="uniform"
app:autoSizeMaxTextSize="60sp"
android:text="8"
app:layout_constraintBottom_toTopOf="@id/rule_horiz_2"
app:layout_constraintEnd_toStartOf="@id/rule_vert_4"
app:layout_constraintStart_toStartOf="@id/rule_vert_3"
app:layout_constraintTop_toTopOf="@id/rule_horiz_1" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_9"
style="@style/Widget.Buckwheat.Button.Keyboard.Light"
android:layout_width="0dp"
android:layout_height="0dp"
app:autoSizeTextType="uniform"
app:autoSizeMaxTextSize="60sp"
android:text="9"
app:layout_constraintBottom_toTopOf="@id/rule_horiz_2"
app:layout_constraintEnd_toStartOf="@id/rule_vert_6"
app:layout_constraintStart_toStartOf="@id/rule_vert_5"
app:layout_constraintTop_toTopOf="@id/rule_horiz_1" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_backspace"
style="@style/Widget.Buckwheat.Button.Keyboard.IconOnly"
android:layout_width="0dp"
android:layout_height="0dp"
app:icon="@drawable/ic_backspace"
app:iconSize="40dp"
app:layout_constraintBottom_toTopOf="@id/rule_horiz_2"
app:layout_constraintEnd_toEndOf="@id/rule_vert_8"
app:layout_constraintStart_toStartOf="@id/rule_vert_7"
app:layout_constraintTop_toTopOf="@id/rule_horiz_1" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_4"
style="@style/Widget.Buckwheat.Button.Keyboard.Light"
android:layout_width="0dp"
android:layout_height="0dp"
app:autoSizeTextType="uniform"
app:autoSizeMaxTextSize="60sp"
android:text="4"
app:layout_constraintBottom_toTopOf="@id/rule_horiz_4"
app:layout_constraintEnd_toStartOf="@id/rule_vert_2"
app:layout_constraintStart_toStartOf="@id/rule_vert_1"
app:layout_constraintTop_toTopOf="@id/rule_horiz_3" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_5"
style="@style/Widget.Buckwheat.Button.Keyboard.Light"
android:layout_width="0dp"
android:layout_height="0dp"
app:autoSizeTextType="uniform"
app:autoSizeMaxTextSize="60sp"
android:text="5"
app:layout_constraintBottom_toTopOf="@id/rule_horiz_4"
app:layout_constraintEnd_toStartOf="@id/rule_vert_4"
app:layout_constraintStart_toStartOf="@id/rule_vert_3"
app:layout_constraintTop_toTopOf="@id/rule_horiz_3" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_6"
style="@style/Widget.Buckwheat.Button.Keyboard.Light"
android:layout_width="0dp"
android:layout_height="0dp"
app:autoSizeTextType="uniform"
app:autoSizeMaxTextSize="60sp"
android:text="6"
app:layout_constraintBottom_toTopOf="@id/rule_horiz_4"
app:layout_constraintEnd_toStartOf="@id/rule_vert_6"
app:layout_constraintStart_toStartOf="@id/rule_vert_5"
app:layout_constraintTop_toTopOf="@id/rule_horiz_3" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_1"
style="@style/Widget.Buckwheat.Button.Keyboard.Light"
android:layout_width="0dp"
android:layout_height="0dp"
app:autoSizeTextType="uniform"
app:autoSizeMaxTextSize="60sp"
android:text="1"
app:layout_constraintBottom_toTopOf="@id/rule_horiz_6"
app:layout_constraintEnd_toStartOf="@id/rule_vert_2"
app:layout_constraintStart_toStartOf="@id/rule_vert_1"
app:layout_constraintTop_toTopOf="@id/rule_horiz_5" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_2"
style="@style/Widget.Buckwheat.Button.Keyboard.Light"
android:layout_width="0dp"
android:layout_height="0dp"
app:autoSizeTextType="uniform"
app:autoSizeMaxTextSize="60sp"
android:text="2"
app:layout_constraintBottom_toTopOf="@id/rule_horiz_6"
app:layout_constraintEnd_toStartOf="@id/rule_vert_4"
app:layout_constraintStart_toStartOf="@id/rule_vert_3"
app:layout_constraintTop_toTopOf="@id/rule_horiz_5" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_3"
style="@style/Widget.Buckwheat.Button.Keyboard.Light"
android:layout_width="0dp"
android:layout_height="0dp"
app:autoSizeTextType="uniform"
app:autoSizeMaxTextSize="60sp"
android:text="3"
android:textSize="40sp"
app:layout_constraintBottom_toTopOf="@id/rule_horiz_6"
app:layout_constraintEnd_toStartOf="@id/rule_vert_6"
app:layout_constraintStart_toStartOf="@id/rule_vert_5"
app:layout_constraintTop_toTopOf="@id/rule_horiz_5" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_0"
style="@style/Widget.Buckwheat.Button.Keyboard.Light"
android:layout_width="0dp"
android:layout_height="0dp"
app:autoSizeTextType="uniform"
app:autoSizeMaxTextSize="60sp"
android:text="0"
app:layout_constraintBottom_toBottomOf="@id/rule_horiz_8"
app:layout_constraintEnd_toStartOf="@id/rule_vert_4"
app:layout_constraintStart_toStartOf="@id/rule_vert_1"
app:layout_constraintTop_toTopOf="@id/rule_horiz_7" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_dot"
style="@style/Widget.Buckwheat.Button.Keyboard.Light"
android:layout_width="0dp"
android:layout_height="0dp"
app:autoSizeTextType="uniform"
app:autoSizeMaxTextSize="60sp"
android:text="."
app:layout_constraintBottom_toBottomOf="@id/rule_horiz_8"
app:layout_constraintEnd_toStartOf="@id/rule_vert_6"
app:layout_constraintStart_toStartOf="@id/rule_vert_5"
app:layout_constraintTop_toTopOf="@id/rule_horiz_7" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_eval"
style="@style/Widget.Buckwheat.Button.Keyboard.IconOnly"
android:layout_width="0dp"
android:layout_height="0dp"
app:icon="@drawable/ic_apply"
app:layout_constraintBottom_toBottomOf="@id/rule_horiz_8"
app:layout_constraintEnd_toEndOf="@id/rule_vert_8"
app:layout_constraintStart_toEndOf="@id/rule_vert_7"
app:layout_constraintTop_toBottomOf="@id/rule_horiz_3" />
</androidx.constraintlayout.motion.widget.MotionLayout>

Some files were not shown because too many files have changed in this diff Show more