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

@ -7,6 +7,21 @@
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
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
android:name=".util.BookApplication"
android:allowBackup="true"
@ -19,7 +34,7 @@
tools:targetApi="tiramisu">
<activity
android:name=".MainActivity"
android:name=".Activity"
android:configChanges="screenSize|screenLayout|smallestScreenSize|locale|density|uiMode"
android:windowSoftInputMode="adjustResize"
android:exported="true"
@ -30,15 +45,6 @@
</intent-filter>
</activity>
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
android:enabled="false"
android:exported="false">
<meta-data
android:name="autoStoreLocales"
android:value="true" />
</service>
</application>
</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
import android.annotation.SuppressLint
import android.content.res.Configuration
import android.database.CursorWindow
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
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.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat
import dagger.hilt.android.AndroidEntryPoint
import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.presentation.NavigationHost
import ua.acclorite.book_story.presentation.Screen
import ua.acclorite.book_story.presentation.components.BottomNavigationBar
import ua.acclorite.book_story.presentation.components.bottom_navigation_bar.BottomNavigationBar
import ua.acclorite.book_story.presentation.components.custom_navigation_rail.CustomNavigationRail
import ua.acclorite.book_story.presentation.data.MainEvent
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.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.data.HistoryViewModel
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.settings.SettingsScreen
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.reader.ReaderSettings
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.isDark
import java.lang.reflect.Field
@SuppressLint("DiscouragedPrivateApi")
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
class Activity : ComponentActivity() {
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?) {
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 {
setKeepOnScreenCondition {
!mainViewModel.isReady.value
}
}
// Edge to edge
enableEdgeToEdge()
WindowCompat.setDecorFitsSystemWindows(window, false)
setContent {
val windowClass = calculateWindowSizeClass(activity = this)
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(
theme = theme,
@ -70,28 +107,36 @@ class MainActivity : AppCompatActivity() {
if (!updating) {
NavigationHost(startScreen = Screen.LIBRARY) {
val currentScreen by this.getCurrentScreen().collectAsState()
AnimatedVisibility(
visible = currentScreen == Screen.LIBRARY ||
currentScreen == Screen.HISTORY ||
currentScreen == Screen.BROWSE,
enter = fadeIn(tween(200)) +
slideInHorizontally(tween(200)) { it / 15 },
enter = Transitions.BackSlidingTransitionIn,
exit = Transitions.SlidingTransitionOut
) {
Scaffold(
bottomBar = {
BottomNavigationBar(navigator = this@NavigationHost)
if (!tabletUI) {
BottomNavigationBar(navigator = this@NavigationHost)
}
},
containerColor = MaterialTheme.colorScheme.surface
) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(bottom = it.calculateBottomPadding())
.padding(
start = if (tabletUI) 80.dp else 0.dp,
bottom = it.calculateBottomPadding()
)
) {
composable(screen = Screen.LIBRARY) {
@Suppress("UNCHECKED_CAST")
LibraryScreen(
viewModel = libraryViewModel,
historyViewModel = historyViewModel,
browseViewModel = browseViewModel,
navigator = this@NavigationHost,
addedBooks = retrieveArgument("added_books") as? List<Book>
?: emptyList()
@ -99,13 +144,25 @@ class MainActivity : AppCompatActivity() {
}
composable(screen = Screen.HISTORY) {
HistoryScreen(navigator = this@NavigationHost)
HistoryScreen(
viewModel = historyViewModel,
libraryViewModel = libraryViewModel,
navigator = this@NavigationHost
)
}
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
) {
BookInfoScreen(
libraryViewModel = libraryViewModel,
browseViewModel = browseViewModel,
historyViewModel = historyViewModel,
navigator = this@NavigationHost
)
}
@ -125,6 +185,9 @@ class MainActivity : AppCompatActivity() {
exitAnim = Transitions.SlidingTransitionOut
) {
ReaderScreen(
mainViewModel = mainViewModel,
libraryViewModel = libraryViewModel,
historyViewModel = historyViewModel,
navigator = this@NavigationHost
)
}
@ -147,6 +210,7 @@ class MainActivity : AppCompatActivity() {
exitAnim = Transitions.SlidingTransitionOut
) {
GeneralSettings(
mainViewModel = mainViewModel,
navigator = this@NavigationHost
)
}
@ -156,9 +220,25 @@ class MainActivity : AppCompatActivity() {
exitAnim = Transitions.SlidingTransitionOut
) {
AppearanceSettings(
mainViewModel = mainViewModel,
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 {
Box(

View file

@ -6,10 +6,10 @@ import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
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.mapper.BookMapper
import ua.acclorite.book_story.data.mapper.BookMapperImpl
import ua.acclorite.book_story.data.mapper.HistoryMapper
import ua.acclorite.book_story.data.mapper.HistoryMapperImpl
import ua.acclorite.book_story.data.mapper.book.BookMapper
import ua.acclorite.book_story.data.mapper.book.BookMapperImpl
import ua.acclorite.book_story.data.mapper.history.HistoryMapper
import ua.acclorite.book_story.data.mapper.history.HistoryMapperImpl
import ua.acclorite.book_story.data.repository.BookRepositoryImpl
import ua.acclorite.book_story.domain.repository.BookRepository
import javax.inject.Singleton

View file

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

View file

@ -19,8 +19,7 @@ interface BookDao {
@Query(
"""
SELECT *
FROM bookentity
SELECT * FROM bookentity
WHERE LOWER(title) LIKE '%' || LOWER(:query) || '%'
"""
)
@ -31,6 +30,9 @@ interface BookDao {
)
suspend fun findBookById(id: Int): BookEntity
@Query("SELECT * FROM bookentity WHERE id IN (:ids)")
suspend fun findBooksById(ids: List<Int>): List<BookEntity>
@Delete
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.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.domain.model.Book
import ua.acclorite.book_story.domain.model.History
interface HistoryMapper {
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.domain.model.Book
import ua.acclorite.book_story.domain.model.History
import javax.inject.Inject
@ -9,15 +8,15 @@ class HistoryMapperImpl @Inject constructor() : HistoryMapper {
override suspend fun toHistoryEntity(history: History): HistoryEntity {
return HistoryEntity(
id = history.id,
bookId = history.book.id ?: 0,
bookId = history.bookId,
time = history.time
)
}
override suspend fun toHistory(historyEntity: HistoryEntity, book: Book): History {
override suspend fun toHistory(historyEntity: HistoryEntity): History {
return History(
historyEntity.id,
book = book,
bookId = historyEntity.bookId,
time = historyEntity.time
)
}

View file

@ -1,8 +1,9 @@
package ua.acclorite.book_story.data.parser
import ua.acclorite.book_story.domain.model.StringWithId
import ua.acclorite.book_story.util.Resource
import java.io.File
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 androidx.compose.ui.graphics.asImageBitmap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
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.Category
import ua.acclorite.book_story.util.UIText
@ -51,7 +53,7 @@ class EpubFileParser @Inject constructor() : FileParser {
filePath = file.path,
lastOpened = null,
category = Category.entries[0],
coverImage = coverImage
coverImage = coverImage?.asImageBitmap()
)
} catch (e: Exception) {
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.withContext
import nl.siegmann.epublib.epub.EpubReader
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.util.Resource
import ua.acclorite.book_story.util.UIText
import java.io.BufferedReader
import java.io.File
import java.io.FileInputStream
@ -14,9 +18,9 @@ import javax.inject.Inject
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")) {
return emptyList()
return Resource.Error(UIText.StringResource(R.string.error_wrong_file_format))
}
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) {
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 com.tom_roush.pdfbox.android.PDFBoxResourceLoader
import com.tom_roush.pdfbox.pdmodel.PDDocument
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.Category
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 com.tom_roush.pdfbox.android.PDFBoxResourceLoader
import com.tom_roush.pdfbox.pdmodel.PDDocument
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.util.Resource
import ua.acclorite.book_story.util.UIText
import java.io.File
import javax.inject.Inject
class PdfTextParser @Inject constructor(private val application: Application) : TextParser {
override suspend fun parse(file: File): List<StringWithId> {
override suspend fun parse(file: File): Resource<List<StringWithId>> {
if (!file.name.endsWith(".pdf")) {
return emptyList()
return Resource.Error(UIText.StringResource(R.string.error_wrong_file_format))
}
try {
@ -24,6 +28,11 @@ class PdfTextParser @Inject constructor(private val application: Application) :
val pdfStripper = PDFTextStripper()
pdfStripper.paragraphStart = "_new_line_"
if (document.isEncrypted) {
document.close()
return Resource.Error(UIText.StringResource(R.string.error_file_encrypted))
}
val oldText = pdfStripper.getText(document)
.replace("\r", "")
@ -38,7 +47,7 @@ class PdfTextParser @Inject constructor(private val application: Application) :
}
val unformattedLines = text.split("${pdfStripper.paragraphStart}|\\n".toRegex())
.filter { it.isNotEmpty() }
.filter { it.isNotBlank() }
val lines = mutableListOf<String>()
unformattedLines.forEachIndexed { index, string ->
@ -52,10 +61,18 @@ class PdfTextParser @Inject constructor(private val application: Application) :
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) {
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.data.parser.FileParser
import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.model.Category
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 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.dto.BookEntity
import ua.acclorite.book_story.data.local.room.BookDao
import ua.acclorite.book_story.data.mapper.BookMapper
import ua.acclorite.book_story.data.mapper.HistoryMapper
import ua.acclorite.book_story.data.parser.EpubFileParser
import ua.acclorite.book_story.data.parser.EpubTextParser
import ua.acclorite.book_story.data.parser.PdfFileParser
import ua.acclorite.book_story.data.parser.PdfTextParser
import ua.acclorite.book_story.data.parser.TxtFileParser
import ua.acclorite.book_story.data.parser.TxtTextParser
import ua.acclorite.book_story.data.mapper.book.BookMapper
import ua.acclorite.book_story.data.mapper.history.HistoryMapper
import ua.acclorite.book_story.data.parser.epub.EpubFileParser
import ua.acclorite.book_story.data.parser.epub.EpubTextParser
import ua.acclorite.book_story.data.parser.pdf.PdfFileParser
import ua.acclorite.book_story.data.parser.pdf.PdfTextParser
import ua.acclorite.book_story.data.parser.txt.TxtFileParser
import ua.acclorite.book_story.data.parser.txt.TxtTextParser
import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.domain.model.History
import ua.acclorite.book_story.domain.model.NullableBook
import ua.acclorite.book_story.domain.model.StringWithId
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.Resource
import ua.acclorite.book_story.util.UIText
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
@ -49,9 +52,20 @@ class BookRepositoryImpl @Inject constructor(
emit(Resource.Loading(true))
val books = database.searchBooks(query)
val history = database.getHistory()
emit(
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> {
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(
@ -154,7 +182,10 @@ class BookRepositoryImpl @Inject constructor(
filteredFiles = if (query.trim().isNotEmpty()) {
filteredFiles.sortedByDescending {
it.name.lowercase().startsWith(query.trim().lowercase())
calculateFamiliarity(
query,
it.name.lowercase().trim()
)
}.toMutableList()
} else {
filteredFiles.sortedByDescending {
@ -182,61 +213,113 @@ class BookRepositoryImpl @Inject constructor(
} else if (file.name.endsWith(".epub")) {
epubTextParser.parse(file)
} else {
emit(Resource.Error(UIText.StringResource(R.string.error_wrong_file_format)))
return@flow
}
emit(Resource.Success(text))
}
}
override suspend fun getBooksFromFiles(files: List<File>): Flow<Resource<List<NullableBook>>> {
return flow {
emit(Resource.Loading(true))
val books = mutableListOf<NullableBook>()
for (file in files) {
val parsedBook = if (file.name.endsWith(".txt")) {
txtFileParser.parse(file)
} else if (file.name.endsWith(".pdf")) {
pdfFileParser.parse(file)
} else if (file.name.endsWith(".epub")) {
epubFileParser.parse(file)
} else {
books.add(NullableBook.Null(file.name))
continue
}
val parsedText = if (file.name.endsWith(".txt")) {
txtTextParser.parse(file)
} else if (file.name.endsWith(".pdf")) {
pdfTextParser.parse(file)
} else if (file.name.endsWith(".epub")) {
epubTextParser.parse(file)
} else {
books.add(NullableBook.Null(file.name))
continue
}
if (parsedBook == null || parsedText.isEmpty()) {
books.add(NullableBook.Null(file.name))
continue
}
books.add(
NullableBook.NotNull(
Pair(
parsedBook.copy(text = parsedText),
true
)
if (text is Resource.Success && text.data == null) {
emit(
Resource.Error(
text.message
?: UIText.StringResource(R.string.error_file_empty)
)
)
}
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>) {
database.insertHistory(history.map { historyMapper.toHistoryEntity(it) })
}
@ -246,11 +329,13 @@ class BookRepositoryImpl @Inject constructor(
emit(Resource.Loading(true))
val history = database.getHistory()
emit(
Resource.Success(
data = history.map {
val book = database.findBookById(it.bookId)
historyMapper.toHistory(it, bookMapper.toBook(book))
historyMapper.toHistory(
it
)
}
)
)

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,17 @@
package ua.acclorite.book_story.domain.model
sealed class NullableBook(val book: Pair<Book, Boolean>?, val fileName: String?) {
class NotNull(book: Pair<Book, Boolean>) : NullableBook(book, null)
class Null(fileName: String) : NullableBook(null, fileName)
}
import androidx.compose.runtime.Immutable
import ua.acclorite.book_story.util.UIText
@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
): List<Book>
suspend fun getBooksById(
ids: List<Int>
): List<Book>
suspend fun findBook(
id: Int
): BookEntity
@ -50,7 +54,7 @@ interface BookRepository {
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>)

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
import kotlinx.coroutines.flow.Flow
import ua.acclorite.book_story.domain.model.NullableBook
import ua.acclorite.book_story.domain.repository.BookRepository
import ua.acclorite.book_story.util.Resource
import java.io.File
import javax.inject.Inject
@ -11,7 +9,7 @@ class GetBooksFromFiles @Inject constructor(
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)
}
}

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.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)
@Composable
fun AnimatedTopAppBar(
@ -50,6 +58,7 @@ fun AnimatedTopAppBar(
content3Title: @Composable () -> Unit = {},
content3Actions: @Composable RowScope.() -> Unit = {}
) {
//todo fix lags.
Box(modifier = Modifier.fillMaxWidth()) {
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.sp
/**
* Category Title. Used to name a category.
*/
@Composable
fun CategoryTitle(
modifier: Modifier = Modifier,

View file

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

View file

@ -11,6 +11,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
/**
* Custom Dropdown Menu Item. Used in Dropdown.
*/
@Composable
fun CustomDropDownMenuItem(
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.dp
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.data.Navigator
import ua.acclorite.book_story.presentation.data.Screen
/**
* More drop down. Navigates to Settings, About and Help screens.
*/
@Composable
fun MoreDropDown(navigator: Navigator) {
var showDropDown by remember { mutableStateOf(false) }
@ -58,7 +61,7 @@ fun MoreDropDown(navigator: Navigator) {
)
},
onClick = {
navigator.navigate(Screen.SETTINGS)
navigator.navigate(Screen.SETTINGS, false)
showDropDown = false
},
contentPadding = PaddingValues(start = startPadding, end = endPadding)
@ -72,7 +75,7 @@ fun MoreDropDown(navigator: Navigator) {
)
},
onClick = {
navigator.navigate(Screen.HELP)
navigator.navigate(Screen.HELP, false)
showDropDown = false
},
contentPadding = PaddingValues(start = startPadding, end = endPadding)
@ -86,7 +89,7 @@ fun MoreDropDown(navigator: Navigator) {
)
},
onClick = {
navigator.navigate(Screen.ABOUT)
navigator.navigate(Screen.ABOUT, false)
showDropDown = false
},
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.NavigationBar
@ -11,9 +11,9 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.model.NavigationBarItem
import ua.acclorite.book_story.presentation.Navigator
import ua.acclorite.book_story.presentation.Screen
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
/**
@ -37,34 +37,34 @@ fun BottomNavigationBar(
containerColor = MaterialTheme.elevation()
) {
BottomNavigationBarItem(
item = NavigationBarItem(
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)
navigator.navigate(Screen.LIBRARY, false)
}
BottomNavigationBarItem(
item = NavigationBarItem(
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)
navigator.navigate(Screen.HISTORY, false)
}
BottomNavigationBarItem(
item = NavigationBarItem(
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)
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.size
@ -11,7 +11,7 @@ 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.NavigationBarItem
import ua.acclorite.book_story.domain.model.NavigationItem
/**
* Bottom Navigation Bar Item, uses default [NavigationBarItem].
@ -19,7 +19,7 @@ import ua.acclorite.book_story.domain.model.NavigationBarItem
@Composable
fun RowScope.BottomNavigationBarItem(
modifier: Modifier = Modifier,
item: NavigationBarItem,
item: NavigationItem,
isSelected: Boolean,
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.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.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)
@Composable
fun CustomDialogWithContent(
@ -54,7 +71,7 @@ fun CustomDialogWithContent(
) {
AlertDialog(
onDismissRequest = { onDismiss() },
properties = properties,
properties = properties
) {
(LocalView.current.parent as DialogWindowProvider).window.setDimAmount(
backgroundTransparency
@ -118,8 +135,8 @@ fun CustomDialogWithContent(
if (withDivider) {
Spacer(modifier = Modifier.height(12.dp))
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))
} 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.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.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)
@Composable
fun CustomDialogWithLazyColumn(
@ -55,7 +73,7 @@ fun CustomDialogWithLazyColumn(
) {
AlertDialog(
onDismissRequest = { onDismiss() },
properties = properties,
properties = properties
) {
(LocalView.current.parent as DialogWindowProvider).window.setDimAmount(
backgroundTransparency
@ -119,8 +137,8 @@ fun CustomDialogWithLazyColumn(
if (withDivider) {
Spacer(modifier = Modifier.height(12.dp))
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))
} 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.layout.Row
@ -17,6 +17,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
/**
* Selectable dialog item. Perfectly combines with [CustomDialogWithLazyColumn].
*/
@Composable
fun SelectableDialogItem(selected: Boolean, title: String, onClick: () -> Unit) {
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.Column
@ -20,6 +20,16 @@ import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.text.style.TextAlign
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
fun IsEmpty(
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.Column
@ -20,6 +20,15 @@ import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.text.style.TextAlign
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
fun IsError(
modifier: Modifier = Modifier,

View file

@ -1,5 +1,9 @@
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 {
if (!this.contains('.'))
return this
@ -8,4 +12,19 @@ fun String.removeTrailingZero(): String {
.dropLastWhile { it == '.' }
}
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.GetDatastore
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.Theme
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(
language, theme, darkTheme, showStartScreen, backgroundColor,
fontColor, fontFamily, isItalic, fontSize, lineHeight,
paragraphHeight, paragraphIndentation
language,
theme,
darkTheme,
showStartScreen,
backgroundColor,
fontColor,
fontFamily,
isItalic,
fontSize,
lineHeight,
paragraphHeight,
paragraphIndentation
) { values ->
values.all { it != null }
}
val isReady = combine(
isViewModelsReady,
isDataReady
) { values ->
values.all { it }
}
viewModelScope.launch {
isDataReady.collect { bool ->
isReady.collect { 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 androidx.activity.ComponentActivity
@ -33,6 +33,7 @@ import ua.acclorite.book_story.ui.Transitions
private const val CURRENT_SCREEN = "current_screen"
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.
@ -48,9 +49,11 @@ enum class Screen {
SETTINGS,
GENERAL_SETTINGS,
APPEARANCE_SETTINGS,
READER_SETTINGS,
ABOUT,
HELP
HELP,
START
}
data class Argument(
@ -72,6 +75,7 @@ class Navigator @AssistedInject constructor(
) : ViewModel() {
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 arguments = mutableStateListOf<Argument>()
@ -104,7 +108,7 @@ class Navigator @AssistedInject constructor(
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) {
backStack.value.add(currentScreen.value)
@ -112,10 +116,11 @@ class Navigator @AssistedInject constructor(
putArgument(it)
}
savedStateHandle[USE_BACK_ANIM] = useBackAnimation
savedStateHandle[CURRENT_SCREEN] = screen
}
fun navigateWithoutBackStack(screen: Screen, vararg args: Argument) =
fun navigateWithoutBackStack(screen: Screen, useBackAnimation: Boolean, vararg args: Argument) =
viewModelScope.launch(Dispatchers.Default) {
if (backStack.value.last() == screen) {
backStack.value.removeLast()
@ -125,15 +130,18 @@ class Navigator @AssistedInject constructor(
putArgument(it)
}
savedStateHandle[USE_BACK_ANIM] = useBackAnimation
savedStateHandle[CURRENT_SCREEN] = screen
}
fun navigateBack() = viewModelScope.launch(Dispatchers.Default) {
if (canGoBack()) {
savedStateHandle[CURRENT_SCREEN] = backStack.value.last()
backStack.value.removeLast()
fun navigateBack(useBackAnimation: Boolean = true) =
viewModelScope.launch(Dispatchers.Default) {
if (canGoBack()) {
savedStateHandle[USE_BACK_ANIM] = useBackAnimation
savedStateHandle[CURRENT_SCREEN] = backStack.value.last()
backStack.value.removeLast()
}
}
}
fun canGoBack(): Boolean {
return backStack.value.isNotEmpty()
@ -148,7 +156,9 @@ class Navigator @AssistedInject constructor(
*
* @param screen The [Screen] that represents [content].
* @param enterAnim Enter Animation.
* @param backEnterAnim Enter Animation for navigating back.
* @param exitAnim Exit Animation.
* @param backExitAnim Exit Animation for navigating back.
* @param content The Screen content to show when [Navigator.currentScreen] equals [screen].
*/
@SuppressLint("ComposableNaming")
@ -156,14 +166,16 @@ class Navigator @AssistedInject constructor(
fun composable(
screen: Screen,
enterAnim: EnterTransition = Transitions.FadeTransitionIn,
backEnterAnim: EnterTransition = Transitions.BackSlidingTransitionIn,
exitAnim: ExitTransition = Transitions.FadeTransitionOut,
backExitAnim: ExitTransition = Transitions.BackSlidingTransitionOut,
content: @Composable () -> Unit
) {
AnimatedVisibility(
visible = getCurrentScreen().collectAsState().value == screen,
enter = enterAnim,
exit = exitAnim,
label = screen.toString()
enter = if (!useBackAnimation.collectAsState().value) enterAnim else backEnterAnim,
exit = if (!useBackAnimation.collectAsState().value) exitAnim else backExitAnim,
label = ""
) {
content()
}

View file

@ -21,10 +21,15 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.ExperimentalMaterialApi
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.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.FloatingActionButton
import androidx.compose.material3.FloatingActionButtonDefaults
@ -32,6 +37,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
@ -43,32 +49,33 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import dagger.hilt.android.lifecycle.withCreationCallback
import ua.acclorite.book_story.R
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.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.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.BookInfoDetailsBottomSheet
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.BookInfoMoveDialog
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.BookInfoViewModel
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.library.data.LibraryEvent
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.util.Constants
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
@Composable
fun BookInfoScreen(
libraryViewModel: LibraryViewModel = hiltViewModel(),
browseViewModel: BrowseViewModel = hiltViewModel(),
historyViewModel: HistoryViewModel = hiltViewModel(),
libraryViewModel: LibraryViewModel,
browseViewModel: BrowseViewModel,
historyViewModel: HistoryViewModel,
navigator: Navigator
) {
val context = LocalContext.current
@ -104,22 +111,29 @@ fun BookInfoScreen(
val state by viewModel.state.collectAsState()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
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 } }
LaunchedEffect(Unit) {
viewModel.init(
navigator,
failure = {
Toast.makeText(
context,
context.getString(
R.string.file_not_found,
it.takeLast(40)
),
Toast.LENGTH_LONG
).show()
}
navigator
)
}
@ -153,8 +167,9 @@ fun BookInfoScreen(
Scaffold(
Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection)
.windowInsetsPadding(WindowInsets.navigationBars),
.windowInsetsPadding(WindowInsets.navigationBars)
.pullRefresh(refreshState)
.nestedScroll(scrollBehavior.nestedScrollConnection),
containerColor = MaterialTheme.colorScheme.surface,
topBar = {
AnimatedTopAppBar(
@ -164,15 +179,7 @@ fun BookInfoScreen(
isTopBarScrolled = null,
content1NavigationIcon = {
IconButton(onClick = {
navigator.navigateBack()
}) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
contentDescription = "Go back",
tint = MaterialTheme.colorScheme.onSurface
)
}
GoBackButton(navigator = navigator)
},
content1Title = {
DefaultTransition(visible = firstVisibleItemIndex > 0) {
@ -186,11 +193,37 @@ fun BookInfoScreen(
}
},
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 {
DefaultTransition(
visible = !state.editTitle
) {
BookInfoMoreDropDown(viewModel = viewModel)
BookInfoMoreDropDown(
viewModel = viewModel,
snackbarState = snackbarState
)
}
androidx.compose.animation.AnimatedVisibility(
visible = state.editTitle,
@ -230,6 +263,12 @@ fun BookInfoScreen(
}
}
)
},
bottomBar = {
CustomSnackbar(
modifier = Modifier.padding(bottom = 70.dp),
snackbarState = snackbarState
)
}
) { paddingValues ->
Box(
@ -246,7 +285,7 @@ fun BookInfoScreen(
if (state.book.coverImage != null) {
BookInfoBackground(
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(
onClick = {
navigator.navigate(
Screen.READER, Argument(
"book", state.book
)
FloatingActionButton(
onClick = {
navigator.navigate(
Screen.READER, false, Argument(
"book", state.book
)
},
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(16.dp),
shape = MaterialTheme.shapes.large,
elevation = FloatingActionButtonDefaults.elevation(),
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
content = {
Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(modifier = Modifier.width(13.dp))
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = "Continue reading",
Modifier.size(24.dp)
)
},
shape = MaterialTheme.shapes.large,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(16.dp),
elevation = FloatingActionButtonDefaults.elevation(),
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
content = {
Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(modifier = Modifier.width(13.dp))
Icon(
imageVector = Icons.Filled.PlayArrow,
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
/**
* Background of the BookInfoScreen.
*/
@Composable
fun BookInfoBackground(height: Dp, image: ImageBitmap) {
val background = MaterialTheme.colorScheme.background
@ -34,7 +37,7 @@ fun BookInfoBackground(height: Dp, image: ImageBitmap) {
)
)
}
.blur(2.dp),
.blur(3.dp),
alpha = 0.3f,
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.domain.model.Book
/**
* Description section.
*/
@Composable
fun BookInfoDescriptionSection(book: Book) {
SelectionContainer {
Text(
book.description
?: stringResource(id = R.string.error_no_description),
if (book.description?.isNotBlank() == true) book.description
else stringResource(id = R.string.error_no_description),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
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.graphics.SolidColor
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
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.ui.elevation
/**
* Info section.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun BookInfoInfoSection(viewModel: BookInfoViewModel, book: Book) {
@ -84,7 +86,7 @@ fun BookInfoInfoSection(viewModel: BookInfoViewModel, book: Book) {
if (book.coverImage != null) {
Image(
bitmap = book.coverImage.asImageBitmap(),
bitmap = book.coverImage,
contentDescription = "Cover",
modifier = Modifier
.fillMaxSize()

View file

@ -11,6 +11,7 @@ import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
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.BookInfoViewModel
/**
* Book info more dropdown. Has three options: Delete, Move and Details.
*/
@Composable
fun BookInfoMoreDropDown(viewModel: BookInfoViewModel) {
fun BookInfoMoreDropDown(viewModel: BookInfoViewModel, snackbarState: SnackbarHostState) {
val state by viewModel.state.collectAsState()
Box {
@ -64,6 +68,7 @@ fun BookInfoMoreDropDown(viewModel: BookInfoViewModel) {
leadingIcon = Icons.Outlined.Info,
text = stringResource(id = R.string.details_book)
) {
snackbarState.currentSnackbarData?.dismiss()
viewModel.onEvent(BookInfoEvent.OnShowHideDetailsBottomSheet)
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.removeTrailingZero
/**
* Statistic section.
*/
@Composable
fun BookInfoStatisticSection(book: Book) {
Column(
@ -41,16 +44,16 @@ fun BookInfoStatisticSection(book: Book) {
LinearProgressIndicator(
book.progress.coerceAtLeast(0.01f),
strokeCap = StrokeCap.Round,
modifier = Modifier
.fillMaxWidth(),
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))
Text(
text = stringResource(
text = if (book.progress < 1f) stringResource(
id = R.string.you_read_query,
(book.progress * 100)
.toDouble()
@ -59,7 +62,7 @@ fun BookInfoStatisticSection(book: Book) {
) + " " + stringResource(
if (book.progress > 0.2f) R.string.read_keep
else R.string.read_more
),
) else stringResource(id = R.string.read_done),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
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 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.elevation
/**
* Change cover bottom sheet. Lets user select photo from the gallery and replaces old one.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
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.layout.Arrangement
@ -21,6 +21,9 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* Change cover bottom sheet item.
*/
@Composable
fun BookInfoChangeCoverBottomSheetItem(
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 androidx.compose.foundation.layout.Spacer
@ -25,6 +25,9 @@ import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Details bottom sheet. Displays name, path, last opened and size of the file.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
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.combinedClickable
@ -18,6 +18,9 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* Details bottom sheet item.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun BookInfoDetailsBottomSheetItem(
@ -51,7 +54,6 @@ fun BookInfoDetailsBottomSheetItem(
Spacer(modifier = Modifier.height(2.dp))
Text(
description,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodyMedium,
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 androidx.compose.material.icons.Icons
@ -7,8 +7,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.Navigator
import ua.acclorite.book_story.presentation.components.CustomDialogWithContent
import ua.acclorite.book_story.presentation.components.custom_dialog.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.BookInfoViewModel
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.LibraryViewModel
/**
* Delete dialog. Deletes current book.
*/
@Composable
fun BookInfoDeleteDialog(
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 androidx.compose.foundation.lazy.items
@ -13,14 +13,17 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.model.Category
import ua.acclorite.book_story.presentation.Navigator
import ua.acclorite.book_story.presentation.components.CustomDialogWithLazyColumn
import ua.acclorite.book_story.presentation.components.SelectableDialogItem
import ua.acclorite.book_story.presentation.components.custom_dialog.CustomDialogWithLazyColumn
import ua.acclorite.book_story.presentation.components.custom_dialog.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.BookInfoViewModel
import ua.acclorite.book_story.presentation.screens.library.data.LibraryEvent
import ua.acclorite.book_story.presentation.screens.library.data.LibraryViewModel
/**
* Move dialog. Moves current book to the selected category.
*/
@Composable
fun BookInfoMoveDialog(
libraryViewModel: LibraryViewModel,

View file

@ -2,9 +2,10 @@ package ua.acclorite.book_story.presentation.screens.book_info.data
import android.content.Context
import android.net.Uri
import androidx.compose.material3.SnackbarHostState
import androidx.compose.ui.focus.FocusRequester
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 {
data object OnShowHideChangeCoverBottomSheet : BookInfoEvent()
@ -33,4 +34,18 @@ sealed class BookInfoEvent {
val text: String,
val success: () -> Unit
) : 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
data class BookInfoState(
val book: Book,
val isRefreshing: Boolean = false,
val editTitle: Boolean = false,
val hasFocused: Boolean = false,

View file

@ -5,6 +5,8 @@ import android.content.ClipboardManager
import android.content.Context.CLIPBOARD_SERVICE
import android.graphics.BitmapFactory
import android.os.Build
import androidx.compose.material3.SnackbarResult
import androidx.compose.ui.graphics.asImageBitmap
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.assisted.Assisted
@ -12,28 +14,36 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.model.Book
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.GetBookFromFile
import ua.acclorite.book_story.domain.use_case.UpdateBooks
import ua.acclorite.book_story.presentation.Navigator
import ua.acclorite.book_story.presentation.Screen
import ua.acclorite.book_story.presentation.data.Navigator
import ua.acclorite.book_story.presentation.data.Screen
@HiltViewModel(assistedFactory = BookInfoViewModel.Factory::class)
class BookInfoViewModel @AssistedInject constructor(
@Assisted book: Book,
private val updateBooks: UpdateBooks,
private val deleteBooks: DeleteBooks
private val deleteBooks: DeleteBooks,
private val getBookFromFile: GetBookFromFile
) : ViewModel() {
private val _state = MutableStateFlow(BookInfoState(book))
val state = _state.asStateFlow()
var job: Job? = null
fun onEvent(event: BookInfoEvent) {
when (event) {
is BookInfoEvent.OnShowHideChangeCoverBottomSheet -> {
@ -47,9 +57,11 @@ class BookInfoViewModel @AssistedInject constructor(
is BookInfoEvent.OnChangeCover -> {
viewModelScope.launch {
val image = event.context.contentResolver?.openInputStream(event.uri)?.use {
BitmapFactory.decodeStream(it)
BitmapFactory.decodeStream(it).asImageBitmap()
} ?: return@launch
image.prepareToDraw()
updateBooks.execute(
listOf(
_state.value.book.copy(coverImage = image)
@ -221,7 +233,7 @@ class BookInfoViewModel @AssistedInject constructor(
it != _state.value.selectedCategory
}.size - 1
)
event.navigator.navigate(Screen.LIBRARY)
event.navigator.navigate(Screen.LIBRARY, true)
}
}
@ -243,10 +255,139 @@ class BookInfoViewModel @AssistedInject constructor(
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 {
val book = navigator.retrieveArgument("book") as? Book
@ -255,10 +396,6 @@ class BookInfoViewModel @AssistedInject constructor(
return@launch
}
if (book.file == null) {
failure(book.filePath)
}
_state.update {
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.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.rememberPermissionState
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
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.IsEmpty
import ua.acclorite.book_story.presentation.components.IsError
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.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.BrowseViewModel
import ua.acclorite.book_story.presentation.screens.library.data.LibraryViewModel
import ua.acclorite.book_story.ui.DefaultTransition
import ua.acclorite.book_story.ui.Transitions
import ua.acclorite.book_story.ui.elevation
@ -83,7 +83,8 @@ import ua.acclorite.book_story.ui.elevation
)
@Composable
fun BrowseScreen(
viewModel: BrowseViewModel = hiltViewModel(),
viewModel: BrowseViewModel,
libraryViewModel: LibraryViewModel,
navigator: Navigator
) {
val permissionState = rememberPermissionState(
@ -127,7 +128,11 @@ fun BrowseScreen(
BrowseStoragePermissionDialog(viewModel, permissionState)
}
if (state.showAddingDialog) {
BrowseAddingDialog(viewModel = viewModel, navigator = navigator)
BrowseAddingDialog(
viewModel = viewModel,
navigator = navigator,
libraryViewModel = libraryViewModel
)
}
Scaffold(
@ -270,13 +275,14 @@ fun BrowseScreen(
columns = GridCells.Adaptive(170.dp),
contentPadding = PaddingValues(12.dp)
) {
items(state.selectableFiles, key = { it.first.path }) { selectableFile ->
items(
state.selectableFiles,
key = { it.first.path }
) { selectableFile ->
BrowseFileItem(
file = selectableFile,
modifier = Modifier
.animateItemPlacement(
animationSpec = tween(300)
),
.animateItemPlacement(),
onClick = {
viewModel.onEvent(BrowseEvent.OnSelectFile(selectableFile))
}
@ -317,7 +323,7 @@ fun BrowseScreen(
message = stringResource(id = R.string.browse_empty),
icon = painterResource(id = R.drawable.empty_browse),
actionTitle = stringResource(id = R.string.get_help),
action = { navigator.navigate(Screen.HELP) }
action = { navigator.navigate(Screen.HELP, false) }
)
}
@ -325,9 +331,8 @@ fun BrowseScreen(
state.isRefreshing,
refreshState,
Modifier.align(Alignment.TopCenter),
backgroundColor = MaterialTheme.elevation(),
contentColor = MaterialTheme.colorScheme.primary,
scale = true
backgroundColor = MaterialTheme.colorScheme.inverseSurface,
contentColor = MaterialTheme.colorScheme.inverseOnSurface
)
}
}
@ -343,7 +348,7 @@ fun BrowseScreen(
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.Locale
/**
* Browse list element item. Can be selected.
*/
@Composable
fun BrowseFileItem(file: Pair<File, Boolean>, modifier: Modifier, onClick: () -> Unit) {
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.PermissionState
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.BrowseViewModel
/**
* Storage permission dialog.
*/
@OptIn(ExperimentalPermissionsApi::class)
@Composable
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 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.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import ua.acclorite.book_story.R
import ua.acclorite.book_story.presentation.Navigator
import ua.acclorite.book_story.presentation.components.CustomDialogWithLazyColumn
import ua.acclorite.book_story.domain.model.NullableBook
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.BrowseViewModel
import ua.acclorite.book_story.presentation.screens.library.data.LibraryEvent
import ua.acclorite.book_story.presentation.screens.library.data.LibraryViewModel
/**
* Adding dialog. Adds all selected books to the Library.
*/
@Composable
fun BrowseAddingDialog(
libraryViewModel: LibraryViewModel = hiltViewModel(),
libraryViewModel: LibraryViewModel,
viewModel: BrowseViewModel,
navigator: Navigator
) {
@ -42,7 +45,10 @@ fun BrowseAddingDialog(
imageVectorIcon = Icons.Default.AddChart,
description = stringResource(id = R.string.add_books_description),
actionText = stringResource(id = R.string.add),
isActionEnabled = !state.isBooksLoading,
isActionEnabled = !state.isBooksLoading &&
state.selectedBooks
.filterIsInstance<NullableBook.NotNull>()
.isNotEmpty(),
onDismiss = { viewModel.onEvent(BrowseEvent.OnAddingDialogDismiss) },
onAction = {
viewModel.onEvent(
@ -80,7 +86,15 @@ fun BrowseAddingDialog(
} else {
items(state.selectedBooks) { 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.layout.Box
@ -10,27 +10,32 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
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.presentation.components.CustomCheckbox
/**
* Adding dialog item.
*/
@Composable
fun BrowseAddingDialogItem(result: NullableBook, onClick: () -> Unit) {
fun BrowseAddingDialogItem(result: NullableBook, onClick: (Boolean) -> Unit) {
if (result is NullableBook.NotNull) {
Row(
Modifier
.fillMaxWidth()
.clickable {
onClick()
onClick(true)
}
.padding(vertical = 12.dp, horizontal = 24.dp),
verticalAlignment = Alignment.CenterVertically
@ -58,14 +63,24 @@ fun BrowseAddingDialogItem(result: NullableBook, onClick: () -> Unit) {
}
}
} else {
val icon =
if (result.message?.asString() == stringResource(R.string.error_file_encrypted)) {
Icons.Default.Lock
} else {
Icons.Default.Error
}
Row(
Modifier
.fillMaxWidth()
.clickable {
onClick(false)
}
.padding(vertical = 12.dp, horizontal = 24.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Warning,
imageVector = icon,
contentDescription = "Error",
modifier = Modifier.size(26.dp),
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.PermissionState
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
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.GetFilesFromDownloads
import ua.acclorite.book_story.domain.use_case.InsertBooks
import ua.acclorite.book_story.presentation.Argument
import ua.acclorite.book_story.presentation.Screen
import ua.acclorite.book_story.presentation.data.Argument
import ua.acclorite.book_story.presentation.data.Screen
import ua.acclorite.book_story.util.Resource
import javax.inject.Inject
@ -42,7 +42,14 @@ class BrowseViewModel @Inject constructor(
private var job: Job? = null
init {
onEvent(BrowseEvent.OnLoadList)
viewModelScope.launch(Dispatchers.IO) {
_state.update {
it.copy(
isLoading = true
)
}
getFilesFromDownloads()
}
}
fun onEvent(event: BrowseEvent) {
@ -268,34 +275,23 @@ class BrowseViewModel @Inject constructor(
is BrowseEvent.OnGetBooksFromFiles -> {
viewModelScope.launch(Dispatchers.IO) {
getBooksFromFiles.execute(
_state.update {
it.copy(
isBooksLoading = true
)
}
val books = getBooksFromFiles.execute(
_state.value.selectableFiles
.filter { it.second }
.map { it.first }
).collect { result ->
when (result) {
is Resource.Success -> {
if (result.data?.isEmpty() ?: return@collect) {
return@collect
}
_state.update {
it.copy(
selectedBooks = result.data,
isBooksLoading = false
)
}
}
)
is Resource.Loading -> {
_state.update {
it.copy(
isBooksLoading = result.isLoading
)
}
}
is Resource.Error -> Unit
}
_state.update {
it.copy(
selectedBooks = books,
isBooksLoading = false
)
}
}
}
@ -323,7 +319,11 @@ class BrowseViewModel @Inject constructor(
onEvent(BrowseEvent.OnClearSelectedFiles)
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.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
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.CategoryTitle
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.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.HistoryItem
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.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.elevation
import java.util.UUID
@ -81,11 +82,14 @@ import java.util.UUID
)
@Composable
fun HistoryScreen(
viewModel: HistoryViewModel = hiltViewModel(),
viewModel: HistoryViewModel,
libraryViewModel: LibraryViewModel,
navigator: Navigator
) {
val context = LocalContext.current
val state by viewModel.state.collectAsState()
val books = libraryViewModel.state.collectAsState().value.books.map { it.first }
val refreshState = rememberPullRefreshState(
refreshing = state.isRefreshing,
onRefresh = {
@ -118,7 +122,7 @@ fun HistoryScreen(
}
if (state.showDeleteWholeHistoryDialog) {
HistoryDeleteWholeHistoryDialog(viewModel = viewModel)
HistoryDeleteWholeHistoryDialog(viewModel = viewModel, libraryViewModel = libraryViewModel)
}
Scaffold(
@ -144,7 +148,10 @@ fun HistoryScreen(
)
},
content1Actions = {
IconButton(onClick = { viewModel.onEvent(HistoryEvent.OnSearchShowHide) }) {
IconButton(
enabled = !state.isRefreshing,
onClick = { viewModel.onEvent(HistoryEvent.OnSearchShowHide) }
) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = "Search history",
@ -153,7 +160,9 @@ fun HistoryScreen(
)
}
IconButton(
enabled = !state.isLoading && !state.isRefreshing && state.history.isNotEmpty(),
enabled = !state.isLoading
&& !state.isRefreshing
&& state.history.isNotEmpty(),
onClick = {
viewModel.onEvent(HistoryEvent.OnShowHideDeleteWholeHistoryDialog)
}
@ -248,7 +257,7 @@ fun HistoryScreen(
}
CategoryTitle(
modifier = Modifier.animateItemPlacement(tween(300)),
modifier = Modifier.animateItemPlacement(),
title = when (groupedHistory.title) {
"today" -> stringResource(id = R.string.today)
"yesterday" -> stringResource(id = R.string.yesterday)
@ -262,27 +271,36 @@ fun HistoryScreen(
items(
groupedHistory.history, key = { it.id ?: UUID.randomUUID() }
) {
val book = books.find { book -> book.id == it.bookId } ?: return@items
HistoryItem(
modifier = Modifier.animateItemPlacement(tween(300)),
modifier = Modifier.animateItemPlacement(),
history = it,
book = book,
onBodyClick = {
navigator.navigate(
Screen.BOOK_INFO,
Argument("book", it.book)
false,
Argument("book", book)
)
},
onTitleClick = {
navigator.navigate(
Screen.READER,
Argument("book", it.book)
false,
Argument("book", book)
)
},
isDeleteEnabled = !state.isRefreshing,
onDeleteClick = {
viewModel.onEvent(
HistoryEvent.OnDeleteHistoryElement(
it,
snackbarState,
context
historyToDelete = it,
snackbarState = snackbarState,
context = context,
refreshList = {
libraryViewModel.onEvent(LibraryEvent.OnLoadList)
}
)
)
}
@ -318,9 +336,8 @@ fun HistoryScreen(
state.isRefreshing,
refreshState,
Modifier.align(Alignment.TopCenter),
backgroundColor = MaterialTheme.elevation(),
contentColor = MaterialTheme.colorScheme.primary,
scale = true
backgroundColor = MaterialTheme.colorScheme.inverseSurface,
contentColor = MaterialTheme.colorScheme.inverseOnSurface
)
}
}
@ -331,7 +348,7 @@ fun HistoryScreen(
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.res.stringResource
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.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
fun HistoryDeleteWholeHistoryDialog(
viewModel: HistoryViewModel
viewModel: HistoryViewModel,
libraryViewModel: LibraryViewModel
) {
val context = LocalContext.current
@ -29,7 +35,9 @@ fun HistoryDeleteWholeHistoryDialog(
isActionEnabled = true,
onAction = {
viewModel.onEvent(
HistoryEvent.OnDeleteWholeHistory
HistoryEvent.OnDeleteWholeHistory {
libraryViewModel.onEvent(LibraryEvent.OnLoadList)
}
)
Toast.makeText(
context,

View file

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

View file

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

View file

@ -1,6 +1,5 @@
package ua.acclorite.book_story.presentation.screens.history.data
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarResult
import androidx.lifecycle.ViewModel
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.use_case.DeleteHistory
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.InsertHistory
import ua.acclorite.book_story.util.Resource
@ -30,16 +30,25 @@ class HistoryViewModel @Inject constructor(
private val insertHistory: InsertHistory,
private val getHistory: GetHistory,
private val deleteWholeHistory: DeleteWholeHistory,
private val deleteHistory: DeleteHistory
private val deleteHistory: DeleteHistory,
private val getBooksById: GetBooksById
) : ViewModel() {
private val _state = MutableStateFlow(HistoryState())
val state = _state.asStateFlow()
private var job: Job? = null
private var job2: Job? = null
init {
onEvent(HistoryEvent.OnLoadList)
viewModelScope.launch(Dispatchers.IO) {
_state.update {
it.copy(
isLoading = true
)
}
getHistoryFromDatabase()
}
}
fun onEvent(event: HistoryEvent) {
@ -85,6 +94,7 @@ class HistoryViewModel @Inject constructor(
}
deleteWholeHistory.execute()
event.refreshList()
getHistoryFromDatabase("")
}
@ -104,20 +114,27 @@ class HistoryViewModel @Inject constructor(
listOf(event.historyToDelete)
)
onEvent(HistoryEvent.OnRefreshList)
event.refreshList()
job2?.cancel()
event.snackbarState.currentSnackbarData?.dismiss()
job2 = viewModelScope.launch(Dispatchers.IO) {
delay(10000)
event.snackbarState.currentSnackbarData?.dismiss()
}
val snackbar = event.snackbarState.showSnackbar(
event.context.getString(R.string.history_element_deleted),
event.context.getString(R.string.undo),
duration = SnackbarDuration.Long
event.context.getString(R.string.undo)
)
when (snackbar) {
SnackbarResult.Dismissed -> return@launch
SnackbarResult.Dismissed -> Unit
SnackbarResult.ActionPerformed -> {
insertHistory.execute(
listOf(event.historyToDelete)
)
event.refreshList()
onEvent(HistoryEvent.OnRefreshList)
}
}
@ -197,7 +214,7 @@ class HistoryViewModel @Inject constructor(
}
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) ->
values.maxByOrNull { it.time }
}
@ -223,11 +240,15 @@ class HistoryViewModel @Inject constructor(
return@collect
}
val books = getBooksById.execute(
history.map { it.bookId }.distinct()
)
val groupedHistory = mutableListOf<GroupedHistory>()
history
.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 ->
val calendar = Calendar.getInstance().apply {
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.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.CircularProgressIndicator
@ -69,24 +68,23 @@ import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import kotlinx.coroutines.delay
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.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.IsEmpty
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.history.data.HistoryViewModel
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.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.LibraryViewModel
import ua.acclorite.book_story.ui.Transitions
@ -99,15 +97,17 @@ import java.util.UUID
)
@Composable
fun LibraryScreen(
viewModel: LibraryViewModel = hiltViewModel(),
browseViewModel: BrowseViewModel = hiltViewModel(),
historyViewModel: HistoryViewModel = hiltViewModel(),
viewModel: LibraryViewModel,
browseViewModel: BrowseViewModel,
historyViewModel: HistoryViewModel,
navigator: Navigator,
addedBooks: List<Book>
) {
val state by viewModel.state.collectAsState()
val pagerState =
rememberPagerState(initialPage = state.currentPage, pageCount = { Category.entries.size })
val pagerState = rememberPagerState(
initialPage = state.currentPage,
pageCount = { Category.entries.size }
)
val refreshState = rememberPullRefreshState(
refreshing = state.isRefreshing,
onRefresh = {
@ -165,16 +165,15 @@ fun LibraryScreen(
overflow = TextOverflow.Ellipsis,
maxLines = 1
)
Spacer(modifier = Modifier.width(4.dp))
Spacer(modifier = Modifier.width(6.dp))
Text(
text = state.books.size.toString(),
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.elevation(elevation = 6.dp))
.padding(horizontal = 10.dp, vertical = 4.dp),
.padding(horizontal = 8.dp, vertical = 3.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 16.sp,
style = MaterialTheme.typography.titleLarge
fontSize = 16.sp
)
}
},
@ -313,9 +312,11 @@ fun LibraryScreen(
LaunchedEffect(state.books) {
categorizedBooks.clear()
categorizedBooks.addAll(state.books.filter { it.first.category == category }
.sortedByDescending {
it.first.lastOpened
})
.sortedWith(
compareByDescending<Pair<Book, Boolean>> { it.first.lastOpened }
.thenBy { it.first.title }
)
)
categoryIsLoading = false
}
@ -333,15 +334,14 @@ fun LibraryScreen(
) {
LibraryBookItem(
book = it,
modifier = Modifier.animateItemPlacement(
animationSpec = tween(300)
),
modifier = Modifier.animateItemPlacement(),
onCoverImageClick = {
if (state.hasSelectedItems) {
viewModel.onEvent(LibraryEvent.OnSelectBook(it))
} else {
navigator.navigate(
Screen.BOOK_INFO,
false,
Argument("book", it.first)
)
}
@ -354,6 +354,7 @@ fun LibraryScreen(
onButtonClick = {
navigator.navigate(
Screen.READER,
false,
Argument("book", it.first)
)
}
@ -385,7 +386,7 @@ fun LibraryScreen(
modifier = Modifier.align(Alignment.Center),
actionTitle = stringResource(id = R.string.add_book)
) {
navigator.navigate(Screen.BROWSE)
navigator.navigate(Screen.BROWSE, false)
}
}
@ -393,9 +394,8 @@ fun LibraryScreen(
state.isRefreshing,
refreshState,
Modifier.align(Alignment.TopCenter),
backgroundColor = MaterialTheme.elevation(),
contentColor = MaterialTheme.colorScheme.primary,
scale = true
backgroundColor = MaterialTheme.colorScheme.inverseSurface,
contentColor = MaterialTheme.colorScheme.inverseOnSurface
)
}
}

View file

@ -18,7 +18,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults
@ -30,7 +29,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@ -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.ui.elevation
/**
* Library list element item.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun LibraryBookItem(
@ -102,7 +103,7 @@ fun LibraryBookItem(
if (book.first.coverImage != null) {
Image(
bitmap = book.first.coverImage!!.asImageBitmap(),
bitmap = book.first.coverImage!!,
contentDescription = "Cover",
modifier = Modifier
.fillMaxSize()
@ -111,40 +112,25 @@ fun LibraryBookItem(
)
}
if (book.first.file != null) {
Text(
"${
(book.first.progress * 100)
.toDouble()
.removeDigits(1)
.removeTrailingZero()
}%",
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier
.padding(6.dp)
.clip(MaterialTheme.shapes.small)
.background(MaterialTheme.colorScheme.primary)
.padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.bodySmall
)
} else {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = "File is not found",
modifier = Modifier
.padding(6.dp)
.clip(MaterialTheme.shapes.small)
.background(MaterialTheme.colorScheme.error)
.padding(4.dp)
.size(20.dp),
tint = MaterialTheme.colorScheme.onError
)
}
Text(
"${
(book.first.progress * 100)
.toDouble()
.removeDigits(1)
.removeTrailingZero()
}%",
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier
.padding(6.dp)
.clip(MaterialTheme.shapes.small)
.background(MaterialTheme.colorScheme.primary)
.padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.bodySmall
)
FilledIconButton(
onClick = { onButtonClick() },
enabled = book.first.file != null,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(6.dp)
@ -154,11 +140,7 @@ fun LibraryBookItem(
containerColor = MaterialTheme.colorScheme.primaryContainer
.copy(0.9f),
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
.copy(0.9f),
disabledContainerColor = MaterialTheme.colorScheme.primaryContainer
.copy(0.6f),
disabledContentColor = MaterialTheme.colorScheme.onPrimaryContainer
.copy(0.6f)
.copy(0.9f)
)
) {
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.ui.elevation
/**
* Tab row, either scrollable or static, depends on screen width.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun LibraryTabRow(viewModel: LibraryViewModel, books: List<Book>, pagerState: PagerState) {
@ -97,9 +100,9 @@ fun LibraryTabRow(viewModel: LibraryViewModel, books: List<Book>, pagerState: Pa
Text(
text = tabItem.second.toString(),
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.elevation(6.dp))
.padding(horizontal = 6.dp, vertical = 4.dp),
.padding(horizontal = 8.dp, vertical = 4.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium
)
@ -153,9 +156,9 @@ fun LibraryTabRow(viewModel: LibraryViewModel, books: List<Book>, pagerState: Pa
Text(
text = tabItem.second.toString(),
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.elevation(6.dp))
.padding(horizontal = 6.dp, vertical = 4.dp),
.padding(horizontal = 8.dp, vertical = 4.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant,
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 androidx.compose.material.icons.Icons
@ -9,7 +9,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
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.BrowseViewModel
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.LibraryViewModel
/**
* Delete dialog. Deletes all selected books.
*/
@Composable
fun LibraryDeleteDialog(
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 androidx.compose.foundation.ExperimentalFoundationApi
@ -13,11 +13,14 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.model.Category
import ua.acclorite.book_story.presentation.components.CustomDialogWithLazyColumn
import ua.acclorite.book_story.presentation.components.SelectableDialogItem
import ua.acclorite.book_story.presentation.components.custom_dialog.CustomDialogWithLazyColumn
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.LibraryViewModel
/**
* Move dialog. Moves all selected books to the selected category.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun LibraryMoveDialog(viewModel: LibraryViewModel, pagerState: PagerState) {

View file

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

View file

@ -1,6 +1,8 @@
package ua.acclorite.book_story.presentation.screens.reader
import android.annotation.SuppressLint
import android.os.Build
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
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.rememberLazyListState
import androidx.compose.foundation.text.selection.DisableSelection
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
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.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import dagger.hilt.android.lifecycle.withCreationCallback
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.model.Book
import ua.acclorite.book_story.presentation.Navigator
import ua.acclorite.book_story.presentation.components.IsError
import ua.acclorite.book_story.presentation.components.CustomSelectionContainer
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.Navigator
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.library.data.LibraryEvent
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.ReaderSettingsBottomSheet
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.ReaderViewModel
import ua.acclorite.book_story.ui.elevation
@ -78,9 +79,9 @@ import ua.acclorite.book_story.util.Constants
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@Composable
fun ReaderScreen(
mainViewModel: MainViewModel = hiltViewModel(),
libraryViewModel: LibraryViewModel = hiltViewModel(),
historyViewModel: HistoryViewModel = hiltViewModel(),
mainViewModel: MainViewModel,
libraryViewModel: LibraryViewModel,
historyViewModel: HistoryViewModel,
navigator: Navigator
) {
val context = LocalContext.current as ComponentActivity
@ -189,7 +190,7 @@ fun ReaderScreen(
Modifier
.fillMaxSize()
.nestedScroll(nestedScrollConnection),
containerColor = Color(backgroundColor.toULong()),
containerColor = MaterialTheme.colorScheme.surface,
topBar = {
AnimatedVisibility(
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(
state = listState,
modifier = Modifier
.fillMaxSize()
.clickable(
interactionSource = null,
indication = null,
enabled = !loading,
onClick = {
viewModel.onEvent(
ReaderEvent.OnShowHideMenu(context = context)
)
.then(
if (!loading && toolbarShowed) {
Modifier
.fillMaxSize()
.clickable(
interactionSource = null,
indication = null,
onClick = {
viewModel.onEvent(
ReaderEvent.OnShowHideMenu(context = context)
)
}
)
} else {
Modifier
.fillMaxSize()
}
)
) {
@ -246,28 +294,31 @@ fun ReaderScreen(
itemsIndexed(
state.book.text, key = { _, key -> key.id }
) { index, text ->
if (index == 0)
Spacer(modifier = Modifier.height(36.dp))
Column(
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(
text = "${if (paragraphIndentation) " " else ""}${text.line}",
color = Color(fontColor.toULong()),
style = TextStyle(
lineBreak = LineBreak.Paragraph
),
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))
style = TextStyle(
lineBreak = LineBreak.Paragraph
),
fontFamily = fontFamily.font,
fontStyle = fontStyle,
fontSize = fontSize.sp,
lineHeight = (fontSize + lineHeight).sp
)
}
}
if (state.book.text.isNotEmpty()) {
@ -300,6 +351,9 @@ fun ReaderScreen(
actionTitle = stringResource(id = R.string.go_back),
action = {
navigator.navigateBack()
libraryViewModel.onEvent(LibraryEvent.OnLoadList)
historyViewModel.onEvent(HistoryEvent.OnLoadList)
viewModel.onEvent(ReaderEvent.OnShowSystemBars(context))
}
)
} else {

View file

@ -4,11 +4,15 @@ import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsIgnoringVisibility
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@ -26,13 +30,17 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.R
import ua.acclorite.book_story.domain.model.Category
import ua.acclorite.book_story.presentation.Navigator
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.screens.library.data.LibraryEvent
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.ReaderViewModel
/**
* Reader end item. Displays at the end of the book.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ReaderEndItem(
libraryViewModel: LibraryViewModel,
@ -46,7 +54,13 @@ fun ReaderEndItem(
modifier = Modifier
.fillMaxWidth()
.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
) {
Text(
@ -56,7 +70,14 @@ fun ReaderEndItem(
textAlign = TextAlign.Center,
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(
onClick = {
if (state.book.category != Category.ALREADY_READ) {
@ -86,7 +107,7 @@ fun ReaderEndItem(
context
)
)
navigator.navigate(Screen.LIBRARY)
navigator.navigate(Screen.LIBRARY, true)
}
},
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.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsIgnoringVisibility
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Image
import androidx.compose.material3.Icon
@ -24,7 +28,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
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.ui.elevation
/**
* Reader start item. Displays at the beginning of the book.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ReaderStartItem(viewModel: ReaderViewModel) {
val state by viewModel.state.collectAsState()
@ -41,6 +48,7 @@ fun ReaderStartItem(viewModel: ReaderViewModel) {
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface)
.windowInsetsPadding(WindowInsets.statusBarsIgnoringVisibility)
.padding(24.dp)
) {
Row(
@ -66,7 +74,7 @@ fun ReaderStartItem(viewModel: ReaderViewModel) {
if (state.book.coverImage != null) {
Image(
bitmap = state.book.coverImage!!.asImageBitmap(),
bitmap = state.book.coverImage!!,
contentDescription = "Cover",
modifier = Modifier
.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.clickable
@ -19,7 +19,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
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.removeTrailingZero
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.ReaderViewModel
/**
* Reader bottom bar. Has a slider to change progress.
*/
@Composable
fun ReaderBottomBar(
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.compose.foundation.basicMarquee
@ -6,10 +6,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
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.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.ExperimentalMaterial3Api
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.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
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.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.data.removeDigits
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.ReaderViewModel
/**
* Reader top bar. Displays title of the book.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ReaderTopBar(viewModel: ReaderViewModel, navigator: Navigator, containerColor: Color) {
@ -48,15 +48,8 @@ fun ReaderTopBar(viewModel: ReaderViewModel, navigator: Navigator, containerColo
TopAppBar(
navigationIcon = {
IconButton(onClick = {
navigator.navigateBack()
GoBackButton(navigator = navigator) {
viewModel.onEvent(ReaderEvent.OnShowSystemBars(context))
}) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Go back",
tint = MaterialTheme.colorScheme.onSurface
)
}
},
title = {
@ -75,6 +68,7 @@ fun ReaderTopBar(viewModel: ReaderViewModel, navigator: Navigator, containerColo
onClick = {
navigator.navigateWithoutBackStack(
Screen.BOOK_INFO,
true,
Argument(
"book",
book
@ -86,7 +80,6 @@ fun ReaderTopBar(viewModel: ReaderViewModel, navigator: Navigator, containerColo
delayMillis = 4000
)
)
Spacer(modifier = Modifier.height(4.dp))
Text(
stringResource(
id = R.string.read_query,
@ -107,8 +100,7 @@ fun ReaderTopBar(viewModel: ReaderViewModel, navigator: Navigator, containerColo
}) {
Icon(
imageVector = Icons.Default.Settings,
contentDescription = "Open Settings",
tint = MaterialTheme.colorScheme.onSurface
contentDescription = stringResource(id = R.string.open_reader_settings_content_desc),
)
}
},

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.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.util.Constants
/**
* Settings bottom sheet. Has General and Colors categories.
*/
@OptIn(
ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class
)
@ -118,7 +121,7 @@ fun ReaderSettingsBottomSheet(mainViewModel: MainViewModel, viewModel: ReaderVie
}
item {
CategoryTitle(
title = stringResource(id = R.string.font_settings),
title = stringResource(id = R.string.font_reader_settings),
color = MaterialTheme.colorScheme.onSurface
)
}
@ -197,7 +200,7 @@ fun ReaderSettingsBottomSheet(mainViewModel: MainViewModel, viewModel: ReaderVie
}
item {
CategoryTitle(
title = stringResource(id = R.string.text_settings),
title = stringResource(id = R.string.text_reader_settings),
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.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.ReaderViewModel
/**
* Settings bottom sheet tab row. It is used to switch between categories.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
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.pager.PagerState
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 {
data class OnFileNotFound(val onLoaded: () -> Unit) : ReaderEvent()
data class OnTextIsEmpty(val onLoaded: () -> Unit) : ReaderEvent()
data class OnLoadText(
val scrollState: LazyListState,
val navigator: Navigator,
@ -37,4 +37,16 @@ sealed class ReaderEvent {
val updatePage: (Int) -> Unit,
val navigator: Navigator
) : 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
data class ReaderState(
val book: Book,
val letters: Int = 0,
val words: Int = 0,
val errorMessage: UIText? = null,
val showMenu: Boolean = false,

View file

@ -1,5 +1,7 @@
package ua.acclorite.book_story.presentation.screens.reader.data
import android.app.SearchManager
import android.content.Intent
import androidx.activity.ComponentActivity
import androidx.compose.foundation.ExperimentalFoundationApi
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.Category
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.UpdateBooks
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.util.Resource
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.util.UIText
import java.util.Date
import kotlin.math.roundToInt
@ -39,7 +39,6 @@ import kotlin.math.roundToInt
@HiltViewModel(assistedFactory = ReaderViewModel.Factory::class)
class ReaderViewModel @AssistedInject constructor(
@Assisted book: Book,
private val getText: GetText,
private val updateBooks: UpdateBooks,
private val insertHistory: InsertHistory
) : ViewModel() {
@ -49,61 +48,29 @@ class ReaderViewModel @AssistedInject constructor(
fun onEvent(event: ReaderEvent) {
when (event) {
is ReaderEvent.OnFileNotFound -> {
is ReaderEvent.OnTextIsEmpty -> {
_state.update {
event.onLoaded()
it.copy(
errorMessage = UIText.StringResource(R.string.error_file_not_found),
errorMessage = UIText.StringResource(R.string.error_no_text),
)
}
}
is ReaderEvent.OnLoadText -> {
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 text = _state.value.book.text.joinToString(separator = " ") { it.line }
val letters = text.replace(" ", "").length
val words = text.split("\\s+".toRegex()).size
_state.update {
it.copy(
book = it.book.copy(
lastOpened = time
)
),
letters = letters,
words = words
)
}
@ -114,7 +81,7 @@ class ReaderViewModel @AssistedInject constructor(
listOf(
History(
null,
_state.value.book,
_state.value.book.id!!,
time
)
)
@ -141,7 +108,7 @@ class ReaderViewModel @AssistedInject constructor(
}
}
delay(500)
delay(100)
event.onLoaded()
return@collectLatest
}
@ -234,7 +201,8 @@ class ReaderViewModel @AssistedInject constructor(
onEvent(ReaderEvent.OnShowSystemBars(event.context))
val book = _state.value.book.copy(
category = Category.ALREADY_READ
category = Category.ALREADY_READ,
progress = 1f
)
updateBooks.execute(listOf(book))
@ -245,7 +213,59 @@ class ReaderViewModel @AssistedInject constructor(
it != Category.ALREADY_READ
}.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)
}
if (book.file == null) {
onEvent(ReaderEvent.OnFileNotFound(onLoaded = { onLoaded() }))
if (book.text.isEmpty()) {
onEvent(ReaderEvent.OnTextIsEmpty(onLoaded = { onLoaded() }))
} else {
onEvent(ReaderEvent.OnShowHideMenu(false, context))
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.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
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.Palette
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
@ -21,19 +21,24 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import 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.GoBackButton
import ua.acclorite.book_story.presentation.data.Navigator
import ua.acclorite.book_story.presentation.data.Screen
import ua.acclorite.book_story.presentation.screens.settings.components.SettingsCategoryItem
import ua.acclorite.book_story.ui.elevation
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
navigator: Navigator
) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val listState = rememberLazyListState()
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(
canScroll = {
listState.canScrollForward
}
)
Scaffold(
Modifier
@ -42,36 +47,26 @@ fun SettingsScreen(
.windowInsetsPadding(WindowInsets.navigationBars),
containerColor = MaterialTheme.colorScheme.surface,
topBar = {
AnimatedTopAppBar(
LargeTopAppBar(
title = {
Text(stringResource(id = R.string.settings_screen))
},
navigationIcon = {
GoBackButton(navigator = navigator)
},
scrollBehavior = scrollBehavior,
isTopBarScrolled = null,
content1NavigationIcon = {
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 = {}
colors = TopAppBarDefaults.largeTopAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
scrolledContainerColor = MaterialTheme.elevation()
)
)
}
) { paddingValues ->
LazyColumn(
Modifier
.fillMaxSize()
.padding(top = paddingValues.calculateTopPadding())
.padding(top = paddingValues.calculateTopPadding()),
state = listState
) {
item {
SettingsCategoryItem(
@ -79,7 +74,7 @@ fun SettingsScreen(
text = stringResource(id = R.string.general_settings),
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),
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 ua.acclorite.book_story.presentation.components.CategoryTitle
/**
* Checkbox with title.
*/
@Composable
fun CheckboxWithTitle(
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.presentation.components.CategoryTitle
/**
* Chips with title. Use list of [ChipItem]s to display chips.
*/
@OptIn(ExperimentalMaterialApi::class, ExperimentalLayoutApi::class)
@Composable
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.presentation.components.CategoryTitle
/**
* Color picker with title.
*/
@Composable
fun ColorPickerWithTitle(
value: Color,

View file

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

View file

@ -18,6 +18,9 @@ import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.presentation.components.CategoryTitle
import kotlin.math.roundToInt
/**
* Slider with title.
*/
@Composable
fun SliderWithTitle(
modifier: Modifier = Modifier,

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