diff --git a/README.md b/README.md index 85ce8aa..73955b8 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ Supports light mode, dark mode, Material You dynamic colors and 300+ Wikipedia l and view in full-screen - **Random article:** Feeling lucky? Click the random article button to read a random article - **Choose your language:** Choose from over 300 languages on Wikipedia +- **Save articles:** Download articles to your device for offline reading - **One-handed use:** Use the floating action buttons at the bottom for a complete one-handed experience - **Lightweight:** The app starts instantly, and works smoothly @@ -65,6 +66,7 @@ Supports light mode, dark mode, Material You dynamic colors and 300+ Wikipedia l - **Customizable colors:** Choose from light/dark themes and customize the Material 3 color palette - **Customizable font size:** Choose your own comfortable font size - **Data saver:** Save your limited data plan by loading text only +- **Math expressions:** View properly rendered mathematical expressions for easily reading mathematical articles ## Special Thanks diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b69c003..a2c976f 100755 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,8 +13,8 @@ android { applicationId = "org.nsh07.wikireader" minSdk = 26 targetSdk = 35 - versionCode = 19 - versionName = "1.8.3" + versionCode = 20 + versionName = "1.9.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/main/java/org/nsh07/wikireader/MainActivity.kt b/app/src/main/java/org/nsh07/wikireader/MainActivity.kt index 5fa5768..2784dab 100755 --- a/app/src/main/java/org/nsh07/wikireader/MainActivity.kt +++ b/app/src/main/java/org/nsh07/wikireader/MainActivity.kt @@ -26,6 +26,7 @@ class MainActivity : ComponentActivity() { installSplashScreen().setKeepOnScreenCondition { !viewModel.isReady || !viewModel.isAnimDurationComplete } + viewModel.setFilesDir(filesDir.path) enableEdgeToEdge() setContent { diff --git a/app/src/main/java/org/nsh07/wikireader/data/WRStatus.kt b/app/src/main/java/org/nsh07/wikireader/data/WRStatus.kt new file mode 100644 index 0000000..329fa9d --- /dev/null +++ b/app/src/main/java/org/nsh07/wikireader/data/WRStatus.kt @@ -0,0 +1,5 @@ +package org.nsh07.wikireader.data + +enum class WRStatus { + SUCCESS, NETWORK_ERROR, IO_ERROR, NO_SEARCH_RESULT, UNINITIALIZED, OTHER +} \ No newline at end of file diff --git a/app/src/main/java/org/nsh07/wikireader/data/WikiApiResponse.kt b/app/src/main/java/org/nsh07/wikireader/data/WikiApiResponse.kt index fd3f4f7..8764ed3 100755 --- a/app/src/main/java/org/nsh07/wikireader/data/WikiApiResponse.kt +++ b/app/src/main/java/org/nsh07/wikireader/data/WikiApiResponse.kt @@ -17,6 +17,7 @@ data class WikiApiQuery( data class WikiApiPage( val title: String, val extract: String, + @SerialName(value = "pageid") val pageId: Int? = null, @SerialName(value = "original") val photo: WikiPhoto? = null, @SerialName(value = "terms") val photoDesc: WikiPhotoDesc? = null, @SerialName(value = "langlinks") val langs: List? = null diff --git a/app/src/main/java/org/nsh07/wikireader/data/parsers.kt b/app/src/main/java/org/nsh07/wikireader/data/parsers.kt index c7ec192..dc650b6 100644 --- a/app/src/main/java/org/nsh07/wikireader/data/parsers.kt +++ b/app/src/main/java/org/nsh07/wikireader/data/parsers.kt @@ -81,12 +81,27 @@ fun parseText(text: String): List { return out } +fun bytesToHumanReadableSize(bytes: Double) = when { + bytes >= 1 shl 30 -> "%.1f GB".format(bytes / (1 shl 30)) + bytes >= 1 shl 20 -> "%.1f MB".format(bytes / (1 shl 20)) + bytes >= 1 shl 10 -> "%.0f kB".format(bytes / (1 shl 10)) + else -> "$bytes bytes" +} + fun langCodeToName(langCode: String): String { - Log.d("Language", "CodeToName called") try { return LanguageData.langNames[LanguageData.langCodes.binarySearch(langCode)] } catch(_: Exception) { Log.e("Language", "Unknown Language: $langCode") - return "Unknown Language: $langCode" + return langCode + } +} + +fun langCodeToWikiName(langCode: String): String { + try { + return LanguageData.wikipediaNames[LanguageData.langCodes.binarySearch(langCode)] + } catch(_: Exception) { + Log.e("Language", "Unknown Language: $langCode") + return langCode } } \ No newline at end of file diff --git a/app/src/main/java/org/nsh07/wikireader/ui/AppScreen.kt b/app/src/main/java/org/nsh07/wikireader/ui/AppScreen.kt index a510b0b..79fda52 100755 --- a/app/src/main/java/org/nsh07/wikireader/ui/AppScreen.kt +++ b/app/src/main/java/org/nsh07/wikireader/ui/AppScreen.kt @@ -24,6 +24,8 @@ import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -48,12 +50,15 @@ import coil3.ImageLoader import coil3.gif.AnimatedImageDecoder import coil3.gif.GifDecoder import coil3.svg.SvgDecoder +import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import org.nsh07.wikireader.data.WRStatus import org.nsh07.wikireader.ui.aboutScreen.AboutScreen import org.nsh07.wikireader.ui.homeScreen.AppFab import org.nsh07.wikireader.ui.homeScreen.AppHomeScreen import org.nsh07.wikireader.ui.homeScreen.AppSearchBar import org.nsh07.wikireader.ui.image.FullScreenImage +import org.nsh07.wikireader.ui.savedArticlesScreen.SavedArticlesScreen import org.nsh07.wikireader.ui.settingsScreen.SettingsScreen import org.nsh07.wikireader.ui.viewModel.PreferencesState import org.nsh07.wikireader.ui.viewModel.UiViewModel @@ -84,6 +89,7 @@ fun AppScreen( .build() val coroutineScope = rememberCoroutineScope() + val snackBarHostState = remember { SnackbarHostState() } val index by remember { derivedStateOf { listState.firstVisibleItemIndex } } val (showDeleteDialog, setShowDeleteDialog) = remember { mutableStateOf(false) } @@ -140,7 +146,11 @@ fun AppScreen( BackHandler(!homeScreenState.isBackStackEmpty) { val curr = viewModel.popBackStack() - viewModel.performSearch(query = curr?.first, lang = curr?.second, fromBackStack = true) + viewModel.performSearch( + query = curr?.first, + lang = curr?.second, + fromBackStack = true + ) } if (showDeleteDialog) @@ -169,12 +179,16 @@ fun AppScreen( setHistoryItem(it) setShowDeleteDialog(true) }, + onSavedArticlesClick = { + navController.navigate("savedArticles") + it(false) + }, onSettingsClick = { - navController.navigate("Settings") + navController.navigate("settings") it(false) }, onAboutClick = { - navController.navigate("About") + navController.navigate("about") it(false) }, modifier = Modifier.padding( @@ -184,6 +198,7 @@ fun AppScreen( }, floatingActionButton = { AppFab( + index = index, focusSearch = { viewModel.focusSearchBar() }, scrollToTop = { coroutineScope.launch { listState.animateScrollToItem(0) } }, performRandomPageSearch = { @@ -191,10 +206,10 @@ fun AppScreen( query = null, random = true ) - }, - index = index + } ) }, + snackbarHost = { SnackbarHost(snackBarHostState) }, modifier = Modifier.fillMaxSize() ) { insets -> AppHomeScreen( @@ -207,13 +222,32 @@ fun AppScreen( showLanguageSheet = showArticleLanguageSheet, onImageClick = { if (homeScreenState.photo != null) - navController.navigate("FullScreenImage") + navController.navigate("fullScreenImage") }, insets = insets, onLinkClick = { viewModel.performSearch(it, fromLink = true) }, + refreshSearch = { viewModel.refreshSearch(true) }, setLang = { viewModel.saveLang(it) }, setSearchStr = { viewModel.updateLanguageSearchStr(it) }, setShowArticleLanguageSheet = { showArticleLanguageSheet = it }, + saveArticle = { + coroutineScope.launch { + if (!homeScreenState.isSaved) { + val status = viewModel.saveArticle() + if (status == WRStatus.SUCCESS) + snackBarHostState.showSnackbar("Article saved for offline reading") + else + snackBarHostState.showSnackbar("Unable to save article: ${status.name}") + delay(150L) + } else { + val status = viewModel.deleteArticle() + if (status == WRStatus.SUCCESS) + snackBarHostState.showSnackbar("Article deleted") + else + snackBarHostState.showSnackbar("Unable to delete article: ${status.name}") + } + } + }, modifier = Modifier .fillMaxSize() .padding(top = insets.calculateTopPadding()) @@ -221,7 +255,7 @@ fun AppScreen( } } - composable("FullScreenImage") { + composable("fullScreenImage") { if (homeScreenState.photo == null) navController.navigateUp() FullScreenImage( photo = homeScreenState.photo, @@ -231,7 +265,21 @@ fun AppScreen( ) } - composable("Settings") { + composable("savedArticles") { + SavedArticlesScreen( + loadArticles = { viewModel.listArticles() }, + openSavedArticle = { + viewModel.loadSavedArticle(it) + navController.navigateUp() + }, + articlesSize = { viewModel.totalArticlesSize() }, + deleteArticle = { viewModel.deleteArticle(it) }, + deleteAll = { viewModel.deleteAllArticles() }, + onBack = { navController.navigateUp() } + ) + } + + composable("settings") { SettingsScreen( preferencesState = preferencesState, onBack = { navController.navigateUp() }, @@ -239,7 +287,7 @@ fun AppScreen( ) } - composable("About") { + composable("about") { AboutScreen( onBack = { navController.navigateUp() } ) diff --git a/app/src/main/java/org/nsh07/wikireader/ui/homeScreen/AppFab.kt b/app/src/main/java/org/nsh07/wikireader/ui/homeScreen/AppFab.kt index b92800d..38e8f8c 100644 --- a/app/src/main/java/org/nsh07/wikireader/ui/homeScreen/AppFab.kt +++ b/app/src/main/java/org/nsh07/wikireader/ui/homeScreen/AppFab.kt @@ -19,10 +19,10 @@ import org.nsh07.wikireader.R @Composable fun AppFab( + index: Int, focusSearch: () -> Unit, scrollToTop: () -> Unit, - performRandomPageSearch: () -> Unit, - index: Int + performRandomPageSearch: () -> Unit ) { Column(horizontalAlignment = Alignment.End) { SmallFloatingActionButton( diff --git a/app/src/main/java/org/nsh07/wikireader/ui/homeScreen/AppHomeScreen.kt b/app/src/main/java/org/nsh07/wikireader/ui/homeScreen/AppHomeScreen.kt index e2e5614..e34965b 100755 --- a/app/src/main/java/org/nsh07/wikireader/ui/homeScreen/AppHomeScreen.kt +++ b/app/src/main/java/org/nsh07/wikireader/ui/homeScreen/AppHomeScreen.kt @@ -1,10 +1,12 @@ package org.nsh07.wikireader.ui.homeScreen import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -14,13 +16,21 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource @@ -28,8 +38,10 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import coil3.ImageLoader import org.nsh07.wikireader.R +import org.nsh07.wikireader.data.WRStatus import org.nsh07.wikireader.data.langCodeToName import org.nsh07.wikireader.ui.image.ImageCard +import org.nsh07.wikireader.ui.theme.isDark import org.nsh07.wikireader.ui.viewModel.HomeScreenState import org.nsh07.wikireader.ui.viewModel.PreferencesState @@ -51,6 +63,7 @@ import org.nsh07.wikireader.ui.viewModel.PreferencesState * @param modifier Self explanatory */ +@OptIn(ExperimentalMaterial3Api::class) @Composable fun AppHomeScreen( homeScreenState: HomeScreenState, @@ -62,9 +75,11 @@ fun AppHomeScreen( showLanguageSheet: Boolean, onImageClick: () -> Unit, onLinkClick: (String) -> Unit, + refreshSearch: () -> Unit, setLang: (String) -> Unit, setSearchStr: (String) -> Unit, setShowArticleLanguageSheet: (Boolean) -> Unit, + saveArticle: () -> Unit, insets: PaddingValues, modifier: Modifier = Modifier ) { @@ -72,6 +87,8 @@ fun AppHomeScreen( val photoDesc = homeScreenState.photoDesc val fontSize = preferencesState.fontSize + var isRefreshing by remember { mutableStateOf(false) } + var s = homeScreenState.extract.size if (s > 1) s -= 2 else s = 0 @@ -88,72 +105,108 @@ fun AppHomeScreen( ) Box(modifier = modifier) { // The container for all the composables in the home screen - if (homeScreenState.title != "") { - LazyColumn( // The article - state = listState, - modifier = Modifier.fillMaxSize() - ) { - item { // Title - FilledTonalButton( - onClick = { setShowArticleLanguageSheet(true) }, - enabled = homeScreenState.langs?.isEmpty() == false, - modifier = Modifier.padding(16.dp) - ) { - Icon(painterResource(R.drawable.translate), null) - Spacer(Modifier.width(8.dp)) - Text(langCodeToName(preferencesState.lang)) - } - HorizontalDivider() + if (homeScreenState.status != WRStatus.UNINITIALIZED) { + LaunchedEffect(isRefreshing) { isRefreshing = false } // hide refresh indicator instantly + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = { + refreshSearch() + isRefreshing = true } - item { // Title + Image/description - Text( - text = homeScreenState.title, - style = MaterialTheme.typography.displayMedium, - fontFamily = FontFamily.Serif, - modifier = Modifier.padding(16.dp) - ) - if (photoDesc != null) { - ImageCard( - photo = photo, - photoDesc = photoDesc, - imageLoader = imageLoader, - showPhoto = !preferencesState.dataSaver, - onClick = onImageClick, - ) + ) { + LazyColumn( // The article + state = listState, + modifier = Modifier.fillMaxSize() + ) { + item { // Title + Row(modifier = Modifier.padding(16.dp)) { + FilledTonalButton( + onClick = { setShowArticleLanguageSheet(true) }, + enabled = homeScreenState.langs?.isEmpty() == false + ) { + Icon(painterResource(R.drawable.translate), null) + Spacer(Modifier.width(8.dp)) + Text(langCodeToName(preferencesState.lang)) + } + Spacer(Modifier.weight(1f)) + FilledTonalIconButton( + onClick = saveArticle, + enabled = homeScreenState.status == WRStatus.SUCCESS + ) { + Crossfade( + homeScreenState.isSaved, + label = "saveAnimation" + ) { saved -> + if (saved) + Icon( + painterResource(R.drawable.download_done), + contentDescription = "Delete downloaded article" + ) + else + Icon( + painterResource(R.drawable.download), + contentDescription = "Download article" + ) + } + } + } + HorizontalDivider() } - } - item { // Main description - SelectionContainer { - ParsedBodyText( - title = "", - pageTitle = homeScreenState.title.substringBefore("(disam").trim(), - body = homeScreenState.extract[0], - fontSize = fontSize, - description = photoDesc?.description?.get(0) ?: "", - intro = true, - onLinkClick = onLinkClick + item { // Title + Image/description + Text( + text = homeScreenState.title, + style = MaterialTheme.typography.displayMedium, + fontFamily = FontFamily.Serif, + modifier = Modifier.padding(16.dp) ) + if (photoDesc != null) { + ImageCard( + photo = photo, + photoDesc = photoDesc, + imageLoader = imageLoader, + showPhoto = !preferencesState.dataSaver, + onClick = onImageClick, + ) + } } - } - - for (i in 1..s step 2) { - item { // Expandable sections logic + item { // Main description SelectionContainer { - ExpandableSection( - title = homeScreenState.extract[i], + ParsedBodyText( + title = "", pageTitle = homeScreenState.title.substringBefore("(disam").trim(), - body = homeScreenState.extract[i + 1], + body = homeScreenState.extract[0], fontSize = fontSize, description = photoDesc?.description?.get(0) ?: "", - expanded = preferencesState.expandedSections, + intro = true, + renderMath = preferencesState.renderMath, + darkTheme = MaterialTheme.colorScheme.isDark(), onLinkClick = onLinkClick ) } } - } - item { - Spacer(Modifier.height(insets.calculateBottomPadding() + 152.dp)) + for (i in 1..s step 2) { + item { // Expandable sections logic + SelectionContainer { + ExpandableSection( + title = homeScreenState.extract[i], + pageTitle = homeScreenState.title.substringBefore("(disam") + .trim(), + body = homeScreenState.extract[i + 1], + fontSize = fontSize, + description = photoDesc?.description?.get(0) ?: "", + expanded = preferencesState.expandedSections, + onLinkClick = onLinkClick, + darkTheme = MaterialTheme.colorScheme.isDark(), + renderMath = preferencesState.renderMath + ) + } + } + } + + item { + Spacer(Modifier.height(insets.calculateBottomPadding() + 152.dp)) + } } } } else { diff --git a/app/src/main/java/org/nsh07/wikireader/ui/homeScreen/AppSearchBar.kt b/app/src/main/java/org/nsh07/wikireader/ui/homeScreen/AppSearchBar.kt index b99c0d4..7442dbf 100755 --- a/app/src/main/java/org/nsh07/wikireader/ui/homeScreen/AppSearchBar.kt +++ b/app/src/main/java/org/nsh07/wikireader/ui/homeScreen/AppSearchBar.kt @@ -58,6 +58,7 @@ fun AppSearchBar( setQuery: (String) -> Unit, removeHistoryItem: (String) -> Unit, clearHistory: () -> Unit, + onSavedArticlesClick: ((Boolean) -> Unit) -> Unit, onSettingsClick: ((Boolean) -> Unit) -> Unit, onAboutClick: ((Boolean) -> Unit) -> Unit, modifier: Modifier = Modifier @@ -102,6 +103,18 @@ fun AppSearchBar( expanded = dropdownExpanded, onDismissRequest = { setDropdownExpanded(false) } ) { + DropdownMenuItem( + text = { Text("Saved articles") }, + onClick = { onSavedArticlesClick(setDropdownExpanded) }, + leadingIcon = { + Icon( + painterResource(R.drawable.download_done), + contentDescription = null + ) + }, + modifier = Modifier.width(200.dp) + ) + HorizontalDivider(Modifier.padding(vertical = 8.dp)) DropdownMenuItem( text = { Text("Settings") }, onClick = { onSettingsClick(setDropdownExpanded) }, @@ -216,7 +229,7 @@ fun AppSearchBarPreview() { WikiReaderTheme { AppSearchBar( searchBarState = SearchBarState(), true, 0, - {}, {}, {}, {}, {}, {}, {} + {}, {}, {}, {}, {}, {}, {}, {} ) } } diff --git a/app/src/main/java/org/nsh07/wikireader/ui/homeScreen/ExpandableSection.kt b/app/src/main/java/org/nsh07/wikireader/ui/homeScreen/ExpandableSection.kt index b1ae91b..4b69f69 100644 --- a/app/src/main/java/org/nsh07/wikireader/ui/homeScreen/ExpandableSection.kt +++ b/app/src/main/java/org/nsh07/wikireader/ui/homeScreen/ExpandableSection.kt @@ -39,6 +39,8 @@ fun ExpandableSection( fontSize: Int, description: String, expanded: Boolean, + renderMath: Boolean, + darkTheme: Boolean, onLinkClick: (String) -> Unit, modifier: Modifier = Modifier ) { @@ -89,7 +91,9 @@ fun ExpandableSection( body = body, fontSize = fontSize, description = description, - onLinkClick = onLinkClick + onLinkClick = onLinkClick, + renderMath = renderMath, + darkTheme = darkTheme ) } } @@ -106,7 +110,9 @@ fun ExpandableSectionPreview() { fontSize = 16, description = "", onLinkClick = {}, - expanded = false + expanded = false, + renderMath = true, + darkTheme = false ) } } diff --git a/app/src/main/java/org/nsh07/wikireader/ui/homeScreen/ParsedBodyText.kt b/app/src/main/java/org/nsh07/wikireader/ui/homeScreen/ParsedBodyText.kt index 3fefe3a..88ce433 100644 --- a/app/src/main/java/org/nsh07/wikireader/ui/homeScreen/ParsedBodyText.kt +++ b/app/src/main/java/org/nsh07/wikireader/ui/homeScreen/ParsedBodyText.kt @@ -1,11 +1,22 @@ package org.nsh07.wikireader.ui.homeScreen -import androidx.compose.foundation.layout.ExperimentalLayoutApi +import android.graphics.PorterDuff +import android.graphics.PorterDuffColorFilter +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asComposeColorFilter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextLinkStyles @@ -14,8 +25,11 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.withLink import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import coil3.size.Size +import org.nsh07.wikireader.R -@OptIn(ExperimentalLayoutApi::class) @Composable fun ParsedBodyText( title: String, @@ -23,18 +37,71 @@ fun ParsedBodyText( body: String, fontSize: Int, description: String, + renderMath: Boolean, + darkTheme: Boolean, intro: Boolean = false, onLinkClick: (String) -> Unit ) { - if (!description.contains("disambiguation") && title != "See also") - Text( - text = body, - style = MaterialTheme.typography.bodyLarge, - fontSize = fontSize.sp, - lineHeight = (24 * (fontSize / 16.0)).toInt().sp, - modifier = Modifier.padding(16.dp) - ) - else + if (!description.contains("disambiguation") && title != "See also") { + if (renderMath) { // Split content by newlines and render LaTeX if line starts with \{displaystyle + val context = LocalContext.current + val dpi = context.resources.displayMetrics.density + Column(horizontalAlignment = Alignment.CenterHorizontally) { + body.split('\n').forEach { + if (!it.startsWith(" ") && it.trim() != "") + Text( + text = it, + style = MaterialTheme.typography.bodyLarge, + fontSize = fontSize.sp, + lineHeight = (24 * (fontSize / 16.0)).toInt().sp, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) + else if (it.trimStart().startsWith("{\\displaystyle")) + Box(modifier = Modifier.horizontalScroll(rememberScrollState())) { + AsyncImage( + model = ImageRequest.Builder(context) + .data( + "https://latex.codecogs.com/png.image?\\dpi{${(dpi * 160).toInt()}}${ + it.trim() + }" + ) + .size(Size.ORIGINAL) + .build(), + placeholder = painterResource(R.drawable.more_horiz), + error = painterResource(R.drawable.error), + contentDescription = null, + colorFilter = if (darkTheme) + PorterDuffColorFilter( + 0xffffffff.toInt(), + PorterDuff.Mode.SRC_IN + ).asComposeColorFilter() + else null, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) + ) + } + } + } + } else { // Clean up string and then display + val pattern = + remember { "\n {6}\n {4}\n {3} \\{\\\\displaystyle.*\\}\n {2}\n".toRegex() } + val multipleSpaces = remember { "[^\\S\\n]{2,}".toRegex() } + Text( + text = body + .replace(pattern, "") + .replace("\n \n \n ", "") + .replace("\n ", "") + .replace(multipleSpaces, " "), + style = MaterialTheme.typography.bodyLarge, + fontSize = fontSize.sp, + lineHeight = (24 * (fontSize / 16.0)).toInt().sp, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) + } + } else Text( buildAnnotatedString { body.split('\n').forEachIndexed { ind, curr -> diff --git a/app/src/main/java/org/nsh07/wikireader/ui/savedArticlesScreen/DeleteArticleDialog.kt b/app/src/main/java/org/nsh07/wikireader/ui/savedArticlesScreen/DeleteArticleDialog.kt new file mode 100644 index 0000000..6d1ed69 --- /dev/null +++ b/app/src/main/java/org/nsh07/wikireader/ui/savedArticlesScreen/DeleteArticleDialog.kt @@ -0,0 +1,79 @@ +package org.nsh07.wikireader.ui.savedArticlesScreen + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.nsh07.wikireader.data.WRStatus + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DeleteArticleDialog( + articleFileName: String?, + showSnackbar: (String) -> Unit, + setShowDeleteDialog: (Boolean) -> Unit, + deleteArticle: (String) -> WRStatus, + deleteAll: () -> WRStatus +) { + val articleName: String? = articleFileName?.substringBeforeLast('.')?.substringBeforeLast('.') + BasicAlertDialog( + onDismissRequest = { setShowDeleteDialog(false) } + ) { + Surface( + modifier = Modifier + .wrapContentWidth() + .wrapContentHeight(), + shape = MaterialTheme.shapes.extraLarge, + tonalElevation = AlertDialogDefaults.TonalElevation + ) { + Column(modifier = Modifier.padding(24.dp)) { + Text( + text = if (articleName != null) "Delete saved article?" + else "Delete all articles?", + style = MaterialTheme.typography.headlineSmall + ) + Spacer(modifier = Modifier.padding(16.dp)) + Text( + text = + if (articleName != null) + "\"$articleName\" will be permanently deleted from your device" + else + "All articles will be permanently deleted from your device", + style = MaterialTheme.typography.bodyMedium + ) + Spacer(modifier = Modifier.height(24.dp)) + Row(modifier = Modifier.align(Alignment.End)) { + TextButton(onClick = { setShowDeleteDialog(false) }) { + Text(text = "Cancel") + } + TextButton(onClick = { + setShowDeleteDialog(false) + val status = if (articleFileName != null) deleteArticle(articleFileName) + else deleteAll() + if (status == WRStatus.SUCCESS) + showSnackbar("Article deleted") + else + showSnackbar("Unable to delete article: ${status.name}") + } + ) { + Text(text = "Delete") + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/nsh07/wikireader/ui/savedArticlesScreen/SavedArticlesScreen.kt b/app/src/main/java/org/nsh07/wikireader/ui/savedArticlesScreen/SavedArticlesScreen.kt new file mode 100644 index 0000000..c9bbb29 --- /dev/null +++ b/app/src/main/java/org/nsh07/wikireader/ui/savedArticlesScreen/SavedArticlesScreen.kt @@ -0,0 +1,180 @@ +package org.nsh07.wikireader.ui.savedArticlesScreen + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import org.nsh07.wikireader.R +import org.nsh07.wikireader.data.WRStatus +import org.nsh07.wikireader.data.bytesToHumanReadableSize +import org.nsh07.wikireader.data.langCodeToWikiName + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +fun SavedArticlesScreen( + modifier: Modifier = Modifier, + loadArticles: () -> List, + articlesSize: () -> Long, + openSavedArticle: (String) -> Unit, + deleteArticle: (String) -> WRStatus, + deleteAll: () -> WRStatus, + onBack: () -> Unit +) { + val coroutineScope = rememberCoroutineScope() + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + val snackBarHostState = remember { SnackbarHostState() } + var savedArticles by remember { mutableStateOf(loadArticles()) } + var savedArticlesSize by remember { mutableLongStateOf(articlesSize()) } + var toDelete: String? by remember { mutableStateOf("") } + var showArticleDeleteDialog by remember { mutableStateOf(false) } + + if (showArticleDeleteDialog) + DeleteArticleDialog( + articleFileName = toDelete, + setShowDeleteDialog = { showArticleDeleteDialog = it }, + deleteArticle = { + val status = deleteArticle(it) + if (status == WRStatus.SUCCESS) { + savedArticles -= it + savedArticlesSize = articlesSize() + } + status + }, + deleteAll = { + val status = deleteAll() + if (status == WRStatus.SUCCESS) { + savedArticles = emptyList() + savedArticlesSize = articlesSize() + } + status + }, + showSnackbar = { coroutineScope.launch { snackBarHostState.showSnackbar(it) } } + ) + + Scaffold( + topBar = { SavedArticlesTopBar(scrollBehavior = scrollBehavior, onBack = onBack) }, + snackbarHost = { SnackbarHost(snackBarHostState) }, + modifier = modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection) + ) { insets -> + if (savedArticles.isNotEmpty()) + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(top = insets.calculateTopPadding()) + ) { + item { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "${savedArticles.size} articles, ${ + bytesToHumanReadableSize( + savedArticlesSize.toDouble() + ) + } total", + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(16.dp) + ) + Spacer(Modifier.weight(1f)) + TextButton( + onClick = { + toDelete = null + showArticleDeleteDialog = true + }, + modifier = Modifier.padding(horizontal = 16.dp) + ) { Text("Delete all") } + } + HorizontalDivider() + } + items(savedArticles, key = { it }) { + ListItem( + headlineContent = { + Text( + remember { it.substringBeforeLast(".").substringBeforeLast('.') }, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + supportingContent = { + Text(remember { + langCodeToWikiName( + it.substringAfterLast( + '.' + ) + ) + }) + }, + modifier = Modifier + .combinedClickable( + onClick = { openSavedArticle(it) }, + onLongClick = { + toDelete = it + showArticleDeleteDialog = true + } + ) + .animateItem() + ) + } + item { + Spacer(modifier.height(insets.calculateBottomPadding())) + } + } + else + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + painterResource(R.drawable.save), + contentDescription = null, + tint = MaterialTheme.colorScheme.outline, + modifier = Modifier.size(100.dp) + ) + Text( + "No saved articles", + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(8.dp) + ) + Text( + "Click on the download button at the top of an article to get started", + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(horizontal = 48.dp) + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/nsh07/wikireader/ui/savedArticlesScreen/SavedArticlesTopBar.kt b/app/src/main/java/org/nsh07/wikireader/ui/savedArticlesScreen/SavedArticlesTopBar.kt new file mode 100644 index 0000000..48a096b --- /dev/null +++ b/app/src/main/java/org/nsh07/wikireader/ui/savedArticlesScreen/SavedArticlesTopBar.kt @@ -0,0 +1,30 @@ +package org.nsh07.wikireader.ui.savedArticlesScreen + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LargeTopAppBar +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import org.nsh07.wikireader.R + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun SavedArticlesTopBar(scrollBehavior: TopAppBarScrollBehavior, onBack: () -> Unit) { + LargeTopAppBar( + title = { Text("Saved Articles") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = stringResource(R.string.back) + ) + } + }, + scrollBehavior = scrollBehavior + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/nsh07/wikireader/ui/settingsScreen/SettingsScreen.kt b/app/src/main/java/org/nsh07/wikireader/ui/settingsScreen/SettingsScreen.kt index 0e7aee7..1a289e1 100644 --- a/app/src/main/java/org/nsh07/wikireader/ui/settingsScreen/SettingsScreen.kt +++ b/app/src/main/java/org/nsh07/wikireader/ui/settingsScreen/SettingsScreen.kt @@ -65,6 +65,7 @@ fun SettingsScreen( var blackTheme by remember { mutableStateOf(preferencesState.blackTheme) } var expandedSections by remember { mutableStateOf(preferencesState.expandedSections) } var dataSaver by remember { mutableStateOf(preferencesState.dataSaver) } + var renderMath by remember { mutableStateOf(preferencesState.renderMath) } val expandedIcon = if (expandedSections) R.drawable.expand_all @@ -238,6 +239,27 @@ fun SettingsScreen( ) } ) + ListItem( + leadingContent = { + Icon( + painterResource(R.drawable.function), + contentDescription = null + ) + }, + headlineContent = { Text("Render math expressions") }, + supportingContent = { + Text("Requires small amounts of additional data. Turn off to improve performance at the cost of readability") + }, + trailingContent = { + Switch( + checked = renderMath, + onCheckedChange = { + renderMath = it + viewModel.saveRenderMath(it) + } + ) + } + ) Spacer(Modifier.height(insets.calculateBottomPadding())) } diff --git a/app/src/main/java/org/nsh07/wikireader/ui/theme/Theme.kt b/app/src/main/java/org/nsh07/wikireader/ui/theme/Theme.kt index 46298d8..0541aae 100755 --- a/app/src/main/java/org/nsh07/wikireader/ui/theme/Theme.kt +++ b/app/src/main/java/org/nsh07/wikireader/ui/theme/Theme.kt @@ -3,6 +3,7 @@ package org.nsh07.wikireader.ui.theme import android.app.Activity import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ColorScheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme @@ -10,6 +11,7 @@ import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowCompat @@ -74,3 +76,6 @@ fun WikiReaderTheme( content = content ) } + +@Composable +fun ColorScheme.isDark() = this.background.luminance() < 0.5 diff --git a/app/src/main/java/org/nsh07/wikireader/ui/viewModel/UiState.kt b/app/src/main/java/org/nsh07/wikireader/ui/viewModel/UiState.kt index e4bb918..09d12dc 100755 --- a/app/src/main/java/org/nsh07/wikireader/ui/viewModel/UiState.kt +++ b/app/src/main/java/org/nsh07/wikireader/ui/viewModel/UiState.kt @@ -3,6 +3,7 @@ package org.nsh07.wikireader.ui.viewModel import androidx.compose.runtime.Immutable import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.Color +import org.nsh07.wikireader.data.WRStatus import org.nsh07.wikireader.data.WikiLang import org.nsh07.wikireader.data.WikiPhoto import org.nsh07.wikireader.data.WikiPhotoDesc @@ -22,6 +23,10 @@ data class HomeScreenState( val photo: WikiPhoto? = null, val photoDesc: WikiPhotoDesc? = null, val langs: List? = null, + val currentLang: String? = null, + val pageId: Int? = null, + val status: WRStatus = WRStatus.UNINITIALIZED, + val isSaved: Boolean = false, val isLoading: Boolean = false, val isBackStackEmpty: Boolean = true ) @@ -34,5 +39,6 @@ data class PreferencesState( val fontSize: Int = 16, val blackTheme: Boolean = false, val expandedSections: Boolean = false, - val dataSaver: Boolean = false + val dataSaver: Boolean = false, + val renderMath: Boolean = true ) \ No newline at end of file diff --git a/app/src/main/java/org/nsh07/wikireader/ui/viewModel/UiViewModel.kt b/app/src/main/java/org/nsh07/wikireader/ui/viewModel/UiViewModel.kt index 7585f56..6967b24 100755 --- a/app/src/main/java/org/nsh07/wikireader/ui/viewModel/UiViewModel.kt +++ b/app/src/main/java/org/nsh07/wikireader/ui/viewModel/UiViewModel.kt @@ -17,11 +17,18 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import org.nsh07.wikireader.WikiReaderApplication import org.nsh07.wikireader.data.AppPreferencesRepository +import org.nsh07.wikireader.data.WRStatus +import org.nsh07.wikireader.data.WikiApiResponse import org.nsh07.wikireader.data.WikipediaRepository import org.nsh07.wikireader.data.parseText import org.nsh07.wikireader.network.HostSelectionInterceptor +import java.io.File +import java.io.FileOutputStream +import kotlin.io.path.listDirectoryEntries class UiViewModel( private val interceptor: HostSelectionInterceptor, @@ -48,6 +55,7 @@ class UiViewModel( private val backStack = mutableListOf>() private var lastQuery: Pair? = null + private var filesDir: String = "" var isReady = false var isAnimDurationComplete = false @@ -79,6 +87,9 @@ class UiViewModel( val dataSaver = appPreferencesRepository.readBooleanPreference("data-saver") ?: appPreferencesRepository.saveBooleanPreference("data-saver", false) + val renderMath = appPreferencesRepository.readBooleanPreference("render-math") + ?: appPreferencesRepository.saveBooleanPreference("render-math", true) + _preferencesState.update { currentState -> currentState.copy( theme = theme, @@ -87,7 +98,8 @@ class UiViewModel( fontSize = fontSize, blackTheme = blackTheme, expandedSections = expandedSections, - dataSaver = dataSaver + dataSaver = dataSaver, + renderMath = renderMath ) } @@ -107,6 +119,10 @@ class UiViewModel( } } + fun setFilesDir(path: String) { + filesDir = path + } + private fun updateBackstack(q: String, setLang: String, fromBackStack: Boolean) { if (lastQuery != null) { if (!fromBackStack && (Pair(q, setLang) != lastQuery)) { @@ -135,11 +151,12 @@ class UiViewModel( fromBackStack: Boolean = false ) { val q = query?.trim() ?: " " - var setLang = preferencesState.value.lang val history = searchBarState.value.history.toMutableSet() if (q != "") { viewModelScope.launch { + var setLang = preferencesState.value.lang + if (lang != null) { interceptor.setHost("$lang.wikipedia.org") setLang = lang @@ -148,6 +165,7 @@ class UiViewModel( history.remove(q) history.add(q) if (history.size > 50) history.remove(history.first()) + appPreferencesRepository.saveHistory(history) } if (!random) updateBackstack(q, setLang, fromBackStack) @@ -156,9 +174,6 @@ class UiViewModel( currentState.copy(isLoading = true) } - if (!random && !fromLink && !fromBackStack) - appPreferencesRepository.saveHistory(history) - try { val apiResponse = when (random) { false -> wikipediaRepository @@ -175,10 +190,27 @@ class UiViewModel( val extractText = apiResponse ?.extract ?: "" - val extract = if (extractText != "") - parseText(extractText) - else - listOf("No search results found for \"$q\"") + val extract: List + val status: WRStatus + var saved = false + if (extractText != "") { + extract = parseText(extractText) + status = WRStatus.SUCCESS + + try { + val articlesDir = File(filesDir, "savedArticles") + + val file = File( + articlesDir, + "${apiResponse!!.title}.${apiResponse.pageId}.${lastQuery!!.second}" + ) + if (file.exists()) saved = true + } catch (_: Exception) { + } + } else { + extract = listOf("No search results found for \"$q\"") + status = WRStatus.NO_SEARCH_RESULT + } if (random && apiResponse != null) updateBackstack( @@ -194,8 +226,12 @@ class UiViewModel( photo = apiResponse?.photo, photoDesc = apiResponse?.photoDesc, langs = apiResponse?.langs, + currentLang = setLang, + status = status, + pageId = apiResponse?.pageId, isLoading = false, - isBackStackEmpty = backStack.isEmpty() + isBackStackEmpty = backStack.isEmpty(), + isSaved = saved ) } } catch (e: Exception) { @@ -205,9 +241,13 @@ class UiViewModel( title = "Error", extract = listOf("An error occurred :(\nPlease check your internet connection"), langs = null, + currentLang = null, photo = null, photoDesc = null, - isLoading = false + status = WRStatus.NETWORK_ERROR, + pageId = null, + isLoading = false, + isSaved = false ) } } @@ -234,16 +274,213 @@ class UiViewModel( } fun refreshSearch( - random: Boolean = false, - fromLink: Boolean = false, - fromBackStack: Boolean = false + persistLang: Boolean = false ) { - performSearch( - lastQuery?.first, - random = random, - fromLink = fromLink, - fromBackStack = fromBackStack - ) + if (persistLang) + performSearch( + lastQuery?.first, + lang = lastQuery?.second, + random = false, + fromLink = true, + fromBackStack = false + ) + else + performSearch( + lastQuery?.first, + random = false, + fromLink = true, + fromBackStack = false + ) + } + + /** + * Saves the current article to the given directory + * + * @return A [WRStatus] enum value indicating the status of the save operation + */ + suspend fun saveArticle(): WRStatus { + if (homeScreenState.value.status == WRStatus.UNINITIALIZED) { + Log.e("SaveArticle", "Cannot save article, HomeScreenState not initialized") + return WRStatus.OTHER + } + + val currentLang = preferencesState.value.lang + interceptor.setHost("${homeScreenState.value.currentLang}.wikipedia.org") + + try { + val apiResponse = wikipediaRepository + .getSearchResult(homeScreenState.value.title) + + val apiResponseQuery = apiResponse + .query + ?.pages?.get(0) + + if (apiResponseQuery == null) { + Log.e("SaveArticle", "Cannot save article, apiResponse is null") + interceptor.setHost("$currentLang.wikipedia.org") + return WRStatus.NO_SEARCH_RESULT + } + + try { + val fileName = + "${apiResponseQuery.title}.${apiResponseQuery.pageId}.${homeScreenState.value.currentLang}" + val articlesDir = File(filesDir, "savedArticles") + articlesDir.mkdirs() + + val file = File( + articlesDir, + fileName + ) + + FileOutputStream(file).use { + it.write(Json.encodeToString(apiResponse).toByteArray()) + } + + _homeScreenState.update { currentState -> + currentState.copy(isSaved = true) + } + Log.d("HomeScreenState", "Updated saved state to ${homeScreenState.value.isSaved}") + return WRStatus.SUCCESS + } catch (e: Exception) { + Log.e( + "SaveArticle", + "Cannot save article, file IO error" + ) + e.printStackTrace() + interceptor.setHost("$currentLang.wikipedia.org") + return WRStatus.IO_ERROR + } + } catch (_: Exception) { + Log.e("SaveArticle", "Cannot save article, network error") + interceptor.setHost("$currentLang.wikipedia.org") + return WRStatus.NETWORK_ERROR + } + + return WRStatus.OTHER + } + + /** + * Deletes the current article + * + * @return A [WRStatus] enum value indicating the status of the delete operation + */ + fun deleteArticle(fileName: String? = null): WRStatus { + if (homeScreenState.value.status == WRStatus.UNINITIALIZED && fileName == null) { + Log.e("DeleteArticle", "Cannot delete article, HomeScreenState is uninitialized") + return WRStatus.OTHER + } + + try { + val articlesDir = File(filesDir, "savedArticles") + val file = if (fileName == null) + File( + articlesDir, + "${homeScreenState.value.title}.${homeScreenState.value.pageId}.${homeScreenState.value.currentLang}" + ) + else + File(articlesDir, fileName) + + val deleted = file.delete() + if (deleted) { + _homeScreenState.update { currentState -> + currentState.copy(isSaved = false) + } + return WRStatus.SUCCESS + } else return WRStatus.IO_ERROR + } catch (e: Exception) { + Log.e("DeleteArticle", "Cannot delete article") + e.printStackTrace() + return WRStatus.IO_ERROR + } + } + + fun deleteAllArticles(): WRStatus { + try { + val articlesDir = File(filesDir, "savedArticles") + val deleted = articlesDir.deleteRecursively() + return if (deleted) WRStatus.SUCCESS + else WRStatus.IO_ERROR + } catch (e: Exception) { + Log.e("DeleteArticle", "Cannot delete all articles") + e.printStackTrace() + return WRStatus.IO_ERROR + } + } + + fun listArticles(): List { + try { + val articlesDir = File(filesDir, "savedArticles") + val directoryEntries = articlesDir.toPath().listDirectoryEntries() + val out = mutableListOf() + directoryEntries.forEach { + out.add(it.fileName.toString()) + } + out.sort() + Log.d("ListArticles", "${out.size} articles loaded") + return out.toList() + } catch (e: Exception) { + Log.e("ListArticles", "Cannot load list of downloaded articles, IO error") + e.printStackTrace() + return listOf() + } + } + + fun totalArticlesSize(): Long { + try { + val size = File(filesDir, "savedArticles") + .walkTopDown() + .map { it.length() } + .sum() + Log.d("ArticlesSize", "Articles size loaded: $size") + return size + } catch (e: Exception) { + Log.e("ArticlesSize", "Cannot load total article size, IO error") + e.printStackTrace() + return 0 + } + } + + fun loadSavedArticle(fileName: String): WRStatus { + try { + val articlesDir = File(filesDir, "savedArticles") + val file = File(articlesDir, fileName) + val apiResponse = + Json.decodeFromString(file.readText()).query?.pages?.get(0) + + val extract: List = if (apiResponse?.extract != null) + parseText(apiResponse.extract) + else + listOf("Unknown error") + + _preferencesState.update { currentState -> + currentState.copy( + lang = fileName.substringAfterLast('.') + ) + } + _homeScreenState.update { currentState -> + currentState.copy( + title = apiResponse?.title ?: "Error", + extract = extract, + photo = apiResponse?.photo, + photoDesc = apiResponse?.photoDesc, + langs = apiResponse?.langs, + currentLang = preferencesState.value.lang, + status = WRStatus.SUCCESS, + pageId = apiResponse?.pageId, + isLoading = false, + isBackStackEmpty = backStack.isEmpty(), + isSaved = true + ) + } + + if (apiResponse != null) updateBackstack(apiResponse.title, fileName.substringAfterLast('.'), false) + + return WRStatus.SUCCESS + } catch (e: Exception) { + Log.e("LoadSavedArticle", "Cannot load saved article, IO error") + e.printStackTrace() + return WRStatus.IO_ERROR + } } fun popBackStack(): Pair? { @@ -351,6 +588,15 @@ class UiViewModel( } } + fun saveRenderMath(renderMath: Boolean) { + viewModelScope.launch { + appPreferencesRepository.saveBooleanPreference("render-math", renderMath) + _preferencesState.update { currentState -> + currentState.copy(renderMath = renderMath) + } + } + } + fun saveDataSaver(dataSaver: Boolean) { viewModelScope.launch { appPreferencesRepository.saveBooleanPreference("data-saver", dataSaver) diff --git a/app/src/main/res/drawable/delete.xml b/app/src/main/res/drawable/delete.xml new file mode 100644 index 0000000..a146bcb --- /dev/null +++ b/app/src/main/res/drawable/delete.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/drawable/download.xml b/app/src/main/res/drawable/download.xml new file mode 100644 index 0000000..dbbe6cc --- /dev/null +++ b/app/src/main/res/drawable/download.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/download_done.xml b/app/src/main/res/drawable/download_done.xml new file mode 100644 index 0000000..c3a2409 --- /dev/null +++ b/app/src/main/res/drawable/download_done.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/function.xml b/app/src/main/res/drawable/function.xml new file mode 100644 index 0000000..bb2fd89 --- /dev/null +++ b/app/src/main/res/drawable/function.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/drawable/more_horiz.xml b/app/src/main/res/drawable/more_horiz.xml new file mode 100644 index 0000000..1f75d19 --- /dev/null +++ b/app/src/main/res/drawable/more_horiz.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/save.xml b/app/src/main/res/drawable/save.xml new file mode 100644 index 0000000..bb2191a --- /dev/null +++ b/app/src/main/res/drawable/save.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/fastlane/metadata/android/en-US/changelogs/20.txt b/fastlane/metadata/android/en-US/changelogs/20.txt new file mode 100644 index 0000000..225cf40 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/20.txt @@ -0,0 +1,6 @@ +v1.9.0: + +New features: +- Rendering math expressions is now supported +- You can now pull down when completely scrolled up to reload the page +- You can now save articles to your device and read them when offline \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index d91b472..884cc91 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -6,6 +6,7 @@
  • Article image: View an image of the topic from its Wikipedia page. Click on it to enlarge it and view in full-screen
  • Random article: Feeling lucky? Click the random article button to read a random article
  • Choose your language: Choose from over 300 languages on Wikipedia
  • +
  • Save articles: Download articles to your device for offline reading
  • One-handed use: Use the floating action buttons at the bottom for a complete one-handed experience
  • Lightweight: The app starts instantly, and works smoothly
  • Material Design 3: Designed according to the latest Material Design 3 guidelines
  • @@ -13,4 +14,5 @@
  • Customizable colors: Choose from light/dark themes and customize the Material 3 color palette
  • Customizable font size: Choose your own comfortable font size
  • Data saver: Save your limited data plan by loading text only
  • +
  • Math expressions: View properly rendered mathematical expressions for easily reading mathematical articles
  • diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png index 01996f9..17f4ce9 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png index 5c701fe..f3bab62 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png index a5e5a35..f35f8f7 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png index 63d2973..c77aa8e 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png index 71368f5..1cf88c2 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png index 8ff4a9c..defc0ab 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/7.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/7.png index 699eb52..210e6ec 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/7.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/7.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/8.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/8.png new file mode 100644 index 0000000..e26f86d Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/8.png differ