0.9.0 - New loading system(much faster) + New between screen transitions + New snackbar(vertical animation + visuals) + New selection toolbar(translate + dictionary options) in ReaderScreen + New top app bar in SettingsScreen and nested categories + New updating system in BookInfo(updating text, author and description of the book) + Tablet UI support(on wide screens NavigationBar -> NavigationRail) + New app icon + New error handling system(it won't let you add/update book if it's not valid) + New theme(+new names) + Small bugs fixed.. + General performance optimisation(still in progress).

This commit is contained in:
acclorite 2024-03-12 17:06:08 +02:00
parent 5a966e9d9d
commit 6806ac904c
136 changed files with 2580 additions and 911 deletions

View file

@ -2,10 +2,7 @@ plugins {
id("com.android.application") id("com.android.application")
id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.android")
id("com.google.devtools.ksp") id("com.google.devtools.ksp")
id("com.google.dagger.hilt.android") id("com.google.dagger.hilt.android")
kotlin("kapt")
id("kotlin-parcelize") id("kotlin-parcelize")
} }
@ -18,7 +15,7 @@ android {
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 1 versionCode = 1
versionName = "0.8.0" versionName = "0.9.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {
@ -62,35 +59,35 @@ android {
} }
dependencies { dependencies {
val composeVersion = "1.5.7"
// Default // Default
implementation("androidx.core:core-ktx:1.12.0") implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
implementation("androidx.activity:activity-compose:1.8.2") implementation("androidx.activity:activity-compose:1.8.2")
// Compose BOM libraries
implementation(platform("androidx.compose:compose-bom:2024.01.00")) implementation(platform("androidx.compose:compose-bom:2024.01.00"))
implementation("androidx.compose.ui:ui:$composeVersion") implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics:$composeVersion") implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview:$composeVersion") implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.ui:ui-android:1.6.0") implementation("androidx.compose.ui:ui-android")
implementation("androidx.compose.material3:material3:1.1.2") implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material:1.6.0") implementation("androidx.compose.material3:material3-window-size-class")
implementation("androidx.compose.material:material")
// 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.4.2")
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:1.5.4") debugImplementation("androidx.compose.ui:ui-test-manifest:1.6.3")
// 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")
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")
@ -102,17 +99,16 @@ dependencies {
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.7.0")
implementation("androidx.compose.material:material-icons-extended:1.5.4") implementation("androidx.compose.material:material-icons-extended")
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 - Hilt // Dagger - Hilt
implementation("com.google.dagger:hilt-android:2.50") implementation("com.google.dagger:hilt-android:2.51")
kapt("com.google.dagger:hilt-android-compiler:2.50") ksp("com.google.dagger:hilt-android-compiler:2.51")
implementation("com.google.dagger:hilt-compiler:latest.release") implementation("com.google.dagger:hilt-compiler:latest.release")
kapt("androidx.hilt:hilt-compiler:latest.release") ksp("androidx.hilt:hilt-compiler:latest.release")
implementation("androidx.hilt:hilt-navigation-compose:1.1.0") implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
// Room // Room
implementation("androidx.room:room-runtime:2.6.1") implementation("androidx.room:room-runtime:2.6.1")

View file

@ -7,6 +7,21 @@
android:name="android.permission.MANAGE_EXTERNAL_STORAGE" android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" /> tools:ignore="ScopedStorage" />
<queries>
<!-- Translator -->
<intent>
<action android:name="android.intent.action.PROCESS_TEXT" />
<data android:mimeType="text/plain" />
</intent>
<!-- Browser -->
<intent>
<action android:name="android.intent.action.WEB_SEARCH" />
</intent>
</queries>
<application <application
android:name=".util.BookApplication" android:name=".util.BookApplication"
android:allowBackup="true" android:allowBackup="true"
@ -19,7 +34,7 @@
tools:targetApi="tiramisu"> tools:targetApi="tiramisu">
<activity <activity
android:name=".MainActivity" android:name=".Activity"
android:configChanges="screenSize|screenLayout|smallestScreenSize|locale|density|uiMode" android:configChanges="screenSize|screenLayout|smallestScreenSize|locale|density|uiMode"
android:windowSoftInputMode="adjustResize" android:windowSoftInputMode="adjustResize"
android:exported="true" android:exported="true"
@ -30,15 +45,6 @@
</intent-filter> </intent-filter>
</activity> </activity>
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
android:enabled="false"
android:exported="false">
<meta-data
android:name="autoStoreLocales"
android:value="true" />
</service>
</application> </application>
</manifest> </manifest>

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

View file

@ -1,67 +1,104 @@
package ua.acclorite.book_story package ua.acclorite.book_story
import android.annotation.SuppressLint
import android.content.res.Configuration import android.content.res.Configuration
import android.database.CursorWindow
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.slideInHorizontally
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
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import ua.acclorite.book_story.domain.model.Book import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.presentation.NavigationHost import ua.acclorite.book_story.presentation.components.bottom_navigation_bar.BottomNavigationBar
import ua.acclorite.book_story.presentation.Screen import ua.acclorite.book_story.presentation.components.custom_navigation_rail.CustomNavigationRail
import ua.acclorite.book_story.presentation.components.BottomNavigationBar
import ua.acclorite.book_story.presentation.data.MainEvent import ua.acclorite.book_story.presentation.data.MainEvent
import ua.acclorite.book_story.presentation.data.MainViewModel import ua.acclorite.book_story.presentation.data.MainViewModel
import ua.acclorite.book_story.presentation.data.NavigationHost
import ua.acclorite.book_story.presentation.data.Screen
import ua.acclorite.book_story.presentation.screens.book_info.BookInfoScreen import ua.acclorite.book_story.presentation.screens.book_info.BookInfoScreen
import ua.acclorite.book_story.presentation.screens.browse.BrowseScreen import ua.acclorite.book_story.presentation.screens.browse.BrowseScreen
import ua.acclorite.book_story.presentation.screens.browse.data.BrowseViewModel
import ua.acclorite.book_story.presentation.screens.history.HistoryScreen import ua.acclorite.book_story.presentation.screens.history.HistoryScreen
import ua.acclorite.book_story.presentation.screens.history.data.HistoryViewModel
import ua.acclorite.book_story.presentation.screens.library.LibraryScreen import ua.acclorite.book_story.presentation.screens.library.LibraryScreen
import ua.acclorite.book_story.presentation.screens.library.data.LibraryViewModel
import ua.acclorite.book_story.presentation.screens.reader.ReaderScreen import ua.acclorite.book_story.presentation.screens.reader.ReaderScreen
import ua.acclorite.book_story.presentation.screens.settings.SettingsScreen import ua.acclorite.book_story.presentation.screens.settings.SettingsScreen
import ua.acclorite.book_story.presentation.screens.settings.nested.appearance.AppearanceSettings import ua.acclorite.book_story.presentation.screens.settings.nested.appearance.AppearanceSettings
import ua.acclorite.book_story.presentation.screens.settings.nested.general.GeneralSettings import ua.acclorite.book_story.presentation.screens.settings.nested.general.GeneralSettings
import ua.acclorite.book_story.presentation.screens.settings.nested.reader.ReaderSettings
import ua.acclorite.book_story.ui.BooksHistoryResurrectionTheme import ua.acclorite.book_story.ui.BooksHistoryResurrectionTheme
import ua.acclorite.book_story.ui.DarkTheme
import ua.acclorite.book_story.ui.Theme
import ua.acclorite.book_story.ui.Transitions import ua.acclorite.book_story.ui.Transitions
import ua.acclorite.book_story.ui.isDark import ua.acclorite.book_story.ui.isDark
import java.lang.reflect.Field
@SuppressLint("DiscouragedPrivateApi")
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : AppCompatActivity() { class Activity : ComponentActivity() {
private val mainViewModel: MainViewModel by viewModels() private val mainViewModel: MainViewModel by viewModels()
private val libraryViewModel: LibraryViewModel by viewModels()
private val historyViewModel: HistoryViewModel by viewModels()
private val browseViewModel: BrowseViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
mainViewModel.init(this) // New Cursor size for Room
try {
val field: Field = CursorWindow::class.java.getDeclaredField("sCursorWindowSize")
field.isAccessible = true
field.set(null, 100 * 1024 * 1024)
} catch (e: Exception) {
e.printStackTrace()
}
// Initializing all variables
mainViewModel.init(
activity = this,
libraryViewModel,
)
// Splash screen
installSplashScreen().apply { installSplashScreen().apply {
setKeepOnScreenCondition { setKeepOnScreenCondition {
!mainViewModel.isReady.value !mainViewModel.isReady.value
} }
} }
// Edge to edge
enableEdgeToEdge() enableEdgeToEdge()
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
setContent { setContent {
val windowClass = calculateWindowSizeClass(activity = this)
val updating = mainViewModel.updating.collectAsState().value val updating = mainViewModel.updating.collectAsState().value
val theme = mainViewModel.theme.collectAsState().value!!
val darkTheme = mainViewModel.darkTheme.collectAsState().value!! val theme = mainViewModel.theme.collectAsState().value ?: Theme.BLUE
val darkTheme =
mainViewModel.darkTheme.collectAsState().value ?: DarkTheme.FOLLOW_SYSTEM
val tabletUI = windowClass.widthSizeClass != WindowWidthSizeClass.Compact
BooksHistoryResurrectionTheme( BooksHistoryResurrectionTheme(
theme = theme, theme = theme,
@ -70,28 +107,36 @@ class MainActivity : AppCompatActivity() {
if (!updating) { if (!updating) {
NavigationHost(startScreen = Screen.LIBRARY) { NavigationHost(startScreen = Screen.LIBRARY) {
val currentScreen by this.getCurrentScreen().collectAsState() val currentScreen by this.getCurrentScreen().collectAsState()
AnimatedVisibility( AnimatedVisibility(
visible = currentScreen == Screen.LIBRARY || visible = currentScreen == Screen.LIBRARY ||
currentScreen == Screen.HISTORY || currentScreen == Screen.HISTORY ||
currentScreen == Screen.BROWSE, currentScreen == Screen.BROWSE,
enter = fadeIn(tween(200)) + enter = Transitions.BackSlidingTransitionIn,
slideInHorizontally(tween(200)) { it / 15 },
exit = Transitions.SlidingTransitionOut exit = Transitions.SlidingTransitionOut
) { ) {
Scaffold( Scaffold(
bottomBar = { bottomBar = {
BottomNavigationBar(navigator = this@NavigationHost) if (!tabletUI) {
BottomNavigationBar(navigator = this@NavigationHost)
}
}, },
containerColor = MaterialTheme.colorScheme.surface containerColor = MaterialTheme.colorScheme.surface
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(bottom = it.calculateBottomPadding()) .padding(
start = if (tabletUI) 80.dp else 0.dp,
bottom = it.calculateBottomPadding()
)
) { ) {
composable(screen = Screen.LIBRARY) { composable(screen = Screen.LIBRARY) {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
LibraryScreen( LibraryScreen(
viewModel = libraryViewModel,
historyViewModel = historyViewModel,
browseViewModel = browseViewModel,
navigator = this@NavigationHost, navigator = this@NavigationHost,
addedBooks = retrieveArgument("added_books") as? List<Book> addedBooks = retrieveArgument("added_books") as? List<Book>
?: emptyList() ?: emptyList()
@ -99,13 +144,25 @@ class MainActivity : AppCompatActivity() {
} }
composable(screen = Screen.HISTORY) { composable(screen = Screen.HISTORY) {
HistoryScreen(navigator = this@NavigationHost) HistoryScreen(
viewModel = historyViewModel,
libraryViewModel = libraryViewModel,
navigator = this@NavigationHost
)
} }
composable(screen = Screen.BROWSE) { composable(screen = Screen.BROWSE) {
BrowseScreen(navigator = this@NavigationHost) BrowseScreen(
viewModel = browseViewModel,
libraryViewModel = libraryViewModel,
navigator = this@NavigationHost
)
} }
} }
if (tabletUI) {
CustomNavigationRail(navigator = this@NavigationHost)
}
} }
} }
@ -116,6 +173,9 @@ class MainActivity : AppCompatActivity() {
exitAnim = Transitions.SlidingTransitionOut exitAnim = Transitions.SlidingTransitionOut
) { ) {
BookInfoScreen( BookInfoScreen(
libraryViewModel = libraryViewModel,
browseViewModel = browseViewModel,
historyViewModel = historyViewModel,
navigator = this@NavigationHost navigator = this@NavigationHost
) )
} }
@ -125,6 +185,9 @@ class MainActivity : AppCompatActivity() {
exitAnim = Transitions.SlidingTransitionOut exitAnim = Transitions.SlidingTransitionOut
) { ) {
ReaderScreen( ReaderScreen(
mainViewModel = mainViewModel,
libraryViewModel = libraryViewModel,
historyViewModel = historyViewModel,
navigator = this@NavigationHost navigator = this@NavigationHost
) )
} }
@ -147,6 +210,7 @@ class MainActivity : AppCompatActivity() {
exitAnim = Transitions.SlidingTransitionOut exitAnim = Transitions.SlidingTransitionOut
) { ) {
GeneralSettings( GeneralSettings(
mainViewModel = mainViewModel,
navigator = this@NavigationHost navigator = this@NavigationHost
) )
} }
@ -156,9 +220,25 @@ class MainActivity : AppCompatActivity() {
exitAnim = Transitions.SlidingTransitionOut exitAnim = Transitions.SlidingTransitionOut
) { ) {
AppearanceSettings( AppearanceSettings(
mainViewModel = mainViewModel,
navigator = this@NavigationHost navigator = this@NavigationHost
) )
} }
composable(
screen = Screen.READER_SETTINGS,
enterAnim = Transitions.SlidingTransitionIn,
exitAnim = Transitions.SlidingTransitionOut
) {
ReaderSettings(
mainViewModel = mainViewModel,
navigator = this@NavigationHost
)
}
// Start screen (later)
// composable(screen = Screen.START) {
// StartScreen()
// }
} }
} else { } else {
Box( Box(

View file

@ -6,10 +6,10 @@ import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import ua.acclorite.book_story.data.local.data_store.DataStore import ua.acclorite.book_story.data.local.data_store.DataStore
import ua.acclorite.book_story.data.local.data_store.DataStoreImpl import ua.acclorite.book_story.data.local.data_store.DataStoreImpl
import ua.acclorite.book_story.data.mapper.BookMapper import ua.acclorite.book_story.data.mapper.book.BookMapper
import ua.acclorite.book_story.data.mapper.BookMapperImpl import ua.acclorite.book_story.data.mapper.book.BookMapperImpl
import ua.acclorite.book_story.data.mapper.HistoryMapper import ua.acclorite.book_story.data.mapper.history.HistoryMapper
import ua.acclorite.book_story.data.mapper.HistoryMapperImpl import ua.acclorite.book_story.data.mapper.history.HistoryMapperImpl
import ua.acclorite.book_story.data.repository.BookRepositoryImpl import ua.acclorite.book_story.data.repository.BookRepositoryImpl
import ua.acclorite.book_story.domain.repository.BookRepository import ua.acclorite.book_story.domain.repository.BookRepository
import javax.inject.Singleton import javax.inject.Singleton

View file

@ -8,9 +8,11 @@ import ua.acclorite.book_story.domain.model.Category
data class BookEntity( data class BookEntity(
@PrimaryKey(true) val id: Int? = null, @PrimaryKey(true) val id: Int? = null,
val title: String, val title: String,
val author: String?,
val description: String?,
val text: String,
val filePath: String, val filePath: String,
val progress: Float, val progress: Float,
var lastOpened: Long? = null,
val image: ByteArray = byteArrayOf(), val image: ByteArray = byteArrayOf(),
val category: Category val category: Category
) { ) {

View file

@ -19,8 +19,7 @@ interface BookDao {
@Query( @Query(
""" """
SELECT * SELECT * FROM bookentity
FROM bookentity
WHERE LOWER(title) LIKE '%' || LOWER(:query) || '%' WHERE LOWER(title) LIKE '%' || LOWER(:query) || '%'
""" """
) )
@ -31,6 +30,9 @@ interface BookDao {
) )
suspend fun findBookById(id: Int): BookEntity suspend fun findBookById(id: Int): BookEntity
@Query("SELECT * FROM bookentity WHERE id IN (:ids)")
suspend fun findBooksById(ids: List<Int>): List<BookEntity>
@Delete @Delete
suspend fun deleteBooks(books: List<BookEntity>) suspend fun deleteBooks(books: List<BookEntity>)

View file

@ -1,81 +0,0 @@
package ua.acclorite.book_story.data.mapper
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.local.dto.BookEntity
import ua.acclorite.book_story.data.parser.EpubFileParser
import ua.acclorite.book_story.data.parser.PdfFileParser
import ua.acclorite.book_story.data.parser.TxtFileParser
import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.util.UIText
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.File
import javax.inject.Inject
class BookMapperImpl @Inject constructor(
private val txtFileParser: TxtFileParser,
private val pdfFileParser: PdfFileParser,
private val epubFileParser: EpubFileParser
) : BookMapper {
override suspend fun toBookEntity(book: Book): BookEntity {
val stream = ByteArrayOutputStream()
book.coverImage?.compress(Bitmap.CompressFormat.JPEG, 30, stream)
return BookEntity(
id = book.id,
title = book.title,
filePath = book.filePath,
progress = book.progress,
lastOpened = book.lastOpened,
image = stream.toByteArray(),
category = book.category
)
}
override suspend fun toBook(bookEntity: BookEntity): Book {
val file = File(bookEntity.filePath)
val inputStream = ByteArrayInputStream(bookEntity.image)
val image = BitmapFactory.decodeStream(inputStream)
val parsedBook = if (file.name.endsWith(".txt")) {
txtFileParser.parse(file)
} else if (file.name.endsWith(".pdf")) {
pdfFileParser.parse(file)
} else if (file.name.endsWith(".epub")) {
epubFileParser.parse(file)
} else {
null
}
return if (parsedBook != null)
Book(
id = bookEntity.id,
title = bookEntity.title,
author = parsedBook.author,
description = parsedBook.description,
text = emptyList(),
progress = bookEntity.progress,
file = file,
filePath = bookEntity.filePath,
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,
filePath = bookEntity.filePath,
lastOpened = bookEntity.lastOpened,
category = bookEntity.category,
coverImage = if (bookEntity.image.isNotEmpty()) image else null
)
}
}

View file

@ -1,4 +1,4 @@
package ua.acclorite.book_story.data.mapper package ua.acclorite.book_story.data.mapper.book
import ua.acclorite.book_story.data.local.dto.BookEntity import ua.acclorite.book_story.data.local.dto.BookEntity
import ua.acclorite.book_story.domain.model.Book import ua.acclorite.book_story.domain.model.Book

View file

@ -0,0 +1,74 @@
package ua.acclorite.book_story.data.mapper.book
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Build
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.compose.ui.graphics.asImageBitmap
import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.local.dto.BookEntity
import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.model.StringWithId
import ua.acclorite.book_story.util.UIText
import java.io.ByteArrayOutputStream
import java.io.File
import javax.inject.Inject
class BookMapperImpl @Inject constructor() : BookMapper {
override suspend fun toBookEntity(book: Book): BookEntity {
val legacyAPI = Build.VERSION.SDK_INT < Build.VERSION_CODES.R
val stream = ByteArrayOutputStream()
book.coverImage?.asAndroidBitmap()?.compress(
if (legacyAPI) Bitmap.CompressFormat.WEBP
else Bitmap.CompressFormat.WEBP_LOSSLESS,
if (legacyAPI) 30 else 100,
stream
)
val text = book.text.joinToString("\n") {
it.line.trim()
}
return BookEntity(
id = book.id,
title = book.title,
filePath = book.filePath,
progress = book.progress,
author = book.author.string,
text = text,
description = book.description,
image = stream.toByteArray(),
category = book.category
)
}
override suspend fun toBook(bookEntity: BookEntity): Book {
val file = File(bookEntity.filePath)
var image: ImageBitmap? = null
if (bookEntity.image.isNotEmpty()) {
image = BitmapFactory
.decodeByteArray(bookEntity.image, 0, bookEntity.image.size)
.asImageBitmap()
image.prepareToDraw()
}
return Book(
id = bookEntity.id,
title = bookEntity.title,
author = bookEntity.author?.let { UIText.StringValue(it) } ?: UIText.StringResource(
R.string.unknown_author
),
description = bookEntity.description,
progress = bookEntity.progress,
file = if (file.exists()) file else null,
text = bookEntity.text.split("\n").map { StringWithId(it) },
filePath = bookEntity.filePath,
lastOpened = null,
category = bookEntity.category,
coverImage = image
)
}
}

View file

@ -1,11 +1,10 @@
package ua.acclorite.book_story.data.mapper package ua.acclorite.book_story.data.mapper.history
import ua.acclorite.book_story.data.local.dto.HistoryEntity import ua.acclorite.book_story.data.local.dto.HistoryEntity
import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.model.History import ua.acclorite.book_story.domain.model.History
interface HistoryMapper { interface HistoryMapper {
suspend fun toHistoryEntity(history: History): HistoryEntity suspend fun toHistoryEntity(history: History): HistoryEntity
suspend fun toHistory(historyEntity: HistoryEntity, book: Book): History suspend fun toHistory(historyEntity: HistoryEntity): History
} }

View file

@ -1,7 +1,6 @@
package ua.acclorite.book_story.data.mapper package ua.acclorite.book_story.data.mapper.history
import ua.acclorite.book_story.data.local.dto.HistoryEntity import ua.acclorite.book_story.data.local.dto.HistoryEntity
import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.model.History import ua.acclorite.book_story.domain.model.History
import javax.inject.Inject import javax.inject.Inject
@ -9,15 +8,15 @@ class HistoryMapperImpl @Inject constructor() : HistoryMapper {
override suspend fun toHistoryEntity(history: History): HistoryEntity { override suspend fun toHistoryEntity(history: History): HistoryEntity {
return HistoryEntity( return HistoryEntity(
id = history.id, id = history.id,
bookId = history.book.id ?: 0, bookId = history.bookId,
time = history.time time = history.time
) )
} }
override suspend fun toHistory(historyEntity: HistoryEntity, book: Book): History { override suspend fun toHistory(historyEntity: HistoryEntity): History {
return History( return History(
historyEntity.id, historyEntity.id,
book = book, bookId = historyEntity.bookId,
time = historyEntity.time time = historyEntity.time
) )
} }

View file

@ -1,8 +1,9 @@
package ua.acclorite.book_story.data.parser package ua.acclorite.book_story.data.parser
import ua.acclorite.book_story.domain.model.StringWithId import ua.acclorite.book_story.domain.model.StringWithId
import ua.acclorite.book_story.util.Resource
import java.io.File import java.io.File
interface TextParser { interface TextParser {
suspend fun parse(file: File): List<StringWithId> suspend fun parse(file: File): Resource<List<StringWithId>>
} }

View file

@ -1,34 +0,0 @@
package ua.acclorite.book_story.data.parser
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import ua.acclorite.book_story.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>()
withContext(Dispatchers.IO) {
BufferedReader(FileReader(file)).forEachLine { line ->
formattedLines.add(
StringWithId(line)
)
}
}
formattedLines
} catch (e: Exception) {
emptyList()
}
}
}

View file

@ -1,9 +1,11 @@
package ua.acclorite.book_story.data.parser package ua.acclorite.book_story.data.parser.epub
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import androidx.compose.ui.graphics.asImageBitmap
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import nl.siegmann.epublib.epub.EpubReader import nl.siegmann.epublib.epub.EpubReader
import ua.acclorite.book_story.data.parser.FileParser
import ua.acclorite.book_story.domain.model.Book import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.model.Category import ua.acclorite.book_story.domain.model.Category
import ua.acclorite.book_story.util.UIText import ua.acclorite.book_story.util.UIText
@ -51,7 +53,7 @@ class EpubFileParser @Inject constructor() : FileParser {
filePath = file.path, filePath = file.path,
lastOpened = null, lastOpened = null,
category = Category.entries[0], category = Category.entries[0],
coverImage = coverImage coverImage = coverImage?.asImageBitmap()
) )
} catch (e: Exception) { } catch (e: Exception) {
return null return null

View file

@ -1,10 +1,14 @@
package ua.acclorite.book_story.data.parser package ua.acclorite.book_story.data.parser.epub
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import nl.siegmann.epublib.epub.EpubReader import nl.siegmann.epublib.epub.EpubReader
import org.jsoup.Jsoup import org.jsoup.Jsoup
import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.parser.TextParser
import ua.acclorite.book_story.domain.model.StringWithId import ua.acclorite.book_story.domain.model.StringWithId
import ua.acclorite.book_story.util.Resource
import ua.acclorite.book_story.util.UIText
import java.io.BufferedReader import java.io.BufferedReader
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
@ -14,9 +18,9 @@ import javax.inject.Inject
class EpubTextParser @Inject constructor() : TextParser { class EpubTextParser @Inject constructor() : TextParser {
override suspend fun parse(file: File): List<StringWithId> { override suspend fun parse(file: File): Resource<List<StringWithId>> {
if (!file.name.endsWith(".epub")) { if (!file.name.endsWith(".epub")) {
return emptyList() return Resource.Error(UIText.StringResource(R.string.error_wrong_file_format))
} }
try { try {
@ -47,9 +51,18 @@ class EpubTextParser @Inject constructor() : TextParser {
} }
} }
return stringWithIds if (stringWithIds.isEmpty()) {
return Resource.Error(UIText.StringResource(R.string.error_file_empty))
}
return Resource.Success(stringWithIds)
} catch (e: Exception) { } catch (e: Exception) {
return emptyList() return Resource.Error(
UIText.StringResource(
R.string.error_query,
e.message?.take(40)?.trim() ?: ""
)
)
} }
} }
} }

View file

@ -1,9 +1,10 @@
package ua.acclorite.book_story.data.parser package ua.acclorite.book_story.data.parser.pdf
import android.app.Application import android.app.Application
import com.tom_roush.pdfbox.android.PDFBoxResourceLoader import com.tom_roush.pdfbox.android.PDFBoxResourceLoader
import com.tom_roush.pdfbox.pdmodel.PDDocument import com.tom_roush.pdfbox.pdmodel.PDDocument
import ua.acclorite.book_story.R import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.parser.FileParser
import ua.acclorite.book_story.domain.model.Book import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.model.Category import ua.acclorite.book_story.domain.model.Category
import ua.acclorite.book_story.domain.model.StringWithId import ua.acclorite.book_story.domain.model.StringWithId

View file

@ -1,18 +1,22 @@
package ua.acclorite.book_story.data.parser package ua.acclorite.book_story.data.parser.pdf
import android.app.Application import android.app.Application
import com.tom_roush.pdfbox.android.PDFBoxResourceLoader import com.tom_roush.pdfbox.android.PDFBoxResourceLoader
import com.tom_roush.pdfbox.pdmodel.PDDocument import com.tom_roush.pdfbox.pdmodel.PDDocument
import com.tom_roush.pdfbox.text.PDFTextStripper import com.tom_roush.pdfbox.text.PDFTextStripper
import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.parser.TextParser
import ua.acclorite.book_story.domain.model.StringWithId import ua.acclorite.book_story.domain.model.StringWithId
import ua.acclorite.book_story.util.Resource
import ua.acclorite.book_story.util.UIText
import java.io.File import java.io.File
import javax.inject.Inject import javax.inject.Inject
class PdfTextParser @Inject constructor(private val application: Application) : TextParser { class PdfTextParser @Inject constructor(private val application: Application) : TextParser {
override suspend fun parse(file: File): List<StringWithId> { override suspend fun parse(file: File): Resource<List<StringWithId>> {
if (!file.name.endsWith(".pdf")) { if (!file.name.endsWith(".pdf")) {
return emptyList() return Resource.Error(UIText.StringResource(R.string.error_wrong_file_format))
} }
try { try {
@ -24,6 +28,11 @@ class PdfTextParser @Inject constructor(private val application: Application) :
val pdfStripper = PDFTextStripper() val pdfStripper = PDFTextStripper()
pdfStripper.paragraphStart = "_new_line_" pdfStripper.paragraphStart = "_new_line_"
if (document.isEncrypted) {
document.close()
return Resource.Error(UIText.StringResource(R.string.error_file_encrypted))
}
val oldText = pdfStripper.getText(document) val oldText = pdfStripper.getText(document)
.replace("\r", "") .replace("\r", "")
@ -38,7 +47,7 @@ class PdfTextParser @Inject constructor(private val application: Application) :
} }
val unformattedLines = text.split("${pdfStripper.paragraphStart}|\\n".toRegex()) val unformattedLines = text.split("${pdfStripper.paragraphStart}|\\n".toRegex())
.filter { it.isNotEmpty() } .filter { it.isNotBlank() }
val lines = mutableListOf<String>() val lines = mutableListOf<String>()
unformattedLines.forEachIndexed { index, string -> unformattedLines.forEachIndexed { index, string ->
@ -52,10 +61,18 @@ class PdfTextParser @Inject constructor(private val application: Application) :
stringWithIds.add(StringWithId(line.trim())) stringWithIds.add(StringWithId(line.trim()))
} }
return stringWithIds if (stringWithIds.isEmpty()) {
return Resource.Error(UIText.StringResource(R.string.error_file_empty))
}
return Resource.Success(stringWithIds)
} catch (e: Exception) { } catch (e: Exception) {
return emptyList() return Resource.Error(
UIText.StringResource(
R.string.error_query,
e.message?.take(40)?.trim() ?: ""
)
)
} }
} }
} }

View file

@ -1,6 +1,7 @@
package ua.acclorite.book_story.data.parser package ua.acclorite.book_story.data.parser.txt
import ua.acclorite.book_story.R import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.parser.FileParser
import ua.acclorite.book_story.domain.model.Book import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.model.Category import ua.acclorite.book_story.domain.model.Category
import ua.acclorite.book_story.domain.model.StringWithId import ua.acclorite.book_story.domain.model.StringWithId

View file

@ -0,0 +1,49 @@
package ua.acclorite.book_story.data.parser.txt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.parser.TextParser
import ua.acclorite.book_story.domain.model.StringWithId
import ua.acclorite.book_story.util.Resource
import ua.acclorite.book_story.util.UIText
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): Resource<List<StringWithId>> {
if (!file.name.endsWith(".txt")) {
return Resource.Error(UIText.StringResource(R.string.error_wrong_file_format))
}
return try {
val formattedLines = mutableListOf<StringWithId>()
withContext(Dispatchers.IO) {
BufferedReader(FileReader(file)).forEachLine { line ->
if (line.isNotBlank()) {
formattedLines.add(
StringWithId(line)
)
}
}
}
if (formattedLines.isEmpty()) {
return Resource.Error(UIText.StringResource(R.string.error_file_empty))
}
Resource.Success(formattedLines)
} catch (e: Exception) {
Resource.Error(
UIText.StringResource(
R.string.error_query,
e.message?.take(40)?.trim() ?: ""
)
)
}
}
}

View file

@ -4,24 +4,27 @@ import android.os.Environment
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.local.data_store.DataStore import ua.acclorite.book_story.data.local.data_store.DataStore
import ua.acclorite.book_story.data.local.dto.BookEntity import ua.acclorite.book_story.data.local.dto.BookEntity
import ua.acclorite.book_story.data.local.room.BookDao import ua.acclorite.book_story.data.local.room.BookDao
import ua.acclorite.book_story.data.mapper.BookMapper import ua.acclorite.book_story.data.mapper.book.BookMapper
import ua.acclorite.book_story.data.mapper.HistoryMapper import ua.acclorite.book_story.data.mapper.history.HistoryMapper
import ua.acclorite.book_story.data.parser.EpubFileParser import ua.acclorite.book_story.data.parser.epub.EpubFileParser
import ua.acclorite.book_story.data.parser.EpubTextParser import ua.acclorite.book_story.data.parser.epub.EpubTextParser
import ua.acclorite.book_story.data.parser.PdfFileParser import ua.acclorite.book_story.data.parser.pdf.PdfFileParser
import ua.acclorite.book_story.data.parser.PdfTextParser import ua.acclorite.book_story.data.parser.pdf.PdfTextParser
import ua.acclorite.book_story.data.parser.TxtFileParser import ua.acclorite.book_story.data.parser.txt.TxtFileParser
import ua.acclorite.book_story.data.parser.TxtTextParser import ua.acclorite.book_story.data.parser.txt.TxtTextParser
import ua.acclorite.book_story.domain.model.Book import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.model.History import ua.acclorite.book_story.domain.model.History
import ua.acclorite.book_story.domain.model.NullableBook import ua.acclorite.book_story.domain.model.NullableBook
import ua.acclorite.book_story.domain.model.StringWithId import ua.acclorite.book_story.domain.model.StringWithId
import ua.acclorite.book_story.domain.repository.BookRepository import ua.acclorite.book_story.domain.repository.BookRepository
import ua.acclorite.book_story.presentation.data.calculateFamiliarity
import ua.acclorite.book_story.util.Constants import ua.acclorite.book_story.util.Constants
import ua.acclorite.book_story.util.Resource import ua.acclorite.book_story.util.Resource
import ua.acclorite.book_story.util.UIText
import java.io.File import java.io.File
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -49,9 +52,20 @@ class BookRepositoryImpl @Inject constructor(
emit(Resource.Loading(true)) emit(Resource.Loading(true))
val books = database.searchBooks(query) val books = database.searchBooks(query)
val history = database.getHistory()
emit( emit(
Resource.Success( Resource.Success(
data = books.map { bookMapper.toBook(it) } data = books.map { entity ->
val book = bookMapper.toBook(entity)
val lastHistory = history
.filter { it.bookId == book.id }
.maxByOrNull { it.time }
book.copy(
lastOpened = lastHistory?.time
)
}
) )
) )
} }
@ -59,7 +73,21 @@ class BookRepositoryImpl @Inject constructor(
override suspend fun fastGetBooks(query: String): List<Book> { override suspend fun fastGetBooks(query: String): List<Book> {
val books = database.searchBooks(query) val books = database.searchBooks(query)
return books.map { bookMapper.toBook(it) } val history = database.getHistory()
return books.map { entity ->
val book = bookMapper.toBook(entity)
val lastHistory =
history.filter { it.bookId == book.id }.maxByOrNull { it.time }
book.copy(
lastOpened = lastHistory?.time
)
}
}
override suspend fun getBooksById(ids: List<Int>): List<Book> {
return database.findBooksById(ids).map { bookMapper.toBook(it) }
} }
override suspend fun findBook( override suspend fun findBook(
@ -154,7 +182,10 @@ class BookRepositoryImpl @Inject constructor(
filteredFiles = if (query.trim().isNotEmpty()) { filteredFiles = if (query.trim().isNotEmpty()) {
filteredFiles.sortedByDescending { filteredFiles.sortedByDescending {
it.name.lowercase().startsWith(query.trim().lowercase()) calculateFamiliarity(
query,
it.name.lowercase().trim()
)
}.toMutableList() }.toMutableList()
} else { } else {
filteredFiles.sortedByDescending { filteredFiles.sortedByDescending {
@ -182,61 +213,113 @@ class BookRepositoryImpl @Inject constructor(
} else if (file.name.endsWith(".epub")) { } else if (file.name.endsWith(".epub")) {
epubTextParser.parse(file) epubTextParser.parse(file)
} else { } else {
emit(Resource.Error(UIText.StringResource(R.string.error_wrong_file_format)))
return@flow return@flow
} }
emit(Resource.Success(text)) if (text is Resource.Success && text.data == null) {
} emit(
} Resource.Error(
text.message
override suspend fun getBooksFromFiles(files: List<File>): Flow<Resource<List<NullableBook>>> { ?: UIText.StringResource(R.string.error_file_empty)
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)) when (text) {
is Resource.Success -> emit(Resource.Success(text.data))
is Resource.Error -> emit(
Resource.Error(
text.message
?: UIText.StringResource(R.string.error_something_went_wrong)
)
)
is Resource.Loading -> Unit
}
} }
} }
override suspend fun getBooksFromFiles(files: List<File>): List<NullableBook> {
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,
UIText.StringResource(R.string.error_wrong_file_format)
)
)
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,
UIText.StringResource(R.string.error_wrong_file_format)
)
)
continue
}
if (parsedBook == null) {
books.add(
NullableBook.Null(
file.name,
UIText.StringResource(R.string.error_something_went_wrong)
)
)
continue
}
if (parsedText is Resource.Error) {
books.add(
NullableBook.Null(
file.name,
parsedText.message
)
)
continue
}
if (parsedText.data == null) {
books.add(
NullableBook.Null(
file.name,
UIText.StringResource(R.string.error_file_empty)
)
)
continue
}
books.add(
NullableBook.NotNull(
Pair(
parsedBook.copy(text = parsedText.data),
true
)
)
)
}
return books
}
override suspend fun insertHistory(history: List<History>) { override suspend fun insertHistory(history: List<History>) {
database.insertHistory(history.map { historyMapper.toHistoryEntity(it) }) database.insertHistory(history.map { historyMapper.toHistoryEntity(it) })
} }
@ -246,11 +329,13 @@ class BookRepositoryImpl @Inject constructor(
emit(Resource.Loading(true)) emit(Resource.Loading(true))
val history = database.getHistory() val history = database.getHistory()
emit( emit(
Resource.Success( Resource.Success(
data = history.map { data = history.map {
val book = database.findBookById(it.bookId) historyMapper.toHistory(
historyMapper.toHistory(it, bookMapper.toBook(book)) it
)
} }
) )
) )

View file

@ -1,7 +1,7 @@
package ua.acclorite.book_story.domain.model package ua.acclorite.book_story.domain.model
import android.graphics.Bitmap
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.ImageBitmap
import ua.acclorite.book_story.util.UIText import ua.acclorite.book_story.util.UIText
import java.io.File import java.io.File
@ -17,5 +17,5 @@ data class Book(
val filePath: String, val filePath: String,
val lastOpened: Long?, val lastOpened: Long?,
val category: Category, val category: Category,
val coverImage: Bitmap? val coverImage: ImageBitmap?
) )

