Fixed lagging in downloads screen, but screen position in the epub remains broken. Updated README.md.
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
# AO3 Client
|
# AO3 Client (v1.0)
|
||||||
|
|
||||||
A feature-rich Android client for Archive of Our Own (AO3), designed for a seamless browsing, downloading, and reading experience.
|
A feature-rich Android client for Archive of Our Own (AO3), designed for a seamless browsing, downloading, and reading experience.
|
||||||
|
|
||||||
@@ -13,19 +13,23 @@ A feature-rich Android client for Archive of Our Own (AO3), designed for a seaml
|
|||||||
- **Built-in EPUB Reader**:
|
- **Built-in EPUB Reader**:
|
||||||
- Custom reader optimized for fanfiction.
|
- Custom reader optimized for fanfiction.
|
||||||
- Dark mode by default for comfortable reading.
|
- Dark mode by default for comfortable reading.
|
||||||
- Remembers your scroll position for every work.
|
- **Persistent Progress**: Remembers your scroll position for every work using a deterministic layout-based restoration.
|
||||||
- **Offline Mode**: A dedicated toggle to browse your downloaded library without an internet connection.
|
- **Offline Mode**: A dedicated toggle to browse your downloaded library without an internet connection.
|
||||||
- **Library Management**:
|
- **Library Management**:
|
||||||
- Search through your downloaded works.
|
- Search through your downloaded works.
|
||||||
- Sort by Name, Date, or File Size.
|
- Sort by Name, Date, or File Size.
|
||||||
- Move, delete, and manually manage your files.
|
- Move, delete, and manually manage your files with safety confirmations.
|
||||||
- **State Persistence**: The app remembers where you left off, including your last visited URL and the last book you were reading.
|
- **State Persistence**: The app remembers where you left off, including your last visited URL and the last book you were reading, powered by **Jetpack DataStore**.
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack & Architecture
|
||||||
|
|
||||||
|
This project follows **Clean Architecture** principles and modern Android development practices:
|
||||||
|
|
||||||
- **Language**: [Kotlin](https://kotlinlang.org/)
|
- **Language**: [Kotlin](https://kotlinlang.org/)
|
||||||
- **UI Framework**: [Jetpack Compose](https://developer.android.com/jetpack/compose)
|
- **UI Framework**: [Jetpack Compose](https://developer.android.com/jetpack/compose)
|
||||||
- **Navigation**: [Compose Navigation](https://developer.android.com/jetpack/compose/navigation)
|
- **State Management**: [ViewModel](https://developer.android.com/topic/libraries/architecture/viewmodel) with [StateFlow](https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/-state-flow/)
|
||||||
|
- **Persistence**: [Jetpack DataStore](https://developer.android.com/topic/libraries/architecture/datastore) (Preferences)
|
||||||
|
- **Concurrency**: [Kotlin Coroutines](https://kotlinlang.org/docs/coroutines-overview.html) for non-blocking I/O
|
||||||
- **EPUB Engine**: [epublib](https://github.com/psiegman/epublib)
|
- **EPUB Engine**: [epublib](https://github.com/psiegman/epublib)
|
||||||
- **Design System**: Material 3
|
- **Design System**: Material 3
|
||||||
|
|
||||||
@@ -56,6 +60,10 @@ A feature-rich Android client for Archive of Our Own (AO3), designed for a seaml
|
|||||||
|
|
||||||
Contributions are welcome! Please feel free to submit a Pull Request.
|
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||||
|
|
||||||
|
## Acknowledgements
|
||||||
|
|
||||||
|
This project was developed and refactored with the assistance of **AI technology**, which helped in modernizing the architecture, improving performance, and implementing robust state management.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
This project is licensed under the MIT License - see the LICENSE file for details.
|
This project is licensed under the MIT License - see the LICENSE file for details.
|
||||||
|
|||||||
@@ -213,7 +213,10 @@ fun DownloadsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
items(items) { item ->
|
items(
|
||||||
|
items = items,
|
||||||
|
key = { it.absolutePath }
|
||||||
|
) { item ->
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text(item.name) },
|
headlineContent = { Text(item.name) },
|
||||||
supportingContent = {
|
supportingContent = {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package net.pythonsden.ao3_.reader
|
package net.pythonsden.ao3_.reader
|
||||||
|
|
||||||
|
import android.view.ViewTreeObserver
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import android.webkit.WebViewClient
|
import android.webkit.WebViewClient
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -19,6 +20,9 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
@@ -74,12 +78,13 @@ fun ReaderScreen(
|
|||||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||||
}
|
}
|
||||||
is ReaderUiState.Success -> {
|
is ReaderUiState.Success -> {
|
||||||
|
var hasRestoredScroll by remember(file.absolutePath) { mutableStateOf(false) }
|
||||||
|
|
||||||
AndroidView(
|
AndroidView(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
factory = { ctx ->
|
factory = { ctx ->
|
||||||
WebView(ctx).apply {
|
WebView(ctx).apply {
|
||||||
setBackgroundColor(android.graphics.Color.parseColor("#121212"))
|
setBackgroundColor(android.graphics.Color.parseColor("#121212"))
|
||||||
// Security: Disable JavaScript for EPUB reading unless strictly needed
|
|
||||||
settings.javaScriptEnabled = false
|
settings.javaScriptEnabled = false
|
||||||
settings.allowFileAccess = false
|
settings.allowFileAccess = false
|
||||||
settings.allowContentAccess = false
|
settings.allowContentAccess = false
|
||||||
@@ -87,18 +92,33 @@ fun ReaderScreen(
|
|||||||
webViewClient = object : WebViewClient() {
|
webViewClient = object : WebViewClient() {
|
||||||
override fun onPageFinished(view: WebView?, url: String?) {
|
override fun onPageFinished(view: WebView?, url: String?) {
|
||||||
super.onPageFinished(view, url)
|
super.onPageFinished(view, url)
|
||||||
if (state.initialScroll > 0) {
|
if (!hasRestoredScroll && state.initialScroll > 0) {
|
||||||
// Restore scroll position
|
|
||||||
view?.scrollTo(0, state.initialScroll)
|
view?.scrollTo(0, state.initialScroll)
|
||||||
// Multiple attempts to ensure it works after layout
|
|
||||||
view?.postDelayed({ view.scrollTo(0, state.initialScroll) }, 100)
|
|
||||||
view?.postDelayed({ view.scrollTo(0, state.initialScroll) }, 300)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val layoutListener = object : ViewTreeObserver.OnGlobalLayoutListener {
|
||||||
|
override fun onGlobalLayout() {
|
||||||
|
if (!hasRestoredScroll && state.initialScroll > 0) {
|
||||||
|
if (contentHeight > 0) {
|
||||||
|
scrollTo(0, state.initialScroll)
|
||||||
|
// If we've scrolled to or past the target, or reached bottom
|
||||||
|
if (scrollY >= state.initialScroll || scrollY > 0) {
|
||||||
|
hasRestoredScroll = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (state.initialScroll == 0) {
|
||||||
|
hasRestoredScroll = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
viewTreeObserver.addOnGlobalLayoutListener(layoutListener)
|
||||||
|
|
||||||
setOnScrollChangeListener { _, _, _, scrollY, _ ->
|
setOnScrollChangeListener { _, _, _, scrollY, _ ->
|
||||||
viewModel.saveScrollPosition(file.absolutePath, scrollY)
|
if (hasRestoredScroll) {
|
||||||
|
viewModel.saveScrollPosition(file.absolutePath, scrollY)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ package net.pythonsden.ao3_.viewmodel
|
|||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import net.pythonsden.ao3_.data.repository.EpubRepository
|
import net.pythonsden.ao3_.data.repository.EpubRepository
|
||||||
import net.pythonsden.ao3_.data.repository.FileRepository
|
import net.pythonsden.ao3_.data.repository.FileRepository
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -46,23 +48,26 @@ class DownloadsViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun refreshFiles(dir: File, query: String, sort: SortOrder) {
|
private suspend fun refreshFiles(dir: File, query: String, sort: SortOrder) {
|
||||||
val allItems = fileRepository.listFiles(dir)
|
withContext(Dispatchers.Default) {
|
||||||
val filtered = if (query.isBlank()) {
|
val allItems = fileRepository.listFiles(dir)
|
||||||
allItems
|
val filtered = if (query.isBlank()) {
|
||||||
} else {
|
allItems
|
||||||
allItems.filter { it.name.contains(query, ignoreCase = true) }
|
} else {
|
||||||
}
|
allItems.filter { it.name.contains(query, ignoreCase = true) }
|
||||||
|
}
|
||||||
|
|
||||||
_items.value = filtered.sortedWith { f1, f2 ->
|
val sorted = filtered.sortedWith { f1, f2 ->
|
||||||
if (f1.isDirectory && !f2.isDirectory) -1
|
if (f1.isDirectory && !f2.isDirectory) -1
|
||||||
else if (!f1.isDirectory && f2.isDirectory) 1
|
else if (!f1.isDirectory && f2.isDirectory) 1
|
||||||
else {
|
else {
|
||||||
when (sort) {
|
when (sort) {
|
||||||
SortOrder.NAME -> f1.name.lowercase().compareTo(f2.name.lowercase())
|
SortOrder.NAME -> f1.name.lowercase().compareTo(f2.name.lowercase())
|
||||||
SortOrder.DATE -> f2.lastModified().compareTo(f1.lastModified())
|
SortOrder.DATE -> f2.lastModified().compareTo(f1.lastModified())
|
||||||
SortOrder.SIZE -> f2.length().compareTo(f1.length())
|
SortOrder.SIZE -> f2.length().compareTo(f1.length())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
_items.value = sorted
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user