0.5.0 - Browse + Library Screen + Settings

This commit is contained in:
Acclorite 2024-02-15 14:15:19 +02:00
parent 17fa0973b8
commit 090d5fc003
117 changed files with 5648 additions and 210 deletions

View file

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

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

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

@ -1 +1,2 @@
/build /build
gradle.properties

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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
)
}
return BookEntity(
id = book.id,
title = book.title,
filePath = book.file.path,
progress = book.progress,
lastOpened = book.lastOpened,
image = stream.toByteArray(),
category = book.category
)
} }
override fun toBook(bookEntity: BookEntity): Book { override suspend fun toBook(bookEntity: BookEntity): Book {
TODO("Not yet implemented") 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
)
} }
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,5 @@
package com.acclorite.books_history.presentation.screens.settings.nested.appearance.data
sealed class AppearanceSettingsEvent() {
data object OnShowHideDarkThemeDialog : AppearanceSettingsEvent()
}

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,5 @@
package com.acclorite.books_history.presentation.screens.settings.nested.general.data
sealed class GeneralSettingsEvent() {
data object OnShowHideLanguageDialog : GeneralSettingsEvent()
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,7 @@
package com.acclorite.books_history.ui
import androidx.compose.ui.unit.dp
object ElevationDefaults {
val DialogElevation = 6.dp
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

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