nice ui and ux ig
This commit is contained in:
parent
e57d8b7490
commit
04820240dc
@ -79,7 +79,6 @@ dependencies {
|
||||
exclude(group = "xmlpull", module = "xmlpull")
|
||||
}
|
||||
implementation("org.slf4j:slf4j-android:1.7.36")
|
||||
implementation("org.jsoup:jsoup:1.15.3")
|
||||
implementation("net.sf.kxml:kxml2:2.3.0")
|
||||
|
||||
// Add material icons extended for the settings icon
|
||||
@ -93,4 +92,43 @@ dependencies {
|
||||
|
||||
// Add Timber for logging
|
||||
implementation("com.jakewharton.timber:timber:5.0.1")
|
||||
|
||||
// Add Material Design Icons Extended
|
||||
implementation("androidx.compose.material:material-icons-extended:1.6.1")
|
||||
|
||||
// Add Compose Animation libraries
|
||||
implementation("androidx.compose.animation:animation:1.6.1")
|
||||
implementation("androidx.compose.animation:animation-graphics:1.6.1")
|
||||
|
||||
// Add Accompanist for UI utilities
|
||||
implementation("com.google.accompanist:accompanist-systemuicontroller:0.34.0")
|
||||
implementation("com.google.accompanist:accompanist-placeholder-material:0.34.0")
|
||||
|
||||
// Add foundation dependency using BOM version management
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation("androidx.compose.foundation:foundation")
|
||||
|
||||
// Add Jsoup for HTML processing
|
||||
implementation("org.jsoup:jsoup:1.16.2")
|
||||
|
||||
// Add epublib for epub handling
|
||||
implementation("com.positiondev.epublib:epublib-core:3.1")
|
||||
|
||||
// Add Compose Animation dependencies
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation("androidx.compose.animation:animation")
|
||||
implementation("androidx.compose.foundation:foundation")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
|
||||
configurations.all {
|
||||
resolutionStrategy {
|
||||
// Exclude conflicting SLF4J implementations
|
||||
exclude(group = "org.slf4j", module = "slf4j-simple")
|
||||
// Exclude conflicting XML pull parser
|
||||
exclude(group = "xmlpull", module = "xmlpull")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -15,6 +15,8 @@ import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.automirrored.filled.MenuBook
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
@ -35,13 +37,64 @@ import kotlinx.coroutines.launch
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.navArgument
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LargeTopAppBar
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shadow
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.input.pointer.PointerInputScope
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
// import androidx.navigation.compose.NavHostController
|
||||
import inhale.rip.epook.data.SettingsStore
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
|
||||
// Add settings route
|
||||
// Add these imports at the top
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
// import androidx.compose.ui.text.style.TextAlign
|
||||
|
||||
// Add sealed class for navigation routes
|
||||
sealed class Screen(val route: String) {
|
||||
object BookList : Screen("books")
|
||||
object Reader : Screen("reader/{bookId}")
|
||||
object BookList : Screen("bookList")
|
||||
object Settings : Screen("settings")
|
||||
object Reader : Screen("reader/{bookId}") {
|
||||
fun createRoute(bookId: String) = "reader/$bookId"
|
||||
}
|
||||
}
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
@ -49,9 +102,7 @@ class MainActivity : ComponentActivity() {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
EpookTheme {
|
||||
App()
|
||||
}
|
||||
App()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -59,29 +110,34 @@ class MainActivity : ComponentActivity() {
|
||||
@Composable
|
||||
fun App() {
|
||||
val navController = rememberNavController()
|
||||
val context = LocalContext.current
|
||||
val settingsStore = remember(context) { SettingsStore(context) }
|
||||
|
||||
NavHost(navController = navController, startDestination = Screen.BookList.route) {
|
||||
composable(Screen.BookList.route) {
|
||||
BookshelfScreen(
|
||||
onBookClick = { bookId ->
|
||||
navController.navigate("reader/$bookId")
|
||||
}
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = "reader/{bookId}",
|
||||
arguments = listOf(navArgument("bookId") { type = NavType.StringType })
|
||||
) { backStackEntry ->
|
||||
ReaderScreen(
|
||||
bookId = backStackEntry.arguments?.getString("bookId") ?: "",
|
||||
onNavigateBack = { navController.navigateUp() },
|
||||
onOpenSettings = { navController.navigate(Screen.Settings.route) }
|
||||
)
|
||||
}
|
||||
composable(Screen.Settings.route) {
|
||||
SettingsScreen(
|
||||
onNavigateBack = { navController.navigateUp() }
|
||||
)
|
||||
EpookTheme {
|
||||
NavHost(navController = navController, startDestination = Screen.BookList.route) {
|
||||
composable(Screen.BookList.route) {
|
||||
BookshelfScreen(
|
||||
onBookClick = { bookId ->
|
||||
navController.navigate(Screen.Reader.createRoute(bookId))
|
||||
}
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Screen.Reader.route,
|
||||
arguments = listOf(navArgument("bookId") { type = NavType.StringType })
|
||||
) { backStackEntry ->
|
||||
val bookId = backStackEntry.arguments?.getString("bookId") ?: ""
|
||||
ReaderScreen(
|
||||
bookId = bookId,
|
||||
onNavigateBack = { navController.navigateUp() }
|
||||
)
|
||||
}
|
||||
composable(Screen.Settings.route) {
|
||||
SettingsScreen(
|
||||
onNavigateBack = { navController.navigateUp() },
|
||||
settingsStore = settingsStore
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -96,88 +152,92 @@ fun BookshelfScreen(onBookClick: (String) -> Unit) {
|
||||
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
var bookToDelete by remember { mutableStateOf<Book?>(null) }
|
||||
|
||||
var showEmptyState by remember { mutableStateOf(false) }
|
||||
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.GetContent()
|
||||
) { uri: Uri? ->
|
||||
uri?.let { selectedUri ->
|
||||
context.contentResolver.openInputStream(selectedUri)?.use { inputStream ->
|
||||
try {
|
||||
val bookId = System.currentTimeMillis().toString()
|
||||
val epubFile = File(context.filesDir, "book_$bookId.epub")
|
||||
|
||||
println("Saving book to: ${epubFile.absolutePath}")
|
||||
|
||||
// Use buffered streams for better performance
|
||||
inputStream.buffered().use { bufferedInput ->
|
||||
FileOutputStream(epubFile).buffered().use { bufferedOutput ->
|
||||
bufferedInput.copyTo(bufferedOutput)
|
||||
}
|
||||
try {
|
||||
val inputStream = context.contentResolver.openInputStream(selectedUri)
|
||||
val book = EpubReader().readEpub(inputStream)
|
||||
|
||||
val bookId = java.util.UUID.randomUUID().toString()
|
||||
|
||||
val epubFile = File(context.filesDir, "book_$bookId.epub")
|
||||
context.contentResolver.openInputStream(selectedUri)?.use { input ->
|
||||
FileOutputStream(epubFile).use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
|
||||
println("Book file saved, size: ${epubFile.length()} bytes")
|
||||
|
||||
// Verify the file exists and is readable
|
||||
if (!epubFile.exists() || !epubFile.canRead()) {
|
||||
println("Error: File not accessible after saving")
|
||||
return@use
|
||||
}
|
||||
|
||||
// Test reading the EPUB
|
||||
epubFile.inputStream().buffered().use { fileInput ->
|
||||
val epubReader = EpubReader()
|
||||
val book = epubReader.readEpub(fileInput)
|
||||
println("Book loaded for verification: ${book.title}")
|
||||
println("Number of spine refs: ${book.spine.spineReferences.size}")
|
||||
|
||||
// Extract and save cover image if it exists
|
||||
val coverFile = book.coverImage?.let { coverImage ->
|
||||
File(context.filesDir, "cover_$bookId.jpg").also { file ->
|
||||
FileOutputStream(file).buffered().use { output ->
|
||||
output.write(coverImage.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add the book to the store
|
||||
scope.launch {
|
||||
bookStore.addBook(Book(
|
||||
id = bookId,
|
||||
title = book.title ?: "Unknown Title",
|
||||
coverImageFile = coverFile,
|
||||
filePath = epubFile.absolutePath
|
||||
))
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("Error saving book: ${e.message}")
|
||||
e.printStackTrace()
|
||||
}
|
||||
|
||||
val coverFile = book.coverImage?.let { coverImage ->
|
||||
File(context.filesDir, "cover_$bookId.jpg").also { file ->
|
||||
FileOutputStream(file).use { output ->
|
||||
output.write(coverImage.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
bookStore.addBook(Book(
|
||||
id = bookId,
|
||||
title = book.title ?: "Unknown Title",
|
||||
coverImageFile = coverFile,
|
||||
filePath = epubFile.absolutePath
|
||||
))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("Error saving book: ${e.message}")
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(books) {
|
||||
showEmptyState = books.isEmpty()
|
||||
}
|
||||
|
||||
if (showDeleteDialog && bookToDelete != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteDialog = false },
|
||||
title = { Text("Delete Book") },
|
||||
text = { Text("Are you sure you want to delete '${bookToDelete?.title}'?") },
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Delete,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
"Delete Book",
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
"Are you sure you want to delete '${bookToDelete?.title}'? This action cannot be undone.",
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
scope.launch {
|
||||
bookToDelete?.let { bookStore.deleteBook(it.id) }
|
||||
}
|
||||
showDeleteDialog = false
|
||||
bookToDelete = null
|
||||
}) {
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
bookToDelete?.let { bookStore.deleteBook(it.id) }
|
||||
showDeleteDialog = false
|
||||
bookToDelete = null
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
Text("Delete")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = {
|
||||
showDeleteDialog = false
|
||||
bookToDelete = null
|
||||
}) {
|
||||
OutlinedButton(onClick = { showDeleteDialog = false }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
@ -185,42 +245,97 @@ fun BookshelfScreen(onBookClick: (String) -> Unit) {
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
LargeTopAppBar(
|
||||
title = {
|
||||
Column {
|
||||
Text(
|
||||
"Library",
|
||||
style = MaterialTheme.typography.headlineLarge
|
||||
)
|
||||
AnimatedVisibility(
|
||||
visible = !showEmptyState,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically()
|
||||
) {
|
||||
Text(
|
||||
"${books.size} book${if (books.size != 1) "s" else ""}",
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.largeTopAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
scrolledContainerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(onClick = { launcher.launch("application/epub+zip") }) {
|
||||
FloatingActionButton(
|
||||
onClick = { launcher.launch("application/epub+zip") },
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
) {
|
||||
Icon(Icons.Default.Add, contentDescription = "Add book")
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 128.dp),
|
||||
contentPadding = padding,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
) {
|
||||
items(books) { book ->
|
||||
Card(
|
||||
if (showEmptyState) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(8.dp)
|
||||
.combinedClickable(
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.MenuBook,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(96.dp),
|
||||
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
"Your library is empty",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
"Tap the + button to add your first book",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 160.dp),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
items(
|
||||
items = books,
|
||||
key = { it.id }
|
||||
) { book ->
|
||||
BookCard(
|
||||
book = book,
|
||||
onClick = { onBookClick(book.id) },
|
||||
onLongClick = {
|
||||
bookToDelete = book
|
||||
showDeleteDialog = true
|
||||
}
|
||||
)
|
||||
) {
|
||||
Column {
|
||||
AsyncImage(
|
||||
model = book.coverImageFile ?: R.drawable.ic_launcher_background,
|
||||
contentDescription = book.title,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(0.7f),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
Text(
|
||||
text = book.title,
|
||||
modifier = Modifier.padding(8.dp),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
},
|
||||
modifier = Modifier.animateItemPlacement()
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -229,27 +344,54 @@ fun BookshelfScreen(onBookClick: (String) -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun BookCover(book: Book, onClick: () -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.padding(8.dp)
|
||||
.clickable(onClick = onClick)
|
||||
private fun BookCard(
|
||||
book: Book,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val isPressed by interactionSource.collectIsPressedAsState()
|
||||
|
||||
ElevatedCard(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(0.7f)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onTap = { onClick() },
|
||||
onLongPress = { onLongClick() }
|
||||
)
|
||||
},
|
||||
elevation = CardDefaults.elevatedCardElevation(
|
||||
defaultElevation = 4.dp,
|
||||
pressedElevation = if (isPressed) 8.dp else 4.dp
|
||||
)
|
||||
) {
|
||||
Column {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
AsyncImage(
|
||||
model = book.coverImageFile ?: R.drawable.ic_launcher_background,
|
||||
contentDescription = book.title,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(0.7f),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
Text(
|
||||
text = book.title,
|
||||
modifier = Modifier.padding(8.dp),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.BottomCenter),
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f)
|
||||
) {
|
||||
Text(
|
||||
text = book.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2,290 +2,402 @@ package inhale.rip.epook
|
||||
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import kotlinx.coroutines.launch
|
||||
import nl.siegmann.epublib.domain.Book
|
||||
import nl.siegmann.epublib.epub.EpubReader
|
||||
import org.jsoup.Jsoup
|
||||
import java.io.File
|
||||
import inhale.rip.epook.data.SettingsStore
|
||||
import inhale.rip.epook.data.BookStore
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import inhale.rip.epook.data.SettingsStore
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jsoup.Jsoup
|
||||
import timber.log.Timber
|
||||
import java.nio.charset.Charset
|
||||
import nl.siegmann.epublib.domain.MediaType
|
||||
import nl.siegmann.epublib.domain.Resource
|
||||
|
||||
// Update the JavaScript for accurate page calculation
|
||||
private const val PAGE_CALCULATION_JS = """
|
||||
(function() {
|
||||
const content = document.body;
|
||||
const contentWidth = content.scrollWidth;
|
||||
const pageWidth = window.innerWidth;
|
||||
const totalPages = Math.max(1, Math.ceil(contentWidth / pageWidth));
|
||||
console.log('Content width:', contentWidth, 'Page width:', pageWidth, 'Total pages:', totalPages);
|
||||
return totalPages;
|
||||
})();
|
||||
"""
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ReaderScreen(
|
||||
bookId: String,
|
||||
onNavigateBack: () -> Unit,
|
||||
onOpenSettings: () -> Unit
|
||||
onNavigateBack: () -> Unit
|
||||
) {
|
||||
var book by remember { mutableStateOf<Book?>(null) }
|
||||
var currentHtml by remember { mutableStateOf<String?>(null) }
|
||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||
var currentPageIndex by remember { mutableStateOf(0) }
|
||||
var totalPages by remember { mutableStateOf(0) }
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val settingsStore = remember(context) { SettingsStore(context) }
|
||||
val fontFamily by settingsStore.fontFamily.collectAsState(initial = "Georgia")
|
||||
val fontSize by settingsStore.fontSize.collectAsState(initial = 16)
|
||||
val lineHeight by settingsStore.lineHeight.collectAsState(initial = 1.6f)
|
||||
val bookStore = remember { BookStore(context) }
|
||||
val savedPosition by bookStore.getReadingPosition(bookId).collectAsState(initial = 0)
|
||||
val settingsStore = remember { SettingsStore(context) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var showControls by remember { mutableStateOf(true) }
|
||||
var showSettings by remember { mutableStateOf(false) }
|
||||
var brightness by remember { mutableStateOf(1f) }
|
||||
var isDarkMode by remember { mutableStateOf(false) }
|
||||
var fontSize by remember { mutableStateOf(16f) }
|
||||
var lineHeight by remember { mutableStateOf(1.5f) }
|
||||
var padding by remember { mutableStateOf(16f) }
|
||||
var fontFamily by remember { mutableStateOf("Roboto") }
|
||||
var currentChapter by remember { mutableStateOf(0) }
|
||||
var showChapterList by remember { mutableStateOf(false) }
|
||||
var chapters by remember { mutableStateOf(listOf<String>()) }
|
||||
|
||||
var currentPage by remember { mutableStateOf(1) }
|
||||
var totalPages by remember { mutableStateOf(1) }
|
||||
var bookContent by remember { mutableStateOf("") }
|
||||
var webViewRef by remember { mutableStateOf<WebView?>(null) }
|
||||
|
||||
// Function to load content for a specific page
|
||||
fun loadPage(pageIndex: Int) {
|
||||
scope.launch {
|
||||
try {
|
||||
book?.spine?.spineReferences?.getOrNull(pageIndex)?.let { ref ->
|
||||
val resource = ref.resource
|
||||
val html = String(resource.data, Charsets.UTF_8)
|
||||
val doc = Jsoup.parse(html)
|
||||
// Move processChapterHtml outside the Composable
|
||||
fun processChapterHtml(rawHtml: String, chapterIndex: Int): String {
|
||||
val document = Jsoup.parse(rawHtml)
|
||||
val body = document.body()
|
||||
body.select("script, style").remove()
|
||||
return "<div class='chapter' id='chapter_$chapterIndex'>\n${body.html()}\n</div>"
|
||||
}
|
||||
|
||||
// Load book content
|
||||
LaunchedEffect(bookId) {
|
||||
try {
|
||||
Timber.d("📚 Starting to load book with ID: $bookId")
|
||||
val book = bookStore.getBook(bookId)
|
||||
Timber.d("📚 Book loaded successfully: ${book.title}")
|
||||
|
||||
// Load settings first
|
||||
val savedSettings = settingsStore.loadSettings()
|
||||
currentPage = savedSettings.currentPage
|
||||
fontSize = savedSettings.fontSize
|
||||
lineHeight = savedSettings.lineHeight
|
||||
padding = savedSettings.padding
|
||||
fontFamily = savedSettings.fontFamily
|
||||
isDarkMode = savedSettings.isDarkMode
|
||||
Timber.d("📚 Settings loaded - Page: $currentPage, Font: $fontSize, Line: $lineHeight, Padding: $padding")
|
||||
|
||||
// Process chapters
|
||||
val processedChapters = mutableListOf<String>()
|
||||
book.spine.spineReferences.forEachIndexed { index, spineReference ->
|
||||
Timber.d("📚 Processing chapter $index")
|
||||
val resource = book.resources.getByHref(spineReference.resource.href)
|
||||
Timber.d("📚 Resource href: ${resource.href}")
|
||||
Timber.d("📚 Resource media type: ${resource.mediaType}")
|
||||
|
||||
if (resource.mediaType == MediaType.XHTML) {
|
||||
val rawHtml = String(resource.data, Charset.forName("UTF-8"))
|
||||
Timber.d("📚 Raw HTML length: ${rawHtml.length}")
|
||||
|
||||
// Remove unnecessary elements but keep styling
|
||||
doc.select("script").remove()
|
||||
|
||||
// Add custom CSS for better reading experience
|
||||
val style = doc.head().appendElement("style")
|
||||
style.appendText("""
|
||||
body {
|
||||
font-family: '$fontFamily', serif;
|
||||
font-size: ${fontSize}px;
|
||||
line-height: $lineHeight;
|
||||
padding: 20px;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
color: #333;
|
||||
background-color: #f8f8f8;
|
||||
}
|
||||
p {
|
||||
margin: 1em 0;
|
||||
text-align: justify;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
color: #2c3e50;
|
||||
margin: 1.5em 0 0.5em;
|
||||
}
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
margin: 1em auto;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background-color: #1a1a1a;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
color: #b0b0b0;
|
||||
}
|
||||
}
|
||||
""".trimIndent())
|
||||
|
||||
currentHtml = doc.outerHtml()
|
||||
println("Loaded page $pageIndex")
|
||||
|
||||
// Save the position whenever we load a new page
|
||||
bookStore.updateReadingPosition(bookId, pageIndex)
|
||||
|
||||
} ?: run {
|
||||
errorMessage = "Page not found"
|
||||
val processedHtml = processChapterHtml(rawHtml, index)
|
||||
processedChapters.add(processedHtml)
|
||||
Timber.d("📚 Chapter $index processed successfully")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("Error loading page: ${e.message}")
|
||||
errorMessage = "Error loading page: ${e.message}"
|
||||
}
|
||||
|
||||
// Combine all chapters
|
||||
bookContent = processedChapters.joinToString("\n")
|
||||
Timber.d("📚 Final book content processed:")
|
||||
Timber.d("📚 - Total length: ${bookContent.length}")
|
||||
Timber.d("📚 - First 100 chars: ${bookContent.take(100)}")
|
||||
Timber.d("📚 - Last 100 chars: ${bookContent.takeLast(100)}")
|
||||
|
||||
// Update chapter list
|
||||
chapters = book.tableOfContents.tocReferences.map { it.title }
|
||||
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "📚 Error loading book")
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(bookId) {
|
||||
scope.launch {
|
||||
try {
|
||||
val bookFile = File(context.filesDir, "book_$bookId.epub")
|
||||
if (!bookFile.exists()) {
|
||||
errorMessage = "Book file not found"
|
||||
return@launch
|
||||
}
|
||||
val css = """
|
||||
:root {
|
||||
--page-width: calc(100vw - ${padding * 2}px);
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
padding: ${padding}px;
|
||||
width: var(--page-width);
|
||||
max-width: var(--page-width);
|
||||
height: calc(100vh - ${padding * 2}px);
|
||||
column-width: var(--page-width);
|
||||
column-gap: ${padding * 2}px;
|
||||
column-fill: auto;
|
||||
overflow-x: hidden;
|
||||
overflow-y: hidden;
|
||||
font-family: $fontFamily;
|
||||
font-size: ${fontSize}px;
|
||||
line-height: ${lineHeight}em;
|
||||
background-color: ${if (isDarkMode) "#1C1B1F" else "#FFFFFF"};
|
||||
color: ${if (isDarkMode) "#E6E1E5" else "#1C1B1F"};
|
||||
}
|
||||
.chapter {
|
||||
break-inside: avoid;
|
||||
margin-bottom: 2em;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
}
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
p {
|
||||
margin: 0.5em 0;
|
||||
text-align: justify;
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
bookFile.inputStream().buffered().use { inputStream ->
|
||||
val epubReader = EpubReader()
|
||||
book = epubReader.readEpub(inputStream)
|
||||
totalPages = book?.spine?.spineReferences?.size ?: 0
|
||||
println("Book loaded: ${book?.title}, total pages: $totalPages")
|
||||
|
||||
// Use saved position instead of always starting at the beginning
|
||||
currentPageIndex = savedPosition
|
||||
loadPage(currentPageIndex)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
errorMessage = "Error loading book: ${e.message}"
|
||||
}
|
||||
BackHandler(enabled = showSettings || showChapterList) {
|
||||
when {
|
||||
showSettings -> showSettings = false
|
||||
showChapterList -> showChapterList = false
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(book?.title ?: "Loading...") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
||||
AnimatedVisibility(
|
||||
visible = showControls,
|
||||
enter = slideInVertically() + fadeIn(),
|
||||
exit = slideOutVertically() + fadeOut()
|
||||
) {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Column {
|
||||
Text("Chapter ${currentChapter + 1}")
|
||||
Text(
|
||||
"Page $currentPage of $totalPages",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { showChapterList = true }) {
|
||||
Icon(Icons.Default.List, "Chapters")
|
||||
}
|
||||
IconButton(onClick = { showSettings = true }) {
|
||||
Icon(Icons.Default.Settings, "Settings")
|
||||
}
|
||||
IconButton(onClick = { isDarkMode = !isDarkMode }) {
|
||||
Icon(
|
||||
if (isDarkMode) Icons.Default.LightMode else Icons.Default.DarkMode,
|
||||
"Toggle theme"
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = onOpenSettings) {
|
||||
Icon(Icons.Default.Settings, contentDescription = "Settings")
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
) {
|
||||
when {
|
||||
errorMessage != null -> {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ArrowBack,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = errorMessage ?: "Unknown error",
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
currentHtml == null -> {
|
||||
Column(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text("Loading book...")
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.pointerInput(Unit) {
|
||||
detectDragGestures { change, dragAmount ->
|
||||
change.consume()
|
||||
val (x, _) = dragAmount
|
||||
when {
|
||||
x > 50 && currentPageIndex > 0 -> {
|
||||
currentPageIndex--
|
||||
loadPage(currentPageIndex)
|
||||
}
|
||||
x < -50 && currentPageIndex < totalPages - 1 -> {
|
||||
currentPageIndex++
|
||||
loadPage(currentPageIndex)
|
||||
Box(modifier = Modifier.padding(padding)) {
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
WebView(context).apply {
|
||||
settings.apply {
|
||||
javaScriptEnabled = true
|
||||
useWideViewPort = true
|
||||
loadWithOverviewMode = true
|
||||
defaultFontSize = fontSize.toInt()
|
||||
Timber.d("📚 WebView settings configured")
|
||||
}
|
||||
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun onPageStarted(view: WebView?, url: String?, favicon: android.graphics.Bitmap?) {
|
||||
super.onPageStarted(view, url, favicon)
|
||||
Timber.d("📚 WebView page load started")
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
super.onPageFinished(view, url)
|
||||
Timber.d("📚 WebView page load finished")
|
||||
|
||||
// Add a slight delay to ensure content is fully laid out
|
||||
view?.postDelayed({
|
||||
Timber.d("📚 Starting page calculation")
|
||||
view.evaluateJavascript(PAGE_CALCULATION_JS) { result ->
|
||||
try {
|
||||
val pages = result.toInt()
|
||||
Timber.d("📚 Page calculation complete - Total pages: $pages")
|
||||
totalPages = pages
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "📚 Error calculating pages")
|
||||
}
|
||||
}
|
||||
|
||||
// Log the current HTML content for debugging
|
||||
view.evaluateJavascript(
|
||||
"(function() { return document.documentElement.outerHTML; })()",
|
||||
) { result ->
|
||||
Timber.d("📚 Current HTML content length: ${result.length}")
|
||||
Timber.d("📚 First 100 chars of HTML: ${result.take(100)}")
|
||||
}
|
||||
|
||||
// Scroll to last position
|
||||
view.evaluateJavascript(
|
||||
"window.scrollTo({left: (${currentPage - 1}) * window.innerWidth, behavior: 'auto'})",
|
||||
null
|
||||
)
|
||||
}, 500)
|
||||
}
|
||||
|
||||
override fun onReceivedError(
|
||||
view: WebView?,
|
||||
errorCode: Int,
|
||||
description: String?,
|
||||
failingUrl: String?
|
||||
) {
|
||||
super.onReceivedError(view, errorCode, description, failingUrl)
|
||||
Timber.e("📚 WebView error: $errorCode - $description")
|
||||
}
|
||||
}
|
||||
|
||||
Timber.d("📚 Loading content into WebView")
|
||||
loadDataWithBaseURL(
|
||||
null,
|
||||
"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
|
||||
<style>$css</style>
|
||||
</head>
|
||||
<body>$bookContent</body>
|
||||
</html>
|
||||
""".trimIndent(),
|
||||
"text/html",
|
||||
"UTF-8",
|
||||
null
|
||||
)
|
||||
webViewRef = this
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures { offset ->
|
||||
val screenWidth = this.size.width.toFloat()
|
||||
when {
|
||||
offset.x < screenWidth * 0.3f && currentPage > 1 -> {
|
||||
currentPage--
|
||||
webViewRef?.evaluateJavascript(
|
||||
"window.scrollTo({left: (${currentPage - 1}) * window.innerWidth, behavior: 'smooth'})",
|
||||
null
|
||||
)
|
||||
}
|
||||
offset.x > screenWidth * 0.7f && currentPage < totalPages -> {
|
||||
currentPage++
|
||||
webViewRef?.evaluateJavascript(
|
||||
"window.scrollTo({left: (${currentPage - 1}) * window.innerWidth, behavior: 'smooth'})",
|
||||
null
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
showControls = !showControls
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Settings sheet
|
||||
if (showSettings) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { showSettings = false }
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
// WebView for rendering HTML content
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
WebView(context).apply {
|
||||
settings.apply {
|
||||
javaScriptEnabled = false
|
||||
builtInZoomControls = true
|
||||
displayZoomControls = false
|
||||
useWideViewPort = true
|
||||
loadWithOverviewMode = true
|
||||
}
|
||||
webViewClient = WebViewClient()
|
||||
setBackgroundColor(android.graphics.Color.TRANSPARENT)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
update = { webView ->
|
||||
webView.loadDataWithBaseURL(
|
||||
null,
|
||||
currentHtml ?: "",
|
||||
"text/html",
|
||||
"UTF-8",
|
||||
Text("Reading Settings", style = MaterialTheme.typography.titleLarge)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text("Font Size: $fontSize", style = MaterialTheme.typography.bodyMedium)
|
||||
Slider(
|
||||
value = fontSize,
|
||||
onValueChange = {
|
||||
fontSize = it
|
||||
// Update WebView font size
|
||||
webViewRef?.evaluateJavascript(
|
||||
"document.body.style.fontSize = '${fontSize}px'",
|
||||
null
|
||||
)
|
||||
}
|
||||
},
|
||||
valueRange = 12f..24f,
|
||||
steps = 11
|
||||
)
|
||||
|
||||
// Navigation controls
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (currentPageIndex > 0) {
|
||||
currentPageIndex--
|
||||
loadPage(currentPageIndex)
|
||||
}
|
||||
},
|
||||
enabled = currentPageIndex > 0
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ArrowBack,
|
||||
contentDescription = "Previous page"
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "Page ${currentPageIndex + 1} of $totalPages",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
// Similar sliders for lineHeight, padding, brightness
|
||||
// Font family selector
|
||||
// Color theme selector
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Chapter list
|
||||
if (showChapterList) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { showChapterList = false }
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
items(chapters) { chapter ->
|
||||
ListItem(
|
||||
headlineContent = { Text(chapter) },
|
||||
modifier = Modifier.clickable {
|
||||
// Navigate to chapter
|
||||
webViewRef?.evaluateJavascript(
|
||||
"document.getElementById('chapter_$currentChapter').scrollIntoView()",
|
||||
null
|
||||
)
|
||||
showChapterList = false
|
||||
}
|
||||
)
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (currentPageIndex < totalPages - 1) {
|
||||
currentPageIndex++
|
||||
loadPage(currentPageIndex)
|
||||
}
|
||||
},
|
||||
enabled = currentPageIndex < totalPages - 1
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ArrowBack,
|
||||
contentDescription = "Next page",
|
||||
modifier = Modifier.rotate(180f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Brightness overlay
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 1f - brightness))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Save reading progress when leaving
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
scope.launch {
|
||||
settingsStore.saveLastPosition(bookId, currentPage)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,7 @@ package inhale.rip.epook
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
@ -14,15 +14,21 @@ import kotlinx.coroutines.launch
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
onNavigateBack: () -> Unit
|
||||
onNavigateBack: () -> Unit,
|
||||
settingsStore: SettingsStore
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val settingsStore = remember { SettingsStore(context) }
|
||||
val scope = rememberCoroutineScope()
|
||||
var fontSize by remember { mutableStateOf(16f) }
|
||||
var lineHeight by remember { mutableStateOf(1.5f) }
|
||||
var padding by remember { mutableStateOf(16f) }
|
||||
var currentFontFamily by remember { mutableStateOf("Roboto") }
|
||||
|
||||
val fontFamily by settingsStore.fontFamily.collectAsState(initial = "Georgia")
|
||||
val fontSize by settingsStore.fontSize.collectAsState(initial = 16)
|
||||
val lineHeight by settingsStore.lineHeight.collectAsState(initial = 1.6f)
|
||||
LaunchedEffect(Unit) {
|
||||
fontSize = settingsStore.getFontSize()
|
||||
lineHeight = settingsStore.getLineHeight()
|
||||
padding = settingsStore.getPadding()
|
||||
currentFontFamily = settingsStore.getFontFamily()
|
||||
}
|
||||
|
||||
val fontFamilies = listOf("Georgia", "Roboto", "Times New Roman", "Arial", "Verdana")
|
||||
|
||||
@ -32,16 +38,16 @@ fun SettingsScreen(
|
||||
title = { Text("Settings") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
) { contentPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(contentPadding)
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
@ -57,8 +63,9 @@ fun SettingsScreen(
|
||||
.padding(vertical = 8.dp)
|
||||
) {
|
||||
RadioButton(
|
||||
selected = font == fontFamily,
|
||||
selected = currentFontFamily == font,
|
||||
onClick = {
|
||||
currentFontFamily = font
|
||||
scope.launch {
|
||||
settingsStore.updateFontFamily(font)
|
||||
}
|
||||
@ -78,10 +85,11 @@ fun SettingsScreen(
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Slider(
|
||||
value = fontSize.toFloat(),
|
||||
value = fontSize,
|
||||
onValueChange = {
|
||||
fontSize = it
|
||||
scope.launch {
|
||||
settingsStore.updateFontSize(it.toInt())
|
||||
settingsStore.updateFontSize(it)
|
||||
}
|
||||
},
|
||||
valueRange = 12f..24f,
|
||||
@ -97,6 +105,7 @@ fun SettingsScreen(
|
||||
Slider(
|
||||
value = lineHeight,
|
||||
onValueChange = {
|
||||
lineHeight = it
|
||||
scope.launch {
|
||||
settingsStore.updateLineHeight(it)
|
||||
}
|
||||
@ -104,6 +113,24 @@ fun SettingsScreen(
|
||||
valueRange = 1f..2f,
|
||||
steps = 9
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "Padding: $padding",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Slider(
|
||||
value = padding,
|
||||
onValueChange = {
|
||||
padding = it
|
||||
scope.launch {
|
||||
settingsStore.updatePadding(it)
|
||||
}
|
||||
},
|
||||
valueRange = 8f..32f,
|
||||
steps = 11
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -9,41 +9,29 @@ import kotlinx.coroutines.flow.map
|
||||
import java.io.File
|
||||
import inhale.rip.epook.data.AppDatabase
|
||||
import android.util.Log
|
||||
import nl.siegmann.epublib.domain.Book as EpubBook
|
||||
import nl.siegmann.epublib.epub.EpubReader
|
||||
import java.io.FileInputStream
|
||||
|
||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "books")
|
||||
|
||||
class BookStore(private val context: Context) {
|
||||
private val TAG = "BookStore" // Tag for logging
|
||||
private val TAG = "BookStore"
|
||||
private val bookIdsKey = stringSetPreferencesKey("book_ids")
|
||||
private val bookDao = AppDatabase.getDatabase(context).bookDao()
|
||||
|
||||
fun getAllBooks(): Flow<List<Book>> = bookDao.getAllBooks().map { entities ->
|
||||
entities.mapNotNull { entity ->
|
||||
try {
|
||||
val bookFile = File(entity.filePath)
|
||||
if (bookFile.exists()) {
|
||||
entity.toBook()
|
||||
} else {
|
||||
// If the file doesn't exist, delete the database entry
|
||||
Log.d(TAG, "Book file missing for ${entity.id}, cleaning up database entry")
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error loading book ${entity.id}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
entities.map { it.toBook() }
|
||||
}
|
||||
|
||||
suspend fun addBook(book: Book) {
|
||||
context.dataStore.edit { preferences ->
|
||||
val currentIds = preferences[bookIdsKey]?.toMutableSet() ?: mutableSetOf()
|
||||
currentIds.add(book.id)
|
||||
preferences[bookIdsKey] = currentIds
|
||||
preferences[stringPreferencesKey("title_${book.id}")] = book.title
|
||||
}
|
||||
// Also add to Room database
|
||||
bookDao.insertBook(book.toEntity())
|
||||
bookDao.insertBook(BookEntity(
|
||||
id = book.id,
|
||||
title = book.title,
|
||||
coverImagePath = book.coverImageFile?.absolutePath,
|
||||
filePath = book.filePath,
|
||||
readingPosition = 0
|
||||
))
|
||||
}
|
||||
|
||||
suspend fun updateReadingPosition(bookId: String, position: Int) {
|
||||
@ -55,11 +43,10 @@ class BookStore(private val context: Context) {
|
||||
}
|
||||
|
||||
suspend fun deleteBook(bookId: String) {
|
||||
Log.e(TAG, "🔥 DELETE BOOK STARTED 🔥") // Very visible error log
|
||||
Log.e(TAG, "🔥 DELETE BOOK STARTED 🔥")
|
||||
Log.e(TAG, "Attempting to delete book with ID: $bookId")
|
||||
|
||||
try {
|
||||
// Check if book exists in Room database
|
||||
val bookEntity = bookDao.getBook(bookId)
|
||||
Log.e(TAG, if (bookEntity != null) {
|
||||
"Found book in database: ${bookEntity.title} (ID: ${bookEntity.id})"
|
||||
@ -68,56 +55,25 @@ class BookStore(private val context: Context) {
|
||||
})
|
||||
|
||||
if (bookEntity != null) {
|
||||
// Try to delete from Room database
|
||||
try {
|
||||
bookDao.deleteBook(bookEntity)
|
||||
Log.e(TAG, "Successfully deleted from database: ${bookEntity.title}")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to delete from database: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
// File deletion
|
||||
val bookFile = File(context.filesDir, "book_$bookId.epub")
|
||||
Log.e(TAG, "Book file path: ${bookFile.absolutePath}")
|
||||
|
||||
if (bookFile.exists()) {
|
||||
Log.e(TAG, "Book file exists, size: ${bookFile.length()} bytes")
|
||||
val deleted = bookFile.delete()
|
||||
Log.e(TAG, "Book file deleted: $deleted")
|
||||
} else {
|
||||
Log.e(TAG, "Book file does not exist")
|
||||
}
|
||||
|
||||
val coverFile = File(context.filesDir, "cover_$bookId.jpg")
|
||||
Log.e(TAG, "Cover file path: ${coverFile.absolutePath}")
|
||||
|
||||
if (coverFile.exists()) {
|
||||
Log.e(TAG, "Cover file exists, size: ${coverFile.length()} bytes")
|
||||
val deleted = coverFile.delete()
|
||||
Log.e(TAG, "Cover file deleted: $deleted")
|
||||
} else {
|
||||
Log.e(TAG, "Cover file does not exist")
|
||||
}
|
||||
|
||||
// DataStore cleanup
|
||||
Log.e(TAG, "Starting DataStore cleanup")
|
||||
try {
|
||||
context.dataStore.edit { preferences ->
|
||||
val currentIds = preferences[bookIdsKey]?.toMutableSet() ?: mutableSetOf()
|
||||
Log.e(TAG, "Current IDs in DataStore: $currentIds")
|
||||
val removed = currentIds.remove(bookId)
|
||||
Log.e(TAG, "ID removed from DataStore: $removed")
|
||||
preferences[bookIdsKey] = currentIds
|
||||
Log.e(TAG, "Updated IDs in DataStore: $currentIds")
|
||||
Log.e(TAG, "Successfully deleted book from Room database")
|
||||
|
||||
preferences.remove(stringPreferencesKey("title_$bookId"))
|
||||
preferences.remove(stringPreferencesKey("position_$bookId"))
|
||||
Log.e(TAG, "Removed related preferences for book ID: $bookId")
|
||||
// Delete associated files
|
||||
val bookFile = File(bookEntity.filePath)
|
||||
if (bookFile.exists() && bookFile.delete()) {
|
||||
Log.e(TAG, "Successfully deleted book file")
|
||||
}
|
||||
|
||||
bookEntity.coverImagePath?.let { coverPath ->
|
||||
val coverFile = File(coverPath)
|
||||
if (coverFile.exists() && coverFile.delete()) {
|
||||
Log.e(TAG, "Successfully deleted cover image")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error deleting book files", e)
|
||||
}
|
||||
Log.e(TAG, "DataStore cleanup completed successfully")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error during DataStore cleanup", e)
|
||||
}
|
||||
|
||||
Log.e(TAG, "🔥 DELETE BOOK COMPLETED SUCCESSFULLY 🔥")
|
||||
@ -128,4 +84,9 @@ class BookStore(private val context: Context) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getBook(bookId: String): EpubBook {
|
||||
val bookEntity = bookDao.getBook(bookId) ?: throw IllegalArgumentException("Book not found")
|
||||
return EpubReader().readEpub(FileInputStream(bookEntity.filePath))
|
||||
}
|
||||
}
|
||||
@ -2,45 +2,83 @@ package inhale.rip.epook.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.*
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.floatPreferencesKey
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
private val Context.settingsDataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
|
||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
|
||||
|
||||
class SettingsStore(private val context: Context) {
|
||||
private val fontFamilyKey = stringPreferencesKey("font_family")
|
||||
private val fontSizeKey = intPreferencesKey("font_size")
|
||||
private val lineHeightKey = floatPreferencesKey("line_height")
|
||||
|
||||
val fontFamily: Flow<String> = context.settingsDataStore.data.map { preferences ->
|
||||
preferences[fontFamilyKey] ?: "Georgia"
|
||||
private object PreferencesKeys {
|
||||
val FONT_SIZE = floatPreferencesKey("font_size")
|
||||
val LINE_HEIGHT = floatPreferencesKey("line_height")
|
||||
val PADDING = floatPreferencesKey("padding")
|
||||
val FONT_FAMILY = stringPreferencesKey("font_family")
|
||||
fun lastPositionKey(bookId: String) = intPreferencesKey("last_position_$bookId")
|
||||
}
|
||||
|
||||
val fontSize: Flow<Int> = context.settingsDataStore.data.map { preferences ->
|
||||
preferences[fontSizeKey] ?: 16
|
||||
suspend fun getFontSize(): Float {
|
||||
return context.dataStore.data.map { preferences ->
|
||||
preferences[PreferencesKeys.FONT_SIZE] ?: 16f
|
||||
}.first()
|
||||
}
|
||||
|
||||
val lineHeight: Flow<Float> = context.settingsDataStore.data.map { preferences ->
|
||||
preferences[lineHeightKey] ?: 1.6f
|
||||
suspend fun getLineHeight(): Float {
|
||||
return context.dataStore.data.map { preferences ->
|
||||
preferences[PreferencesKeys.LINE_HEIGHT] ?: 1.5f
|
||||
}.first()
|
||||
}
|
||||
|
||||
suspend fun updateFontFamily(fontFamily: String) {
|
||||
context.settingsDataStore.edit { preferences ->
|
||||
preferences[fontFamilyKey] = fontFamily
|
||||
}
|
||||
suspend fun getPadding(): Float {
|
||||
return context.dataStore.data.map { preferences ->
|
||||
preferences[PreferencesKeys.PADDING] ?: 16f
|
||||
}.first()
|
||||
}
|
||||
|
||||
suspend fun updateFontSize(size: Int) {
|
||||
context.settingsDataStore.edit { preferences ->
|
||||
preferences[fontSizeKey] = size
|
||||
suspend fun updateFontSize(size: Float) {
|
||||
context.dataStore.edit { preferences ->
|
||||
preferences[PreferencesKeys.FONT_SIZE] = size
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateLineHeight(height: Float) {
|
||||
context.settingsDataStore.edit { preferences ->
|
||||
preferences[lineHeightKey] = height
|
||||
context.dataStore.edit { preferences ->
|
||||
preferences[PreferencesKeys.LINE_HEIGHT] = height
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updatePadding(padding: Float) {
|
||||
context.dataStore.edit { preferences ->
|
||||
preferences[PreferencesKeys.PADDING] = padding
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getFontFamily(): String {
|
||||
return context.dataStore.data.map { preferences ->
|
||||
preferences[PreferencesKeys.FONT_FAMILY] ?: "Roboto"
|
||||
}.first()
|
||||
}
|
||||
|
||||
suspend fun updateFontFamily(family: String) {
|
||||
context.dataStore.edit { preferences ->
|
||||
preferences[PreferencesKeys.FONT_FAMILY] = family
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getLastPosition(bookId: String): Int? {
|
||||
return context.dataStore.data.map { preferences ->
|
||||
preferences[PreferencesKeys.lastPositionKey(bookId)]
|
||||
}.first()
|
||||
}
|
||||
|
||||
suspend fun saveLastPosition(bookId: String, position: Int) {
|
||||
context.dataStore.edit { preferences ->
|
||||
preferences[PreferencesKeys.lastPositionKey(bookId)] = position
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -3,40 +3,46 @@ package inhale.rip.epook.ui.theme
|
||||
import android.app.Activity
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = Purple80,
|
||||
secondary = PurpleGrey80,
|
||||
tertiary = Pink80
|
||||
private val LightColors = lightColorScheme(
|
||||
primary = Color(0xFF2D5DA1),
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = Color(0xFFD5E3FF),
|
||||
onPrimaryContainer = Color(0xFF001B3D),
|
||||
secondary = Color(0xFF565E71),
|
||||
onSecondary = Color.White,
|
||||
secondaryContainer = Color(0xFFDAE2F9),
|
||||
background = Color(0xFFFBFCFF),
|
||||
surface = Color(0xFFFBFCFF),
|
||||
surfaceVariant = Color(0xFFE1E2EC),
|
||||
onSurfaceVariant = Color(0xFF44474F)
|
||||
)
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = Purple40,
|
||||
secondary = PurpleGrey40,
|
||||
tertiary = Pink40
|
||||
|
||||
/* Other default colors to override
|
||||
background = Color(0xFFFFFBFE),
|
||||
surface = Color(0xFFFFFBFE),
|
||||
onPrimary = Color.White,
|
||||
onSecondary = Color.White,
|
||||
onTertiary = Color.White,
|
||||
onBackground = Color(0xFF1C1B1F),
|
||||
onSurface = Color(0xFF1C1B1F),
|
||||
*/
|
||||
private val DarkColors = darkColorScheme(
|
||||
primary = Color(0xFFA6C8FF),
|
||||
onPrimary = Color(0xFF003062),
|
||||
primaryContainer = Color(0xFF004689),
|
||||
onPrimaryContainer = Color(0xFFD5E3FF),
|
||||
secondary = Color(0xFFBEC6DC),
|
||||
onSecondary = Color(0xFF283041),
|
||||
secondaryContainer = Color(0xFF3E4759),
|
||||
background = Color(0xFF1B1B1F),
|
||||
surface = Color(0xFF1B1B1F),
|
||||
surfaceVariant = Color(0xFF44474F),
|
||||
onSurfaceVariant = Color(0xFFC4C6D0)
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun EpookTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
// Dynamic color is available on Android 12+
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
@ -45,9 +51,17 @@ fun EpookTheme(
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
darkTheme -> DarkColors
|
||||
else -> LightColors
|
||||
}
|
||||
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
val view = LocalView.current
|
||||
if (!view.isInEditMode) {
|
||||
SideEffect {
|
||||
val window = (view.context as Activity).window
|
||||
window.statusBarColor = colorScheme.surface.toArgb()
|
||||
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
|
||||
}
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
|
||||
@ -6,29 +6,41 @@ import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
// Set of Material typography styles to start with
|
||||
val Typography = Typography(
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
/* Other default text styles to override
|
||||
headlineLarge = TextStyle(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 32.sp,
|
||||
lineHeight = 40.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
headlineMedium = TextStyle(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 28.sp,
|
||||
lineHeight = 36.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
bodyLarge = TextStyle(
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
),
|
||||
bodyMedium = TextStyle(
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.25.sp
|
||||
),
|
||||
labelMedium = TextStyle(
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
*/
|
||||
)
|
||||
Loading…
Reference in New Issue
Block a user