first official release

This commit is contained in:
inhale-dir
2024-12-13 15:14:13 +01:00
parent d73f54eeb4
commit 6e795bd7d1
3 changed files with 358 additions and 249 deletions
+79
View File
@@ -0,0 +1,79 @@
# 📚 Epook - Modern EPUB Reader
Epook is a sleek, modern EPUB reader built for Android using Jetpack Compose. It provides a clean, intuitive interface for reading your favorite ebooks with powerful features and smooth animations.
## ✨ Features
### 📖 Reading Experience
- Fluid chapter navigation with swipe gestures
- Progress tracking across reading sessions
- Interactive chapter selection through table of contents
- Smooth animations for UI elements
- Full-screen reading mode with tap controls
### 📱 User Interface
- Material Design 3 with dynamic theming
- Animated controls overlay
- Bottom navigation bar with chapter progress
- Clean, minimalist book library view
- Beautiful book cards with cover display
### ⚙️ Customization
- Adjustable font size (12-24sp)
- Customizable line height (1.0x-2.0x)
- Margin control (8-32dp)
- Multiple font family options:
- Georgia
- Roboto
- Times New Roman
- Arial
- Verdana
### 📚 Library Management
- Import EPUB files
- Automatic cover image extraction
- Book deletion with confirmation
- Reading progress persistence
- Organized book collection view
### 🔧 Technical Features
- CSS stylesheet handling
- HTML content processing
- Efficient file management
- WebView-based rendering
- Resource caching
## 🛠️ Technical Stack
- **UI Framework**: Jetpack Compose
- **Language**: Kotlin
- **Architecture**: MVVM
- **Storage**: Room Database
- **EPUB Processing**: epublib
- **HTML Processing**: JSoup
- **Image Loading**: Coil
- **Logging**: Timber
## 🎯 Upcoming Features
- [ ] Search functionality
- [ ] Bookmarks
- [ ] Highlights and notes
- [ ] Dark mode support
- [ ] Reading statistics
- [ ] Custom themes
- [ ] Cloud sync
## 🤝 Contributing
Contributions are welcome! Feel free to submit issues and pull requests.
## 📄 License
This project is licensed under the MIT License - see the LICENSE file for details.
## 🙏 Acknowledgments
- [epublib](https://github.com/psiegman/epublib) for EPUB processing
- [Jsoup](https://jsoup.org/) for HTML parsing
- [Material Design 3](https://m3.material.io/) for design guidelines
@@ -47,6 +47,11 @@ import androidx.compose.foundation.background
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.SmallTopAppBar
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@@ -173,6 +178,26 @@ fun MainScreen(
} }
Scaffold( Scaffold(
topBar = {
SmallTopAppBar(
title = {
Column {
Text(
text = "My Library",
style = MaterialTheme.typography.titleLarge
)
Text(
text = "${books.size} book${if (books.size != 1) "s" else ""}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
}
},
colors = TopAppBarDefaults.smallTopAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
},
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
floatingActionButton = { floatingActionButton = {
FloatingActionButton( FloatingActionButton(
@@ -195,10 +220,10 @@ fun MainScreen(
) )
} else { } else {
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 120.dp), columns = GridCells.Adaptive(minSize = 140.dp),
contentPadding = PaddingValues(12.dp), contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(24.dp),
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding) .padding(padding)
@@ -254,56 +279,73 @@ private fun BookCard(
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState() val isPressed by interactionSource.collectIsPressedAsState()
ElevatedCard( Column(
modifier = modifier modifier = modifier.width(140.dp),
.fillMaxWidth() horizontalAlignment = Alignment.CenterHorizontally
.aspectRatio(0.75f)
.pointerInput(Unit) {
detectTapGestures(
onTap = { onClick() },
onLongPress = { onLongClick() }
)
},
elevation = CardDefaults.elevatedCardElevation(
defaultElevation = 2.dp,
pressedElevation = if (isPressed) 8.dp else 2.dp
),
colors = CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.surface
)
) { ) {
Box(modifier = Modifier.fillMaxSize()) { ElevatedCard(
// Book cover modifier = Modifier
AsyncImage( .fillMaxWidth()
model = book.coverPath ?: R.drawable.ic_book_24dp, .aspectRatio(0.67f) // Standard book cover ratio
contentDescription = book.title, .pointerInput(Unit) {
modifier = Modifier.fillMaxSize(), detectTapGestures(
contentScale = ContentScale.Crop, onTap = { onClick() },
onLongPress = { onLongClick() }
)
}
.shadow(
elevation = if (isPressed) 8.dp else 4.dp,
shape = RoundedCornerShape(8.dp)
),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
) )
) {
// Simple overlay for title Box(modifier = Modifier.fillMaxSize()) {
Box( // Book cover
modifier = Modifier AsyncImage(
.fillMaxWidth() model = book.coverPath ?: R.drawable.ic_book_24dp,
.background( contentDescription = book.title,
Brush.verticalGradient( modifier = Modifier.fillMaxSize(),
colors = listOf( contentScale = ContentScale.Crop,
Color.Transparent, )
Color.Black.copy(alpha = 0.6f)
// Gradient overlay
Box(
modifier = Modifier
.fillMaxWidth()
.height(80.dp)
.align(Alignment.BottomCenter)
.background(
brush = Brush.verticalGradient(
colors = listOf(
Color.Transparent,
Color.Black.copy(alpha = 0.7f)
)
) )
) )
)
.align(Alignment.BottomCenter)
.padding(8.dp)
) {
Text(
text = book.title,
style = MaterialTheme.typography.bodyMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
color = Color.White
) )
} }
} }
// Book info below the card
Text(
text = book.title,
style = MaterialTheme.typography.titleSmall,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 8.dp)
)
Text(
text = book.author,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 2.dp)
)
} }
} }
+189 -201
View File
@@ -7,6 +7,7 @@ import androidx.compose.animation.*
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
@@ -77,9 +78,10 @@ fun ReaderScreen(
var showChapterList by remember { mutableStateOf(false) } var showChapterList by remember { mutableStateOf(false) }
var showSettings by remember { mutableStateOf(false) } var showSettings by remember { mutableStateOf(false) }
val backgroundColor = MaterialTheme.colorScheme.background.toArgb() var currentX by remember { mutableStateOf(0f) }
val textColor = MaterialTheme.colorScheme.onBackground.toArgb()
val primaryColor = MaterialTheme.colorScheme.primary.toArgb() var extractedPath by remember { mutableStateOf<String?>(null) }
var webView by remember { mutableStateOf<WebView?>(null) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
try { try {
@@ -147,235 +149,221 @@ fun ReaderScreen(
} }
} }
Scaffold( Box(modifier = Modifier.fillMaxSize()) {
topBar = {
AnimatedVisibility(
visible = showControls,
enter = fadeIn() + slideInVertically(),
exit = fadeOut() + slideOutVertically()
) {
TopAppBar(
title = {
Column {
Text(
text = book?.title ?: "Reader",
style = MaterialTheme.typography.titleLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = "Chapter ${currentChapterIndex + 1} of ${chapters.size}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
}
},
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(onClick = { showChapterList = true }) {
Icon(Icons.AutoMirrored.Filled.List, contentDescription = "Chapters")
}
IconButton(onClick = { showSettings = true }) {
Icon(Icons.Default.Settings, contentDescription = "Settings")
}
}
)
}
}
) { padding ->
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding) .padding(bottom = if (showControls) 80.dp else 0.dp)
) { .pointerInput(Unit) {
if (chapters.isNotEmpty()) { detectTapGestures(
AndroidView( onTap = { showControls = !showControls }
modifier = Modifier )
.fillMaxSize() }
.pointerInput(Unit) { .pointerInput(Unit) {
detectTapGestures( var initialX = 0f
onTap = { showControls = !showControls } detectHorizontalDragGestures(
) onDragStart = { offset ->
initialX = offset.x
}, },
factory = { context -> onDragEnd = {
var extractedPath: String? = null // Implement drag threshold for swipe
val dragThreshold = size.width * 0.2f // 20% of screen width
WebView(context).apply { val dragDistance = initialX - currentX
webViewClient = object : WebViewClient() {
override fun onReceivedError(
view: WebView?,
request: WebResourceRequest?,
error: WebResourceError?
) {
Timber.e("WebView error loading ${request?.url}: ${error?.description}")
super.onReceivedError(view, request, error)
}
override fun shouldInterceptRequest(
view: WebView?,
request: WebResourceRequest
): WebResourceResponse? {
val url = request.url.toString()
Timber.d("Loading resource: $url")
// Try to load the resource from the extracted directory
try {
if (url.endsWith(".css") && extractedPath != null) {
// Try multiple possible CSS file locations
val possiblePaths = listOf(
url.substringAfterLast("/"), // stylesheet.css
"Styles/${url.substringAfterLast("/")}", // Styles/stylesheet.css
url.substringAfter("/extracted/") // Text/Styles/stylesheet.css
)
for (cssPath in possiblePaths) {
val cssFile = File(File(extractedPath!!), cssPath)
if (cssFile.exists()) {
Timber.d("Found CSS file at: ${cssFile.absolutePath}")
return WebResourceResponse(
"text/css",
"UTF-8",
cssFile.inputStream()
)
}
}
Timber.e("CSS file not found. Tried paths: ${possiblePaths.joinToString()}")
}
} catch (e: Exception) {
Timber.e(e, "Error intercepting resource request")
}
return super.shouldInterceptRequest(view, request)
}
}
settings.apply { when {
javaScriptEnabled = true dragDistance > dragThreshold && currentChapterIndex < chapters.size - 1 -> {
builtInZoomControls = true // Swiped left - next chapter
displayZoomControls = false currentChapterIndex++
allowFileAccess = true }
allowFileAccessFromFileURLs = true dragDistance < -dragThreshold && currentChapterIndex > 0 -> {
allowUniversalAccessFromFileURLs = true // Swiped right - previous chapter
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW currentChapterIndex--
domStorageEnabled = true
// Remove deprecated calls
// setAppCacheEnabled and setAppCachePath are deprecated
cacheMode = WebSettings.LOAD_DEFAULT
}
}.also { webView ->
// Update extractedPath when loading URLs
scope.launch {
try {
extractedPath = bookStore.getBook(bookId)?.extractedPath
?: throw IllegalStateException("Book not properly extracted")
} catch (e: Exception) {
Timber.e(e, "Error getting extracted path")
} }
} }
} }
}, ) { change, _ ->
update = { webView -> currentX = change.position.x
val chapter = chapters.getOrNull(currentChapterIndex)?.resource change.consume()
chapter?.let { resource -> }
scope.launch { }
) {
AndroidView(
factory = { context ->
WebView(context).apply {
webViewClient = object : WebViewClient() {
override fun onReceivedError(
view: WebView?,
request: WebResourceRequest?,
error: WebResourceError?
) {
Timber.e("WebView error loading ${request?.url}: ${error?.description}")
super.onReceivedError(view, request, error)
}
override fun shouldInterceptRequest(
view: WebView?,
request: WebResourceRequest
): WebResourceResponse? {
val url = request.url.toString()
Timber.d("Loading resource: $url")
try { try {
val extractedPath = bookStore.getBook(bookId)?.extractedPath if (url.endsWith(".css") && extractedPath != null) {
?: throw IllegalStateException("Book not properly extracted") val possiblePaths = listOf(
url.substringAfterLast("/"),
val baseUrl = "file://$extractedPath/" "Styles/${url.substringAfterLast("/")}",
val fullUrl = baseUrl + resource.href.replace("../", "") url.substringAfter("/extracted/")
)
Timber.d("Loading chapter from: $fullUrl")
Timber.d("Base URL: $baseUrl") for (cssPath in possiblePaths) {
val cssFile = File(extractedPath!!, cssPath)
webView.loadUrl(fullUrl) if (cssFile.exists()) {
Timber.d("Found CSS file at: ${cssFile.absolutePath}")
return WebResourceResponse(
"text/css",
"UTF-8",
cssFile.inputStream()
)
}
}
}
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "Error loading chapter") Timber.e(e, "Error intercepting resource request")
} }
return super.shouldInterceptRequest(view, request)
}
}
settings.apply {
javaScriptEnabled = true
builtInZoomControls = true
displayZoomControls = false
allowFileAccess = true
@Suppress("DEPRECATION")
allowFileAccessFromFileURLs = true
@Suppress("DEPRECATION")
allowUniversalAccessFromFileURLs = true
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
domStorageEnabled = true
cacheMode = WebSettings.LOAD_DEFAULT
}
}.also { webView = it }
},
update = { view ->
val chapter = chapters.getOrNull(currentChapterIndex)?.resource
chapter?.let { resource ->
scope.launch {
try {
extractedPath = bookStore.getBook(bookId)?.extractedPath
?: throw IllegalStateException("Book not properly extracted")
val baseUrl = "file://$extractedPath/"
val fullUrl = baseUrl + resource.href.replace("../", "")
Timber.d("Loading chapter from: $fullUrl")
Timber.d("Base URL: $baseUrl")
view.loadUrl(fullUrl)
} catch (e: Exception) {
Timber.e(e, "Error loading chapter")
} }
} }
} }
) },
} modifier = Modifier.fillMaxSize()
)
}
// Navigation controls // Controls overlay
AnimatedVisibility( AnimatedVisibility(
visible = showControls, visible = showControls,
modifier = Modifier.align(Alignment.BottomCenter), enter = slideInVertically(initialOffsetY = { it }),
enter = fadeIn() + slideInVertically { it }, exit = slideOutVertically(targetOffsetY = { it }),
exit = fadeOut() + slideOutVertically { it } modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
) {
Surface(
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f),
tonalElevation = 8.dp,
shadowElevation = 8.dp
) { ) {
Surface( Column {
modifier = Modifier // Chapter progress
.fillMaxWidth() LinearProgressIndicator(
.padding(16.dp), progress = { currentChapterIndex.toFloat() / (chapters.size - 1).coerceAtLeast(1).toFloat() },
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f), modifier = Modifier
tonalElevation = 3.dp, .fillMaxWidth()
shape = MaterialTheme.shapes.large .height(2.dp)
) { )
// Controls
Row( Row(
modifier = Modifier modifier = Modifier
.padding(horizontal = 24.dp, vertical = 16.dp) .fillMaxWidth()
.fillMaxWidth(), .padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// Previous Chapter Button // Previous chapter
FilledTonalButton( IconButton(
onClick = { if (currentChapterIndex > 0) currentChapterIndex-- }, onClick = { if (currentChapterIndex > 0) currentChapterIndex-- },
enabled = currentChapterIndex > 0, enabled = currentChapterIndex > 0
modifier = Modifier.weight(1f)
) { ) {
Row( Icon(
horizontalArrangement = Arrangement.Center, imageVector = Icons.AutoMirrored.Filled.NavigateBefore,
verticalAlignment = Alignment.CenterVertically contentDescription = "Previous Chapter"
) { )
Icon(
Icons.AutoMirrored.Filled.NavigateBefore,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
Spacer(Modifier.width(4.dp))
Text("Back")
}
} }
// Chapter Counter // Chapter selector
Text( TextButton(
text = "${currentChapterIndex + 1} / ${chapters.size}", onClick = { showChapterList = true }
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = 16.dp)
)
// Next Chapter Button
FilledTonalButton(
onClick = { if (currentChapterIndex < chapters.size - 1) currentChapterIndex++ },
enabled = currentChapterIndex < chapters.size - 1,
modifier = Modifier.weight(1f)
) { ) {
Row( Text(
horizontalArrangement = Arrangement.Center, text = "Chapter ${currentChapterIndex + 1}/${chapters.size}",
verticalAlignment = Alignment.CenterVertically style = MaterialTheme.typography.bodyMedium
) { )
Text("Next") }
Spacer(Modifier.width(4.dp))
Icon( // Next chapter
Icons.AutoMirrored.Filled.NavigateNext, IconButton(
contentDescription = null, onClick = { if (currentChapterIndex < chapters.size - 1) currentChapterIndex++ },
modifier = Modifier.size(20.dp) enabled = currentChapterIndex < chapters.size - 1
) ) {
} Icon(
imageVector = Icons.AutoMirrored.Filled.NavigateNext,
contentDescription = "Next Chapter"
)
} }
} }
} }
} }
} }
// Back button overlay
AnimatedVisibility(
visible = showControls,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier
.align(Alignment.TopStart)
.statusBarsPadding()
.padding(8.dp)
) {
IconButton(
onClick = onNavigateBack,
colors = IconButtonDefaults.iconButtonColors(
containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f),
contentColor = MaterialTheme.colorScheme.onSurface
)
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
}
}
} }
// Chapter list dialog // Chapter list dialog