View file

@ -1,5 +1,8 @@
package ua.acclorite.book_story.domain.model package ua.acclorite.book_story.domain.model
import androidx.compose.runtime.Immutable
@Immutable
enum class Category { enum class Category {
READING, ALREADY_READ, PLANNING, DROPPED READING, ALREADY_READ, PLANNING, DROPPED
} }

View file

@ -5,6 +5,6 @@ import androidx.compose.runtime.Immutable
@Immutable @Immutable
data class History( data class History(
val id: Int?, val id: Int?,
val book: Book, val bookId: Int,
val time: Long val time: Long
) )

View file

@ -4,7 +4,7 @@ import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
@Immutable @Immutable
data class NavigationBarItem( data class NavigationItem(
val title: String, val title: String,
val selectedIcon: Painter, val selectedIcon: Painter,
val unselectedIcon: Painter val unselectedIcon: Painter

View file

@ -1,6 +1,17 @@
package ua.acclorite.book_story.domain.model package ua.acclorite.book_story.domain.model
sealed class NullableBook(val book: Pair<Book, Boolean>?, val fileName: String?) { import androidx.compose.runtime.Immutable
class NotNull(book: Pair<Book, Boolean>) : NullableBook(book, null) import ua.acclorite.book_story.util.UIText
class Null(fileName: String) : NullableBook(null, fileName)
@Immutable
sealed class NullableBook(
val book: Pair<Book, Boolean>?,
val fileName: String?,
val message: UIText?
) {
class NotNull(book: Pair<Book, Boolean>) : NullableBook(book, null, null)
class Null(
fileName: String,
message: UIText?
) : NullableBook(null, fileName, message)
} }

View file

@ -20,6 +20,10 @@ interface BookRepository {
query: String query: String
): List<Book> ): List<Book>
suspend fun getBooksById(
ids: List<Int>
): List<Book>
suspend fun findBook( suspend fun findBook(
id: Int id: Int
): BookEntity ): BookEntity
@ -50,7 +54,7 @@ interface BookRepository {
suspend fun getBookTextFromFile(file: File): Flow<Resource<List<StringWithId>>> suspend fun getBookTextFromFile(file: File): Flow<Resource<List<StringWithId>>>
suspend fun getBooksFromFiles(files: List<File>): Flow<Resource<List<NullableBook>>> suspend fun getBooksFromFiles(files: List<File>): List<NullableBook>
suspend fun insertHistory(history: List<History>) suspend fun insertHistory(history: List<History>)

View file

@ -0,0 +1,15 @@
package ua.acclorite.book_story.domain.use_case
import ua.acclorite.book_story.domain.model.NullableBook
import ua.acclorite.book_story.domain.repository.BookRepository
import java.io.File
import javax.inject.Inject
class GetBookFromFile @Inject constructor(
private val repository: BookRepository
) {
suspend fun execute(file: File): NullableBook {
return repository.getBooksFromFiles(listOf(file)).first()
}
}

View file

@ -0,0 +1,12 @@
package ua.acclorite.book_story.domain.use_case
import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.repository.BookRepository
import javax.inject.Inject
class GetBooksById @Inject constructor(private val repository: BookRepository) {
suspend fun execute(ids: List<Int>): List<Book> {
return repository.getBooksById(ids)
}
}

View file

@ -1,9 +1,7 @@
package ua.acclorite.book_story.domain.use_case package ua.acclorite.book_story.domain.use_case
import kotlinx.coroutines.flow.Flow
import ua.acclorite.book_story.domain.model.NullableBook import ua.acclorite.book_story.domain.model.NullableBook
import ua.acclorite.book_story.domain.repository.BookRepository import ua.acclorite.book_story.domain.repository.BookRepository
import ua.acclorite.book_story.util.Resource
import java.io.File import java.io.File
import javax.inject.Inject import javax.inject.Inject
@ -11,7 +9,7 @@ class GetBooksFromFiles @Inject constructor(
private val repository: BookRepository private val repository: BookRepository
) { ) {
suspend fun execute(files: List<File>): Flow<Resource<List<NullableBook>>> { suspend fun execute(files: List<File>): List<NullableBook> {
return repository.getBooksFromFiles(files) return repository.getBooksFromFiles(files)
} }
} }

View file

@ -26,6 +26,14 @@ import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.ui.DefaultTransition import ua.acclorite.book_story.ui.DefaultTransition
import ua.acclorite.book_story.ui.elevation import ua.acclorite.book_story.ui.elevation
/**
* Animated top app bar. Has up to 3 different top bars inside it. Follow [TopAppBar] for more info.
*
* @param isTopBarScrolled Whether isScrolled state should be forced or not.
* @param content1Visibility Whether first top app bar should be shown.
* @param content2Visibility Whether second top app bar should be shown.
* @param content3Visibility Whether third top app bar should be shown.
*/
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun AnimatedTopAppBar( fun AnimatedTopAppBar(
@ -50,6 +58,7 @@ fun AnimatedTopAppBar(
content3Title: @Composable () -> Unit = {}, content3Title: @Composable () -> Unit = {},
content3Actions: @Composable RowScope.() -> Unit = {} content3Actions: @Composable RowScope.() -> Unit = {}
) { ) {
//todo fix lags.
Box(modifier = Modifier.fillMaxWidth()) { Box(modifier = Modifier.fillMaxWidth()) {
if ((scrollBehavior != null || isTopBarScrolled != null) && scrolledContainerColor != null) { if ((scrollBehavior != null || isTopBarScrolled != null) && scrolledContainerColor != null) {

View file

@ -14,6 +14,9 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
/**
* Category Title. Used to name a category.
*/
@Composable @Composable
fun CategoryTitle( fun CategoryTitle(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,

View file

@ -16,6 +16,9 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.ui.elevation import ua.acclorite.book_story.ui.elevation
/**
* Custom Checkbox. Has a Circle shape.
*/
@Composable @Composable
fun CustomCheckbox(selected: Boolean) { fun CustomCheckbox(selected: Boolean) {
Icon( Icon(

View file

@ -11,6 +11,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
/**
* Custom Dropdown Menu Item. Used in Dropdown.
*/
@Composable @Composable
fun CustomDropDownMenuItem( fun CustomDropDownMenuItem(
leadingIcon: ImageVector, leadingIcon: ImageVector,

View file

@ -0,0 +1,236 @@
package ua.acclorite.book_story.presentation.components
import android.content.ClipboardManager
import android.content.Context
import android.view.ActionMode
import android.view.Menu
import android.view.MenuItem
import android.view.View
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalTextToolbar
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.TextToolbar
import androidx.compose.ui.platform.TextToolbarStatus
import ua.acclorite.book_story.R
private const val MENU_ITEM_COPY = 0
private const val MENU_ITEM_TRANSLATE = 1
private const val MENU_ITEM_DICTIONARY = 2
/**
* Custom Text ActionMode callback. Used in pair with [CustomSelectionToolbar]. Follow [TextToolbar] for more info.
*/
private class CustomTextActionModeCallback(
private val context: Context,
var rect: Rect = Rect.Zero,
var onCopyRequested: (() -> Unit)? = null,
var onTranslateRequested: (() -> Unit)? = null,
var onDictionaryRequested: (() -> Unit)? = null
) : ActionMode.Callback {
override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean {
requireNotNull(menu)
requireNotNull(mode)
onCopyRequested?.let {
menu.add(0, MENU_ITEM_COPY, 0, context.getString(R.string.copy))
.setShowAsAction(MenuItem.SHOW_AS_ACTION_NEVER)
}
onTranslateRequested?.let {
menu.add(0, MENU_ITEM_TRANSLATE, 1, context.getString(R.string.translate))
.setShowAsAction(MenuItem.SHOW_AS_ACTION_NEVER)
}
onDictionaryRequested?.let {
menu.add(0, MENU_ITEM_DICTIONARY, 2, context.getString(R.string.dictionary))
.setShowAsAction(MenuItem.SHOW_AS_ACTION_NEVER)
}
return true
}
override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?): Boolean {
return false
}
override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean {
when (item!!.itemId) {
MENU_ITEM_COPY -> onCopyRequested?.invoke()
MENU_ITEM_TRANSLATE -> onTranslateRequested?.invoke()
MENU_ITEM_DICTIONARY -> onDictionaryRequested?.invoke()
else -> return false
}
mode?.finish()
return true
}
override fun onDestroyActionMode(mode: ActionMode?) {}
}
/**
* Floating [CustomTextActionModeCallback].
*/
private class FloatingTextActionModeCallback(
val callback: CustomTextActionModeCallback
) : ActionMode.Callback2() {
override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean {
return callback.onActionItemClicked(mode, item)
}
override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean {
return callback.onCreateActionMode(mode, menu)
}
override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?): Boolean {
return callback.onPrepareActionMode(mode, menu)
}
override fun onDestroyActionMode(mode: ActionMode?) {
callback.onDestroyActionMode(mode)
}
override fun onGetContentRect(mode: ActionMode?, view: View?, outRect: android.graphics.Rect?) {
val rect = callback.rect
outRect?.set(
rect.left.toInt(),
rect.top.toInt(),
rect.right.toInt(),
rect.bottom.toInt()
)
}
}
/**
* Custom Selection Toolbar. Used in pair with [CustomSelectionContainer] to display custom toolbar.
*/
private class CustomSelectionToolbar(
private val view: View,
context: Context,
private val onCopyRequest: (() -> Unit)?,
private val onTranslateRequest: ((String) -> Unit)?,
private val onDictionaryRequest: ((String) -> Unit)?
) : TextToolbar {
private var actionMode: ActionMode? = null
private val callback = CustomTextActionModeCallback(context = context)
val clipboardManager =
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
override var status: TextToolbarStatus by mutableStateOf(TextToolbarStatus.Hidden)
private set
override fun showMenu(
rect: Rect,
onCopyRequested: (() -> Unit)?,
onPasteRequested: (() -> Unit)?,
onCutRequested: (() -> Unit)?,
onSelectAllRequested: (() -> Unit)?
) {
callback.rect = rect
callback.onCopyRequested = {
onCopyRequested?.invoke()
onCopyRequest?.invoke()
}
callback.onTranslateRequested = {
val previousClipboard = clipboardManager.primaryClip
onCopyRequested?.invoke()
val currentClipboard = clipboardManager.text
onTranslateRequest?.invoke(currentClipboard.toString())
if (previousClipboard != null) {
clipboardManager.setPrimaryClip(
previousClipboard
)
}
}
callback.onDictionaryRequested = {
val previousClipboard = clipboardManager.primaryClip
onCopyRequested?.invoke()
val currentClipboard = clipboardManager.text
onDictionaryRequest?.invoke(currentClipboard.toString())
if (previousClipboard != null) {
clipboardManager.setPrimaryClip(
previousClipboard
)
}
}
if (actionMode == null) {
status = TextToolbarStatus.Shown
actionMode = view.startActionMode(
FloatingTextActionModeCallback(callback),
ActionMode.TYPE_FLOATING
)
} else {
actionMode?.invalidate()
}
}
override fun hide() {
status = TextToolbarStatus.Hidden
actionMode?.finish()
actionMode = null
}
}
/**
* Custom selection container.
*
* @param onCopyRequested Callback for when the copy option is clicked.
* @param onTranslateRequested Callback for when the translate option is clicked.
* @param onDictionaryRequested Callback for when the dictionary option is clicked.
* @param content Selection container content.
*/
@Composable
fun CustomSelectionContainer(
onCopyRequested: (() -> Unit),
onTranslateRequested: ((String) -> Unit),
onDictionaryRequested: ((String) -> Unit),
content: @Composable (Boolean) -> Unit
) {
val view = LocalView.current
val context = LocalContext.current
val customSelectionToolbar = remember {
CustomSelectionToolbar(
view = view,
context = context,
onCopyRequest = {
onCopyRequested()
},
onTranslateRequest = {
onTranslateRequested(it)
},
onDictionaryRequest = {
onDictionaryRequested(it)
}
)
}
val isToolbarHidden = remember(customSelectionToolbar.status) {
derivedStateOf {
customSelectionToolbar.status == TextToolbarStatus.Hidden
}
}
CompositionLocalProvider(
LocalTextToolbar provides customSelectionToolbar
) {
SelectionContainer {
content(isToolbarHidden.value)
}
}
}

View file

@ -1,49 +0,0 @@
package ua.acclorite.book_story.presentation.components
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.DismissValue
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.SwipeToDismiss
import androidx.compose.material.rememberDismissState
import androidx.compose.material3.Snackbar
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun CustomSnackbar(snackbarState: SnackbarHostState) {
val dismissState = rememberDismissState { value ->
if (value != DismissValue.Default) {
snackbarState.currentSnackbarData?.dismiss()
true
} else false
}
LaunchedEffect(dismissState.currentValue) {
if (dismissState.currentValue != DismissValue.Default) {
dismissState.reset()
}
}
SwipeToDismiss(
modifier = Modifier
.padding(vertical = 4.dp)
.fillMaxWidth(),
state = dismissState,
background = {}
) {
SnackbarHost(
hostState = snackbarState,
snackbar = { data ->
Snackbar(
data
)
}
)
}
}

View file

@ -0,0 +1,100 @@
package ua.acclorite.book_story.presentation.components
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.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsDraggedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Snackbar
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import kotlin.math.roundToInt
/**
* Custom Snackbar with vertical Swipe-To-Dismiss and animation.
* It is recommended to put it in Scaffold bottomBar(this way padding is the same at all sides)
*
* @param snackbarState Regular [SnackbarHostState], use ..remember { SnackbarHostState() }
*/
@Composable
fun CustomSnackbar(modifier: Modifier = Modifier, snackbarState: SnackbarHostState) {
var showSnackbar by remember { mutableStateOf(false) }
var snackbarData by remember { mutableStateOf(snackbarState.currentSnackbarData) }
var offset by remember { mutableFloatStateOf(0f) }
val density = LocalDensity.current
val mutableInteractionSource = remember { MutableInteractionSource() }
val isDragged by mutableInteractionSource.collectIsDraggedAsState()
LaunchedEffect(offset, isDragged) {
val offsetDp = with(density) { offset.toDp() }
if (offsetDp > 35.dp && !isDragged) {
snackbarState.currentSnackbarData?.dismiss()
return@LaunchedEffect
}
if (offsetDp <= 35.dp && !isDragged) {
offset = 0f
}
}
LaunchedEffect(snackbarState.currentSnackbarData) {
val data = snackbarState.currentSnackbarData
if (data == null) {
showSnackbar = false
return@LaunchedEffect
}
offset = 0f
snackbarData = data
showSnackbar = true
}
AnimatedVisibility(
visible = showSnackbar,
enter = slideInVertically(tween(250)) { it } + fadeIn(tween(250)),
exit = slideOutVertically(tween(100)) { it } + fadeOut(tween(100))
) {
Box(
modifier = modifier
.offset { IntOffset(0, offset.roundToInt()) }
.padding(vertical = 4.dp)
.fillMaxWidth()
.draggable(
interactionSource = mutableInteractionSource,
orientation = Orientation.Vertical,
state = rememberDraggableState {
offset = (offset + it).coerceAtLeast(0f)
}
)
) {
Snackbar(
modifier = Modifier.align(Alignment.Center),
snackbarData = snackbarData!!,
shape = MaterialTheme.shapes.medium
)
}
}
}

View file

@ -0,0 +1,38 @@
package ua.acclorite.book_story.presentation.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
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.res.stringResource
import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.data.Navigator
/**
* Go back arrow button. Prevents double or triple clicking on it while going back action is performed.
*/
@Composable
fun GoBackButton(navigator: Navigator, customOnClick: () -> Unit = {}) {
var isClicked by remember { mutableStateOf(false) }
IconButton(
enabled = !isClicked,
onClick = {
isClicked = true
navigator.navigateBack()
customOnClick()
}
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(id = R.string.go_back_content_desc),
tint = MaterialTheme.colorScheme.onSurface
)
}
}

View file

@ -21,9 +21,12 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.R import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.Navigator import ua.acclorite.book_story.presentation.data.Navigator
import ua.acclorite.book_story.presentation.Screen import ua.acclorite.book_story.presentation.data.Screen
/**
* More drop down. Navigates to Settings, About and Help screens.
*/
@Composable @Composable
fun MoreDropDown(navigator: Navigator) { fun MoreDropDown(navigator: Navigator) {
var showDropDown by remember { mutableStateOf(false) } var showDropDown by remember { mutableStateOf(false) }
@ -58,7 +61,7 @@ fun MoreDropDown(navigator: Navigator) {
) )
}, },
onClick = { onClick = {
navigator.navigate(Screen.SETTINGS) navigator.navigate(Screen.SETTINGS, false)
showDropDown = false showDropDown = false
}, },
contentPadding = PaddingValues(start = startPadding, end = endPadding) contentPadding = PaddingValues(start = startPadding, end = endPadding)
@ -72,7 +75,7 @@ fun MoreDropDown(navigator: Navigator) {
) )
}, },
onClick = { onClick = {
navigator.navigate(Screen.HELP) navigator.navigate(Screen.HELP, false)
showDropDown = false showDropDown = false
}, },
contentPadding = PaddingValues(start = startPadding, end = endPadding) contentPadding = PaddingValues(start = startPadding, end = endPadding)
@ -86,7 +89,7 @@ fun MoreDropDown(navigator: Navigator) {
) )
}, },
onClick = { onClick = {
navigator.navigate(Screen.ABOUT) navigator.navigate(Screen.ABOUT, false)
showDropDown = false showDropDown = false
}, },
contentPadding = PaddingValues(start = startPadding, end = endPadding) contentPadding = PaddingValues(start = startPadding, end = endPadding)

View file

@ -1,4 +1,4 @@
package ua.acclorite.book_story.presentation.components package ua.acclorite.book_story.presentation.components.bottom_navigation_bar
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBar
@ -11,9 +11,9 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import ua.acclorite.book_story.R import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.model.NavigationBarItem import ua.acclorite.book_story.domain.model.NavigationItem
import ua.acclorite.book_story.presentation.Navigator import ua.acclorite.book_story.presentation.data.Navigator
import ua.acclorite.book_story.presentation.Screen import ua.acclorite.book_story.presentation.data.Screen
import ua.acclorite.book_story.ui.elevation import ua.acclorite.book_story.ui.elevation
/** /**
@ -37,34 +37,34 @@ fun BottomNavigationBar(
containerColor = MaterialTheme.elevation() containerColor = MaterialTheme.elevation()
) { ) {
BottomNavigationBarItem( BottomNavigationBarItem(
item = NavigationBarItem( item = NavigationItem(
stringResource(id = R.string.library_screen), stringResource(id = R.string.library_screen),
selectedIcon = painterResource(id = R.drawable.library_screen_filled), selectedIcon = painterResource(id = R.drawable.library_screen_filled),
unselectedIcon = painterResource(id = R.drawable.library_screen_outlined) unselectedIcon = painterResource(id = R.drawable.library_screen_outlined)
), ),
isSelected = currentScreen == Screen.LIBRARY isSelected = currentScreen == Screen.LIBRARY
) { ) {
navigator.navigate(Screen.LIBRARY) navigator.navigate(Screen.LIBRARY, false)
} }
BottomNavigationBarItem( BottomNavigationBarItem(
item = NavigationBarItem( item = NavigationItem(
stringResource(id = R.string.history_screen), stringResource(id = R.string.history_screen),
selectedIcon = painterResource(id = R.drawable.history_screen_filled), selectedIcon = painterResource(id = R.drawable.history_screen_filled),
unselectedIcon = painterResource(id = R.drawable.history_screen_outlined) unselectedIcon = painterResource(id = R.drawable.history_screen_outlined)
), ),
isSelected = currentScreen == Screen.HISTORY isSelected = currentScreen == Screen.HISTORY
) { ) {
navigator.navigate(Screen.HISTORY) navigator.navigate(Screen.HISTORY, false)
} }
BottomNavigationBarItem( BottomNavigationBarItem(
item = NavigationBarItem( item = NavigationItem(
stringResource(id = R.string.browse_screen), stringResource(id = R.string.browse_screen),
selectedIcon = painterResource(id = R.drawable.browse_screen_filled), selectedIcon = painterResource(id = R.drawable.browse_screen_filled),
unselectedIcon = painterResource(id = R.drawable.browse_screen_outlined) unselectedIcon = painterResource(id = R.drawable.browse_screen_outlined)
), ),
isSelected = currentScreen == Screen.BROWSE isSelected = currentScreen == Screen.BROWSE
) { ) {
navigator.navigate(Screen.BROWSE) navigator.navigate(Screen.BROWSE, false)
} }
} }
} }

View file

@ -1,4 +1,4 @@
package ua.acclorite.book_story.presentation.components package ua.acclorite.book_story.presentation.components.bottom_navigation_bar
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@ -11,7 +11,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.domain.model.NavigationBarItem import ua.acclorite.book_story.domain.model.NavigationItem
/** /**
* Bottom Navigation Bar Item, uses default [NavigationBarItem]. * Bottom Navigation Bar Item, uses default [NavigationBarItem].
@ -19,7 +19,7 @@ import ua.acclorite.book_story.domain.model.NavigationBarItem
@Composable @Composable
fun RowScope.BottomNavigationBarItem( fun RowScope.BottomNavigationBarItem(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
item: NavigationBarItem, item: NavigationItem,
isSelected: Boolean, isSelected: Boolean,
onClick: () -> Unit onClick: () -> Unit
) { ) {

View file

@ -1,4 +1,4 @@
package ua.acclorite.book_story.presentation.components package ua.acclorite.book_story.presentation.components.custom_dialog
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -35,6 +35,23 @@ import ua.acclorite.book_story.R
import ua.acclorite.book_story.ui.ElevationDefaults import ua.acclorite.book_story.ui.ElevationDefaults
import ua.acclorite.book_story.ui.elevation import ua.acclorite.book_story.ui.elevation
/**
* Custom Dialog with custom content.
*
* @param modifier Modifier to be applied.
* @param properties Dialog properties.
* @param drawableIcon Drawable icon to show(1/2).
* @param imageVectorIcon Image vector icon to show(2/2).
* @param backgroundTransparency The transparency of the background behind the dialog.
* @param title The title of the dialog.
* @param description The description of the dialog.
* @param actionText The action text of the dialog.
* @param isActionEnabled Whether action is enabled or not,
* @param onDismiss Code to execute when the dialog is dismissed.
* @param onAction Code to execute when action is clicked.
* @param withDivider Should divider be shown or not.
* @param customContent Custom content(not necessary, dialog also can be use just as alert dialog).
*/
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun CustomDialogWithContent( fun CustomDialogWithContent(
@ -54,7 +71,7 @@ fun CustomDialogWithContent(
) { ) {
AlertDialog( AlertDialog(
onDismissRequest = { onDismiss() }, onDismissRequest = { onDismiss() },
properties = properties, properties = properties
) { ) {
(LocalView.current.parent as DialogWindowProvider).window.setDimAmount( (LocalView.current.parent as DialogWindowProvider).window.setDimAmount(
backgroundTransparency backgroundTransparency
@ -118,8 +135,8 @@ fun CustomDialogWithContent(
if (withDivider) { if (withDivider) {
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
Divider( Divider(
color = MaterialTheme.colorScheme.outlineVariant, modifier = Modifier.padding(horizontal = 24.dp),
modifier = Modifier.padding(horizontal = 24.dp) color = MaterialTheme.colorScheme.outlineVariant
) )
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
} else { } else {

View file

@ -1,4 +1,4 @@
package ua.acclorite.book_story.presentation.components package ua.acclorite.book_story.presentation.components.custom_dialog
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -36,6 +36,24 @@ import ua.acclorite.book_story.R
import ua.acclorite.book_story.ui.ElevationDefaults import ua.acclorite.book_story.ui.ElevationDefaults
import ua.acclorite.book_story.ui.elevation import ua.acclorite.book_story.ui.elevation
/**
* Custom Dialog with lazy column.
*
* @param modifier Modifier to be applied.
* @param properties Dialog properties.
* @param drawableIcon Drawable icon to show(1/2).
* @param imageVectorIcon Image vector icon to show(2/2).
* @param backgroundTransparency The transparency of the background behind the dialog.
* @param title The title of the dialog.
* @param description The description of the dialog.
* @param actionText The action text of the dialog.
* @param isActionEnabled Whether action is enabled or not,
* @param onDismiss Code to execute when the dialog is dismissed.
* @param onAction Code to execute when action is clicked.
* @param withDivider Should divider be shown or not.
* @param items The items of this dialog, use items(..) { .. } or item { .. }.
*/
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun CustomDialogWithLazyColumn( fun CustomDialogWithLazyColumn(
@ -55,7 +73,7 @@ fun CustomDialogWithLazyColumn(
) { ) {
AlertDialog( AlertDialog(
onDismissRequest = { onDismiss() }, onDismissRequest = { onDismiss() },
properties = properties, properties = properties
) { ) {
(LocalView.current.parent as DialogWindowProvider).window.setDimAmount( (LocalView.current.parent as DialogWindowProvider).window.setDimAmount(
backgroundTransparency backgroundTransparency
@ -119,8 +137,8 @@ fun CustomDialogWithLazyColumn(
if (withDivider) { if (withDivider) {
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
Divider( Divider(
color = MaterialTheme.colorScheme.outlineVariant, modifier = Modifier.padding(horizontal = 24.dp),
modifier = Modifier.padding(horizontal = 24.dp) color = MaterialTheme.colorScheme.outlineVariant
) )
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
} else { } else {

View file

@ -1,4 +1,4 @@
package ua.acclorite.book_story.presentation.components package ua.acclorite.book_story.presentation.components.custom_dialog
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@ -17,6 +17,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
/**
* Selectable dialog item. Perfectly combines with [CustomDialogWithLazyColumn].
*/
@Composable @Composable
fun SelectableDialogItem(selected: Boolean, title: String, onClick: () -> Unit) { fun SelectableDialogItem(selected: Boolean, title: String, onClick: () -> Unit) {
Row( Row(

View file

@ -0,0 +1,90 @@
package ua.acclorite.book_story.presentation.components.custom_navigation_rail
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationRail
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.model.NavigationItem
import ua.acclorite.book_story.presentation.data.Navigator
import ua.acclorite.book_story.presentation.data.Screen
import ua.acclorite.book_story.ui.elevation
/**
* Custom Navigation Rail. It is used to be shown on Tablets.
*/
@Composable
fun CustomNavigationRail(
navigator: Navigator
) {
var currentScreen: Screen? by remember { mutableStateOf(null) }
LaunchedEffect(Unit) {
navigator.getCurrentScreen().collect {
if (it == Screen.LIBRARY || it == Screen.HISTORY || it == Screen.BROWSE) {
currentScreen = it
}
}
}
NavigationRail(
modifier = Modifier
.width(80.dp)
.background(MaterialTheme.elevation())
.padding(horizontal = 12.dp),
containerColor = MaterialTheme.elevation()
) {
Column(
Modifier
.fillMaxHeight(),
verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally
) {
CustomNavigationRailItem(
item = NavigationItem(
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, false)
}
CustomNavigationRailItem(
item = NavigationItem(
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, false)
}
CustomNavigationRailItem(
item = NavigationItem(
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, false)
}
}
}
}

View file

@ -0,0 +1,54 @@
package ua.acclorite.book_story.presentation.components.custom_navigation_rail
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.NavigationRailItemDefaults
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 ua.acclorite.book_story.domain.model.NavigationItem
/**
* Custom Navigation Rail item.
*/
@Composable
fun CustomNavigationRailItem(
modifier: Modifier = Modifier,
item: NavigationItem,
isSelected: Boolean,
onClick: () -> Unit
) {
NavigationRailItem(
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)
)
},
modifier = modifier,
colors = NavigationRailItemDefaults.colors(
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
disabledIconColor = MaterialTheme.colorScheme.onSurface,
disabledTextColor = MaterialTheme.colorScheme.onSurface
)
)
}

View file

@ -1,4 +1,4 @@
package ua.acclorite.book_story.presentation.components package ua.acclorite.book_story.presentation.components.is_messages
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -20,6 +20,16 @@ import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
/**
* Is Empty message. It usually placed when the list is empty.
*
* @param modifier Modifier to be applied.
* @param message The message.
* @param icon Icon to be displayed.
* @param actionTitle Optional action.
* @param action On click action.
*/
@Composable @Composable
fun IsEmpty( fun IsEmpty(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,

View file

@ -1,4 +1,4 @@
package ua.acclorite.book_story.presentation.components package ua.acclorite.book_story.presentation.components.is_messages
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -20,6 +20,15 @@ import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
/**
* Is Empty message. It usually placed when something went wrong and you need to display large error message.
*
* @param modifier Modifier to be applied.
* @param errorMessage The error message.
* @param icon Icon to be displayed.
* @param actionTitle Optional action.
* @param action On click action.
*/
@Composable @Composable
fun IsError( fun IsError(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,

View file

@ -1,5 +1,9 @@
package ua.acclorite.book_story.presentation.data package ua.acclorite.book_story.presentation.data
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Path
import kotlin.math.abs
fun String.removeTrailingZero(): String { fun String.removeTrailingZero(): String {
if (!this.contains('.')) if (!this.contains('.'))
return this return this
@ -9,3 +13,18 @@ fun String.removeTrailingZero(): String {
} }
fun Double.removeDigits(digits: Int) = "%.${digits}f".format(this).replace(",", ".") fun Double.removeDigits(digits: Int) = "%.${digits}f".format(this).replace(",", ".")
fun calculateFamiliarity(string: String, target: String): Int {
val targetCounts = target.lowercase().trim().groupingBy { it }.eachCount()
val familiarity = string.lowercase().trim().sumOf { targetCounts.getOrDefault(it, 0) }
return familiarity
}
fun Path.standardQuadFromTo(from: Offset, to: Offset) {
quadraticTo(
from.x,
from.y,
abs(from.x + to.x) / 2f,
abs(from.y + to.y) / 2f
)
}

View file

@ -19,6 +19,7 @@ import kotlinx.coroutines.launch
import ua.acclorite.book_story.domain.use_case.ChangeLanguage import ua.acclorite.book_story.domain.use_case.ChangeLanguage
import ua.acclorite.book_story.domain.use_case.GetDatastore import ua.acclorite.book_story.domain.use_case.GetDatastore
import ua.acclorite.book_story.domain.use_case.SetDatastore import ua.acclorite.book_story.domain.use_case.SetDatastore
import ua.acclorite.book_story.presentation.screens.library.data.LibraryViewModel
import ua.acclorite.book_story.ui.DarkTheme import ua.acclorite.book_story.ui.DarkTheme
import ua.acclorite.book_story.ui.Theme import ua.acclorite.book_story.ui.Theme
import ua.acclorite.book_story.ui.toDarkTheme import ua.acclorite.book_story.ui.toDarkTheme
@ -264,19 +265,47 @@ class MainViewModel @Inject constructor(
} }
} }
fun init(activity: ComponentActivity) { fun init(
activity: ComponentActivity,
libraryViewModel: LibraryViewModel,
) {
val isViewModelsReady = combine(
libraryViewModel.isReady,
) { values ->
values.all { it }
}
val isDataReady = combine( val isDataReady = combine(
language, theme, darkTheme, showStartScreen, backgroundColor, language,
fontColor, fontFamily, isItalic, fontSize, lineHeight, theme,
paragraphHeight, paragraphIndentation darkTheme,
showStartScreen,
backgroundColor,
fontColor,
fontFamily,
isItalic,
fontSize,
lineHeight,
paragraphHeight,
paragraphIndentation
) { values -> ) { values ->
values.all { it != null } values.all { it != null }
} }
val isReady = combine(
isViewModelsReady,
isDataReady
) { values ->
values.all { it }
}
viewModelScope.launch { viewModelScope.launch {
isDataReady.collect { bool -> isReady.collect { bool ->
if (bool) { if (bool) {
_isReady.value = true _isReady.update {
true
}
return@collect
} }
} }
} }

View file

@ -1,4 +1,4 @@
package ua.acclorite.book_story.presentation package ua.acclorite.book_story.presentation.data
import android.annotation.SuppressLint import android.annotation.SuppressLint
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
@ -33,6 +33,7 @@ import ua.acclorite.book_story.ui.Transitions
private const val CURRENT_SCREEN = "current_screen" private const val CURRENT_SCREEN = "current_screen"
private const val BACKSTACK = "back_stack" private const val BACKSTACK = "back_stack"
private const val USE_BACK_ANIM = "use_back_animation"
/** /**
* All screens are listed here, later each screen will be passed as a param for [Navigator.composable] function. * All screens are listed here, later each screen will be passed as a param for [Navigator.composable] function.
@ -48,9 +49,11 @@ enum class Screen {
SETTINGS, SETTINGS,
GENERAL_SETTINGS, GENERAL_SETTINGS,
APPEARANCE_SETTINGS, APPEARANCE_SETTINGS,
READER_SETTINGS,
ABOUT, ABOUT,
HELP HELP,
START
} }
data class Argument( data class Argument(
@ -72,6 +75,7 @@ class Navigator @AssistedInject constructor(
) : ViewModel() { ) : ViewModel() {
private val currentScreen = savedStateHandle.getStateFlow(CURRENT_SCREEN, startScreen) private val currentScreen = savedStateHandle.getStateFlow(CURRENT_SCREEN, startScreen)
private val useBackAnimation = savedStateHandle.getStateFlow(USE_BACK_ANIM, false)
private val backStack = savedStateHandle.getStateFlow(BACKSTACK, mutableListOf<Screen>()) private val backStack = savedStateHandle.getStateFlow(BACKSTACK, mutableListOf<Screen>())
private val arguments = mutableStateListOf<Argument>() private val arguments = mutableStateListOf<Argument>()
@ -104,7 +108,7 @@ class Navigator @AssistedInject constructor(
arguments.removeIf { it.key == key } arguments.removeIf { it.key == key }
} }
fun navigate(screen: Screen, vararg args: Argument) = fun navigate(screen: Screen, useBackAnimation: Boolean, vararg args: Argument) =
viewModelScope.launch(Dispatchers.Default) { viewModelScope.launch(Dispatchers.Default) {
backStack.value.add(currentScreen.value) backStack.value.add(currentScreen.value)
@ -112,10 +116,11 @@ class Navigator @AssistedInject constructor(
putArgument(it) putArgument(it)
} }
savedStateHandle[USE_BACK_ANIM] = useBackAnimation
savedStateHandle[CURRENT_SCREEN] = screen savedStateHandle[CURRENT_SCREEN] = screen
} }
fun navigateWithoutBackStack(screen: Screen, vararg args: Argument) = fun navigateWithoutBackStack(screen: Screen, useBackAnimation: Boolean, vararg args: Argument) =
viewModelScope.launch(Dispatchers.Default) { viewModelScope.launch(Dispatchers.Default) {
if (backStack.value.last() == screen) { if (backStack.value.last() == screen) {
backStack.value.removeLast() backStack.value.removeLast()
@ -125,15 +130,18 @@ class Navigator @AssistedInject constructor(
putArgument(it) putArgument(it)
} }
savedStateHandle[USE_BACK_ANIM] = useBackAnimation
savedStateHandle[CURRENT_SCREEN] = screen savedStateHandle[CURRENT_SCREEN] = screen
} }
fun navigateBack() = viewModelScope.launch(Dispatchers.Default) { fun navigateBack(useBackAnimation: Boolean = true) =
if (canGoBack()) { viewModelScope.launch(Dispatchers.Default) {
savedStateHandle[CURRENT_SCREEN] = backStack.value.last() if (canGoBack()) {
backStack.value.removeLast() savedStateHandle[USE_BACK_ANIM] = useBackAnimation
savedStateHandle[CURRENT_SCREEN] = backStack.value.last()
backStack.value.removeLast()
}
} }
}
fun canGoBack(): Boolean { fun canGoBack(): Boolean {
return backStack.value.isNotEmpty() return backStack.value.isNotEmpty()
@ -148,7 +156,9 @@ class Navigator @AssistedInject constructor(
* *
* @param screen The [Screen] that represents [content]. * @param screen The [Screen] that represents [content].
* @param enterAnim Enter Animation. * @param enterAnim Enter Animation.
* @param backEnterAnim Enter Animation for navigating back.
* @param exitAnim Exit Animation. * @param exitAnim Exit Animation.
* @param backExitAnim Exit Animation for navigating back.
* @param content The Screen content to show when [Navigator.currentScreen] equals [screen]. * @param content The Screen content to show when [Navigator.currentScreen] equals [screen].
*/ */
@SuppressLint("ComposableNaming") @SuppressLint("ComposableNaming")
@ -156,14 +166,16 @@ class Navigator @AssistedInject constructor(
fun composable( fun composable(
screen: Screen, screen: Screen,
enterAnim: EnterTransition = Transitions.FadeTransitionIn, enterAnim: EnterTransition = Transitions.FadeTransitionIn,
backEnterAnim: EnterTransition = Transitions.BackSlidingTransitionIn,
exitAnim: ExitTransition = Transitions.FadeTransitionOut, exitAnim: ExitTransition = Transitions.FadeTransitionOut,
backExitAnim: ExitTransition = Transitions.BackSlidingTransitionOut,
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
AnimatedVisibility( AnimatedVisibility(
visible = getCurrentScreen().collectAsState().value == screen, visible = getCurrentScreen().collectAsState().value == screen,
enter = enterAnim, enter = if (!useBackAnimation.collectAsState().value) enterAnim else backEnterAnim,
exit = exitAnim, exit = if (!useBackAnimation.collectAsState().value) exitAnim else backExitAnim,
label = screen.toString() label = ""
) { ) {
content() content()
} }

View file

@ -21,10 +21,15 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.filled.Done import androidx.compose.material.icons.filled.Done
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.pullrefresh.PullRefreshDefaults
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.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.FloatingActionButtonDefaults import androidx.compose.material3.FloatingActionButtonDefaults
@ -32,6 +37,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -43,32 +49,33 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
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.graphics.asImageBitmap
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import dagger.hilt.android.lifecycle.withCreationCallback import dagger.hilt.android.lifecycle.withCreationCallback
import ua.acclorite.book_story.R import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.model.Book import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.presentation.Argument
import ua.acclorite.book_story.presentation.Navigator
import ua.acclorite.book_story.presentation.Screen
import ua.acclorite.book_story.presentation.components.AnimatedTopAppBar import ua.acclorite.book_story.presentation.components.AnimatedTopAppBar
import ua.acclorite.book_story.presentation.components.CustomSnackbar
import ua.acclorite.book_story.presentation.components.GoBackButton
import ua.acclorite.book_story.presentation.data.Argument
import ua.acclorite.book_story.presentation.data.Navigator
import ua.acclorite.book_story.presentation.data.Screen
import ua.acclorite.book_story.presentation.screens.book_info.components.BookInfoBackground import ua.acclorite.book_story.presentation.screens.book_info.components.BookInfoBackground
import ua.acclorite.book_story.presentation.screens.book_info.components.BookInfoChangeCoverBottomSheet
import ua.acclorite.book_story.presentation.screens.book_info.components.BookInfoDeleteDialog
import ua.acclorite.book_story.presentation.screens.book_info.components.BookInfoDescriptionSection import ua.acclorite.book_story.presentation.screens.book_info.components.BookInfoDescriptionSection
import ua.acclorite.book_story.presentation.screens.book_info.components.BookInfoDetailsBottomSheet
import ua.acclorite.book_story.presentation.screens.book_info.components.BookInfoInfoSection import ua.acclorite.book_story.presentation.screens.book_info.components.BookInfoInfoSection
import ua.acclorite.book_story.presentation.screens.book_info.components.BookInfoMoreDropDown import ua.acclorite.book_story.presentation.screens.book_info.components.BookInfoMoreDropDown
import ua.acclorite.book_story.presentation.screens.book_info.components.BookInfoMoveDialog
import ua.acclorite.book_story.presentation.screens.book_info.components.BookInfoStatisticSection import ua.acclorite.book_story.presentation.screens.book_info.components.BookInfoStatisticSection
import ua.acclorite.book_story.presentation.screens.book_info.components.change_cover_bottom_sheet.BookInfoChangeCoverBottomSheet
import ua.acclorite.book_story.presentation.screens.book_info.components.details_bottom_sheet.BookInfoDetailsBottomSheet
import ua.acclorite.book_story.presentation.screens.book_info.components.dialog.BookInfoDeleteDialog
import ua.acclorite.book_story.presentation.screens.book_info.components.dialog.BookInfoMoveDialog
import ua.acclorite.book_story.presentation.screens.book_info.data.BookInfoEvent import ua.acclorite.book_story.presentation.screens.book_info.data.BookInfoEvent
import ua.acclorite.book_story.presentation.screens.book_info.data.BookInfoViewModel import ua.acclorite.book_story.presentation.screens.book_info.data.BookInfoViewModel
import ua.acclorite.book_story.presentation.screens.browse.data.BrowseViewModel import ua.acclorite.book_story.presentation.screens.browse.data.BrowseViewModel
import ua.acclorite.book_story.presentation.screens.history.data.HistoryEvent
import ua.acclorite.book_story.presentation.screens.history.data.HistoryViewModel import ua.acclorite.book_story.presentation.screens.history.data.HistoryViewModel
import ua.acclorite.book_story.presentation.screens.library.data.LibraryEvent import ua.acclorite.book_story.presentation.screens.library.data.LibraryEvent
import ua.acclorite.book_story.presentation.screens.library.data.LibraryViewModel import ua.acclorite.book_story.presentation.screens.library.data.LibraryViewModel
@ -77,12 +84,12 @@ import ua.acclorite.book_story.ui.Transitions
import ua.acclorite.book_story.ui.elevation import ua.acclorite.book_story.ui.elevation
import ua.acclorite.book_story.util.Constants import ua.acclorite.book_story.util.Constants
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
@Composable @Composable
fun BookInfoScreen( fun BookInfoScreen(
libraryViewModel: LibraryViewModel = hiltViewModel(), libraryViewModel: LibraryViewModel,
browseViewModel: BrowseViewModel = hiltViewModel(), browseViewModel: BrowseViewModel,
historyViewModel: HistoryViewModel = hiltViewModel(), historyViewModel: HistoryViewModel,
navigator: Navigator navigator: Navigator
) { ) {
val context = LocalContext.current val context = LocalContext.current
@ -104,22 +111,29 @@ fun BookInfoScreen(
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val listState = rememberLazyListState() val listState = rememberLazyListState()
val snackbarState = remember { SnackbarHostState() }
val refreshState = rememberPullRefreshState(
refreshing = state.isRefreshing,
refreshThreshold = PullRefreshDefaults.RefreshThreshold + 32.dp,
refreshingOffset = PullRefreshDefaults.RefreshingOffset + 64.dp,
onRefresh = {
viewModel.onEvent(
BookInfoEvent.OnUpdateBook(
refreshList = {
libraryViewModel.onEvent(LibraryEvent.OnLoadList)
historyViewModel.onEvent(HistoryEvent.OnLoadList)
},
snackbarState,
context
)
)
}
)
val firstVisibleItemIndex by remember { derivedStateOf { listState.firstVisibleItemIndex } } val firstVisibleItemIndex by remember { derivedStateOf { listState.firstVisibleItemIndex } }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.init( viewModel.init(
navigator, navigator
failure = {
Toast.makeText(
context,
context.getString(
R.string.file_not_found,
it.takeLast(40)
),
Toast.LENGTH_LONG
).show()
}
) )
} }
@ -153,8 +167,9 @@ fun BookInfoScreen(
Scaffold( Scaffold(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection) .windowInsetsPadding(WindowInsets.navigationBars)
.windowInsetsPadding(WindowInsets.navigationBars), .pullRefresh(refreshState)
.nestedScroll(scrollBehavior.nestedScrollConnection),
containerColor = MaterialTheme.colorScheme.surface, containerColor = MaterialTheme.colorScheme.surface,
topBar = { topBar = {
AnimatedTopAppBar( AnimatedTopAppBar(
@ -164,15 +179,7 @@ fun BookInfoScreen(
isTopBarScrolled = null, isTopBarScrolled = null,
content1NavigationIcon = { content1NavigationIcon = {
IconButton(onClick = { GoBackButton(navigator = navigator)
navigator.navigateBack()
}) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
contentDescription = "Go back",
tint = MaterialTheme.colorScheme.onSurface
)
}
}, },
content1Title = { content1Title = {
DefaultTransition(visible = firstVisibleItemIndex > 0) { DefaultTransition(visible = firstVisibleItemIndex > 0) {
@ -186,11 +193,37 @@ fun BookInfoScreen(
} }
}, },
content1Actions = { content1Actions = {
IconButton(
enabled = !state.isRefreshing,
onClick = {
viewModel.onEvent(
BookInfoEvent.OnUpdateBook(
refreshList = {
libraryViewModel.onEvent(LibraryEvent.OnLoadList)
historyViewModel.onEvent(HistoryEvent.OnLoadList)
},
snackbarState,
context
)
)
}
) {
Icon(
imageVector = Icons.Default.Refresh,
contentDescription = stringResource(id = R.string.refresh_book_content_desc),
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Box { Box {
DefaultTransition( DefaultTransition(
visible = !state.editTitle visible = !state.editTitle
) { ) {
BookInfoMoreDropDown(viewModel = viewModel) BookInfoMoreDropDown(
viewModel = viewModel,
snackbarState = snackbarState
)
} }
androidx.compose.animation.AnimatedVisibility( androidx.compose.animation.AnimatedVisibility(
visible = state.editTitle, visible = state.editTitle,
@ -230,6 +263,12 @@ fun BookInfoScreen(
} }
} }
) )
},
bottomBar = {
CustomSnackbar(
modifier = Modifier.padding(bottom = 70.dp),
snackbarState = snackbarState
)
} }
) { paddingValues -> ) { paddingValues ->
Box( Box(
@ -246,7 +285,7 @@ fun BookInfoScreen(
if (state.book.coverImage != null) { if (state.book.coverImage != null) {
BookInfoBackground( BookInfoBackground(
height = paddingValues.calculateTopPadding() + 12.dp + 195.dp, height = paddingValues.calculateTopPadding() + 12.dp + 195.dp,
image = state.book.coverImage!!.asImageBitmap() image = state.book.coverImage!!
) )
} }
@ -271,48 +310,54 @@ fun BookInfoScreen(
} }
} }
if (state.book.file != null) { FloatingActionButton(
FloatingActionButton( onClick = {
onClick = { navigator.navigate(
navigator.navigate( Screen.READER, false, Argument(
Screen.READER, Argument( "book", state.book
"book", state.book
)
) )
}, )
modifier = Modifier },
.align(Alignment.BottomEnd) shape = MaterialTheme.shapes.large,
.padding(16.dp), modifier = Modifier
shape = MaterialTheme.shapes.large, .align(Alignment.BottomEnd)
elevation = FloatingActionButtonDefaults.elevation(), .padding(16.dp),
containerColor = MaterialTheme.colorScheme.primaryContainer, elevation = FloatingActionButtonDefaults.elevation(),
contentColor = MaterialTheme.colorScheme.onPrimaryContainer, containerColor = MaterialTheme.colorScheme.primaryContainer,
content = { contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
Row(verticalAlignment = Alignment.CenterVertically) { content = {
Spacer(modifier = Modifier.width(13.dp)) Row(verticalAlignment = Alignment.CenterVertically) {
Icon( Spacer(modifier = Modifier.width(13.dp))
imageVector = Icons.Filled.PlayArrow, Icon(
contentDescription = "Continue reading", imageVector = Icons.Filled.PlayArrow,
Modifier.size(24.dp) contentDescription = stringResource(id = R.string.continue_reading_content_desc),
Modifier.size(24.dp)
)
AnimatedVisibility(
visible = !listState.canScrollBackward
) {
Text(
text = stringResource(
if (state.book.progress == 0f) R.string.start
else R.string.continue_read
),
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
modifier = Modifier.padding(start = 8.dp)
) )
AnimatedVisibility(
visible = !listState.canScrollBackward
) {
Text(
text = stringResource(
if (state.book.progress == 0f) R.string.start
else R.string.continue_read
),
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
modifier = Modifier.padding(start = 8.dp)
)
}
Spacer(modifier = Modifier.width(16.dp))
} }
Spacer(modifier = Modifier.width(16.dp))
} }
) }
} )
PullRefreshIndicator(
state.isRefreshing,
refreshState,
Modifier.align(Alignment.TopCenter),
backgroundColor = MaterialTheme.colorScheme.inverseSurface,
contentColor = MaterialTheme.colorScheme.inverseOnSurface
)
} }
} }
} }

View file

@ -15,6 +15,9 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
/**
* Background of the BookInfoScreen.
*/
@Composable @Composable
fun BookInfoBackground(height: Dp, image: ImageBitmap) { fun BookInfoBackground(height: Dp, image: ImageBitmap) {
val background = MaterialTheme.colorScheme.background val background = MaterialTheme.colorScheme.background
@ -34,7 +37,7 @@ fun BookInfoBackground(height: Dp, image: ImageBitmap) {
) )
) )
} }
.blur(2.dp), .blur(3.dp),
alpha = 0.3f, alpha = 0.3f,
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
) )

View file

@ -14,12 +14,15 @@ import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.R import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.model.Book import ua.acclorite.book_story.domain.model.Book
/**
* Description section.
*/
@Composable @Composable
fun BookInfoDescriptionSection(book: Book) { fun BookInfoDescriptionSection(book: Book) {
SelectionContainer { SelectionContainer {
Text( Text(
book.description if (book.description?.isNotBlank() == true) book.description
?: stringResource(id = R.string.error_no_description), else stringResource(id = R.string.error_no_description),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Start, textAlign = TextAlign.Start,

View file

@ -34,7 +34,6 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
@ -46,6 +45,9 @@ import ua.acclorite.book_story.presentation.screens.book_info.data.BookInfoEvent
import ua.acclorite.book_story.presentation.screens.book_info.data.BookInfoViewModel import ua.acclorite.book_story.presentation.screens.book_info.data.BookInfoViewModel
import ua.acclorite.book_story.ui.elevation import ua.acclorite.book_story.ui.elevation
/**
* Info section.
*/
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun BookInfoInfoSection(viewModel: BookInfoViewModel, book: Book) { fun BookInfoInfoSection(viewModel: BookInfoViewModel, book: Book) {
@ -84,7 +86,7 @@ fun BookInfoInfoSection(viewModel: BookInfoViewModel, book: Book) {
if (book.coverImage != null) { if (book.coverImage != null) {
Image( Image(
bitmap = book.coverImage.asImageBitmap(), bitmap = book.coverImage,
contentDescription = "Cover", contentDescription = "Cover",
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()

View file

@ -11,6 +11,7 @@ import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -23,8 +24,11 @@ import ua.acclorite.book_story.presentation.components.CustomDropDownMenuItem
import ua.acclorite.book_story.presentation.screens.book_info.data.BookInfoEvent import ua.acclorite.book_story.presentation.screens.book_info.data.BookInfoEvent
import ua.acclorite.book_story.presentation.screens.book_info.data.BookInfoViewModel import ua.acclorite.book_story.presentation.screens.book_info.data.BookInfoViewModel
/**
* Book info more dropdown. Has three options: Delete, Move and Details.
*/
@Composable @Composable
fun BookInfoMoreDropDown(viewModel: BookInfoViewModel) { fun BookInfoMoreDropDown(viewModel: BookInfoViewModel, snackbarState: SnackbarHostState) {
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
Box { Box {
@ -64,6 +68,7 @@ fun BookInfoMoreDropDown(viewModel: BookInfoViewModel) {
leadingIcon = Icons.Outlined.Info, leadingIcon = Icons.Outlined.Info,
text = stringResource(id = R.string.details_book) text = stringResource(id = R.string.details_book)
) { ) {
snackbarState.currentSnackbarData?.dismiss()
viewModel.onEvent(BookInfoEvent.OnShowHideDetailsBottomSheet) viewModel.onEvent(BookInfoEvent.OnShowHideDetailsBottomSheet)
viewModel.onEvent(BookInfoEvent.OnShowHideMoreDropDown) viewModel.onEvent(BookInfoEvent.OnShowHideMoreDropDown)
} }

View file

@ -19,6 +19,9 @@ import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.presentation.data.removeDigits import ua.acclorite.book_story.presentation.data.removeDigits
import ua.acclorite.book_story.presentation.data.removeTrailingZero import ua.acclorite.book_story.presentation.data.removeTrailingZero
/**
* Statistic section.
*/
@Composable @Composable
fun BookInfoStatisticSection(book: Book) { fun BookInfoStatisticSection(book: Book) {
Column( Column(
@ -41,16 +44,16 @@ fun BookInfoStatisticSection(book: Book) {
LinearProgressIndicator( LinearProgressIndicator(
book.progress.coerceAtLeast(0.01f), book.progress.coerceAtLeast(0.01f),
strokeCap = StrokeCap.Round,
modifier = Modifier modifier = Modifier
.fillMaxWidth(), .fillMaxWidth(),
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.onPrimaryContainer.copy(0.05f) trackColor = MaterialTheme.colorScheme.onPrimaryContainer.copy(0.05f),
strokeCap = StrokeCap.Round,
) )
Spacer(modifier = Modifier.height(6.dp)) Spacer(modifier = Modifier.height(6.dp))
Text( Text(
text = stringResource( text = if (book.progress < 1f) stringResource(
id = R.string.you_read_query, id = R.string.you_read_query,
(book.progress * 100) (book.progress * 100)
.toDouble() .toDouble()
@ -59,7 +62,7 @@ fun BookInfoStatisticSection(book: Book) {
) + " " + stringResource( ) + " " + stringResource(
if (book.progress > 0.2f) R.string.read_keep if (book.progress > 0.2f) R.string.read_keep
else R.string.read_more else R.string.read_more
), ) else stringResource(id = R.string.read_done),
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
maxLines = 1, maxLines = 1,

View file

@ -1,4 +1,4 @@
package ua.acclorite.book_story.presentation.screens.book_info.components package ua.acclorite.book_story.presentation.screens.book_info.components.change_cover_bottom_sheet
import android.widget.Toast import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
@ -32,6 +32,9 @@ import ua.acclorite.book_story.presentation.screens.library.data.LibraryViewMode
import ua.acclorite.book_story.ui.ElevationDefaults import ua.acclorite.book_story.ui.ElevationDefaults
import ua.acclorite.book_story.ui.elevation import ua.acclorite.book_story.ui.elevation
/**
* Change cover bottom sheet. Lets user select photo from the gallery and replaces old one.
*/
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun BookInfoChangeCoverBottomSheet( fun BookInfoChangeCoverBottomSheet(

View file

@ -1,4 +1,4 @@
package ua.acclorite.book_story.presentation.screens.book_info.components package ua.acclorite.book_story.presentation.screens.book_info.components.change_cover_bottom_sheet
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@ -21,6 +21,9 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
/**
* Change cover bottom sheet item.
*/
@Composable @Composable
fun BookInfoChangeCoverBottomSheetItem( fun BookInfoChangeCoverBottomSheetItem(
icon: ImageVector, icon: ImageVector,

View file

@ -1,4 +1,4 @@
package ua.acclorite.book_story.presentation.screens.book_info.components package ua.acclorite.book_story.presentation.screens.book_info.components.details_bottom_sheet
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@ -25,6 +25,9 @@ import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
/**
* Details bottom sheet. Displays name, path, last opened and size of the file.
*/
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun BookInfoDetailsBottomSheet( fun BookInfoDetailsBottomSheet(

View file

@ -1,4 +1,4 @@
package ua.acclorite.book_story.presentation.screens.book_info.components package ua.acclorite.book_story.presentation.screens.book_info.components.details_bottom_sheet
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
@ -18,6 +18,9 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
/**
* Details bottom sheet item.
*/
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun BookInfoDetailsBottomSheetItem( fun BookInfoDetailsBottomSheetItem(
@ -51,7 +54,6 @@ fun BookInfoDetailsBottomSheetItem(
Spacer(modifier = Modifier.height(2.dp)) Spacer(modifier = Modifier.height(2.dp))
Text( Text(
description, description,
maxLines = 3,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,

View file

@ -1,4 +1,4 @@
package ua.acclorite.book_story.presentation.screens.book_info.components package ua.acclorite.book_story.presentation.screens.book_info.components.dialog
import android.widget.Toast import android.widget.Toast
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@ -7,8 +7,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import ua.acclorite.book_story.R import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.Navigator import ua.acclorite.book_story.presentation.components.custom_dialog.CustomDialogWithContent
import ua.acclorite.book_story.presentation.components.CustomDialogWithContent import ua.acclorite.book_story.presentation.data.Navigator
import ua.acclorite.book_story.presentation.screens.book_info.data.BookInfoEvent import ua.acclorite.book_story.presentation.screens.book_info.data.BookInfoEvent
import ua.acclorite.book_story.presentation.screens.book_info.data.BookInfoViewModel import ua.acclorite.book_story.presentation.screens.book_info.data.BookInfoViewModel
import ua.acclorite.book_story.presentation.screens.browse.data.BrowseEvent import ua.acclorite.book_story.presentation.screens.browse.data.BrowseEvent
@ -18,6 +18,9 @@ import ua.acclorite.book_story.presentation.screens.history.data.HistoryViewMode
import ua.acclorite.book_story.presentation.screens.library.data.LibraryEvent import ua.acclorite.book_story.presentation.screens.library.data.LibraryEvent
import ua.acclorite.book_story.presentation.screens.library.data.LibraryViewModel import ua.acclorite.book_story.presentation.screens.library.data.LibraryViewModel
/**
* Delete dialog. Deletes current book.
*/
@Composable @Composable
fun BookInfoDeleteDialog( fun BookInfoDeleteDialog(
libraryViewModel: LibraryViewModel, libraryViewModel: LibraryViewModel,

View file

@ -1,4 +1,4 @@
package ua.acclorite.book_story.presentation.screens.book_info.components package ua.acclorite.book_story.presentation.screens.book_info.components.dialog
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
@ -13,14 +13,17 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import ua.acclorite.book_story.R import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.model.Category import ua.acclorite.book_story.domain.model.Category
import ua.acclorite.book_story.presentation.Navigator import ua.acclorite.book_story.presentation.components.custom_dialog.CustomDialogWithLazyColumn
import ua.acclorite.book_story.presentation.components.CustomDialogWithLazyColumn import ua.acclorite.book_story.presentation.components.custom_dialog.SelectableDialogItem
import ua.acclorite.book_story.presentation.components.SelectableDialogItem import ua.acclorite.book_story.presentation.data.Navigator
import ua.acclorite.book_story.presentation.screens.book_info.data.BookInfoEvent import ua.acclorite.book_story.presentation.screens.book_info.data.BookInfoEvent
import ua.acclorite.book_story.presentation.screens.book_info.data.BookInfoViewModel import ua.acclorite.book_story.presentation.screens.book_info.data.BookInfoViewModel
import ua.acclorite.book_story.presentation.screens.library.data.LibraryEvent import ua.acclorite.book_story.presentation.screens.library.data.LibraryEvent
import ua.acclorite.book_story.presentation.screens.library.data.LibraryViewModel import ua.acclorite.book_story.presentation.screens.library.data.LibraryViewModel
/**
* Move dialog. Moves current book to the selected category.
*/
@Composable @Composable
fun BookInfoMoveDialog( fun BookInfoMoveDialog(
libraryViewModel: LibraryViewModel, libraryViewModel: LibraryViewModel,

View file

@ -2,9 +2,10 @@ package ua.acclorite.book_story.presentation.screens.book_info.data
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import androidx.compose.material3.SnackbarHostState
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import ua.acclorite.book_story.domain.model.Category import ua.acclorite.book_story.domain.model.Category
import ua.acclorite.book_story.presentation.Navigator import ua.acclorite.book_story.presentation.data.Navigator
sealed class BookInfoEvent { sealed class BookInfoEvent {
data object OnShowHideChangeCoverBottomSheet : BookInfoEvent() data object OnShowHideChangeCoverBottomSheet : BookInfoEvent()
@ -33,4 +34,18 @@ sealed class BookInfoEvent {
val text: String, val text: String,
val success: () -> Unit val success: () -> Unit
) : BookInfoEvent() ) : BookInfoEvent()
data class OnShowSnackbar(
val text: String,
val action: String?,
val onAction: () -> Unit = {},
val durationMillis: Long,
val snackbarState: SnackbarHostState
) : BookInfoEvent()
data class OnUpdateBook(
val refreshList: () -> Unit,
val snackbarState: SnackbarHostState,
val context: Context
) : BookInfoEvent()
} }

View file

@ -7,6 +7,7 @@ import ua.acclorite.book_story.domain.model.Category
@Immutable @Immutable
data class BookInfoState( data class BookInfoState(
val book: Book, val book: Book,
val isRefreshing: Boolean = false,
val editTitle: Boolean = false, val editTitle: Boolean = false,
val hasFocused: Boolean = false, val hasFocused: Boolean = false,

View file

@ -5,6 +5,8 @@ import android.content.ClipboardManager
import android.content.Context.CLIPBOARD_SERVICE import android.content.Context.CLIPBOARD_SERVICE
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.os.Build import android.os.Build
import androidx.compose.material3.SnackbarResult
import androidx.compose.ui.graphics.asImageBitmap
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.assisted.Assisted import dagger.assisted.Assisted
@ -12,28 +14,36 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.model.Book import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.model.Category import ua.acclorite.book_story.domain.model.Category
import ua.acclorite.book_story.domain.model.NullableBook
import ua.acclorite.book_story.domain.use_case.DeleteBooks import ua.acclorite.book_story.domain.use_case.DeleteBooks
import ua.acclorite.book_story.domain.use_case.GetBookFromFile
import ua.acclorite.book_story.domain.use_case.UpdateBooks import ua.acclorite.book_story.domain.use_case.UpdateBooks
import ua.acclorite.book_story.presentation.Navigator import ua.acclorite.book_story.presentation.data.Navigator
import ua.acclorite.book_story.presentation.Screen import ua.acclorite.book_story.presentation.data.Screen
@HiltViewModel(assistedFactory = BookInfoViewModel.Factory::class) @HiltViewModel(assistedFactory = BookInfoViewModel.Factory::class)
class BookInfoViewModel @AssistedInject constructor( class BookInfoViewModel @AssistedInject constructor(
@Assisted book: Book, @Assisted book: Book,
private val updateBooks: UpdateBooks, private val updateBooks: UpdateBooks,
private val deleteBooks: DeleteBooks private val deleteBooks: DeleteBooks,
private val getBookFromFile: GetBookFromFile
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(BookInfoState(book)) private val _state = MutableStateFlow(BookInfoState(book))
val state = _state.asStateFlow() val state = _state.asStateFlow()
var job: Job? = null
fun onEvent(event: BookInfoEvent) { fun onEvent(event: BookInfoEvent) {
when (event) { when (event) {
is BookInfoEvent.OnShowHideChangeCoverBottomSheet -> { is BookInfoEvent.OnShowHideChangeCoverBottomSheet -> {
@ -47,9 +57,11 @@ class BookInfoViewModel @AssistedInject constructor(
is BookInfoEvent.OnChangeCover -> { is BookInfoEvent.OnChangeCover -> {
viewModelScope.launch { viewModelScope.launch {
val image = event.context.contentResolver?.openInputStream(event.uri)?.use { val image = event.context.contentResolver?.openInputStream(event.uri)?.use {
BitmapFactory.decodeStream(it) BitmapFactory.decodeStream(it).asImageBitmap()
} ?: return@launch } ?: return@launch
image.prepareToDraw()
updateBooks.execute( updateBooks.execute(
listOf( listOf(
_state.value.book.copy(coverImage = image) _state.value.book.copy(coverImage = image)
@ -221,7 +233,7 @@ class BookInfoViewModel @AssistedInject constructor(
it != _state.value.selectedCategory it != _state.value.selectedCategory
}.size - 1 }.size - 1
) )
event.navigator.navigate(Screen.LIBRARY) event.navigator.navigate(Screen.LIBRARY, true)
} }
} }
@ -243,10 +255,139 @@ class BookInfoViewModel @AssistedInject constructor(
event.success() event.success()
} }
} }
is BookInfoEvent.OnShowSnackbar -> {
viewModelScope.launch {
job?.cancel()
event.snackbarState.currentSnackbarData?.dismiss()
if (event.durationMillis > 0) {
job = viewModelScope.launch(Dispatchers.IO) {
delay(event.durationMillis)
event.snackbarState.currentSnackbarData?.dismiss()
}
}
val snackbar = event.snackbarState.showSnackbar(
event.text,
actionLabel = event.action
)
when (snackbar) {
SnackbarResult.Dismissed -> Unit
SnackbarResult.ActionPerformed -> {
event.onAction()
}
}
}
}
is BookInfoEvent.OnUpdateBook -> {
viewModelScope.launch(Dispatchers.IO) {
_state.update {
it.copy(
isRefreshing = true
)
}
if (_state.value.book.file == null) {
onEvent(
BookInfoEvent.OnShowSnackbar(
text = event.context.getString(
R.string.file_not_found,
_state.value.book.filePath
.substringAfterLast("/")
.takeLast(25)
),
action = event.context.getString(R.string.retry),
onAction = {
onEvent(
BookInfoEvent.OnUpdateBook(
refreshList = event.refreshList,
snackbarState = event.snackbarState,
context = event.context
)
)
},
durationMillis = 4000L,
snackbarState = event.snackbarState
)
)
delay(500)
_state.update {
it.copy(
isRefreshing = false
)
}
return@launch
}
val nullableBook = getBookFromFile.execute(_state.value.book.file!!)
if (nullableBook is NullableBook.Null) {
onEvent(
BookInfoEvent.OnShowSnackbar(
text = nullableBook.message?.asString(event.context)
?: event.context.getString(R.string.error_something_went_wrong),
action = event.context.getString(R.string.retry),
onAction = {
onEvent(
BookInfoEvent.OnUpdateBook(
refreshList = event.refreshList,
snackbarState = event.snackbarState,
context = event.context
)
)
},
durationMillis = 4000L,
snackbarState = event.snackbarState
)
)
delay(500)
_state.update {
it.copy(
isRefreshing = false
)
}
return@launch
}
val updatedBook = nullableBook.book?.first ?: return@launch
_state.update {
it.copy(
book = it.book.copy(
author = updatedBook.author,
description = updatedBook.description,
text = updatedBook.text
)
)
}
updateBooks.execute(listOf(_state.value.book))
event.refreshList()
onEvent(
BookInfoEvent.OnShowSnackbar(
event.context.getString(R.string.book_updated),
action = null,
durationMillis = 4000L,
snackbarState = event.snackbarState
)
)
delay(500)
_state.update {
it.copy(
isRefreshing = false
)
}
}
}
} }
} }
fun init(navigator: Navigator, failure: (String) -> Unit) { fun init(navigator: Navigator) {
viewModelScope.launch { viewModelScope.launch {
val book = navigator.retrieveArgument("book") as? Book val book = navigator.retrieveArgument("book") as? Book
@ -255,10 +396,6 @@ class BookInfoViewModel @AssistedInject constructor(
return@launch return@launch
} }
if (book.file == null) {
failure(book.filePath)
}
_state.update { _state.update {
BookInfoState(book = book) BookInfoState(book = book)
} }

View file

@ -53,24 +53,24 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.rememberPermissionState import com.google.accompanist.permissions.rememberPermissionState
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import ua.acclorite.book_story.R import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.Navigator
import ua.acclorite.book_story.presentation.Screen
import ua.acclorite.book_story.presentation.components.AnimatedTopAppBar import ua.acclorite.book_story.presentation.components.AnimatedTopAppBar
import ua.acclorite.book_story.presentation.components.IsEmpty
import ua.acclorite.book_story.presentation.components.IsError
import ua.acclorite.book_story.presentation.components.MoreDropDown import ua.acclorite.book_story.presentation.components.MoreDropDown
import ua.acclorite.book_story.presentation.screens.browse.components.BrowseAddingDialog import ua.acclorite.book_story.presentation.components.is_messages.IsEmpty
import ua.acclorite.book_story.presentation.components.is_messages.IsError
import ua.acclorite.book_story.presentation.data.Navigator
import ua.acclorite.book_story.presentation.data.Screen
import ua.acclorite.book_story.presentation.screens.browse.components.BrowseFileItem import ua.acclorite.book_story.presentation.screens.browse.components.BrowseFileItem
import ua.acclorite.book_story.presentation.screens.browse.components.BrowseStoragePermissionDialog import ua.acclorite.book_story.presentation.screens.browse.components.BrowseStoragePermissionDialog
import ua.acclorite.book_story.presentation.screens.browse.components.adding_dialog.BrowseAddingDialog
import ua.acclorite.book_story.presentation.screens.browse.data.BrowseEvent import ua.acclorite.book_story.presentation.screens.browse.data.BrowseEvent
import ua.acclorite.book_story.presentation.screens.browse.data.BrowseViewModel import ua.acclorite.book_story.presentation.screens.browse.data.BrowseViewModel
import ua.acclorite.book_story.presentation.screens.library.data.LibraryViewModel
import ua.acclorite.book_story.ui.DefaultTransition import ua.acclorite.book_story.ui.DefaultTransition
import ua.acclorite.book_story.ui.Transitions import ua.acclorite.book_story.ui.Transitions
import ua.acclorite.book_story.ui.elevation import ua.acclorite.book_story.ui.elevation
@ -83,7 +83,8 @@ import ua.acclorite.book_story.ui.elevation
) )
@Composable @Composable
fun BrowseScreen( fun BrowseScreen(
viewModel: BrowseViewModel = hiltViewModel(), viewModel: BrowseViewModel,
libraryViewModel: LibraryViewModel,
navigator: Navigator navigator: Navigator
) { ) {
val permissionState = rememberPermissionState( val permissionState = rememberPermissionState(
@ -127,7 +128,11 @@ fun BrowseScreen(
BrowseStoragePermissionDialog(viewModel, permissionState) BrowseStoragePermissionDialog(viewModel, permissionState)
} }
if (state.showAddingDialog) { if (state.showAddingDialog) {
BrowseAddingDialog(viewModel = viewModel, navigator = navigator) BrowseAddingDialog(
viewModel = viewModel,
navigator = navigator,
libraryViewModel = libraryViewModel
)
} }
Scaffold( Scaffold(
@ -270,13 +275,14 @@ fun BrowseScreen(
columns = GridCells.Adaptive(170.dp), columns = GridCells.Adaptive(170.dp),
contentPadding = PaddingValues(12.dp) contentPadding = PaddingValues(12.dp)
) { ) {
items(state.selectableFiles, key = { it.first.path }) { selectableFile -> items(
state.selectableFiles,
key = { it.first.path }
) { selectableFile ->
BrowseFileItem( BrowseFileItem(
file = selectableFile, file = selectableFile,
modifier = Modifier modifier = Modifier
.animateItemPlacement( .animateItemPlacement(),
animationSpec = tween(300)
),
onClick = { onClick = {
viewModel.onEvent(BrowseEvent.OnSelectFile(selectableFile)) viewModel.onEvent(BrowseEvent.OnSelectFile(selectableFile))
} }
@ -317,7 +323,7 @@ fun BrowseScreen(
message = stringResource(id = R.string.browse_empty), message = stringResource(id = R.string.browse_empty),
icon = painterResource(id = R.drawable.empty_browse), icon = painterResource(id = R.drawable.empty_browse),
actionTitle = stringResource(id = R.string.get_help), actionTitle = stringResource(id = R.string.get_help),
action = { navigator.navigate(Screen.HELP) } action = { navigator.navigate(Screen.HELP, false) }
) )
} }
@ -325,9 +331,8 @@ fun BrowseScreen(
state.isRefreshing, state.isRefreshing,
refreshState, refreshState,
Modifier.align(Alignment.TopCenter), Modifier.align(Alignment.TopCenter),
backgroundColor = MaterialTheme.elevation(), backgroundColor = MaterialTheme.colorScheme.inverseSurface,
contentColor = MaterialTheme.colorScheme.primary, contentColor = MaterialTheme.colorScheme.inverseOnSurface
scale = true
) )
} }
} }
@ -343,7 +348,7 @@ fun BrowseScreen(
return@BackHandler return@BackHandler
} }
navigator.navigate(Screen.LIBRARY) navigator.navigate(Screen.LIBRARY, false)
} }
} }

View file

@ -37,6 +37,9 @@ import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
/**
* Browse list element item. Can be selected.
*/
@Composable @Composable
fun BrowseFileItem(file: Pair<File, Boolean>, modifier: Modifier, onClick: () -> Unit) { fun BrowseFileItem(file: Pair<File, Boolean>, modifier: Modifier, onClick: () -> Unit) {
val fileExtension: String = file.first.name.substringAfterLast(".", "") val fileExtension: String = file.first.name.substringAfterLast(".", "")

View file

@ -10,10 +10,13 @@ import androidx.compose.ui.res.stringResource
import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionState import com.google.accompanist.permissions.PermissionState
import ua.acclorite.book_story.R import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.components.CustomDialogWithContent import ua.acclorite.book_story.presentation.components.custom_dialog.CustomDialogWithContent
import ua.acclorite.book_story.presentation.screens.browse.data.BrowseEvent import ua.acclorite.book_story.presentation.screens.browse.data.BrowseEvent
import ua.acclorite.book_story.presentation.screens.browse.data.BrowseViewModel import ua.acclorite.book_story.presentation.screens.browse.data.BrowseViewModel
/**
* Storage permission dialog.
*/
@OptIn(ExperimentalPermissionsApi::class) @OptIn(ExperimentalPermissionsApi::class)
@Composable @Composable
fun BrowseStoragePermissionDialog(viewModel: BrowseViewModel, permissionState: PermissionState) { fun BrowseStoragePermissionDialog(viewModel: BrowseViewModel, permissionState: PermissionState) {

View file

@ -1,4 +1,4 @@
package ua.acclorite.book_story.presentation.screens.browse.components package ua.acclorite.book_story.presentation.screens.browse.components.adding_dialog
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -19,18 +19,21 @@ import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import ua.acclorite.book_story.R import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.Navigator import ua.acclorite.book_story.domain.model.NullableBook
import ua.acclorite.book_story.presentation.components.CustomDialogWithLazyColumn import ua.acclorite.book_story.presentation.components.custom_dialog.CustomDialogWithLazyColumn
import ua.acclorite.book_story.presentation.data.Navigator
import ua.acclorite.book_story.presentation.screens.browse.data.BrowseEvent import ua.acclorite.book_story.presentation.screens.browse.data.BrowseEvent
import ua.acclorite.book_story.presentation.screens.browse.data.BrowseViewModel import ua.acclorite.book_story.presentation.screens.browse.data.BrowseViewModel
import ua.acclorite.book_story.presentation.screens.library.data.LibraryEvent import ua.acclorite.book_story.presentation.screens.library.data.LibraryEvent
import ua.acclorite.book_story.presentation.screens.library.data.LibraryViewModel import ua.acclorite.book_story.presentation.screens.library.data.LibraryViewModel
/**
* Adding dialog. Adds all selected books to the Library.
*/
@Composable @Composable
fun BrowseAddingDialog( fun BrowseAddingDialog(
libraryViewModel: LibraryViewModel = hiltViewModel(), libraryViewModel: LibraryViewModel,
viewModel: BrowseViewModel, viewModel: BrowseViewModel,
navigator: Navigator navigator: Navigator
) { ) {
@ -42,7 +45,10 @@ fun BrowseAddingDialog(
imageVectorIcon = Icons.Default.AddChart, imageVectorIcon = Icons.Default.AddChart,
description = stringResource(id = R.string.add_books_description), description = stringResource(id = R.string.add_books_description),
actionText = stringResource(id = R.string.add), actionText = stringResource(id = R.string.add),
isActionEnabled = !state.isBooksLoading, isActionEnabled = !state.isBooksLoading &&
state.selectedBooks
.filterIsInstance<NullableBook.NotNull>()
.isNotEmpty(),
onDismiss = { viewModel.onEvent(BrowseEvent.OnAddingDialogDismiss) }, onDismiss = { viewModel.onEvent(BrowseEvent.OnAddingDialogDismiss) },
onAction = { onAction = {
viewModel.onEvent( viewModel.onEvent(
@ -80,7 +86,15 @@ fun BrowseAddingDialog(
} else { } else {
items(state.selectedBooks) { book -> items(state.selectedBooks) { book ->
BrowseAddingDialogItem(result = book) { BrowseAddingDialogItem(result = book) {
viewModel.onEvent(BrowseEvent.OnSelectBook(book)) if (it) {
viewModel.onEvent(BrowseEvent.OnSelectBook(book))
} else {
Toast.makeText(
context,
book.message?.asString(context),
Toast.LENGTH_LONG
).show()
}
} }
} }
} }

View file

@ -1,4 +1,4 @@
package ua.acclorite.book_story.presentation.screens.browse.components package ua.acclorite.book_story.presentation.screens.browse.components.adding_dialog
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -10,27 +10,32 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.model.NullableBook import ua.acclorite.book_story.domain.model.NullableBook
import ua.acclorite.book_story.presentation.components.CustomCheckbox import ua.acclorite.book_story.presentation.components.CustomCheckbox
/**
* Adding dialog item.
*/
@Composable @Composable
fun BrowseAddingDialogItem(result: NullableBook, onClick: () -> Unit) { fun BrowseAddingDialogItem(result: NullableBook, onClick: (Boolean) -> Unit) {
if (result is NullableBook.NotNull) { if (result is NullableBook.NotNull) {
Row( Row(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { .clickable {
onClick() onClick(true)
} }
.padding(vertical = 12.dp, horizontal = 24.dp), .padding(vertical = 12.dp, horizontal = 24.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
@ -58,14 +63,24 @@ fun BrowseAddingDialogItem(result: NullableBook, onClick: () -> Unit) {
} }
} }
} else { } else {
val icon =
if (result.message?.asString() == stringResource(R.string.error_file_encrypted)) {
Icons.Default.Lock
} else {
Icons.Default.Error
}
Row( Row(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable {
onClick(false)
}
.padding(vertical = 12.dp, horizontal = 24.dp), .padding(vertical = 12.dp, horizontal = 24.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Icon( Icon(
imageVector = Icons.Default.Warning, imageVector = icon,
contentDescription = "Error", contentDescription = "Error",
modifier = Modifier.size(26.dp), modifier = Modifier.size(26.dp),
tint = MaterialTheme.colorScheme.error tint = MaterialTheme.colorScheme.error

View file

@ -7,7 +7,7 @@ import androidx.compose.ui.focus.FocusRequester
import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionState import com.google.accompanist.permissions.PermissionState
import ua.acclorite.book_story.domain.model.NullableBook import ua.acclorite.book_story.domain.model.NullableBook
import ua.acclorite.book_story.presentation.Navigator import ua.acclorite.book_story.presentation.data.Navigator
import java.io.File import java.io.File
sealed class BrowseEvent { sealed class BrowseEvent {

View file

@ -22,8 +22,8 @@ import ua.acclorite.book_story.domain.use_case.FastGetBooks
import ua.acclorite.book_story.domain.use_case.GetBooksFromFiles import ua.acclorite.book_story.domain.use_case.GetBooksFromFiles
import ua.acclorite.book_story.domain.use_case.GetFilesFromDownloads import ua.acclorite.book_story.domain.use_case.GetFilesFromDownloads
import ua.acclorite.book_story.domain.use_case.InsertBooks import ua.acclorite.book_story.domain.use_case.InsertBooks
import ua.acclorite.book_story.presentation.Argument import ua.acclorite.book_story.presentation.data.Argument
import ua.acclorite.book_story.presentation.Screen import ua.acclorite.book_story.presentation.data.Screen
import ua.acclorite.book_story.util.Resource import ua.acclorite.book_story.util.Resource
import javax.inject.Inject import javax.inject.Inject
@ -42,7 +42,14 @@ class BrowseViewModel @Inject constructor(
private var job: Job? = null private var job: Job? = null
init { init {
onEvent(BrowseEvent.OnLoadList) viewModelScope.launch(Dispatchers.IO) {
_state.update {
it.copy(
isLoading = true
)
}
getFilesFromDownloads()
}
} }
fun onEvent(event: BrowseEvent) { fun onEvent(event: BrowseEvent) {
@ -268,34 +275,23 @@ class BrowseViewModel @Inject constructor(
is BrowseEvent.OnGetBooksFromFiles -> { is BrowseEvent.OnGetBooksFromFiles -> {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
getBooksFromFiles.execute( _state.update {
it.copy(
isBooksLoading = true
)
}
val books = getBooksFromFiles.execute(
_state.value.selectableFiles _state.value.selectableFiles
.filter { it.second } .filter { it.second }
.map { it.first } .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 {
_state.update { it.copy(
it.copy( selectedBooks = books,
isBooksLoading = result.isLoading isBooksLoading = false
) )
}
}
is Resource.Error -> Unit
}
} }
} }
} }
@ -323,7 +319,11 @@ class BrowseViewModel @Inject constructor(
onEvent(BrowseEvent.OnClearSelectedFiles) onEvent(BrowseEvent.OnClearSelectedFiles)
onEvent(BrowseEvent.OnLoadList) onEvent(BrowseEvent.OnLoadList)
event.navigator.navigate(Screen.LIBRARY, Argument("added_books", books)) event.navigator.navigate(
Screen.LIBRARY,
false,
Argument("added_books", books)
)
} }
} }

View file

@ -54,23 +54,24 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import ua.acclorite.book_story.R import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.Argument
import ua.acclorite.book_story.presentation.Navigator
import ua.acclorite.book_story.presentation.Screen
import ua.acclorite.book_story.presentation.components.AnimatedTopAppBar import ua.acclorite.book_story.presentation.components.AnimatedTopAppBar
import ua.acclorite.book_story.presentation.components.CategoryTitle import ua.acclorite.book_story.presentation.components.CategoryTitle
import ua.acclorite.book_story.presentation.components.CustomSnackbar import ua.acclorite.book_story.presentation.components.CustomSnackbar
import ua.acclorite.book_story.presentation.components.IsEmpty
import ua.acclorite.book_story.presentation.components.MoreDropDown import ua.acclorite.book_story.presentation.components.MoreDropDown
import ua.acclorite.book_story.presentation.components.is_messages.IsEmpty
import ua.acclorite.book_story.presentation.data.Argument
import ua.acclorite.book_story.presentation.data.Navigator
import ua.acclorite.book_story.presentation.data.Screen
import ua.acclorite.book_story.presentation.screens.history.components.HistoryDeleteWholeHistoryDialog import ua.acclorite.book_story.presentation.screens.history.components.HistoryDeleteWholeHistoryDialog
import ua.acclorite.book_story.presentation.screens.history.components.HistoryItem import ua.acclorite.book_story.presentation.screens.history.components.HistoryItem
import ua.acclorite.book_story.presentation.screens.history.data.HistoryEvent import ua.acclorite.book_story.presentation.screens.history.data.HistoryEvent
import ua.acclorite.book_story.presentation.screens.history.data.HistoryViewModel import ua.acclorite.book_story.presentation.screens.history.data.HistoryViewModel
import ua.acclorite.book_story.presentation.screens.library.data.LibraryEvent
import ua.acclorite.book_story.presentation.screens.library.data.LibraryViewModel
import ua.acclorite.book_story.ui.Transitions import ua.acclorite.book_story.ui.Transitions
import ua.acclorite.book_story.ui.elevation import ua.acclorite.book_story.ui.elevation
import java.util.UUID import java.util.UUID
@ -81,11 +82,14 @@ import java.util.UUID
) )
@Composable @Composable
fun HistoryScreen( fun HistoryScreen(
viewModel: HistoryViewModel = hiltViewModel(), viewModel: HistoryViewModel,
libraryViewModel: LibraryViewModel,
navigator: Navigator navigator: Navigator
) { ) {
val context = LocalContext.current val context = LocalContext.current
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
val books = libraryViewModel.state.collectAsState().value.books.map { it.first }
val refreshState = rememberPullRefreshState( val refreshState = rememberPullRefreshState(
refreshing = state.isRefreshing, refreshing = state.isRefreshing,
onRefresh = { onRefresh = {
@ -118,7 +122,7 @@ fun HistoryScreen(
} }
if (state.showDeleteWholeHistoryDialog) { if (state.showDeleteWholeHistoryDialog) {
HistoryDeleteWholeHistoryDialog(viewModel = viewModel) HistoryDeleteWholeHistoryDialog(viewModel = viewModel, libraryViewModel = libraryViewModel)
} }
Scaffold( Scaffold(
@ -144,7 +148,10 @@ fun HistoryScreen(
) )
}, },
content1Actions = { content1Actions = {
IconButton(onClick = { viewModel.onEvent(HistoryEvent.OnSearchShowHide) }) { IconButton(
enabled = !state.isRefreshing,
onClick = { viewModel.onEvent(HistoryEvent.OnSearchShowHide) }
) {
Icon( Icon(
imageVector = Icons.Default.Search, imageVector = Icons.Default.Search,
contentDescription = "Search history", contentDescription = "Search history",
@ -153,7 +160,9 @@ fun HistoryScreen(
) )
} }
IconButton( IconButton(
enabled = !state.isLoading && !state.isRefreshing && state.history.isNotEmpty(), enabled = !state.isLoading
&& !state.isRefreshing
&& state.history.isNotEmpty(),
onClick = { onClick = {
viewModel.onEvent(HistoryEvent.OnShowHideDeleteWholeHistoryDialog) viewModel.onEvent(HistoryEvent.OnShowHideDeleteWholeHistoryDialog)
} }
@ -248,7 +257,7 @@ fun HistoryScreen(
} }
CategoryTitle( CategoryTitle(
modifier = Modifier.animateItemPlacement(tween(300)), modifier = Modifier.animateItemPlacement(),
title = when (groupedHistory.title) { title = when (groupedHistory.title) {
"today" -> stringResource(id = R.string.today) "today" -> stringResource(id = R.string.today)
"yesterday" -> stringResource(id = R.string.yesterday) "yesterday" -> stringResource(id = R.string.yesterday)
@ -262,27 +271,36 @@ fun HistoryScreen(
items( items(
groupedHistory.history, key = { it.id ?: UUID.randomUUID() } groupedHistory.history, key = { it.id ?: UUID.randomUUID() }
) { ) {
val book = books.find { book -> book.id == it.bookId } ?: return@items
HistoryItem( HistoryItem(
modifier = Modifier.animateItemPlacement(tween(300)), modifier = Modifier.animateItemPlacement(),
history = it, history = it,
book = book,
onBodyClick = { onBodyClick = {
navigator.navigate( navigator.navigate(
Screen.BOOK_INFO, Screen.BOOK_INFO,
Argument("book", it.book) false,
Argument("book", book)
) )
}, },
onTitleClick = { onTitleClick = {
navigator.navigate( navigator.navigate(
Screen.READER, Screen.READER,
Argument("book", it.book) false,
Argument("book", book)
) )
}, },
isDeleteEnabled = !state.isRefreshing,
onDeleteClick = { onDeleteClick = {
viewModel.onEvent( viewModel.onEvent(
HistoryEvent.OnDeleteHistoryElement( HistoryEvent.OnDeleteHistoryElement(
it, historyToDelete = it,
snackbarState, snackbarState = snackbarState,
context context = context,
refreshList = {
libraryViewModel.onEvent(LibraryEvent.OnLoadList)
}
) )
) )
} }
@ -318,9 +336,8 @@ fun HistoryScreen(
state.isRefreshing, state.isRefreshing,
refreshState, refreshState,
Modifier.align(Alignment.TopCenter), Modifier.align(Alignment.TopCenter),
backgroundColor = MaterialTheme.elevation(), backgroundColor = MaterialTheme.colorScheme.inverseSurface,
contentColor = MaterialTheme.colorScheme.primary, contentColor = MaterialTheme.colorScheme.inverseOnSurface
scale = true
) )
} }
} }
@ -331,7 +348,7 @@ fun HistoryScreen(
return@BackHandler return@BackHandler
} }
navigator.navigate(Screen.LIBRARY) navigator.navigate(Screen.LIBRARY, false)
} }
} }

View file

@ -7,13 +7,19 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import ua.acclorite.book_story.R import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.components.CustomDialogWithContent import ua.acclorite.book_story.presentation.components.custom_dialog.CustomDialogWithContent
import ua.acclorite.book_story.presentation.screens.history.data.HistoryEvent import ua.acclorite.book_story.presentation.screens.history.data.HistoryEvent
import ua.acclorite.book_story.presentation.screens.history.data.HistoryViewModel import ua.acclorite.book_story.presentation.screens.history.data.HistoryViewModel
import ua.acclorite.book_story.presentation.screens.library.data.LibraryEvent
import ua.acclorite.book_story.presentation.screens.library.data.LibraryViewModel
/**
* Delete whole history dialog.
*/
@Composable @Composable
fun HistoryDeleteWholeHistoryDialog( fun HistoryDeleteWholeHistoryDialog(
viewModel: HistoryViewModel viewModel: HistoryViewModel,
libraryViewModel: LibraryViewModel
) { ) {
val context = LocalContext.current val context = LocalContext.current
@ -29,7 +35,9 @@ fun HistoryDeleteWholeHistoryDialog(
isActionEnabled = true, isActionEnabled = true,
onAction = { onAction = {
viewModel.onEvent( viewModel.onEvent(
HistoryEvent.OnDeleteWholeHistory HistoryEvent.OnDeleteWholeHistory {
libraryViewModel.onEvent(LibraryEvent.OnLoadList)
}
) )
Toast.makeText( Toast.makeText(
context, context,

View file

@ -26,22 +26,29 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.model.History import ua.acclorite.book_story.domain.model.History
import ua.acclorite.book_story.ui.elevation import ua.acclorite.book_story.ui.elevation
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
/**
* History list element item.
*/
@Composable @Composable
fun HistoryItem( fun HistoryItem(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
history: History, history: History,
book: Book,
onBodyClick: () -> Unit, onBodyClick: () -> Unit,
onTitleClick: () -> Unit, onTitleClick: () -> Unit,
isDeleteEnabled: Boolean,
onDeleteClick: () -> Unit onDeleteClick: () -> Unit
) { ) {
val date = Date(history.time) val date = Date(history.time)
@ -77,10 +84,10 @@ fun HistoryItem(
tint = MaterialTheme.elevation(12.dp) tint = MaterialTheme.elevation(12.dp)
) )
if (history.book.coverImage != null) { if (book.coverImage != null) {
Image( Image(
bitmap = history.book.coverImage.asImageBitmap(), bitmap = book.coverImage,
contentDescription = "Cover", contentDescription = stringResource(id = R.string.cover_image_content_desc),
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.clip(MaterialTheme.shapes.medium), .clip(MaterialTheme.shapes.medium),
@ -96,7 +103,7 @@ fun HistoryItem(
modifier = Modifier.fillMaxHeight() modifier = Modifier.fillMaxHeight()
) { ) {
Text( Text(
history.book.title, book.title,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
modifier = Modifier modifier = Modifier
@ -109,7 +116,7 @@ fun HistoryItem(
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(2.dp))
Text( Text(
pattern.format(date), pattern.format(date),
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
@ -123,6 +130,7 @@ fun HistoryItem(
} }
Box(modifier = Modifier.weight(0.1f), contentAlignment = Alignment.CenterEnd) { Box(modifier = Modifier.weight(0.1f), contentAlignment = Alignment.CenterEnd) {
IconButton( IconButton(
enabled = isDeleteEnabled,
onClick = { onClick = {
onDeleteClick() onDeleteClick()
} }

View file

@ -9,10 +9,11 @@ sealed class HistoryEvent {
data object OnRefreshList : HistoryEvent() data object OnRefreshList : HistoryEvent()
data object OnLoadList : HistoryEvent() data object OnLoadList : HistoryEvent()
data object OnShowHideDeleteWholeHistoryDialog : HistoryEvent() data object OnShowHideDeleteWholeHistoryDialog : HistoryEvent()
data object OnDeleteWholeHistory : HistoryEvent() data class OnDeleteWholeHistory(val refreshList: () -> Unit) : HistoryEvent()
data class OnDeleteHistoryElement( data class OnDeleteHistoryElement(
val historyToDelete: History, val historyToDelete: History,
val snackbarState: SnackbarHostState, val snackbarState: SnackbarHostState,
val refreshList: () -> Unit,
val context: Context val context: Context
) : HistoryEvent() ) : HistoryEvent()

View file

@ -1,6 +1,5 @@
package ua.acclorite.book_story.presentation.screens.history.data package ua.acclorite.book_story.presentation.screens.history.data
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarResult import androidx.compose.material3.SnackbarResult
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@ -17,6 +16,7 @@ import ua.acclorite.book_story.domain.model.GroupedHistory
import ua.acclorite.book_story.domain.model.History import ua.acclorite.book_story.domain.model.History
import ua.acclorite.book_story.domain.use_case.DeleteHistory import ua.acclorite.book_story.domain.use_case.DeleteHistory
import ua.acclorite.book_story.domain.use_case.DeleteWholeHistory import ua.acclorite.book_story.domain.use_case.DeleteWholeHistory
import ua.acclorite.book_story.domain.use_case.GetBooksById
import ua.acclorite.book_story.domain.use_case.GetHistory import ua.acclorite.book_story.domain.use_case.GetHistory
import ua.acclorite.book_story.domain.use_case.InsertHistory import ua.acclorite.book_story.domain.use_case.InsertHistory
import ua.acclorite.book_story.util.Resource import ua.acclorite.book_story.util.Resource
@ -30,16 +30,25 @@ class HistoryViewModel @Inject constructor(
private val insertHistory: InsertHistory, private val insertHistory: InsertHistory,
private val getHistory: GetHistory, private val getHistory: GetHistory,
private val deleteWholeHistory: DeleteWholeHistory, private val deleteWholeHistory: DeleteWholeHistory,
private val deleteHistory: DeleteHistory private val deleteHistory: DeleteHistory,
private val getBooksById: GetBooksById
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(HistoryState()) private val _state = MutableStateFlow(HistoryState())
val state = _state.asStateFlow() val state = _state.asStateFlow()
private var job: Job? = null private var job: Job? = null
private var job2: Job? = null
init { init {
onEvent(HistoryEvent.OnLoadList) viewModelScope.launch(Dispatchers.IO) {
_state.update {
it.copy(
isLoading = true
)
}
getHistoryFromDatabase()
}
} }
fun onEvent(event: HistoryEvent) { fun onEvent(event: HistoryEvent) {
@ -85,6 +94,7 @@ class HistoryViewModel @Inject constructor(
} }
deleteWholeHistory.execute() deleteWholeHistory.execute()
event.refreshList()
getHistoryFromDatabase("") getHistoryFromDatabase("")
} }
@ -104,20 +114,27 @@ class HistoryViewModel @Inject constructor(
listOf(event.historyToDelete) listOf(event.historyToDelete)
) )
onEvent(HistoryEvent.OnRefreshList) onEvent(HistoryEvent.OnRefreshList)
event.refreshList()
job2?.cancel()
event.snackbarState.currentSnackbarData?.dismiss() event.snackbarState.currentSnackbarData?.dismiss()
job2 = viewModelScope.launch(Dispatchers.IO) {
delay(10000)
event.snackbarState.currentSnackbarData?.dismiss()
}
val snackbar = event.snackbarState.showSnackbar( val snackbar = event.snackbarState.showSnackbar(
event.context.getString(R.string.history_element_deleted), event.context.getString(R.string.history_element_deleted),
event.context.getString(R.string.undo), event.context.getString(R.string.undo)
duration = SnackbarDuration.Long
) )
when (snackbar) { when (snackbar) {
SnackbarResult.Dismissed -> return@launch SnackbarResult.Dismissed -> Unit
SnackbarResult.ActionPerformed -> { SnackbarResult.ActionPerformed -> {
insertHistory.execute( insertHistory.execute(
listOf(event.historyToDelete) listOf(event.historyToDelete)
) )
event.refreshList()
onEvent(HistoryEvent.OnRefreshList) onEvent(HistoryEvent.OnRefreshList)
} }
} }
@ -197,7 +214,7 @@ class HistoryViewModel @Inject constructor(
} }
fun filterMaxElementsById(elements: List<History>): List<History> { fun filterMaxElementsById(elements: List<History>): List<History> {
val groupedById = elements.groupBy { it.book.id } val groupedById = elements.groupBy { it.bookId }
val maxElementsById = groupedById.map { (_, values) -> val maxElementsById = groupedById.map { (_, values) ->
values.maxByOrNull { it.time } values.maxByOrNull { it.time }
} }
@ -223,11 +240,15 @@ class HistoryViewModel @Inject constructor(
return@collect return@collect
} }
val books = getBooksById.execute(
history.map { it.bookId }.distinct()
)
val groupedHistory = mutableListOf<GroupedHistory>() val groupedHistory = mutableListOf<GroupedHistory>()
history history
.filter { .filter {
it.book.title.lowercase().trim().contains(query.lowercase().trim()) val book = books.find { book -> book.id == it.bookId }!!
book.title.lowercase().trim().contains(query.lowercase().trim())
}.groupBy { item -> }.groupBy { item ->
val calendar = Calendar.getInstance().apply { val calendar = Calendar.getInstance().apply {
timeInMillis = item.time timeInMillis = item.time

View file

@ -24,7 +24,6 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.CircularProgressIndicator
@ -69,24 +68,23 @@ import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ua.acclorite.book_story.R import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.model.Book import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.model.Category import ua.acclorite.book_story.domain.model.Category
import ua.acclorite.book_story.presentation.Argument
import ua.acclorite.book_story.presentation.Navigator
import ua.acclorite.book_story.presentation.Screen
import ua.acclorite.book_story.presentation.components.AnimatedTopAppBar import ua.acclorite.book_story.presentation.components.AnimatedTopAppBar
import ua.acclorite.book_story.presentation.components.IsEmpty
import ua.acclorite.book_story.presentation.components.MoreDropDown import ua.acclorite.book_story.presentation.components.MoreDropDown
import ua.acclorite.book_story.presentation.components.is_messages.IsEmpty
import ua.acclorite.book_story.presentation.data.Argument
import ua.acclorite.book_story.presentation.data.Navigator
import ua.acclorite.book_story.presentation.data.Screen
import ua.acclorite.book_story.presentation.screens.browse.data.BrowseViewModel import ua.acclorite.book_story.presentation.screens.browse.data.BrowseViewModel
import ua.acclorite.book_story.presentation.screens.history.data.HistoryViewModel import ua.acclorite.book_story.presentation.screens.history.data.HistoryViewModel
import ua.acclorite.book_story.presentation.screens.library.components.LibraryBookItem import ua.acclorite.book_story.presentation.screens.library.components.LibraryBookItem
import ua.acclorite.book_story.presentation.screens.library.components.LibraryDeleteDialog
import ua.acclorite.book_story.presentation.screens.library.components.LibraryMoveDialog
import ua.acclorite.book_story.presentation.screens.library.components.LibraryTabRow import ua.acclorite.book_story.presentation.screens.library.components.LibraryTabRow
import ua.acclorite.book_story.presentation.screens.library.components.dialog.LibraryDeleteDialog
import ua.acclorite.book_story.presentation.screens.library.components.dialog.LibraryMoveDialog
import ua.acclorite.book_story.presentation.screens.library.data.LibraryEvent import ua.acclorite.book_story.presentation.screens.library.data.LibraryEvent
import ua.acclorite.book_story.presentation.screens.library.data.LibraryViewModel import ua.acclorite.book_story.presentation.screens.library.data.LibraryViewModel
import ua.acclorite.book_story.ui.Transitions import ua.acclorite.book_story.ui.Transitions
@ -99,15 +97,17 @@ import java.util.UUID
) )
@Composable @Composable
fun LibraryScreen( fun LibraryScreen(
viewModel: LibraryViewModel = hiltViewModel(), viewModel: LibraryViewModel,
browseViewModel: BrowseViewModel = hiltViewModel(), browseViewModel: BrowseViewModel,
historyViewModel: HistoryViewModel = hiltViewModel(), historyViewModel: HistoryViewModel,
navigator: Navigator, navigator: Navigator,
addedBooks: List<Book> addedBooks: List<Book>
) { ) {
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
val pagerState = val pagerState = rememberPagerState(
rememberPagerState(initialPage = state.currentPage, pageCount = { Category.entries.size }) initialPage = state.currentPage,
pageCount = { Category.entries.size }
)
val refreshState = rememberPullRefreshState( val refreshState = rememberPullRefreshState(
refreshing = state.isRefreshing, refreshing = state.isRefreshing,
onRefresh = { onRefresh = {
@ -165,16 +165,15 @@ fun LibraryScreen(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
maxLines = 1 maxLines = 1
) )
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(6.dp))
Text( Text(
text = state.books.size.toString(), text = state.books.size.toString(),
modifier = Modifier modifier = Modifier
.clip(RoundedCornerShape(12.dp)) .clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.elevation(elevation = 6.dp)) .background(MaterialTheme.elevation(elevation = 6.dp))
.padding(horizontal = 10.dp, vertical = 4.dp), .padding(horizontal = 8.dp, vertical = 3.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 16.sp, fontSize = 16.sp
style = MaterialTheme.typography.titleLarge
) )
} }
}, },
@ -313,9 +312,11 @@ fun LibraryScreen(
LaunchedEffect(state.books) { LaunchedEffect(state.books) {
categorizedBooks.clear() categorizedBooks.clear()
categorizedBooks.addAll(state.books.filter { it.first.category == category } categorizedBooks.addAll(state.books.filter { it.first.category == category }
.sortedByDescending { .sortedWith(
it.first.lastOpened compareByDescending<Pair<Book, Boolean>> { it.first.lastOpened }
}) .thenBy { it.first.title }
)
)
categoryIsLoading = false categoryIsLoading = false
} }
@ -333,15 +334,14 @@ fun LibraryScreen(
) { ) {
LibraryBookItem( LibraryBookItem(
book = it, book = it,
modifier = Modifier.animateItemPlacement( modifier = Modifier.animateItemPlacement(),
animationSpec = tween(300)
),
onCoverImageClick = { onCoverImageClick = {
if (state.hasSelectedItems) { if (state.hasSelectedItems) {
viewModel.onEvent(LibraryEvent.OnSelectBook(it)) viewModel.onEvent(LibraryEvent.OnSelectBook(it))
} else { } else {
navigator.navigate( navigator.navigate(
Screen.BOOK_INFO, Screen.BOOK_INFO,
false,
Argument("book", it.first) Argument("book", it.first)
) )
} }
@ -354,6 +354,7 @@ fun LibraryScreen(
onButtonClick = { onButtonClick = {
navigator.navigate( navigator.navigate(
Screen.READER, Screen.READER,
false,
Argument("book", it.first) Argument("book", it.first)
) )
} }
@ -385,7 +386,7 @@ fun LibraryScreen(
modifier = Modifier.align(Alignment.Center), modifier = Modifier.align(Alignment.Center),
actionTitle = stringResource(id = R.string.add_book) actionTitle = stringResource(id = R.string.add_book)
) { ) {
navigator.navigate(Screen.BROWSE) navigator.navigate(Screen.BROWSE, false)
} }
} }
@ -393,9 +394,8 @@ fun LibraryScreen(
state.isRefreshing, state.isRefreshing,
refreshState, refreshState,
Modifier.align(Alignment.TopCenter), Modifier.align(Alignment.TopCenter),
backgroundColor = MaterialTheme.elevation(), backgroundColor = MaterialTheme.colorScheme.inverseSurface,
contentColor = MaterialTheme.colorScheme.primary, contentColor = MaterialTheme.colorScheme.inverseOnSurface
scale = true
) )
} }
} }

View file

@ -18,7 +18,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Image import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.FilledIconButton import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.IconButtonDefaults
@ -30,7 +29,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@ -40,6 +38,9 @@ import ua.acclorite.book_story.presentation.data.removeDigits
import ua.acclorite.book_story.presentation.data.removeTrailingZero import ua.acclorite.book_story.presentation.data.removeTrailingZero
import ua.acclorite.book_story.ui.elevation import ua.acclorite.book_story.ui.elevation
/**
* Library list element item.
*/
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun LibraryBookItem( fun LibraryBookItem(
@ -102,7 +103,7 @@ fun LibraryBookItem(
if (book.first.coverImage != null) { if (book.first.coverImage != null) {
Image( Image(
bitmap = book.first.coverImage!!.asImageBitmap(), bitmap = book.first.coverImage!!,
contentDescription = "Cover", contentDescription = "Cover",
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -111,40 +112,25 @@ fun LibraryBookItem(
) )
} }
if (book.first.file != null) { Text(
Text( "${
"${ (book.first.progress * 100)
(book.first.progress * 100) .toDouble()
.toDouble() .removeDigits(1)
.removeDigits(1) .removeTrailingZero()
.removeTrailingZero() }%",
}%", textAlign = TextAlign.Center,
textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onPrimary,
color = MaterialTheme.colorScheme.onPrimary, modifier = Modifier
modifier = Modifier .padding(6.dp)
.padding(6.dp) .clip(MaterialTheme.shapes.small)
.clip(MaterialTheme.shapes.small) .background(MaterialTheme.colorScheme.primary)
.background(MaterialTheme.colorScheme.primary) .padding(horizontal = 8.dp, vertical = 4.dp),
.padding(horizontal = 8.dp, vertical = 4.dp), style = MaterialTheme.typography.bodySmall
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( FilledIconButton(
onClick = { onButtonClick() }, onClick = { onButtonClick() },
enabled = book.first.file != null,
modifier = Modifier modifier = Modifier
.align(Alignment.BottomEnd) .align(Alignment.BottomEnd)
.padding(6.dp) .padding(6.dp)
@ -154,11 +140,7 @@ fun LibraryBookItem(
containerColor = MaterialTheme.colorScheme.primaryContainer containerColor = MaterialTheme.colorScheme.primaryContainer
.copy(0.9f), .copy(0.9f),
contentColor = MaterialTheme.colorScheme.onPrimaryContainer contentColor = MaterialTheme.colorScheme.onPrimaryContainer
.copy(0.9f), .copy(0.9f)
disabledContainerColor = MaterialTheme.colorScheme.primaryContainer
.copy(0.6f),
disabledContentColor = MaterialTheme.colorScheme.onPrimaryContainer
.copy(0.6f)
) )
) { ) {
Icon( Icon(

View file

@ -31,6 +31,9 @@ import ua.acclorite.book_story.presentation.screens.library.data.LibraryEvent
import ua.acclorite.book_story.presentation.screens.library.data.LibraryViewModel import ua.acclorite.book_story.presentation.screens.library.data.LibraryViewModel
import ua.acclorite.book_story.ui.elevation import ua.acclorite.book_story.ui.elevation
/**
* Tab row, either scrollable or static, depends on screen width.
*/
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun LibraryTabRow(viewModel: LibraryViewModel, books: List<Book>, pagerState: PagerState) { fun LibraryTabRow(viewModel: LibraryViewModel, books: List<Book>, pagerState: PagerState) {
@ -97,9 +100,9 @@ fun LibraryTabRow(viewModel: LibraryViewModel, books: List<Book>, pagerState: Pa
Text( Text(
text = tabItem.second.toString(), text = tabItem.second.toString(),
modifier = Modifier modifier = Modifier
.clip(RoundedCornerShape(12.dp)) .clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.elevation(6.dp)) .background(MaterialTheme.elevation(6.dp))
.padding(horizontal = 6.dp, vertical = 4.dp), .padding(horizontal = 8.dp, vertical = 4.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium
) )
@ -153,9 +156,9 @@ fun LibraryTabRow(viewModel: LibraryViewModel, books: List<Book>, pagerState: Pa
Text( Text(
text = tabItem.second.toString(), text = tabItem.second.toString(),
modifier = Modifier modifier = Modifier
.clip(RoundedCornerShape(12.dp)) .clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.elevation(6.dp)) .background(MaterialTheme.elevation(6.dp))
.padding(horizontal = 6.dp, vertical = 4.dp), .padding(horizontal = 8.dp, vertical = 4.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium
) )

View file

@ -1,4 +1,4 @@
package ua.acclorite.book_story.presentation.screens.library.components package ua.acclorite.book_story.presentation.screens.library.components.dialog
import android.widget.Toast import android.widget.Toast
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@ -9,7 +9,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import ua.acclorite.book_story.R import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.components.CustomDialogWithContent import ua.acclorite.book_story.presentation.components.custom_dialog.CustomDialogWithContent
import ua.acclorite.book_story.presentation.screens.browse.data.BrowseEvent import ua.acclorite.book_story.presentation.screens.browse.data.BrowseEvent
import ua.acclorite.book_story.presentation.screens.browse.data.BrowseViewModel import ua.acclorite.book_story.presentation.screens.browse.data.BrowseViewModel
import ua.acclorite.book_story.presentation.screens.history.data.HistoryEvent import ua.acclorite.book_story.presentation.screens.history.data.HistoryEvent
@ -17,6 +17,9 @@ import ua.acclorite.book_story.presentation.screens.history.data.HistoryViewMode
import ua.acclorite.book_story.presentation.screens.library.data.LibraryEvent import ua.acclorite.book_story.presentation.screens.library.data.LibraryEvent
import ua.acclorite.book_story.presentation.screens.library.data.LibraryViewModel import ua.acclorite.book_story.presentation.screens.library.data.LibraryViewModel
/**
* Delete dialog. Deletes all selected books.
*/
@Composable @Composable
fun LibraryDeleteDialog( fun LibraryDeleteDialog(
viewModel: LibraryViewModel, viewModel: LibraryViewModel,

View file

@ -1,4 +1,4 @@
package ua.acclorite.book_story.presentation.screens.library.components package ua.acclorite.book_story.presentation.screens.library.components.dialog
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
@ -13,11 +13,14 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import ua.acclorite.book_story.R import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.model.Category import ua.acclorite.book_story.domain.model.Category
import ua.acclorite.book_story.presentation.components.CustomDialogWithLazyColumn import ua.acclorite.book_story.presentation.components.custom_dialog.CustomDialogWithLazyColumn
import ua.acclorite.book_story.presentation.components.SelectableDialogItem import ua.acclorite.book_story.presentation.components.custom_dialog.SelectableDialogItem
import ua.acclorite.book_story.presentation.screens.library.data.LibraryEvent import ua.acclorite.book_story.presentation.screens.library.data.LibraryEvent
import ua.acclorite.book_story.presentation.screens.library.data.LibraryViewModel import ua.acclorite.book_story.presentation.screens.library.data.LibraryViewModel
/**
* Move dialog. Moves all selected books to the selected category.
*/
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun LibraryMoveDialog(viewModel: LibraryViewModel, pagerState: PagerState) { fun LibraryMoveDialog(viewModel: LibraryViewModel, pagerState: PagerState) {

View file

@ -29,10 +29,23 @@ class LibraryViewModel @Inject constructor(
private val _state = MutableStateFlow(LibraryState()) private val _state = MutableStateFlow(LibraryState())
val state = _state.asStateFlow() val state = _state.asStateFlow()
private val _isReady = MutableStateFlow(false)
val isReady = _isReady.asStateFlow()
private var job: Job? = null private var job: Job? = null
init { init {
onEvent(LibraryEvent.OnLoadList) viewModelScope.launch(Dispatchers.IO) {
_state.update {
it.copy(
isLoading = true
)
}
getBooksFromDatabase()
_isReady.update {
true
}
}
} }
fun onEvent(event: LibraryEvent) { fun onEvent(event: LibraryEvent) {

View file

@ -1,6 +1,8 @@
package ua.acclorite.book_story.presentation.screens.reader package ua.acclorite.book_story.presentation.screens.reader
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.os.Build
import android.widget.Toast
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.viewModels import androidx.activity.viewModels
@ -20,7 +22,6 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.selection.DisableSelection import androidx.compose.foundation.text.selection.DisableSelection
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.LinearProgressIndicator import androidx.compose.material.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
@ -50,25 +51,25 @@ import androidx.compose.ui.text.style.LineBreak
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import dagger.hilt.android.lifecycle.withCreationCallback import dagger.hilt.android.lifecycle.withCreationCallback
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import ua.acclorite.book_story.R import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.model.Book import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.presentation.Navigator import ua.acclorite.book_story.presentation.components.CustomSelectionContainer
import ua.acclorite.book_story.presentation.components.IsError import ua.acclorite.book_story.presentation.components.is_messages.IsError
import ua.acclorite.book_story.presentation.data.MainViewModel import ua.acclorite.book_story.presentation.data.MainViewModel
import ua.acclorite.book_story.presentation.data.Navigator
import ua.acclorite.book_story.presentation.screens.history.data.HistoryEvent import ua.acclorite.book_story.presentation.screens.history.data.HistoryEvent
import ua.acclorite.book_story.presentation.screens.history.data.HistoryViewModel import ua.acclorite.book_story.presentation.screens.history.data.HistoryViewModel
import ua.acclorite.book_story.presentation.screens.library.data.LibraryEvent import ua.acclorite.book_story.presentation.screens.library.data.LibraryEvent
import ua.acclorite.book_story.presentation.screens.library.data.LibraryViewModel import ua.acclorite.book_story.presentation.screens.library.data.LibraryViewModel
import ua.acclorite.book_story.presentation.screens.reader.components.ReaderBottomBar
import ua.acclorite.book_story.presentation.screens.reader.components.ReaderEndItem import ua.acclorite.book_story.presentation.screens.reader.components.ReaderEndItem
import ua.acclorite.book_story.presentation.screens.reader.components.ReaderSettingsBottomSheet
import ua.acclorite.book_story.presentation.screens.reader.components.ReaderStartItem import ua.acclorite.book_story.presentation.screens.reader.components.ReaderStartItem
import ua.acclorite.book_story.presentation.screens.reader.components.ReaderTopBar import ua.acclorite.book_story.presentation.screens.reader.components.app_bar.ReaderBottomBar
import ua.acclorite.book_story.presentation.screens.reader.components.app_bar.ReaderTopBar
import ua.acclorite.book_story.presentation.screens.reader.components.settings_bottom_sheet.ReaderSettingsBottomSheet
import ua.acclorite.book_story.presentation.screens.reader.data.ReaderEvent import ua.acclorite.book_story.presentation.screens.reader.data.ReaderEvent
import ua.acclorite.book_story.presentation.screens.reader.data.ReaderViewModel import ua.acclorite.book_story.presentation.screens.reader.data.ReaderViewModel
import ua.acclorite.book_story.ui.elevation import ua.acclorite.book_story.ui.elevation
@ -78,9 +79,9 @@ import ua.acclorite.book_story.util.Constants
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@Composable @Composable
fun ReaderScreen( fun ReaderScreen(
mainViewModel: MainViewModel = hiltViewModel(), mainViewModel: MainViewModel,
libraryViewModel: LibraryViewModel = hiltViewModel(), libraryViewModel: LibraryViewModel,
historyViewModel: HistoryViewModel = hiltViewModel(), historyViewModel: HistoryViewModel,
navigator: Navigator navigator: Navigator
) { ) {
val context = LocalContext.current as ComponentActivity val context = LocalContext.current as ComponentActivity
@ -189,7 +190,7 @@ fun ReaderScreen(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.nestedScroll(nestedScrollConnection), .nestedScroll(nestedScrollConnection),
containerColor = Color(backgroundColor.toULong()), containerColor = MaterialTheme.colorScheme.surface,
topBar = { topBar = {
AnimatedVisibility( AnimatedVisibility(
visible = state.showMenu, visible = state.showMenu,
@ -219,19 +220,66 @@ fun ReaderScreen(
} }
} }
) { ) {
SelectionContainer { CustomSelectionContainer(
onCopyRequested = {
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
Toast.makeText(
context,
context.getString(R.string.copied),
Toast.LENGTH_SHORT
).show()
}
},
onTranslateRequested = { textToTranslate ->
viewModel.onEvent(
ReaderEvent.OnTranslateText(
textToTranslate,
context,
noAppsFound = {
Toast.makeText(
context,
context.getString(R.string.error_no_translator),
Toast.LENGTH_SHORT
).show()
}
)
)
},
onDictionaryRequested = { textToDefine ->
viewModel.onEvent(
ReaderEvent.OnOpenDictionary(
textToDefine,
context,
noAppsFound = {
Toast.makeText(
context,
context.getString(R.string.error_no_browser),
Toast.LENGTH_SHORT
).show()
}
)
)
}
) { toolbarShowed ->
LazyColumn( LazyColumn(
state = listState, state = listState,
modifier = Modifier modifier = Modifier
.fillMaxSize() .then(
.clickable( if (!loading && toolbarShowed) {
interactionSource = null, Modifier
indication = null, .fillMaxSize()
enabled = !loading, .clickable(
onClick = { interactionSource = null,
viewModel.onEvent( indication = null,
ReaderEvent.OnShowHideMenu(context = context) onClick = {
) viewModel.onEvent(
ReaderEvent.OnShowHideMenu(context = context)
)
}
)
} else {
Modifier
.fillMaxSize()
} }
) )
) { ) {
@ -246,28 +294,31 @@ fun ReaderScreen(
itemsIndexed( itemsIndexed(
state.book.text, key = { _, key -> key.id } state.book.text, key = { _, key -> key.id }
) { index, text -> ) { index, text ->
if (index == 0) Column(
Spacer(modifier = Modifier.height(36.dp)) Modifier
.background(Color(backgroundColor.toULong()))
.fillMaxWidth()
.padding(
top = if (index == 0) 36.dp else 0.dp,
start = 18.dp,
end = 18.dp,
bottom = if (index == state.book.text.lastIndex) 36.dp
else (paragraphHeight * 3).dp
)
) {
Text(
text = "${if (paragraphIndentation) " " else ""}${text.line}",
color = Color(fontColor.toULong()),
Text( style = TextStyle(
text = "${if (paragraphIndentation) " " else ""}${text.line}", lineBreak = LineBreak.Paragraph
color = Color(fontColor.toULong()), ),
fontFamily = fontFamily.font,
style = TextStyle( fontStyle = fontStyle,
lineBreak = LineBreak.Paragraph fontSize = fontSize.sp,
), lineHeight = (fontSize + lineHeight).sp
fontFamily = fontFamily.font, )
fontStyle = fontStyle, }
fontSize = fontSize.sp,
lineHeight = (fontSize + lineHeight).sp,
modifier = Modifier.padding(horizontal = 18.dp)
)
if (index == state.book.text.lastIndex)
Spacer(modifier = Modifier.height(36.dp))
else
Spacer(modifier = Modifier.height((paragraphHeight * 3).dp))
} }
if (state.book.text.isNotEmpty()) { if (state.book.text.isNotEmpty()) {
@ -300,6 +351,9 @@ fun ReaderScreen(
actionTitle = stringResource(id = R.string.go_back), actionTitle = stringResource(id = R.string.go_back),
action = { action = {
navigator.navigateBack() navigator.navigateBack()
libraryViewModel.onEvent(LibraryEvent.OnLoadList)
historyViewModel.onEvent(HistoryEvent.OnLoadList)
viewModel.onEvent(ReaderEvent.OnShowSystemBars(context))
} }
) )
} else { } else {

View file

@ -4,11 +4,15 @@ import android.widget.Toast
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsIgnoringVisibility
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@ -26,13 +30,17 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.R import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.model.Category import ua.acclorite.book_story.domain.model.Category
import ua.acclorite.book_story.presentation.Navigator import ua.acclorite.book_story.presentation.data.Navigator
import ua.acclorite.book_story.presentation.Screen import ua.acclorite.book_story.presentation.data.Screen
import ua.acclorite.book_story.presentation.screens.library.data.LibraryEvent import ua.acclorite.book_story.presentation.screens.library.data.LibraryEvent
import ua.acclorite.book_story.presentation.screens.library.data.LibraryViewModel import ua.acclorite.book_story.presentation.screens.library.data.LibraryViewModel
import ua.acclorite.book_story.presentation.screens.reader.data.ReaderEvent import ua.acclorite.book_story.presentation.screens.reader.data.ReaderEvent
import ua.acclorite.book_story.presentation.screens.reader.data.ReaderViewModel import ua.acclorite.book_story.presentation.screens.reader.data.ReaderViewModel
/**
* Reader end item. Displays at the end of the book.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
fun ReaderEndItem( fun ReaderEndItem(
libraryViewModel: LibraryViewModel, libraryViewModel: LibraryViewModel,
@ -46,7 +54,13 @@ fun ReaderEndItem(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(MaterialTheme.colorScheme.surface) .background(MaterialTheme.colorScheme.surface)
.padding(24.dp), .windowInsetsPadding(WindowInsets.navigationBarsIgnoringVisibility)
.padding(
top = 24.dp,
start = 24.dp,
end = 24.dp,
bottom = 8.dp
),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Text( Text(
@ -56,7 +70,14 @@ fun ReaderEndItem(
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(4.dp))
Text(
stringResource(id = R.string.letters_and_words, state.letters, state.words),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(12.dp))
TextButton( TextButton(
onClick = { onClick = {
if (state.book.category != Category.ALREADY_READ) { if (state.book.category != Category.ALREADY_READ) {
@ -86,7 +107,7 @@ fun ReaderEndItem(
context context
) )
) )
navigator.navigate(Screen.LIBRARY) navigator.navigate(Screen.LIBRARY, true)
} }
}, },
contentPadding = PaddingValues(horizontal = 12.dp), contentPadding = PaddingValues(horizontal = 12.dp),

View file

@ -5,14 +5,18 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsIgnoringVisibility
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Image import androidx.compose.material.icons.filled.Image
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@ -24,7 +28,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@ -33,6 +36,10 @@ import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.screens.reader.data.ReaderViewModel import ua.acclorite.book_story.presentation.screens.reader.data.ReaderViewModel
import ua.acclorite.book_story.ui.elevation import ua.acclorite.book_story.ui.elevation
/**
* Reader start item. Displays at the beginning of the book.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
fun ReaderStartItem(viewModel: ReaderViewModel) { fun ReaderStartItem(viewModel: ReaderViewModel) {
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
@ -41,6 +48,7 @@ fun ReaderStartItem(viewModel: ReaderViewModel) {
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(MaterialTheme.colorScheme.surface) .background(MaterialTheme.colorScheme.surface)
.windowInsetsPadding(WindowInsets.statusBarsIgnoringVisibility)
.padding(24.dp) .padding(24.dp)
) { ) {
Row( Row(
@ -66,7 +74,7 @@ fun ReaderStartItem(viewModel: ReaderViewModel) {
if (state.book.coverImage != null) { if (state.book.coverImage != null) {
Image( Image(
bitmap = state.book.coverImage!!.asImageBitmap(), bitmap = state.book.coverImage!!,
contentDescription = "Cover", contentDescription = "Cover",
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()

View file

@ -1,4 +1,4 @@
package ua.acclorite.book_story.presentation.screens.reader.components package ua.acclorite.book_story.presentation.screens.reader.components.app_bar
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@ -19,7 +19,7 @@ import androidx.compose.ui.Alignment
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.unit.dp import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.presentation.Navigator import ua.acclorite.book_story.presentation.data.Navigator
import ua.acclorite.book_story.presentation.data.removeDigits import ua.acclorite.book_story.presentation.data.removeDigits
import ua.acclorite.book_story.presentation.data.removeTrailingZero import ua.acclorite.book_story.presentation.data.removeTrailingZero
import ua.acclorite.book_story.presentation.screens.library.data.LibraryEvent import ua.acclorite.book_story.presentation.screens.library.data.LibraryEvent
@ -27,6 +27,9 @@ import ua.acclorite.book_story.presentation.screens.library.data.LibraryViewMode
import ua.acclorite.book_story.presentation.screens.reader.data.ReaderEvent import ua.acclorite.book_story.presentation.screens.reader.data.ReaderEvent
import ua.acclorite.book_story.presentation.screens.reader.data.ReaderViewModel import ua.acclorite.book_story.presentation.screens.reader.data.ReaderViewModel
/**
* Reader bottom bar. Has a slider to change progress.
*/
@Composable @Composable
fun ReaderBottomBar( fun ReaderBottomBar(
viewModel: ReaderViewModel, viewModel: ReaderViewModel,

View file

@ -1,4 +1,4 @@
package ua.acclorite.book_story.presentation.screens.reader.components package ua.acclorite.book_story.presentation.screens.reader.components.app_bar
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.basicMarquee
@ -6,10 +6,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@ -28,17 +25,20 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import ua.acclorite.book_story.R import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.Argument import ua.acclorite.book_story.presentation.components.GoBackButton
import ua.acclorite.book_story.presentation.Navigator import ua.acclorite.book_story.presentation.data.Argument
import ua.acclorite.book_story.presentation.Screen import ua.acclorite.book_story.presentation.data.Navigator
import ua.acclorite.book_story.presentation.data.Screen
import ua.acclorite.book_story.presentation.data.removeDigits import ua.acclorite.book_story.presentation.data.removeDigits
import ua.acclorite.book_story.presentation.data.removeTrailingZero import ua.acclorite.book_story.presentation.data.removeTrailingZero
import ua.acclorite.book_story.presentation.screens.reader.data.ReaderEvent import ua.acclorite.book_story.presentation.screens.reader.data.ReaderEvent
import ua.acclorite.book_story.presentation.screens.reader.data.ReaderViewModel import ua.acclorite.book_story.presentation.screens.reader.data.ReaderViewModel
/**
* Reader top bar. Displays title of the book.
*/
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ReaderTopBar(viewModel: ReaderViewModel, navigator: Navigator, containerColor: Color) { fun ReaderTopBar(viewModel: ReaderViewModel, navigator: Navigator, containerColor: Color) {
@ -48,15 +48,8 @@ fun ReaderTopBar(viewModel: ReaderViewModel, navigator: Navigator, containerColo
TopAppBar( TopAppBar(
navigationIcon = { navigationIcon = {
IconButton(onClick = { GoBackButton(navigator = navigator) {
navigator.navigateBack()
viewModel.onEvent(ReaderEvent.OnShowSystemBars(context)) viewModel.onEvent(ReaderEvent.OnShowSystemBars(context))
}) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Go back",
tint = MaterialTheme.colorScheme.onSurface
)
} }
}, },
title = { title = {
@ -75,6 +68,7 @@ fun ReaderTopBar(viewModel: ReaderViewModel, navigator: Navigator, containerColo
onClick = { onClick = {
navigator.navigateWithoutBackStack( navigator.navigateWithoutBackStack(
Screen.BOOK_INFO, Screen.BOOK_INFO,
true,
Argument( Argument(
"book", "book",
book book
@ -86,7 +80,6 @@ fun ReaderTopBar(viewModel: ReaderViewModel, navigator: Navigator, containerColo
delayMillis = 4000 delayMillis = 4000
) )
) )
Spacer(modifier = Modifier.height(4.dp))
Text( Text(
stringResource( stringResource(
id = R.string.read_query, id = R.string.read_query,
@ -107,8 +100,7 @@ fun ReaderTopBar(viewModel: ReaderViewModel, navigator: Navigator, containerColo
}) { }) {
Icon( Icon(
imageVector = Icons.Default.Settings, imageVector = Icons.Default.Settings,
contentDescription = "Open Settings", contentDescription = stringResource(id = R.string.open_reader_settings_content_desc),
tint = MaterialTheme.colorScheme.onSurface
) )
} }
}, },

View file

@ -1,4 +1,4 @@
package ua.acclorite.book_story.presentation.screens.reader.components package ua.acclorite.book_story.presentation.screens.reader.components.settings_bottom_sheet
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
@ -48,6 +48,9 @@ import ua.acclorite.book_story.ui.ElevationDefaults
import ua.acclorite.book_story.ui.elevation import ua.acclorite.book_story.ui.elevation
import ua.acclorite.book_story.util.Constants import ua.acclorite.book_story.util.Constants
/**
* Settings bottom sheet. Has General and Colors categories.
*/
@OptIn( @OptIn(
ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class
) )
@ -118,7 +121,7 @@ fun ReaderSettingsBottomSheet(mainViewModel: MainViewModel, viewModel: ReaderVie
} }
item { item {
CategoryTitle( CategoryTitle(
title = stringResource(id = R.string.font_settings), title = stringResource(id = R.string.font_reader_settings),
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface
) )
} }
@ -197,7 +200,7 @@ fun ReaderSettingsBottomSheet(mainViewModel: MainViewModel, viewModel: ReaderVie
} }
item { item {
CategoryTitle( CategoryTitle(
title = stringResource(id = R.string.text_settings), title = stringResource(id = R.string.text_reader_settings),
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface
) )
} }

View file

@ -1,4 +1,4 @@
package ua.acclorite.book_story.presentation.screens.reader.components package ua.acclorite.book_story.presentation.screens.reader.components.settings_bottom_sheet
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@ -21,6 +21,9 @@ import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.screens.reader.data.ReaderEvent import ua.acclorite.book_story.presentation.screens.reader.data.ReaderEvent
import ua.acclorite.book_story.presentation.screens.reader.data.ReaderViewModel import ua.acclorite.book_story.presentation.screens.reader.data.ReaderViewModel
/**
* Settings bottom sheet tab row. It is used to switch between categories.
*/
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun ReaderSettingsBottomSheetTabRow(viewModel: ReaderViewModel, pagerState: PagerState) { fun ReaderSettingsBottomSheetTabRow(viewModel: ReaderViewModel, pagerState: PagerState) {

View file

@ -7,10 +7,10 @@ import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.PagerState
import ua.acclorite.book_story.domain.model.Book import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.presentation.Navigator import ua.acclorite.book_story.presentation.data.Navigator
sealed class ReaderEvent { sealed class ReaderEvent {
data class OnFileNotFound(val onLoaded: () -> Unit) : ReaderEvent() data class OnTextIsEmpty(val onLoaded: () -> Unit) : ReaderEvent()
data class OnLoadText( data class OnLoadText(
val scrollState: LazyListState, val scrollState: LazyListState,
val navigator: Navigator, val navigator: Navigator,
@ -37,4 +37,16 @@ sealed class ReaderEvent {
val updatePage: (Int) -> Unit, val updatePage: (Int) -> Unit,
val navigator: Navigator val navigator: Navigator
) : ReaderEvent() ) : ReaderEvent()
data class OnTranslateText(
val textToTranslate: String,
val context: ComponentActivity,
val noAppsFound: () -> Unit
) : ReaderEvent()
data class OnOpenDictionary(
val textToDefine: String,
val context: ComponentActivity,
val noAppsFound: () -> Unit
) : ReaderEvent()
} }

View file

@ -7,6 +7,9 @@ import ua.acclorite.book_story.util.UIText
@Immutable @Immutable
data class ReaderState( data class ReaderState(
val book: Book, val book: Book,
val letters: Int = 0,
val words: Int = 0,
val errorMessage: UIText? = null, val errorMessage: UIText? = null,
val showMenu: Boolean = false, val showMenu: Boolean = false,

View file

@ -1,5 +1,7 @@
package ua.acclorite.book_story.presentation.screens.reader.data package ua.acclorite.book_story.presentation.screens.reader.data
import android.app.SearchManager
import android.content.Intent
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
@ -24,13 +26,11 @@ import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.model.Book import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.model.Category import ua.acclorite.book_story.domain.model.Category
import ua.acclorite.book_story.domain.model.History import ua.acclorite.book_story.domain.model.History
import ua.acclorite.book_story.domain.use_case.GetText
import ua.acclorite.book_story.domain.use_case.InsertHistory import ua.acclorite.book_story.domain.use_case.InsertHistory
import ua.acclorite.book_story.domain.use_case.UpdateBooks import ua.acclorite.book_story.domain.use_case.UpdateBooks
import ua.acclorite.book_story.presentation.Argument import ua.acclorite.book_story.presentation.data.Argument
import ua.acclorite.book_story.presentation.Navigator import ua.acclorite.book_story.presentation.data.Navigator
import ua.acclorite.book_story.presentation.Screen import ua.acclorite.book_story.presentation.data.Screen
import ua.acclorite.book_story.util.Resource
import ua.acclorite.book_story.util.UIText import ua.acclorite.book_story.util.UIText
import java.util.Date import java.util.Date
import kotlin.math.roundToInt import kotlin.math.roundToInt
@ -39,7 +39,6 @@ import kotlin.math.roundToInt
@HiltViewModel(assistedFactory = ReaderViewModel.Factory::class) @HiltViewModel(assistedFactory = ReaderViewModel.Factory::class)
class ReaderViewModel @AssistedInject constructor( class ReaderViewModel @AssistedInject constructor(
@Assisted book: Book, @Assisted book: Book,
private val getText: GetText,
private val updateBooks: UpdateBooks, private val updateBooks: UpdateBooks,
private val insertHistory: InsertHistory private val insertHistory: InsertHistory
) : ViewModel() { ) : ViewModel() {
@ -49,61 +48,29 @@ class ReaderViewModel @AssistedInject constructor(
fun onEvent(event: ReaderEvent) { fun onEvent(event: ReaderEvent) {
when (event) { when (event) {
is ReaderEvent.OnFileNotFound -> { is ReaderEvent.OnTextIsEmpty -> {
_state.update { _state.update {
event.onLoaded() event.onLoaded()
it.copy( it.copy(
errorMessage = UIText.StringResource(R.string.error_file_not_found), errorMessage = UIText.StringResource(R.string.error_no_text),
) )
} }
} }
is ReaderEvent.OnLoadText -> { is ReaderEvent.OnLoadText -> {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
if (_state.value.book.file == null) {
return@launch
}
if (_state.value.book.text.isEmpty()) {
getText.execute(_state.value.book.file!!).collect { result ->
when (result) {
is Resource.Loading -> Unit
is Resource.Success -> {
if (result.data?.isEmpty() != false) {
_state.update {
it.copy(
errorMessage = UIText.StringResource(
R.string.error_something_went_wrong
),
)
}
event.onLoaded()
return@collect
}
_state.update {
it.copy(
book = it.book.copy(
text = result.data,
)
)
}
}
is Resource.Error -> Unit
}
}
}
val time = Date().time val time = Date().time
val text = _state.value.book.text.joinToString(separator = " ") { it.line }
val letters = text.replace(" ", "").length
val words = text.split("\\s+".toRegex()).size
_state.update { _state.update {
it.copy( it.copy(
book = it.book.copy( book = it.book.copy(
lastOpened = time lastOpened = time
) ),
letters = letters,
words = words
) )
} }
@ -114,7 +81,7 @@ class ReaderViewModel @AssistedInject constructor(
listOf( listOf(
History( History(
null, null,
_state.value.book, _state.value.book.id!!,
time time
) )
) )
@ -141,7 +108,7 @@ class ReaderViewModel @AssistedInject constructor(
} }
} }
delay(500) delay(100)
event.onLoaded() event.onLoaded()
return@collectLatest return@collectLatest
} }
@ -234,7 +201,8 @@ class ReaderViewModel @AssistedInject constructor(
onEvent(ReaderEvent.OnShowSystemBars(event.context)) onEvent(ReaderEvent.OnShowSystemBars(event.context))
val book = _state.value.book.copy( val book = _state.value.book.copy(
category = Category.ALREADY_READ category = Category.ALREADY_READ,
progress = 1f
) )
updateBooks.execute(listOf(book)) updateBooks.execute(listOf(book))
@ -245,7 +213,59 @@ class ReaderViewModel @AssistedInject constructor(
it != Category.ALREADY_READ it != Category.ALREADY_READ
}.size - 1 }.size - 1
) )
event.navigator.navigate(Screen.LIBRARY) event.navigator.navigate(Screen.LIBRARY, true)
}
}
is ReaderEvent.OnTranslateText -> {
viewModelScope.launch {
val translatorIntent = Intent()
val browserIntent = Intent()
translatorIntent.type = "text/plain"
translatorIntent.action = Intent.ACTION_PROCESS_TEXT
browserIntent.action = Intent.ACTION_WEB_SEARCH
translatorIntent.putExtra(Intent.EXTRA_PROCESS_TEXT, event.textToTranslate)
translatorIntent.putExtra(Intent.EXTRA_PROCESS_TEXT_READONLY, true)
browserIntent.putExtra(
SearchManager.QUERY,
"translate: ${event.textToTranslate.trim()}"
)
if (translatorIntent.resolveActivity(event.context.packageManager) != null) {
event.context.startActivity(translatorIntent)
return@launch
}
if (browserIntent.resolveActivity(event.context.packageManager) != null) {
event.context.startActivity(browserIntent)
return@launch
}
event.noAppsFound()
}
}
is ReaderEvent.OnOpenDictionary -> {
viewModelScope.launch {
val browserIntent = Intent()
browserIntent.action = Intent.ACTION_WEB_SEARCH
browserIntent.putExtra(
SearchManager.QUERY,
"dictionary" +
": ${event.textToDefine.trim()}"
)
if (browserIntent.resolveActivity(event.context.packageManager) != null) {
event.context.startActivity(browserIntent)
return@launch
}
event.noAppsFound()
} }
} }
} }
@ -270,8 +290,8 @@ class ReaderViewModel @AssistedInject constructor(
ReaderState(book = book) ReaderState(book = book)
} }
if (book.file == null) { if (book.text.isEmpty()) {
onEvent(ReaderEvent.OnFileNotFound(onLoaded = { onLoaded() })) onEvent(ReaderEvent.OnTextIsEmpty(onLoaded = { onLoaded() }))
} else { } else {
onEvent(ReaderEvent.OnShowHideMenu(false, context)) onEvent(ReaderEvent.OnShowHideMenu(false, context))
onEvent( onEvent(
@ -304,3 +324,5 @@ class ReaderViewModel @AssistedInject constructor(

View file

@ -6,13 +6,13 @@ import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.MenuBook
import androidx.compose.material.icons.filled.DisplaySettings import androidx.compose.material.icons.filled.DisplaySettings
import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.Palette
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
@ -21,19 +21,24 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import ua.acclorite.book_story.R import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.Navigator import ua.acclorite.book_story.presentation.components.GoBackButton
import ua.acclorite.book_story.presentation.Screen import ua.acclorite.book_story.presentation.data.Navigator
import ua.acclorite.book_story.presentation.components.AnimatedTopAppBar import ua.acclorite.book_story.presentation.data.Screen
import ua.acclorite.book_story.presentation.screens.settings.components.SettingsCategoryItem import ua.acclorite.book_story.presentation.screens.settings.components.SettingsCategoryItem
import ua.acclorite.book_story.ui.elevation
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
navigator: Navigator navigator: Navigator
) { ) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val listState = rememberLazyListState()
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(
canScroll = {
listState.canScrollForward
}
)
Scaffold( Scaffold(
Modifier Modifier
@ -42,36 +47,26 @@ fun SettingsScreen(
.windowInsetsPadding(WindowInsets.navigationBars), .windowInsetsPadding(WindowInsets.navigationBars),
containerColor = MaterialTheme.colorScheme.surface, containerColor = MaterialTheme.colorScheme.surface,
topBar = { topBar = {
AnimatedTopAppBar( LargeTopAppBar(
title = {
Text(stringResource(id = R.string.settings_screen))
},
navigationIcon = {
GoBackButton(navigator = navigator)
},
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
isTopBarScrolled = null, colors = TopAppBarDefaults.largeTopAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
content1NavigationIcon = { scrolledContainerColor = MaterialTheme.elevation()
IconButton(onClick = { navigator.navigateBack() }) { )
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Go back",
tint = MaterialTheme.colorScheme.onSurface
)
}
},
content1Title = {
Text(
stringResource(id = R.string.settings_screen),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
overflow = TextOverflow.Ellipsis,
maxLines = 1
)
},
content1Actions = {}
) )
} }
) { paddingValues -> ) { paddingValues ->
LazyColumn( LazyColumn(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.padding(top = paddingValues.calculateTopPadding()) .padding(top = paddingValues.calculateTopPadding()),
state = listState
) { ) {
item { item {
SettingsCategoryItem( SettingsCategoryItem(
@ -79,7 +74,7 @@ fun SettingsScreen(
text = stringResource(id = R.string.general_settings), text = stringResource(id = R.string.general_settings),
description = stringResource(id = R.string.general_settings_desc) description = stringResource(id = R.string.general_settings_desc)
) { ) {
navigator.navigate(Screen.GENERAL_SETTINGS) navigator.navigate(Screen.GENERAL_SETTINGS, false)
} }
} }
@ -89,11 +84,19 @@ fun SettingsScreen(
text = stringResource(id = R.string.appearance_settings), text = stringResource(id = R.string.appearance_settings),
description = stringResource(id = R.string.appearance_settings_desc) description = stringResource(id = R.string.appearance_settings_desc)
) { ) {
navigator.navigate(Screen.APPEARANCE_SETTINGS) navigator.navigate(Screen.APPEARANCE_SETTINGS, false)
} }
} }
//todo Reader settings item {
SettingsCategoryItem(
icon = Icons.AutoMirrored.Filled.MenuBook,
text = stringResource(id = R.string.reader_settings),
description = stringResource(id = R.string.reader_settings_desc)
) {
navigator.navigate(Screen.READER_SETTINGS, false)
}
}
} }
} }
} }

View file

@ -14,6 +14,9 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.presentation.components.CategoryTitle import ua.acclorite.book_story.presentation.components.CategoryTitle
/**
* Checkbox with title.
*/
@Composable @Composable
fun CheckboxWithTitle( fun CheckboxWithTitle(
selected: Boolean, selected: Boolean,

View file

@ -22,6 +22,9 @@ import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.domain.model.ChipItem import ua.acclorite.book_story.domain.model.ChipItem
import ua.acclorite.book_story.presentation.components.CategoryTitle import ua.acclorite.book_story.presentation.components.CategoryTitle
/**
* Chips with title. Use list of [ChipItem]s to display chips.
*/
@OptIn(ExperimentalMaterialApi::class, ExperimentalLayoutApi::class) @OptIn(ExperimentalMaterialApi::class, ExperimentalLayoutApi::class)
@Composable @Composable
fun ChipsWithTitle( fun ChipsWithTitle(

View file

@ -28,6 +28,9 @@ import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.R import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.components.CategoryTitle import ua.acclorite.book_story.presentation.components.CategoryTitle
/**
* Color picker with title.
*/
@Composable @Composable
fun ColorPickerWithTitle( fun ColorPickerWithTitle(
value: Color, value: Color,

View file

@ -17,10 +17,14 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
/**
* Settings category item. It is used to represent the whole category.
*/
@Composable @Composable
fun SettingsCategoryItem( fun SettingsCategoryItem(
icon: ImageVector, icon: ImageVector,
@ -53,13 +57,17 @@ fun SettingsCategoryItem(
text, text,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
fontSize = 18.sp fontSize = 18.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
) )
Spacer(modifier = Modifier.height(2.dp)) Spacer(modifier = Modifier.height(2.dp))
Text( Text(
description, description,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
) )
} }
} }

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