Skip to content

Commit b495fc8

Browse files
committed
✨ feat: sync reset, restart book, clean local books (#3)
1 parent 7746cb4 commit b495fc8

17 files changed

Lines changed: 2007 additions & 42 deletions

File tree

app/build.gradle.kts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ android {
4242
}
4343
kotlinOptions { jvmTarget = "17" }
4444
buildFeatures { compose = true }
45+
testOptions { unitTests.isIncludeAndroidResources = true }
4546
signingConfigs {
4647
create("release") {
4748
val storePath = System.getenv("QUIRE_RELEASE_KEYSTORE")
@@ -96,4 +97,13 @@ dependencies {
9697
debugImplementation(libs.compose.ui.tooling)
9798

9899
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.2")
100+
101+
testImplementation(libs.junit)
102+
testImplementation(libs.truth)
103+
testImplementation(libs.robolectric)
104+
testImplementation(libs.kotlinx.coroutines.test)
105+
testImplementation(libs.turbine)
106+
testImplementation(libs.okhttp.mockwebserver)
107+
testImplementation(libs.androidx.test.core)
108+
testImplementation(libs.room.testing)
99109
}

app/src/main/java/io/theficos/ereader/di/AppContainer.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,10 @@ class AppContainer(context: Context) {
2222

2323
val opdsHttp = OpdsHttpClient(credentialStore)
2424
val opdsClient: OpdsClient = OpdsClient(opdsHttp.okHttp)
25+
val booksDir: File = File(appContext.filesDir, "books")
2526
val bookDownloader: BookDownloader = BookDownloader(
2627
okHttp = opdsHttp.okHttp,
27-
booksDir = File(appContext.filesDir, "books"),
28+
booksDir = booksDir,
2829
)
2930

3031
private val db: EReaderDatabase = EReaderDatabase.build(appContext)

app/src/main/java/io/theficos/ereader/ui/AppNavGraph.kt

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,14 @@ fun AppNavGraph(container: AppContainer) {
2525
val nav = rememberNavController()
2626
NavHost(navController = nav, startDestination = "home") {
2727
composable("home") {
28-
val libVm = remember { LibraryViewModel(container.documentRepository, container.progressRepository) }
28+
val libVm = remember {
29+
LibraryViewModel(
30+
docs = container.documentRepository,
31+
progress = container.progressRepository,
32+
syncOrchestrator = container.syncOrchestrator,
33+
booksDir = container.booksDir,
34+
)
35+
}
2936
val catVm = remember {
3037
CatalogViewModel(container.opdsClient, container.bookDownloader, container.documentRepository, container.credentialStore)
3138
}
@@ -34,6 +41,8 @@ fun AppNavGraph(container: AppContainer) {
3441
store = container.credentialStore,
3542
readerStore = container.readerPreferencesStore,
3643
syncStateDao = container.syncStateDao,
44+
documentRepo = container.documentRepository,
45+
booksDir = container.booksDir,
3746
)
3847
}
3948
MainScaffold { tab, padding ->

app/src/main/java/io/theficos/ereader/ui/library/LibraryScreen.kt

Lines changed: 126 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Arrangement
66
import androidx.compose.foundation.layout.Box
77
import androidx.compose.foundation.layout.Column
88
import androidx.compose.foundation.layout.PaddingValues
9+
import androidx.compose.foundation.layout.Row
910
import androidx.compose.foundation.layout.aspectRatio
1011
import androidx.compose.foundation.layout.fillMaxSize
1112
import androidx.compose.foundation.layout.fillMaxWidth
@@ -15,9 +16,15 @@ import androidx.compose.foundation.lazy.grid.GridItemSpan
1516
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
1617
import androidx.compose.foundation.lazy.grid.itemsIndexed
1718
import androidx.compose.material3.AlertDialog
19+
import androidx.compose.material3.Checkbox
20+
import androidx.compose.material3.ExperimentalMaterial3Api
1821
import androidx.compose.material3.MaterialTheme
22+
import androidx.compose.material3.ModalBottomSheet
1923
import androidx.compose.material3.Text
2024
import androidx.compose.material3.TextButton
25+
import androidx.compose.material3.SnackbarHost
26+
import androidx.compose.material3.SnackbarHostState
27+
import androidx.compose.material3.rememberModalBottomSheetState
2128
import androidx.compose.runtime.Composable
2229
import androidx.compose.runtime.LaunchedEffect
2330
import androidx.compose.runtime.collectAsState
@@ -29,14 +36,14 @@ import androidx.compose.ui.Alignment
2936
import androidx.compose.ui.Modifier
3037
import androidx.compose.ui.platform.LocalContext
3138
import androidx.compose.ui.text.style.TextOverflow
32-
import io.theficos.ereader.data.sync.SyncEnqueuer
3339
import androidx.compose.ui.unit.dp
3440
import io.theficos.ereader.core.model.Document
41+
import io.theficos.ereader.data.sync.SyncEnqueuer
3542
import io.theficos.ereader.ui.components.CoverImage
3643
import io.theficos.ereader.ui.components.SectionLabel
3744
import io.theficos.ereader.ui.theme.Lora
3845

39-
@OptIn(ExperimentalFoundationApi::class)
46+
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
4047
@Composable
4148
fun LibraryScreen(
4249
viewModel: LibraryViewModel,
@@ -48,63 +55,142 @@ fun LibraryScreen(
4855

4956
val items by viewModel.items.collectAsState()
5057
val cont by viewModel.continueReading.collectAsState()
58+
var menuFor by remember { mutableStateOf<Document?>(null) }
5159
var pendingDelete by remember { mutableStateOf<Document?>(null) }
60+
var pendingRestart by remember { mutableStateOf<Document?>(null) }
61+
val snackbarHostState = remember { SnackbarHostState() }
62+
63+
LaunchedEffect(Unit) {
64+
viewModel.events.collect { event ->
65+
when (event) {
66+
LibraryEvent.RestartFailed ->
67+
snackbarHostState.showSnackbar("Couldn't sync restart — will retry.")
68+
}
69+
}
70+
}
5271

5372
if (items.isEmpty()) {
5473
EmptyState(modifier = Modifier.padding(contentPadding))
5574
return
5675
}
5776

58-
LazyVerticalGrid(
59-
columns = GridCells.Fixed(3),
60-
modifier = Modifier
61-
.fillMaxSize()
62-
.padding(contentPadding),
63-
contentPadding = PaddingValues(16.dp),
64-
verticalArrangement = Arrangement.spacedBy(16.dp),
65-
horizontalArrangement = Arrangement.spacedBy(12.dp),
66-
) {
67-
item(span = { GridItemSpan(maxLineSpan) }) {
68-
Text(
69-
text = "Quire",
70-
style = MaterialTheme.typography.displaySmall,
71-
color = MaterialTheme.colorScheme.onSurface,
72-
)
73-
}
74-
cont?.let { row ->
77+
Box(modifier = Modifier.fillMaxSize().padding(contentPadding)) {
78+
LazyVerticalGrid(
79+
columns = GridCells.Fixed(3),
80+
modifier = Modifier.fillMaxSize(),
81+
contentPadding = PaddingValues(16.dp),
82+
verticalArrangement = Arrangement.spacedBy(16.dp),
83+
horizontalArrangement = Arrangement.spacedBy(12.dp),
84+
) {
7585
item(span = { GridItemSpan(maxLineSpan) }) {
76-
ContinueReadingCard(row = row, onClick = { onOpenBook(row.document.id) })
86+
Text(
87+
text = "Quire",
88+
style = MaterialTheme.typography.displaySmall,
89+
color = MaterialTheme.colorScheme.onSurface,
90+
)
91+
}
92+
cont?.let { row ->
93+
item(span = { GridItemSpan(maxLineSpan) }) {
94+
ContinueReadingCard(row = row, onClick = { onOpenBook(row.document.id) })
95+
}
96+
}
97+
item(span = { GridItemSpan(maxLineSpan) }) {
98+
SectionLabel("Library · ${items.size}")
99+
}
100+
itemsIndexed(items, key = { _, r -> r.document.id }) { _, row ->
101+
Column(
102+
modifier = Modifier.combinedClickable(
103+
onClick = { onOpenBook(row.document.id) },
104+
onLongClick = { menuFor = row.document },
105+
),
106+
) {
107+
CoverImage(
108+
source = row.document.coverPath,
109+
title = row.document.title,
110+
author = row.document.author,
111+
modifier = Modifier
112+
.fillMaxWidth()
113+
.aspectRatio(2f / 3f),
114+
)
115+
Text(
116+
text = row.document.title,
117+
style = MaterialTheme.typography.titleMedium,
118+
maxLines = 2,
119+
overflow = TextOverflow.Ellipsis,
120+
modifier = Modifier.padding(top = 6.dp),
121+
)
122+
}
77123
}
78124
}
79-
item(span = { GridItemSpan(maxLineSpan) }) {
80-
SectionLabel("Library · ${items.size}")
81-
}
82-
itemsIndexed(items, key = { _, r -> r.document.id }) { _, row ->
83-
Column(
84-
modifier = Modifier.combinedClickable(
85-
onClick = { onOpenBook(row.document.id) },
86-
onLongClick = { pendingDelete = row.document },
87-
),
88-
) {
89-
CoverImage(
90-
source = row.document.coverPath,
91-
title = row.document.title,
92-
author = row.document.author,
93-
modifier = Modifier
94-
.fillMaxWidth()
95-
.aspectRatio(2f / 3f),
96-
)
125+
SnackbarHost(hostState = snackbarHostState, modifier = Modifier.align(Alignment.BottomCenter))
126+
}
127+
128+
menuFor?.let { doc ->
129+
val sheetState = rememberModalBottomSheetState()
130+
ModalBottomSheet(
131+
onDismissRequest = { menuFor = null },
132+
sheetState = sheetState,
133+
) {
134+
Column(modifier = Modifier.fillMaxWidth().padding(bottom = 24.dp)) {
97135
Text(
98-
text = row.document.title,
136+
text = doc.title,
99137
style = MaterialTheme.typography.titleMedium,
100138
maxLines = 2,
101139
overflow = TextOverflow.Ellipsis,
102-
modifier = Modifier.padding(top = 6.dp),
140+
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp),
103141
)
142+
TextButton(
143+
onClick = {
144+
pendingRestart = doc
145+
menuFor = null
146+
},
147+
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
148+
) {
149+
Text("Restart book", modifier = Modifier.fillMaxWidth())
150+
}
151+
TextButton(
152+
onClick = {
153+
pendingDelete = doc
154+
menuFor = null
155+
},
156+
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
157+
) {
158+
Text(
159+
"Delete from library",
160+
modifier = Modifier.fillMaxWidth(),
161+
color = MaterialTheme.colorScheme.error,
162+
)
163+
}
104164
}
105165
}
106166
}
107167

168+
pendingRestart?.let { doc ->
169+
var alsoDelete by remember { mutableStateOf(false) }
170+
AlertDialog(
171+
onDismissRequest = { pendingRestart = null },
172+
title = { Text("Restart book?") },
173+
text = {
174+
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
175+
Text("\"${doc.title}\" will be marked as unread and synced to your other devices.")
176+
Row(verticalAlignment = Alignment.CenterVertically) {
177+
Checkbox(checked = alsoDelete, onCheckedChange = { alsoDelete = it })
178+
Text("Also delete the downloaded copy", modifier = Modifier.padding(start = 8.dp))
179+
}
180+
}
181+
},
182+
confirmButton = {
183+
TextButton(onClick = {
184+
viewModel.restartFromUi(doc, alsoDelete, context)
185+
pendingRestart = null
186+
}) { Text("Restart") }
187+
},
188+
dismissButton = {
189+
TextButton(onClick = { pendingRestart = null }) { Text("Cancel") }
190+
},
191+
)
192+
}
193+
108194
pendingDelete?.let { doc ->
109195
AlertDialog(
110196
onDismissRequest = { pendingDelete = null },

app/src/main/java/io/theficos/ereader/ui/library/LibraryViewModel.kt

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,40 @@
11
package io.theficos.ereader.ui.library
22

3+
import android.content.Context
34
import androidx.lifecycle.ViewModel
45
import androidx.lifecycle.viewModelScope
56
import io.theficos.ereader.core.model.Document
67
import io.theficos.ereader.data.local.DocumentRepository
78
import io.theficos.ereader.data.local.ProgressRepository
9+
import io.theficos.ereader.data.sync.SyncEnqueuer
10+
import io.theficos.ereader.data.sync.SyncOrchestrator
11+
import io.theficos.ereader.data.sync.SyncResult
812
import kotlinx.coroutines.ExperimentalCoroutinesApi
13+
import kotlinx.coroutines.flow.MutableSharedFlow
14+
import kotlinx.coroutines.flow.SharedFlow
915
import kotlinx.coroutines.flow.SharingStarted
1016
import kotlinx.coroutines.flow.StateFlow
17+
import kotlinx.coroutines.flow.asSharedFlow
1118
import kotlinx.coroutines.flow.combine
1219
import kotlinx.coroutines.flow.flatMapLatest
1320
import kotlinx.coroutines.flow.flowOf
1421
import kotlinx.coroutines.flow.map
1522
import kotlinx.coroutines.flow.stateIn
1623
import kotlinx.coroutines.launch
24+
import java.io.File
25+
26+
sealed interface LibraryEvent {
27+
data object RestartFailed : LibraryEvent
28+
}
1729

1830
@OptIn(ExperimentalCoroutinesApi::class)
1931
class LibraryViewModel(
2032
private val docs: DocumentRepository,
2133
private val progress: ProgressRepository,
34+
private val syncOrchestrator: SyncOrchestrator,
35+
private val booksDir: File,
36+
private val nowMillis: () -> Long = System::currentTimeMillis,
37+
private val syncEnqueuer: (Context) -> Unit = { SyncEnqueuer.enqueue(it, expedited = true, replaceExisting = true) },
2238
) : ViewModel() {
2339

2440
private val rows: StateFlow<List<LibraryRow>> =
@@ -48,9 +64,44 @@ class LibraryViewModel(
4864
}
4965
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
5066

67+
private val _events = MutableSharedFlow<LibraryEvent>(extraBufferCapacity = 4)
68+
val events: SharedFlow<LibraryEvent> = _events.asSharedFlow()
69+
5170
fun delete(document: Document) {
5271
viewModelScope.launch { docs.delete(document) }
5372
}
73+
74+
/**
75+
* Returns true on success, false on failure (a [LibraryEvent.RestartFailed]
76+
* is also emitted). Suspends until the push completes; intended to be
77+
* launched from a coroutine.
78+
*/
79+
suspend fun restart(document: Document, alsoDeleteFile: Boolean): Boolean {
80+
progress.resetForDocument(document.id, now = nowMillis())
81+
val pushed = syncOrchestrator.runOnce()
82+
return when (pushed) {
83+
is SyncResult.Success -> {
84+
if (alsoDeleteFile) docs.delete(document)
85+
true
86+
}
87+
else -> {
88+
_events.tryEmit(LibraryEvent.RestartFailed)
89+
false
90+
}
91+
}
92+
}
93+
94+
/**
95+
* Fire-and-forget wrapper for the UI. Schedules a WorkManager retry on
96+
* failure so the dirty row eventually drains.
97+
*/
98+
fun restartFromUi(document: Document, alsoDeleteFile: Boolean, context: Context) {
99+
viewModelScope.launch {
100+
if (!restart(document, alsoDeleteFile)) {
101+
syncEnqueuer(context)
102+
}
103+
}
104+
}
54105
}
55106

56107
data class LibraryRow(

0 commit comments

Comments
 (0)