0.5.0 - Browse + Library Screen + Settings
35
.idea/deploymentTargetDropDown.xml
generated
|
|
@ -3,7 +3,40 @@
|
||||||
<component name="deploymentTargetDropDown">
|
<component name="deploymentTargetDropDown">
|
||||||
<value>
|
<value>
|
||||||
<entry key="app">
|
<entry key="app">
|
||||||
<State />
|
<State>
|
||||||
|
<targetSelectedWithDropDown>
|
||||||
|
<Target>
|
||||||
|
<type value="QUICK_BOOT_TARGET" />
|
||||||
|
<deviceKey>
|
||||||
|
<Key>
|
||||||
|
<type value="VIRTUAL_DEVICE_PATH" />
|
||||||
|
<value value="$USER_HOME$/.android/avd/Phone_13_.avd" />
|
||||||
|
</Key>
|
||||||
|
</deviceKey>
|
||||||
|
</Target>
|
||||||
|
</targetSelectedWithDropDown>
|
||||||
|
<timeTargetWasSelectedWithDropDown value="2024-02-15T11:58:02.950581796Z" />
|
||||||
|
<targetsSelectedWithDialog>
|
||||||
|
<Target>
|
||||||
|
<type value="QUICK_BOOT_TARGET" />
|
||||||
|
<deviceKey>
|
||||||
|
<Key>
|
||||||
|
<type value="VIRTUAL_DEVICE_PATH" />
|
||||||
|
<value value="C:\Users\Administrator\.android\avd\Resizable_Experimental_API_33.avd" />
|
||||||
|
</Key>
|
||||||
|
</deviceKey>
|
||||||
|
</Target>
|
||||||
|
<Target>
|
||||||
|
<type value="QUICK_BOOT_TARGET" />
|
||||||
|
<deviceKey>
|
||||||
|
<Key>
|
||||||
|
<type value="VIRTUAL_DEVICE_PATH" />
|
||||||
|
<value value="C:\Users\Administrator\.android\avd\Pixel_2_API_26.avd" />
|
||||||
|
</Key>
|
||||||
|
</deviceKey>
|
||||||
|
</Target>
|
||||||
|
</targetsSelectedWithDialog>
|
||||||
|
</State>
|
||||||
</entry>
|
</entry>
|
||||||
</value>
|
</value>
|
||||||
</component>
|
</component>
|
||||||
|
|
|
||||||
2
.idea/gradle.xml
generated
|
|
@ -5,7 +5,7 @@
|
||||||
<option name="linkedExternalProjectsSettings">
|
<option name="linkedExternalProjectsSettings">
|
||||||
<GradleProjectSettings>
|
<GradleProjectSettings>
|
||||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
|
<option name="gradleJvm" value="jbr-17" />
|
||||||
<option name="modules">
|
<option name="modules">
|
||||||
<set>
|
<set>
|
||||||
<option value="$PROJECT_DIR$" />
|
<option value="$PROJECT_DIR$" />
|
||||||
|
|
|
||||||
1
.idea/misc.xml
generated
|
|
@ -1,4 +1,3 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="EntryPointsManager">
|
<component name="EntryPointsManager">
|
||||||
<list size="4">
|
<list size="4">
|
||||||
|
|
|
||||||
1
app/.gitignore
vendored
|
|
@ -1 +1,2 @@
|
||||||
/build
|
/build
|
||||||
|
gradle.properties
|
||||||
|
|
@ -5,6 +5,8 @@ plugins {
|
||||||
|
|
||||||
id("com.google.dagger.hilt.android")
|
id("com.google.dagger.hilt.android")
|
||||||
kotlin("kapt")
|
kotlin("kapt")
|
||||||
|
|
||||||
|
id("kotlin-parcelize")
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
|
@ -16,7 +18,7 @@ android {
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "0.2.0"
|
versionName = "0.5.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
|
|
@ -31,6 +33,7 @@ android {
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro"
|
"proguard-rules.pro"
|
||||||
)
|
)
|
||||||
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
compileOptions {
|
compileOptions {
|
||||||
|
|
@ -49,6 +52,8 @@ android {
|
||||||
packaging {
|
packaging {
|
||||||
resources {
|
resources {
|
||||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||||
|
excludes += "/META-INF/LICENSE.md"
|
||||||
|
excludes += "/META-INF/LICENSE-notice.md"
|
||||||
excludes += "/META-INF/gradle/incremental.annotation.processors"
|
excludes += "/META-INF/gradle/incremental.annotation.processors"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -62,57 +67,75 @@ dependencies {
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
|
||||||
implementation("androidx.activity:activity-compose:1.8.2")
|
implementation("androidx.activity:activity-compose:1.8.2")
|
||||||
|
|
||||||
implementation(platform("androidx.compose:compose-bom:latest.release"))
|
implementation(platform("androidx.compose:compose-bom:2024.01.00"))
|
||||||
implementation("androidx.compose.ui:ui:$composeVersion")
|
implementation("androidx.compose.ui:ui:$composeVersion")
|
||||||
implementation("androidx.compose.ui:ui-graphics:$composeVersion")
|
implementation("androidx.compose.ui:ui-graphics:$composeVersion")
|
||||||
implementation("androidx.compose.ui:ui-tooling-preview:$composeVersion")
|
implementation("androidx.compose.ui:ui-tooling-preview:$composeVersion")
|
||||||
implementation("androidx.compose.ui:ui-android:latest.release")
|
implementation("androidx.compose.ui:ui-android:1.6.0")
|
||||||
implementation("androidx.compose.material3:material3")
|
implementation("androidx.compose.material3:material3:1.1.2")
|
||||||
|
implementation("androidx.compose.material:material:1.6.0")
|
||||||
|
|
||||||
// Unit tests
|
// Unit tests
|
||||||
testImplementation ("androidx.test:core:latest.release")
|
testImplementation("androidx.test:core:latest.release")
|
||||||
testImplementation ("junit:junit:latest.release")
|
testImplementation("junit:junit:latest.release")
|
||||||
testImplementation ("androidx.arch.core:core-testing:latest.release")
|
testImplementation("androidx.arch.core:core-testing:latest.release")
|
||||||
testImplementation ("org.jetbrains.kotlinx:kotlinx-coroutines-test:latest.release")
|
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:latest.release")
|
||||||
testImplementation ("com.google.truth:truth:1.2.0")
|
testImplementation("com.google.truth:truth:1.2.0")
|
||||||
testImplementation ("androidx.test.ext:truth:1.5.0")
|
testImplementation("androidx.test.ext:truth:1.5.0")
|
||||||
testImplementation ("com.squareup.okhttp3:mockwebserver:latest.release")
|
testImplementation("com.squareup.okhttp3:mockwebserver:latest.release")
|
||||||
testImplementation ("io.mockk:mockk:latest.release")
|
testImplementation("io.mockk:mockk:latest.release")
|
||||||
debugImplementation ("androidx.compose.ui:ui-test-manifest:latest.release")
|
debugImplementation("androidx.compose.ui:ui-test-manifest:1.5.4")
|
||||||
|
|
||||||
// Instrumentation tests
|
// Instrumentation tests
|
||||||
androidTestImplementation ("com.google.dagger:hilt-android-testing:latest.release")
|
androidTestImplementation("com.google.dagger:hilt-android-testing:latest.release")
|
||||||
kaptAndroidTest ("com.google.dagger:hilt-android-compiler:latest.release")
|
kaptAndroidTest("com.google.dagger:hilt-android-compiler:latest.release")
|
||||||
androidTestImplementation ("junit:junit:latest.release")
|
androidTestImplementation("junit:junit:latest.release")
|
||||||
androidTestImplementation ("org.jetbrains.kotlinx:kotlinx-coroutines-test:latest.release")
|
androidTestImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:latest.release")
|
||||||
androidTestImplementation ("androidx.arch.core:core-testing:latest.release")
|
androidTestImplementation("androidx.arch.core:core-testing:latest.release")
|
||||||
androidTestImplementation ("com.google.truth:truth:latest.release")
|
androidTestImplementation("com.google.truth:truth:latest.release")
|
||||||
androidTestImplementation ("androidx.test.ext:junit:latest.release")
|
androidTestImplementation("androidx.test.ext:junit:latest.release")
|
||||||
androidTestImplementation ("androidx.test:core-ktx:latest.release")
|
androidTestImplementation("androidx.test:core-ktx:latest.release")
|
||||||
androidTestImplementation ("com.squareup.okhttp3:mockwebserver:latest.release")
|
androidTestImplementation("com.squareup.okhttp3:mockwebserver:latest.release")
|
||||||
androidTestImplementation ("io.mockk:mockk-android:latest.release")
|
androidTestImplementation("io.mockk:mockk-android:latest.release")
|
||||||
androidTestImplementation ("androidx.test:runner:latest.release")
|
androidTestImplementation("androidx.test:runner:latest.release")
|
||||||
|
|
||||||
// All dependencies
|
// All dependencies
|
||||||
implementation ("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2")
|
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2")
|
||||||
implementation ("androidx.compose.material:material-icons-extended")
|
implementation("androidx.compose.material:material-icons-extended:1.5.4")
|
||||||
implementation ("androidx.activity:activity-compose:1.8.2")
|
implementation("androidx.activity:activity-compose:1.8.2")
|
||||||
implementation ("com.google.accompanist:accompanist-swiperefresh:0.24.2-alpha")
|
implementation("com.google.accompanist:accompanist-swiperefresh:0.24.2-alpha")
|
||||||
|
|
||||||
// Dagger
|
// Dagger - Hilt
|
||||||
implementation("com.google.dagger:hilt-android:2.50")
|
implementation("com.google.dagger:hilt-android:2.50")
|
||||||
kapt("com.google.dagger:hilt-android-compiler:2.50")
|
kapt("com.google.dagger:hilt-android-compiler:2.50")
|
||||||
|
implementation("com.google.dagger:hilt-compiler:latest.release")
|
||||||
|
kapt("androidx.hilt:hilt-compiler:latest.release")
|
||||||
|
implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
|
||||||
|
|
||||||
// Room
|
// Room
|
||||||
implementation ("androidx.room:room-runtime:2.6.1")
|
implementation("androidx.room:room-runtime:2.6.1")
|
||||||
ksp ("androidx.room:room-compiler:2.6.1")
|
ksp("androidx.room:room-compiler:2.6.1")
|
||||||
|
|
||||||
// Kotlin Extensions and Coroutines support for Room
|
// Kotlin Extensions and Coroutines support for Room
|
||||||
implementation ("androidx.room:room-ktx:2.6.1")
|
implementation("androidx.room:room-ktx:2.6.1")
|
||||||
|
|
||||||
// Datastore
|
// Datastore
|
||||||
implementation ("androidx.datastore:datastore-preferences:latest.release")
|
implementation("androidx.datastore:datastore-preferences:latest.release")
|
||||||
|
|
||||||
// Splash API
|
// Splash API
|
||||||
implementation ("androidx.core:core-splashscreen:latest.release")
|
implementation("androidx.core:core-splashscreen:latest.release")
|
||||||
|
|
||||||
|
// Permission Handling
|
||||||
|
implementation("com.google.accompanist:accompanist-permissions:latest.release")
|
||||||
|
|
||||||
|
// Pdf parser
|
||||||
|
implementation("com.tom-roush:pdfbox-android:latest.release")
|
||||||
|
|
||||||
|
// Epub parser
|
||||||
|
implementation("nl.siegmann.epublib:epublib-core:latest.release") {
|
||||||
|
exclude("org.slf4j")
|
||||||
|
exclude("xmlpull")
|
||||||
|
}
|
||||||
|
implementation("org.slf4j:slf4j-android:latest.release")
|
||||||
|
implementation("org.jsoup:jsoup:latest.release")
|
||||||
}
|
}
|
||||||
|
|
@ -2,11 +2,15 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
|
||||||
|
tools:ignore="ScopedStorage" />
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".util.BookApplication"
|
android:name=".util.BookApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
|
||||||
android:icon="@mipmap/app_icon"
|
android:icon="@mipmap/app_icon"
|
||||||
android:roundIcon="@mipmap/app_icon_round"
|
android:roundIcon="@mipmap/app_icon_round"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
|
|
@ -16,11 +20,12 @@
|
||||||
tools:targetApi="tiramisu">
|
tools:targetApi="tiramisu">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
|
android:configChanges="screenSize|screenLayout|smallestScreenSize|locale|density"
|
||||||
|
android:windowSoftInputMode="adjustResize"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:theme="@style/Theme.Start.Splash">
|
android:theme="@style/Theme.Start.Splash">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,41 @@
|
||||||
package com.acclorite.books_history
|
package com.acclorite.books_history
|
||||||
|
|
||||||
|
import android.content.res.Configuration
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
import com.acclorite.books_history.presentation.MainViewModel
|
import com.acclorite.books_history.domain.model.Book
|
||||||
import com.acclorite.books_history.presentation.NavigationHost
|
import com.acclorite.books_history.presentation.NavigationHost
|
||||||
import com.acclorite.books_history.presentation.Screen
|
import com.acclorite.books_history.presentation.Screen
|
||||||
import com.acclorite.books_history.ui.theme.BooksHistoryResurrectionTheme
|
import com.acclorite.books_history.presentation.components.BottomNavigationBar
|
||||||
import com.acclorite.books_history.ui.theme.isDark
|
import com.acclorite.books_history.presentation.main.MainEvent
|
||||||
|
import com.acclorite.books_history.presentation.main.MainViewModel
|
||||||
|
import com.acclorite.books_history.presentation.screens.browse.BrowseScreen
|
||||||
|
import com.acclorite.books_history.presentation.screens.library.LibraryScreen
|
||||||
|
import com.acclorite.books_history.presentation.screens.settings.SettingsScreen
|
||||||
|
import com.acclorite.books_history.presentation.screens.settings.nested.appearance.AppearanceSettings
|
||||||
|
import com.acclorite.books_history.presentation.screens.settings.nested.general.GeneralSettings
|
||||||
|
import com.acclorite.books_history.ui.BooksHistoryResurrectionTheme
|
||||||
|
import com.acclorite.books_history.ui.DefaultTransition
|
||||||
|
import com.acclorite.books_history.ui.Transitions
|
||||||
|
import com.acclorite.books_history.ui.isDark
|
||||||
|
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
|
||||||
|
@OptIn(ExperimentalPermissionsApi::class)
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
private val mainViewModel: MainViewModel by viewModels()
|
private val mainViewModel: MainViewModel by viewModels()
|
||||||
|
|
@ -26,8 +49,10 @@ class MainActivity : ComponentActivity() {
|
||||||
!mainViewModel.isReady.value
|
!mainViewModel.isReady.value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
enableEdgeToEdge()
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
|
val updating = mainViewModel.updating.collectAsState().value
|
||||||
val theme = mainViewModel.theme.collectAsState().value!!
|
val theme = mainViewModel.theme.collectAsState().value!!
|
||||||
val darkTheme = mainViewModel.darkTheme.collectAsState().value!!
|
val darkTheme = mainViewModel.darkTheme.collectAsState().value!!
|
||||||
|
|
||||||
|
|
@ -35,10 +60,90 @@ class MainActivity : ComponentActivity() {
|
||||||
theme = theme,
|
theme = theme,
|
||||||
isDark = darkTheme.isDark()
|
isDark = darkTheme.isDark()
|
||||||
) {
|
) {
|
||||||
NavigationHost(startScreen = Screen.LIBRARY, this) {
|
if (!updating) {
|
||||||
|
NavigationHost(startScreen = Screen.LIBRARY) {
|
||||||
|
val currentScreen by this.getCurrentScreen().collectAsState()
|
||||||
|
DefaultTransition(
|
||||||
|
visible = currentScreen == Screen.LIBRARY ||
|
||||||
|
currentScreen == Screen.HISTORY ||
|
||||||
|
currentScreen == Screen.BROWSE
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
bottomBar = {
|
||||||
|
BottomNavigationBar(navigator = this)
|
||||||
|
},
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(bottom = it.calculateBottomPadding())
|
||||||
|
) {
|
||||||
|
composable(screen = Screen.LIBRARY) {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
LibraryScreen(
|
||||||
|
navigator = this@NavigationHost,
|
||||||
|
addedBooks = retrieveArgument("added_books") as? List<Book>
|
||||||
|
?: emptyList()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(screen = Screen.BROWSE) {
|
||||||
|
BrowseScreen(navigator = this@NavigationHost)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
composable(
|
||||||
|
screen = Screen.SETTINGS,
|
||||||
|
enterAnim = Transitions.SlidingTransitionIn,
|
||||||
|
exitAnim = Transitions.SlidingTransitionOut
|
||||||
|
) {
|
||||||
|
SettingsScreen(
|
||||||
|
viewModel = mainViewModel,
|
||||||
|
navigator = this@NavigationHost
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(
|
||||||
|
screen = Screen.GENERAL_SETTINGS,
|
||||||
|
enterAnim = Transitions.SlidingTransitionIn,
|
||||||
|
exitAnim = Transitions.SlidingTransitionOut
|
||||||
|
) {
|
||||||
|
GeneralSettings(
|
||||||
|
mainViewModel = mainViewModel,
|
||||||
|
navigator = this@NavigationHost
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(
|
||||||
|
screen = Screen.APPEARANCE_SETTINGS,
|
||||||
|
enterAnim = Transitions.SlidingTransitionIn,
|
||||||
|
exitAnim = Transitions.SlidingTransitionOut
|
||||||
|
) {
|
||||||
|
AppearanceSettings(
|
||||||
|
mainViewModel = mainViewModel,
|
||||||
|
navigator = this@NavigationHost
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(
|
||||||
|
if (darkTheme.isDark()) Color.Black
|
||||||
|
else Color.White
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||||
|
super.onConfigurationChanged(newConfig)
|
||||||
|
mainViewModel.onEvent(MainEvent.OnLocaleUpdate(this))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -25,6 +25,11 @@ interface BookDao {
|
||||||
)
|
)
|
||||||
suspend fun searchBooks(query: String): List<BookEntity>
|
suspend fun searchBooks(query: String): List<BookEntity>
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT * FROM bookentity WHERE id=:id"
|
||||||
|
)
|
||||||
|
suspend fun findBookById(id: Int): BookEntity
|
||||||
|
|
||||||
@Delete
|
@Delete
|
||||||
suspend fun deleteBooks(books: List<BookEntity>)
|
suspend fun deleteBooks(books: List<BookEntity>)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
package com.acclorite.books_history.data.mapper
|
package com.acclorite.books_history.data.mapper
|
||||||
|
|
||||||
import com.acclorite.books_history.data.local.dto.BookEntity
|
import com.acclorite.books_history.data.local.dto.BookEntity
|
||||||
|
import com.acclorite.books_history.data.local.room.BookDao
|
||||||
import com.acclorite.books_history.domain.model.Book
|
import com.acclorite.books_history.domain.model.Book
|
||||||
|
|
||||||
interface BookMapper {
|
interface BookMapper {
|
||||||
fun toBookEntity(book: Book): BookEntity
|
suspend fun toBookEntity(book: Book, database: BookDao): BookEntity
|
||||||
|
|
||||||
fun toBook(bookEntity: BookEntity): Book
|
suspend fun toBook(bookEntity: BookEntity): Book
|
||||||
}
|
}
|
||||||
|
|
@ -1,15 +1,94 @@
|
||||||
package com.acclorite.books_history.data.mapper
|
package com.acclorite.books_history.data.mapper
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import com.acclorite.books_history.R
|
||||||
import com.acclorite.books_history.data.local.dto.BookEntity
|
import com.acclorite.books_history.data.local.dto.BookEntity
|
||||||
|
import com.acclorite.books_history.data.local.room.BookDao
|
||||||
|
import com.acclorite.books_history.data.parser.EpubFileParser
|
||||||
|
import com.acclorite.books_history.data.parser.PdfFileParser
|
||||||
|
import com.acclorite.books_history.data.parser.TxtFileParser
|
||||||
import com.acclorite.books_history.domain.model.Book
|
import com.acclorite.books_history.domain.model.Book
|
||||||
|
import com.acclorite.books_history.util.UIText
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.io.File
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class BookMapperImpl @Inject constructor(): BookMapper {
|
class BookMapperImpl @Inject constructor(
|
||||||
override fun toBookEntity(book: Book): BookEntity {
|
private val txtFileParser: TxtFileParser,
|
||||||
TODO("Not yet implemented")
|
private val pdfFileParser: PdfFileParser,
|
||||||
|
private val epubFileParser: EpubFileParser
|
||||||
|
) : BookMapper {
|
||||||
|
override suspend fun toBookEntity(book: Book, database: BookDao): BookEntity {
|
||||||
|
val stream = ByteArrayOutputStream()
|
||||||
|
book.coverImage?.compress(Bitmap.CompressFormat.JPEG, 50, stream)
|
||||||
|
|
||||||
|
if (book.file == null) {
|
||||||
|
val bookWithoutFile = database.findBookById(book.id!!)
|
||||||
|
|
||||||
|
return BookEntity(
|
||||||
|
id = book.id,
|
||||||
|
title = book.title,
|
||||||
|
filePath = bookWithoutFile.filePath,
|
||||||
|
progress = book.progress,
|
||||||
|
lastOpened = book.lastOpened,
|
||||||
|
image = stream.toByteArray(),
|
||||||
|
category = book.category
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun toBook(bookEntity: BookEntity): Book {
|
return BookEntity(
|
||||||
TODO("Not yet implemented")
|
id = book.id,
|
||||||
|
title = book.title,
|
||||||
|
filePath = book.file.path,
|
||||||
|
progress = book.progress,
|
||||||
|
lastOpened = book.lastOpened,
|
||||||
|
image = stream.toByteArray(),
|
||||||
|
category = book.category
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun toBook(bookEntity: BookEntity): Book {
|
||||||
|
val file = File(bookEntity.filePath)
|
||||||
|
val inputStream = ByteArrayInputStream(bookEntity.image)
|
||||||
|
val image = BitmapFactory.decodeStream(inputStream)
|
||||||
|
|
||||||
|
val parsedBook = if (file.name.endsWith(".txt")) {
|
||||||
|
txtFileParser.parse(file)
|
||||||
|
} else if (file.name.endsWith(".pdf")) {
|
||||||
|
pdfFileParser.parse(file)
|
||||||
|
} else if (file.name.endsWith(".epub")) {
|
||||||
|
epubFileParser.parse(file)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
return if (parsedBook != null)
|
||||||
|
Book(
|
||||||
|
id = bookEntity.id,
|
||||||
|
title = bookEntity.title,
|
||||||
|
author = parsedBook.author,
|
||||||
|
description = parsedBook.description,
|
||||||
|
text = emptyList(),
|
||||||
|
progress = bookEntity.progress,
|
||||||
|
file = file,
|
||||||
|
lastOpened = bookEntity.lastOpened,
|
||||||
|
category = bookEntity.category,
|
||||||
|
coverImage = if (bookEntity.image.isNotEmpty()) image else null
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Book(
|
||||||
|
id = bookEntity.id,
|
||||||
|
title = bookEntity.title,
|
||||||
|
author = UIText.StringResource(R.string.unknown_author),
|
||||||
|
text = emptyList(),
|
||||||
|
description = null,
|
||||||
|
progress = bookEntity.progress,
|
||||||
|
file = null,
|
||||||
|
lastOpened = bookEntity.lastOpened,
|
||||||
|
category = bookEntity.category,
|
||||||
|
coverImage = if (bookEntity.image.isNotEmpty()) image else null
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
package com.acclorite.books_history.data.parser
|
||||||
|
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import com.acclorite.books_history.domain.model.Book
|
||||||
|
import com.acclorite.books_history.domain.model.Category
|
||||||
|
import com.acclorite.books_history.util.UIText
|
||||||
|
import nl.siegmann.epublib.epub.EpubReader
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileInputStream
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class EpubFileParser @Inject constructor() : FileParser {
|
||||||
|
|
||||||
|
override suspend fun parse(file: File): Book? {
|
||||||
|
if (!file.name.endsWith(".epub")) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val epubReader = EpubReader()
|
||||||
|
val book = epubReader.readEpub(FileInputStream(file))
|
||||||
|
|
||||||
|
val metadata = book.metadata
|
||||||
|
val author = UIText.StringValue(metadata.authors.joinToString(", "))
|
||||||
|
val title = metadata.titles.joinToString(", ")
|
||||||
|
val coverImage = book.coverImage?.let { BitmapFactory.decodeStream(it.inputStream) }
|
||||||
|
|
||||||
|
var description: StringBuilder? = StringBuilder()
|
||||||
|
metadata.descriptions.forEach {
|
||||||
|
description?.append(it)?.append(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (description != null) {
|
||||||
|
if (description.isEmpty()) {
|
||||||
|
description = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Book(
|
||||||
|
id = null,
|
||||||
|
title = title,
|
||||||
|
author = author,
|
||||||
|
description = description?.toString(),
|
||||||
|
text = emptyList(),
|
||||||
|
progress = 0f,
|
||||||
|
file = file,
|
||||||
|
lastOpened = null,
|
||||||
|
category = Category.entries[0],
|
||||||
|
coverImage = coverImage
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
package com.acclorite.books_history.data.parser
|
||||||
|
|
||||||
|
import com.acclorite.books_history.domain.model.StringWithId
|
||||||
|
import nl.siegmann.epublib.epub.EpubReader
|
||||||
|
import org.jsoup.Jsoup
|
||||||
|
import java.io.BufferedReader
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileInputStream
|
||||||
|
import java.io.InputStreamReader
|
||||||
|
import java.nio.charset.Charset
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class EpubTextParser @Inject constructor() : TextParser {
|
||||||
|
|
||||||
|
override suspend fun parse(file: File): List<StringWithId> {
|
||||||
|
if (!file.name.endsWith(".epub")) {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val book = EpubReader().readEpub(FileInputStream(file))
|
||||||
|
val unformattedText = StringBuilder()
|
||||||
|
|
||||||
|
for (spineReference in book.spine.spineReferences) {
|
||||||
|
val resource = spineReference.resource
|
||||||
|
val inputStream = resource.inputStream
|
||||||
|
val reader =
|
||||||
|
BufferedReader(InputStreamReader(inputStream, Charset.forName("UTF-8")))
|
||||||
|
var line: String?
|
||||||
|
|
||||||
|
while (reader.readLine().also { line = it } != null) {
|
||||||
|
unformattedText.append(line).append("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val stringWithIds = mutableListOf<StringWithId>()
|
||||||
|
|
||||||
|
Jsoup.parse(unformattedText.toString()).wholeText().split("\n").forEach {
|
||||||
|
if (it.isNotBlank()) {
|
||||||
|
stringWithIds.add(StringWithId(it.trim()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stringWithIds
|
||||||
|
} catch (e: Exception) {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
package com.acclorite.books_history.data.parser
|
||||||
|
|
||||||
|
import com.acclorite.books_history.domain.model.Book
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
interface FileParser {
|
||||||
|
|
||||||
|
suspend fun parse(file: File): Book?
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
package com.acclorite.books_history.data.parser
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import com.acclorite.books_history.R
|
||||||
|
import com.acclorite.books_history.domain.model.Book
|
||||||
|
import com.acclorite.books_history.domain.model.Category
|
||||||
|
import com.acclorite.books_history.domain.model.StringWithId
|
||||||
|
import com.acclorite.books_history.util.UIText
|
||||||
|
import com.tom_roush.pdfbox.android.PDFBoxResourceLoader
|
||||||
|
import com.tom_roush.pdfbox.pdmodel.PDDocument
|
||||||
|
import java.io.File
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class PdfFileParser @Inject constructor(private val application: Application) : FileParser {
|
||||||
|
|
||||||
|
override suspend fun parse(file: File): Book? {
|
||||||
|
if (!file.name.endsWith(".pdf")) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
PDFBoxResourceLoader.init(application)
|
||||||
|
|
||||||
|
val document = PDDocument.load(file)
|
||||||
|
|
||||||
|
val title = document.documentInformation.title ?: file.name.dropLast(4).trim()
|
||||||
|
val fileAuthor = document.documentInformation.author
|
||||||
|
val author = if (fileAuthor != null) UIText.StringValue(fileAuthor)
|
||||||
|
else UIText.StringResource(R.string.unknown_author)
|
||||||
|
|
||||||
|
val text = emptyList<StringWithId>()
|
||||||
|
val description = document.documentInformation.subject ?: null
|
||||||
|
|
||||||
|
document.close()
|
||||||
|
|
||||||
|
return Book(
|
||||||
|
id = null,
|
||||||
|
title = title,
|
||||||
|
author = author,
|
||||||
|
description = description,
|
||||||
|
text = text,
|
||||||
|
progress = 0f,
|
||||||
|
file = file,
|
||||||
|
lastOpened = null,
|
||||||
|
category = Category.entries[0],
|
||||||
|
coverImage = null
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
package com.acclorite.books_history.data.parser
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import com.acclorite.books_history.domain.model.StringWithId
|
||||||
|
import com.tom_roush.pdfbox.android.PDFBoxResourceLoader
|
||||||
|
import com.tom_roush.pdfbox.pdmodel.PDDocument
|
||||||
|
import com.tom_roush.pdfbox.text.PDFTextStripper
|
||||||
|
import java.io.File
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class PdfTextParser @Inject constructor(private val application: Application) : TextParser {
|
||||||
|
|
||||||
|
override suspend fun parse(file: File): List<StringWithId> {
|
||||||
|
if (!file.name.endsWith(".pdf")) {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
PDFBoxResourceLoader.init(application)
|
||||||
|
|
||||||
|
val document = PDDocument.load(file)
|
||||||
|
val stringWithIds = mutableListOf<StringWithId>()
|
||||||
|
|
||||||
|
val pdfStripper = PDFTextStripper()
|
||||||
|
pdfStripper.paragraphStart = "_new_line_"
|
||||||
|
|
||||||
|
val oldText = pdfStripper.getText(document)
|
||||||
|
.replace("\r", "")
|
||||||
|
|
||||||
|
document.close()
|
||||||
|
|
||||||
|
val text = oldText.filterIndexed { index, c ->
|
||||||
|
if (c == ' ') {
|
||||||
|
oldText[index - 1] != ' '
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val unformattedLines = text.split("${pdfStripper.paragraphStart}|\\n".toRegex())
|
||||||
|
.filter { it.isNotEmpty() }
|
||||||
|
|
||||||
|
val lines = mutableListOf<String>()
|
||||||
|
unformattedLines.forEachIndexed { index, string ->
|
||||||
|
if (string.trim().first().isLowerCase() && index > 0) {
|
||||||
|
lines[lines.lastIndex] += " ${string.trim()}"
|
||||||
|
} else {
|
||||||
|
lines.add(string.trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.forEach { line ->
|
||||||
|
stringWithIds.add(StringWithId(line.trim()))
|
||||||
|
}
|
||||||
|
|
||||||
|
return stringWithIds
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
package com.acclorite.books_history.data.parser
|
||||||
|
|
||||||
|
import com.acclorite.books_history.domain.model.StringWithId
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
interface TextParser {
|
||||||
|
suspend fun parse(file: File): List<StringWithId>
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
package com.acclorite.books_history.data.parser
|
||||||
|
|
||||||
|
import com.acclorite.books_history.R
|
||||||
|
import com.acclorite.books_history.domain.model.Book
|
||||||
|
import com.acclorite.books_history.domain.model.Category
|
||||||
|
import com.acclorite.books_history.domain.model.StringWithId
|
||||||
|
import com.acclorite.books_history.util.UIText
|
||||||
|
import java.io.File
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class TxtFileParser @Inject constructor() : FileParser {
|
||||||
|
|
||||||
|
override suspend fun parse(file: File): Book? {
|
||||||
|
if (!file.name.endsWith(".txt")) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val title = file.name.trim().dropLast(4)
|
||||||
|
val author = UIText.StringResource(R.string.unknown_author)
|
||||||
|
val text = emptyList<StringWithId>()
|
||||||
|
|
||||||
|
return Book(
|
||||||
|
id = null,
|
||||||
|
title = title,
|
||||||
|
author = author,
|
||||||
|
description = null,
|
||||||
|
text = text,
|
||||||
|
progress = 0f,
|
||||||
|
file = file,
|
||||||
|
lastOpened = null,
|
||||||
|
category = Category.entries[0],
|
||||||
|
coverImage = null
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
package com.acclorite.books_history.data.parser
|
||||||
|
|
||||||
|
import com.acclorite.books_history.domain.model.StringWithId
|
||||||
|
import java.io.BufferedReader
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileReader
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class TxtTextParser @Inject constructor() : TextParser {
|
||||||
|
|
||||||
|
override suspend fun parse(file: File): List<StringWithId> {
|
||||||
|
if (!file.name.endsWith(".txt")) {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val formattedLines = mutableListOf<StringWithId>()
|
||||||
|
|
||||||
|
BufferedReader(FileReader(file)).forEachLine { line ->
|
||||||
|
formattedLines.add(
|
||||||
|
StringWithId(line)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
formattedLines
|
||||||
|
} catch (e: Exception) {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,26 @@
|
||||||
package com.acclorite.books_history.data.repository
|
package com.acclorite.books_history.data.repository
|
||||||
|
|
||||||
|
import android.os.Environment
|
||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
import com.acclorite.books_history.data.local.data_store.DataStore
|
import com.acclorite.books_history.data.local.data_store.DataStore
|
||||||
|
import com.acclorite.books_history.data.local.dto.BookEntity
|
||||||
import com.acclorite.books_history.data.local.room.BookDao
|
import com.acclorite.books_history.data.local.room.BookDao
|
||||||
import com.acclorite.books_history.data.mapper.BookMapper
|
import com.acclorite.books_history.data.mapper.BookMapper
|
||||||
|
import com.acclorite.books_history.data.parser.EpubFileParser
|
||||||
|
import com.acclorite.books_history.data.parser.EpubTextParser
|
||||||
|
import com.acclorite.books_history.data.parser.PdfFileParser
|
||||||
|
import com.acclorite.books_history.data.parser.PdfTextParser
|
||||||
|
import com.acclorite.books_history.data.parser.TxtFileParser
|
||||||
|
import com.acclorite.books_history.data.parser.TxtTextParser
|
||||||
import com.acclorite.books_history.domain.model.Book
|
import com.acclorite.books_history.domain.model.Book
|
||||||
|
import com.acclorite.books_history.domain.model.NullableBook
|
||||||
|
import com.acclorite.books_history.domain.model.StringWithId
|
||||||
import com.acclorite.books_history.domain.repository.BookRepository
|
import com.acclorite.books_history.domain.repository.BookRepository
|
||||||
|
import com.acclorite.books_history.util.Constants
|
||||||
import com.acclorite.books_history.util.Resource
|
import com.acclorite.books_history.util.Resource
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import java.io.File
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
|
@ -16,23 +29,52 @@ class BookRepositoryImpl @Inject constructor(
|
||||||
private val database: BookDao,
|
private val database: BookDao,
|
||||||
private val dataStore: DataStore,
|
private val dataStore: DataStore,
|
||||||
|
|
||||||
private val bookMapper: BookMapper
|
private val bookMapper: BookMapper,
|
||||||
) : BookRepository {
|
|
||||||
|
private val txtFileParser: TxtFileParser,
|
||||||
|
private val pdfFileParser: PdfFileParser,
|
||||||
|
private val epubFileParser: EpubFileParser,
|
||||||
|
|
||||||
|
private val txtTextParser: TxtTextParser,
|
||||||
|
private val pdfTextParser: PdfTextParser,
|
||||||
|
private val epubTextParser: EpubTextParser,
|
||||||
|
|
||||||
|
) : BookRepository {
|
||||||
|
|
||||||
override suspend fun getBooks(query: String): Flow<Resource<List<Book>>> {
|
override suspend fun getBooks(query: String): Flow<Resource<List<Book>>> {
|
||||||
TODO("Not yet implemented")
|
return flow {
|
||||||
|
emit(Resource.Loading(true))
|
||||||
|
|
||||||
|
val books = database.searchBooks(query)
|
||||||
|
emit(
|
||||||
|
Resource.Success(
|
||||||
|
data = books.map { bookMapper.toBook(it) }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun fastGetBooks(query: String): List<Book> {
|
||||||
|
val books = database.searchBooks(query)
|
||||||
|
return books.map { bookMapper.toBook(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun findBook(
|
||||||
|
id: Int
|
||||||
|
): BookEntity {
|
||||||
|
return database.findBookById(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun insertBooks(books: List<Book>) {
|
override suspend fun insertBooks(books: List<Book>) {
|
||||||
database.insertBooks(books.map { bookMapper.toBookEntity(it) })
|
database.insertBooks(books.map { bookMapper.toBookEntity(it, database) })
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun updateBooks(books: List<Book>) {
|
override suspend fun updateBooks(books: List<Book>) {
|
||||||
TODO("Not yet implemented")
|
database.updateBooks(books.map { bookMapper.toBookEntity(it, database) })
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun deleteBooks(books: List<Book>) {
|
override suspend fun deleteBooks(books: List<Book>) {
|
||||||
TODO("Not yet implemented")
|
database.deleteBooks(books.map { bookMapper.toBookEntity(it, database) })
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun <T> retrieveDataFromDataStore(
|
override suspend fun <T> retrieveDataFromDataStore(
|
||||||
|
|
@ -45,4 +87,164 @@ class BookRepositoryImpl @Inject constructor(
|
||||||
override suspend fun <T> putDataToDataStore(key: Preferences.Key<T>, value: T) {
|
override suspend fun <T> putDataToDataStore(key: Preferences.Key<T>, value: T) {
|
||||||
dataStore.putData(key, value)
|
dataStore.putData(key, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getFilesFromDownloads(query: String): Flow<Resource<List<File>>> {
|
||||||
|
fun getAllFilesInDirectory(directory: File): List<File> {
|
||||||
|
val filesList = mutableListOf<File>()
|
||||||
|
|
||||||
|
val files = directory.listFiles()
|
||||||
|
if (files != null) {
|
||||||
|
for (file in files) {
|
||||||
|
if (file.isFile) {
|
||||||
|
filesList.add(file)
|
||||||
|
}
|
||||||
|
if (file.isDirectory) {
|
||||||
|
val subDirectoryFiles = getAllFilesInDirectory(file)
|
||||||
|
filesList.addAll(subDirectoryFiles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filesList
|
||||||
|
}
|
||||||
|
|
||||||
|
return flow {
|
||||||
|
emit(Resource.Loading(true))
|
||||||
|
|
||||||
|
val existingBooks = database
|
||||||
|
.searchBooks("")
|
||||||
|
.map { bookMapper.toBook(it) }
|
||||||
|
.filter { it.file != null }
|
||||||
|
|
||||||
|
val allFiles = getAllFilesInDirectory(
|
||||||
|
Environment
|
||||||
|
.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (allFiles.isEmpty()) {
|
||||||
|
emit(Resource.Loading(false))
|
||||||
|
return@flow
|
||||||
|
}
|
||||||
|
|
||||||
|
val supportedExtensions = Constants.EXTENSIONS
|
||||||
|
var filteredFiles = mutableListOf<File>()
|
||||||
|
|
||||||
|
allFiles.filter { file ->
|
||||||
|
val isFileSupported = supportedExtensions.any { ext ->
|
||||||
|
file.name.endsWith(
|
||||||
|
ext,
|
||||||
|
ignoreCase = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val isFileAlreadyAdded = existingBooks.all {
|
||||||
|
it.file != file
|
||||||
|
}
|
||||||
|
val isQuery = if (query.isEmpty()) true else file.name.lowercase()
|
||||||
|
.contains(query.trim().lowercase())
|
||||||
|
|
||||||
|
isFileAlreadyAdded && isFileSupported && isQuery
|
||||||
|
}.forEach {
|
||||||
|
filteredFiles.add(
|
||||||
|
it
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredFiles = if (query.trim().isNotEmpty()) {
|
||||||
|
filteredFiles.sortedByDescending {
|
||||||
|
it.name.lowercase().startsWith(query.trim().lowercase())
|
||||||
|
}.toMutableList()
|
||||||
|
} else {
|
||||||
|
filteredFiles.sortedByDescending {
|
||||||
|
it.lastModified()
|
||||||
|
}.toMutableList()
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(Resource.Loading(false))
|
||||||
|
emit(
|
||||||
|
Resource.Success(
|
||||||
|
data = filteredFiles
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getBookTextFromFile(file: File): Flow<Resource<List<StringWithId>>> {
|
||||||
|
return flow {
|
||||||
|
emit(Resource.Loading(true))
|
||||||
|
|
||||||
|
val text = if (file.name.endsWith(".txt")) {
|
||||||
|
txtTextParser.parse(file)
|
||||||
|
} else if (file.name.endsWith(".pdf")) {
|
||||||
|
pdfTextParser.parse(file)
|
||||||
|
} else if (file.name.endsWith(".epub")) {
|
||||||
|
epubTextParser.parse(file)
|
||||||
|
} else {
|
||||||
|
return@flow
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(Resource.Success(text))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getBooksFromFiles(files: List<File>): Flow<Resource<List<NullableBook>>> {
|
||||||
|
return flow {
|
||||||
|
emit(Resource.Loading(true))
|
||||||
|
|
||||||
|
val books = mutableListOf<NullableBook>()
|
||||||
|
for (file in files) {
|
||||||
|
|
||||||
|
val parsedBook = if (file.name.endsWith(".txt")) {
|
||||||
|
txtFileParser.parse(file)
|
||||||
|
} else if (file.name.endsWith(".pdf")) {
|
||||||
|
pdfFileParser.parse(file)
|
||||||
|
} else if (file.name.endsWith(".epub")) {
|
||||||
|
epubFileParser.parse(file)
|
||||||
|
} else {
|
||||||
|
books.add(NullableBook.Null(file.name))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
val parsedText = if (file.name.endsWith(".txt")) {
|
||||||
|
txtTextParser.parse(file)
|
||||||
|
} else if (file.name.endsWith(".pdf")) {
|
||||||
|
pdfTextParser.parse(file)
|
||||||
|
} else if (file.name.endsWith(".epub")) {
|
||||||
|
epubTextParser.parse(file)
|
||||||
|
} else {
|
||||||
|
books.add(NullableBook.Null(file.name))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedBook == null || parsedText.isEmpty()) {
|
||||||
|
books.add(NullableBook.Null(file.name))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
books.add(
|
||||||
|
NullableBook.NotNull(
|
||||||
|
Pair(
|
||||||
|
parsedBook.copy(text = parsedText),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(Resource.Success(books))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,16 @@ package com.acclorite.books_history.domain.model
|
||||||
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
|
import com.acclorite.books_history.util.UIText
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
data class Book(
|
data class Book(
|
||||||
val id: Int,
|
val id: Int?,
|
||||||
val title: String,
|
val title: String,
|
||||||
val author: String,
|
val author: UIText,
|
||||||
val description: String?,
|
val description: String?,
|
||||||
val text: List<FormattedLine> = emptyList(),
|
val text: List<StringWithId> = emptyList(),
|
||||||
val progress: Float,
|
val progress: Float,
|
||||||
val file: File?,
|
val file: File?,
|
||||||
val lastOpened: Long?,
|
val lastOpened: Long?,
|
||||||
|
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
package com.acclorite.books_history.domain.model
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Immutable
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.unit.TextUnit
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
@Immutable
|
|
||||||
data class FormattedLine(
|
|
||||||
val line: String,
|
|
||||||
val style: TextStyle = TextStyle(),
|
|
||||||
val textSize: TextUnit = 0.sp,
|
|
||||||
val id: String = UUID.randomUUID().toString()
|
|
||||||
)
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
package com.acclorite.books_history.domain.model
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class NavigationBarItem(
|
||||||
|
val title: String,
|
||||||
|
val selectedIcon: Painter,
|
||||||
|
val unselectedIcon: Painter
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
package com.acclorite.books_history.domain.model
|
||||||
|
|
||||||
|
sealed class NullableBook(val book: Pair<Book, Boolean>?, val fileName: String?) {
|
||||||
|
class NotNull(book: Pair<Book, Boolean>) : NullableBook(book, null)
|
||||||
|
class Null(fileName: String) : NullableBook(null, fileName)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
package com.acclorite.books_history.domain.model
|
||||||
|
|
||||||
|
import android.os.Parcelable
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
@Immutable
|
||||||
|
data class StringWithId(
|
||||||
|
val line: String,
|
||||||
|
val id: String = UUID.randomUUID().toString()
|
||||||
|
) : Parcelable
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
package com.acclorite.books_history.domain.repository
|
package com.acclorite.books_history.domain.repository
|
||||||
|
|
||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import com.acclorite.books_history.data.local.dto.BookEntity
|
||||||
import com.acclorite.books_history.domain.model.Book
|
import com.acclorite.books_history.domain.model.Book
|
||||||
|
import com.acclorite.books_history.domain.model.NullableBook
|
||||||
|
import com.acclorite.books_history.domain.model.StringWithId
|
||||||
import com.acclorite.books_history.util.Resource
|
import com.acclorite.books_history.util.Resource
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
interface BookRepository {
|
interface BookRepository {
|
||||||
|
|
||||||
|
|
@ -11,6 +15,14 @@ interface BookRepository {
|
||||||
query: String
|
query: String
|
||||||
): Flow<Resource<List<Book>>>
|
): Flow<Resource<List<Book>>>
|
||||||
|
|
||||||
|
suspend fun fastGetBooks(
|
||||||
|
query: String
|
||||||
|
): List<Book>
|
||||||
|
|
||||||
|
suspend fun findBook(
|
||||||
|
id: Int
|
||||||
|
): BookEntity
|
||||||
|
|
||||||
suspend fun insertBooks(
|
suspend fun insertBooks(
|
||||||
books: List<Book>
|
books: List<Book>
|
||||||
)
|
)
|
||||||
|
|
@ -32,4 +44,10 @@ interface BookRepository {
|
||||||
key: Preferences.Key<T>,
|
key: Preferences.Key<T>,
|
||||||
value: T
|
value: T
|
||||||
)
|
)
|
||||||
|
|
||||||
|
suspend fun getFilesFromDownloads(query: String = ""): Flow<Resource<List<File>>>
|
||||||
|
|
||||||
|
suspend fun getBookTextFromFile(file: File): Flow<Resource<List<StringWithId>>>
|
||||||
|
|
||||||
|
suspend fun getBooksFromFiles(files: List<File>): Flow<Resource<List<NullableBook>>>
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
package com.acclorite.books_history.domain.use_case
|
||||||
|
|
||||||
|
import com.acclorite.books_history.domain.model.Book
|
||||||
|
import com.acclorite.books_history.domain.repository.BookRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class FastGetBooks @Inject constructor(private val repository: BookRepository) {
|
||||||
|
|
||||||
|
suspend fun execute(query: String): List<Book> {
|
||||||
|
return repository.fastGetBooks(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
package com.acclorite.books_history.domain.use_case
|
||||||
|
|
||||||
|
import com.acclorite.books_history.domain.model.Book
|
||||||
|
import com.acclorite.books_history.domain.repository.BookRepository
|
||||||
|
import com.acclorite.books_history.util.Resource
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class GetBooks @Inject constructor(private val repository: BookRepository) {
|
||||||
|
|
||||||
|
suspend fun execute(query: String): Flow<Resource<List<Book>>> {
|
||||||
|
return repository.getBooks(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
package com.acclorite.books_history.domain.use_case
|
||||||
|
|
||||||
|
import com.acclorite.books_history.domain.model.NullableBook
|
||||||
|
import com.acclorite.books_history.domain.repository.BookRepository
|
||||||
|
import com.acclorite.books_history.util.Resource
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import java.io.File
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class GetBooksFromFiles @Inject constructor(
|
||||||
|
private val repository: BookRepository
|
||||||
|
) {
|
||||||
|
|
||||||
|
suspend fun execute(files: List<File>): Flow<Resource<List<NullableBook>>> {
|
||||||
|
return repository.getBooksFromFiles(files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
package com.acclorite.books_history.domain.use_case
|
||||||
|
|
||||||
|
import com.acclorite.books_history.domain.repository.BookRepository
|
||||||
|
import com.acclorite.books_history.util.Resource
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import java.io.File
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class GetFilesFromDownloads @Inject constructor(
|
||||||
|
private val repository: BookRepository
|
||||||
|
) {
|
||||||
|
|
||||||
|
suspend fun execute(query: String): Flow<Resource<List<File>>> {
|
||||||
|
return repository.getFilesFromDownloads(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
package com.acclorite.books_history.domain.use_case
|
||||||
|
|
||||||
|
import com.acclorite.books_history.domain.model.Book
|
||||||
|
import com.acclorite.books_history.domain.repository.BookRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class UpdateBooks @Inject constructor(private val repository: BookRepository) {
|
||||||
|
|
||||||
|
suspend fun execute(books: List<Book>) {
|
||||||
|
repository.updateBooks(books)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,9 +7,6 @@ import androidx.activity.viewModels
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.EnterTransition
|
import androidx.compose.animation.EnterTransition
|
||||||
import androidx.compose.animation.ExitTransition
|
import androidx.compose.animation.ExitTransition
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.animation.fadeIn
|
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
|
@ -19,9 +16,11 @@ import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.acclorite.books_history.ui.Transitions
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedFactory
|
import dagger.assisted.AssistedFactory
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
|
|
@ -43,7 +42,15 @@ enum class Screen {
|
||||||
HISTORY,
|
HISTORY,
|
||||||
BROWSE,
|
BROWSE,
|
||||||
|
|
||||||
|
BOOKS_INFO,
|
||||||
|
READER,
|
||||||
|
|
||||||
|
SETTINGS,
|
||||||
|
GENERAL_SETTINGS,
|
||||||
|
APPEARANCE_SETTINGS,
|
||||||
|
|
||||||
|
ABOUT,
|
||||||
|
HELP
|
||||||
}
|
}
|
||||||
|
|
||||||
data class Argument(
|
data class Argument(
|
||||||
|
|
@ -93,6 +100,10 @@ class Navigator @AssistedInject constructor(
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun clearArgument(key: String) {
|
||||||
|
arguments.removeIf { it.key == key }
|
||||||
|
}
|
||||||
|
|
||||||
fun navigate(screen: Screen, vararg args: Argument) =
|
fun navigate(screen: Screen, vararg args: Argument) =
|
||||||
viewModelScope.launch(Dispatchers.Default) {
|
viewModelScope.launch(Dispatchers.Default) {
|
||||||
backStack.value.add(currentScreen.value)
|
backStack.value.add(currentScreen.value)
|
||||||
|
|
@ -139,8 +150,8 @@ class Navigator @AssistedInject constructor(
|
||||||
@Composable
|
@Composable
|
||||||
fun composable(
|
fun composable(
|
||||||
screen: Screen,
|
screen: Screen,
|
||||||
enterAnim: EnterTransition = fadeIn(tween(300)),
|
enterAnim: EnterTransition = Transitions.DefaultTransitionIn,
|
||||||
exitAnim: ExitTransition = fadeOut(tween(300)),
|
exitAnim: ExitTransition = Transitions.DefaultTransitionOut,
|
||||||
content: @Composable () -> Unit
|
content: @Composable () -> Unit
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
|
|
@ -171,10 +182,11 @@ class Navigator @AssistedInject constructor(
|
||||||
@Composable
|
@Composable
|
||||||
fun NavigationHost(
|
fun NavigationHost(
|
||||||
startScreen: Screen,
|
startScreen: Screen,
|
||||||
activity: ComponentActivity,
|
|
||||||
colorBetweenAnimations: Color = MaterialTheme.colorScheme.surface,
|
colorBetweenAnimations: Color = MaterialTheme.colorScheme.surface,
|
||||||
content: @Composable Navigator.() -> Unit
|
content: @Composable Navigator.() -> Unit
|
||||||
) {
|
) {
|
||||||
|
val activity = LocalContext.current as ComponentActivity
|
||||||
|
|
||||||
activity.apply {
|
activity.apply {
|
||||||
val navigator by viewModels<Navigator>(
|
val navigator by viewModels<Navigator>(
|
||||||
extrasProducer = {
|
extrasProducer = {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
package com.acclorite.books_history.presentation.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.statusBars
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.acclorite.books_history.ui.DefaultTransition
|
||||||
|
import com.acclorite.books_history.ui.elevation
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun AnimatedTopAppBar(
|
||||||
|
containerColor: Color,
|
||||||
|
scrolledContainerColor: Color?,
|
||||||
|
scrollBehavior: TopAppBarScrollBehavior?,
|
||||||
|
|
||||||
|
content1Visibility: Boolean,
|
||||||
|
content1NavigationIcon: @Composable () -> Unit,
|
||||||
|
content1Title: @Composable () -> Unit,
|
||||||
|
content1Actions: @Composable RowScope.() -> Unit,
|
||||||
|
|
||||||
|
content2Visibility: Boolean,
|
||||||
|
content2NavigationIcon: @Composable () -> Unit,
|
||||||
|
content2Title: @Composable () -> Unit,
|
||||||
|
content2Actions: @Composable RowScope.() -> Unit,
|
||||||
|
|
||||||
|
content3Visibility: Boolean,
|
||||||
|
content3NavigationIcon: @Composable () -> Unit,
|
||||||
|
content3Title: @Composable () -> Unit,
|
||||||
|
content3Actions: @Composable RowScope.() -> Unit
|
||||||
|
) {
|
||||||
|
if (scrollBehavior != null && scrolledContainerColor != null) {
|
||||||
|
val isScrolled = scrollBehavior.state.overlappedFraction > 0.01f
|
||||||
|
val statusBarPadding = with(LocalDensity.current) {
|
||||||
|
WindowInsets.statusBars.getTop(LocalDensity.current).toDp()
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(64.dp + statusBarPadding)
|
||||||
|
.background(
|
||||||
|
if (isScrolled) scrolledContainerColor
|
||||||
|
else containerColor
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
DefaultTransition(visible = content1Visibility) {
|
||||||
|
TopAppBar(
|
||||||
|
navigationIcon = content1NavigationIcon,
|
||||||
|
title = content1Title,
|
||||||
|
actions = content1Actions,
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = containerColor,
|
||||||
|
scrolledContainerColor = MaterialTheme.elevation()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
DefaultTransition(visible = content2Visibility) {
|
||||||
|
TopAppBar(
|
||||||
|
navigationIcon = content2NavigationIcon,
|
||||||
|
title = content2Title,
|
||||||
|
actions = content2Actions,
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = containerColor,
|
||||||
|
scrolledContainerColor = MaterialTheme.elevation()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
DefaultTransition(visible = content3Visibility) {
|
||||||
|
TopAppBar(
|
||||||
|
navigationIcon = content3NavigationIcon,
|
||||||
|
title = content3Title,
|
||||||
|
actions = content3Actions,
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = containerColor,
|
||||||
|
scrolledContainerColor = MaterialTheme.elevation()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
package com.acclorite.books_history.presentation.components
|
||||||
|
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.NavigationBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import com.acclorite.books_history.R
|
||||||
|
import com.acclorite.books_history.domain.model.NavigationBarItem
|
||||||
|
import com.acclorite.books_history.presentation.Navigator
|
||||||
|
import com.acclorite.books_history.presentation.Screen
|
||||||
|
import com.acclorite.books_history.ui.elevation
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bottom navigation bar, uses default [NavigationBar].
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun BottomNavigationBar(
|
||||||
|
navigator: Navigator
|
||||||
|
) {
|
||||||
|
val currentScreen by navigator.getCurrentScreen().collectAsState()
|
||||||
|
|
||||||
|
NavigationBar(
|
||||||
|
containerColor = MaterialTheme.elevation()
|
||||||
|
) {
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
Modifier.weight(1f),
|
||||||
|
item = NavigationBarItem(
|
||||||
|
stringResource(id = R.string.library_screen),
|
||||||
|
selectedIcon = painterResource(id = R.drawable.library_screen_filled),
|
||||||
|
unselectedIcon = painterResource(id = R.drawable.library_screen_outlined)
|
||||||
|
),
|
||||||
|
isSelected = currentScreen == Screen.LIBRARY
|
||||||
|
) {
|
||||||
|
navigator.navigate(Screen.LIBRARY)
|
||||||
|
}
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
Modifier.weight(1f),
|
||||||
|
item = NavigationBarItem(
|
||||||
|
stringResource(id = R.string.history_screen),
|
||||||
|
selectedIcon = painterResource(id = R.drawable.history_screen_filled),
|
||||||
|
unselectedIcon = painterResource(id = R.drawable.history_screen_outlined)
|
||||||
|
),
|
||||||
|
isSelected = currentScreen == Screen.HISTORY
|
||||||
|
) {
|
||||||
|
navigator.navigate(Screen.HISTORY)
|
||||||
|
}
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
Modifier.weight(1f),
|
||||||
|
item = NavigationBarItem(
|
||||||
|
stringResource(id = R.string.browse_screen),
|
||||||
|
selectedIcon = painterResource(id = R.drawable.browse_screen_filled),
|
||||||
|
unselectedIcon = painterResource(id = R.drawable.browse_screen_outlined)
|
||||||
|
),
|
||||||
|
isSelected = currentScreen == Screen.BROWSE
|
||||||
|
) {
|
||||||
|
navigator.navigate(Screen.BROWSE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
package com.acclorite.books_history.presentation.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.NavigationBarItem
|
||||||
|
import androidx.compose.material3.NavigationBarItemDefaults
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.acclorite.books_history.domain.model.NavigationBarItem
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bottom Navigation Bar Item, uses default [NavigationBarItem].
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun BottomNavigationBarItem(
|
||||||
|
modifier: Modifier,
|
||||||
|
item: NavigationBarItem,
|
||||||
|
isSelected: Boolean,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
Row(modifier.fillMaxHeight()) {
|
||||||
|
NavigationBarItem(
|
||||||
|
label = {
|
||||||
|
Text(
|
||||||
|
text = item.title,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
},
|
||||||
|
selected = isSelected,
|
||||||
|
enabled = !isSelected,
|
||||||
|
onClick = { onClick() },
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
painter =
|
||||||
|
if (isSelected) item.selectedIcon
|
||||||
|
else item.unselectedIcon,
|
||||||
|
contentDescription = item.title,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
colors = NavigationBarItemDefaults.colors(
|
||||||
|
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
disabledIconColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
disabledTextColor = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,115 @@
|
||||||
|
package com.acclorite.books_history.presentation.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import androidx.compose.ui.window.DialogWindowProvider
|
||||||
|
import com.acclorite.books_history.R
|
||||||
|
import com.acclorite.books_history.ui.ElevationDefaults
|
||||||
|
import com.acclorite.books_history.ui.elevation
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CustomAlertDialog(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
properties: DialogProperties = DialogProperties(),
|
||||||
|
drawableIcon: Painter? = null,
|
||||||
|
imageVectorIcon: ImageVector? = null,
|
||||||
|
backgroundTransparency: Float = 0.2f,
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
actionText: String,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onAction: () -> Unit
|
||||||
|
) {
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
properties = properties,
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { onAction() }) {
|
||||||
|
Text(
|
||||||
|
text = actionText,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
shape = MaterialTheme.shapes.extraLarge,
|
||||||
|
modifier = modifier
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.statusBarsPadding()
|
||||||
|
.clip(MaterialTheme.shapes.extraLarge)
|
||||||
|
.background(MaterialTheme.elevation(ElevationDefaults.DialogElevation)),
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { onDismiss() }) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.cancel),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon = {
|
||||||
|
if (drawableIcon != null) {
|
||||||
|
Icon(
|
||||||
|
painter = drawableIcon,
|
||||||
|
contentDescription = title,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(24.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.secondary
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageVectorIcon != null) {
|
||||||
|
Icon(
|
||||||
|
imageVector = imageVectorIcon,
|
||||||
|
contentDescription = title,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(24.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.secondary
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
(LocalView.current.parent as DialogWindowProvider).window.setDimAmount(
|
||||||
|
backgroundTransparency
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = description,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Start
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
package com.acclorite.books_history.presentation.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.acclorite.books_history.ui.elevation
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CustomCheckbox(selected: Boolean) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Check,
|
||||||
|
tint = if (selected) MaterialTheme.elevation(elevation = 2.dp) else Color.Transparent,
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(CircleShape)
|
||||||
|
.border(
|
||||||
|
width = if (!selected) 1.dp else 0.dp,
|
||||||
|
if (!selected) MaterialTheme.colorScheme.outline else Color.Transparent,
|
||||||
|
CircleShape
|
||||||
|
)
|
||||||
|
.background(
|
||||||
|
if (selected) MaterialTheme.colorScheme.primary else Color.Transparent,
|
||||||
|
shape = CircleShape
|
||||||
|
)
|
||||||
|
.padding(4.dp)
|
||||||
|
.size(22.dp),
|
||||||
|
contentDescription = "checkbox"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,158 @@
|
||||||
|
package com.acclorite.books_history.presentation.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Divider
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import androidx.compose.ui.window.DialogWindowProvider
|
||||||
|
import com.acclorite.books_history.R
|
||||||
|
import com.acclorite.books_history.ui.ElevationDefaults
|
||||||
|
import com.acclorite.books_history.ui.elevation
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun CustomDialogWithContent(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
properties: DialogProperties = DialogProperties(),
|
||||||
|
drawableIcon: Painter? = null,
|
||||||
|
imageVectorIcon: ImageVector? = null,
|
||||||
|
backgroundTransparency: Float = 0.2f,
|
||||||
|
title: String,
|
||||||
|
description: String?,
|
||||||
|
actionText: String?,
|
||||||
|
isActionEnabled: Boolean?,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onAction: () -> Unit,
|
||||||
|
withDivider: Boolean,
|
||||||
|
customContent: @Composable (ColumnScope.() -> Unit) = {}
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { onDismiss() },
|
||||||
|
properties = properties,
|
||||||
|
) {
|
||||||
|
(LocalView.current.parent as DialogWindowProvider).window.setDimAmount(
|
||||||
|
backgroundTransparency
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.statusBarsPadding()
|
||||||
|
.clip(MaterialTheme.shapes.extraLarge)
|
||||||
|
.background(MaterialTheme.elevation(ElevationDefaults.DialogElevation))
|
||||||
|
.padding(top = 24.dp, bottom = 12.dp)
|
||||||
|
) {
|
||||||
|
if (drawableIcon != null) {
|
||||||
|
Icon(
|
||||||
|
painter = drawableIcon,
|
||||||
|
contentDescription = title,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.CenterHorizontally)
|
||||||
|
.size(24.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.secondary
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageVectorIcon != null) {
|
||||||
|
Icon(
|
||||||
|
imageVector = imageVectorIcon,
|
||||||
|
contentDescription = title,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.CenterHorizontally)
|
||||||
|
.size(24.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.secondary
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(
|
||||||
|
if (drawableIcon != null || imageVectorIcon != null) Alignment.CenterHorizontally
|
||||||
|
else Alignment.Start
|
||||||
|
)
|
||||||
|
.padding(horizontal = 24.dp),
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
if (description != null) {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
text = description,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Start,
|
||||||
|
modifier = Modifier.padding(horizontal = 24.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (withDivider) {
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
Divider(
|
||||||
|
color = MaterialTheme.colorScheme.outlineVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 24.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
} else {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
customContent()
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.End)
|
||||||
|
.padding(horizontal = 24.dp)
|
||||||
|
) {
|
||||||
|
TextButton(onClick = { onDismiss() }) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.cancel),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (actionText != null) {
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
TextButton(
|
||||||
|
onClick = { onAction() },
|
||||||
|
enabled = isActionEnabled == true
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = actionText,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color =
|
||||||
|
if (isActionEnabled == true) MaterialTheme.colorScheme.primary
|
||||||
|
else MaterialTheme.colorScheme.primary.copy(0.5f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,166 @@
|
||||||
|
package com.acclorite.books_history.presentation.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Divider
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import androidx.compose.ui.window.DialogWindowProvider
|
||||||
|
import com.acclorite.books_history.R
|
||||||
|
import com.acclorite.books_history.ui.ElevationDefaults
|
||||||
|
import com.acclorite.books_history.ui.elevation
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun CustomDialogWithLazyColumn(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
properties: DialogProperties = DialogProperties(),
|
||||||
|
drawableIcon: Painter? = null,
|
||||||
|
imageVectorIcon: ImageVector? = null,
|
||||||
|
backgroundTransparency: Float = 0.2f,
|
||||||
|
title: String,
|
||||||
|
description: String?,
|
||||||
|
actionText: String?,
|
||||||
|
isActionEnabled: Boolean?,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onAction: () -> Unit,
|
||||||
|
withDivider: Boolean,
|
||||||
|
items: (LazyListScope.() -> Unit) = {}
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { onDismiss() },
|
||||||
|
properties = properties,
|
||||||
|
) {
|
||||||
|
(LocalView.current.parent as DialogWindowProvider).window.setDimAmount(
|
||||||
|
backgroundTransparency
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.statusBarsPadding()
|
||||||
|
.clip(MaterialTheme.shapes.extraLarge)
|
||||||
|
.background(MaterialTheme.elevation(ElevationDefaults.DialogElevation))
|
||||||
|
.padding(top = 24.dp, bottom = 12.dp)
|
||||||
|
) {
|
||||||
|
if (drawableIcon != null) {
|
||||||
|
Icon(
|
||||||
|
painter = drawableIcon,
|
||||||
|
contentDescription = title,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.CenterHorizontally)
|
||||||
|
.size(24.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.secondary
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageVectorIcon != null) {
|
||||||
|
Icon(
|
||||||
|
imageVector = imageVectorIcon,
|
||||||
|
contentDescription = title,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.CenterHorizontally)
|
||||||
|
.size(24.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.secondary
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(
|
||||||
|
if (drawableIcon != null || imageVectorIcon != null) Alignment.CenterHorizontally
|
||||||
|
else Alignment.Start
|
||||||
|
)
|
||||||
|
.padding(horizontal = 24.dp),
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
if (description != null) {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
text = description,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Start,
|
||||||
|
modifier = Modifier.padding(horizontal = 24.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (withDivider) {
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
Divider(
|
||||||
|
color = MaterialTheme.colorScheme.outlineVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 24.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
} else {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn {
|
||||||
|
items()
|
||||||
|
|
||||||
|
item {
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.End)
|
||||||
|
.padding(horizontal = 24.dp)
|
||||||
|
) {
|
||||||
|
TextButton(onClick = { onDismiss() }) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.cancel),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (actionText != null) {
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
TextButton(
|
||||||
|
onClick = { onAction() },
|
||||||
|
enabled = isActionEnabled == true
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = actionText,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color =
|
||||||
|
if (isActionEnabled == true) MaterialTheme.colorScheme.primary
|
||||||
|
else MaterialTheme.colorScheme.primary.copy(0.5f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
package com.acclorite.books_history.presentation.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
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.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun IsEmpty(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
message: String,
|
||||||
|
icon: Painter,
|
||||||
|
actionTitle: String? = null,
|
||||||
|
action: () -> Unit = {}
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 80.dp, vertical = 40.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = icon,
|
||||||
|
contentDescription = message,
|
||||||
|
modifier = Modifier.size(120.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(36.dp))
|
||||||
|
Text(
|
||||||
|
text = message,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
if (actionTitle != null) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
action()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
actionTitle,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
package com.acclorite.books_history.presentation.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
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.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun IsError(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
errorMessage: String,
|
||||||
|
icon: Painter,
|
||||||
|
actionTitle: String? = null,
|
||||||
|
action: () -> Unit = {}
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 80.dp, vertical = 40.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = icon,
|
||||||
|
contentDescription = errorMessage,
|
||||||
|
modifier = Modifier.size(120.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(36.dp))
|
||||||
|
Text(
|
||||||
|
text = errorMessage,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
if (actionTitle != null) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
action()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
actionTitle,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
package com.acclorite.books_history.presentation.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.DpOffset
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.acclorite.books_history.R
|
||||||
|
import com.acclorite.books_history.presentation.Navigator
|
||||||
|
import com.acclorite.books_history.presentation.Screen
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MoreDropDown(navigator: Navigator) {
|
||||||
|
var showDropDown by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val startPadding = 12.dp
|
||||||
|
val endPadding = 36.dp
|
||||||
|
|
||||||
|
Box {
|
||||||
|
IconButton(onClick = {
|
||||||
|
showDropDown = true
|
||||||
|
}) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.MoreVert,
|
||||||
|
contentDescription = "Show drop down",
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = showDropDown,
|
||||||
|
onDismissRequest = { showDropDown = false },
|
||||||
|
offset = DpOffset(10.dp, 0.dp)
|
||||||
|
) {
|
||||||
|
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.settings_screen),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
style = MaterialTheme.typography.bodyLarge
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
navigator.navigate(Screen.SETTINGS)
|
||||||
|
showDropDown = false
|
||||||
|
},
|
||||||
|
contentPadding = PaddingValues(start = startPadding, end = endPadding)
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.help_screen),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
style = MaterialTheme.typography.bodyLarge
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
navigator.navigate(Screen.HELP)
|
||||||
|
showDropDown = false
|
||||||
|
},
|
||||||
|
contentPadding = PaddingValues(start = startPadding, end = endPadding)
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.about_screen),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
style = MaterialTheme.typography.bodyLarge
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
navigator.navigate(Screen.ABOUT)
|
||||||
|
showDropDown = false
|
||||||
|
},
|
||||||
|
contentPadding = PaddingValues(start = startPadding, end = endPadding)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
package com.acclorite.books_history.presentation.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.RadioButtonDefaults
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SelectableDialogItem(selected: Boolean, title: String, onClick: () -> Unit) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(enabled = !selected) { onClick() }
|
||||||
|
.padding(horizontal = 24.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
RadioButton(
|
||||||
|
selected = selected,
|
||||||
|
onClick = null,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
colors = RadioButtonDefaults.colors(
|
||||||
|
selectedColor = MaterialTheme.colorScheme.primary,
|
||||||
|
unselectedColor = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
package com.acclorite.books_history.presentation.main
|
||||||
|
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
|
||||||
|
sealed class MainEvent() {
|
||||||
|
data class OnChangeLanguage(val lang: String, val activity: ComponentActivity) : MainEvent()
|
||||||
|
data class OnLocaleUpdate(val activity: ComponentActivity) : MainEvent()
|
||||||
|
data class OnChangeTheme(val theme: String) : MainEvent()
|
||||||
|
data class OnChangeDarkTheme(val darkTheme: String) : MainEvent()
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package com.acclorite.books_history.presentation
|
package com.acclorite.books_history.presentation.main
|
||||||
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
|
|
@ -9,18 +9,21 @@ import androidx.lifecycle.viewModelScope
|
||||||
import com.acclorite.books_history.domain.use_case.ChangeLanguage
|
import com.acclorite.books_history.domain.use_case.ChangeLanguage
|
||||||
import com.acclorite.books_history.domain.use_case.GetDatastore
|
import com.acclorite.books_history.domain.use_case.GetDatastore
|
||||||
import com.acclorite.books_history.domain.use_case.SetDatastore
|
import com.acclorite.books_history.domain.use_case.SetDatastore
|
||||||
import com.acclorite.books_history.ui.theme.DarkTheme
|
import com.acclorite.books_history.ui.DarkTheme
|
||||||
import com.acclorite.books_history.ui.theme.Theme
|
import com.acclorite.books_history.ui.Theme
|
||||||
import com.acclorite.books_history.ui.theme.toDarkTheme
|
import com.acclorite.books_history.ui.toDarkTheme
|
||||||
import com.acclorite.books_history.ui.theme.toTheme
|
import com.acclorite.books_history.ui.toTheme
|
||||||
import com.acclorite.books_history.util.Constants
|
import com.acclorite.books_history.util.Constants
|
||||||
import com.acclorite.books_history.util.DataStoreConstants
|
import com.acclorite.books_history.util.DataStoreConstants
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
@ -40,6 +43,9 @@ class MainViewModel @Inject constructor(
|
||||||
private val _isReady = MutableStateFlow(false)
|
private val _isReady = MutableStateFlow(false)
|
||||||
val isReady = _isReady.asStateFlow()
|
val isReady = _isReady.asStateFlow()
|
||||||
|
|
||||||
|
private val _updating = MutableStateFlow(false)
|
||||||
|
val updating = _updating.asStateFlow()
|
||||||
|
|
||||||
/* -- Language ----------------------------------------------------- */
|
/* -- Language ----------------------------------------------------- */
|
||||||
private var _language: String =
|
private var _language: String =
|
||||||
stateHandle[Constants.LANGUAGE] ?: Locale.getDefault().language
|
stateHandle[Constants.LANGUAGE] ?: Locale.getDefault().language
|
||||||
|
|
@ -52,8 +58,8 @@ class MainViewModel @Inject constructor(
|
||||||
|
|
||||||
/* -- Theme -------------------------------------------------------- */
|
/* -- Theme -------------------------------------------------------- */
|
||||||
private var _theme: Theme =
|
private var _theme: Theme =
|
||||||
stateHandle[Constants.THEME] ?:
|
stateHandle[Constants.THEME]
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) Theme.DYNAMIC else Theme.BLUE
|
?: if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) Theme.DYNAMIC else Theme.BLUE
|
||||||
set(value) {
|
set(value) {
|
||||||
field = value
|
field = value
|
||||||
stateHandle[Constants.THEME] = value
|
stateHandle[Constants.THEME] = value
|
||||||
|
|
@ -162,6 +168,44 @@ class MainViewModel @Inject constructor(
|
||||||
get() = stateHandle.getStateFlow(Constants.PARAGRAPH_INDENTATION, null)
|
get() = stateHandle.getStateFlow(Constants.PARAGRAPH_INDENTATION, null)
|
||||||
/* --------------------------------------------------------------- */
|
/* --------------------------------------------------------------- */
|
||||||
|
|
||||||
|
fun onEvent(event: MainEvent) {
|
||||||
|
when (event) {
|
||||||
|
is MainEvent.OnChangeLanguage -> {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
_updating.update { true }
|
||||||
|
|
||||||
|
changeLanguage.execute(event.lang, event.activity)
|
||||||
|
_language = event.lang
|
||||||
|
delay(100)
|
||||||
|
|
||||||
|
_updating.update { false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is MainEvent.OnLocaleUpdate -> {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
_updating.update { true }
|
||||||
|
changeLanguage.execute(_language, event.activity)
|
||||||
|
delay(10)
|
||||||
|
_updating.update { false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is MainEvent.OnChangeDarkTheme -> {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
setDatastore.execute(DataStoreConstants.DARK_THEME, event.darkTheme)
|
||||||
|
_darkTheme = event.darkTheme.toDarkTheme()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is MainEvent.OnChangeTheme -> {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
setDatastore.execute(DataStoreConstants.THEME, event.theme)
|
||||||
|
_theme = event.theme.toTheme()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun init(activity: ComponentActivity) {
|
fun init(activity: ComponentActivity) {
|
||||||
val isDataReady = combine(
|
val isDataReady = combine(
|
||||||
|
|
@ -184,9 +228,9 @@ class MainViewModel @Inject constructor(
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
getDatastore
|
getDatastore
|
||||||
.execute(DataStoreConstants.LANGUAGE, _language)
|
.execute(DataStoreConstants.LANGUAGE, _language)
|
||||||
.collect {
|
.first {
|
||||||
changeLanguage.execute(it, activity)
|
onEvent(MainEvent.OnChangeLanguage(it, activity))
|
||||||
_language = it
|
it.isNotBlank()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -194,9 +238,9 @@ class MainViewModel @Inject constructor(
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
getDatastore
|
getDatastore
|
||||||
.execute(DataStoreConstants.THEME, _theme.toString())
|
.execute(DataStoreConstants.THEME, _theme.toString())
|
||||||
.collect {
|
.first {
|
||||||
setDatastore.execute(DataStoreConstants.THEME, it)
|
onEvent(MainEvent.OnChangeTheme(it))
|
||||||
_theme = it.toTheme()
|
it.isNotBlank()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -204,9 +248,9 @@ class MainViewModel @Inject constructor(
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
getDatastore
|
getDatastore
|
||||||
.execute(DataStoreConstants.DARK_THEME, _darkTheme.toString())
|
.execute(DataStoreConstants.DARK_THEME, _darkTheme.toString())
|
||||||
.collect {
|
.first {
|
||||||
setDatastore.execute(DataStoreConstants.DARK_THEME, it)
|
onEvent(MainEvent.OnChangeDarkTheme(it))
|
||||||
_darkTheme = it.toDarkTheme()
|
it.isNotBlank()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -214,9 +258,11 @@ class MainViewModel @Inject constructor(
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
getDatastore
|
getDatastore
|
||||||
.execute(DataStoreConstants.SHOW_START_SCREEN, _showStartScreen)
|
.execute(DataStoreConstants.SHOW_START_SCREEN, _showStartScreen)
|
||||||
.collect {
|
.first {
|
||||||
setDatastore.execute(DataStoreConstants.SHOW_START_SCREEN, it)
|
setDatastore.execute(DataStoreConstants.SHOW_START_SCREEN, it)
|
||||||
_showStartScreen = it
|
_showStartScreen = it
|
||||||
|
|
||||||
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -224,9 +270,11 @@ class MainViewModel @Inject constructor(
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
getDatastore
|
getDatastore
|
||||||
.execute(DataStoreConstants.BACKGROUND_COLOR, _backgroundColor)
|
.execute(DataStoreConstants.BACKGROUND_COLOR, _backgroundColor)
|
||||||
.collect {
|
.first {
|
||||||
setDatastore.execute(DataStoreConstants.BACKGROUND_COLOR, it)
|
setDatastore.execute(DataStoreConstants.BACKGROUND_COLOR, it)
|
||||||
_backgroundColor = it
|
_backgroundColor = it
|
||||||
|
|
||||||
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -234,9 +282,11 @@ class MainViewModel @Inject constructor(
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
getDatastore
|
getDatastore
|
||||||
.execute(DataStoreConstants.FONT_COLOR, _fontColor)
|
.execute(DataStoreConstants.FONT_COLOR, _fontColor)
|
||||||
.collect {
|
.first {
|
||||||
setDatastore.execute(DataStoreConstants.FONT_COLOR, it)
|
setDatastore.execute(DataStoreConstants.FONT_COLOR, it)
|
||||||
_fontColor = it
|
_fontColor = it
|
||||||
|
|
||||||
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -244,10 +294,12 @@ class MainViewModel @Inject constructor(
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
getDatastore
|
getDatastore
|
||||||
.execute(DataStoreConstants.FONT, _fontFamily)
|
.execute(DataStoreConstants.FONT, _fontFamily)
|
||||||
.collect {
|
.first {
|
||||||
setDatastore.execute(DataStoreConstants.FONT, it)
|
setDatastore.execute(DataStoreConstants.FONT, it)
|
||||||
_fontFamily = Constants.FONTS.find { font -> font.fontName == it }?.fontName
|
_fontFamily = Constants.FONTS.find { font -> font.fontName == it }?.fontName
|
||||||
?: Constants.FONTS[0].fontName
|
?: Constants.FONTS[0].fontName
|
||||||
|
|
||||||
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -255,9 +307,11 @@ class MainViewModel @Inject constructor(
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
getDatastore
|
getDatastore
|
||||||
.execute(DataStoreConstants.IS_ITALIC, _isItalic)
|
.execute(DataStoreConstants.IS_ITALIC, _isItalic)
|
||||||
.collect {
|
.first {
|
||||||
setDatastore.execute(DataStoreConstants.IS_ITALIC, it)
|
setDatastore.execute(DataStoreConstants.IS_ITALIC, it)
|
||||||
_isItalic = it
|
_isItalic = it
|
||||||
|
|
||||||
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -265,9 +319,11 @@ class MainViewModel @Inject constructor(
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
getDatastore
|
getDatastore
|
||||||
.execute(DataStoreConstants.FONT_SIZE, _fontSize)
|
.execute(DataStoreConstants.FONT_SIZE, _fontSize)
|
||||||
.collect {
|
.first {
|
||||||
setDatastore.execute(DataStoreConstants.FONT_SIZE, it)
|
setDatastore.execute(DataStoreConstants.FONT_SIZE, it)
|
||||||
_fontSize = it
|
_fontSize = it
|
||||||
|
|
||||||
|
it > 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -275,9 +331,11 @@ class MainViewModel @Inject constructor(
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
getDatastore
|
getDatastore
|
||||||
.execute(DataStoreConstants.LINE_HEIGHT, _lineHeight)
|
.execute(DataStoreConstants.LINE_HEIGHT, _lineHeight)
|
||||||
.collect {
|
.first {
|
||||||
setDatastore.execute(DataStoreConstants.LINE_HEIGHT, it)
|
setDatastore.execute(DataStoreConstants.LINE_HEIGHT, it)
|
||||||
_lineHeight = it
|
_lineHeight = it
|
||||||
|
|
||||||
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -285,9 +343,11 @@ class MainViewModel @Inject constructor(
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
getDatastore
|
getDatastore
|
||||||
.execute(DataStoreConstants.PARAGRAPH_HEIGHT, _paragraphHeight)
|
.execute(DataStoreConstants.PARAGRAPH_HEIGHT, _paragraphHeight)
|
||||||
.collect {
|
.first {
|
||||||
setDatastore.execute(DataStoreConstants.PARAGRAPH_HEIGHT, it)
|
setDatastore.execute(DataStoreConstants.PARAGRAPH_HEIGHT, it)
|
||||||
_paragraphHeight = it
|
_paragraphHeight = it
|
||||||
|
|
||||||
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -295,9 +355,11 @@ class MainViewModel @Inject constructor(
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
getDatastore
|
getDatastore
|
||||||
.execute(DataStoreConstants.PARAGRAPH_INDENTATION, _paragraphIndentation)
|
.execute(DataStoreConstants.PARAGRAPH_INDENTATION, _paragraphIndentation)
|
||||||
.collect {
|
.first {
|
||||||
setDatastore.execute(DataStoreConstants.PARAGRAPH_INDENTATION, it)
|
setDatastore.execute(DataStoreConstants.PARAGRAPH_INDENTATION, it)
|
||||||
_paragraphIndentation = it
|
_paragraphIndentation = it
|
||||||
|
|
||||||
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,337 @@
|
||||||
|
package com.acclorite.books_history.presentation.screens.browse
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
import androidx.compose.animation.core.LinearEasing
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.CircularProgressIndicator
|
||||||
|
import androidx.compose.material.ExperimentalMaterialApi
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material.icons.filled.Clear
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material.pullrefresh.PullRefreshIndicator
|
||||||
|
import androidx.compose.material.pullrefresh.pullRefresh
|
||||||
|
import androidx.compose.material.pullrefresh.rememberPullRefreshState
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import com.acclorite.books_history.R
|
||||||
|
import com.acclorite.books_history.presentation.Navigator
|
||||||
|
import com.acclorite.books_history.presentation.Screen
|
||||||
|
import com.acclorite.books_history.presentation.components.AnimatedTopAppBar
|
||||||
|
import com.acclorite.books_history.presentation.components.IsEmpty
|
||||||
|
import com.acclorite.books_history.presentation.components.IsError
|
||||||
|
import com.acclorite.books_history.presentation.components.MoreDropDown
|
||||||
|
import com.acclorite.books_history.presentation.screens.browse.components.BrowseAddingDialog
|
||||||
|
import com.acclorite.books_history.presentation.screens.browse.components.BrowseFileItem
|
||||||
|
import com.acclorite.books_history.presentation.screens.browse.components.BrowseStoragePermissionDialog
|
||||||
|
import com.acclorite.books_history.presentation.screens.browse.data.BrowseEvent
|
||||||
|
import com.acclorite.books_history.presentation.screens.browse.data.BrowseViewModel
|
||||||
|
import com.acclorite.books_history.ui.DefaultTransition
|
||||||
|
import com.acclorite.books_history.ui.Transitions
|
||||||
|
import com.acclorite.books_history.ui.elevation
|
||||||
|
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||||
|
import com.google.accompanist.permissions.rememberPermissionState
|
||||||
|
|
||||||
|
@OptIn(
|
||||||
|
ExperimentalMaterial3Api::class,
|
||||||
|
ExperimentalPermissionsApi::class,
|
||||||
|
ExperimentalFoundationApi::class,
|
||||||
|
ExperimentalMaterialApi::class
|
||||||
|
)
|
||||||
|
@Composable
|
||||||
|
fun BrowseScreen(
|
||||||
|
viewModel: BrowseViewModel = hiltViewModel(),
|
||||||
|
navigator: Navigator
|
||||||
|
) {
|
||||||
|
val permissionState = rememberPermissionState(
|
||||||
|
permission = Manifest.permission.READ_EXTERNAL_STORAGE
|
||||||
|
)
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.onEvent(BrowseEvent.OnPermissionCheck(permissionState))
|
||||||
|
}
|
||||||
|
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
|
val refreshState = rememberPullRefreshState(
|
||||||
|
refreshing = state.isRefreshing,
|
||||||
|
onRefresh = {
|
||||||
|
viewModel.onEvent(BrowseEvent.OnRefreshList)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
|
val topAppBarColor = if (state.hasSelectedItems) MaterialTheme.elevation()
|
||||||
|
else MaterialTheme.colorScheme.surface
|
||||||
|
|
||||||
|
val animatedTopAppBarColor by animateColorAsState(
|
||||||
|
targetValue = topAppBarColor,
|
||||||
|
animationSpec = tween(300, easing = LinearEasing),
|
||||||
|
label = "TopAppBar color animation"
|
||||||
|
)
|
||||||
|
|
||||||
|
if (state.requestPermissionDialog) {
|
||||||
|
BrowseStoragePermissionDialog(viewModel, permissionState)
|
||||||
|
}
|
||||||
|
if (state.showAddingDialog) {
|
||||||
|
BrowseAddingDialog(viewModel = viewModel, navigator = navigator)
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.pullRefresh(refreshState)
|
||||||
|
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
topBar = {
|
||||||
|
AnimatedTopAppBar(
|
||||||
|
containerColor = animatedTopAppBarColor,
|
||||||
|
scrolledContainerColor = MaterialTheme.elevation(),
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
|
||||||
|
content1Visibility = !state.hasSelectedItems && !state.showSearch,
|
||||||
|
content1NavigationIcon = {},
|
||||||
|
content1Title = {
|
||||||
|
Text(
|
||||||
|
stringResource(id = R.string.browse_screen),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
maxLines = 1
|
||||||
|
)
|
||||||
|
},
|
||||||
|
content1Actions = {
|
||||||
|
IconButton(onClick = { viewModel.onEvent(BrowseEvent.OnSearchShowHide) }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Search,
|
||||||
|
contentDescription = "Search files",
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
MoreDropDown(navigator = navigator)
|
||||||
|
},
|
||||||
|
|
||||||
|
content2Visibility = state.hasSelectedItems,
|
||||||
|
content2NavigationIcon = {
|
||||||
|
IconButton(onClick = { viewModel.onEvent(BrowseEvent.OnClearSelectedFiles) }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Clear,
|
||||||
|
contentDescription = "Clear selected items",
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
content2Title = {
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
id = R.string.selected_items_count_query,
|
||||||
|
state.selectedItemsCount.coerceAtLeast(1)
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
maxLines = 1
|
||||||
|
)
|
||||||
|
},
|
||||||
|
content2Actions = {
|
||||||
|
IconButton(onClick = { viewModel.onEvent(BrowseEvent.OnAddingDialogRequest) }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Check,
|
||||||
|
contentDescription = "Add files to library",
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
content3Visibility = state.showSearch && !state.hasSelectedItems,
|
||||||
|
content3NavigationIcon = {
|
||||||
|
IconButton(onClick = { viewModel.onEvent(BrowseEvent.OnSearchShowHide) }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Default.ArrowBack,
|
||||||
|
contentDescription = "Exit search mode",
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
content3Title = {
|
||||||
|
BasicTextField(
|
||||||
|
value = state.searchQuery,
|
||||||
|
singleLine = true,
|
||||||
|
textStyle = TextStyle(
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
fontSize = MaterialTheme.typography.titleLarge.fontSize,
|
||||||
|
lineHeight = MaterialTheme.typography.titleLarge.lineHeight,
|
||||||
|
fontFamily = MaterialTheme.typography.titleLarge.fontFamily
|
||||||
|
),
|
||||||
|
modifier = Modifier
|
||||||
|
.focusRequester(focusRequester)
|
||||||
|
.onGloballyPositioned {
|
||||||
|
viewModel.onEvent(BrowseEvent.OnRequestFocus(focusRequester))
|
||||||
|
},
|
||||||
|
onValueChange = {
|
||||||
|
viewModel.onEvent(BrowseEvent.OnSearchQueryChange(it))
|
||||||
|
},
|
||||||
|
keyboardOptions = KeyboardOptions(KeyboardCapitalization.Words),
|
||||||
|
cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
) { innerText ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxHeight(),
|
||||||
|
contentAlignment = Alignment.CenterStart
|
||||||
|
) {
|
||||||
|
if (state.searchQuery.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
id = R.string.search_query,
|
||||||
|
stringResource(id = R.string.files)
|
||||||
|
),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
innerText()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
content3Actions = {
|
||||||
|
MoreDropDown(navigator = navigator)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(top = padding.calculateTopPadding())
|
||||||
|
) {
|
||||||
|
LazyVerticalGrid(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
columns = GridCells.Adaptive(170.dp),
|
||||||
|
contentPadding = PaddingValues(10.dp)
|
||||||
|
) {
|
||||||
|
items(state.selectableFiles) { selectableFile ->
|
||||||
|
BrowseFileItem(
|
||||||
|
file = selectableFile,
|
||||||
|
modifier = Modifier
|
||||||
|
.animateItemPlacement(
|
||||||
|
animationSpec = tween(500)
|
||||||
|
),
|
||||||
|
onClick = {
|
||||||
|
viewModel.onEvent(BrowseEvent.OnSelectFile(selectableFile))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.isLoading && !state.isRefreshing && state.selectableFiles.isEmpty()) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
strokeCap = StrokeCap.Round,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.size(36.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
DefaultTransition(visible = state.showErrorMessage, Modifier.align(Alignment.Center)) {
|
||||||
|
IsError(
|
||||||
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
errorMessage = stringResource(id = R.string.error_permission),
|
||||||
|
icon = painterResource(id = R.drawable.error),
|
||||||
|
actionTitle = stringResource(id = R.string.grant_permission)
|
||||||
|
) {
|
||||||
|
viewModel.onEvent(BrowseEvent.OnPermissionCheck(permissionState))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = !state.isLoading && state.selectableFiles.isEmpty()
|
||||||
|
&& !state.showErrorMessage && !state.requestPermissionDialog,
|
||||||
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
enter = Transitions.DefaultTransitionIn,
|
||||||
|
exit = fadeOut(tween(0))
|
||||||
|
) {
|
||||||
|
IsEmpty(
|
||||||
|
message = stringResource(id = R.string.browse_empty),
|
||||||
|
icon = painterResource(id = R.drawable.empty_browse),
|
||||||
|
actionTitle = stringResource(id = R.string.get_help),
|
||||||
|
action = { navigator.navigate(Screen.HELP) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
PullRefreshIndicator(
|
||||||
|
state.isRefreshing,
|
||||||
|
refreshState,
|
||||||
|
Modifier.align(Alignment.TopCenter),
|
||||||
|
backgroundColor = MaterialTheme.elevation(),
|
||||||
|
contentColor = MaterialTheme.colorScheme.primary,
|
||||||
|
scale = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BackHandler {
|
||||||
|
if (state.hasSelectedItems) {
|
||||||
|
viewModel.onEvent(BrowseEvent.OnClearSelectedFiles)
|
||||||
|
} else if (state.showSearch) {
|
||||||
|
viewModel.onEvent(BrowseEvent.OnSearchShowHide)
|
||||||
|
} else {
|
||||||
|
navigator.navigate(Screen.LIBRARY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
package com.acclorite.books_history.presentation.screens.browse.components
|
||||||
|
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.CircularProgressIndicator
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.AddChart
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.acclorite.books_history.R
|
||||||
|
import com.acclorite.books_history.presentation.Navigator
|
||||||
|
import com.acclorite.books_history.presentation.components.CustomDialogWithLazyColumn
|
||||||
|
import com.acclorite.books_history.presentation.screens.browse.data.BrowseEvent
|
||||||
|
import com.acclorite.books_history.presentation.screens.browse.data.BrowseViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BrowseAddingDialog(viewModel: BrowseViewModel, navigator: Navigator) {
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
CustomDialogWithLazyColumn(
|
||||||
|
title = stringResource(id = R.string.add_books),
|
||||||
|
imageVectorIcon = Icons.Default.AddChart,
|
||||||
|
description = stringResource(id = R.string.add_books_description),
|
||||||
|
actionText = stringResource(id = R.string.add),
|
||||||
|
isActionEnabled = !state.isBooksLoading,
|
||||||
|
onDismiss = { viewModel.onEvent(BrowseEvent.OnAddingDialogDismiss) },
|
||||||
|
onAction = {
|
||||||
|
viewModel.onEvent(BrowseEvent.OnAddBooks(navigator))
|
||||||
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
context.getString(R.string.books_added),
|
||||||
|
Toast.LENGTH_LONG
|
||||||
|
).show()
|
||||||
|
},
|
||||||
|
withDivider = true,
|
||||||
|
items = {
|
||||||
|
if (state.isBooksLoading) {
|
||||||
|
item {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 16.dp)
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
strokeCap = StrokeCap.Round,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.size(36.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
items(state.selectedBooks) { book ->
|
||||||
|
BrowseAddingDialogItem(result = book) {
|
||||||
|
viewModel.onEvent(BrowseEvent.OnSelectBook(book))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
package com.acclorite.books_history.presentation.screens.browse.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Warning
|
||||||
|
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.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.acclorite.books_history.domain.model.NullableBook
|
||||||
|
import com.acclorite.books_history.presentation.components.CustomCheckbox
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BrowseAddingDialogItem(result: NullableBook, onClick: () -> Unit) {
|
||||||
|
|
||||||
|
if (result is NullableBook.NotNull) {
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable {
|
||||||
|
onClick()
|
||||||
|
}
|
||||||
|
.padding(vertical = 12.dp, horizontal = 24.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier.weight(0.85f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = result.book!!.first.title,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = result.book.first.author.asString(),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box(modifier = Modifier.weight(0.15f), contentAlignment = Alignment.CenterEnd) {
|
||||||
|
CustomCheckbox(selected = result.book!!.second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 12.dp, horizontal = 24.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Warning,
|
||||||
|
contentDescription = "Error",
|
||||||
|
modifier = Modifier.size(26.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = result.fileName!!,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,147 @@
|
||||||
|
package com.acclorite.books_history.presentation.screens.browse.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.acclorite.books_history.R
|
||||||
|
import com.acclorite.books_history.ui.DefaultTransition
|
||||||
|
import java.io.File
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BrowseFileItem(file: Pair<File, Boolean>, modifier: Modifier, onClick: () -> Unit) {
|
||||||
|
val fileExtension: String = file.first.name.substringAfterLast(".", "")
|
||||||
|
|
||||||
|
val pattern = SimpleDateFormat("HH:mm dd MMM", Locale.getDefault())
|
||||||
|
|
||||||
|
val lastModified = pattern.format(Date(file.first.lastModified()))
|
||||||
|
val icon = when (fileExtension) {
|
||||||
|
"txt" -> painterResource(id = R.drawable.txt)
|
||||||
|
"epub" -> painterResource(id = R.drawable.epub)
|
||||||
|
"pdf" -> painterResource(id = R.drawable.pdf)
|
||||||
|
else -> painterResource(id = R.drawable.file)
|
||||||
|
}
|
||||||
|
val outlineColor = if (file.second) MaterialTheme.colorScheme.primary
|
||||||
|
else MaterialTheme.colorScheme.outlineVariant
|
||||||
|
val backgroundColor = if (file.second) MaterialTheme.colorScheme.secondaryContainer
|
||||||
|
else Color.Transparent
|
||||||
|
|
||||||
|
val animatedOutlineColor by animateColorAsState(
|
||||||
|
targetValue = outlineColor,
|
||||||
|
tween(300),
|
||||||
|
label = "Outline animation"
|
||||||
|
)
|
||||||
|
val animatedBackgroundColor by animateColorAsState(
|
||||||
|
targetValue = backgroundColor,
|
||||||
|
tween(300),
|
||||||
|
label = "Background animation"
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = modifier
|
||||||
|
.padding(6.dp)
|
||||||
|
.clip(MaterialTheme.shapes.medium)
|
||||||
|
.border(
|
||||||
|
width = 1.dp,
|
||||||
|
color = animatedOutlineColor,
|
||||||
|
shape = MaterialTheme.shapes.medium
|
||||||
|
)
|
||||||
|
.padding(1.dp)
|
||||||
|
.background(animatedBackgroundColor)
|
||||||
|
.clickable {
|
||||||
|
onClick()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = icon,
|
||||||
|
contentDescription = "File icon",
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(vertical = 60.dp)
|
||||||
|
.size(70.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.Start,
|
||||||
|
modifier = Modifier
|
||||||
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
|
.padding(horizontal = 10.dp, vertical = 12.dp)
|
||||||
|
) {
|
||||||
|
Box {
|
||||||
|
DefaultTransition(visible = !file.second) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.file),
|
||||||
|
contentDescription = "File",
|
||||||
|
modifier = Modifier
|
||||||
|
.size(28.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DefaultTransition(visible = file.second) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.CheckCircle,
|
||||||
|
contentDescription = "Checked",
|
||||||
|
modifier = Modifier
|
||||||
|
.size(28.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
file.first.name,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
lastModified,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
package com.acclorite.books_history.presentation.screens.browse.components
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.SdStorage
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import com.acclorite.books_history.R
|
||||||
|
import com.acclorite.books_history.presentation.components.CustomAlertDialog
|
||||||
|
import com.acclorite.books_history.presentation.screens.browse.data.BrowseEvent
|
||||||
|
import com.acclorite.books_history.presentation.screens.browse.data.BrowseViewModel
|
||||||
|
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||||
|
import com.google.accompanist.permissions.PermissionState
|
||||||
|
|
||||||
|
@OptIn(ExperimentalPermissionsApi::class)
|
||||||
|
@Composable
|
||||||
|
fun BrowseStoragePermissionDialog(viewModel: BrowseViewModel, permissionState: PermissionState) {
|
||||||
|
val activity = LocalContext.current as ComponentActivity
|
||||||
|
|
||||||
|
CustomAlertDialog(
|
||||||
|
title = stringResource(id = R.string.storage_permission),
|
||||||
|
description = stringResource(id = R.string.storage_permission_description),
|
||||||
|
actionText = stringResource(id = R.string.grant),
|
||||||
|
imageVectorIcon = Icons.Default.SdStorage,
|
||||||
|
onDismiss = { viewModel.onEvent(BrowseEvent.OnStoragePermissionDismiss(permissionState)) },
|
||||||
|
onAction = {
|
||||||
|
viewModel.onEvent(
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
BrowseEvent.OnStoragePermissionRequest(activity)
|
||||||
|
} else {
|
||||||
|
BrowseEvent.OnLegacyStoragePermissionRequest(permissionState)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
@file:OptIn(ExperimentalPermissionsApi::class)
|
||||||
|
|
||||||
|
package com.acclorite.books_history.presentation.screens.browse.data
|
||||||
|
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import com.acclorite.books_history.domain.model.NullableBook
|
||||||
|
import com.acclorite.books_history.presentation.Navigator
|
||||||
|
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||||
|
import com.google.accompanist.permissions.PermissionState
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
sealed class BrowseEvent() {
|
||||||
|
data class OnStoragePermissionRequest(val activity: ComponentActivity) : BrowseEvent()
|
||||||
|
data class OnLegacyStoragePermissionRequest(val permissionState: PermissionState) :
|
||||||
|
BrowseEvent()
|
||||||
|
|
||||||
|
data class OnStoragePermissionDismiss(val permissionState: PermissionState) : BrowseEvent()
|
||||||
|
data object OnRefreshList : BrowseEvent()
|
||||||
|
data class OnPermissionCheck(val permissionState: PermissionState) : BrowseEvent()
|
||||||
|
data class OnSelectFile(val file: Pair<File, Boolean>) : BrowseEvent()
|
||||||
|
data class OnSelectBook(val book: NullableBook) : BrowseEvent()
|
||||||
|
data object OnSearchShowHide : BrowseEvent()
|
||||||
|
data class OnRequestFocus(val focusRequester: FocusRequester) : BrowseEvent()
|
||||||
|
|
||||||
|
data object OnClearSelectedFiles : BrowseEvent()
|
||||||
|
data class OnSearchQueryChange(val query: String) : BrowseEvent()
|
||||||
|
data object OnAddingDialogRequest : BrowseEvent()
|
||||||
|
data object OnAddingDialogDismiss : BrowseEvent()
|
||||||
|
data object OnGetBooksFromFiles : BrowseEvent()
|
||||||
|
data class OnAddBooks(val navigator: Navigator) : BrowseEvent()
|
||||||
|
data object OnLoadList : BrowseEvent()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
package com.acclorite.books_history.presentation.screens.browse.data
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import com.acclorite.books_history.domain.model.NullableBook
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class BrowseState(
|
||||||
|
val selectableFiles: List<Pair<File, Boolean>> = emptyList(),
|
||||||
|
val isLoading: Boolean = true,
|
||||||
|
val isRefreshing: Boolean = false,
|
||||||
|
val requestPermissionDialog: Boolean = false,
|
||||||
|
val showErrorMessage: Boolean = false,
|
||||||
|
|
||||||
|
val selectedItemsCount: Int = 0,
|
||||||
|
val hasSelectedItems: Boolean = false,
|
||||||
|
|
||||||
|
val showSearch: Boolean = false,
|
||||||
|
val searchQuery: String = "",
|
||||||
|
val hasFocused: Boolean = false,
|
||||||
|
|
||||||
|
val showAddingDialog: Boolean = false,
|
||||||
|
val selectedBooks: List<NullableBook> = emptyList(),
|
||||||
|
val isBooksLoading: Boolean = false
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,378 @@
|
||||||
|
package com.acclorite.books_history.presentation.screens.browse.data
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Environment
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.acclorite.books_history.domain.model.NullableBook
|
||||||
|
import com.acclorite.books_history.domain.use_case.FastGetBooks
|
||||||
|
import com.acclorite.books_history.domain.use_case.GetBooksFromFiles
|
||||||
|
import com.acclorite.books_history.domain.use_case.GetFilesFromDownloads
|
||||||
|
import com.acclorite.books_history.domain.use_case.InsertBooks
|
||||||
|
import com.acclorite.books_history.presentation.Argument
|
||||||
|
import com.acclorite.books_history.presentation.Screen
|
||||||
|
import com.acclorite.books_history.util.Resource
|
||||||
|
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||||
|
import com.google.accompanist.permissions.PermissionState
|
||||||
|
import com.google.accompanist.permissions.isGranted
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@OptIn(ExperimentalPermissionsApi::class)
|
||||||
|
@HiltViewModel
|
||||||
|
class BrowseViewModel @Inject constructor(
|
||||||
|
private val getBooksFromFiles: GetBooksFromFiles,
|
||||||
|
private val getFilesFromDownloads: GetFilesFromDownloads,
|
||||||
|
private val insertBooks: InsertBooks,
|
||||||
|
private val fastGetBooks: FastGetBooks
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(BrowseState())
|
||||||
|
val state = _state.asStateFlow()
|
||||||
|
|
||||||
|
private var job: Job? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
onEvent(BrowseEvent.OnLoadList)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onEvent(event: BrowseEvent) {
|
||||||
|
when (event) {
|
||||||
|
is BrowseEvent.OnLegacyStoragePermissionRequest -> {
|
||||||
|
event.permissionState.launchPermissionRequest()
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
showErrorMessage = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for (i in 0 until 100) {
|
||||||
|
if (!event.permissionState.status.isGranted) {
|
||||||
|
delay(100)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
requestPermissionDialog = false,
|
||||||
|
showErrorMessage = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onEvent(BrowseEvent.OnRefreshList)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is BrowseEvent.OnStoragePermissionRequest -> {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
|
||||||
|
val uri = Uri.parse("package:${event.activity.packageName}")
|
||||||
|
val intent = Intent(
|
||||||
|
Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION,
|
||||||
|
uri
|
||||||
|
)
|
||||||
|
event.activity.startActivity(intent)
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
for (i in 0 until 20) {
|
||||||
|
if (!Environment.isExternalStorageManager()) {
|
||||||
|
delay(1000)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
requestPermissionDialog = false,
|
||||||
|
showErrorMessage = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onEvent(BrowseEvent.OnRefreshList)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is BrowseEvent.OnStoragePermissionDismiss -> {
|
||||||
|
val legacyPermission = Build.VERSION.SDK_INT < Build.VERSION_CODES.R
|
||||||
|
val isPermissionGranted =
|
||||||
|
if (!legacyPermission) Environment.isExternalStorageManager()
|
||||||
|
else event.permissionState.status.isGranted
|
||||||
|
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
requestPermissionDialog = false,
|
||||||
|
showErrorMessage = !isPermissionGranted
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPermissionGranted) {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
getFilesFromDownloads()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
is BrowseEvent.OnRefreshList -> {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
isRefreshing = true,
|
||||||
|
selectedItemsCount = 0,
|
||||||
|
hasSelectedItems = false,
|
||||||
|
showSearch = false,
|
||||||
|
selectableFiles = emptyList()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
getFilesFromDownloads("")
|
||||||
|
delay(1000)
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
isRefreshing = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is BrowseEvent.OnPermissionCheck -> {
|
||||||
|
checkPermission(event.permissionState)
|
||||||
|
}
|
||||||
|
|
||||||
|
is BrowseEvent.OnSelectFile -> {
|
||||||
|
val indexOfFile = _state.value.selectableFiles.indexOf(event.file)
|
||||||
|
val editedList = _state.value.selectableFiles.toMutableList()
|
||||||
|
editedList[indexOfFile] = editedList[indexOfFile].copy(
|
||||||
|
second = !editedList[indexOfFile].second
|
||||||
|
)
|
||||||
|
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
selectableFiles = editedList.toList(),
|
||||||
|
selectedItemsCount = editedList.filter { file -> file.second }.size,
|
||||||
|
hasSelectedItems = editedList.any { file -> file.second }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is BrowseEvent.OnSelectBook -> {
|
||||||
|
val indexOfFile = _state.value.selectedBooks.indexOf(event.book)
|
||||||
|
val editedList = _state.value.selectedBooks.toMutableList()
|
||||||
|
editedList[indexOfFile] = NullableBook.NotNull(
|
||||||
|
editedList[indexOfFile].book!!.copy(
|
||||||
|
second = !editedList[indexOfFile].book!!.second
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!editedList.any { it.book?.second == true }) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
selectedBooks = editedList
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is BrowseEvent.OnSearchShowHide -> {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val shouldHide = _state.value.showSearch
|
||||||
|
|
||||||
|
if (shouldHide) {
|
||||||
|
getFilesFromDownloads("")
|
||||||
|
} else {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
searchQuery = "",
|
||||||
|
hasFocused = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
showSearch = !shouldHide
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is BrowseEvent.OnRequestFocus -> {
|
||||||
|
if (!_state.value.hasFocused) {
|
||||||
|
event.focusRequester.requestFocus()
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
hasFocused = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is BrowseEvent.OnClearSelectedFiles -> {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
selectableFiles = it.selectableFiles.map { file -> file.copy(second = false) },
|
||||||
|
hasSelectedItems = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is BrowseEvent.OnSearchQueryChange -> {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
searchQuery = event.query
|
||||||
|
)
|
||||||
|
}
|
||||||
|
job?.cancel()
|
||||||
|
job = viewModelScope.launch {
|
||||||
|
delay(500)
|
||||||
|
getFilesFromDownloads()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is BrowseEvent.OnAddingDialogDismiss -> {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
showAddingDialog = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is BrowseEvent.OnAddingDialogRequest -> {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
showAddingDialog = true,
|
||||||
|
selectedBooks = emptyList()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onEvent(BrowseEvent.OnGetBooksFromFiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
is BrowseEvent.OnGetBooksFromFiles -> {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
getBooksFromFiles.execute(
|
||||||
|
_state.value.selectableFiles
|
||||||
|
.filter { it.second }
|
||||||
|
.map { it.first }
|
||||||
|
).collect { result ->
|
||||||
|
when (result) {
|
||||||
|
is Resource.Success -> {
|
||||||
|
if (result.data?.isEmpty() ?: return@collect) {
|
||||||
|
return@collect
|
||||||
|
}
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
selectedBooks = result.data,
|
||||||
|
isBooksLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is Resource.Loading -> {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
isBooksLoading = result.isLoading
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is Resource.Error -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is BrowseEvent.OnAddBooks -> {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val booksToInsert = _state.value.selectedBooks
|
||||||
|
.filterIsInstance<NullableBook.NotNull>()
|
||||||
|
.map { it.book!!.first }
|
||||||
|
|
||||||
|
if (booksToInsert.isEmpty()) {
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
insertBooks.execute(booksToInsert)
|
||||||
|
val books = fastGetBooks.execute("")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
showAddingDialog = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onEvent(BrowseEvent.OnClearSelectedFiles)
|
||||||
|
|
||||||
|
event.navigator.navigate(Screen.LIBRARY, Argument("added_books", books))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is BrowseEvent.OnLoadList -> {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
getFilesFromDownloads("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkPermission(permissionState: PermissionState) {
|
||||||
|
val legacyPermission = Build.VERSION.SDK_INT < Build.VERSION_CODES.R
|
||||||
|
val isPermissionGranted = if (!legacyPermission) Environment.isExternalStorageManager()
|
||||||
|
else permissionState.status.isGranted
|
||||||
|
|
||||||
|
if (isPermissionGranted) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
requestPermissionDialog = true,
|
||||||
|
showErrorMessage = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getFilesFromDownloads(query: String = _state.value.searchQuery) {
|
||||||
|
getFilesFromDownloads.execute(query).collect { result ->
|
||||||
|
when (result) {
|
||||||
|
is Resource.Success -> {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
selectableFiles = result.data?.map { book -> Pair(book, false) }
|
||||||
|
?: emptyList(),
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is Resource.Loading -> {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = result.isLoading
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is Resource.Error -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,421 @@
|
||||||
|
package com.acclorite.books_history.presentation.screens.library
|
||||||
|
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
import androidx.compose.animation.core.LinearEasing
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.CircularProgressIndicator
|
||||||
|
import androidx.compose.material.ExperimentalMaterialApi
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.automirrored.outlined.DriveFileMove
|
||||||
|
import androidx.compose.material.icons.filled.Clear
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material.icons.outlined.Delete
|
||||||
|
import androidx.compose.material.pullrefresh.PullRefreshIndicator
|
||||||
|
import androidx.compose.material.pullrefresh.pullRefresh
|
||||||
|
import androidx.compose.material.pullrefresh.rememberPullRefreshState
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import com.acclorite.books_history.R
|
||||||
|
import com.acclorite.books_history.domain.model.Book
|
||||||
|
import com.acclorite.books_history.domain.model.Category
|
||||||
|
import com.acclorite.books_history.presentation.Navigator
|
||||||
|
import com.acclorite.books_history.presentation.Screen
|
||||||
|
import com.acclorite.books_history.presentation.components.AnimatedTopAppBar
|
||||||
|
import com.acclorite.books_history.presentation.components.IsEmpty
|
||||||
|
import com.acclorite.books_history.presentation.components.MoreDropDown
|
||||||
|
import com.acclorite.books_history.presentation.screens.library.components.LibraryBookItem
|
||||||
|
import com.acclorite.books_history.presentation.screens.library.components.LibraryMoveDialog
|
||||||
|
import com.acclorite.books_history.presentation.screens.library.components.LibraryTabRow
|
||||||
|
import com.acclorite.books_history.presentation.screens.library.data.LibraryEvent
|
||||||
|
import com.acclorite.books_history.presentation.screens.library.data.LibraryViewModel
|
||||||
|
import com.acclorite.books_history.ui.Transitions
|
||||||
|
import com.acclorite.books_history.ui.elevation
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@OptIn(
|
||||||
|
ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class,
|
||||||
|
ExperimentalMaterialApi::class
|
||||||
|
)
|
||||||
|
@Composable
|
||||||
|
fun LibraryScreen(
|
||||||
|
viewModel: LibraryViewModel = hiltViewModel(),
|
||||||
|
navigator: Navigator,
|
||||||
|
addedBooks: List<Book>
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
|
val pagerState =
|
||||||
|
rememberPagerState(initialPage = state.currentPage, pageCount = { Category.entries.size })
|
||||||
|
val refreshState = rememberPullRefreshState(
|
||||||
|
refreshing = state.isRefreshing,
|
||||||
|
onRefresh = {
|
||||||
|
viewModel.onEvent(LibraryEvent.OnRefreshList)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
if (addedBooks.isNotEmpty()) {
|
||||||
|
viewModel.onEvent(LibraryEvent.OnPreloadBooks(addedBooks))
|
||||||
|
navigator.clearArgument("added_books")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.onEvent(LibraryEvent.OnScrollToPage(state.currentPage, pagerState))
|
||||||
|
}
|
||||||
|
LaunchedEffect(pagerState.currentPage) {
|
||||||
|
viewModel.onEvent(LibraryEvent.OnUpdateCurrentPage(pagerState.currentPage))
|
||||||
|
}
|
||||||
|
|
||||||
|
val topAppBarColor = if (state.hasSelectedItems) MaterialTheme.elevation()
|
||||||
|
else MaterialTheme.colorScheme.surface
|
||||||
|
|
||||||
|
val animatedTopAppBarColor by animateColorAsState(
|
||||||
|
targetValue = topAppBarColor,
|
||||||
|
animationSpec = tween(300, easing = LinearEasing),
|
||||||
|
label = "TopAppBar color animation"
|
||||||
|
)
|
||||||
|
|
||||||
|
if (state.showMoveDialog) {
|
||||||
|
LibraryMoveDialog(viewModel = viewModel, pagerState = pagerState)
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.pullRefresh(refreshState),
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
topBar = {
|
||||||
|
Column(Modifier.fillMaxWidth()) {
|
||||||
|
AnimatedTopAppBar(
|
||||||
|
containerColor = animatedTopAppBarColor,
|
||||||
|
scrolledContainerColor = null,
|
||||||
|
scrollBehavior = null,
|
||||||
|
|
||||||
|
content1Visibility = !state.hasSelectedItems && !state.showSearch,
|
||||||
|
content1NavigationIcon = {},
|
||||||
|
content1Title = {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
stringResource(id = R.string.library_screen),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
maxLines = 1
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text(
|
||||||
|
text = state.books.size.toString(),
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(MaterialTheme.elevation(elevation = 6.dp))
|
||||||
|
.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
style = MaterialTheme.typography.titleLarge
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
content1Actions = {
|
||||||
|
IconButton(onClick = { viewModel.onEvent(LibraryEvent.OnSearchShowHide) }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Search,
|
||||||
|
contentDescription = "Search files",
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
MoreDropDown(navigator = navigator)
|
||||||
|
},
|
||||||
|
|
||||||
|
content2Visibility = state.hasSelectedItems,
|
||||||
|
content2NavigationIcon = {
|
||||||
|
IconButton(onClick = { viewModel.onEvent(LibraryEvent.OnClearSelectedBooks) }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Clear,
|
||||||
|
contentDescription = "Clear selected items",
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
content2Title = {
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
id = R.string.selected_items_count_query,
|
||||||
|
state.selectedItemsCount.coerceAtLeast(1)
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
maxLines = 1
|
||||||
|
)
|
||||||
|
},
|
||||||
|
content2Actions = {
|
||||||
|
IconButton(onClick = { viewModel.onEvent(LibraryEvent.OnShowHideMoveDialog) }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Outlined.DriveFileMove,
|
||||||
|
contentDescription = "Move books to another category",
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = { /* TODO Delete book */ }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.Delete,
|
||||||
|
contentDescription = "Delete books from database",
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
content3Visibility = state.showSearch && !state.hasSelectedItems,
|
||||||
|
content3NavigationIcon = {
|
||||||
|
IconButton(onClick = {
|
||||||
|
viewModel.onEvent(
|
||||||
|
LibraryEvent.OnSearchShowHide
|
||||||
|
)
|
||||||
|
}) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Default.ArrowBack,
|
||||||
|
contentDescription = "Exit search mode",
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
content3Title = {
|
||||||
|
BasicTextField(
|
||||||
|
value = state.searchQuery,
|
||||||
|
singleLine = true,
|
||||||
|
textStyle = TextStyle(
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
fontSize = MaterialTheme.typography.titleLarge.fontSize,
|
||||||
|
lineHeight = MaterialTheme.typography.titleLarge.lineHeight,
|
||||||
|
fontFamily = MaterialTheme.typography.titleLarge.fontFamily
|
||||||
|
),
|
||||||
|
modifier = Modifier
|
||||||
|
.focusRequester(focusRequester)
|
||||||
|
.onGloballyPositioned {
|
||||||
|
viewModel.onEvent(LibraryEvent.OnRequestFocus(focusRequester))
|
||||||
|
},
|
||||||
|
onValueChange = {
|
||||||
|
viewModel.onEvent(LibraryEvent.OnSearchQueryChange(it))
|
||||||
|
},
|
||||||
|
keyboardOptions = KeyboardOptions(KeyboardCapitalization.Words),
|
||||||
|
cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
) { innerText ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxHeight(),
|
||||||
|
contentAlignment = Alignment.CenterStart
|
||||||
|
) {
|
||||||
|
if (state.searchQuery.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
id = R.string.search_query,
|
||||||
|
stringResource(id = R.string.books)
|
||||||
|
),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
innerText()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
content3Actions = {
|
||||||
|
MoreDropDown(navigator = navigator)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
LibraryTabRow(
|
||||||
|
viewModel = viewModel,
|
||||||
|
books = state.books.map { it.first },
|
||||||
|
pagerState = pagerState
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(top = paddingValues.calculateTopPadding())
|
||||||
|
) {
|
||||||
|
HorizontalPager(state = pagerState, userScrollEnabled = !state.isRefreshing) { index ->
|
||||||
|
var categoryIsLoading by remember { mutableStateOf(true) }
|
||||||
|
val categorizedBooks = remember { mutableStateListOf<Pair<Book, Boolean>>() }
|
||||||
|
val category = Category.entries[index]
|
||||||
|
|
||||||
|
LaunchedEffect(state.books) {
|
||||||
|
categorizedBooks.clear()
|
||||||
|
categorizedBooks.addAll(state.books.filter { it.first.category == category }
|
||||||
|
.sortedByDescending {
|
||||||
|
it.first.lastOpened
|
||||||
|
})
|
||||||
|
categoryIsLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Adaptive(120.dp),
|
||||||
|
Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(8.dp)
|
||||||
|
) {
|
||||||
|
if (!state.isLoading) {
|
||||||
|
items(categorizedBooks) {
|
||||||
|
LibraryBookItem(
|
||||||
|
book = it,
|
||||||
|
onCoverImageClick = {
|
||||||
|
if (state.hasSelectedItems) {
|
||||||
|
viewModel.onEvent(LibraryEvent.OnSelectBook(it))
|
||||||
|
} else {
|
||||||
|
navigator.navigate(Screen.BOOKS_INFO)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLongClick = {
|
||||||
|
if (!it.second) {
|
||||||
|
viewModel.onEvent(LibraryEvent.OnSelectBook(it, true))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onButtonClick = { navigator.navigate(Screen.READER) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.isLoading && !state.isRefreshing) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
strokeCap = StrokeCap.Round,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.size(36.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = !state.isLoading && !state.isRefreshing && categorizedBooks.isEmpty()
|
||||||
|
&& !categoryIsLoading,
|
||||||
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
enter = Transitions.DefaultTransitionIn,
|
||||||
|
exit = fadeOut(tween(0))
|
||||||
|
) {
|
||||||
|
IsEmpty(
|
||||||
|
message = stringResource(id = R.string.library_empty),
|
||||||
|
icon = painterResource(id = R.drawable.empty_library),
|
||||||
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
actionTitle = stringResource(id = R.string.add_book)
|
||||||
|
) {
|
||||||
|
navigator.navigate(Screen.BROWSE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PullRefreshIndicator(
|
||||||
|
state.isRefreshing,
|
||||||
|
refreshState,
|
||||||
|
Modifier.align(Alignment.TopCenter),
|
||||||
|
backgroundColor = MaterialTheme.elevation(),
|
||||||
|
contentColor = MaterialTheme.colorScheme.primary,
|
||||||
|
scale = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val activity = LocalContext.current as ComponentActivity
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var shouldExit = false
|
||||||
|
BackHandler {
|
||||||
|
if (state.hasSelectedItems) {
|
||||||
|
viewModel.onEvent(LibraryEvent.OnClearSelectedBooks)
|
||||||
|
return@BackHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.showSearch) {
|
||||||
|
viewModel.onEvent(LibraryEvent.OnSearchShowHide)
|
||||||
|
return@BackHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.currentPage > 0) {
|
||||||
|
viewModel.onEvent(LibraryEvent.OnScrollToPage(0, pagerState))
|
||||||
|
return@BackHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldExit) {
|
||||||
|
activity.finish()
|
||||||
|
return@BackHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
Toast.makeText(
|
||||||
|
activity,
|
||||||
|
activity.getString(R.string.press_again_toast),
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
shouldExit = true
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
delay(1500)
|
||||||
|
shouldExit = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,183 @@
|
||||||
|
package com.acclorite.books_history.presentation.screens.library.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Image
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material.icons.filled.Warning
|
||||||
|
import androidx.compose.material3.FilledIconButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.acclorite.books_history.domain.model.Book
|
||||||
|
import com.acclorite.books_history.ui.elevation
|
||||||
|
import com.acclorite.books_history.util.removeDigits
|
||||||
|
import com.acclorite.books_history.util.removeTrailingZero
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
fun LibraryBookItem(
|
||||||
|
book: Pair<Book, Boolean>,
|
||||||
|
onCoverImageClick: () -> Unit,
|
||||||
|
onLongClick: () -> Unit,
|
||||||
|
onButtonClick: () -> Unit
|
||||||
|
) {
|
||||||
|
val backgroundColor = if (book.second) MaterialTheme.colorScheme.primary
|
||||||
|
else Color.Transparent
|
||||||
|
val fontColor = if (book.second) MaterialTheme.colorScheme.onPrimary
|
||||||
|
else MaterialTheme.colorScheme.onSurface
|
||||||
|
|
||||||
|
val animatedBackgroundColor by animateColorAsState(
|
||||||
|
targetValue = backgroundColor,
|
||||||
|
tween(300),
|
||||||
|
label = "Background animation"
|
||||||
|
)
|
||||||
|
val animatedFontColor by animateColorAsState(
|
||||||
|
targetValue = fontColor,
|
||||||
|
tween(300),
|
||||||
|
label = "Font color animation"
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.padding(3.dp)
|
||||||
|
.background(
|
||||||
|
animatedBackgroundColor,
|
||||||
|
MaterialTheme.shapes.large
|
||||||
|
)
|
||||||
|
.padding(3.dp)
|
||||||
|
.padding(bottom = 2.dp)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.aspectRatio(1f / 1.5f)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(MaterialTheme.shapes.large)
|
||||||
|
.background(MaterialTheme.elevation())
|
||||||
|
.combinedClickable(
|
||||||
|
onClick = {
|
||||||
|
onCoverImageClick()
|
||||||
|
},
|
||||||
|
onLongClick = {
|
||||||
|
onLongClick()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Image,
|
||||||
|
contentDescription = "Cover Image not found",
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.fillMaxWidth(0.7f)
|
||||||
|
.aspectRatio(1f),
|
||||||
|
tint = MaterialTheme.elevation(12.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (book.first.coverImage != null) {
|
||||||
|
Image(
|
||||||
|
bitmap = book.first.coverImage!!.asImageBitmap(),
|
||||||
|
contentDescription = "Cover",
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.clip(MaterialTheme.shapes.large),
|
||||||
|
contentScale = ContentScale.Crop
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (book.first.file != null) {
|
||||||
|
Text(
|
||||||
|
"${
|
||||||
|
(book.first.progress * 100)
|
||||||
|
.toDouble()
|
||||||
|
.removeDigits(1)
|
||||||
|
.removeTrailingZero()
|
||||||
|
}%",
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(6.dp)
|
||||||
|
.clip(MaterialTheme.shapes.small)
|
||||||
|
.background(MaterialTheme.colorScheme.primary)
|
||||||
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Warning,
|
||||||
|
contentDescription = "File is not found",
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(6.dp)
|
||||||
|
.clip(MaterialTheme.shapes.small)
|
||||||
|
.background(MaterialTheme.colorScheme.error)
|
||||||
|
.padding(4.dp)
|
||||||
|
.size(20.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
FilledIconButton(
|
||||||
|
onClick = { onButtonClick() },
|
||||||
|
enabled = book.first.file != null,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.padding(6.dp)
|
||||||
|
.size(32.dp),
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
colors = IconButtonDefaults.iconButtonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||||
|
.copy(0.9f),
|
||||||
|
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
.copy(0.9f),
|
||||||
|
disabledContainerColor = MaterialTheme.colorScheme.primaryContainer
|
||||||
|
.copy(0.6f),
|
||||||
|
disabledContentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
.copy(0.6f)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.PlayArrow,
|
||||||
|
contentDescription = "Continue reading",
|
||||||
|
Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
Text(
|
||||||
|
book.first.title,
|
||||||
|
color = animatedFontColor,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 4.dp),
|
||||||
|
minLines = 2,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
package com.acclorite.books_history.presentation.screens.library.components
|
||||||
|
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.pager.PagerState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import com.acclorite.books_history.R
|
||||||
|
import com.acclorite.books_history.domain.model.Category
|
||||||
|
import com.acclorite.books_history.presentation.components.CustomDialogWithLazyColumn
|
||||||
|
import com.acclorite.books_history.presentation.components.SelectableDialogItem
|
||||||
|
import com.acclorite.books_history.presentation.screens.library.data.LibraryEvent
|
||||||
|
import com.acclorite.books_history.presentation.screens.library.data.LibraryViewModel
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
fun LibraryMoveDialog(viewModel: LibraryViewModel, pagerState: PagerState) {
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
CustomDialogWithLazyColumn(
|
||||||
|
title = stringResource(id = R.string.move_books),
|
||||||
|
imageVectorIcon = null,
|
||||||
|
description = stringResource(
|
||||||
|
id = R.string.move_books_description,
|
||||||
|
state.books.filter { it.second }.size
|
||||||
|
),
|
||||||
|
actionText = stringResource(id = R.string.move),
|
||||||
|
isActionEnabled = true,
|
||||||
|
onDismiss = { viewModel.onEvent(LibraryEvent.OnShowHideMoveDialog) },
|
||||||
|
onAction = {
|
||||||
|
viewModel.onEvent(LibraryEvent.OnMoveBooks(pagerState))
|
||||||
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
context.getString(R.string.books_moved),
|
||||||
|
Toast.LENGTH_LONG
|
||||||
|
).show()
|
||||||
|
},
|
||||||
|
withDivider = false,
|
||||||
|
items = {
|
||||||
|
// item {
|
||||||
|
// Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
// }
|
||||||
|
|
||||||
|
items(state.categories) {
|
||||||
|
val category = when (it) {
|
||||||
|
Category.READING -> stringResource(id = R.string.reading_tab)
|
||||||
|
Category.ALREADY_READ -> stringResource(id = R.string.already_read_tab)
|
||||||
|
Category.PLANNING -> stringResource(id = R.string.planning_tab)
|
||||||
|
Category.DROPPED -> stringResource(id = R.string.dropped_tab)
|
||||||
|
}
|
||||||
|
|
||||||
|
SelectableDialogItem(selected = it == state.selectedCategory, title = category) {
|
||||||
|
viewModel.onEvent(LibraryEvent.OnSelectCategory(it))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,168 @@
|
||||||
|
package com.acclorite.books_history.presentation.screens.library.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.pager.PagerState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Divider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ScrollableTabRow
|
||||||
|
import androidx.compose.material3.Tab
|
||||||
|
import androidx.compose.material3.TabRow
|
||||||
|
import androidx.compose.material3.TabRowDefaults
|
||||||
|
import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.acclorite.books_history.R
|
||||||
|
import com.acclorite.books_history.domain.model.Book
|
||||||
|
import com.acclorite.books_history.domain.model.Category
|
||||||
|
import com.acclorite.books_history.presentation.screens.library.data.LibraryEvent
|
||||||
|
import com.acclorite.books_history.presentation.screens.library.data.LibraryViewModel
|
||||||
|
import com.acclorite.books_history.ui.elevation
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
fun LibraryTabRow(viewModel: LibraryViewModel, books: List<Book>, pagerState: PagerState) {
|
||||||
|
val tabItems = listOf(
|
||||||
|
Pair(
|
||||||
|
stringResource(id = R.string.reading_tab),
|
||||||
|
books.filter { it.category == Category.READING }.size
|
||||||
|
),
|
||||||
|
Pair(
|
||||||
|
stringResource(id = R.string.already_read_tab),
|
||||||
|
books.filter { it.category == Category.ALREADY_READ }.size
|
||||||
|
),
|
||||||
|
Pair(
|
||||||
|
stringResource(id = R.string.planning_tab),
|
||||||
|
books.filter { it.category == Category.PLANNING }.size
|
||||||
|
),
|
||||||
|
Pair(
|
||||||
|
stringResource(id = R.string.dropped_tab),
|
||||||
|
books.filter { it.category == Category.DROPPED }.size
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (LocalConfiguration.current.screenWidthDp > 450) {
|
||||||
|
TabRow(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth(),
|
||||||
|
selectedTabIndex = pagerState.currentPage,
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
divider = {
|
||||||
|
Divider(color = MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
},
|
||||||
|
indicator = { tabPositions ->
|
||||||
|
TabRowDefaults.Indicator(
|
||||||
|
Modifier
|
||||||
|
.tabIndicatorOffset(tabPositions[pagerState.currentPage])
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.clip(
|
||||||
|
RoundedCornerShape(
|
||||||
|
topStartPercent = 100,
|
||||||
|
topEndPercent = 100
|
||||||
|
)
|
||||||
|
),
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
tabItems.forEachIndexed { index, tabItem ->
|
||||||
|
Tab(
|
||||||
|
modifier = Modifier,
|
||||||
|
selected = pagerState.currentPage == index,
|
||||||
|
onClick = {
|
||||||
|
viewModel.onEvent(LibraryEvent.OnScrollToPage(index, pagerState))
|
||||||
|
},
|
||||||
|
selectedContentColor = MaterialTheme.colorScheme.primary,
|
||||||
|
unselectedContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
text = {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
tabItem.first,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
text = tabItem.second.toString(),
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(MaterialTheme.elevation(6.dp))
|
||||||
|
.padding(horizontal = 6.dp, vertical = 4.dp),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ScrollableTabRow(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth(),
|
||||||
|
selectedTabIndex = pagerState.currentPage,
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
divider = {
|
||||||
|
Divider(color = MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
},
|
||||||
|
edgePadding = 0.dp,
|
||||||
|
indicator = { tabPositions ->
|
||||||
|
TabRowDefaults.Indicator(
|
||||||
|
Modifier
|
||||||
|
.tabIndicatorOffset(tabPositions[pagerState.currentPage])
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.clip(
|
||||||
|
RoundedCornerShape(
|
||||||
|
topStartPercent = 100,
|
||||||
|
topEndPercent = 100
|
||||||
|
)
|
||||||
|
),
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
tabItems.forEachIndexed { index, tabItem ->
|
||||||
|
Tab(
|
||||||
|
modifier = Modifier,
|
||||||
|
selected = pagerState.currentPage == index,
|
||||||
|
onClick = {
|
||||||
|
viewModel.onEvent(LibraryEvent.OnScrollToPage(index, pagerState))
|
||||||
|
},
|
||||||
|
selectedContentColor = MaterialTheme.colorScheme.primary,
|
||||||
|
unselectedContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
text = {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
tabItem.first,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
text = tabItem.second.toString(),
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(MaterialTheme.elevation(6.dp))
|
||||||
|
.padding(horizontal = 6.dp, vertical = 4.dp),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
@file:OptIn(ExperimentalFoundationApi::class)
|
||||||
|
|
||||||
|
package com.acclorite.books_history.presentation.screens.library.data
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.pager.PagerState
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import com.acclorite.books_history.domain.model.Book
|
||||||
|
import com.acclorite.books_history.domain.model.Category
|
||||||
|
|
||||||
|
sealed class LibraryEvent {
|
||||||
|
data class OnPreloadBooks(val books: List<Book>) : LibraryEvent()
|
||||||
|
data object OnRefreshList : LibraryEvent()
|
||||||
|
data class OnScrollToPage(val index: Int, val pagerState: PagerState) : LibraryEvent()
|
||||||
|
data class OnUpdateCurrentPage(val page: Int) : LibraryEvent()
|
||||||
|
data object OnSearchShowHide : LibraryEvent()
|
||||||
|
data class OnRequestFocus(val focusRequester: FocusRequester) : LibraryEvent()
|
||||||
|
data class OnSearchQueryChange(val query: String) : LibraryEvent()
|
||||||
|
data class OnSelectBook(val book: Pair<Book, Boolean>, val select: Boolean? = null) :
|
||||||
|
LibraryEvent()
|
||||||
|
|
||||||
|
data object OnClearSelectedBooks : LibraryEvent()
|
||||||
|
data object OnShowHideMoveDialog : LibraryEvent()
|
||||||
|
data class OnSelectCategory(val category: Category) : LibraryEvent()
|
||||||
|
data class OnMoveBooks(val pagerState: PagerState) : LibraryEvent()
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
package com.acclorite.books_history.presentation.screens.library.data
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import com.acclorite.books_history.domain.model.Book
|
||||||
|
import com.acclorite.books_history.domain.model.Category
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class LibraryState(
|
||||||
|
val books: List<Pair<Book, Boolean>> = emptyList(),
|
||||||
|
val isLoading: Boolean = true,
|
||||||
|
val isRefreshing: Boolean = false,
|
||||||
|
|
||||||
|
val currentPage: Int = 0,
|
||||||
|
|
||||||
|
val selectedItemsCount: Int = 0,
|
||||||
|
val hasSelectedItems: Boolean = false,
|
||||||
|
|
||||||
|
val showSearch: Boolean = false,
|
||||||
|
val searchQuery: String = "",
|
||||||
|
val hasFocused: Boolean = false,
|
||||||
|
|
||||||
|
val showMoveDialog: Boolean = false,
|
||||||
|
val categories: List<Category> = emptyList(),
|
||||||
|
val selectedCategory: Category = Category.READING,
|
||||||
|
|
||||||
|
val showDeleteDialog: Boolean = false,
|
||||||
|
|
||||||
|
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,261 @@
|
||||||
|
package com.acclorite.books_history.presentation.screens.library.data
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.acclorite.books_history.domain.model.Category
|
||||||
|
import com.acclorite.books_history.domain.use_case.GetBooks
|
||||||
|
import com.acclorite.books_history.domain.use_case.UpdateBooks
|
||||||
|
import com.acclorite.books_history.util.Resource
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@HiltViewModel
|
||||||
|
class LibraryViewModel @Inject constructor(
|
||||||
|
private val getBooks: GetBooks,
|
||||||
|
private val updateBooks: UpdateBooks
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(LibraryState())
|
||||||
|
val state = _state.asStateFlow()
|
||||||
|
|
||||||
|
private var job: Job? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
getBooksFromDatabase()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onEvent(event: LibraryEvent) {
|
||||||
|
when (event) {
|
||||||
|
is LibraryEvent.OnScrollToPage -> {
|
||||||
|
viewModelScope.launch {
|
||||||
|
event.pagerState.scrollToPage(event.index)
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
currentPage = event.index
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is LibraryEvent.OnUpdateCurrentPage -> {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
currentPage = event.page
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is LibraryEvent.OnPreloadBooks -> {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
books = event.books.map { book -> Pair(book, false) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is LibraryEvent.OnRefreshList -> {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
isRefreshing = true,
|
||||||
|
selectedItemsCount = 0,
|
||||||
|
hasSelectedItems = false,
|
||||||
|
showSearch = false,
|
||||||
|
books = emptyList()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
getBooksFromDatabase("")
|
||||||
|
delay(500)
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
isRefreshing = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is LibraryEvent.OnSearchShowHide -> {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val shouldHide = _state.value.showSearch
|
||||||
|
|
||||||
|
if (shouldHide) {
|
||||||
|
getBooksFromDatabase("")
|
||||||
|
} else {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
searchQuery = "",
|
||||||
|
hasFocused = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
showSearch = !shouldHide
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is LibraryEvent.OnRequestFocus -> {
|
||||||
|
if (!_state.value.hasFocused) {
|
||||||
|
event.focusRequester.requestFocus()
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
hasFocused = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is LibraryEvent.OnSearchQueryChange -> {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
searchQuery = event.query
|
||||||
|
)
|
||||||
|
}
|
||||||
|
job?.cancel()
|
||||||
|
job = viewModelScope.launch {
|
||||||
|
delay(500)
|
||||||
|
getBooksFromDatabase()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is LibraryEvent.OnSelectBook -> {
|
||||||
|
val indexOfBook = _state.value.books.indexOf(event.book)
|
||||||
|
val editedList = _state.value.books.toMutableList()
|
||||||
|
editedList[indexOfBook] = editedList[indexOfBook].copy(
|
||||||
|
second = event.select ?: !editedList[indexOfBook].second
|
||||||
|
)
|
||||||
|
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
books = editedList.toList(),
|
||||||
|
selectedItemsCount = editedList.filter { book -> book.second }.size,
|
||||||
|
hasSelectedItems = editedList.any { book -> book.second }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is LibraryEvent.OnClearSelectedBooks -> {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
books = it.books.map { book -> book.copy(second = false) },
|
||||||
|
hasSelectedItems = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is LibraryEvent.OnShowHideMoveDialog -> {
|
||||||
|
val categories = mutableListOf<Category>()
|
||||||
|
|
||||||
|
Category.entries.forEach { category ->
|
||||||
|
if (!_state.value.books
|
||||||
|
.filter { it.second }
|
||||||
|
.all { it.first.category == category }
|
||||||
|
) {
|
||||||
|
categories.add(
|
||||||
|
category
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
categories = categories,
|
||||||
|
selectedCategory = categories[0],
|
||||||
|
showMoveDialog = !it.showMoveDialog
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is LibraryEvent.OnSelectCategory -> {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
selectedCategory = event.category
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is LibraryEvent.OnMoveBooks -> {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
showMoveDialog = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val books = _state.value.books.filter {
|
||||||
|
it.second
|
||||||
|
}.map { it.first.copy(category = _state.value.selectedCategory) }
|
||||||
|
updateBooks.execute(books)
|
||||||
|
|
||||||
|
getBooksFromDatabase()
|
||||||
|
|
||||||
|
onEvent(LibraryEvent.OnClearSelectedBooks)
|
||||||
|
onEvent(
|
||||||
|
LibraryEvent.OnScrollToPage(
|
||||||
|
Category.entries.dropLastWhile {
|
||||||
|
it != _state.value.selectedCategory
|
||||||
|
}.size - 1,
|
||||||
|
event.pagerState
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getBooksFromDatabase(query: String = _state.value.searchQuery) {
|
||||||
|
getBooks.execute(query).collect { result ->
|
||||||
|
when (result) {
|
||||||
|
is Resource.Success -> {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
books = result.data?.map { book -> Pair(book, false) }
|
||||||
|
?: emptyList(),
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is Resource.Loading -> {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = result.isLoading
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is Resource.Error -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
package com.acclorite.books_history.presentation.screens.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.DisplaySettings
|
||||||
|
import androidx.compose.material.icons.filled.Palette
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import com.acclorite.books_history.R
|
||||||
|
import com.acclorite.books_history.presentation.Navigator
|
||||||
|
import com.acclorite.books_history.presentation.Screen
|
||||||
|
import com.acclorite.books_history.presentation.main.MainViewModel
|
||||||
|
import com.acclorite.books_history.presentation.screens.settings.components.SettingsCategoryItem
|
||||||
|
import com.acclorite.books_history.ui.elevation
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SettingsScreen(
|
||||||
|
viewModel: MainViewModel,
|
||||||
|
navigator: Navigator
|
||||||
|
) {
|
||||||
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.nestedScroll(scrollBehavior.nestedScrollConnection)
|
||||||
|
.windowInsetsPadding(WindowInsets.navigationBars),
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
stringResource(id = R.string.settings_screen),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
maxLines = 1
|
||||||
|
)
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { navigator.navigateBack() }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = "Go back",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
scrolledContainerColor = MaterialTheme.elevation()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(top = paddingValues.calculateTopPadding())
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
) {
|
||||||
|
SettingsCategoryItem(
|
||||||
|
icon = Icons.Default.DisplaySettings,
|
||||||
|
text = stringResource(id = R.string.general_settings),
|
||||||
|
description = stringResource(id = R.string.general_settings_desc)
|
||||||
|
) {
|
||||||
|
navigator.navigate(Screen.GENERAL_SETTINGS)
|
||||||
|
}
|
||||||
|
SettingsCategoryItem(
|
||||||
|
icon = Icons.Default.Palette,
|
||||||
|
text = stringResource(id = R.string.appearance_settings),
|
||||||
|
description = stringResource(id = R.string.appearance_settings_desc)
|
||||||
|
) {
|
||||||
|
navigator.navigate(Screen.APPEARANCE_SETTINGS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
package com.acclorite.books_history.presentation.screens.settings.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
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.vector.ImageVector
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsCategoryItem(
|
||||||
|
icon: ImageVector,
|
||||||
|
text: String,
|
||||||
|
description: String,
|
||||||
|
verticalPadding: Dp = 16.dp,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable {
|
||||||
|
onClick()
|
||||||
|
}
|
||||||
|
.padding(vertical = verticalPadding, horizontal = 16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.Start
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
fontSize = 18.sp
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
description,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
package com.acclorite.books_history.presentation.screens.settings.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
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.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsOptionItem(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
verticalPadding: Dp = 8.dp,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable {
|
||||||
|
onClick()
|
||||||
|
}
|
||||||
|
.padding(horizontal = 16.dp, vertical = verticalPadding),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.Start
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
fontSize = 18.sp
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
description,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
package com.acclorite.books_history.presentation.screens.settings.nested.appearance
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import com.acclorite.books_history.R
|
||||||
|
import com.acclorite.books_history.presentation.Navigator
|
||||||
|
import com.acclorite.books_history.presentation.main.MainViewModel
|
||||||
|
import com.acclorite.books_history.presentation.screens.settings.components.SettingsOptionItem
|
||||||
|
import com.acclorite.books_history.presentation.screens.settings.nested.appearance.components.AppearanceSettingsDarkThemeDialog
|
||||||
|
import com.acclorite.books_history.presentation.screens.settings.nested.appearance.components.AppearanceSettingsThemeSwitcher
|
||||||
|
import com.acclorite.books_history.presentation.screens.settings.nested.appearance.data.AppearanceSettingsEvent
|
||||||
|
import com.acclorite.books_history.presentation.screens.settings.nested.appearance.data.AppearanceSettingsViewModel
|
||||||
|
import com.acclorite.books_history.ui.DarkTheme
|
||||||
|
import com.acclorite.books_history.ui.elevation
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun AppearanceSettings(
|
||||||
|
viewModel: AppearanceSettingsViewModel = hiltViewModel(),
|
||||||
|
mainViewModel: MainViewModel,
|
||||||
|
navigator: Navigator
|
||||||
|
) {
|
||||||
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
|
|
||||||
|
val darkTheme = mainViewModel.darkTheme.collectAsState().value!!
|
||||||
|
|
||||||
|
if (state.showDarkThemeDialog) {
|
||||||
|
AppearanceSettingsDarkThemeDialog(viewModel = viewModel, mainViewModel = mainViewModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.nestedScroll(scrollBehavior.nestedScrollConnection)
|
||||||
|
.windowInsetsPadding(WindowInsets.navigationBars),
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
stringResource(id = R.string.appearance_settings),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
maxLines = 1
|
||||||
|
)
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { navigator.navigateBack() }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = "Go back",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
scrolledContainerColor = MaterialTheme.elevation()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(top = paddingValues.calculateTopPadding())
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
) {
|
||||||
|
SettingsOptionItem(
|
||||||
|
title = stringResource(id = R.string.dark_theme_option),
|
||||||
|
description = when (darkTheme) {
|
||||||
|
DarkTheme.OFF -> stringResource(id = R.string.dark_theme_off)
|
||||||
|
DarkTheme.ON -> stringResource(id = R.string.dark_theme_on)
|
||||||
|
DarkTheme.FOLLOW_SYSTEM -> stringResource(id = R.string.dark_theme_follow_system)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
viewModel.onEvent(AppearanceSettingsEvent.OnShowHideDarkThemeDialog)
|
||||||
|
}
|
||||||
|
AppearanceSettingsThemeSwitcher(mainViewModel = mainViewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
package com.acclorite.books_history.presentation.screens.settings.nested.appearance.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.acclorite.books_history.R
|
||||||
|
import com.acclorite.books_history.presentation.components.CustomDialogWithLazyColumn
|
||||||
|
import com.acclorite.books_history.presentation.components.SelectableDialogItem
|
||||||
|
import com.acclorite.books_history.presentation.main.MainEvent
|
||||||
|
import com.acclorite.books_history.presentation.main.MainViewModel
|
||||||
|
import com.acclorite.books_history.presentation.screens.settings.nested.appearance.data.AppearanceSettingsEvent
|
||||||
|
import com.acclorite.books_history.presentation.screens.settings.nested.appearance.data.AppearanceSettingsViewModel
|
||||||
|
import com.acclorite.books_history.ui.DarkTheme
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AppearanceSettingsDarkThemeDialog(
|
||||||
|
viewModel: AppearanceSettingsViewModel,
|
||||||
|
mainViewModel: MainViewModel
|
||||||
|
) {
|
||||||
|
val darkTheme = mainViewModel.darkTheme.collectAsState().value!!
|
||||||
|
|
||||||
|
CustomDialogWithLazyColumn(
|
||||||
|
title = stringResource(id = R.string.dark_theme_option),
|
||||||
|
imageVectorIcon = null,
|
||||||
|
description = null,
|
||||||
|
actionText = null,
|
||||||
|
isActionEnabled = null,
|
||||||
|
onDismiss = { viewModel.onEvent(AppearanceSettingsEvent.OnShowHideDarkThemeDialog) },
|
||||||
|
onAction = {},
|
||||||
|
withDivider = false,
|
||||||
|
items = {
|
||||||
|
item {
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
items(DarkTheme.entries) {
|
||||||
|
val theme = when (it) {
|
||||||
|
DarkTheme.OFF -> stringResource(id = R.string.dark_theme_off)
|
||||||
|
DarkTheme.ON -> stringResource(id = R.string.dark_theme_on)
|
||||||
|
DarkTheme.FOLLOW_SYSTEM -> stringResource(id = R.string.dark_theme_follow_system)
|
||||||
|
}
|
||||||
|
|
||||||
|
SelectableDialogItem(selected = it == darkTheme, title = theme) {
|
||||||
|
mainViewModel.onEvent(
|
||||||
|
MainEvent.OnChangeDarkTheme(
|
||||||
|
it.toString()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
package com.acclorite.books_history.presentation.screens.settings.nested.appearance.components
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.acclorite.books_history.R
|
||||||
|
import com.acclorite.books_history.presentation.main.MainEvent
|
||||||
|
import com.acclorite.books_history.presentation.main.MainViewModel
|
||||||
|
import com.acclorite.books_history.ui.Theme
|
||||||
|
import com.acclorite.books_history.ui.isDark
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AppearanceSettingsThemeSwitcher(
|
||||||
|
mainViewModel: MainViewModel
|
||||||
|
) {
|
||||||
|
val theme = mainViewModel.theme.collectAsState().value!!
|
||||||
|
val darkTheme = mainViewModel.darkTheme.collectAsState().value!!
|
||||||
|
|
||||||
|
val themes = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) Theme.entries
|
||||||
|
else Theme.entries.dropWhile { it == Theme.DYNAMIC }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.app_theme_option),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
fontSize = 18.sp,
|
||||||
|
modifier = Modifier.padding(start = 16.dp, top = 8.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
LazyRow(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
itemsIndexed(themes) { index, themeEntry ->
|
||||||
|
if (index == 0)
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
|
||||||
|
AppearanceSettingsThemeSwitcherItem(
|
||||||
|
theme = themeEntry,
|
||||||
|
darkTheme = darkTheme.isDark(),
|
||||||
|
selected = theme == themeEntry
|
||||||
|
) {
|
||||||
|
mainViewModel.onEvent(MainEvent.OnChangeTheme(themeEntry.toString()))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index != Theme.entries.lastIndex) {
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
} else {
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,149 @@
|
||||||
|
package com.acclorite.books_history.presentation.screens.settings.nested.appearance.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.NavigationBarDefaults
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.surfaceColorAtElevation
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.acclorite.books_history.R
|
||||||
|
import com.acclorite.books_history.ui.Theme
|
||||||
|
import com.acclorite.books_history.ui.colorScheme
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AppearanceSettingsThemeSwitcherItem(
|
||||||
|
theme: Theme,
|
||||||
|
darkTheme: Boolean,
|
||||||
|
selected: Boolean,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
val colorScheme = colorScheme(theme, darkTheme)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(MaterialTheme.shapes.large)
|
||||||
|
.clickable(enabled = !selected) {
|
||||||
|
onClick()
|
||||||
|
}
|
||||||
|
.background(
|
||||||
|
colorScheme.surface
|
||||||
|
)
|
||||||
|
.border(
|
||||||
|
4.dp,
|
||||||
|
if (selected) colorScheme.primary
|
||||||
|
else MaterialTheme.colorScheme.outlineVariant,
|
||||||
|
MaterialTheme.shapes.large
|
||||||
|
)
|
||||||
|
.padding(4.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 8.dp, vertical = 14.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.clip(RoundedCornerShape(10.dp))
|
||||||
|
.height(20.dp)
|
||||||
|
.width(80.dp)
|
||||||
|
.background(colorScheme.onSurface)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.CheckCircle,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(26.dp),
|
||||||
|
tint =
|
||||||
|
if (selected) colorScheme.primary
|
||||||
|
else Color.Transparent
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.padding(start = 8.dp)
|
||||||
|
.height(80.dp)
|
||||||
|
.width(70.dp)
|
||||||
|
.clip(RoundedCornerShape(14.dp))
|
||||||
|
.background(
|
||||||
|
colorScheme.surfaceColorAtElevation(
|
||||||
|
NavigationBarDefaults.Elevation
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(40.dp))
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.width(130.dp)
|
||||||
|
.height(40.dp)
|
||||||
|
.background(
|
||||||
|
colorScheme.surfaceColorAtElevation(
|
||||||
|
NavigationBarDefaults.Elevation
|
||||||
|
)
|
||||||
|
),
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.clip(CircleShape)
|
||||||
|
.size(20.dp)
|
||||||
|
.background(colorScheme.primary)
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.clip(RoundedCornerShape(10.dp))
|
||||||
|
.height(20.dp)
|
||||||
|
.width(60.dp)
|
||||||
|
.background(colorScheme.onSurfaceVariant)
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
Text(
|
||||||
|
text = when (theme) {
|
||||||
|
Theme.BLUE -> stringResource(id = R.string.blue_theme)
|
||||||
|
Theme.PURPLE -> stringResource(id = R.string.purple_theme)
|
||||||
|
Theme.PINK -> stringResource(id = R.string.pink_theme)
|
||||||
|
Theme.GREEN -> stringResource(id = R.string.green_theme)
|
||||||
|
Theme.DYNAMIC -> stringResource(id = R.string.dynamic_theme)
|
||||||
|
},
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
maxLines = 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
package com.acclorite.books_history.presentation.screens.settings.nested.appearance.data
|
||||||
|
|
||||||
|
sealed class AppearanceSettingsEvent() {
|
||||||
|
data object OnShowHideDarkThemeDialog : AppearanceSettingsEvent()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
package com.acclorite.books_history.presentation.screens.settings.nested.appearance.data
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class AppearanceSettingsState(
|
||||||
|
val showDarkThemeDialog: Boolean = false
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
package com.acclorite.books_history.presentation.screens.settings.nested.appearance.data
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@HiltViewModel
|
||||||
|
class AppearanceSettingsViewModel @Inject constructor(
|
||||||
|
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(AppearanceSettingsState())
|
||||||
|
val state = _state.asStateFlow()
|
||||||
|
|
||||||
|
fun onEvent(event: AppearanceSettingsEvent) {
|
||||||
|
when (event) {
|
||||||
|
is AppearanceSettingsEvent.OnShowHideDarkThemeDialog -> {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
showDarkThemeDialog = !it.showDarkThemeDialog
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
package com.acclorite.books_history.presentation.screens.settings.nested.general
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import com.acclorite.books_history.R
|
||||||
|
import com.acclorite.books_history.presentation.Navigator
|
||||||
|
import com.acclorite.books_history.presentation.main.MainViewModel
|
||||||
|
import com.acclorite.books_history.presentation.screens.settings.components.SettingsOptionItem
|
||||||
|
import com.acclorite.books_history.presentation.screens.settings.nested.general.components.GeneralSettingsLanguageDialog
|
||||||
|
import com.acclorite.books_history.presentation.screens.settings.nested.general.data.GeneralSettingsEvent
|
||||||
|
import com.acclorite.books_history.presentation.screens.settings.nested.general.data.GeneralSettingsViewModel
|
||||||
|
import com.acclorite.books_history.ui.elevation
|
||||||
|
import com.acclorite.books_history.util.Constants
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun GeneralSettings(
|
||||||
|
viewModel: GeneralSettingsViewModel = hiltViewModel(),
|
||||||
|
mainViewModel: MainViewModel,
|
||||||
|
navigator: Navigator
|
||||||
|
) {
|
||||||
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
|
|
||||||
|
val language = mainViewModel.language.collectAsState().value!!
|
||||||
|
|
||||||
|
if (state.showLanguageDialog) {
|
||||||
|
GeneralSettingsLanguageDialog(viewModel = viewModel, mainViewModel = mainViewModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.nestedScroll(scrollBehavior.nestedScrollConnection)
|
||||||
|
.windowInsetsPadding(WindowInsets.navigationBars),
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
stringResource(id = R.string.general_settings),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
maxLines = 1
|
||||||
|
)
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { navigator.navigateBack() }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = "Go back",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
scrolledContainerColor = MaterialTheme.elevation()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(top = paddingValues.calculateTopPadding())
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
) {
|
||||||
|
SettingsOptionItem(
|
||||||
|
title = stringResource(id = R.string.language_option),
|
||||||
|
description = Constants.LANGUAGES.find { it.first == language }?.second
|
||||||
|
?: return@Column
|
||||||
|
) {
|
||||||
|
viewModel.onEvent(GeneralSettingsEvent.OnShowHideLanguageDialog)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
package com.acclorite.books_history.presentation.screens.settings.nested.general.components
|
||||||
|
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.acclorite.books_history.R
|
||||||
|
import com.acclorite.books_history.presentation.components.CustomDialogWithLazyColumn
|
||||||
|
import com.acclorite.books_history.presentation.components.SelectableDialogItem
|
||||||
|
import com.acclorite.books_history.presentation.main.MainEvent
|
||||||
|
import com.acclorite.books_history.presentation.main.MainViewModel
|
||||||
|
import com.acclorite.books_history.presentation.screens.settings.nested.general.data.GeneralSettingsEvent
|
||||||
|
import com.acclorite.books_history.presentation.screens.settings.nested.general.data.GeneralSettingsViewModel
|
||||||
|
import com.acclorite.books_history.util.Constants
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GeneralSettingsLanguageDialog(
|
||||||
|
viewModel: GeneralSettingsViewModel,
|
||||||
|
mainViewModel: MainViewModel
|
||||||
|
) {
|
||||||
|
val language = mainViewModel.language.collectAsState().value!!
|
||||||
|
val activity = LocalContext.current as ComponentActivity
|
||||||
|
|
||||||
|
CustomDialogWithLazyColumn(
|
||||||
|
title = stringResource(id = R.string.language_option),
|
||||||
|
imageVectorIcon = null,
|
||||||
|
description = null,
|
||||||
|
actionText = null,
|
||||||
|
isActionEnabled = null,
|
||||||
|
onDismiss = { viewModel.onEvent(GeneralSettingsEvent.OnShowHideLanguageDialog) },
|
||||||
|
onAction = {},
|
||||||
|
withDivider = false,
|
||||||
|
items = {
|
||||||
|
item {
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
}
|
||||||
|
items(Constants.LANGUAGES) { lang ->
|
||||||
|
SelectableDialogItem(selected = lang.first == language, title = lang.second) {
|
||||||
|
mainViewModel.onEvent(
|
||||||
|
MainEvent.OnChangeLanguage(
|
||||||
|
lang.first,
|
||||||
|
activity
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
package com.acclorite.books_history.presentation.screens.settings.nested.general.data
|
||||||
|
|
||||||
|
sealed class GeneralSettingsEvent() {
|
||||||
|
data object OnShowHideLanguageDialog : GeneralSettingsEvent()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
package com.acclorite.books_history.presentation.screens.settings.nested.general.data
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class GeneralSettingsState(
|
||||||
|
val showLanguageDialog: Boolean = false
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
package com.acclorite.books_history.presentation.screens.settings.nested.general.data
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@HiltViewModel
|
||||||
|
class GeneralSettingsViewModel @Inject constructor(
|
||||||
|
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(GeneralSettingsState())
|
||||||
|
val state = _state.asStateFlow()
|
||||||
|
|
||||||
|
fun onEvent(event: GeneralSettingsEvent) {
|
||||||
|
when (event) {
|
||||||
|
is GeneralSettingsEvent.OnShowHideLanguageDialog -> {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
showLanguageDialog = !it.showLanguageDialog
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package com.acclorite.books_history.ui.theme
|
package com.acclorite.books_history.ui
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import androidx.compose.material3.ColorScheme
|
import androidx.compose.material3.ColorScheme
|
||||||
|
|
@ -6,6 +6,10 @@ import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
import androidx.compose.material3.dynamicLightColorScheme
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import com.acclorite.books_history.ui.theme.blueTheme
|
||||||
|
import com.acclorite.books_history.ui.theme.greenTheme
|
||||||
|
import com.acclorite.books_history.ui.theme.pinkTheme
|
||||||
|
import com.acclorite.books_history.ui.theme.purpleTheme
|
||||||
|
|
||||||
enum class Theme {
|
enum class Theme {
|
||||||
DYNAMIC,
|
DYNAMIC,
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
package com.acclorite.books_history.ui
|
||||||
|
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
object ElevationDefaults {
|
||||||
|
val DialogElevation = 6.dp
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
package com.acclorite.books_history.ui.theme
|
package com.acclorite.books_history.ui
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.NavigationBarDefaults
|
import androidx.compose.material3.NavigationBarDefaults
|
||||||
|
import androidx.compose.material3.Shapes
|
||||||
import androidx.compose.material3.surfaceColorAtElevation
|
import androidx.compose.material3.surfaceColorAtElevation
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.SideEffect
|
import androidx.compose.runtime.SideEffect
|
||||||
|
|
@ -59,15 +60,16 @@ fun BooksHistoryResurrectionTheme(
|
||||||
|
|
||||||
MaterialTheme(
|
MaterialTheme(
|
||||||
colorScheme = colorScheme(
|
colorScheme = colorScheme(
|
||||||
theme = if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) theme
|
theme = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) theme
|
||||||
else if(theme != Theme.DYNAMIC) theme else Theme.BLUE,
|
else if (theme != Theme.DYNAMIC) theme else Theme.BLUE,
|
||||||
darkTheme = isDark
|
darkTheme = isDark
|
||||||
),
|
),
|
||||||
|
shapes = Shapes(),
|
||||||
typography = Typography,
|
typography = Typography,
|
||||||
content = content
|
content = content
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MaterialTheme.getElevatedColor(elevation: Dp = NavigationBarDefaults.Elevation): Color =
|
fun MaterialTheme.elevation(elevation: Dp = NavigationBarDefaults.Elevation): Color =
|
||||||
colorScheme.surfaceColorAtElevation(elevation)
|
colorScheme.surfaceColorAtElevation(elevation)
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
package com.acclorite.books_history.ui
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.slideInHorizontally
|
||||||
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
|
||||||
|
object Transitions {
|
||||||
|
val DefaultTransitionIn = fadeIn(tween(300))
|
||||||
|
val DefaultTransitionOut = fadeOut(tween(300))
|
||||||
|
|
||||||
|
val SlidingTransitionIn = fadeIn(tween(300), initialAlpha = 0.6f) +
|
||||||
|
slideInHorizontally(tween(300)) { it / 6 }
|
||||||
|
|
||||||
|
val SlidingTransitionOut = fadeOut(tween(300)) +
|
||||||
|
slideOutHorizontally(tween(300)) { it / 6 }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DefaultTransition(
|
||||||
|
visible: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
|
||||||
|
content: @Composable (() -> Unit)
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible,
|
||||||
|
modifier = modifier,
|
||||||
|
enter = Transitions.DefaultTransitionIn,
|
||||||
|
exit = Transitions.DefaultTransitionOut
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TopAppBarTransition(visible: Boolean, content: @Composable (() -> Unit)) {
|
||||||
|
DefaultTransition(visible = visible) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
package com.acclorite.books_history.ui
|
||||||
|
|
||||||
|
import androidx.compose.material3.Typography
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
val Typography = Typography(
|
||||||
|
headlineSmall = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 24.sp,
|
||||||
|
lineHeight = 32.sp
|
||||||
|
),
|
||||||
|
|
||||||
|
titleLarge = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 22.sp,
|
||||||
|
lineHeight = 28.sp
|
||||||
|
),
|
||||||
|
titleMedium = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
lineHeight = 24.sp
|
||||||
|
),
|
||||||
|
titleSmall = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
lineHeight = 20.sp
|
||||||
|
),
|
||||||
|
|
||||||
|
bodyLarge = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
lineHeight = 24.sp
|
||||||
|
),
|
||||||
|
bodyMedium = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
lineHeight = 20.sp
|
||||||
|
),
|
||||||
|
bodySmall = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
lineHeight = 16.sp
|
||||||
|
),
|
||||||
|
|
||||||
|
labelLarge = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
lineHeight = 20.sp
|
||||||
|
),
|
||||||
|
labelMedium = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
lineHeight = 16.sp
|
||||||
|
),
|
||||||
|
|
||||||
|
)
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
package com.acclorite.books_history.ui.theme
|
|
||||||
|
|
||||||
import androidx.compose.material3.Typography
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
|
|
||||||
// Set of Material typography styles to start with
|
|
||||||
val Typography = Typography(
|
|
||||||
bodyLarge = TextStyle(
|
|
||||||
fontFamily = FontFamily.Default,
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
fontSize = 16.sp,
|
|
||||||
lineHeight = 24.sp,
|
|
||||||
letterSpacing = 0.5.sp
|
|
||||||
)
|
|
||||||
/* Other default text styles to override
|
|
||||||
titleLarge = TextStyle(
|
|
||||||
fontFamily = FontFamily.Default,
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
fontSize = 22.sp,
|
|
||||||
lineHeight = 28.sp,
|
|
||||||
letterSpacing = 0.sp
|
|
||||||
),
|
|
||||||
labelSmall = TextStyle(
|
|
||||||
fontFamily = FontFamily.Default,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
fontSize = 11.sp,
|
|
||||||
lineHeight = 16.sp,
|
|
||||||
letterSpacing = 0.5.sp
|
|
||||||
)
|
|
||||||
*/
|
|
||||||
)
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
package com.acclorite.books_history.util
|
||||||
|
|
||||||
|
fun String.removeTrailingZero(): String {
|
||||||
|
if (!this.contains('.'))
|
||||||
|
return this
|
||||||
|
return this
|
||||||
|
.dropLastWhile { it == '0' }
|
||||||
|
.dropLastWhile { it == '.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Double.removeDigits(digits: Int) = "%.${digits}f".format(this).replace(",", ".")
|
||||||
|
|
@ -22,6 +22,13 @@ object Constants {
|
||||||
const val PARAGRAPH_HEIGHT = "paragraph_height"
|
const val PARAGRAPH_HEIGHT = "paragraph_height"
|
||||||
const val PARAGRAPH_INDENTATION = "paragraph_indentation"
|
const val PARAGRAPH_INDENTATION = "paragraph_indentation"
|
||||||
|
|
||||||
|
val EXTENSIONS = listOf(".txt", ".pdf", ".epub")
|
||||||
|
|
||||||
|
val LANGUAGES = listOf(
|
||||||
|
Pair("en", "English"),
|
||||||
|
Pair("uk", "Українська")
|
||||||
|
)
|
||||||
|
|
||||||
val FONTS = listOf(
|
val FONTS = listOf(
|
||||||
FontWithName(
|
FontWithName(
|
||||||
"Raleway",
|
"Raleway",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
package com.acclorite.books_history.util
|
package com.acclorite.books_history.util
|
||||||
|
|
||||||
sealed class Resource <T>(val data: T? = null, val message: String? = null) {
|
sealed class Resource<T>(val data: T? = null, val message: UIText? = null) {
|
||||||
class Success<T>(data: T?): Resource<T>(data)
|
class Success<T>(data: T?) : Resource<T>(data)
|
||||||
class Error<T>(message: String, data: T? = null): Resource<T>(data, message)
|
class Error<T>(message: UIText, data: T? = null) : Resource<T>(data, message)
|
||||||
class Loading<T>(val isLoading: Boolean = true): Resource<T>()
|
class Loading<T>(val isLoading: Boolean = true) : Resource<T>()
|
||||||
}
|
}
|
||||||
|
|
@ -5,20 +5,21 @@ import androidx.annotation.StringRes
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
|
||||||
|
|
||||||
sealed class UIText {
|
sealed class UIText {
|
||||||
data class StringValue(val value: String): UIText()
|
data class StringValue(val value: String) : UIText()
|
||||||
class StringResource(@StringRes val resId: Int, vararg val args: Any): UIText()
|
class StringResource(@StringRes val resId: Int, vararg val args: Any) : UIText()
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun asString(): String {
|
fun asString(): String {
|
||||||
return when(this) {
|
return when (this) {
|
||||||
is StringValue -> value
|
is StringValue -> value
|
||||||
is StringResource -> stringResource(resId, *args)
|
is StringResource -> stringResource(resId, *args)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun asString(context: Context): String {
|
fun asString(context: Context): String {
|
||||||
return when(this) {
|
return when (this) {
|
||||||
is StringValue -> value
|
is StringValue -> value
|
||||||
is StringResource -> context.getString(resId, *args)
|
is StringResource -> context.getString(resId, *args)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
BIN
app/src/main/res/drawable/browse_screen_filled.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
app/src/main/res/drawable/browse_screen_outlined.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
app/src/main/res/drawable/empty_browse.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
app/src/main/res/drawable/empty_history.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
app/src/main/res/drawable/empty_library.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
app/src/main/res/drawable/epub.png
Normal file
|
After Width: | Height: | Size: 9.8 KiB |
BIN
app/src/main/res/drawable/error.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
app/src/main/res/drawable/file.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
app/src/main/res/drawable/history_screen_filled.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
app/src/main/res/drawable/history_screen_outlined.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
app/src/main/res/drawable/library_screen_filled.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
app/src/main/res/drawable/library_screen_outlined.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
app/src/main/res/drawable/pdf.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
BIN
app/src/main/res/drawable/txt.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |