mirror of
https://github.com/danilkinkin/buckwheat.git
synced 2025-12-31 04:13:02 +00:00
feat: draft migrate
This commit is contained in:
parent
9ae12c2283
commit
9b6cc8add8
131 changed files with 6178 additions and 5086 deletions
|
|
@ -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
131
app/build.gradle.kts
Normal 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)
|
||||
}
|
||||
7
app/proguard-rules.pro
vendored
7
app/proguard-rules.pro
vendored
|
|
@ -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
|
||||
|
|
|
|||
22
app/src/androidTest/AndroidManifest.xml
Normal file
22
app/src/androidTest/AndroidManifest.xml
Normal 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>
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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" />
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
package com.danilkinkin.buckwheat
|
||||
|
||||
import android.app.Application
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
|
||||
@HiltAndroidApp
|
||||
class Application : Application()
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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!!
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
11
app/src/main/java/com/danilkinkin/buckwheat/base/Divider.kt
Normal file
11
app/src/main/java/com/danilkinkin/buckwheat/base/Divider.kt
Normal 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)
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
76
app/src/main/java/com/danilkinkin/buckwheat/base/TextRow.kt
Normal file
76
app/src/main/java/com/danilkinkin/buckwheat/base/TextRow.kt
Normal 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",
|
||||
)
|
||||
}
|
||||
}
|
||||
123
app/src/main/java/com/danilkinkin/buckwheat/calendar/Calendar.kt
Normal file
123
app/src/main/java/com/danilkinkin/buckwheat/calendar/Calendar.kt
Normal 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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
135
app/src/main/java/com/danilkinkin/buckwheat/calendar/Day.kt
Normal file
135
app/src/main/java/com/danilkinkin/buckwheat/calendar/Day.kt
Normal 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
|
||||
|
|
@ -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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
58
app/src/main/java/com/danilkinkin/buckwheat/calendar/Week.kt
Normal file
58
app/src/main/java/com/danilkinkin/buckwheat/calendar/Week.kt
Normal 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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.danilkinkin.buckwheat.calendar.model
|
||||
|
||||
import java.time.YearMonth
|
||||
|
||||
data class Month(
|
||||
val yearMonth: YearMonth,
|
||||
val weeks: List<Week>
|
||||
)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.danilkinkin.buckwheat.calendar.model
|
||||
|
||||
import java.time.YearMonth
|
||||
|
||||
data class Week(
|
||||
val number: Int,
|
||||
val yearMonth: YearMonth
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
@ -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 {
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package com.danilkinkin.buckwheat.entities
|
||||
package com.danilkinkin.buckwheat.data.entities
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
36
app/src/main/java/com/danilkinkin/buckwheat/di/AppModule.kt
Normal file
36
app/src/main/java/com/danilkinkin/buckwheat/di/AppModule.kt
Normal 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()
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
343
app/src/main/java/com/danilkinkin/buckwheat/editor/Editor.kt
Normal file
343
app/src/main/java/com/danilkinkin/buckwheat/editor/Editor.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
239
app/src/main/java/com/danilkinkin/buckwheat/home/MainScreen.kt
Normal file
239
app/src/main/java/com/danilkinkin/buckwheat/home/MainScreen.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
165
app/src/main/java/com/danilkinkin/buckwheat/keyboard/Keyboard.kt
Normal file
165
app/src/main/java/com/danilkinkin/buckwheat/keyboard/Keyboard.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
163
app/src/main/java/com/danilkinkin/buckwheat/settings/Settings.kt
Normal file
163
app/src/main/java/com/danilkinkin/buckwheat/settings/Settings.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
212
app/src/main/java/com/danilkinkin/buckwheat/topSheet/TopSheet.kt
Normal file
212
app/src/main/java/com/danilkinkin/buckwheat/topSheet/TopSheet.kt
Normal 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)
|
||||
}
|
||||
11
app/src/main/java/com/danilkinkin/buckwheat/ui/Color.kt
Normal file
11
app/src/main/java/com/danilkinkin/buckwheat/ui/Color.kt
Normal 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)
|
||||
13
app/src/main/java/com/danilkinkin/buckwheat/ui/Shape.kt
Normal file
13
app/src/main/java/com/danilkinkin/buckwheat/ui/Shape.kt
Normal 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),
|
||||
)
|
||||
67
app/src/main/java/com/danilkinkin/buckwheat/ui/Theme.kt
Normal file
67
app/src/main/java/com/danilkinkin/buckwheat/ui/Theme.kt
Normal 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
|
||||
)
|
||||
}
|
||||
147
app/src/main/java/com/danilkinkin/buckwheat/ui/Typography.kt
Normal file
147
app/src/main/java/com/danilkinkin/buckwheat/ui/Typography.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
919
app/src/main/java/com/danilkinkin/buckwheat/util/Swipeable.kt
Normal file
919
app/src/main/java/com/danilkinkin/buckwheat/util/Swipeable.kt
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
} */
|
||||
14
app/src/main/java/com/danilkinkin/buckwheat/util/colors.kt
Normal file
14
app/src/main/java/com/danilkinkin/buckwheat/util/colors.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package com.danilkinkin.buckwheat.utils
|
||||
package com.danilkinkin.buckwheat.util
|
||||
|
||||
import android.content.res.Resources
|
||||
import kotlin.math.roundToInt
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = { }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {})
|
||||
}
|
||||
}
|
||||
214
app/src/main/java/com/danilkinkin/buckwheat/wallet/Wallet.kt
Normal file
214
app/src/main/java/com/danilkinkin/buckwheat/wallet/Wallet.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = { }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
30
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
30
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue