diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1bb0f8578..f62fd501d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -178,4 +178,6 @@ dependencies { implementation(libs.accompanist.permissions) implementation(libs.glance) implementation(libs.glance.appwidget) + implementation(libs.coil.compose) + implementation(libs.coil.network.okhttp) } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e0d435e3a..7dda33881 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -73,6 +73,9 @@ + + + 0 } + val initialTrail = intent.getStringExtra(KEY_TOPIC_TRAIL) + .orEmpty() + .split(',') + .mapNotNull { it.toIntOrNull() } + + setContent { + QuranAppTheme { + QuranicTopicsScreen( + start = screenType, + initialTopicId = initialTopicId, + initialTrail = initialTrail, + ) + } + } + } +} diff --git a/app/src/main/java/com/quranapp/android/api/InventoryUrlFetch.kt b/app/src/main/java/com/quranapp/android/api/InventoryUrlFetch.kt index d027ef8d4..731f7e67d 100644 --- a/app/src/main/java/com/quranapp/android/api/InventoryUrlFetch.kt +++ b/app/src/main/java/com/quranapp/android/api/InventoryUrlFetch.kt @@ -1,8 +1,24 @@ package com.quranapp.android.api +import com.quranapp.android.utils.app.DownloadSourceUtils import okhttp3.ResponseBody import retrofit2.Response +/** + * Resolves inventory URLs based on current download source preference. + * + * - `ghraw://relative/path` - converted into a fully qualified URL with the + * active mirror root from [DownloadSourceUtils.getDownloadSourceRoot]. + * - Any other string - returned as-is. + */ +fun resolveInventoryUrl(url: String): String { + return if (url.startsWith("ghraw://")) { + DownloadSourceUtils.getDownloadSourceRoot() + url.removePrefix("ghraw://").trimStart('/') + } else { + url + } +} + /** * Resolves inventory URLs for streaming download. * diff --git a/app/src/main/java/com/quranapp/android/components/ReferenceVerseModel.kt b/app/src/main/java/com/quranapp/android/components/ReferenceVerseModel.kt index afaaabf99..5c095f87c 100644 --- a/app/src/main/java/com/quranapp/android/components/ReferenceVerseModel.kt +++ b/app/src/main/java/com/quranapp/android/components/ReferenceVerseModel.kt @@ -1,13 +1,20 @@ package com.quranapp.android.components import android.os.Bundle +import androidx.annotation.DrawableRes + +sealed class ReferenceThumbnail { + data class RemoteUrl(val url: String) : ReferenceThumbnail() + data class ResourceId(@DrawableRes val id: Int) : ReferenceThumbnail() +} data class ReferenceVerseModel( val title: String, val desc: String?, - val translSlugs: Set, + val translSlugs: Set = emptySet(), val chapters: Set, val verses: Set, + val thumbnail: ReferenceThumbnail? = null ) { fun toBundle(): Bundle { @@ -17,6 +24,20 @@ data class ReferenceVerseModel( putStringArrayList("translSlugs", ArrayList(translSlugs)) putIntegerArrayList("chapters", ArrayList(chapters)) putStringArrayList("verses", ArrayList(verses)) + + when (thumbnail) { + is ReferenceThumbnail.RemoteUrl -> { + putString("thumbnail_url", thumbnail.url) + } + + is ReferenceThumbnail.ResourceId -> { + putInt("thumbnail_res_id", thumbnail.id) + } + + else -> { + // noop + } + } } } @@ -26,6 +47,9 @@ data class ReferenceVerseModel( return null } + val thumbnailRes = bundle.getInt("thumbnail_res_id", 0) + val thumbnailUrl = bundle.getString("thumbnail_url") + return ReferenceVerseModel( title = bundle.getString("title") ?: "", desc = bundle.getString("desc"), @@ -33,6 +57,11 @@ data class ReferenceVerseModel( ?: emptySet(), chapters = bundle.getIntegerArrayList("chapters")?.let { it.toSet() } ?: emptySet(), verses = bundle.getStringArrayList("verses")?.let { it.toSet() } ?: emptySet(), + thumbnail = when { + thumbnailRes != 0 -> ReferenceThumbnail.ResourceId(thumbnailRes) + thumbnailUrl != null -> ReferenceThumbnail.RemoteUrl(thumbnailUrl) + else -> null + } ) } } diff --git a/app/src/main/java/com/quranapp/android/components/bookmark/BookmarkModel.kt b/app/src/main/java/com/quranapp/android/components/bookmark/BookmarkModel.kt index bd2a9396c..082f44bd6 100644 --- a/app/src/main/java/com/quranapp/android/components/bookmark/BookmarkModel.kt +++ b/app/src/main/java/com/quranapp/android/components/bookmark/BookmarkModel.kt @@ -2,7 +2,7 @@ package com.quranapp.android.components.bookmark import android.content.Context import com.quranapp.android.R -import com.quranapp.android.db.entities.BookmarkEntity +import com.quranapp.android.db.entities.user.BookmarkEntity import com.quranapp.android.utils.univ.DateUtils import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.contentOrNull diff --git a/app/src/main/java/com/quranapp/android/components/quran/QuranProphet.kt b/app/src/main/java/com/quranapp/android/components/quran/QuranProphet.kt index 1f7f41704..a03ab5cbd 100644 --- a/app/src/main/java/com/quranapp/android/components/quran/QuranProphet.kt +++ b/app/src/main/java/com/quranapp/android/components/quran/QuranProphet.kt @@ -48,7 +48,8 @@ class QuranProphet(val prophets: List) { val nameEn: String, val name: String, val honorific: String, - @param:DrawableRes val iconRes: Int = 0 + @param:DrawableRes val iconRes: Int = 0, + val thumbnail: String? ) : Serializable { var references: String? = null diff --git a/app/src/main/java/com/quranapp/android/components/quran/QuranPropheticDua.kt b/app/src/main/java/com/quranapp/android/components/quran/QuranPropheticDua.kt index 17895a7db..f88b1f876 100644 --- a/app/src/main/java/com/quranapp/android/components/quran/QuranPropheticDua.kt +++ b/app/src/main/java/com/quranapp/android/components/quran/QuranPropheticDua.kt @@ -45,7 +45,8 @@ class QuranPropheticDua(val prophets: List) { val order: Int = 0, val name: String, val honorific: String, - @param:DrawableRes val iconRes: Int = 0 + @param:DrawableRes val iconRes: Int = 0, + val thumbnail: String? ) : Serializable { var references: String? = null diff --git a/app/src/main/java/com/quranapp/android/compose/components/VerseOfTheDay.kt b/app/src/main/java/com/quranapp/android/compose/components/VerseOfTheDay.kt index 2e4c9f86d..cc7139494 100644 --- a/app/src/main/java/com/quranapp/android/compose/components/VerseOfTheDay.kt +++ b/app/src/main/java/com/quranapp/android/compose/components/VerseOfTheDay.kt @@ -62,7 +62,6 @@ import com.quranapp.android.components.reader.ChapterVersePair import com.quranapp.android.compose.components.common.IconButton import com.quranapp.android.compose.components.common.Loader import com.quranapp.android.compose.components.reader.LocalRecitation -import com.quranapp.android.compose.components.reader.LocalWbwState import com.quranapp.android.compose.components.reader.QuranTextWbw import com.quranapp.android.compose.components.reader.ReaderLayoutItem import com.quranapp.android.compose.components.reader.ReaderProvider @@ -76,7 +75,6 @@ import com.quranapp.android.compose.utils.preferences.VersePreferences import com.quranapp.android.utils.mediaplayer.WbwAudioPlayer import com.quranapp.android.utils.reader.LocalVerseActions import com.quranapp.android.utils.reader.QuranTextStyleParams -import com.quranapp.android.utils.reader.TranslUtils import com.quranapp.android.utils.reader.TranslationTextStyleParams import com.quranapp.android.utils.reader.VerseActions import com.quranapp.android.utils.reader.atlas.QuranAtlasLoader @@ -161,10 +159,7 @@ internal fun VotdContent( val verse = VerseUtils.getVOTD(context, vm.repository) ?: return@withContext null - val optimalSlug = translationSlugs.firstOrNull { !TranslUtils.isTransliteration(it) } - ?: TranslUtils.TRANSL_SLUG_DEFAULT - - val slugs = setOf(optimalSlug) + val slugs = setOf(ReaderPreferences.primaryTranslationSlug()) val translations = translationFactory.getTranslationsSingleVerse( slugs, diff --git a/app/src/main/java/com/quranapp/android/compose/components/homepage/HomeSectionReadHistory.kt b/app/src/main/java/com/quranapp/android/compose/components/homepage/HomeSectionReadHistory.kt index 84cc85279..659574318 100644 --- a/app/src/main/java/com/quranapp/android/compose/components/homepage/HomeSectionReadHistory.kt +++ b/app/src/main/java/com/quranapp/android/compose/components/homepage/HomeSectionReadHistory.kt @@ -44,7 +44,7 @@ import com.quranapp.android.compose.components.reader.ReaderMode import com.quranapp.android.compose.screens.subtitleLabel import com.quranapp.android.compose.screens.titleLabel import com.quranapp.android.compose.theme.alpha -import com.quranapp.android.db.entities.ReadHistoryEntity +import com.quranapp.android.db.entities.user.ReadHistoryEntity import com.quranapp.android.utils.reader.ReadType import com.quranapp.android.utils.reader.factory.ReaderFactory import com.quranapp.android.viewModels.ReadHistoryViewModel diff --git a/app/src/main/java/com/quranapp/android/compose/components/homepage/HomeSectionRecommended.kt b/app/src/main/java/com/quranapp/android/compose/components/homepage/HomeSectionRecommended.kt index 2285ee7b8..47786b87e 100644 --- a/app/src/main/java/com/quranapp/android/compose/components/homepage/HomeSectionRecommended.kt +++ b/app/src/main/java/com/quranapp/android/compose/components/homepage/HomeSectionRecommended.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.quranapp.android.R +import com.quranapp.android.components.ReferenceVerseModel import com.quranapp.android.db.DatabaseProvider import com.quranapp.android.utils.reader.factory.ReaderFactory import com.quranapp.android.utils.recommended.Recommendation @@ -89,11 +90,12 @@ private fun RecommendationCard( ReaderFactory.startReferenceVerse( context = context, - title = recommendation.title, - desc = recommendation.description, - translSlug = emptySet(), - chapters = chapters, - verses = verseSpecs + ReferenceVerseModel( + title = recommendation.title, + desc = recommendation.description, + chapters = chapters, + verses = verseSpecs + ) ) return@clickable diff --git a/app/src/main/java/com/quranapp/android/compose/components/homepage/HomeSectionVersesCollections.kt b/app/src/main/java/com/quranapp/android/compose/components/homepage/HomeSectionVersesCollections.kt index 0a9d42a3e..94c9a09fa 100644 --- a/app/src/main/java/com/quranapp/android/compose/components/homepage/HomeSectionVersesCollections.kt +++ b/app/src/main/java/com/quranapp/android/compose/components/homepage/HomeSectionVersesCollections.kt @@ -32,6 +32,8 @@ import com.quranapp.android.R import com.quranapp.android.activities.reference.ActivityExclusiveVerses import com.quranapp.android.activities.reference.ActivityProphets import com.quranapp.android.activities.reference.ActivityQuranScience +import com.quranapp.android.activities.reference.ActivityQuranicTopics +import com.quranapp.android.compose.screens.quranictopics.QuranicTopicsStart import com.quranapp.android.compose.screens.reference.ExclusiveVersesScreenKind import com.quranapp.android.compose.theme.alpha @@ -103,7 +105,29 @@ fun HomeSectionVersesCollections() { context.startActivity( Intent(context, ActivityQuranScience::class.java) ) - } + }, + VersesCollectionCard( + R.string.ontologyExplorer, + R.drawable.hierarchy, + ) { + context.startActivity( + Intent(context, ActivityQuranicTopics::class.java).putExtra( + ActivityQuranicTopics.KEY_SCREEN_TYPE, + QuranicTopicsStart.Ontology.name + ) + ) + }, + VersesCollectionCard( + R.string.thematicTopics, + R.drawable.dr_icon_read_quran, + ) { + context.startActivity( + Intent(context, ActivityQuranicTopics::class.java).putExtra( + ActivityQuranicTopics.KEY_SCREEN_TYPE, + QuranicTopicsStart.Thematic.name + ) + ) + }, ) } diff --git a/app/src/main/java/com/quranapp/android/compose/components/quranic_topics/Breadcrumbs.kt b/app/src/main/java/com/quranapp/android/compose/components/quranic_topics/Breadcrumbs.kt new file mode 100644 index 000000000..07386f6d1 --- /dev/null +++ b/app/src/main/java/com/quranapp/android/compose/components/quranic_topics/Breadcrumbs.kt @@ -0,0 +1,108 @@ +package com.quranapp.android.compose.components.quranic_topics + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.quranapp.android.compose.theme.alpha +import com.quranapp.android.viewModels.TopicNode +import horizontalFadingEdge + + +@Composable +fun BreadcrumbTrail( + rootLabel: String, + breadcrumbs: List, + currentTopic: TopicNode, + onRootClick: () -> Unit, + onBreadcrumbClick: (TopicNode, Int) -> Unit, +) { + val scrollState = rememberLazyListState() + val currentItemIndex = (breadcrumbs.size * 2) + 2 + + LaunchedEffect(currentTopic.id, breadcrumbs.size) { + scrollState.scrollToItem(currentItemIndex) + } + + Box( + Modifier.horizontalFadingEdge(scrollState, color = colorScheme.surfaceContainer) + ) { + LazyRow( + state = scrollState, + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth(), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 6.dp) + ) { + item { + BreadcrumbText( + text = rootLabel, + isCurrent = false, + onClick = onRootClick, + ) + } + + breadcrumbs.forEachIndexed { index, breadcrumb -> + item { BreadcrumbSeparator() } + item { + BreadcrumbText( + text = breadcrumb.title, + isCurrent = false, + onClick = { onBreadcrumbClick(breadcrumb, index) }, + ) + } + } + + item { BreadcrumbSeparator() } + + item { + BreadcrumbText( + text = currentTopic.title, + isCurrent = true, + onClick = {}, + ) + } + } + } +} + +@Composable +private fun BreadcrumbSeparator() { + Text( + text = "›", + style = MaterialTheme.typography.labelSmall, + color = colorScheme.onSurface.alpha(0.65f), + ) +} + +@Composable +private fun BreadcrumbText( + text: String, + isCurrent: Boolean, + onClick: () -> Unit, +) { + Text( + text = text, + style = MaterialTheme.typography.labelMedium, + color = if (isCurrent) colorScheme.onSurface else colorScheme.onSurface.alpha(0.65f), + fontWeight = if (isCurrent) FontWeight.SemiBold else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .then(if (isCurrent) Modifier else Modifier.clickable(onClick = onClick)), + ) +} diff --git a/app/src/main/java/com/quranapp/android/compose/components/quranic_topics/Hero.kt b/app/src/main/java/com/quranapp/android/compose/components/quranic_topics/Hero.kt new file mode 100644 index 000000000..f2a6f2359 --- /dev/null +++ b/app/src/main/java/com/quranapp/android/compose/components/quranic_topics/Hero.kt @@ -0,0 +1,425 @@ +package com.quranapp.android.compose.components.quranic_topics + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.MaterialTheme.shapes +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import androidx.navigation.NavGraph.Companion.findStartDestination +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import coil3.request.crossfade +import com.alfaazplus.sunnah.ui.theme.fontArabic +import com.quranapp.android.R +import com.quranapp.android.api.resolveInventoryUrl +import com.quranapp.android.compose.components.reader.dialogs.QuickReference +import com.quranapp.android.compose.components.reader.dialogs.QuickReferenceData +import com.quranapp.android.compose.extensions.bottomBorder +import com.quranapp.android.compose.screens.quranictopics.QuranicTopicRoutes +import com.quranapp.android.compose.screens.quranictopics.topicsNavOptions +import com.quranapp.android.compose.utils.preferences.AppPreferences +import com.quranapp.android.compose.theme.alpha +import com.quranapp.android.utils.Log +import com.quranapp.android.utils.reader.factory.ReaderFactory +import com.quranapp.android.viewModels.TopicNode +import com.quranapp.android.viewModels.TopicsTree +import org.xmlpull.v1.XmlPullParser +import org.xmlpull.v1.XmlPullParserFactory +import java.io.StringReader + + +@Composable +internal fun TopicHeroCard( + navController: NavController, + topic: TopicNode, + tree: TopicsTree, + breadcrumbs: List, + visibleRelatedCount: Int, +) { + val context = LocalContext.current + val downloadSource = AppPreferences.observeResourceDownloadProxy() + var quickRefData by remember { mutableStateOf(null) } + + val heroImageData = remember(topic.imageUrl, downloadSource) { + topic.imageUrl?.let(::resolveInventoryUrl) ?: R.drawable.topic_thumbnail + } + + val heroImageModel = remember(heroImageData, context) { + ImageRequest.Builder(context) + .data(heroImageData) + .crossfade(true) + .build() + } + + val kindLabel = topic.kindLabel() + val typeLabel = topic.type.readableType() + + Box(Modifier.padding(bottom = 8.dp)) { + Column( + Modifier + .background(colorScheme.surfaceContainer) + .bottomBorder(1.dp, colorScheme.outlineVariant.alpha(0.55f)), + ) { + if (breadcrumbs.isNotEmpty()) { + BreadcrumbTrail( + rootLabel = if (tree == TopicsTree.Ontology) "Ontology" else "Themes", + breadcrumbs = breadcrumbs, + currentTopic = topic, + onRootClick = { + navController.navigate( + if (tree == TopicsTree.Ontology) { + QuranicTopicRoutes.ONTOLOGY + } else { + QuranicTopicRoutes.THEMATIC + }, + topicsNavOptions { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + } + ) + }, + onBreadcrumbClick = { breadcrumb, index -> + navController.navigate( + QuranicTopicRoutes.topic( + tree = tree, + topicId = breadcrumb.id, + trail = breadcrumbs.take(index).map { it.id }, + ), + topicsNavOptions() + ) + }, + ) + } + + Surface( + modifier = Modifier.padding(12.dp), + shape = shapes.medium, + ) { + AsyncImage( + model = heroImageModel, + contentDescription = topic.title, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .height(210.dp), + ) + } + + Column( + Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = topic.title, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + TypePill(topic.kindLabel()) + + if (typeLabel != kindLabel) { + TypePill(typeLabel) + } + } + + val description = topic.shortDescription ?: topic.description ?: "" + + if (description.isNotEmpty()) { + TopicDescriptionRichText( + description = description, + onTopicClick = { targetTopicId -> + navController.navigate( + QuranicTopicRoutes.topic( + tree = tree, + topicId = targetTopicId, + trail = breadcrumbs.map { it.id } + topic.id, + ) + ) + }, + onReferenceClick = { chapterNo, verses -> + quickRefData = QuickReferenceData( + slugs = emptySet(), + chapterNo = chapterNo, + verses = verses, + ) + }, + ) + } + + TopicStatsRow( + topic = topic, + visibleRelatedCount = visibleRelatedCount, + ) + } + } + + QuickReference( + data = quickRefData, + onOpenInReader = { chapterNo, range -> + quickRefData = null + ReaderFactory.startVerseRange(context, chapterNo, range.first, range.last) + }, + onClose = { quickRefData = null }, + ) + } +} + +@Composable +private fun TopicDescriptionRichText( + description: String, + onTopicClick: (Int) -> Unit, + onReferenceClick: (chapterNo: Int, verses: String) -> Unit, +) { + val annotated = remember(description) { + parseTopicDescription( + input = description, + onTopicClick = onTopicClick, + onReferenceClick = onReferenceClick, + ) + } + + Text( + text = annotated, + style = MaterialTheme.typography.bodyMedium.copy( + color = colorScheme.onSurface, + ), + ) +} + +private data class OpenTag( + val name: String, + val pushedStyle: Boolean = false, + val pushedLink: Boolean = false, +) + +private fun parseTopicDescription( + input: String, + onTopicClick: (Int) -> Unit, + onReferenceClick: (chapterNo: Int, verses: String) -> Unit, +): AnnotatedString { + return try { + val builder = AnnotatedString.Builder() + val stack = ArrayDeque() + val parser = XmlPullParserFactory.newInstance().newPullParser().apply { + setInput(StringReader("$input")) + } + + var eventType = parser.eventType + while (eventType != XmlPullParser.END_DOCUMENT) { + when (eventType) { + XmlPullParser.START_TAG -> { + val tagName = parser.name.lowercase() + if (tagName == "root") { + stack.addLast(OpenTag(name = tagName)) + } else { + when (tagName) { + "b" -> { + builder.pushStyle(SpanStyle(fontWeight = FontWeight.Bold)) + stack.addLast(OpenTag(name = tagName, pushedStyle = true)) + } + + "ar" -> { + builder.pushStyle(SpanStyle(fontFamily = fontArabic)) + stack.addLast(OpenTag(name = tagName, pushedStyle = true)) + } + + "topic" -> { + val topicId = parser.getAttributeValue(null, "id") + ?: parser.getAttributeValue("", "id") + + if (!topicId.isNullOrBlank()) { + val topicInt = topicId.toIntOrNull() + + builder.pushLink( + LinkAnnotation.Clickable( + tag = "topic:$topicId", + styles = TextLinkStyles( + style = SpanStyle( + textDecoration = TextDecoration.Underline, + fontWeight = FontWeight.Medium, + ) + ), + ) { + if (topicInt != null) onTopicClick(topicInt) + } + ) + stack.addLast(OpenTag(name = tagName, pushedLink = true)) + } else { + stack.addLast(OpenTag(name = tagName)) + } + } + + "reference" -> { + val chapter = parser.getAttributeValue(null, "chapter") + ?: parser.getAttributeValue("", "chapter") + if (!chapter.isNullOrBlank()) { + val chapterInt = chapter.toIntOrNull() + val verses = parser.getAttributeValue(null, "verses") + ?: parser.getAttributeValue("", "verses") + ?: "" + builder.pushLink( + LinkAnnotation.Clickable( + tag = "reference:$chapter|$verses", + styles = TextLinkStyles( + style = SpanStyle( + textDecoration = TextDecoration.Underline, + fontWeight = FontWeight.Medium, + ) + ), + ) { + if (chapterInt != null) onReferenceClick( + chapterInt, + verses + ) + } + ) + stack.addLast(OpenTag(name = tagName, pushedLink = true)) + } else { + stack.addLast(OpenTag(name = tagName)) + } + } + + else -> { + // Unknown tag: ignore wrapper and keep plain inner text. + stack.addLast(OpenTag(name = tagName)) + } + } + } + } + + XmlPullParser.END_TAG -> { + val closeTag = parser.name.lowercase() + while (stack.isNotEmpty()) { + val open = stack.removeLast() + if (open.pushedStyle) builder.pop() + if (open.pushedLink) builder.pop() + if (open.name == closeTag) break + } + } + + XmlPullParser.TEXT -> { + builder.append(parser.text.orEmpty()) + } + } + eventType = parser.next() + } + + while (stack.isNotEmpty()) { + val open = stack.removeLast() + if (open.pushedStyle) builder.pop() + if (open.pushedLink) builder.pop() + } + + builder.toAnnotatedString() + } catch (e: Exception) { + Log.saveError(e, "parseTopicDescription") + AnnotatedString(stripTagsFallback(input)) + } +} + + +private fun stripTagsFallback(input: String): String { + if (input.isEmpty()) return input + val out = StringBuilder(input.length) + var inTag = false + input.forEach { ch -> + when (ch) { + '<' -> inTag = true + '>' -> inTag = false + else -> if (!inTag) out.append(ch) + } + } + return out.toString() +} + + +@Composable +private fun TopicStatsRow( + topic: TopicNode, + visibleRelatedCount: Int, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + StatTile( + label = "Subtopics", + value = topic.childCount.toString(), + modifier = Modifier.weight(1f) + ) + StatTile( + label = "Verses", + value = topic.verseCount.toString(), + modifier = Modifier.weight(1f) + ) + StatTile( + label = "Related", + value = visibleRelatedCount.toString(), + modifier = Modifier.weight(1f) + ) + } +} + +@Composable +private fun StatTile( + label: String, + value: String, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(8.dp), + color = colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, colorScheme.outlineVariant.copy(alpha = 0.45f)), + ) { + Column( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 9.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = value, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = colorScheme.onSurface, + ) + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + } +} diff --git a/app/src/main/java/com/quranapp/android/compose/components/quranic_topics/TopicCards.kt b/app/src/main/java/com/quranapp/android/compose/components/quranic_topics/TopicCards.kt new file mode 100644 index 000000000..b9812228b --- /dev/null +++ b/app/src/main/java/com/quranapp/android/compose/components/quranic_topics/TopicCards.kt @@ -0,0 +1,381 @@ +package com.quranapp.android.compose.components.quranic_topics + +import android.content.Context +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.MaterialTheme.shapes +import androidx.compose.material3.MaterialTheme.typography +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.quranapp.android.R +import com.quranapp.android.components.ReferenceThumbnail +import com.quranapp.android.components.ReferenceVerseModel +import com.quranapp.android.compose.theme.alpha +import com.quranapp.android.compose.utils.formattedStringResource +import com.quranapp.android.db.entities.topics.RelationshipType +import com.quranapp.android.repository.TopicVersePreview +import com.quranapp.android.utils.extensions.copyToClipboard +import com.quranapp.android.utils.quran.parser.ParserUtils +import com.quranapp.android.utils.reader.factory.ReaderFactory +import com.quranapp.android.utils.univ.StringUtils +import com.quranapp.android.viewModels.TopicNode +import com.quranapp.android.viewModels.TopicRelationship +import com.quranapp.android.viewModels.TopicsTree + +@Composable +internal fun ListIntroCard(tree: TopicsTree) { + val title = when (tree) { + TopicsTree.Ontology -> "Browse from general to specific" + TopicsTree.Thematic -> "Browse by meaning and theme" + } + + val body = when (tree) { + TopicsTree.Ontology -> "Open a category to move gradually into focused concepts." + TopicsTree.Thematic -> "Themes organize meanings people usually explore together." + } + + Column( + Modifier.padding(horizontal = 24.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = title, + style = typography.titleSmall, + ) + + Text( + text = body, + style = typography.bodyMedium, + color = colorScheme.onSurface.alpha(0.7f), + ) + } +} + +@Composable +internal fun TopicListItem( + topic: TopicNode, + accent: Color, + onClick: () -> Unit, +) { + val kindLabel = topic.kindLabel() + val typeLabel = topic.type.readableType() + + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + shape = shapes.medium, + color = colorScheme.surface, + border = BorderStroke(1.dp, colorScheme.outlineVariant.copy(alpha = 0.45f)), + onClick = onClick + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + TopicIcon( + label = topic.icon ?: topic.title.take(1), + background = accent.copy(alpha = 0.18f), + contentColor = colorScheme.onSurface, + size = 42, + ) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = topic.title, + style = typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + + Row( + modifier = Modifier.padding(top = 4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + TypePill(topic.kindLabel()) + + if (typeLabel != kindLabel) { + TypePill(typeLabel) + } + } + } + + NodeCount(topic = topic) + + Icon( + painter = painterResource(R.drawable.dr_icon_chevron_right), + contentDescription = null, + tint = colorScheme.onSurfaceVariant.copy(alpha = 0.55f), + modifier = Modifier.size(16.dp), + ) + } + } +} + +@Composable +internal fun TopicExploreCard( + topic: TopicNode, + hasVerses: Boolean, + hasSubtopics: Boolean, + hasRelated: Boolean, +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + shape = shapes.medium, + color = colorScheme.surface, + border = BorderStroke(1.dp, colorScheme.outlineVariant.copy(alpha = 0.45f)), + ) { + Column( + Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + ExploreLine( + title = "Explore this topic", + body = topic.explorationHint(hasVerses, hasSubtopics, hasRelated), + ) + + if (hasVerses) { + ExploreLine( + title = "Read the verses", + body = "See how this meaning appears in its Qur'anic references.", + ) + } + + if (hasSubtopics) { + ExploreLine( + title = "Go deeper", + body = "Open a subtopic to continue in a narrower direction.", + ) + } + + if (hasRelated) { + ExploreLine( + title = "Follow related concepts", + body = "Move sideways to linked ideas for broader reflection.", + ) + } + } + } +} + +@Composable +private fun ExploreLine( + title: String, + body: String, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Text( + text = title, + style = typography.labelLarge, + ) + + Text( + text = body, + style = typography.bodySmall, + color = colorScheme.onSurface.alpha(0.8f), + modifier = Modifier.padding(top = 2.dp), + ) + } +} + +@Composable +internal fun VerseRefsCard( + topic: TopicNode, + totalCount: Int, + verseRefs: List, + previews: List, +) { + val context = LocalContext.current + + + fun openTopicReference( + context: Context, + topic: TopicNode, + ) { + val compressedRefs = ParserUtils.compressVerseRefsByChapter(verseRefs) + val chapters = compressedRefs + .mapNotNull { it.substringBefore(':').toIntOrNull() } + .toSet() + + val description = topic.shortDescription ?: "" + + ReaderFactory.startReferenceVerse( + context, + ReferenceVerseModel( + title = topic.title, + desc = StringUtils.removeHTML(description, true), + chapters = chapters, + verses = compressedRefs.toSet(), + thumbnail = topic.imageUrl?.let { ReferenceThumbnail.RemoteUrl(it) } + ) + ) + } + + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .clip(shapes.medium) + .background(colorScheme.surface) + .border(1.dp, colorScheme.outlineVariant.copy(0.45f), shapes.medium) + .clickable { + openTopicReference(context, topic) + } + .padding(12.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.padding(4.dp) + ) { + Text( + text = "Verses ($totalCount)", + style = typography.titleSmall, + modifier = Modifier.weight(1f) + ) + + Text( + stringResource(R.string.strLabelViewAll), + style = typography.labelMedium + ) + } + + Column( + modifier = Modifier.padding(top = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + previews.forEachIndexed { index, preview -> + Surface( + modifier = Modifier + .fillMaxWidth(), + shape = shapes.small, + color = colorScheme.background, + border = BorderStroke( + 1.dp, + colorScheme.outlineVariant.copy(alpha = 0.35f), + ), + ) { + Column( + Modifier.padding(10.dp) + ) { + Text( + text = formattedStringResource( + R.string.strLabelVerseSerial, + preview.chapterNo, + preview.verseNo + ), + modifier = Modifier + .clip(RoundedCornerShape(5.dp)) + .background(colorScheme.surface) + .clickable( + onClick = { + context.copyToClipboard("${preview.chapterNo}:${preview.verseNo}") + }, + ) + .padding(horizontal = 8.dp, vertical = 4.dp), + color = colorScheme.onBackground, + style = typography.labelMedium, + ) + + val translationText = StringUtils.removeHTML(preview.translation, false) + + if (translationText.isNotEmpty()) { + Text( + text = translationText, + style = typography.bodyMedium, + color = colorScheme.onSurfaceVariant, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 4.dp), + ) + } + } + } + } + + if (totalCount > previews.size) { + Text( + text = "+${totalCount - previews.size} more verses", + style = typography.labelMedium, + color = colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 4.dp), + ) + } + } + } +} + +@Composable +internal fun RelationshipItem( + relationship: TopicRelationship, + onClick: () -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = relationship.type.readableLabel(), + style = typography.labelSmall, + color = colorScheme.onSurfaceVariant, + ) + + TopicListItem( + topic = relationship.topic, + accent = when (relationship.type) { + RelationshipType.RELATED -> colorScheme.secondary + RelationshipType.THEMATIC_PARENT -> colorScheme.tertiary + else -> colorScheme.primary + }, + onClick = onClick, + ) + } +} + +@Composable +internal fun SectionHeader( + title: String, + count: Int, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = "$title ($count)", + style = typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + + HorizontalDivider( + modifier = Modifier.weight(1f), + ) + } +} diff --git a/app/src/main/java/com/quranapp/android/compose/components/quranic_topics/TopicCommon.kt b/app/src/main/java/com/quranapp/android/compose/components/quranic_topics/TopicCommon.kt new file mode 100644 index 000000000..7052e91ae --- /dev/null +++ b/app/src/main/java/com/quranapp/android/compose/components/quranic_topics/TopicCommon.kt @@ -0,0 +1,190 @@ +package com.quranapp.android.compose.components.quranic_topics + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.MaterialTheme.shapes +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.quranapp.android.db.entities.topics.RelationshipType +import com.quranapp.android.viewModels.TopicNode +import java.util.Locale + +@Composable +internal fun EmptyContent( + modifier: Modifier = Modifier, + message: String, +) { + Box( + modifier = modifier + .fillMaxSize() + .padding(24.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } +} + + +@Composable +internal fun TopicIcon( + label: String?, + background: Color, + contentColor: Color, + modifier: Modifier = Modifier, + icon: Int? = null, + size: Int = 44, +) { + Surface( + modifier = modifier.size(size.dp), + shape = RoundedCornerShape(8.dp), + color = background, + ) { + Box(contentAlignment = Alignment.Center) { + if (icon != null) { + Icon( + painter = painterResource(icon), + contentDescription = null, + tint = contentColor, + modifier = Modifier.size((size * 0.46f).dp), + ) + } else { + Text( + text = label.orEmpty().take(2), + style = MaterialTheme.typography.labelLarge, + color = contentColor, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + ) + } + } + } +} + +@Composable +internal fun NodeCount(topic: TopicNode) { + val labels = buildList { + if (topic.childCount > 0) add(plural(topic.childCount, "subtopic")) + if (topic.verseCount > 0) add(plural(topic.verseCount, "verse")) + } + + if (labels.isEmpty()) return + + Column( + horizontalAlignment = Alignment.End, + ) { + labels.take(2).forEach { label -> + CountPill(label) + } + } +} + +@Composable +private fun CountPill(text: String) { + Surface( + shape = RoundedCornerShape(8.dp), + color = colorScheme.surfaceContainerLow, + ) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } +} + +@Composable +internal fun TypePill( + text: String, +) { + Surface( + shape = shapes.large, + color = colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, colorScheme.outlineVariant.copy(alpha = 0.55f)), + tonalElevation = 4.dp + ) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = colorScheme.onSurface, + maxLines = 1, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + ) + } +} + +internal fun plural(count: Int, singular: String): String { + val suffix = if (count == 1) singular else "${singular}s" + return "$count $suffix" +} + +internal fun TopicNode.kindLabel(): String = + when { + childCount > 0 && verseCount > 0 -> "Hybrid" + childCount > 0 -> "Category" + verseCount > 0 -> "Topic" + else -> "Node" + } + +internal fun String.readableType(): String = + replace('_', ' ') + .replace('-', ' ') + .split(' ') + .filter { it.isNotBlank() } + .joinToString(" ") { word -> + word.replaceFirstChar { + if (it.isLowerCase()) { + it.titlecase(Locale.getDefault()) + } else { + it.toString() + } + } + } + +internal fun RelationshipType.readableLabel(): String = + when (this) { + RelationshipType.RELATED -> "Related concept" + RelationshipType.PARENT -> "Parent connection" + RelationshipType.THEMATIC_PARENT -> "Theme connection" + RelationshipType.ONTOLOGY_PARENT -> "Ontology connection" + RelationshipType.NONE -> "Connection" + } + +internal fun String.asPreviewText(): String = + replace(Regex("<[^>]+>"), " ") + .replace(Regex("\\s+"), " ") + .trim() + +internal fun TopicNode.explorationHint( + hasVerses: Boolean, + hasSubtopics: Boolean, + hasRelated: Boolean, +): String = + when { + hasVerses && hasSubtopics -> "Begin with the verses, then move into the subtopics for deeper study." + hasVerses -> "This topic is verse-focused. Read references in sequence for better context." + hasSubtopics -> "This is a broad topic. Open a subtopic to narrow your study." + hasRelated -> "Use related concepts to continue exploring nearby ideas." + else -> "This topic is present, but linked study material is still limited." + } diff --git a/app/src/main/java/com/quranapp/android/compose/components/reader/ReaderLayout.kt b/app/src/main/java/com/quranapp/android/compose/components/reader/ReaderLayout.kt index 8819cbae9..9e21cfdbc 100644 --- a/app/src/main/java/com/quranapp/android/compose/components/reader/ReaderLayout.kt +++ b/app/src/main/java/com/quranapp/android/compose/components/reader/ReaderLayout.kt @@ -43,7 +43,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.quranapp.android.R import com.quranapp.android.compose.components.common.Loader import com.quranapp.android.compose.components.reader.navigator.ReaderFooterNavigator -import com.quranapp.android.db.entities.BookmarkKey +import com.quranapp.android.db.entities.user.BookmarkKey import com.quranapp.android.db.entities.wbw.WbwWordEntity import com.quranapp.android.db.relations.VerseWithDetails import com.quranapp.android.utils.reader.MUSHAF_FONT_WIDTH_DP_MAX diff --git a/app/src/main/java/com/quranapp/android/compose/components/search/ExclusiveSearchResults.kt b/app/src/main/java/com/quranapp/android/compose/components/search/ExclusiveSearchResults.kt index 74a3a8f9a..f9ba30b5d 100644 --- a/app/src/main/java/com/quranapp/android/compose/components/search/ExclusiveSearchResults.kt +++ b/app/src/main/java/com/quranapp/android/compose/components/search/ExclusiveSearchResults.kt @@ -24,9 +24,12 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.quranapp.android.R -import com.quranapp.android.compose.utils.formattedStringResource import com.quranapp.android.activities.reference.ActivityQuranScienceContent +import com.quranapp.android.activities.reference.ActivityQuranicTopics import com.quranapp.android.components.quran.ExclusiveVersesDataset +import com.quranapp.android.compose.screens.quranictopics.QuranicTopicsStart +import com.quranapp.android.compose.utils.formattedStringResource +import com.quranapp.android.db.entities.topics.RelationshipType import com.quranapp.android.search.CollectionSearchResult import com.quranapp.android.utils.reader.ExclusiveVerseNavigator import com.quranapp.android.viewModels.QuranSearchViewModel @@ -58,6 +61,7 @@ fun ExclusiveSearchResults( items = results, key = { when (it) { + is CollectionSearchResult.TopicsDbItem -> "topics-db:${it.hit.topicId}" is CollectionSearchResult.ExclusiveVerseItem -> "${it.dataset.name}:${it.verse.id}" is CollectionSearchResult.ScienceTopicItem -> "science:${it.item.path}" } @@ -85,6 +89,25 @@ private fun ExclusiveSearchResultCard( onClick = { onClick() when (result) { + is CollectionSearchResult.TopicsDbItem -> { + context.startActivity( + Intent(context, ActivityQuranicTopics::class.java).apply { + putExtra( + ActivityQuranicTopics.KEY_SCREEN_TYPE, + when (result.hit.preferredTree) { + RelationshipType.THEMATIC_PARENT -> QuranicTopicsStart.Thematic.name + else -> QuranicTopicsStart.Ontology.name + } + ) + putExtra(ActivityQuranicTopics.KEY_TOPIC_ID, result.hit.topicId) + putExtra( + ActivityQuranicTopics.KEY_TOPIC_TRAIL, + result.hit.breadcrumbIds.joinToString(",") + ) + } + ) + } + is CollectionSearchResult.ExclusiveVerseItem -> { ExclusiveVerseNavigator.open(context, result.dataset, result.verse) } @@ -117,15 +140,29 @@ private fun ExclusiveSearchResultCard( fontWeight = FontWeight.SemiBold, ) - if (result is CollectionSearchResult.ScienceTopicItem) { - Text( - text = formattedStringResource( - R.string.strLabelScienceReferences, - result.item.referencesCount - ), - style = typography.bodyMedium, - color = colorScheme.onSurfaceVariant, - ) + when (result) { + is CollectionSearchResult.TopicsDbItem -> { + Text( + text = result.hit.pathLabel, + style = typography.bodyMedium, + color = colorScheme.onSurfaceVariant, + ) + } + + is CollectionSearchResult.ScienceTopicItem -> { + Text( + text = formattedStringResource( + R.string.strLabelScienceReferences, + result.item.referencesCount + ), + style = typography.bodyMedium, + color = colorScheme.onSurfaceVariant, + ) + } + + else -> { + // noop + } } } } @@ -133,6 +170,7 @@ private fun ExclusiveSearchResultCard( private fun datasetTitleRes(result: CollectionSearchResult): Int { return when (result) { + is CollectionSearchResult.TopicsDbItem -> R.string.topics is CollectionSearchResult.ExclusiveVerseItem -> { when (result.dataset) { ExclusiveVersesDataset.Dua -> R.string.strTitleFeaturedDuas @@ -148,6 +186,7 @@ private fun datasetTitleRes(result: CollectionSearchResult): Int { private fun resultTitle(result: CollectionSearchResult): String { return when (result) { + is CollectionSearchResult.TopicsDbItem -> result.hit.title is CollectionSearchResult.ExclusiveVerseItem -> result.verse.title is CollectionSearchResult.ScienceTopicItem -> result.item.getTitle() } diff --git a/app/src/main/java/com/quranapp/android/compose/navigation/NavHostController.kt b/app/src/main/java/com/quranapp/android/compose/navigation/NavHostController.kt deleted file mode 100644 index b25dc2884..000000000 --- a/app/src/main/java/com/quranapp/android/compose/navigation/NavHostController.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.quranapp.android.compose.navigation - -import androidx.compose.runtime.compositionLocalOf -import androidx.navigation.NavHostController - -val LocalSettingsNavHostController = compositionLocalOf { - error("NavHostController is not provided") -} \ No newline at end of file diff --git a/app/src/main/java/com/quranapp/android/compose/screens/BookmarksScreen.kt b/app/src/main/java/com/quranapp/android/compose/screens/BookmarksScreen.kt index b00603ff0..3632a3a83 100644 --- a/app/src/main/java/com/quranapp/android/compose/screens/BookmarksScreen.kt +++ b/app/src/main/java/com/quranapp/android/compose/screens/BookmarksScreen.kt @@ -63,7 +63,7 @@ import com.quranapp.android.compose.components.reader.dialogs.BookmarkViewerData import com.quranapp.android.compose.components.reader.dialogs.BookmarkViewerSheet import com.quranapp.android.compose.theme.alpha import com.quranapp.android.compose.utils.formattedStringResource -import com.quranapp.android.db.entities.BookmarkEntity +import com.quranapp.android.db.entities.user.BookmarkEntity import com.quranapp.android.viewModels.BookmarksViewModel import kotlinx.coroutines.launch import java.util.Calendar diff --git a/app/src/main/java/com/quranapp/android/compose/screens/ReadHistoryScreen.kt b/app/src/main/java/com/quranapp/android/compose/screens/ReadHistoryScreen.kt index 874b1e2a9..ae51925eb 100644 --- a/app/src/main/java/com/quranapp/android/compose/screens/ReadHistoryScreen.kt +++ b/app/src/main/java/com/quranapp/android/compose/screens/ReadHistoryScreen.kt @@ -50,7 +50,7 @@ import com.quranapp.android.compose.components.dialogs.SimpleTooltip import com.quranapp.android.compose.components.reader.ReaderMode import com.quranapp.android.compose.theme.alpha import com.quranapp.android.compose.utils.formattedStringResource -import com.quranapp.android.db.entities.ReadHistoryEntity +import com.quranapp.android.db.entities.user.ReadHistoryEntity import com.quranapp.android.utils.reader.ReadType import com.quranapp.android.utils.reader.factory.ReaderFactory import com.quranapp.android.utils.reader.getQuranScriptName diff --git a/app/src/main/java/com/quranapp/android/compose/screens/quranictopics/QuranicTopicsScreen.kt b/app/src/main/java/com/quranapp/android/compose/screens/quranictopics/QuranicTopicsScreen.kt new file mode 100644 index 000000000..dc463806e --- /dev/null +++ b/app/src/main/java/com/quranapp/android/compose/screens/quranictopics/QuranicTopicsScreen.kt @@ -0,0 +1,174 @@ +package com.quranapp.android.compose.screens.quranictopics + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavHostController +import androidx.navigation.NavOptionsBuilder +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import androidx.navigation.navOptions +import com.quranapp.android.compose.screens.settings.route +import com.quranapp.android.viewModels.TopicDetailUiState +import com.quranapp.android.viewModels.TopicsTree +import com.quranapp.android.viewModels.QuranicTopicsViewModel +import com.quranapp.android.viewModels.buildTopicDetailKey + +enum class QuranicTopicsStart { + Ontology, + Thematic, +} + +internal object QuranicTopicRoutes { + const val ONTOLOGY = "ontology" + const val THEMATIC = "thematic" + const val TOPIC = "topic/{tree}/{topicId}?trail={trail}" + const val TOPIC_SEARCH = "topic_search/{tree}" + + fun topic(tree: TopicsTree, topicId: Int, trail: List = emptyList()): String = + "topic/${tree.routeName}/$topicId?trail=${trail.joinToString(",")}" + + fun topicSearch(tree: TopicsTree): String = "topic_search/${tree.routeName}" +} + +val LocalTopicsNavController = compositionLocalOf { + error("NavHostController is not provided") +} + +fun topicsNavOptions(optionsBuilder: (NavOptionsBuilder.() -> Unit)? = null) = + navOptions { + restoreState = true + optionsBuilder?.invoke(this) + } + +@Composable +fun QuranicTopicsScreen( + start: QuranicTopicsStart, + initialTopicId: Int? = null, + initialTrail: List = emptyList(), + viewModel: QuranicTopicsViewModel = viewModel(), +) { + val navController = rememberNavController() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + CompositionLocalProvider(LocalTopicsNavController provides navController) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + NavHost( + navController = navController, + startDestination = initialTopicId?.let { topicId -> + QuranicTopicRoutes.topic( + tree = when (start) { + QuranicTopicsStart.Thematic -> TopicsTree.Thematic + QuranicTopicsStart.Ontology -> TopicsTree.Ontology + }, + topicId = topicId, + trail = initialTrail, + ) + } ?: when (start) { + QuranicTopicsStart.Thematic -> QuranicTopicRoutes.THEMATIC + else -> QuranicTopicRoutes.ONTOLOGY + }, + ) { + route(QuranicTopicRoutes.ONTOLOGY) { + QuranicTopicListRoute( + title = "Ontology Explorer", + tree = TopicsTree.Ontology, + isLoading = uiState.isLoadingRoots, + topics = uiState.ontologyRoots, + primaryTopicCount = uiState.ontologyPrimaryRootCount, + hasMoreSupplementalPages = uiState.hasMoreOntologySupplemental, + isLoadingMoreSupplemental = uiState.isLoadingMoreOntologySupplemental, + onLoadMoreSupplemental = { viewModel.loadMoreSupplementalRoots(TopicsTree.Ontology) }, + onOpenSearch = { + navController.navigate( + QuranicTopicRoutes.topicSearch(TopicsTree.Ontology), + navOptions = topicsNavOptions() + ) + }, + ) + } + route(QuranicTopicRoutes.THEMATIC) { + QuranicTopicListRoute( + title = "Thematic Topics", + tree = TopicsTree.Thematic, + isLoading = uiState.isLoadingRoots, + topics = uiState.thematicRoots, + primaryTopicCount = uiState.thematicPrimaryRootCount, + hasMoreSupplementalPages = uiState.hasMoreThematicSupplemental, + isLoadingMoreSupplemental = uiState.isLoadingMoreThematicSupplemental, + onLoadMoreSupplemental = { viewModel.loadMoreSupplementalRoots(TopicsTree.Thematic) }, + onOpenSearch = { + navController.navigate( + QuranicTopicRoutes.topicSearch(TopicsTree.Thematic), + navOptions = topicsNavOptions() + ) + }, + ) + } + route( + route = QuranicTopicRoutes.TOPIC_SEARCH, + arguments = listOf( + navArgument("tree") { type = NavType.StringType }, + ), + ) { entry -> + val tree = TopicsTree.fromRouteName(entry.arguments?.getString("tree")) + + QuranicTopicsSearchRoute( + tree = tree, + onSearchTopics = { query -> viewModel.searchTopicsForTree(query, tree) }, + ) + } + route( + route = QuranicTopicRoutes.TOPIC, + arguments = listOf( + navArgument("tree") { type = NavType.StringType }, + navArgument("topicId") { type = NavType.IntType }, + navArgument("trail") { + type = NavType.StringType + defaultValue = "" + }, + ), + ) { entry -> + val tree = TopicsTree.fromRouteName(entry.arguments?.getString("tree")) + val topicId = entry.arguments?.getInt("topicId") ?: 0 + + val breadcrumbIds = entry.arguments + ?.getString("trail") + .orEmpty() + .split(',') + .mapNotNull { it.toIntOrNull() } + + val detailKey = buildTopicDetailKey(tree, topicId, breadcrumbIds) + val detailState = uiState.topicDetails[detailKey] + ?: TopicDetailUiState(isLoading = topicId > 0) + + LaunchedEffect(topicId, tree, breadcrumbIds) { + if (topicId > 0) { + viewModel.loadTopic(topicId, tree, breadcrumbIds) + } + } + + QuranicTopicDetailRoute( + state = detailState, + roots = if (tree == TopicsTree.Ontology) uiState.ontologyRoots else uiState.thematicRoots, + tree = tree, + topicId = topicId, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/quranapp/android/compose/screens/quranictopics/QuranicTopicsSearchScreen.kt b/app/src/main/java/com/quranapp/android/compose/screens/quranictopics/QuranicTopicsSearchScreen.kt new file mode 100644 index 000000000..b0e0697d3 --- /dev/null +++ b/app/src/main/java/com/quranapp/android/compose/screens/quranictopics/QuranicTopicsSearchScreen.kt @@ -0,0 +1,219 @@ +package com.quranapp.android.compose.screens.quranictopics + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +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.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.quranapp.android.R +import com.quranapp.android.compose.components.common.AppBar +import com.quranapp.android.compose.components.common.Loader +import com.quranapp.android.compose.components.common.SearchTextField +import com.quranapp.android.compose.components.quranic_topics.EmptyContent +import com.quranapp.android.db.entities.topics.RelationshipType +import com.quranapp.android.repository.TopicSearchHit +import com.quranapp.android.viewModels.TopicsTree +import kotlinx.coroutines.delay + +private const val SEARCH_RESULTS_LIMIT = 60 + +@Composable +internal fun QuranicTopicsSearchRoute( + tree: TopicsTree, + onSearchTopics: suspend (String) -> List, +) { + val navController = LocalTopicsNavController.current + val focusRequester = remember { FocusRequester() } + val keyboard = LocalSoftwareKeyboardController.current + + var query by rememberSaveable { mutableStateOf("") } + var isSearching by remember { mutableStateOf(false) } + var results by remember { mutableStateOf>(emptyList()) } + + val normalizedQuery = query.trim() + + LaunchedEffect(Unit) { + delay(120) + focusRequester.requestFocus() + keyboard?.show() + } + + LaunchedEffect(normalizedQuery, tree) { + if (normalizedQuery.isEmpty()) { + isSearching = false + results = emptyList() + return@LaunchedEffect + } + + isSearching = true + + delay(200) + results = onSearchTopics(normalizedQuery) + + isSearching = false + } + + Scaffold( + containerColor = colorScheme.surface, + topBar = { + AppBar( + title = when (tree) { + TopicsTree.Ontology -> "Search Ontology Topics" + TopicsTree.Thematic -> "Search Thematic Topics" + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + SearchTextField( + value = query, + onValueChange = { query = it }, + placeholder = stringResource(R.string.strHintSearch), + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, top = 16.dp) + .focusRequester(focusRequester), + ) + + when { + normalizedQuery.isEmpty() -> { + // noop + } + + isSearching -> { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + contentAlignment = Alignment.Center, + ) { + Loader() + } + } + + results.isEmpty() -> EmptyContent(message = "No topic matches found") + + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(start = 12.dp, end = 12.dp, bottom = 120.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + items(results, key = { "topic_search_${it.topicId}" }) { hit -> + TopicResultCard( + hit = hit, + tree = tree, + onClick = { + val targetTree = when (hit.preferredTree) { + RelationshipType.THEMATIC_PARENT -> TopicsTree.Thematic + else -> TopicsTree.Ontology + } + navController.navigate( + QuranicTopicRoutes.topic( + tree = targetTree, + topicId = hit.topicId, + trail = hit.breadcrumbIds, + ), + navOptions = topicsNavOptions() + ) + }, + ) + } + } + } + } + } + } +} + +@Composable +private fun TopicResultCard( + hit: TopicSearchHit, + tree: TopicsTree, + onClick: () -> Unit, +) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + border = BorderStroke(1.dp, colorScheme.outlineVariant.copy(alpha = 0.55f)), + onClick = onClick, + ) { + Column( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = hit.title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = colorScheme.onSurface, + ) + + Text( + text = hit.pathLabel, + style = MaterialTheme.typography.bodySmall, + color = colorScheme.onSurfaceVariant, + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + TopicMetaChip( + text = if (hit.ayahCount == 1) "1 verse" else "${hit.ayahCount} verses", + ) + TopicMetaChip( + text = if (tree == TopicsTree.Ontology) "Ontology" else "Thematic", + ) + } + } + } +} + +@Composable +private fun TopicMetaChip(text: String) { + Surface( + shape = MaterialTheme.shapes.large, + color = colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, colorScheme.outlineVariant.copy(alpha = 0.45f)), + ) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + ) + } +} diff --git a/app/src/main/java/com/quranapp/android/compose/screens/quranictopics/TopicsDetailScreen.kt b/app/src/main/java/com/quranapp/android/compose/screens/quranictopics/TopicsDetailScreen.kt new file mode 100644 index 000000000..06bb8a7e5 --- /dev/null +++ b/app/src/main/java/com/quranapp/android/compose/screens/quranictopics/TopicsDetailScreen.kt @@ -0,0 +1,261 @@ +package com.quranapp.android.compose.screens.quranictopics + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.quranapp.android.compose.components.common.AppBar +import com.quranapp.android.compose.components.common.Loader +import com.quranapp.android.compose.components.quranic_topics.EmptyContent +import com.quranapp.android.compose.components.quranic_topics.RelationshipItem +import com.quranapp.android.compose.components.quranic_topics.SectionHeader +import com.quranapp.android.compose.components.quranic_topics.TopicExploreCard +import com.quranapp.android.compose.components.quranic_topics.TopicHeroCard +import com.quranapp.android.compose.components.quranic_topics.TopicListItem +import com.quranapp.android.compose.components.quranic_topics.VerseRefsCard +import com.quranapp.android.viewModels.TopicDetailUiState +import com.quranapp.android.viewModels.TopicNode +import com.quranapp.android.viewModels.TopicsTree + +@Composable +internal fun QuranicTopicDetailRoute( + state: TopicDetailUiState, + roots: List, + tree: TopicsTree, + topicId: Int, +) { + val navController = LocalTopicsNavController.current + val topic = state.topic + val currentTrail = state.breadcrumbs.map { it.id } + val listState = rememberSaveable(topicId, saver = LazyListState.Saver) { + LazyListState() + } + + val showTitleInTopBar by remember { + derivedStateOf { + listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 500 + } + } + + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + AppBar( + title = if (showTitleInTopBar) topic?.title ?: "Topic" else "", + shadowElevation = if (showTitleInTopBar) 4.dp else 0.dp + ) + }, + containerColor = colorScheme.background, + ) { padding -> + when { + state.isLoading && topic == null -> Loader(true) + topic == null -> EmptyContent( + message = "Topic not found" + ) + + else -> { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding), + state = listState, + contentPadding = PaddingValues( + bottom = 128.dp, + ), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { + TopicHeroCard( + navController = navController, + topic = topic, + tree = tree, + breadcrumbs = state.breadcrumbs, + visibleRelatedCount = state.relationships.size, + ) + } + + item { + TopicExploreCard( + topic = topic, + hasVerses = state.verseRefs.isNotEmpty(), + hasSubtopics = state.childTopics.isNotEmpty() || state.broaderCatalogChildren.isNotEmpty(), + hasRelated = state.relationships.isNotEmpty(), + ) + } + + if (state.verseRefs.isNotEmpty()) { + item { + VerseRefsCard( + topic = topic, + totalCount = topic.verseCount, + verseRefs = state.verseRefs, + previews = state.versePreviews, + ) + } + } + + if (state.childTopics.isNotEmpty()) { + item { + SectionHeader( + title = if (topic.verseCount > 0) "Go Deeper" else "Subtopics", + count = state.childTopics.size, + ) + } + + items(state.childTopics, key = { it.id }) { child -> + TopicListItem( + topic = child, + accent = if (tree == TopicsTree.Ontology) { + colorScheme.primary + } else { + colorScheme.tertiary + }, + onClick = { + val (targetTopicId, targetTrail) = resolveChildTopicNavigationTarget( + child = child, + detailState = state, + roots = roots, + fallbackTrail = currentTrail + topic.id, + ) + navController.navigate( + QuranicTopicRoutes.topic( + tree = tree, + topicId = targetTopicId, + trail = targetTrail, + ), + topicsNavOptions() + ) + }, + ) + } + } + + if (state.broaderCatalogChildren.isNotEmpty()) { + item { + SectionHeader( + title = "More from broader catalog", + count = state.broaderCatalogChildren.size, + ) + } + + items(state.broaderCatalogChildren, key = { "broader_${it.id}" }) { child -> + TopicListItem( + topic = child, + accent = if (tree == TopicsTree.Ontology) { + colorScheme.primary + } else { + colorScheme.tertiary + }, + onClick = { + val (targetTopicId, targetTrail) = resolveChildTopicNavigationTarget( + child = child, + detailState = state, + roots = roots, + fallbackTrail = currentTrail + topic.id, + ) + navController.navigate( + QuranicTopicRoutes.topic( + tree = tree, + topicId = targetTopicId, + trail = targetTrail, + ), + topicsNavOptions() + ) + }, + ) + } + } + + if (state.relationships.isNotEmpty()) { + item { + SectionHeader( + title = "Connected Concepts", + count = state.relationships.size, + ) + } + + items( + items = state.relationships, + key = { "${it.type}_${it.topic.id}" }, + ) { relationship -> + RelationshipItem( + relationship = relationship, + onClick = { + navController.navigate( + QuranicTopicRoutes.topic( + tree = tree, + topicId = relationship.topic.id, + trail = currentTrail + topic.id, + ), + topicsNavOptions() + ) + }, + ) + } + } + } + } + } + } +} + +private fun resolveChildTopicNavigationTarget( + child: TopicNode, + detailState: TopicDetailUiState, + roots: List, + fallbackTrail: List, +): Pair> { + if (child.childCount > 0 || child.verseCount > 0) { + return child.id to fallbackTrail + } + + val candidates = buildList { + addAll(detailState.breadcrumbs) + detailState.topic?.let(::add) + addAll(roots) + addAll(detailState.childTopics) + addAll(detailState.broaderCatalogChildren) + addAll(detailState.relationships.map { it.topic }) + } + + val normalizedChildTitle = normalizeTopicLabel(child.title) + if (normalizedChildTitle.isBlank()) return child.id to fallbackTrail + + val target = candidates + .asSequence() + .filter { it.id != child.id } + .distinctBy { it.id } + .sortedByDescending { normalizeTopicLabel(it.title).length } + .firstOrNull { candidate -> + val normalizedCandidate = normalizeTopicLabel(candidate.title) + normalizedCandidate.isNotBlank() && + ( + normalizedChildTitle == normalizedCandidate || + Regex("\\b${Regex.escape(normalizedCandidate)}\\b") + .containsMatchIn(normalizedChildTitle) + ) + } + ?: return child.id to fallbackTrail + + return target.id to emptyList() +} + +private fun normalizeTopicLabel(value: String): String { + return value + .lowercase() + .replace(Regex("[^\\p{L}\\p{N}\\s]"), " ") + .replace(Regex("\\s+"), " ") + .trim() +} diff --git a/app/src/main/java/com/quranapp/android/compose/screens/quranictopics/TopicsListScreen.kt b/app/src/main/java/com/quranapp/android/compose/screens/quranictopics/TopicsListScreen.kt new file mode 100644 index 000000000..9941cb6d5 --- /dev/null +++ b/app/src/main/java/com/quranapp/android/compose/screens/quranictopics/TopicsListScreen.kt @@ -0,0 +1,228 @@ +package com.quranapp.android.compose.screens.quranictopics + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +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.foundation.lazy.rememberLazyListState +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +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.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.quranapp.android.R +import com.quranapp.android.compose.components.common.AppBar +import com.quranapp.android.compose.components.common.IconButton +import com.quranapp.android.compose.components.common.Loader +import com.quranapp.android.compose.components.dialogs.AlertDialog +import com.quranapp.android.compose.components.dialogs.AlertDialogAction +import com.quranapp.android.compose.components.quranic_topics.EmptyContent +import com.quranapp.android.compose.components.quranic_topics.ListIntroCard +import com.quranapp.android.compose.components.quranic_topics.TopicListItem +import com.quranapp.android.viewModels.TopicNode +import com.quranapp.android.viewModels.TopicsTree +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter + +@Composable +internal fun QuranicTopicListRoute( + title: String, + tree: TopicsTree, + isLoading: Boolean, + topics: List, + primaryTopicCount: Int, + hasMoreSupplementalPages: Boolean, + isLoadingMoreSupplemental: Boolean, + onLoadMoreSupplemental: () -> Unit, + onOpenSearch: () -> Unit, +) { + var infoDialogShown by remember { mutableStateOf(false) } + val navController = LocalTopicsNavController.current + val listState = rememberLazyListState() + val clampedPrimaryCount = primaryTopicCount.coerceIn(0, topics.size) + + val primaryTopics = remember(topics, clampedPrimaryCount) { + topics.take(clampedPrimaryCount) + } + + val supplementalTopics = remember(topics, clampedPrimaryCount) { + topics.drop(clampedPrimaryCount) + } + + LaunchedEffect(listState, topics.size, hasMoreSupplementalPages, isLoadingMoreSupplemental) { + snapshotFlow { + val layoutInfo = listState.layoutInfo + val total = layoutInfo.totalItemsCount + + if (total <= 0) return@snapshotFlow false + + val lastVisible = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + + lastVisible >= total - 4 + } + .distinctUntilChanged() + .filter { nearEnd -> nearEnd && hasMoreSupplementalPages && !isLoadingMoreSupplemental } + .collect { + onLoadMoreSupplemental() + } + } + + Scaffold( + containerColor = colorScheme.background, + topBar = { + AppBar( + title = title, + actions = { + IconButton( + painter = painterResource(R.drawable.dr_icon_search) + ) { + onOpenSearch() + } + IconButton( + painter = painterResource(R.drawable.dr_icon_info) + ) { + infoDialogShown = true + } + }, + ) + }, + ) { padding -> + when { + isLoading -> Loader(true) + topics.isEmpty() -> EmptyContent( + modifier = Modifier.padding(padding), + message = "No topics found", + ) + + else -> { + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentPadding = PaddingValues( + top = 12.dp, + bottom = 64.dp, + ), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + item(key = "intro") { + ListIntroCard(tree = tree) + } + + items(primaryTopics, key = { it.id }) { topic -> + TopicListItem( + topic = topic, + accent = if (tree == TopicsTree.Ontology) { + colorScheme.primary + } else { + colorScheme.tertiary + }, + onClick = { + navController.navigate( + QuranicTopicRoutes.topic( + tree = tree, + topicId = topic.id, + ), + navOptions = topicsNavOptions() + ) + } + ) + } + + if (supplementalTopics.isNotEmpty()) { + item(key = "supplemental_header") { + Text( + text = "More topics from broader catalog", + style = MaterialTheme.typography.titleSmall, + color = colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 6.dp), + ) + } + items(supplementalTopics, key = { "supplemental_${it.id}" }) { topic -> + TopicListItem( + topic = topic, + accent = if (tree == TopicsTree.Ontology) { + colorScheme.primary + } else { + colorScheme.tertiary + }, + onClick = { + navController.navigate( + QuranicTopicRoutes.topic( + tree = tree, + topicId = topic.id, + ), + navOptions = topicsNavOptions() + ) + }, + ) + } + } + + if (hasMoreSupplementalPages || isLoadingMoreSupplemental) { + item(key = "load_more_footer") { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (isLoadingMoreSupplemental) { + CircularProgressIndicator( + modifier = Modifier.size(22.dp), + strokeWidth = 2.dp, + ) + Text( + text = "Loading more topics…", + style = MaterialTheme.typography.bodySmall, + color = colorScheme.onSurfaceVariant, + ) + } else { + Text( + text = "Scroll down to load more topics.", + style = MaterialTheme.typography.bodySmall, + color = colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + } + } + } + + AlertDialog( + isOpen = infoDialogShown, + onClose = { infoDialogShown = false }, + title = stringResource(R.string.about_this_page), + actions = listOf( + AlertDialogAction( + text = stringResource(R.string.strLabelGotIt), + ), + ), + ) { + Text( + stringResource(R.string.englishContentOnly) + ) + } +} diff --git a/app/src/main/java/com/quranapp/android/compose/screens/reference/OntologyExplorerScreen.kt b/app/src/main/java/com/quranapp/android/compose/screens/reference/OntologyExplorerScreen.kt new file mode 100644 index 000000000..d337ff18b --- /dev/null +++ b/app/src/main/java/com/quranapp/android/compose/screens/reference/OntologyExplorerScreen.kt @@ -0,0 +1,7 @@ +package com.quranapp.android.compose.screens.reference + +import androidx.compose.runtime.Composable + +@Composable +fun OntologyExplorerScreen() { +} diff --git a/app/src/main/java/com/quranapp/android/compose/screens/reference/PropheticDuasScreen.kt b/app/src/main/java/com/quranapp/android/compose/screens/reference/PropheticDuasScreen.kt index 09885647f..610408595 100644 --- a/app/src/main/java/com/quranapp/android/compose/screens/reference/PropheticDuasScreen.kt +++ b/app/src/main/java/com/quranapp/android/compose/screens/reference/PropheticDuasScreen.kt @@ -45,14 +45,15 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.quranapp.android.R -import com.quranapp.android.activities.reference.ActivityReference +import com.quranapp.android.components.ReferenceThumbnail +import com.quranapp.android.components.ReferenceVerseModel import com.quranapp.android.components.quran.QuranPropheticDua import com.quranapp.android.compose.components.common.AppBar import com.quranapp.android.compose.components.common.BottomSheetMenu import com.quranapp.android.compose.components.common.BottomSheetMenuItem import com.quranapp.android.compose.components.dialogs.SimpleTooltip import com.quranapp.android.compose.theme.alpha -import com.quranapp.android.utils.reader.factory.ReaderFactory.prepareReferenceVerseIntent +import com.quranapp.android.utils.reader.factory.ReaderFactory import kotlinx.coroutines.delay import java.text.MessageFormat import java.util.regex.Pattern @@ -194,16 +195,17 @@ private fun PropheticDuaListItem(prophet: QuranPropheticDua.Prophet) { R.string.strMsgPropheticDuaInQuran, MessageFormat.format("{0} ({1})", prophet.name, prophet.honorific), ) - val intent = prepareReferenceVerseIntent( - refTitle, - resources.getString(R.string.strMsgReferenceDuas), - emptySet(), - prophet.chapters, - prophet.verses, - ).apply { - setClass(context, ActivityReference::class.java) - } - context.startActivity(intent) + + ReaderFactory.startReferenceVerse( + context, + ReferenceVerseModel( + title = refTitle, + desc = resources.getString(R.string.strMsgReferenceDuas), + chapters = prophet.chapters, + verses = prophet.verses, + thumbnail = prophet.thumbnail?.let { ReferenceThumbnail.RemoteUrl(it) } + ) + ) }, modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp), diff --git a/app/src/main/java/com/quranapp/android/compose/screens/reference/ProphetsScreen.kt b/app/src/main/java/com/quranapp/android/compose/screens/reference/ProphetsScreen.kt index dda2fcc41..0e72515a9 100644 --- a/app/src/main/java/com/quranapp/android/compose/screens/reference/ProphetsScreen.kt +++ b/app/src/main/java/com/quranapp/android/compose/screens/reference/ProphetsScreen.kt @@ -45,21 +45,21 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.quranapp.android.R -import com.quranapp.android.activities.reference.ActivityReference +import com.quranapp.android.components.ReferenceThumbnail +import com.quranapp.android.components.ReferenceVerseModel import com.quranapp.android.components.quran.QuranProphet import com.quranapp.android.compose.components.common.AppBar import com.quranapp.android.compose.components.common.BottomSheetMenu import com.quranapp.android.compose.components.common.BottomSheetMenuItem import com.quranapp.android.compose.components.dialogs.SimpleTooltip import com.quranapp.android.compose.theme.alpha -import com.quranapp.android.utils.reader.factory.ReaderFactory.prepareReferenceVerseIntent +import com.quranapp.android.utils.reader.factory.ReaderFactory import kotlinx.coroutines.delay import java.text.MessageFormat import java.util.regex.Pattern @Composable fun ProphetsScreen() { - val context = LocalContext.current val allProphets = QuranProphet.observe() var searchQuery by remember { mutableStateOf("") } @@ -189,23 +189,22 @@ private fun ProphetListItem(prophet: QuranProphet.Prophet) { R.string.strMsgReferenceInQuran, MessageFormat.format("{0} ({1})", prophet.name, prophet.honorific), ) - val desc = resources.getString( R.string.strMsgReferenceFoundPlaces, title, prophet.verses.size, ) - val intent = prepareReferenceVerseIntent( - title, - desc, - emptySet(), - prophet.chapters, - prophet.verses, - ).apply { - setClass(context, ActivityReference::class.java) - } - context.startActivity(intent) + ReaderFactory.startReferenceVerse( + context, + ReferenceVerseModel( + title = title, + desc = desc, + chapters = prophet.chapters, + verses = prophet.verses, + thumbnail = prophet.thumbnail?.let { ReferenceThumbnail.RemoteUrl(it) } + ) + ) }, modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp), diff --git a/app/src/main/java/com/quranapp/android/compose/screens/reference/ReferenceScreen.kt b/app/src/main/java/com/quranapp/android/compose/screens/reference/ReferenceScreen.kt index 119776b59..6f5ac88b0 100644 --- a/app/src/main/java/com/quranapp/android/compose/screens/reference/ReferenceScreen.kt +++ b/app/src/main/java/com/quranapp/android/compose/screens/reference/ReferenceScreen.kt @@ -1,5 +1,6 @@ package com.quranapp.android.compose.screens.reference +import android.content.Context import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -26,7 +27,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.MaterialTheme.shapes import androidx.compose.material3.MaterialTheme.typography @@ -43,8 +43,8 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable @@ -56,6 +56,7 @@ import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource @@ -68,8 +69,13 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.window.core.layout.WindowSizeClass +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import coil3.request.crossfade import com.quranapp.android.R import com.quranapp.android.activities.ActivityReader +import com.quranapp.android.api.resolveInventoryUrl +import com.quranapp.android.components.ReferenceThumbnail import com.quranapp.android.components.ReferenceVerseModel import com.quranapp.android.compose.components.common.Chip import com.quranapp.android.compose.components.common.IconButton @@ -87,10 +93,11 @@ import com.quranapp.android.compose.components.reader.VerseView import com.quranapp.android.compose.components.reader.dialogs.QuickReferenceVerses import com.quranapp.android.compose.components.reader.dialogs.parseVerses import com.quranapp.android.compose.theme.alpha +import com.quranapp.android.compose.utils.preferences.AppPreferences import com.quranapp.android.compose.utils.preferences.ReaderPreferences import com.quranapp.android.compose.utils.readAppLocale -import com.quranapp.android.db.entities.BookmarkKey -import com.quranapp.android.repository.QuranRepository +import com.quranapp.android.db.entities.user.BookmarkKey +import com.quranapp.android.utils.Log import com.quranapp.android.utils.extensions.isSingleValue import com.quranapp.android.utils.reader.ComposeUiConfig import com.quranapp.android.utils.reader.LocalVerseActions @@ -105,10 +112,16 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.withContext -import java.util.concurrent.ConcurrentHashMap +import java.util.Locale +import kotlin.math.min private sealed class ReferenceRow { - data class Description(val title: String, val desc: String?) : ReferenceRow() + data class Description( + val title: String, + val desc: String?, + val thumbnail: ReferenceThumbnail? + ) : ReferenceRow() + data class SectionTitle( val segmentKey: String, val ref: QuickReferenceVerses.Range, @@ -121,6 +134,24 @@ private sealed class ReferenceRow { ) : ReferenceRow() } +private const val REFERENCE_VERSE_CHUNK_SIZE = 32 +private const val REFERENCE_MAX_IN_FLIGHT_CHUNKS = 2 + +private data class ReferenceSegment( + val segmentIndex: Int, + val chapterNo: Int, + val versesRangeStr: String, + val ref: QuickReferenceVerses.Range, + val chapterName: String, +) + +private data class ReferenceChunkRequest( + val segment: ReferenceSegment, + val verseNos: List, + val isFirstChunk: Boolean, + val isLastChunk: Boolean, +) + @OptIn(ExperimentalMaterial3Api::class) @Composable fun ReferenceScreen(refModel: ReferenceVerseModel) { @@ -150,40 +181,39 @@ private fun ReferenceScreenContent( val vm = LocalReaderViewModel.current val textMeasurer = rememberTextMeasurer() - val colors by rememberUpdatedState(MaterialTheme.colorScheme) - val type by rememberUpdatedState(MaterialTheme.typography) + val colors by rememberUpdatedState(colorScheme) + val type by rememberUpdatedState(typography) val density = LocalDensity.current - var rows by remember { mutableStateOf>(emptyList()) } + val rows = remember { mutableStateListOf() } var loading by remember { mutableStateOf(true) } + var chapterNames by remember { mutableStateOf>(emptyMap()) } val verseActions = LocalVerseActions.current val allBookmarks by vm.userRepository.getBookmarksFlow() .collectAsStateWithLifecycle(initialValue = emptyList()) - val referenceBookmarkKeys = remember(rows) { - rows.asSequence().mapNotNull { row -> - when (row) { - is ReferenceRow.SectionTitle -> BookmarkKey( - row.ref.chapterNo, - row.ref.range.first, - row.ref.range.last, - ) - - is ReferenceRow.VerseRow -> BookmarkKey( - row.verseUi.verse.chapterNo, - row.verseUi.verse.verseNo, - row.verseUi.verse.verseNo, - ) + val referenceBookmarkKeys by remember { + derivedStateOf { + rows.asSequence().mapNotNull { row -> + when (row) { + is ReferenceRow.SectionTitle -> BookmarkKey( + row.ref.chapterNo, + row.ref.range.first, + row.ref.range.last, + ) - else -> null - } - }.toHashSet() - } + is ReferenceRow.VerseRow -> BookmarkKey( + row.verseUi.verse.chapterNo, + row.verseUi.verse.verseNo, + row.verseUi.verse.verseNo, + ) - val chapterNames by produceState?>(null, refModel.chapters) { - value = vm.repository.getChapterNames(refModel.chapters.toList()) + else -> null + } + }.toHashSet() + } } val bookmarkedKeys = remember(allBookmarks, referenceBookmarkKeys) { @@ -193,34 +223,47 @@ private fun ReferenceScreenContent( .toHashSet() } - LaunchedEffect(refModel, selectedChapterChip, translationSlugs, colors, type, density) { + LaunchedEffect(refModel, selectedChapterChip, translationSlugs) { loading = true - rows = withContext(Dispatchers.IO) { - val params = TextBuilderParams( - uiConfig = ComposeUiConfig( - context = context, - colors = colors, - type = type, - density = density, - textMeasurer = textMeasurer - ), - fontResolver = vm.fontResolver, - verseActions = verseActions, - arabicEnabled = ReaderPreferences.getArabicTextEnabled(), - script = ReaderPreferences.getQuranScript(), - arabicSizeMultiplier = ReaderPreferences.getArabicTextSizeMultiplier(), - translationSizeMultiplier = ReaderPreferences.getTranslationTextSizeMultiplier(), - slugs = translationSlugs, - ) - buildReferenceRows( - context = context, - refModel = refModel, - selectedChapterFilter = selectedChapterChip, - params = params, - repository = vm.repository, - ) + rows.clear() + rows.add(ReferenceRow.Description(refModel.title, refModel.desc, refModel.thumbnail)) + + chapterNames = withContext(Dispatchers.IO) { + vm.repository.getChapterNames(refModel.chapters.toList()) } + + val params = TextBuilderParams( + uiConfig = ComposeUiConfig( + context = context, + colors = colors, + type = type, + density = density, + textMeasurer = textMeasurer + ), + fontResolver = vm.fontResolver, + verseActions = verseActions, + arabicEnabled = ReaderPreferences.getArabicTextEnabled(), + script = ReaderPreferences.getQuranScript(), + arabicSizeMultiplier = ReaderPreferences.getArabicTextSizeMultiplier(), + translationSizeMultiplier = ReaderPreferences.getTranslationTextSizeMultiplier(), + slugs = translationSlugs, + ) + + buildReferenceRows( + context = context, + refModel = refModel, + selectedChapterFilter = selectedChapterChip, + params = params, + chapterNamesByNo = chapterNames, + onChunkBuilt = { chunkRows -> + if (chunkRows.isNotEmpty()) { + rows.addAll(chunkRows) + loading = false + } + }, + ) + loading = false } @@ -296,103 +339,107 @@ private fun ReferenceScreenContent( .padding(padding) ) { val contentPane: @Composable (Modifier) -> Unit = { modifier -> - Column(modifier = modifier) { - if (translationSlugs.isEmpty()) { - Text( - text = stringResource(R.string.strMsgTranslNoneSelected), - color = colorScheme.error, - style = typography.bodySmall, - modifier = Modifier - .fillMaxWidth() - .background(colorScheme.surface) - .padding(horizontal = 16.dp, vertical = 8.dp), - ) - } + if (loading) { + Loader(fill = true) + } else { + Column(modifier = modifier) { + if (translationSlugs.isEmpty()) { + Text( + text = stringResource(R.string.strMsgTranslNoneSelected), + color = colorScheme.error, + style = typography.bodySmall, + modifier = Modifier + .fillMaxWidth() + .background(colorScheme.surface) + .padding(horizontal = 16.dp, vertical = 8.dp), + ) + } - val referencePageTextStyles = remember(rows) { - buildMap { - for (row in rows) { - if (row is ReferenceRow.VerseRow) { - row.quranTextStyle?.let { - put( - row.verseUi.verse.pageNo, - it - ) + val referencePageTextStyles by remember { + derivedStateOf { + buildMap { + for (row in rows) { + if (row is ReferenceRow.VerseRow) { + row.quranTextStyle?.let { + put( + row.verseUi.verse.pageNo, + it + ) + } + } } } } } - } - when { - loading -> Loader(fill = true) - else -> { - TextStyleProvider(referencePageTextStyles) { - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(bottom = dynamicBottomPadding + 64.dp) - ) { - items( - items = rows, - key = { row -> - when (row) { - is ReferenceRow.Description -> "desc" - is ReferenceRow.SectionTitle -> row.segmentKey - is ReferenceRow.VerseRow -> row.verseUi.key - } - }, - ) { row -> + TextStyleProvider(referencePageTextStyles) { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = dynamicBottomPadding + 64.dp) + ) { + items( + items = rows, + key = { row -> when (row) { - is ReferenceRow.Description -> ReferenceDescription( - row.title, - row.desc - ) - - is ReferenceRow.SectionTitle -> ReferenceSectionTitle( - row = row, - isBookmarked = bookmarkedKeys.contains( - BookmarkKey( - row.ref.chapterNo, - row.ref.range.first, - row.ref.range.last, - ), + is ReferenceRow.Description -> "desc" + is ReferenceRow.SectionTitle -> row.segmentKey + is ReferenceRow.VerseRow -> row.verseUi.key + } + }, + ) { row -> + when (row) { + is ReferenceRow.Description -> ReferenceDescription(row) + + is ReferenceRow.SectionTitle -> ReferenceSectionTitle( + row = row, + isBookmarked = bookmarkedKeys.contains( + BookmarkKey( + row.ref.chapterNo, + row.ref.range.first, + row.ref.range.last, ), - onOpenInReader = { chapterNo, range -> - val i = - ReaderFactory.prepareVerseRangeIntent( - chapterNo, - range.first, - range.last + ), + onOpenInReader = { chapterNo, range -> + val i = + ReaderFactory.prepareVerseRangeIntent( + chapterNo, + range.first, + range.last + ) + .setClass( + context, + ActivityReader::class.java ) - .setClass( - context, - ActivityReader::class.java - ) - .putExtra( - Keys.READER_KEY_TRANSL_SLUGS, - translationSlugs.toTypedArray() - ) - .putExtra( - Keys.READER_KEY_SAVE_TRANSL_CHANGES, - false - ) - - context.startActivity(i) - }, - ) - - is ReferenceRow.VerseRow -> ReferenceVerseViewWrapped( - verseUi = row.verseUi, - isBookmarked = bookmarkedKeys.contains( - BookmarkKey( - row.verseUi.verse.chapterNo, - row.verseUi.verse.verseNo, - row.verseUi.verse.verseNo, - ), + .putExtra( + Keys.READER_KEY_TRANSL_SLUGS, + translationSlugs.toTypedArray() + ) + .putExtra( + Keys.READER_KEY_SAVE_TRANSL_CHANGES, + false + ) + + context.startActivity(i) + }, + ) + + is ReferenceRow.VerseRow -> ReferenceVerseViewWrapped( + verseUi = row.verseUi, + isBookmarked = bookmarkedKeys.contains( + BookmarkKey( + row.verseUi.verse.chapterNo, + row.verseUi.verse.verseNo, + row.verseUi.verse.verseNo, ), - ) - } + ), + ) + } + } + + if (loading) { + item("loading-footer") { + Loader(fill = false) } } } @@ -403,15 +450,13 @@ private fun ReferenceScreenContent( if (showTwoPane) { Row(modifier = Modifier.fillMaxSize()) { - chapterNames?.let { - ReferenceChapterChipsSidebar( - selectedChapterChip = selectedChapterChip, - chapterNames = it, - chapters = refModel.chapters, - onChapterChipChange = onChapterChipChange, - listState = chaptersGroupState, - ) - } + ReferenceChapterChipsSidebar( + selectedChapterChip = selectedChapterChip, + chapterNames = chapterNames, + chapters = refModel.chapters, + onChapterChipChange = onChapterChipChange, + listState = chaptersGroupState, + ) VerticalDivider(color = colorScheme.outlineVariant.alpha(0.6f)) @@ -419,15 +464,13 @@ private fun ReferenceScreenContent( } } else { Column(modifier = Modifier.fillMaxSize()) { - chapterNames?.let { - ReferenceChapterChipsTopBar( - selectedChapterChip = selectedChapterChip, - chapterNames = it, - chapters = refModel.chapters, - onChapterChipChange = onChapterChipChange, - listState = chaptersGroupState, - ) - } + ReferenceChapterChipsTopBar( + selectedChapterChip = selectedChapterChip, + chapterNames = chapterNames, + chapters = refModel.chapters, + onChapterChipChange = onChapterChipChange, + listState = chaptersGroupState, + ) contentPane(Modifier.weight(1f)) } } @@ -554,7 +597,10 @@ private fun ReferenceSidebarItem( } @Composable -private fun ReferenceDescription(title: String, desc: String?) { +private fun ReferenceDescription(row: ReferenceRow.Description) { + val context = LocalContext.current + val downloadSource = AppPreferences.observeResourceDownloadProxy() + val gradient = Brush.verticalGradient( colors = listOf( colorScheme.surfaceContainer, @@ -562,6 +608,23 @@ private fun ReferenceDescription(title: String, desc: String?) { ), ) + + val heroImageModel = remember(context, row.thumbnail, downloadSource) { + val heroImageData = + when (row.thumbnail) { + is ReferenceThumbnail.RemoteUrl -> resolveInventoryUrl(row.thumbnail.url) + is ReferenceThumbnail.ResourceId -> row.thumbnail.id + else -> null + } + + if (heroImageData == null) return@remember null + + ImageRequest.Builder(context) + .data(heroImageData) + .crossfade(true) + .build() + } + Column( modifier = Modifier .fillMaxWidth() @@ -569,15 +632,28 @@ private fun ReferenceDescription(title: String, desc: String?) { .padding(start = 16.dp, end = 16.dp, top = 20.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { + if (heroImageModel != null) { + Surface(shape = shapes.medium) { + AsyncImage( + model = heroImageModel, + contentDescription = row.title, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .height(210.dp), + ) + } + } + Text( - text = title, + text = row.title, style = typography.titleMedium, color = colorScheme.primary, ) - if (!desc.isNullOrBlank()) { + if (!row.desc.isNullOrBlank()) { Text( - text = desc, + text = row.desc, color = colorScheme.onSurface.copy(alpha = 0.85f), ) } @@ -685,27 +761,101 @@ private fun resolveTranslationSlugs( } private suspend fun buildReferenceRows( - context: android.content.Context, + context: Context, refModel: ReferenceVerseModel, selectedChapterFilter: Int, params: TextBuilderParams, - repository: QuranRepository, -): List = coroutineScope { - val out = ArrayList() + chapterNamesByNo: Map, + onChunkBuilt: suspend (List) -> Unit, +) = coroutineScope { + var segments = parseReferenceSegments( + refModel = refModel, + selectedChapterFilter = selectedChapterFilter, + chapterNamesByNo = chapterNamesByNo, + ) - out.add(ReferenceRow.Description(refModel.title, refModel.desc)) + if (segments.isEmpty()) return@coroutineScope - data class Segment( - val segmentIndex: Int, - val chapterNo: Int, - val versesRangeStr: String, - val ref: QuickReferenceVerses.Range, - val chapterName: String, + val locale = readAppLocale(context).platformLocale + val chunkRequests = buildChunkRequests(segments) + val sectionTitleBySegmentIndex = buildSectionTitlesBySegment( + segments = segments, + locale = locale, ) - val segments = ArrayList() + val totalChunkCount = chunkRequests.size + val buildStartNs = System.nanoTime() + var firstChunkDone = false + var windowStart = 0 + + while (windowStart < totalChunkCount) { + val windowEnd = min(windowStart + REFERENCE_MAX_IN_FLIGHT_CHUNKS, totalChunkCount) + val window = chunkRequests.subList(windowStart, windowEnd) + + val builtWindow = withContext(Dispatchers.IO) { + window.map { req -> + async { + val prepared = ReaderItemsBuilder.buildQuickReferenceItems( + context = context, + params = params, + chapterNo = req.segment.chapterNo, + verseNos = req.verseNos, + ) + req to prepared + } + }.awaitAll() + } + + for ((req, prepared) in builtWindow) { + val verseUis = + prepared?.items?.filterIsInstance().orEmpty() + val textStyles = prepared?.textStyles.orEmpty() + val chunkRows = ArrayList(verseUis.size + 1) + + if (req.isFirstChunk) { + chunkRows.add( + ReferenceRow.SectionTitle( + segmentKey = "st-${req.segment.segmentIndex}-${req.segment.chapterNo}-${req.segment.versesRangeStr}", + ref = req.segment.ref, + titleText = sectionTitleBySegmentIndex[req.segment.segmentIndex].orEmpty(), + ), + ) + } + + for ((idx, verseUi) in verseUis.withIndex()) { + val showDivider = if (req.isLastChunk) idx != verseUis.lastIndex else true + chunkRows.add( + ReferenceRow.VerseRow( + verseUi = verseUi.copy( + key = "ref-${req.segment.segmentIndex}-${verseUi.key}", + showDivider = showDivider, + ), + quranTextStyle = textStyles[verseUi.verse.pageNo], + ), + ) + } + + if (chunkRows.isNotEmpty()) { + onChunkBuilt(chunkRows) + if (!firstChunkDone) { + val firstChunkMs = (System.nanoTime() - buildStartNs) / 1_000_000 + Log.d("ReferenceScreen first chunk ready in ms =", firstChunkMs) + firstChunkDone = true + } + } + } + + windowStart = windowEnd + } +} + +private fun parseReferenceSegments( + refModel: ReferenceVerseModel, + selectedChapterFilter: Int, + chapterNamesByNo: Map, +): List { + val segments = ArrayList() var segmentIndex = 0 - val chapterNoCache = ConcurrentHashMap() for (refStr in refModel.verses) { val parts = refStr.split(":") @@ -716,83 +866,79 @@ private suspend fun buildReferenceRows( val versesRangeStr = parts[1] val ref = parseVerses(chapterNo, versesRangeStr) - if (ref !is QuickReferenceVerses.Range) continue - val chapterName = chapterNoCache.getOrPut( - chapterNo, - { repository.getChapterName(chapterNo) }, - ) - segments.add( - Segment( + ReferenceSegment( segmentIndex = segmentIndex, chapterNo = chapterNo, versesRangeStr = versesRangeStr, ref = ref, - chapterName = chapterName, + chapterName = chapterNamesByNo[chapterNo].orEmpty(), ), ) - segmentIndex++ } - val built = segments.map { seg -> - async { - val verseNos = seg.ref.range.toList() - val prepared = ReaderItemsBuilder.buildQuickReferenceItems( - context, - params, - seg.chapterNo, - verseNos, - ) ?: return@async Triple(seg, emptyList(), emptyMap()) - val verseUis = prepared.items.filterIsInstance() - Triple(seg, verseUis, prepared.textStyles) - } - }.awaitAll() - - val locale = readAppLocale(context).platformLocale + return segments +} - for ((seg, verseUis, textStyles) in built) { - val titleText = if (seg.ref.range.isSingleValue) { - String.format( - locale, - $$"%1$s %2$d:%3$d", - seg.chapterName, - seg.chapterNo, - seg.ref.range.first - ) - } else { - String.format( - locale, - $$"%1$s %2$d:%3$d-%4$d", - seg.chapterName, - seg.chapterNo, - seg.ref.range.first, - seg.ref.range.last +private fun buildChunkRequests( + segments: List, +): List { + val requests = ArrayList() + + for (segment in segments) { + val startVerse = segment.ref.range.first + val endVerse = segment.ref.range.last + if (startVerse > endVerse) continue + + val chunkCount = ((endVerse - startVerse) / REFERENCE_VERSE_CHUNK_SIZE) + 1 + for (chunkIndex in 0 until chunkCount) { + val chunkStart = startVerse + (chunkIndex * REFERENCE_VERSE_CHUNK_SIZE) + val chunkEnd = min(chunkStart + REFERENCE_VERSE_CHUNK_SIZE - 1, endVerse) + val chunkVerseNos = (chunkStart..chunkEnd).toList() + requests.add( + ReferenceChunkRequest( + segment = segment, + verseNos = chunkVerseNos, + isFirstChunk = chunkIndex == 0, + isLastChunk = chunkIndex == chunkCount - 1, + ), ) } + } - out.add( - ReferenceRow.SectionTitle( - segmentKey = "st-${seg.segmentIndex}-${seg.chapterNo}-${seg.versesRangeStr}", - ref = seg.ref, - titleText = titleText, - ), - ) + return requests +} - for ((i, v) in verseUis.withIndex()) { - out.add( - ReferenceRow.VerseRow( - verseUi = v.copy( - key = "ref-${seg.segmentIndex}-${v.key}", - showDivider = i != verseUis.lastIndex, - ), - quranTextStyle = textStyles[v.verse.pageNo], - ), - ) +private fun buildSectionTitlesBySegment( + segments: List, + locale: Locale, +): Map { + return buildMap(segments.size) { + for (segment in segments) { + val chapterLabel = segment.chapterName.ifBlank { segment.chapterNo.toString() } + val text = if (segment.ref.range.isSingleValue) { + String.format( + locale, + $$"%1$s %2$d:%3$d", + chapterLabel, + segment.chapterNo, + segment.ref.range.first + ) + } else { + String.format( + locale, + $$"%1$s %2$d:%3$d-%4$d", + chapterLabel, + segment.chapterNo, + segment.ref.range.first, + segment.ref.range.last + ) + } + + put(segment.segmentIndex, text) } } - - out } diff --git a/app/src/main/java/com/quranapp/android/compose/screens/reference/ThematicTopicsScreen.kt b/app/src/main/java/com/quranapp/android/compose/screens/reference/ThematicTopicsScreen.kt new file mode 100644 index 000000000..680b1ef2b --- /dev/null +++ b/app/src/main/java/com/quranapp/android/compose/screens/reference/ThematicTopicsScreen.kt @@ -0,0 +1,7 @@ +package com.quranapp.android.compose.screens.reference + +import androidx.compose.runtime.Composable + +@Composable +fun ThematicTopicsScreen() { +} diff --git a/app/src/main/java/com/quranapp/android/compose/screens/settings/ScriptsScreen.kt b/app/src/main/java/com/quranapp/android/compose/screens/settings/ScriptsScreen.kt index bba6ed5f7..20c5c6cde 100644 --- a/app/src/main/java/com/quranapp/android/compose/screens/settings/ScriptsScreen.kt +++ b/app/src/main/java/com/quranapp/android/compose/screens/settings/ScriptsScreen.kt @@ -1,5 +1,6 @@ package com.quranapp.android.compose.screens.settings +import android.text.format.Formatter import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -327,8 +328,8 @@ private fun ScriptDownloadRequestAlert( msg.append("\n").append( stringResource( R.string.msgDownloadFontsSize, - downloadSize.first, - downloadSize.second + downloadSize.first / 1000, + downloadSize.second / 1000 ) ) } @@ -343,6 +344,9 @@ private fun ScriptAtlasDownloadRequestAlert( scriptKey: String?, onDismiss: () -> Unit, ) { + val estimatedSizeBytes = scriptKey?.getQuranScriptFontPackSizeMb()?.first ?: 0 + val context = LocalContext.current + AlertDialog( isOpen = scriptKey != null, onClose = onDismiss, @@ -365,6 +369,12 @@ private fun ScriptAtlasDownloadRequestAlert( ) { if (scriptKey == null) return@AlertDialog - Text(scriptKey.getQuranScriptName()) + Text( + scriptKey.getQuranScriptName() + "\n" + + stringResource( + R.string.estimatedSize, + Formatter.formatFileSize(context, estimatedSizeBytes * 1000L) + ) + ) } } diff --git a/app/src/main/java/com/quranapp/android/compose/screens/settings/SettingsMainScreen.kt b/app/src/main/java/com/quranapp/android/compose/screens/settings/SettingsMainScreen.kt index 6bfc985b1..39109ecbe 100644 --- a/app/src/main/java/com/quranapp/android/compose/screens/settings/SettingsMainScreen.kt +++ b/app/src/main/java/com/quranapp/android/compose/screens/settings/SettingsMainScreen.kt @@ -30,7 +30,6 @@ import com.quranapp.android.compose.components.settings.ListItemCategoryLabel import com.quranapp.android.compose.components.settings.ResourceDownloadSrcSheet import com.quranapp.android.compose.components.settings.SettingsItem import com.quranapp.android.compose.components.settings.TextSizeSheet -import com.quranapp.android.compose.navigation.LocalSettingsNavHostController import com.quranapp.android.compose.navigation.SettingRoutes import com.quranapp.android.compose.utils.LocalAppLocale import com.quranapp.android.compose.utils.ThemeUtils @@ -52,7 +51,7 @@ fun SettingsMainScreen( showReaderSettingsOnly: Boolean ) { val context = LocalContext.current - val navController = LocalSettingsNavHostController.current + val navController = LocalSettingsNavController.current val coroutineScope = rememberCoroutineScope() val appLocale = LocalAppLocale.current diff --git a/app/src/main/java/com/quranapp/android/compose/screens/settings/SettingsScreen.kt b/app/src/main/java/com/quranapp/android/compose/screens/settings/SettingsScreen.kt index e16a19957..84f5787c7 100644 --- a/app/src/main/java/com/quranapp/android/compose/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/com/quranapp/android/compose/screens/settings/SettingsScreen.kt @@ -2,11 +2,7 @@ package com.quranapp.android.compose.screens.settings import android.content.Intent import androidx.compose.animation.AnimatedContentScope -import androidx.compose.animation.core.FastOutLinearInEasing -import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.tween -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.foundation.layout.fillMaxSize @@ -15,40 +11,40 @@ import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.compositionLocalOf import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.IntOffset import androidx.navigation.NamedNavArgument import androidx.navigation.NavBackStackEntry import androidx.navigation.NavDeepLink import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument -import com.quranapp.android.compose.navigation.LocalSettingsNavHostController import com.quranapp.android.compose.navigation.SettingRoutes import com.quranapp.android.utils.univ.Keys -val enterTransition = slideInHorizontally( +private val enterTransition = slideInHorizontally( initialOffsetX = { fullWidth -> fullWidth }, animationSpec = tween(durationMillis = 100), ) -val exitTransition = slideOutHorizontally( +private val exitTransition = slideOutHorizontally( targetOffsetX = { fullWidth -> -fullWidth }, animationSpec = tween(durationMillis = 100), ) -val popEnterTransition = slideInHorizontally( +private val popEnterTransition = slideInHorizontally( initialOffsetX = { fullWidth -> -fullWidth }, animationSpec = tween(durationMillis = 100), ) -val popExitTransition = slideOutHorizontally( +private val popExitTransition = slideOutHorizontally( targetOffsetX = { fullWidth -> fullWidth }, animationSpec = tween(durationMillis = 100), ) -private fun NavGraphBuilder.route( +fun NavGraphBuilder.route( route: String, arguments: List = emptyList(), deepLinks: List = emptyList(), @@ -66,6 +62,10 @@ private fun NavGraphBuilder.route( ) } +val LocalSettingsNavController = compositionLocalOf { + error("NavHostController is not provided") +} + @Composable fun SettingsScreen(intent: Intent?, isNewIntent: Boolean) { val navController = rememberNavController() @@ -86,7 +86,7 @@ fun SettingsScreen(intent: Intent?, isNewIntent: Boolean) { val startDestination = intent?.getStringExtra(Keys.NAV_DESTINATION) ?: SettingRoutes.MAIN.arg(false) - CompositionLocalProvider(LocalSettingsNavHostController provides navController) { + CompositionLocalProvider(LocalSettingsNavController provides navController) { Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { NavHost( modifier = Modifier.fillMaxSize(), diff --git a/app/src/main/java/com/quranapp/android/compose/screens/settings/TranslationDownloadScreen.kt b/app/src/main/java/com/quranapp/android/compose/screens/settings/TranslationDownloadScreen.kt index c1a6e5bba..38bc921d0 100644 --- a/app/src/main/java/com/quranapp/android/compose/screens/settings/TranslationDownloadScreen.kt +++ b/app/src/main/java/com/quranapp/android/compose/screens/settings/TranslationDownloadScreen.kt @@ -22,7 +22,6 @@ import com.quranapp.android.compose.components.common.ErrorMessageCard import com.quranapp.android.compose.components.common.IconButton import com.quranapp.android.compose.components.common.Loader import com.quranapp.android.compose.components.settings.TranslationDownloadList -import com.quranapp.android.compose.navigation.LocalSettingsNavHostController import com.quranapp.android.compose.navigation.SettingRoutes import com.quranapp.android.utils.univ.MessageUtils import com.quranapp.android.viewModels.TranslationDownloadEvent @@ -35,7 +34,7 @@ import com.quranapp.android.viewModels.TranslationViewModel fun TranslationDownloadScreen() { val context = LocalContext.current val resources = LocalResources.current - val navController = LocalSettingsNavHostController.current + val navController = LocalSettingsNavController.current val viewModel = viewModel() val uiState by viewModel.uiState.collectAsState() diff --git a/app/src/main/java/com/quranapp/android/compose/screens/settings/TranslationSelectionScreen.kt b/app/src/main/java/com/quranapp/android/compose/screens/settings/TranslationSelectionScreen.kt index 240484b3d..b95fa92cd 100644 --- a/app/src/main/java/com/quranapp/android/compose/screens/settings/TranslationSelectionScreen.kt +++ b/app/src/main/java/com/quranapp/android/compose/screens/settings/TranslationSelectionScreen.kt @@ -50,6 +50,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -63,7 +64,6 @@ import com.quranapp.android.components.transls.TranslationGroupModel import com.quranapp.android.compose.components.common.AppBar import com.quranapp.android.compose.components.common.ErrorMessageCard import com.quranapp.android.compose.components.common.IconButton -import com.quranapp.android.compose.navigation.LocalSettingsNavHostController import com.quranapp.android.compose.navigation.SettingRoutes import com.quranapp.android.utils.reader.TranslUtils import com.quranapp.android.utils.univ.MessageUtils @@ -315,6 +315,7 @@ private fun TranslationRow( onDelete: () -> Unit ) { val context = LocalContext.current + val resources = LocalResources.current val bookInfo = translation.bookInfo Row( @@ -385,13 +386,13 @@ private fun TranslationRow( onClick = { MessageUtils.showConfirmationDialog( context = context, - title = context.getString(R.string.strTitleTranslDelete), - msg = context.getString( + title = resources.getString(R.string.strTitleTranslDelete), + msg = resources.getString( R.string.msgDeleteTranslation, bookInfo.bookName, bookInfo.authorName ), - btn = context.getString(R.string.strLabelDelete), + btn = resources.getString(R.string.strLabelDelete), btnColor = ColorUtils.DANGER, action = { onDelete() @@ -412,7 +413,7 @@ private fun TranslationRow( @Composable private fun DownloadTranslationsButton() { - val navController = LocalSettingsNavHostController.current + val navController = LocalSettingsNavController.current Box( modifier = Modifier diff --git a/app/src/main/java/com/quranapp/android/compose/screens/tafsir/TafsirReaderScreen.kt b/app/src/main/java/com/quranapp/android/compose/screens/tafsir/TafsirReaderScreen.kt index 209a4c9d3..f6f3591f5 100644 --- a/app/src/main/java/com/quranapp/android/compose/screens/tafsir/TafsirReaderScreen.kt +++ b/app/src/main/java/com/quranapp/android/compose/screens/tafsir/TafsirReaderScreen.kt @@ -72,6 +72,7 @@ import com.quranapp.android.compose.utils.LocalAppLocale import com.quranapp.android.compose.utils.ThemeUtils import com.quranapp.android.compose.utils.preferences.ReaderPreferences import com.quranapp.android.utils.quran.QuranMeta +import com.quranapp.android.utils.quran.parser.ParserUtils.compressVerseRefsByChapter import com.quranapp.android.utils.tafsir.TafsirUtils import com.quranapp.android.utils.tafsir.TafsirWebViewClient import com.quranapp.android.utils.univ.Keys @@ -431,16 +432,10 @@ private fun buildTafsirHtml( val theme = if (isDark) "dark" else "light" val direction = if (isRtlLanguage(langCode)) "rtl" else "ltr" - val multiVerseAlert = if (verseHeaderHtml.isNotEmpty()) { - "" - } else if (verses.size > 1) { + val multiVerseAlert = if (verses.size > 1) { val alertMsg = context.getString(R.string.readingTafsirMultiVerses) - val versesSorted = verses.sortedWith( - compareBy( - { it.substringBefore(':').toIntOrNull() ?: 0 }, - { it.substringAfter(':').toIntOrNull() ?: 0 } - ) - ) + val versesSorted = compressVerseRefsByChapter(verses) + """
$alertMsg ${versesSorted.joinToString(", ")} diff --git a/app/src/main/java/com/quranapp/android/compose/utils/preferences/ReaderPreferences.kt b/app/src/main/java/com/quranapp/android/compose/utils/preferences/ReaderPreferences.kt index 2062a273f..7e5bad8f9 100644 --- a/app/src/main/java/com/quranapp/android/compose/utils/preferences/ReaderPreferences.kt +++ b/app/src/main/java/com/quranapp/android/compose/utils/preferences/ReaderPreferences.kt @@ -17,6 +17,7 @@ import com.quranapp.android.utils.reader.QuranScriptUtils import com.quranapp.android.utils.reader.QuranScriptVariant import com.quranapp.android.utils.reader.ReaderTextSizeUtils import com.quranapp.android.utils.reader.TranslUtils +import com.quranapp.android.utils.reader.factory.QuranTranslationFactory import com.quranapp.android.utils.reader.isKFQPCScript import com.quranapp.android.utils.reader.isQuranAtlasScript import com.quranapp.android.utils.reader.tafsir.TafsirManager @@ -193,6 +194,7 @@ object ReaderPreferences { val appCtx = context.applicationContext val storedScript = DataStoreManager.read(KEY_SCRIPT) val storedVariant = DataStoreManager.read(KEY_SCRIPT_VARIANT) + val storedTranslations = DataStoreManager.read(KEY_TRANSLATIONS) var working = QuranScriptUtils.validatePreferredScript(storedScript) @@ -226,7 +228,19 @@ object ReaderPreferences { else -> "" } - if (working != storedScript || targetVariant != storedVariant) { + val availableTranslationSlugs = QuranTranslationFactory(appCtx).use { factory -> + factory.getAvailableTranslationBooksInfo().keys + } + val repairedTranslations = storedTranslations.filterTo(hashSetOf()) { + it in availableTranslationSlugs + }.ifEmpty { + TranslUtils.defaultTranslationSlugs() + } + + if (working != storedScript || + targetVariant != storedVariant || + repairedTranslations != storedTranslations + ) { DataStoreManager.edit { if (working != storedScript) { this[KEY_SCRIPT.key] = working @@ -235,6 +249,10 @@ object ReaderPreferences { if (targetVariant != storedVariant) { this[KEY_SCRIPT_VARIANT.key] = targetVariant } + + if (repairedTranslations != storedTranslations) { + this[KEY_TRANSLATIONS.key] = repairedTranslations + } } } } diff --git a/app/src/main/java/com/quranapp/android/db/DatabaseProvider.kt b/app/src/main/java/com/quranapp/android/db/DatabaseProvider.kt index 0f771631c..e4411b8d2 100644 --- a/app/src/main/java/com/quranapp/android/db/DatabaseProvider.kt +++ b/app/src/main/java/com/quranapp/android/db/DatabaseProvider.kt @@ -6,6 +6,7 @@ import com.quranapp.android.db.migrations.ExternalQuranDatabaseMigrations import com.quranapp.android.db.searchindex.SearchIndexDatabase import com.quranapp.android.db.translation.QuranTranslDBHelper import com.quranapp.android.repository.QuranRepository +import com.quranapp.android.repository.TopicsRepository import com.quranapp.android.repository.UserRepository object DatabaseProvider { @@ -16,12 +17,24 @@ object DatabaseProvider { @Volatile private var userRepository: UserRepository? = null + @Volatile + private var quranDatabase: QuranDatabase? = null + @Volatile private var quranRepository: QuranRepository? = null + @Volatile + private var externalQuranDatabase: ExternalQuranDatabase? = null + @Volatile private var searchIndexDatabase: SearchIndexDatabase? = null + @Volatile + private var topicsDatabase: TopicsDatabase? = null + + @Volatile + private var topicsRepository: TopicsRepository? = null + @Volatile private var quranTranslDbHelper: QuranTranslDBHelper? = null @@ -49,28 +62,34 @@ object DatabaseProvider { } private fun getQuranDatabase(context: Context): QuranDatabase { - return Room.databaseBuilder( - context.applicationContext, - QuranDatabase::class.java, - "quranapp" - ) - .createFromAsset("db/quranapp.db") - .fallbackToDestructiveMigration(true) - .build() + return quranDatabase ?: synchronized(this) { + quranDatabase ?: Room.databaseBuilder( + context.applicationContext, + QuranDatabase::class.java, + "quranapp" + ) + .createFromAsset("db/quranapp.db") + .fallbackToDestructiveMigration(true) + .build() + .also { quranDatabase = it } + } } fun getExternalQuranDatabase(context: Context): ExternalQuranDatabase { - return Room.databaseBuilder( - context.applicationContext, - ExternalQuranDatabase::class.java, - "quranapp_external" - ) - .addMigrations( - ExternalQuranDatabaseMigrations.MIGRATION_1_2, - ExternalQuranDatabaseMigrations.MIGRATION_2_3, + return externalQuranDatabase ?: synchronized(this) { + externalQuranDatabase ?: Room.databaseBuilder( + context.applicationContext, + ExternalQuranDatabase::class.java, + "quranapp_external" ) - .fallbackToDestructiveMigration(false) - .build() + .addMigrations( + ExternalQuranDatabaseMigrations.MIGRATION_1_2, + ExternalQuranDatabaseMigrations.MIGRATION_2_3, + ) + .fallbackToDestructiveMigration(false) + .build() + .also { externalQuranDatabase = it } + } } fun getQuranRepository(context: Context): QuranRepository { @@ -95,6 +114,29 @@ object DatabaseProvider { } } + private fun getTopicsDatabase(context: Context): TopicsDatabase { + return topicsDatabase ?: synchronized(this) { + topicsDatabase ?: Room.databaseBuilder( + context.applicationContext, + TopicsDatabase::class.java, + "topics" + ) + .createFromAsset("db/topics.db") + .fallbackToDestructiveMigration(true) + .build() + .also { topicsDatabase = it } + } + } + + fun getTopicsRepository(context: Context): TopicsRepository { + return topicsRepository ?: synchronized(this) { + topicsRepository ?: TopicsRepository( + context.applicationContext, + getTopicsDatabase(context), + ).also { topicsRepository = it } + } + } + fun getQuranTranslDBHelper(context: Context): QuranTranslDBHelper { return quranTranslDbHelper ?: synchronized(this) { quranTranslDbHelper ?: QuranTranslDBHelper(context.applicationContext).also { @@ -102,4 +144,18 @@ object DatabaseProvider { } } } + + fun closeAll() { + synchronized(this) { + userDatabase?.close(); userDatabase = null + quranDatabase?.close(); quranDatabase = null + externalQuranDatabase?.close(); externalQuranDatabase = null + searchIndexDatabase?.close(); searchIndexDatabase = null + topicsDatabase?.close(); topicsDatabase = null + userRepository = null + quranRepository = null + topicsRepository = null + quranTranslDbHelper = null + } + } } diff --git a/app/src/main/java/com/quranapp/android/db/TopicsDatabase.kt b/app/src/main/java/com/quranapp/android/db/TopicsDatabase.kt new file mode 100644 index 000000000..c03568b44 --- /dev/null +++ b/app/src/main/java/com/quranapp/android/db/TopicsDatabase.kt @@ -0,0 +1,27 @@ +package com.quranapp.android.db + +import androidx.room.Database +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import com.quranapp.android.db.converters.QuranConverters +import com.quranapp.android.db.converters.TopicsDbConverters +import com.quranapp.android.db.dao.TopicsDao +import com.quranapp.android.db.entities.topics.RelationshipEntity +import com.quranapp.android.db.entities.topics.TopicAyahEntity +import com.quranapp.android.db.entities.topics.TopicEntity +import com.quranapp.android.db.entities.topics.TopicLocalizationEntity + +@Database( + entities = [ + TopicEntity::class, + TopicLocalizationEntity::class, + TopicAyahEntity::class, + RelationshipEntity::class, + ], + version = 1, + exportSchema = false +) +@TypeConverters(QuranConverters::class, TopicsDbConverters::class) +abstract class TopicsDatabase : RoomDatabase() { + abstract fun topicsDao(): TopicsDao +} diff --git a/app/src/main/java/com/quranapp/android/db/UserDatabase.kt b/app/src/main/java/com/quranapp/android/db/UserDatabase.kt index b9d865258..f013194e7 100644 --- a/app/src/main/java/com/quranapp/android/db/UserDatabase.kt +++ b/app/src/main/java/com/quranapp/android/db/UserDatabase.kt @@ -8,8 +8,8 @@ import androidx.sqlite.db.SupportSQLiteDatabase import com.quranapp.android.db.converters.DbConverters import com.quranapp.android.db.dao.BookmarkDao import com.quranapp.android.db.dao.ReadHistoryDao -import com.quranapp.android.db.entities.BookmarkEntity -import com.quranapp.android.db.entities.ReadHistoryEntity +import com.quranapp.android.db.entities.user.BookmarkEntity +import com.quranapp.android.db.entities.user.ReadHistoryEntity @Database( entities = [BookmarkEntity::class, ReadHistoryEntity::class], @@ -42,4 +42,4 @@ abstract class UserDatabase : RoomDatabase() { } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/quranapp/android/db/bookmark/UserDataMigrationManager.kt b/app/src/main/java/com/quranapp/android/db/bookmark/UserDataMigrationManager.kt index 1bd59a13c..8d42330a9 100644 --- a/app/src/main/java/com/quranapp/android/db/bookmark/UserDataMigrationManager.kt +++ b/app/src/main/java/com/quranapp/android/db/bookmark/UserDataMigrationManager.kt @@ -3,7 +3,7 @@ package com.quranapp.android.db.bookmark import android.content.Context import androidx.core.content.edit import com.quranapp.android.db.DatabaseProvider -import com.quranapp.android.db.entities.BookmarkEntity +import com.quranapp.android.db.entities.user.BookmarkEntity import com.quranapp.android.db.readHistory.ReadHistoryMigration import com.quranapp.android.utils.Log import com.quranapp.android.utils.univ.DateUtils @@ -87,4 +87,4 @@ class UserDataMigrationManager( migrateBookmarksIfNeeded() migrateReadHistoryIfNeeded() } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/quranapp/android/db/converters/QuranConverters.kt b/app/src/main/java/com/quranapp/android/db/converters/QuranConverters.kt index 05cd91678..15459d733 100644 --- a/app/src/main/java/com/quranapp/android/db/converters/QuranConverters.kt +++ b/app/src/main/java/com/quranapp/android/db/converters/QuranConverters.kt @@ -26,4 +26,4 @@ class QuranConverters { @TypeConverter fun toMushafLineType(value: String?): MushafLineType? = value?.let { MushafLineType.valueOf(it) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/quranapp/android/db/converters/TopicsDbConverters.kt b/app/src/main/java/com/quranapp/android/db/converters/TopicsDbConverters.kt new file mode 100644 index 000000000..0871e803b --- /dev/null +++ b/app/src/main/java/com/quranapp/android/db/converters/TopicsDbConverters.kt @@ -0,0 +1,24 @@ +package com.quranapp.android.db.converters + +import androidx.room.TypeConverter +import com.quranapp.android.db.entities.topics.RelationshipType +import com.quranapp.android.db.entities.topics.TopicFlags +import com.quranapp.android.db.entities.quran.MushafLineType +import com.quranapp.android.db.entities.quran.NavigationType +import com.quranapp.android.db.entities.quran.RevelationType + +class TopicsDbConverters { + @TypeConverter + fun fromRelationshipType(value: RelationshipType?): String? = value?.dbValue + + @TypeConverter + fun toRelationshipType(value: String?): RelationshipType? = + value?.let { RelationshipType.fromDbValue(it) } + + @TypeConverter + fun fromTopicFlags(value: TopicFlags?): Int? = value?.dbValue + + @TypeConverter + fun toTopicFlags(value: Int?): TopicFlags? = + value?.let { TopicFlags.fromDbValue(it) } +} diff --git a/app/src/main/java/com/quranapp/android/db/dao/BookmarkDao.kt b/app/src/main/java/com/quranapp/android/db/dao/BookmarkDao.kt index 7d0d8d5f5..d2ccd7829 100644 --- a/app/src/main/java/com/quranapp/android/db/dao/BookmarkDao.kt +++ b/app/src/main/java/com/quranapp/android/db/dao/BookmarkDao.kt @@ -6,7 +6,7 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Update -import com.quranapp.android.db.entities.BookmarkEntity +import com.quranapp.android.db.entities.user.BookmarkEntity import kotlinx.coroutines.flow.Flow @Dao @@ -113,4 +113,4 @@ interface BookmarkDao { fromVerse: Int, toVerse: Int ): Flow -} \ No newline at end of file +} diff --git a/app/src/main/java/com/quranapp/android/db/dao/ReadHistoryDao.kt b/app/src/main/java/com/quranapp/android/db/dao/ReadHistoryDao.kt index 279a396de..1cc1237a4 100644 --- a/app/src/main/java/com/quranapp/android/db/dao/ReadHistoryDao.kt +++ b/app/src/main/java/com/quranapp/android/db/dao/ReadHistoryDao.kt @@ -5,8 +5,7 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query -import com.quranapp.android.db.entities.BookmarkEntity -import com.quranapp.android.db.entities.ReadHistoryEntity +import com.quranapp.android.db.entities.user.ReadHistoryEntity import kotlinx.coroutines.flow.Flow @Dao diff --git a/app/src/main/java/com/quranapp/android/db/dao/TopicsDao.kt b/app/src/main/java/com/quranapp/android/db/dao/TopicsDao.kt new file mode 100644 index 000000000..f6d4761a7 --- /dev/null +++ b/app/src/main/java/com/quranapp/android/db/dao/TopicsDao.kt @@ -0,0 +1,432 @@ +package com.quranapp.android.db.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.RewriteQueriesToDropUnusedColumns +import com.quranapp.android.db.entities.topics.RelationshipType +import com.quranapp.android.db.relations.topics.TopicParentEdgeRow +import com.quranapp.android.db.relations.topics.TopicHierarchyEdgeRow +import com.quranapp.android.db.relations.topics.TopicRelationshipRow +import com.quranapp.android.db.relations.topics.TopicSearchCandidateRow +import com.quranapp.android.db.relations.topics.TopicSummaryRow +import com.quranapp.android.db.relations.topics.TopicVerseRow + +@Dao +@RewriteQueriesToDropUnusedColumns +interface TopicsDao { + @Query( + """ + SELECT + t.id AS topicId, + t.slug AS slug, + t.type AS type, + t.image_url AS imageUrl, + t.icon AS icon, + t.flags AS flags, + COALESCE(loc.title, en.title, ar.title, t.slug, '') AS title, + ar.title AS arabicTitle, + COALESCE(loc.short_description, en.short_description) AS shortDescription, + COALESCE(loc.description, en.description) AS description, + (SELECT COUNT(*) FROM topic_ayahs ta WHERE ta.topic_id = t.id) AS ayahCount, + ( + SELECT COUNT(*) + FROM relationships child_rel + WHERE child_rel.tgt_topic_id = t.id + AND child_rel.type = :parentType + ) AS childCount, + ( + SELECT COUNT(*) + FROM relationships related_rel + WHERE related_rel.type = 'related' + AND (related_rel.src_topic_id = t.id OR related_rel.tgt_topic_id = t.id) + ) AS relatedCount + FROM topics t + LEFT JOIN topic_localizations loc + ON loc.topic_id = t.id AND loc.lang_code = :langCode + LEFT JOIN topic_localizations en + ON en.topic_id = t.id AND en.lang_code = 'en' + LEFT JOIN topic_localizations ar + ON ar.topic_id = t.id AND ar.lang_code = 'ar' + WHERE (COALESCE(t.flags, 0) & :flagMask) != 0 + AND NOT EXISTS ( + SELECT 1 + FROM relationships parent_rel + WHERE parent_rel.src_topic_id = t.id + AND parent_rel.type = :parentType + ) + ORDER BY LOWER(COALESCE(loc.title, en.title, ar.title, t.slug, '')) + """ + ) + suspend fun getRootTopics( + flagMask: Int, + parentType: RelationshipType, + langCode: String, + ): List + + @Query( + """ + SELECT + t.id AS topicId, + t.slug AS slug, + t.type AS type, + t.image_url AS imageUrl, + t.icon AS icon, + t.flags AS flags, + COALESCE(loc.title, en.title, ar.title, t.slug, '') AS title, + ar.title AS arabicTitle, + COALESCE(loc.short_description, en.short_description) AS shortDescription, + COALESCE(loc.description, en.description) AS description, + (SELECT COUNT(*) FROM topic_ayahs ta WHERE ta.topic_id = t.id) AS ayahCount, + ( + SELECT COUNT(*) + FROM relationships child_rel + WHERE child_rel.tgt_topic_id = t.id + AND child_rel.type = :parentType + ) AS childCount, + ( + SELECT COUNT(*) + FROM relationships related_rel + WHERE related_rel.type = 'related' + AND (related_rel.src_topic_id = t.id OR related_rel.tgt_topic_id = t.id) + ) AS relatedCount + FROM topics t + LEFT JOIN topic_localizations loc + ON loc.topic_id = t.id AND loc.lang_code = :langCode + LEFT JOIN topic_localizations en + ON en.topic_id = t.id AND en.lang_code = 'en' + LEFT JOIN topic_localizations ar + ON ar.topic_id = t.id AND ar.lang_code = 'ar' + WHERE t.id = :topicId + LIMIT 1 + """ + ) + suspend fun getTopicById( + topicId: Int, + parentType: RelationshipType, + langCode: String, + ): TopicSummaryRow? + + @Query( + """ + SELECT + t.id AS topicId, + t.slug AS slug, + t.type AS type, + t.image_url AS imageUrl, + t.icon AS icon, + t.flags AS flags, + COALESCE(loc.title, en.title, ar.title, t.slug, '') AS title, + ar.title AS arabicTitle, + COALESCE(loc.short_description, en.short_description) AS shortDescription, + COALESCE(loc.description, en.description) AS description, + (SELECT COUNT(*) FROM topic_ayahs ta WHERE ta.topic_id = t.id) AS ayahCount, + ( + SELECT COUNT(*) + FROM relationships child_rel + WHERE child_rel.tgt_topic_id = t.id + AND child_rel.type = :parentType + ) AS childCount, + ( + SELECT COUNT(*) + FROM relationships related_rel + WHERE related_rel.type = 'related' + AND (related_rel.src_topic_id = t.id OR related_rel.tgt_topic_id = t.id) + ) AS relatedCount + FROM topics t + LEFT JOIN topic_localizations loc + ON loc.topic_id = t.id AND loc.lang_code = :langCode + LEFT JOIN topic_localizations en + ON en.topic_id = t.id AND en.lang_code = 'en' + LEFT JOIN topic_localizations ar + ON ar.topic_id = t.id AND ar.lang_code = 'ar' + WHERE t.slug = :slug + LIMIT 1 + """ + ) + suspend fun getTopicBySlug( + slug: String, + parentType: RelationshipType, + langCode: String, + ): TopicSummaryRow? + + @Query( + """ + SELECT + t.id AS topicId, + t.slug AS slug, + t.type AS type, + t.image_url AS imageUrl, + t.icon AS icon, + t.flags AS flags, + COALESCE(loc.title, en.title, ar.title, t.slug, '') AS title, + ar.title AS arabicTitle, + COALESCE(loc.short_description, en.short_description) AS shortDescription, + COALESCE(loc.description, en.description) AS description, + (SELECT COUNT(*) FROM topic_ayahs ta WHERE ta.topic_id = t.id) AS ayahCount, + ( + SELECT COUNT(*) + FROM relationships child_rel + WHERE child_rel.tgt_topic_id = t.id + AND child_rel.type = :parentType + ) AS childCount, + ( + SELECT COUNT(*) + FROM relationships related_rel + WHERE related_rel.type = 'related' + AND (related_rel.src_topic_id = t.id OR related_rel.tgt_topic_id = t.id) + ) AS relatedCount + FROM relationships r + INNER JOIN topics t + ON t.id = r.src_topic_id + LEFT JOIN topic_localizations loc + ON loc.topic_id = t.id AND loc.lang_code = :langCode + LEFT JOIN topic_localizations en + ON en.topic_id = t.id AND en.lang_code = 'en' + LEFT JOIN topic_localizations ar + ON ar.topic_id = t.id AND ar.lang_code = 'ar' + WHERE r.tgt_topic_id = :parentTopicId + AND r.type = :parentType + ORDER BY r.sort_order, LOWER(COALESCE(loc.title, en.title, ar.title, t.slug, '')) + """ + ) + suspend fun getChildTopics( + parentTopicId: Int, + parentType: RelationshipType, + langCode: String, + ): List + + @Query( + """ + SELECT ayah_id AS ayahId + FROM topic_ayahs + WHERE topic_id = :topicId + ORDER BY ayah_id + LIMIT :limit + """ + ) + suspend fun getTopicVerses(topicId: Int, limit: Int): List + + @Query( + """ + SELECT ayah_id AS ayahId + FROM topic_ayahs + WHERE topic_id = :topicId + ORDER BY ayah_id + """ + ) + suspend fun getAllTopicVerses(topicId: Int): List + + @Query( + """ + SELECT + r.type AS relationshipType, + t.id AS topicId, + t.slug AS slug, + t.type AS type, + t.image_url AS imageUrl, + t.icon AS icon, + t.flags AS flags, + COALESCE(loc.title, en.title, ar.title, t.slug, '') AS title, + ar.title AS arabicTitle, + COALESCE(loc.short_description, en.short_description) AS shortDescription, + COALESCE(loc.description, en.description) AS description, + (SELECT COUNT(*) FROM topic_ayahs ta WHERE ta.topic_id = t.id) AS ayahCount, + ( + SELECT COUNT(*) + FROM relationships child_rel + WHERE child_rel.tgt_topic_id = t.id + AND child_rel.type = :parentType + ) AS childCount, + ( + SELECT COUNT(*) + FROM relationships related_rel + WHERE related_rel.type = 'related' + AND (related_rel.src_topic_id = t.id OR related_rel.tgt_topic_id = t.id) + ) AS relatedCount + FROM relationships r + INNER JOIN topics t + ON t.id = CASE + WHEN r.src_topic_id = :topicId THEN r.tgt_topic_id + ELSE r.src_topic_id + END + LEFT JOIN topic_localizations loc + ON loc.topic_id = t.id AND loc.lang_code = :langCode + LEFT JOIN topic_localizations en + ON en.topic_id = t.id AND en.lang_code = 'en' + LEFT JOIN topic_localizations ar + ON ar.topic_id = t.id AND ar.lang_code = 'ar' + WHERE (r.src_topic_id = :topicId OR r.tgt_topic_id = :topicId) + AND r.type = 'related' + ORDER BY + r.sort_order, + LOWER(COALESCE(loc.title, en.title, ar.title, t.slug, '')) + LIMIT :limit + """ + ) + suspend fun getTopicRelationships( + topicId: Int, + parentType: RelationshipType, + langCode: String, + limit: Int, + ): List + + @Query("SELECT id FROM topics") + suspend fun getAllTopicIds(): List + + @Query( + """ + SELECT r.src_topic_id AS childTopicId, r.tgt_topic_id AS parentTopicId + FROM relationships r + WHERE r.type = 'parent' + """ + ) + suspend fun getAllParentEdges(): List + + @Query( + """ + SELECT + r.src_topic_id AS childTopicId, + r.tgt_topic_id AS parentTopicId, + r.type AS relationshipType + FROM relationships r + WHERE r.type IN ('parent', 'ontology_parent', 'thematic_parent') + """ + ) + suspend fun getAllHierarchyEdges(): List + + @Query( + """ + WITH RECURSIVE ontology_tree(id) AS ( + SELECT t.id + FROM topics t + WHERE (COALESCE(t.flags, 0) & 2) != 0 + AND NOT EXISTS ( + SELECT 1 + FROM relationships pr + WHERE pr.src_topic_id = t.id + AND pr.type = 'ontology_parent' + ) + UNION ALL + SELECT r.src_topic_id + FROM relationships r + INNER JOIN ontology_tree ot ON r.tgt_topic_id = ot.id + WHERE r.type = 'ontology_parent' + ) + SELECT id FROM ontology_tree + """ + ) + suspend fun getVisibleOntologyTopicIds(): List + + @Query( + """ + WITH RECURSIVE thematic_tree(id) AS ( + SELECT t.id + FROM topics t + WHERE (COALESCE(t.flags, 0) & 1) != 0 + AND NOT EXISTS ( + SELECT 1 + FROM relationships pr + WHERE pr.src_topic_id = t.id + AND pr.type = 'thematic_parent' + ) + UNION ALL + SELECT r.src_topic_id + FROM relationships r + INNER JOIN thematic_tree tt ON r.tgt_topic_id = tt.id + WHERE r.type = 'thematic_parent' + ) + SELECT id FROM thematic_tree + """ + ) + suspend fun getVisibleThematicTopicIds(): List + + @Query( + """ + SELECT + t.id AS topicId, + t.slug AS slug, + t.type AS type, + t.image_url AS imageUrl, + t.icon AS icon, + t.flags AS flags, + COALESCE(loc.title, en.title, ar.title, t.slug, '') AS title, + ar.title AS arabicTitle, + COALESCE(loc.short_description, en.short_description) AS shortDescription, + COALESCE(loc.description, en.description) AS description, + (SELECT COUNT(*) FROM topic_ayahs ta WHERE ta.topic_id = t.id) AS ayahCount, + ( + SELECT COUNT(*) + FROM relationships child_rel + WHERE child_rel.tgt_topic_id = t.id + AND child_rel.type = :childCountRelType + ) AS childCount, + ( + SELECT COUNT(*) + FROM relationships related_rel + WHERE related_rel.type = 'related' + AND (related_rel.src_topic_id = t.id OR related_rel.tgt_topic_id = t.id) + ) AS relatedCount + FROM topics t + LEFT JOIN topic_localizations loc + ON loc.topic_id = t.id AND loc.lang_code = :langCode + LEFT JOIN topic_localizations en + ON en.topic_id = t.id AND en.lang_code = 'en' + LEFT JOIN topic_localizations ar + ON ar.topic_id = t.id AND ar.lang_code = 'ar' + WHERE t.id IN (:topicIds) + """ + ) + suspend fun getTopicSummariesByIds( + topicIds: List, + childCountRelType: RelationshipType, + langCode: String, + ): List + + @Query( + """ + SELECT + t.id AS topicId, + t.slug AS slug, + t.type AS type, + t.image_url AS imageUrl, + t.icon AS icon, + t.flags AS flags, + COALESCE(loc.title, en.title, ar.title, t.slug, '') AS title, + COALESCE(loc.short_description, en.short_description) AS shortDescription, + COALESCE(loc.description, en.description) AS description, + (SELECT COUNT(*) FROM topic_ayahs ta WHERE ta.topic_id = t.id) AS ayahCount + FROM topics t + LEFT JOIN topic_localizations loc + ON loc.topic_id = t.id AND loc.lang_code = :langCode + LEFT JOIN topic_localizations en + ON en.topic_id = t.id AND en.lang_code = 'en' + LEFT JOIN topic_localizations ar + ON ar.topic_id = t.id AND ar.lang_code = 'ar' + WHERE + COALESCE(loc.title, en.title, ar.title, t.slug, '') LIKE '%' || :query || '%' + OR COALESCE(loc.short_description, en.short_description, '') LIKE '%' || :query || '%' + OR COALESCE(loc.description, en.description, '') LIKE '%' || :query || '%' + ORDER BY LOWER(COALESCE(loc.title, en.title, ar.title, t.slug, '')) + LIMIT :limit + """ + ) + suspend fun searchTopicCandidates( + query: String, + langCode: String, + limit: Int, + ): List + + @Query( + """ + SELECT + r.src_topic_id AS childTopicId, + r.tgt_topic_id AS parentTopicId + FROM relationships r + WHERE r.type IN ('ontology_parent', 'thematic_parent', 'parent') + AND r.src_topic_id IN (:topicIds) + """ + ) + suspend fun getDirectParentsByChildTopicIds( + topicIds: List, + ): List +} diff --git a/app/src/main/java/com/quranapp/android/db/entities/topics/RelationshipEntity.kt b/app/src/main/java/com/quranapp/android/db/entities/topics/RelationshipEntity.kt new file mode 100644 index 000000000..df3bd7157 --- /dev/null +++ b/app/src/main/java/com/quranapp/android/db/entities/topics/RelationshipEntity.kt @@ -0,0 +1,62 @@ +package com.quranapp.android.db.entities.topics + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity( + tableName = "relationships", + foreignKeys = [ + ForeignKey( + entity = TopicEntity::class, + parentColumns = ["id"], + childColumns = ["src_topic_id"], + onUpdate = ForeignKey.NO_ACTION, + onDelete = ForeignKey.NO_ACTION, + ), + ForeignKey( + entity = TopicEntity::class, + parentColumns = ["id"], + childColumns = ["tgt_topic_id"], + onUpdate = ForeignKey.NO_ACTION, + onDelete = ForeignKey.NO_ACTION, + ), + ], + indices = [ + Index(name = "idx_relationships_source", value = ["src_topic_id"]), + Index(name = "idx_relationships_target", value = ["tgt_topic_id"]), + Index(name = "idx_relationships_type", value = ["type"]), + Index(name = "idx_relationships_source_type", value = ["src_topic_id", "type"]), + ], +) +data class RelationshipEntity( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = "id") + val id: Int? = null, + @ColumnInfo(name = "src_topic_id") + val srcTopicId: Int, + @ColumnInfo(name = "tgt_topic_id") + val tgtTopicId: Int, + @ColumnInfo(name = "type") + val type: RelationshipType, + @ColumnInfo(name = "sort_order", defaultValue = "0") + val sortOrder: Int? = 0, + @ColumnInfo(name = "metadata_json") + val metadataJson: String?, +) + +enum class RelationshipType(val dbValue: String) { + NONE("none"), + PARENT("parent"), + RELATED("related"), + THEMATIC_PARENT("thematic_parent"), + ONTOLOGY_PARENT("ontology_parent"); + + companion object { + fun fromDbValue(value: String): RelationshipType = + entries.firstOrNull { it.dbValue == value } + ?: RelationshipType.NONE + } +} diff --git a/app/src/main/java/com/quranapp/android/db/entities/topics/TopicAyahEntity.kt b/app/src/main/java/com/quranapp/android/db/entities/topics/TopicAyahEntity.kt new file mode 100644 index 000000000..127022623 --- /dev/null +++ b/app/src/main/java/com/quranapp/android/db/entities/topics/TopicAyahEntity.kt @@ -0,0 +1,30 @@ +package com.quranapp.android.db.entities.topics + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index + +@Entity( + tableName = "topic_ayahs", + primaryKeys = ["topic_id", "ayah_id"], + foreignKeys = [ + ForeignKey( + entity = TopicEntity::class, + parentColumns = ["id"], + childColumns = ["topic_id"], + onUpdate = ForeignKey.NO_ACTION, + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [ + Index(name = "idx_topic_ayahs_ayah", value = ["ayah_id"]), + Index(name = "idx_topic_ayahs_topic", value = ["topic_id"]), + ], +) +data class TopicAyahEntity( + @ColumnInfo(name = "topic_id") + val topicId: Int, + @ColumnInfo(name = "ayah_id") + val ayahId: Int, +) diff --git a/app/src/main/java/com/quranapp/android/db/entities/topics/TopicEntity.kt b/app/src/main/java/com/quranapp/android/db/entities/topics/TopicEntity.kt new file mode 100644 index 000000000..daff8b2c3 --- /dev/null +++ b/app/src/main/java/com/quranapp/android/db/entities/topics/TopicEntity.kt @@ -0,0 +1,53 @@ +package com.quranapp.android.db.entities.topics + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity( + tableName = "topics", + indices = [ + Index(name = "idx_topics_type", value = ["type"]), + Index(value = ["slug"], unique = true), + ], +) +data class TopicEntity( + @PrimaryKey + @ColumnInfo(name = "id") + val id: Int?, + @ColumnInfo(name = "slug") + val slug: String?, + @ColumnInfo(name = "type") + val type: String, + @ColumnInfo(name = "image_url") + val imageUrl: String?, + @ColumnInfo(name = "icon") + val icon: String?, + @ColumnInfo(name = "flags", defaultValue = "0") + val flags: TopicFlags? = TopicFlags.NONE, + @ColumnInfo(name = "created_at") + val createdAt: Long?, + @ColumnInfo(name = "updated_at") + val updatedAt: Long?, +) + + +enum class TopicFlags(val dbValue: Int) { + NONE(0), + THEMATIC(1), + ONTOLOGY(2), + THEMATIC_AND_ONTOLOGY(3); + + val isThematic: Boolean + get() = (dbValue and THEMATIC.dbValue) != 0 + + val isOntology: Boolean + get() = (dbValue and ONTOLOGY.dbValue) != 0 + + companion object { + fun fromDbValue(value: Int): TopicFlags = + entries.firstOrNull { it.dbValue == value } + ?: TopicFlags.NONE + } +} diff --git a/app/src/main/java/com/quranapp/android/db/entities/topics/TopicLocalizationEntity.kt b/app/src/main/java/com/quranapp/android/db/entities/topics/TopicLocalizationEntity.kt new file mode 100644 index 000000000..44b3fe723 --- /dev/null +++ b/app/src/main/java/com/quranapp/android/db/entities/topics/TopicLocalizationEntity.kt @@ -0,0 +1,36 @@ +package com.quranapp.android.db.entities.topics + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index + +@Entity( + tableName = "topic_localizations", + primaryKeys = ["topic_id", "lang_code"], + foreignKeys = [ + ForeignKey( + entity = TopicEntity::class, + parentColumns = ["id"], + childColumns = ["topic_id"], + onUpdate = ForeignKey.NO_ACTION, + onDelete = ForeignKey.NO_ACTION, + ), + ], + indices = [ + Index(name = "idx_topic_localizations_lang", value = ["lang_code"]), + Index(name = "idx_topic_localizations_title", value = ["title"]), + ], +) +data class TopicLocalizationEntity( + @ColumnInfo(name = "topic_id") + val topicId: Int, + @ColumnInfo(name = "lang_code") + val langCode: String, + @ColumnInfo(name = "title") + val title: String, + @ColumnInfo(name = "short_description") + val shortDescription: String?, + @ColumnInfo(name = "description") + val description: String?, +) diff --git a/app/src/main/java/com/quranapp/android/db/entities/BookmarkEntity.kt b/app/src/main/java/com/quranapp/android/db/entities/user/BookmarkEntity.kt similarity index 93% rename from app/src/main/java/com/quranapp/android/db/entities/BookmarkEntity.kt rename to app/src/main/java/com/quranapp/android/db/entities/user/BookmarkEntity.kt index 621c20077..d901d5519 100644 --- a/app/src/main/java/com/quranapp/android/db/entities/BookmarkEntity.kt +++ b/app/src/main/java/com/quranapp/android/db/entities/user/BookmarkEntity.kt @@ -1,4 +1,4 @@ -package com.quranapp.android.db.entities +package com.quranapp.android.db.entities.user import androidx.compose.runtime.Immutable import androidx.room.ColumnInfo @@ -34,4 +34,4 @@ data class BookmarkKey( val chapterNo: Int, val fromVerse: Int, val toVerse: Int -) \ No newline at end of file +) diff --git a/app/src/main/java/com/quranapp/android/db/entities/ReadHistoryEntity.kt b/app/src/main/java/com/quranapp/android/db/entities/user/ReadHistoryEntity.kt similarity index 93% rename from app/src/main/java/com/quranapp/android/db/entities/ReadHistoryEntity.kt rename to app/src/main/java/com/quranapp/android/db/entities/user/ReadHistoryEntity.kt index aba738545..dce9faefb 100644 --- a/app/src/main/java/com/quranapp/android/db/entities/ReadHistoryEntity.kt +++ b/app/src/main/java/com/quranapp/android/db/entities/user/ReadHistoryEntity.kt @@ -1,8 +1,7 @@ -package com.quranapp.android.db.entities +package com.quranapp.android.db.entities.user import androidx.room.ColumnInfo import androidx.room.Entity -import androidx.room.Ignore import androidx.room.PrimaryKey @Entity(tableName = "read_history") diff --git a/app/src/main/java/com/quranapp/android/db/readHistory/ReadHistoryMigration.kt b/app/src/main/java/com/quranapp/android/db/readHistory/ReadHistoryMigration.kt index 476704445..6b9b1fe72 100644 --- a/app/src/main/java/com/quranapp/android/db/readHistory/ReadHistoryMigration.kt +++ b/app/src/main/java/com/quranapp/android/db/readHistory/ReadHistoryMigration.kt @@ -4,7 +4,7 @@ import android.content.Context import android.database.sqlite.SQLiteDatabase import com.quranapp.android.compose.components.reader.ReaderMode import com.quranapp.android.db.UserDatabase -import com.quranapp.android.db.entities.ReadHistoryEntity +import com.quranapp.android.db.entities.user.ReadHistoryEntity import com.quranapp.android.utils.Log import com.quranapp.android.utils.reader.ReadType import java.io.File diff --git a/app/src/main/java/com/quranapp/android/db/relations/topics/TopicParentEdgeRow.kt b/app/src/main/java/com/quranapp/android/db/relations/topics/TopicParentEdgeRow.kt new file mode 100644 index 000000000..01b2d3cb7 --- /dev/null +++ b/app/src/main/java/com/quranapp/android/db/relations/topics/TopicParentEdgeRow.kt @@ -0,0 +1,10 @@ +package com.quranapp.android.db.relations.topics + +/** + * A `parent` relationship: [childTopicId] is the child, [parentTopicId] is the parent + * (matches [relationships] row: src_topic_id = child, tgt_topic_id = parent). + */ +data class TopicParentEdgeRow( + val childTopicId: Int, + val parentTopicId: Int, +) diff --git a/app/src/main/java/com/quranapp/android/db/relations/topics/TopicRows.kt b/app/src/main/java/com/quranapp/android/db/relations/topics/TopicRows.kt new file mode 100644 index 000000000..169108680 --- /dev/null +++ b/app/src/main/java/com/quranapp/android/db/relations/topics/TopicRows.kt @@ -0,0 +1,58 @@ +package com.quranapp.android.db.relations.topics + +import com.quranapp.android.db.entities.topics.RelationshipType +import com.quranapp.android.db.entities.topics.TopicFlags + +data class TopicSummaryRow( + val topicId: Int, + val slug: String?, + val type: String, + val imageUrl: String?, + val icon: String?, + val flags: TopicFlags?, + val title: String, + val shortDescription: String?, + val description: String?, + val ayahCount: Int, + val childCount: Int, + val relatedCount: Int, +) + +data class TopicSearchCandidateRow( + val topicId: Int, + val slug: String?, + val type: String, + val imageUrl: String?, + val icon: String?, + val flags: TopicFlags?, + val title: String, + val shortDescription: String?, + val description: String?, + val ayahCount: Int, +) + +data class TopicVerseRow( + val ayahId: Int, +) + +data class TopicRelationshipRow( + val relationshipType: RelationshipType, + val topicId: Int, + val slug: String?, + val type: String, + val imageUrl: String?, + val icon: String?, + val flags: TopicFlags?, + val title: String, + val shortDescription: String?, + val description: String?, + val ayahCount: Int, + val childCount: Int, + val relatedCount: Int, +) + +data class TopicHierarchyEdgeRow( + val childTopicId: Int, + val parentTopicId: Int, + val relationshipType: RelationshipType, +) diff --git a/app/src/main/java/com/quranapp/android/db/search/SearchHistoryStore.kt b/app/src/main/java/com/quranapp/android/db/search/SearchHistoryStore.kt index ec1c1bbd3..106286856 100644 --- a/app/src/main/java/com/quranapp/android/db/search/SearchHistoryStore.kt +++ b/app/src/main/java/com/quranapp/android/db/search/SearchHistoryStore.kt @@ -4,6 +4,7 @@ import android.content.Context import com.quranapp.android.components.search.SearchHistoryModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import java.io.Closeable data class SearchHistoryEntry( val id: Int, @@ -11,11 +12,15 @@ data class SearchHistoryEntry( val date: String, ) -class SearchHistoryStore(context: Context) { +class SearchHistoryStore(context: Context) : Closeable { private val appContext = context.applicationContext private val helper: SearchHistoryDBHelper by lazy { SearchHistoryDBHelper(appContext) } + override fun close() { + helper.close() + } + suspend fun loadAll(): List = withContext(Dispatchers.IO) { helper.getHistories("").mapNotNull { model -> (model as? SearchHistoryModel)?.let { diff --git a/app/src/main/java/com/quranapp/android/repository/QuranRepository.kt b/app/src/main/java/com/quranapp/android/repository/QuranRepository.kt index 8bd5e8363..94ca7d6f1 100644 --- a/app/src/main/java/com/quranapp/android/repository/QuranRepository.kt +++ b/app/src/main/java/com/quranapp/android/repository/QuranRepository.kt @@ -25,6 +25,10 @@ class QuranRepository( private val database: QuranDatabase, private val extDatabase: ExternalQuranDatabase ) { + companion object { + private const val ARBITRARY_BATCH_CHUNK_SIZE = 400 + } + private val mushafDao get() = database.mushafDao() private val arabicSearchDao get() = database.arabicSearchDao() private val ayahDao get() = database.ayahDao() @@ -175,11 +179,27 @@ class QuranRepository( scriptCode: String, arabicEnabled: Boolean, ): ChapterVerseBatch? { - val distinct = verseNos.distinct() + val distinct = verseNos.asSequence() + .filter { it > 0 } + .distinct() + .sorted() + .toList() if (distinct.isEmpty()) return null + if (distinct.isContiguousRange()) { + return loadVersesBatch( + chapterNo = chapterNo, + fromVerse = distinct.first(), + toVerse = distinct.last(), + scriptCode = scriptCode, + arabicEnabled = arabicEnabled, + ) + } + val ayahIds = distinct.map { QuranMeta.getAyahId(chapterNo, it) } - val ayahs = ayahDao.getAyahsByIds(ayahIds) + val ayahs = ayahIds.chunked(ARBITRARY_BATCH_CHUNK_SIZE).flatMap { idsChunk -> + ayahDao.getAyahsByIds(idsChunk) + } if (ayahs.isEmpty()) return null @@ -189,13 +209,19 @@ class QuranRepository( val ayahByVerse = ayahs.associateBy { it.ayahNo } val verseIds = ayahs.map { it.ayahId } - val wordsFlat = if (arabicEnabled) ayahWordDao.getWordsForAyahs(verseIds, scriptCode) - else emptyList() + val wordsFlat = if (arabicEnabled) { + verseIds.chunked(ARBITRARY_BATCH_CHUNK_SIZE).flatMap { verseIdChunk -> + ayahWordDao.getWordsForAyahs(verseIdChunk, scriptCode) + } + } else emptyList() val wordsByAyahId = groupWordsByAyahIdWithLastFlags(wordsFlat) val pageByAyahId = if (verseIds.isNotEmpty()) { - mushafDao.getPagesForAyahIds(mushafId, verseIds) + verseIds.chunked(ARBITRARY_BATCH_CHUNK_SIZE) + .flatMap { verseIdChunk -> + mushafDao.getPagesForAyahIds(mushafId, verseIdChunk) + } .associate { it.ayahId to it.pageNumber } } else { emptyMap() @@ -758,6 +784,14 @@ class QuranRepository( } } +private fun List.isContiguousRange(): Boolean { + if (size <= 1) return true + for (i in 1 until size) { + if (this[i] != this[i - 1] + 1) return false + } + return true +} + private fun mergeAyahIdIntervals(intervals: List>): List> { if (intervals.isEmpty()) return emptyList() val sorted = intervals.sortedBy { it.first } diff --git a/app/src/main/java/com/quranapp/android/repository/TopicsRepository.kt b/app/src/main/java/com/quranapp/android/repository/TopicsRepository.kt new file mode 100644 index 000000000..e4e993191 --- /dev/null +++ b/app/src/main/java/com/quranapp/android/repository/TopicsRepository.kt @@ -0,0 +1,679 @@ +package com.quranapp.android.repository + +import android.content.Context +import com.quranapp.android.compose.utils.preferences.ReaderPreferences +import com.quranapp.android.db.TopicsDatabase +import com.quranapp.android.db.dao.TopicsDao +import com.quranapp.android.db.entities.topics.RelationshipType +import com.quranapp.android.db.entities.topics.TopicFlags +import com.quranapp.android.db.relations.topics.TopicRelationshipRow +import com.quranapp.android.db.relations.topics.TopicSearchCandidateRow +import com.quranapp.android.db.relations.topics.TopicSummaryRow +import com.quranapp.android.utils.quran.QuranMeta +import com.quranapp.android.utils.reader.factory.QuranTranslationFactory +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + + +data class TopicVersePreview( + val chapterNo: Int, + val verseNo: Int, + val translation: String, +) + +data class TopicSearchHit( + val topicId: Int, + val slug: String?, + val title: String, + val shortDescription: String?, + val description: String?, + val ayahCount: Int, + val pathLabel: String, + val breadcrumbIds: List, + val preferredTree: RelationshipType, + val score: Int, +) + +/** + * Topics not in ontology/thematic trees, assigned for display via parent-graph heuristics. + */ +data class SupplementalTopicAssignment( + val ontologyAssigned: Set, + val thematicAssigned: Set, + val ontologySupplementalRootIds: List, + val thematicSupplementalRootIds: List, + val parentToChildren: Map>, + val childToParents: Map>, +) + +private class IntDisjointSet { + private val parent = mutableMapOf() + + private fun ensure(x: Int) { + if (x !in parent) parent[x] = x + } + + fun find(x: Int): Int { + ensure(x) + val p = parent.getValue(x) + if (p != x) { + parent[x] = find(p) + } + return parent.getValue(x) + } + + fun union(a: Int, b: Int) { + val ra = find(a) + val rb = find(b) + if (ra != rb) parent[ra] = rb + } +} + +private data class TopicHierarchyGraph( + val parentsByChild: Map>>, +) + +class TopicsRepository( + private val context: Context, + private val database: TopicsDatabase, +) { + private val topicsDao: TopicsDao get() = database.topicsDao() + + @Volatile + private var supplementalAssignment: SupplementalTopicAssignment? = null + + @Volatile + private var hierarchyGraph: TopicHierarchyGraph? = null + + private val supplementalBuildMutex = Mutex() + private val hierarchyGraphMutex = Mutex() + + companion object { + const val SUPPLEMENTAL_ROOT_PAGE_SIZE: Int = 40 + private const val TOPIC_IDS_QUERY_CHUNK: Int = 450 + private const val TOPIC_SEARCH_CANDIDATE_LIMIT: Int = 140 + private const val TOPIC_SEARCH_MAX_PATH_DEPTH: Int = 8 + } + + private fun preferredLanguageCode(): String { + // For now, only English is supported + return "en" + } + + suspend fun getOntologyRootTopics(): List = withContext(Dispatchers.IO) { + topicsDao.getRootTopics( + flagMask = TopicFlags.ONTOLOGY.dbValue, + parentType = RelationshipType.ONTOLOGY_PARENT, + langCode = preferredLanguageCode(), + ) + } + + suspend fun getThematicRootTopics(): List = withContext(Dispatchers.IO) { + topicsDao.getRootTopics( + flagMask = TopicFlags.THEMATIC.dbValue, + parentType = RelationshipType.THEMATIC_PARENT, + langCode = preferredLanguageCode(), + ) + } + + /** + * Ensures supplemental assignment is built (used for hidden topics). + * Safe to call multiple times. + */ + suspend fun warmSupplementalAssignment(): SupplementalTopicAssignment = + withContext(Dispatchers.IO) { + getOrBuildSupplementalAssignmentLocked() + } + + suspend fun getSupplementalRootTopicsPage( + parentType: RelationshipType, + offset: Int, + limit: Int, + ): List = withContext(Dispatchers.IO) { + val plan = getOrBuildSupplementalAssignmentLocked() + + val ids = when (parentType) { + RelationshipType.ONTOLOGY_PARENT -> plan.ontologySupplementalRootIds + RelationshipType.THEMATIC_PARENT -> plan.thematicSupplementalRootIds + else -> emptyList() + } + + val slice = ids.drop(offset.coerceAtLeast(0)).take(limit.coerceAtLeast(0)) + + if (slice.isEmpty()) return@withContext emptyList() + + val rows = getTopicSummariesByIdsBatched(slice, parentType) + val byId = rows.associateBy { it.topicId } + val assigned = assignedSetFor(parentType, plan) + + slice.mapNotNull { byId[it] }.map { row -> + val cnt = + plan.parentToChildren[row.topicId]?.count { childId -> childId in assigned } ?: 0 + row.copy(childCount = cnt) + } + } + + suspend fun getTopicSummaryForExplorer( + topicId: Int, + parentType: RelationshipType, + ): TopicSummaryRow? = withContext(Dispatchers.IO) { + val summaries = getTopicSummariesForExplorer(listOf(topicId), parentType) + + summaries.firstOrNull() + } + + suspend fun getTopicSummariesForExplorer( + topicIds: List, + parentType: RelationshipType, + ): List = withContext(Dispatchers.IO) { + val distinct = topicIds.distinct() + + if (distinct.isEmpty()) return@withContext emptyList() + + val plan = getOrBuildSupplementalAssignmentLocked() + val assigned = assignedSetFor(parentType, plan) + val rows = getTopicSummariesByIdsBatched(distinct, parentType) + val rowsById = rows.associateBy { it.topicId } + + distinct.mapNotNull { rowsById[it] }.map { base -> + if (base.topicId !in assigned) { + base + } else { + val cnt = + plan.parentToChildren[base.topicId]?.count { childId -> childId in assigned } + ?: 0 + + base.copy(childCount = cnt) + } + } + } + + suspend fun getTopicSummaryById( + topicId: Int, + parentType: RelationshipType, + ): TopicSummaryRow? = withContext(Dispatchers.IO) { + val lang = preferredLanguageCode() + val base = topicsDao.getTopicById(topicId, parentType, lang) ?: return@withContext null + + val plan = getOrBuildSupplementalAssignmentLocked() + val assigned = assignedSetFor(parentType, plan) + + if (topicId !in assigned) return@withContext base + + val cnt = plan.parentToChildren[topicId]?.count { childId -> childId in assigned } ?: 0 + + base.copy(childCount = cnt) + } + + suspend fun getChildTopicsRespectingSupplemental( + topicId: Int, + parentType: RelationshipType, + ): List = withContext(Dispatchers.IO) { + val lang = preferredLanguageCode() + val primary = topicsDao.getChildTopics(topicId, parentType, lang) + + if (primary.isNotEmpty()) return@withContext primary + + val plan = getOrBuildSupplementalAssignmentLocked() + val assigned = assignedSetFor(parentType, plan) + + if (topicId !in assigned) return@withContext emptyList() + val childIds = plan.parentToChildren[topicId].orEmpty().filter { it in assigned } + + if (childIds.isEmpty()) return@withContext emptyList() + + getTopicSummariesByIdsBatched(childIds, parentType) + .map { row -> + val cnt = plan.parentToChildren[row.topicId]?.count { cid -> cid in assigned } ?: 0 + + row.copy(childCount = cnt) + } + .sortedBy { it.title.lowercase() } + } + + suspend fun getBroaderCatalogChildren( + topicId: Int, + parentType: RelationshipType, + excludeTopicIds: Set = emptySet(), + ): List = withContext(Dispatchers.IO) { + val plan = getOrBuildSupplementalAssignmentLocked() + val assigned = assignedSetFor(parentType, plan) + + if (assigned.isEmpty()) return@withContext emptyList() + + val broaderChildIds = plan.parentToChildren[topicId] + .orEmpty() + .filter { childId -> childId in assigned && childId !in excludeTopicIds } + + if (broaderChildIds.isEmpty()) return@withContext emptyList() + + getTopicSummariesByIdsBatched(broaderChildIds, parentType) + .map { row -> + val cnt = plan.parentToChildren[row.topicId] + ?.count { cid -> cid in assigned } + ?: 0 + + row.copy(childCount = cnt) + } + .sortedBy { it.title.lowercase() } + } + + suspend fun getTopicById( + topicId: Int, + tree: RelationshipType, + ): TopicSummaryRow? = withContext(Dispatchers.IO) { + topicsDao.getTopicById(topicId, tree, preferredLanguageCode()) + } + + suspend fun getTopicBySlug( + slug: String, + tree: RelationshipType, + ): TopicSummaryRow? = withContext(Dispatchers.IO) { + topicsDao.getTopicBySlug(slug, tree, preferredLanguageCode()) + } + + suspend fun getChildTopics( + topicId: Int, + tree: RelationshipType, + ): List = withContext(Dispatchers.IO) { + topicsDao.getChildTopics(topicId, tree, preferredLanguageCode()) + } + + suspend fun getTopicVerseRefs(topicId: Int, limit: Int = 8): List = + withContext(Dispatchers.IO) { + topicsDao.getTopicVerses(topicId, limit) + .toVerseRefs() + } + + suspend fun getAllTopicVerseRefs(topicId: Int): List = + withContext(Dispatchers.IO) { + topicsDao.getAllTopicVerses(topicId).toVerseRefs() + } + + suspend fun getTopicVersePreviews(topicId: Int, limit: Int = 5): List = + withContext(Dispatchers.IO) { + val rows = topicsDao.getTopicVerses(topicId, limit) + if (rows.isEmpty()) return@withContext emptyList() + + val primarySlug = ReaderPreferences.primaryTranslationSlug() + + QuranTranslationFactory(context.applicationContext).use { factory -> + rows.map { row -> + val (chapterNo, verseNo) = QuranMeta.getVerseNoFromAyahId(row.ayahId) + TopicVersePreview( + chapterNo = chapterNo, + verseNo = verseNo, + translation = factory + .getTranslationsSingleSlugVerse(primarySlug, chapterNo, verseNo) + ?.text + .orEmpty(), + ) + } + } + } + + suspend fun getTopicRelationships( + topicId: Int, + tree: RelationshipType, + limit: Int = 8, + ): List = withContext(Dispatchers.IO) { + topicsDao.getTopicRelationships( + topicId = topicId, + parentType = tree, + langCode = preferredLanguageCode(), + limit = limit, + ) + } + + suspend fun searchTopicHits( + query: String, + limit: Int = 30, + ): List = withContext(Dispatchers.IO) { + val normalizedQuery = query.trim() + if (normalizedQuery.isEmpty() || limit <= 0) return@withContext emptyList() + + val candidates = topicsDao.searchTopicCandidates( + query = normalizedQuery, + langCode = preferredLanguageCode(), + limit = TOPIC_SEARCH_CANDIDATE_LIMIT, + ) + if (candidates.isEmpty()) return@withContext emptyList() + + val graph = getOrBuildHierarchyGraphLocked() + + val trailsByTopic = candidates.associate { candidate -> + candidate.topicId to findBestAncestorTrail( + topicId = candidate.topicId, + graph = graph, + maxDepth = TOPIC_SEARCH_MAX_PATH_DEPTH, + ) + } + + val allTopicIdsNeeded = buildSet { + candidates.forEach { add(it.topicId) } + trailsByTopic.values.forEach { trail -> addAll(trail) } + } + + val topicTitles = getTopicSummariesByIdsBatched( + topicIds = allTopicIdsNeeded.toList(), + parentType = RelationshipType.ONTOLOGY_PARENT, + ).associate { it.topicId to it.title } + + candidates + .asSequence() + .map { candidate -> + val ancestorTrail = trailsByTopic[candidate.topicId].orEmpty() + val fullPathIds = ancestorTrail + candidate.topicId + val pathText = fullPathIds + .mapNotNull { topicTitles[it] } + .joinToString(" » ") + + val preferredTree = inferPreferredTree(candidate, ancestorTrail, graph) + val score = scoreTopicSearchCandidate(candidate, normalizedQuery, pathText) + + TopicSearchHit( + topicId = candidate.topicId, + slug = candidate.slug, + title = candidate.title, + shortDescription = candidate.shortDescription, + description = candidate.description, + ayahCount = candidate.ayahCount, + pathLabel = pathText.ifBlank { candidate.title }, + breadcrumbIds = ancestorTrail, + preferredTree = preferredTree, + score = score, + ) + } + .sortedWith( + compareByDescending { it.score } + .thenByDescending { it.ayahCount } + .thenBy { it.title.lowercase() } + ) + .take(limit) + .toList() + } + + private suspend fun getOrBuildHierarchyGraphLocked(): TopicHierarchyGraph { + hierarchyGraph?.let { return it } + + return hierarchyGraphMutex.withLock { + hierarchyGraph?.let { return@withLock it } + + val edges = topicsDao.getAllHierarchyEdges() + val parentsByChild = edges + .groupBy { it.childTopicId } + .mapValues { (_, rows) -> + rows + .map { row -> row.parentTopicId to row.relationshipType } + .distinctBy { it.first to it.second } + .sortedWith( + compareBy> { relationshipOrder(it.second) } + .thenBy { it.first } + ) + } + + TopicHierarchyGraph( + parentsByChild = parentsByChild, + ).also { hierarchyGraph = it } + } + } + + private fun findBestAncestorTrail( + topicId: Int, + graph: TopicHierarchyGraph, + maxDepth: Int, + ): List { + val paths = mutableListOf>>() + + fun dfs( + nodeId: Int, + depth: Int, + visited: MutableSet, + path: MutableList>, + ) { + if (depth >= maxDepth) { + paths += path.toList() + return + } + + val parents = graph.parentsByChild[nodeId].orEmpty() + .filter { (parentId, _) -> parentId !in visited } + if (parents.isEmpty()) { + paths += path.toList() + return + } + + parents.forEach { (parentId, relType) -> + visited += parentId + path += parentId to relType + dfs( + nodeId = parentId, + depth = depth + 1, + visited = visited, + path = path, + ) + path.removeAt(path.lastIndex) + visited.remove(parentId) + } + } + + dfs( + nodeId = topicId, + depth = 0, + visited = mutableSetOf(topicId), + path = mutableListOf(), + ) + + val best = paths.maxWithOrNull( + compareBy>> { relationStrength(it) } + .thenBy { it.size } + ).orEmpty() + + return best.map { it.first }.reversed() + } + + private fun inferPreferredTree( + candidate: TopicSearchCandidateRow, + ancestorTrail: List, + graph: TopicHierarchyGraph, + ): RelationshipType { + val path = ancestorTrail + candidate.topicId + val edgeTypes = path.windowed(size = 2, step = 1, partialWindows = false) + .mapNotNull { (parentId, childId) -> + graph.parentsByChild[childId] + ?.firstOrNull { (pId, _) -> pId == parentId } + ?.second + } + + return when { + edgeTypes.any { it == RelationshipType.ONTOLOGY_PARENT } -> RelationshipType.ONTOLOGY_PARENT + edgeTypes.any { it == RelationshipType.THEMATIC_PARENT } -> RelationshipType.THEMATIC_PARENT + candidate.flags?.isOntology == true -> RelationshipType.ONTOLOGY_PARENT + candidate.flags?.isThematic == true -> RelationshipType.THEMATIC_PARENT + else -> RelationshipType.ONTOLOGY_PARENT + } + } + + private fun scoreTopicSearchCandidate( + candidate: TopicSearchCandidateRow, + query: String, + pathText: String, + ): Int { + val normalizedQuery = normalizeSearchText(query) + val normalizedTitle = normalizeSearchText(candidate.title) + val normalizedShort = normalizeSearchText(candidate.shortDescription.orEmpty()) + val normalizedDescription = normalizeSearchText(candidate.description.orEmpty()) + val normalizedPath = normalizeSearchText(pathText) + + val base = when { + normalizedTitle == normalizedQuery -> 1000 + normalizedTitle.startsWith(normalizedQuery) -> 900 + Regex("\\b${Regex.escape(normalizedQuery)}\\b").containsMatchIn(normalizedTitle) -> 820 + normalizedTitle.contains(normalizedQuery) -> 760 + normalizedShort.contains(normalizedQuery) -> 540 + normalizedDescription.contains(normalizedQuery) -> 380 + else -> 120 + } + + val pathBonus = when { + normalizedPath.startsWith(normalizedQuery) -> 55 + normalizedPath.contains(normalizedQuery) -> 35 + else -> 0 + } + + val positionBonus = normalizedTitle.indexOf(normalizedQuery) + .takeIf { it >= 0 } + ?.let { 40 - it.coerceAtMost(40) } + ?: 0 + + val lengthPenalty = (normalizedTitle.length - normalizedQuery.length) + .coerceAtLeast(0) + .coerceAtMost(80) + + val verseBonus = (candidate.ayahCount * 2).coerceAtMost(50) + + return base + pathBonus + positionBonus + verseBonus - lengthPenalty + } + + private fun normalizeSearchText(value: String): String = + value + .lowercase() + .replace(Regex("[^\\p{L}\\p{N}\\s]"), " ") + .replace(Regex("\\s+"), " ") + .trim() + + private fun relationStrength(path: List>): Int = + path.sumOf { (_, rel) -> + when (rel) { + RelationshipType.ONTOLOGY_PARENT -> 6 + RelationshipType.THEMATIC_PARENT -> 5 + RelationshipType.PARENT -> 3 + RelationshipType.RELATED -> 1 + RelationshipType.NONE -> 0 + } + } + + private fun relationshipOrder(type: RelationshipType): Int = + when (type) { + RelationshipType.ONTOLOGY_PARENT -> 0 + RelationshipType.THEMATIC_PARENT -> 1 + RelationshipType.PARENT -> 2 + RelationshipType.RELATED -> 3 + RelationshipType.NONE -> 4 + } + + private fun assignedSetFor( + parentType: RelationshipType, + plan: SupplementalTopicAssignment, + ): Set = when (parentType) { + RelationshipType.ONTOLOGY_PARENT -> plan.ontologyAssigned + RelationshipType.THEMATIC_PARENT -> plan.thematicAssigned + else -> emptySet() + } + + private suspend fun getOrBuildSupplementalAssignmentLocked(): SupplementalTopicAssignment { + supplementalAssignment?.let { return it } + + return supplementalBuildMutex.withLock { + supplementalAssignment?.let { return@withLock it } + + val visibleOntology = topicsDao.getVisibleOntologyTopicIds().toSet() + val visibleThematic = topicsDao.getVisibleThematicTopicIds().toSet() + val allIds = topicsDao.getAllTopicIds().toSet() + val hidden = allIds - visibleOntology - visibleThematic + val edges = topicsDao.getAllParentEdges() + + val uf = IntDisjointSet() + + for (e in edges) { + uf.union(e.childTopicId, e.parentTopicId) + } + + for (h in hidden) { + uf.find(h) + } + + val compTouches = mutableMapOf>() + + for (tid in allIds) { + val r = uf.find(tid) + val (o, t) = compTouches[r] ?: (false to false) + compTouches[r] = (o || tid in visibleOntology) to (t || tid in visibleThematic) + } + + val ontologyAssigned = mutableSetOf() + val thematicAssigned = mutableSetOf() + + for (h in hidden) { + val r = uf.find(h) + val (touchO, touchT) = compTouches[r] ?: (false to false) + when { + touchO && !touchT -> ontologyAssigned.add(h) + touchT && !touchO -> thematicAssigned.add(h) + else -> thematicAssigned.add(h) + } + } + + val parentToChildren = mutableMapOf>() + val childToParents = mutableMapOf>() + + for (e in edges) { + parentToChildren.getOrPut(e.parentTopicId) { mutableListOf() }.add(e.childTopicId) + childToParents.getOrPut(e.childTopicId) { mutableListOf() }.add(e.parentTopicId) + } + + parentToChildren.values.forEach { it.sort() } + childToParents.values.forEach { it.sort() } + + suspend fun supplementalRootsFor(assigned: Set): List { + val roots = assigned.filter { childId -> + childToParents[childId].isNullOrEmpty() + } + + if (roots.isEmpty()) return emptyList() + + val rows = getTopicSummariesByIdsBatched(roots, RelationshipType.ONTOLOGY_PARENT) + val order = rows.associateBy { it.topicId } + + return roots + .mapNotNull { order[it] } + .sortedBy { it.title.lowercase() } + .map { it.topicId } + } + + val ontologyRoots = supplementalRootsFor(ontologyAssigned) + val thematicRoots = supplementalRootsFor(thematicAssigned) + + SupplementalTopicAssignment( + ontologyAssigned = ontologyAssigned, + thematicAssigned = thematicAssigned, + ontologySupplementalRootIds = ontologyRoots, + thematicSupplementalRootIds = thematicRoots, + parentToChildren = parentToChildren.mapValues { (_, v) -> v.toList() }, + childToParents = childToParents.mapValues { (_, v) -> v.toList() }, + ).also { supplementalAssignment = it } + } + } + + private suspend fun getTopicSummariesByIdsBatched( + topicIds: List, + parentType: RelationshipType, + ): List { + val distinct = topicIds.distinct() + + if (distinct.isEmpty()) return emptyList() + + val lang = preferredLanguageCode() + + return distinct.chunked(TOPIC_IDS_QUERY_CHUNK).flatMap { chunk -> + topicsDao.getTopicSummariesByIds(chunk, parentType, lang) + } + } + + private fun List.toVerseRefs(): List { + return map { QuranMeta.getVerseNoFromAyahId(it.ayahId) } + .map { (chapterNo, verseNo) -> "$chapterNo:$verseNo" } + } +} diff --git a/app/src/main/java/com/quranapp/android/repository/UserRepository.kt b/app/src/main/java/com/quranapp/android/repository/UserRepository.kt index 8881e85a7..c9323de5f 100644 --- a/app/src/main/java/com/quranapp/android/repository/UserRepository.kt +++ b/app/src/main/java/com/quranapp/android/repository/UserRepository.kt @@ -7,8 +7,8 @@ import androidx.paging.PagingConfig import androidx.paging.PagingData import com.quranapp.android.R import com.quranapp.android.db.UserDatabase -import com.quranapp.android.db.entities.BookmarkEntity -import com.quranapp.android.db.entities.ReadHistoryEntity +import com.quranapp.android.db.entities.user.BookmarkEntity +import com.quranapp.android.db.entities.user.ReadHistoryEntity import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.mapLatest @@ -215,4 +215,4 @@ class UserRepository( suspend fun deleteAllHistories() { readHistoryDao.deleteAll() } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/quranapp/android/search/ExclusiveVersesSearchProvider.kt b/app/src/main/java/com/quranapp/android/search/ExclusiveVersesSearchProvider.kt index cd9f1c207..ed8f9d3c5 100644 --- a/app/src/main/java/com/quranapp/android/search/ExclusiveVersesSearchProvider.kt +++ b/app/src/main/java/com/quranapp/android/search/ExclusiveVersesSearchProvider.kt @@ -6,8 +6,14 @@ import com.quranapp.android.components.quran.ExclusiveVersesDataset import com.quranapp.android.components.quran.QuranExclusiveVerses import com.quranapp.android.components.quran.QuranScienceItem import com.quranapp.android.compose.screens.science.loadScienceItems +import com.quranapp.android.db.DatabaseProvider +import com.quranapp.android.repository.TopicSearchHit sealed class CollectionSearchResult { + data class TopicsDbItem( + val hit: TopicSearchHit, + ) : CollectionSearchResult() + data class ExclusiveVerseItem( val dataset: ExclusiveVersesDataset, val verse: ExclusiveVerse, @@ -19,10 +25,22 @@ sealed class CollectionSearchResult { } object ExclusiveVersesSearchProvider { + const val TOPIC_RESULTS_LIMIT = 40 + private const val EXCLUSIVE_RESULTS_LIMIT = 40 + private const val SCIENCE_RESULTS_LIMIT = 30 + const val COMBINED_RESULTS_LIMIT = 90 + suspend fun search(context: Context, query: String): List { val normalizedQuery = query.trim() if (normalizedQuery.isEmpty()) return emptyList() + val topicsRepo = DatabaseProvider.getTopicsRepository(context) + val topicMatches = topicsRepo.searchTopicHits( + query = normalizedQuery, + limit = TOPIC_RESULTS_LIMIT, + ) + .map { hit -> CollectionSearchResult.TopicsDbItem(hit) } + val exclusiveMatches = ExclusiveVersesDataset.entries.flatMap { dataset -> QuranExclusiveVerses.get(context, dataset) .asSequence() @@ -40,7 +58,7 @@ object ExclusiveVersesSearchProvider { ) } .toList() - } + }.take(EXCLUSIVE_RESULTS_LIMIT) val scienceMatches = loadScienceItems(context) .asSequence() @@ -52,7 +70,9 @@ object ExclusiveVersesSearchProvider { CollectionSearchResult.ScienceTopicItem(topic) } .toList() + .take(SCIENCE_RESULTS_LIMIT) - return exclusiveMatches + scienceMatches + return (topicMatches + exclusiveMatches + scienceMatches) + .take(COMBINED_RESULTS_LIMIT) } } diff --git a/app/src/main/java/com/quranapp/android/utils/others/ShortcutUtils.kt b/app/src/main/java/com/quranapp/android/utils/others/ShortcutUtils.kt index 49a6c2447..6872c0a78 100644 --- a/app/src/main/java/com/quranapp/android/utils/others/ShortcutUtils.kt +++ b/app/src/main/java/com/quranapp/android/utils/others/ShortcutUtils.kt @@ -12,7 +12,7 @@ import com.quranapp.android.R import com.quranapp.android.activities.ActivityReader import com.quranapp.android.components.reader.ChapterVersePair import com.quranapp.android.compose.components.reader.ReaderMode -import com.quranapp.android.db.entities.ReadHistoryEntity +import com.quranapp.android.db.entities.user.ReadHistoryEntity import com.quranapp.android.utils.Log import com.quranapp.android.utils.app.NotificationUtils import com.quranapp.android.utils.reader.ReadType diff --git a/app/src/main/java/com/quranapp/android/utils/quran/parser/ParserUtils.kt b/app/src/main/java/com/quranapp/android/utils/quran/parser/ParserUtils.kt index a90d9c5f4..d4205a39d 100644 --- a/app/src/main/java/com/quranapp/android/utils/quran/parser/ParserUtils.kt +++ b/app/src/main/java/com/quranapp/android/utils/quran/parser/ParserUtils.kt @@ -53,6 +53,85 @@ object ParserUtils { return chapters } + /** + * Compresses verse references by chapter, collapsing contiguous verses into ranges. + * Example: 1:1, 1:2, 1:3, 2:5 -> [1:1-3, 2:5] + */ + @JvmStatic + fun compressVerseRefsByChapter(verseRefs: Collection): List { + if (verseRefs.isEmpty()) return emptyList() + + val grouped = linkedMapOf>() + + verseRefs.forEach { ref -> + val chapter = ref.substringBefore(':').trim().toIntOrNull() ?: return@forEach + val versePart = ref.substringAfter(':', "").trim() + + if (versePart.isBlank()) return@forEach + + val verses = grouped.getOrPut(chapter) { linkedSetOf() } + + versePart.split(',').forEach { token -> + val piece = token.trim() + if (piece.isBlank()) return@forEach + + val rangeParts = piece.split('-') + + when (rangeParts.size) { + 1 -> { + rangeParts[0].toIntOrNull()?.let(verses::add) + } + + 2 -> { + val from = rangeParts[0].toIntOrNull() + val to = rangeParts[1].toIntOrNull() + if (from != null && to != null) { + val start = minOf(from, to) + val end = maxOf(from, to) + for (verseNo in start..end) { + verses.add(verseNo) + } + } + } + } + } + } + + return grouped.entries.flatMap { (chapter, versesSet) -> + val sorted = versesSet.toList().sorted() + if (sorted.isEmpty()) return@flatMap emptyList() + + val collapsed = mutableListOf() + var start = sorted.first() + var prev = start + + for (i in 1 until sorted.size) { + val current = sorted[i] + + if (current == prev + 1) { + prev = current + } else { + collapsed += if (start == prev) { + "$chapter:$start" + } else { + "$chapter:$start-$prev" + } + + start = current + prev = current + } + } + + collapsed += if (start == prev) { + "$chapter:$start" + } else { + "$chapter:$start-$prev" + } + + collapsed + } + } + suspend fun prepareChapterText( ctx: Context, repository: QuranRepository, diff --git a/app/src/main/java/com/quranapp/android/utils/quran/parser/QuranProphetParser.kt b/app/src/main/java/com/quranapp/android/utils/quran/parser/QuranProphetParser.kt index 35654ebd8..18d56bd37 100644 --- a/app/src/main/java/com/quranapp/android/utils/quran/parser/QuranProphetParser.kt +++ b/app/src/main/java/com/quranapp/android/utils/quran/parser/QuranProphetParser.kt @@ -29,6 +29,7 @@ object QuranProphetParser { private const val PROPHETS_ATTR_NAME_EN = "name-en" private const val PROPHETS_ATTR_NAME = "name" private const val PROPHETS_ATTR_ICON_RES = "drawable" + private const val PROPHETS_ATTR_THUMBNAIL = "thumbnail" /** * Parsed strings and chapter labels depend on [appPlatformLocale]. Cached per locale tag. @@ -85,6 +86,9 @@ object QuranProphetParser { PROPHETS_ATTR_ICON_RES, -1 ), + thumbnail = parser.getAttributeValue(null, PROPHETS_ATTR_THUMBNAIL)?.let { + "ghraw://AlfaazPlus/QuranAppInventory/master/images/" + it + } ) lastReference = lastProphet diff --git a/app/src/main/java/com/quranapp/android/utils/quran/parser/QuranPropheticDuasParser.kt b/app/src/main/java/com/quranapp/android/utils/quran/parser/QuranPropheticDuasParser.kt index 8e72f61c2..f1d5fc4be 100644 --- a/app/src/main/java/com/quranapp/android/utils/quran/parser/QuranPropheticDuasParser.kt +++ b/app/src/main/java/com/quranapp/android/utils/quran/parser/QuranPropheticDuasParser.kt @@ -26,6 +26,7 @@ object QuranPropheticDuasParser { private const val PROPHETS_ATTR_ORDER = "order" private const val PROPHETS_ATTR_NAME = "name" private const val PROPHETS_ATTR_ICON_RES = "drawable" + private const val PROPHETS_ATTR_THUMBNAIL = "thumbnail" /** * Parsed strings and chapter labels depend on [appPlatformLocale]. Cached per locale tag. @@ -84,6 +85,11 @@ object QuranPropheticDuasParser { PROPHETS_ATTR_ICON_RES, -1 ), + thumbnail = parser.getAttributeValue(null, + PROPHETS_ATTR_THUMBNAIL + )?.let { + "ghraw://AlfaazPlus/QuranAppInventory/master/images/" + it + } ) lastReference = lastProphet diff --git a/app/src/main/java/com/quranapp/android/utils/reader/ExclusiveVerseNavigator.kt b/app/src/main/java/com/quranapp/android/utils/reader/ExclusiveVerseNavigator.kt index 5955222bb..361f532b6 100644 --- a/app/src/main/java/com/quranapp/android/utils/reader/ExclusiveVerseNavigator.kt +++ b/app/src/main/java/com/quranapp/android/utils/reader/ExclusiveVerseNavigator.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.Intent import com.quranapp.android.R import com.quranapp.android.activities.reference.ActivityPropheticDuas +import com.quranapp.android.components.ReferenceVerseModel import com.quranapp.android.components.quran.ExclusiveVerse import com.quranapp.android.components.quran.ExclusiveVersesDataset import com.quranapp.android.utils.reader.factory.ReaderFactory @@ -46,33 +47,36 @@ object ExclusiveVerseNavigator { ReaderFactory.startReferenceVerse( context, - nameTitle, - description, - emptySet(), - verse.chapters, - verse.versesRaw, + ReferenceVerseModel( + title = nameTitle, + desc = description, + chapters = verse.chapters, + verses = verse.versesRaw, + ), ) } private fun openEtiquette(context: Context, verse: ExclusiveVerse) { - verse.verses.firstOrNull()?.let { reference -> - ReaderFactory.startVerseRange( - context, - reference.first, - reference.second, - reference.third, - ) - } + ReaderFactory.startReferenceVerse( + context, + ReferenceVerseModel( + title = verse.title, + desc = verse.description, + chapters = verse.chapters, + verses = verse.versesRaw, + ), + ) } private fun openMajorSins(context: Context, verse: ExclusiveVerse) { ReaderFactory.startReferenceVerse( context, - verse.title, - verse.description, - emptySet(), - verse.chapters, - verse.versesRaw, + ReferenceVerseModel( + title = verse.title, + desc = verse.description, + chapters = verse.chapters, + verses = verse.versesRaw, + ) ) } @@ -88,11 +92,12 @@ object ExclusiveVerseNavigator { ) ReaderFactory.startReferenceVerse( context, - nameTitle, - description, - emptySet(), - verse.chapters, - verse.versesRaw, + ReferenceVerseModel( + title = nameTitle, + desc = description, + chapters = verse.chapters, + verses = verse.versesRaw, + ) ) } } diff --git a/app/src/main/java/com/quranapp/android/utils/reader/QuranScriptUtils.kt b/app/src/main/java/com/quranapp/android/utils/reader/QuranScriptUtils.kt index 3c68b8c9d..7af8586c7 100644 --- a/app/src/main/java/com/quranapp/android/utils/reader/QuranScriptUtils.kt +++ b/app/src/main/java/com/quranapp/android/utils/reader/QuranScriptUtils.kt @@ -248,12 +248,14 @@ fun String.getQuranScriptPreview(isDark: Boolean): Int = when (this) { } /** + * In KBs * Download size -> Uncompressed size */ fun String.getQuranScriptFontPackSizeMb(): Pair = when (this) { - QuranScriptUtils.SCRIPT_KFQPC_V1 -> Pair(52, 90) - QuranScriptUtils.SCRIPT_KFQPC_V2 -> Pair(129, 200) - QuranScriptUtils.SCRIPT_KFQPC_V4 -> Pair(132, 320) + QuranScriptUtils.SCRIPT_DK_INDOPAK -> Pair(1835, 1835) + QuranScriptUtils.SCRIPT_KFQPC_V1 -> Pair(52000, 90000) + QuranScriptUtils.SCRIPT_KFQPC_V2 -> Pair(129000, 200000) + QuranScriptUtils.SCRIPT_KFQPC_V4 -> Pair(132000, 320000) else -> Pair(0, 0) } diff --git a/app/src/main/java/com/quranapp/android/utils/reader/ReaderItemsBuilder.kt b/app/src/main/java/com/quranapp/android/utils/reader/ReaderItemsBuilder.kt index 1249a97ba..cae9e90eb 100644 --- a/app/src/main/java/com/quranapp/android/utils/reader/ReaderItemsBuilder.kt +++ b/app/src/main/java/com/quranapp/android/utils/reader/ReaderItemsBuilder.kt @@ -529,6 +529,13 @@ object ReaderItemsBuilder { ) } else emptyMap() + val translationsByVerseNo = loadQuickReferenceTranslationsByVerseNo( + factory = factory, + slugs = params.slugs, + chapterNo = chapterNo, + verseNos = verseNos, + ) + for ((idx, verseNo) in verseNos.withIndex()) { val ayah = batch.ayahByVerseNo[verseNo] ?: continue val words = batch.wordsByVerseNo[verseNo] ?: emptyList() @@ -538,9 +545,7 @@ object ReaderItemsBuilder { ensureQuranTextStyleForPage(pageNo) } - val translations = factory.getTranslationsVerseRange( - params.slugs, chapterNo, verseNo, verseNo - ).firstOrNull() ?: emptyList() + val translations = translationsByVerseNo[verseNo].orEmpty() val verse = VerseWithDetails( words = words, @@ -589,6 +594,49 @@ object ReaderItemsBuilder { return ReaderPreparedData(out, textStyles) } + private fun loadQuickReferenceTranslationsByVerseNo( + factory: QuranTranslationFactory, + slugs: Set, + chapterNo: Int, + verseNos: List, + ): Map> { + if (verseNos.isEmpty() || slugs.isEmpty()) return emptyMap() + + val uniqueSorted = verseNos.asSequence() + .filter { it > 0 } + .distinct() + .sorted() + .toList() + + if (uniqueSorted.isEmpty()) return emptyMap() + + val out = HashMap>(uniqueSorted.size) + var runStart = uniqueSorted.first() + var prev = runStart + + fun flushRange(start: Int, end: Int) { + val grouped = factory.getTranslationsVerseRange(slugs, chapterNo, start, end) + for ((idx, verseNo) in (start..end).withIndex()) { + out[verseNo] = grouped.getOrNull(idx).orEmpty() + } + } + + for (i in 1 until uniqueSorted.size) { + val verseNo = uniqueSorted[i] + if (verseNo == prev + 1) { + prev = verseNo + continue + } + + flushRange(runStart, prev) + runStart = verseNo + prev = verseNo + } + + flushRange(runStart, prev) + return out + } + /** * Builds several mushaf pages: batched mushaf_map + juz queries, two-phase ayah word preload. */ diff --git a/app/src/main/java/com/quranapp/android/utils/reader/factory/QuranTranslationFactory.kt b/app/src/main/java/com/quranapp/android/utils/reader/factory/QuranTranslationFactory.kt index 0bdf2c8d1..70b414818 100644 --- a/app/src/main/java/com/quranapp/android/utils/reader/factory/QuranTranslationFactory.kt +++ b/app/src/main/java/com/quranapp/android/utils/reader/factory/QuranTranslationFactory.kt @@ -16,13 +16,14 @@ import com.quranapp.android.api.models.translation.TranslationBookInfoModel import com.quranapp.android.components.quran.subcomponents.Footnote import com.quranapp.android.components.quran.subcomponents.Translation import com.quranapp.android.compose.utils.preferences.ReaderPreferences +import com.quranapp.android.db.DatabaseProvider import com.quranapp.android.db.translation.QuranTranslContract.QuranTranslEntry.COL_CHAPTER_NO import com.quranapp.android.db.translation.QuranTranslContract.QuranTranslEntry.COL_FOOTNOTES import com.quranapp.android.db.translation.QuranTranslContract.QuranTranslEntry.COL_TEXT -import com.quranapp.android.db.DatabaseProvider import com.quranapp.android.db.translation.QuranTranslContract.QuranTranslEntry.COL_VERSE_NO import com.quranapp.android.db.translation.QuranTranslDBHelper import com.quranapp.android.db.translation.QuranTranslInfoContract.QuranTranslInfoEntry +import com.quranapp.android.utils.Log import com.quranapp.android.utils.quran.QuranConstants import com.quranapp.android.utils.reader.TranslUtils import org.json.JSONArray @@ -199,8 +200,12 @@ class QuranTranslationFactory(private val context: Context) : Closeable { return getTranslationsSingleVerse(ReaderPreferences.getTranslations(), chapNo, verseNo) } - fun getTranslationsSingleSlugVerse(slug: String, chapNo: Int, verseNo: Int): Translation { - return getTranslationsSingleVerse(Collections.singleton(slug), chapNo, verseNo)[0] + fun getTranslationsSingleSlugVerse(slug: String, chapNo: Int, verseNo: Int): Translation? { + return getTranslationsSingleVerse( + Collections.singleton(slug), + chapNo, + verseNo + ).firstOrNull() } /** @@ -374,7 +379,7 @@ class QuranTranslationFactory(private val context: Context) : Closeable { ) getTranslationsFromCursor(translSlug, cursor) } catch (e: Exception) { - e.printStackTrace() + Log.saveError(e, "getTranslationsFromQuery") null } } diff --git a/app/src/main/java/com/quranapp/android/utils/reader/factory/ReaderFactory.kt b/app/src/main/java/com/quranapp/android/utils/reader/factory/ReaderFactory.kt index 1d17fe1ed..7db14fa04 100644 --- a/app/src/main/java/com/quranapp/android/utils/reader/factory/ReaderFactory.kt +++ b/app/src/main/java/com/quranapp/android/utils/reader/factory/ReaderFactory.kt @@ -5,11 +5,12 @@ import android.content.Intent import com.quranapp.android.activities.ActivityReader import com.quranapp.android.activities.ActivityTafsir import com.quranapp.android.activities.reference.ActivityReference +import com.quranapp.android.components.ReferenceThumbnail import com.quranapp.android.components.ReferenceVerseModel import com.quranapp.android.components.reader.ChapterVersePair import com.quranapp.android.compose.components.reader.ReaderMode import com.quranapp.android.compose.utils.preferences.ReaderPreferences -import com.quranapp.android.db.entities.ReadHistoryEntity +import com.quranapp.android.db.entities.user.ReadHistoryEntity import com.quranapp.android.utils.quran.QuranMeta import com.quranapp.android.utils.reader.QuranScriptVariant import com.quranapp.android.utils.reader.ReadType @@ -43,7 +44,7 @@ object ReaderFactory { fun startMushafPage(context: Context, pageNo: Int) { val mushafCode = ReaderPreferences.getQuranScript() val variant = ReaderPreferences.getQuranScriptVariant() - + context.startActivity( ReaderLaunchParams( data = ReaderIntentData.MushafPage( @@ -132,40 +133,12 @@ object ReaderFactory { return prepareVerseRangeIntent(chapterNo, range.first, range.second) } - fun startReferenceVerse( - context: Context, - title: String, - desc: String?, - translSlug: Set, - chapters: Set, - verses: Set - ) { - val intent = prepareReferenceVerseIntent( - title, desc, translSlug, chapters, verses - ) - intent.setClass(context, ActivityReference::class.java) - context.startActivity(intent) - } - fun startReferenceVerse(context: Context, referenceVerseModel: ReferenceVerseModel) { val intent = prepareReferenceVerseIntent(referenceVerseModel) intent.setClass(context, ActivityReference::class.java) context.startActivity(intent) } - fun prepareReferenceVerseIntent( - title: String, - desc: String?, - translSlug: Set, - chapters: Set, - verses: Set - ): Intent { - val referenceVerseModel = ReferenceVerseModel( - title, desc, translSlug, chapters, verses - ) - return prepareReferenceVerseIntent(referenceVerseModel) - } - fun prepareReferenceVerseIntent(referenceVerseModel: ReferenceVerseModel): Intent { return Intent().apply { putExtras(referenceVerseModel.toBundle()) diff --git a/app/src/main/java/com/quranapp/android/utils/verse/VerseUtils.kt b/app/src/main/java/com/quranapp/android/utils/verse/VerseUtils.kt index ab7e2128c..61b749ea1 100644 --- a/app/src/main/java/com/quranapp/android/utils/verse/VerseUtils.kt +++ b/app/src/main/java/com/quranapp/android/utils/verse/VerseUtils.kt @@ -1,13 +1,11 @@ package com.quranapp.android.utils.verse import android.content.Context -import com.quranapp.android.compose.utils.preferences.ReaderPreferences import com.quranapp.android.compose.utils.preferences.VersePreferences import com.quranapp.android.db.relations.VerseWithDetails import com.quranapp.android.repository.QuranRepository import com.quranapp.android.utils.others.ShortcutUtils import com.quranapp.android.utils.quran.QuranMeta -import com.quranapp.android.utils.reader.TranslUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlin.random.Random @@ -115,11 +113,4 @@ object VerseUtils { return chapterNo == votdChapNo && verseNo == votdVerseNo } - - fun obtainOptimalSlugForVotd(): String { - val savedTranslations = ReaderPreferences.getTranslations() - - return savedTranslations.firstOrNull { !TranslUtils.isTransliteration(it) } - ?: TranslUtils.TRANSL_SLUG_DEFAULT - } } diff --git a/app/src/main/java/com/quranapp/android/utils/workers/RecommendedReminderWorker.kt b/app/src/main/java/com/quranapp/android/utils/workers/RecommendedReminderWorker.kt index a3e5e42ec..247c7bbe3 100644 --- a/app/src/main/java/com/quranapp/android/utils/workers/RecommendedReminderWorker.kt +++ b/app/src/main/java/com/quranapp/android/utils/workers/RecommendedReminderWorker.kt @@ -10,7 +10,7 @@ import androidx.work.WorkerParameters import com.quranapp.android.R import com.quranapp.android.activities.ActivityReader import com.quranapp.android.activities.reference.ActivityReference -import com.quranapp.android.compose.utils.appFallbackLanguageCodes +import com.quranapp.android.components.ReferenceVerseModel import com.quranapp.android.compose.utils.preferences.VersePreferences import com.quranapp.android.utils.app.NotificationUtils import com.quranapp.android.utils.reader.ReaderIntentData @@ -119,11 +119,13 @@ class RecommendedReminderWorker( val desc = recommendation.description.takeIf { it.isNotBlank() } ReaderFactory.prepareReferenceVerseIntent( - recommendation.title, - desc, - emptySet(), - chapters, - verseSpecs, + ReferenceVerseModel( + recommendation.title, + desc, + emptySet(), + chapters, + verseSpecs, + ) ).apply { setClass(context, ActivityReference::class.java) } diff --git a/app/src/main/java/com/quranapp/android/viewModels/BookmarksViewModel.kt b/app/src/main/java/com/quranapp/android/viewModels/BookmarksViewModel.kt index 3619df99a..9538892ad 100644 --- a/app/src/main/java/com/quranapp/android/viewModels/BookmarksViewModel.kt +++ b/app/src/main/java/com/quranapp/android/viewModels/BookmarksViewModel.kt @@ -4,7 +4,7 @@ import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.quranapp.android.db.DatabaseProvider -import com.quranapp.android.db.entities.BookmarkEntity +import com.quranapp.android.db.entities.user.BookmarkEntity import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow diff --git a/app/src/main/java/com/quranapp/android/viewModels/ChapterNavigatorViewModel.kt b/app/src/main/java/com/quranapp/android/viewModels/ChapterNavigatorViewModel.kt index 6f8d2681e..15cac5e99 100644 --- a/app/src/main/java/com/quranapp/android/viewModels/ChapterNavigatorViewModel.kt +++ b/app/src/main/java/com/quranapp/android/viewModels/ChapterNavigatorViewModel.kt @@ -7,8 +7,9 @@ import com.quranapp.android.db.DatabaseProvider import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.stateIn -class ChapterNavigatorViewModel(application: Application) : AndroidViewModel(application) { - val repository = DatabaseProvider.getQuranRepository(application) +class ChapterNavigatorViewModel(private val application: Application) : + AndroidViewModel(application) { + val repository get() = DatabaseProvider.getQuranRepository(application) val surahs = repository.getAllSurahs() .stateIn( @@ -16,4 +17,4 @@ class ChapterNavigatorViewModel(application: Application) : AndroidViewModel(app started = SharingStarted.WhileSubscribed(5000), initialValue = emptyList() ) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/quranapp/android/viewModels/QuranSearchViewModel.kt b/app/src/main/java/com/quranapp/android/viewModels/QuranSearchViewModel.kt index ca0d22bfb..ffc42b56b 100644 --- a/app/src/main/java/com/quranapp/android/viewModels/QuranSearchViewModel.kt +++ b/app/src/main/java/com/quranapp/android/viewModels/QuranSearchViewModel.kt @@ -41,8 +41,8 @@ import kotlinx.coroutines.withContext @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) -class QuranSearchViewModel(application: Application) : AndroidViewModel(application) { - private val repository = DatabaseProvider.getQuranRepository(application) +class QuranSearchViewModel(private val application: Application) : AndroidViewModel(application) { + private val repository get() = DatabaseProvider.getQuranRepository(application) private val searchHistoryStore = SearchHistoryStore(application) private val _searchQuery = MutableStateFlow("") @@ -60,6 +60,11 @@ class QuranSearchViewModel(application: Application) : AndroidViewModel(applicat private val _availableTranslations = MutableStateFlow>(emptyList()) val availableTranslations: StateFlow> = _availableTranslations + override fun onCleared() { + searchHistoryStore.close() + super.onCleared() + } + init { loadAvailableTranslations() } @@ -91,7 +96,9 @@ class QuranSearchViewModel(application: Application) : AndroidViewModel(applicat val topicResults: StateFlow> = debouncedQuery .mapLatest { query -> - ExclusiveVersesSearchProvider.search(getApplication(), query) + withContext(Dispatchers.IO) { + ExclusiveVersesSearchProvider.search(getApplication(), query) + } } .stateIn( viewModelScope, @@ -207,15 +214,19 @@ class QuranSearchViewModel(application: Application) : AndroidViewModel(applicat quickLinks: List, ): List { val q = query.trim() + if (q.isEmpty() || quickLinks.isNotEmpty()) return emptyList() + val ql = q.lowercase() val filtered = _searchHistory.value .asSequence() .filter { it.text.lowercase() != ql } .filter { it.text.contains(q, ignoreCase = true) } .toList() + val prefix = filtered.filter { it.text.startsWith(q, ignoreCase = true) } val rest = filtered.filter { !it.text.startsWith(q, ignoreCase = true) } + return (prefix + rest).distinctBy { it.id }.take(5) } } diff --git a/app/src/main/java/com/quranapp/android/viewModels/QuranicTopicsViewModel.kt b/app/src/main/java/com/quranapp/android/viewModels/QuranicTopicsViewModel.kt new file mode 100644 index 000000000..bf02ad3c5 --- /dev/null +++ b/app/src/main/java/com/quranapp/android/viewModels/QuranicTopicsViewModel.kt @@ -0,0 +1,509 @@ +package com.quranapp.android.viewModels + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.quranapp.android.db.DatabaseProvider +import com.quranapp.android.db.entities.topics.RelationshipType +import com.quranapp.android.db.relations.topics.TopicRelationshipRow +import com.quranapp.android.db.relations.topics.TopicSummaryRow +import com.quranapp.android.repository.TopicVersePreview +import com.quranapp.android.repository.TopicSearchHit +import com.quranapp.android.repository.TopicsRepository +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +private const val MAX_TOPIC_DETAIL_CACHE_ENTRIES = 64 + +enum class TopicsTree( + val routeName: String, + val parentType: RelationshipType, +) { + Ontology("ontology", RelationshipType.ONTOLOGY_PARENT), + Thematic("thematic", RelationshipType.THEMATIC_PARENT); + + companion object { + fun fromRouteName(value: String?): TopicsTree = + entries.firstOrNull { it.routeName == value } ?: Ontology + } +} + +data class TopicNode( + val id: Int, + val slug: String, + val title: String, + val type: String, + val imageUrl: String?, + val icon: String?, + val shortDescription: String?, + val description: String?, + val verseCount: Int, + val childCount: Int, + val relatedCount: Int, +) { + val isLeaf: Boolean get() = childCount == 0 +} + +data class TopicRelationship( + val type: RelationshipType, + val topic: TopicNode, +) + +data class TopicsUiState( + val isLoadingRoots: Boolean = true, + val ontologyRoots: List = emptyList(), + val thematicRoots: List = emptyList(), + + val ontologyPrimaryRootCount: Int = 0, + val ontologySupplementalTotal: Int = 0, + val ontologySupplementalLoaded: Int = 0, + val hasMoreOntologySupplemental: Boolean = false, + val isLoadingMoreOntologySupplemental: Boolean = false, + + val thematicPrimaryRootCount: Int = 0, + val thematicSupplementalTotal: Int = 0, + val thematicSupplementalLoaded: Int = 0, + val hasMoreThematicSupplemental: Boolean = false, + val isLoadingMoreThematicSupplemental: Boolean = false, + + val topicDetails: Map = emptyMap(), + val error: String? = null, +) + +data class TopicDetailUiState( + val isLoading: Boolean = false, + val topic: TopicNode? = null, + val childTopics: List = emptyList(), + val broaderCatalogChildren: List = emptyList(), + val verseRefs: List = emptyList(), + val versePreviews: List = emptyList(), + val breadcrumbs: List = emptyList(), + val relationships: List = emptyList(), + val error: String? = null, +) + +private data class TopicLoadResult( + val topic: TopicNode?, + val children: List, + val broaderCatalogChildren: List, + val verseRefs: List, + val versePreviews: List, + val breadcrumbs: List, + val relationships: List, +) + +private data class RootsLoadResult( + val ontologyPrimary: List, + val ontologySupplementalTotal: Int, + val ontologySupplementalFirstPage: List, + + val thematicPrimary: List, + val thematicSupplementalTotal: Int, + val thematicSupplementalFirstPage: List, +) { + val ontologySupplementalLoaded: Int get() = ontologySupplementalFirstPage.size + val thematicSupplementalLoaded: Int get() = thematicSupplementalFirstPage.size +} + +class QuranicTopicsViewModel(application: Application) : AndroidViewModel(application) { + private val repository = DatabaseProvider.getTopicsRepository(application) + private val inFlightTopicLoads = mutableSetOf() + + private val _uiState = MutableStateFlow(TopicsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadRoots() + } + + fun loadRoots() { + viewModelScope.launch { + _uiState.update { it.copy(isLoadingRoots = true, error = null) } + + runCatching { + val assign = repository.warmSupplementalAssignment() + + val ontologyPrimary = repository.getOntologyRootTopics().toTopicNodes() + val thematicPrimary = repository.getThematicRootTopics().toTopicNodes() + + val pageSize = TopicsRepository.SUPPLEMENTAL_ROOT_PAGE_SIZE + + val ontologyExtra = repository + .getSupplementalRootTopicsPage( + TopicsTree.Ontology.parentType, + offset = 0, + limit = pageSize, + ) + .toTopicNodes() + + val thematicExtra = repository + .getSupplementalRootTopicsPage( + TopicsTree.Thematic.parentType, + offset = 0, + limit = pageSize, + ) + .toTopicNodes() + + RootsLoadResult( + ontologyPrimary = ontologyPrimary, + thematicPrimary = thematicPrimary, + ontologySupplementalTotal = assign.ontologySupplementalRootIds.size, + thematicSupplementalTotal = assign.thematicSupplementalRootIds.size, + ontologySupplementalFirstPage = ontologyExtra, + thematicSupplementalFirstPage = thematicExtra, + ) + }.onSuccess { result -> + _uiState.update { + it.copy( + isLoadingRoots = false, + ontologyPrimaryRootCount = result.ontologyPrimary.size, + thematicPrimaryRootCount = result.thematicPrimary.size, + ontologySupplementalTotal = result.ontologySupplementalTotal, + thematicSupplementalTotal = result.thematicSupplementalTotal, + ontologySupplementalLoaded = result.ontologySupplementalFirstPage.size, + thematicSupplementalLoaded = result.thematicSupplementalFirstPage.size, + hasMoreOntologySupplemental = result.ontologySupplementalLoaded < result.ontologySupplementalTotal, + hasMoreThematicSupplemental = result.thematicSupplementalLoaded < result.thematicSupplementalTotal, + ontologyRoots = result.ontologyPrimary + result.ontologySupplementalFirstPage, + thematicRoots = result.thematicPrimary + result.thematicSupplementalFirstPage, + ) + } + }.onFailure { throwable -> + _uiState.update { + it.copy( + isLoadingRoots = false, + error = throwable.localizedMessage ?: throwable.javaClass.simpleName, + ) + } + } + } + } + + fun loadMoreSupplementalRoots(tree: TopicsTree) { + viewModelScope.launch { + val snap = _uiState.value + when (tree) { + TopicsTree.Ontology -> { + if (!snap.hasMoreOntologySupplemental || snap.isLoadingMoreOntologySupplemental) return@launch + + _uiState.update { it.copy(isLoadingMoreOntologySupplemental = true) } + + runCatching { + val pageSize = TopicsRepository.SUPPLEMENTAL_ROOT_PAGE_SIZE + val offset = snap.ontologySupplementalLoaded + + repository.getSupplementalRootTopicsPage( + TopicsTree.Ontology.parentType, + offset = offset, + limit = pageSize, + ).toTopicNodes() + }.onSuccess { more -> + _uiState.update { + val newLoaded = it.ontologySupplementalLoaded + more.size + + it.copy( + ontologyRoots = it.ontologyRoots + more, + ontologySupplementalLoaded = newLoaded, + hasMoreOntologySupplemental = newLoaded < it.ontologySupplementalTotal, + isLoadingMoreOntologySupplemental = false, + ) + } + }.onFailure { + _uiState.update { it.copy(isLoadingMoreOntologySupplemental = false) } + } + } + + TopicsTree.Thematic -> { + if (!snap.hasMoreThematicSupplemental || snap.isLoadingMoreThematicSupplemental) return@launch + + _uiState.update { it.copy(isLoadingMoreThematicSupplemental = true) } + + runCatching { + val pageSize = TopicsRepository.SUPPLEMENTAL_ROOT_PAGE_SIZE + val offset = snap.thematicSupplementalLoaded + + repository.getSupplementalRootTopicsPage( + TopicsTree.Thematic.parentType, + offset = offset, + limit = pageSize, + ).toTopicNodes() + }.onSuccess { more -> + _uiState.update { + val newLoaded = it.thematicSupplementalLoaded + more.size + + it.copy( + thematicRoots = it.thematicRoots + more, + thematicSupplementalLoaded = newLoaded, + hasMoreThematicSupplemental = newLoaded < it.thematicSupplementalTotal, + isLoadingMoreThematicSupplemental = false, + ) + } + }.onFailure { + _uiState.update { it.copy(isLoadingMoreThematicSupplemental = false) } + } + } + } + } + } + + fun loadTopic(topicId: Int, tree: TopicsTree, breadcrumbIds: List = emptyList()) { + val detailKey = buildTopicDetailKey(tree, topicId, breadcrumbIds) + + val cached = _uiState.value.topicDetails[detailKey] + + if (cached?.topic != null) return + + val shouldLoad = synchronized(inFlightTopicLoads) { + inFlightTopicLoads.add(detailKey) + } + + if (!shouldLoad) return + + viewModelScope.launch { + _uiState.update { + val currentDetails = it.topicDetails[detailKey] ?: TopicDetailUiState() + + val nextDetails = currentDetails.copy( + isLoading = true, + error = null, + ) + + it.copy( + topicDetails = putDetailWithCap( + current = it.topicDetails, + key = detailKey, + value = nextDetails, + ) + ) + } + + runCatching { + repository.warmSupplementalAssignment() + + val topic = repository.getTopicSummaryForExplorer(topicId, tree.parentType) + ?.toTopicNode() + + if (topic == null) { + TopicLoadResult( + topic = null, + children = emptyList(), + broaderCatalogChildren = emptyList(), + verseRefs = emptyList(), + versePreviews = emptyList(), + breadcrumbs = emptyList(), + relationships = emptyList(), + ) + } else { + coroutineScope { + val normalizedBreadcrumbIds = breadcrumbIds + .distinct() + .filter { it != topicId } + + val childrenDeferred = async { + repository.getChildTopicsRespectingSupplemental( + topic.id, + tree.parentType + ).toTopicNodes() + } + + val breadcrumbsDeferred = async { + repository.getTopicSummariesForExplorer( + topicIds = normalizedBreadcrumbIds, + parentType = tree.parentType, + ).toTopicNodes() + } + + val verseRefsDeferred = async { + repository.getAllTopicVerseRefs(topic.id) + } + + val versePreviewsDeferred = async { + repository.getTopicVersePreviews(topic.id) + } + + val relationshipsDeferred = async { + repository.getTopicRelationships(topic.id, tree.parentType) + .toTopicRelationships() + } + + val children = childrenDeferred.await() + val breadcrumbs = breadcrumbsDeferred.await() + + val broaderCatalogChildren = repository.getBroaderCatalogChildren( + topicId = topic.id, + parentType = tree.parentType, + excludeTopicIds = buildSet { + add(topic.id) + addAll(children.map { child -> child.id }) + addAll(breadcrumbs.map { breadcrumb -> breadcrumb.id }) + }, + ).toTopicNodes() + + val relationships = relationshipsDeferred.await() + .distinctBy { it.topic.id } + .filterNot { relationship -> + relationship.topic.id == topic.id || + children.any { child -> child.id == relationship.topic.id } || + broaderCatalogChildren.any { child -> child.id == relationship.topic.id } || + breadcrumbs.any { breadcrumb -> breadcrumb.id == relationship.topic.id } + } + + TopicLoadResult( + topic = topic, + children = children, + broaderCatalogChildren = broaderCatalogChildren, + verseRefs = verseRefsDeferred.await(), + versePreviews = versePreviewsDeferred.await(), + breadcrumbs = breadcrumbs, + relationships = relationships, + ) + } + } + }.onSuccess { result -> + _uiState.update { + val nextDetails = TopicDetailUiState( + isLoading = false, + topic = result.topic, + childTopics = result.children, + broaderCatalogChildren = result.broaderCatalogChildren, + verseRefs = result.verseRefs, + versePreviews = result.versePreviews, + breadcrumbs = result.breadcrumbs, + relationships = result.relationships, + ) + + it.copy( + topicDetails = putDetailWithCap( + current = it.topicDetails, + key = detailKey, + value = nextDetails, + ), + ) + } + }.onFailure { throwable -> + _uiState.update { + val currentDetails = it.topicDetails[detailKey] ?: TopicDetailUiState() + + val nextDetails = currentDetails.copy( + isLoading = false, + error = throwable.localizedMessage ?: throwable.javaClass.simpleName, + ) + + it.copy( + topicDetails = putDetailWithCap( + current = it.topicDetails, + key = detailKey, + value = nextDetails, + ), + ) + } + }.also { + synchronized(inFlightTopicLoads) { + inFlightTopicLoads.remove(detailKey) + } + } + } + } + + suspend fun searchTopicsForTree( + query: String, + tree: TopicsTree, + limit: Int = 60, + ): List { + val normalized = query.trim() + if (normalized.isEmpty()) return emptyList() + + val preferred = when (tree) { + TopicsTree.Ontology -> RelationshipType.ONTOLOGY_PARENT + TopicsTree.Thematic -> RelationshipType.THEMATIC_PARENT + } + + val hits = repository.searchTopicHits( + query = normalized, + limit = limit, + ) + + val matchingTree = hits.filter { it.preferredTree == preferred } + + return if (matchingTree.isNotEmpty()) { + matchingTree + } else { + hits + } + } +} + +private fun List.toTopicNodes(): List = + map { it.toTopicNode() } + +private fun TopicSummaryRow.toTopicNode(): TopicNode = + TopicNode( + id = topicId, + slug = slug.orEmpty(), + title = title, + type = type, + imageUrl = imageUrl, + icon = icon, + shortDescription = shortDescription, + description = description, + verseCount = ayahCount, + childCount = childCount, + relatedCount = relatedCount, + ) + +private fun List.toTopicRelationships(): List = + map { + TopicRelationship( + type = it.relationshipType, + topic = TopicNode( + id = it.topicId, + slug = it.slug.orEmpty(), + title = it.title, + type = it.type, + imageUrl = it.imageUrl, + icon = it.icon, + shortDescription = it.shortDescription, + description = it.description, + verseCount = it.ayahCount, + childCount = it.childCount, + relatedCount = it.relatedCount, + ), + ) + } + +private fun putDetailWithCap( + current: Map, + key: String, + value: TopicDetailUiState, +): Map { + val mutable = LinkedHashMap(current) + + mutable.remove(key) + mutable[key] = value + + while (mutable.size > MAX_TOPIC_DETAIL_CACHE_ENTRIES) { + val oldestKey = mutable.keys.firstOrNull() ?: break + + mutable.remove(oldestKey) + } + + return mutable +} + +fun buildTopicDetailKey( + tree: TopicsTree, + topicId: Int, + breadcrumbIds: List, +): String { + val normalizedTrail = breadcrumbIds + .distinct() + .filter { it != topicId } + .joinToString(",") + + return "${tree.routeName}|$topicId|$normalizedTrail" +} diff --git a/app/src/main/java/com/quranapp/android/viewModels/ReadHistoryViewModel.kt b/app/src/main/java/com/quranapp/android/viewModels/ReadHistoryViewModel.kt index f71b7684e..6d2b21ac9 100644 --- a/app/src/main/java/com/quranapp/android/viewModels/ReadHistoryViewModel.kt +++ b/app/src/main/java/com/quranapp/android/viewModels/ReadHistoryViewModel.kt @@ -13,9 +13,9 @@ import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.stateIn @OptIn(ExperimentalCoroutinesApi::class) -class ReadHistoryViewModel(application: Application) : AndroidViewModel(application) { - private val userRepository = DatabaseProvider.getUserRepository(application) - private val quranRepository = DatabaseProvider.getQuranRepository(application) +class ReadHistoryViewModel(private val application: Application) : AndroidViewModel(application) { + private val userRepository get() = DatabaseProvider.getUserRepository(application) + private val quranRepository get() = DatabaseProvider.getQuranRepository(application) val chapterNames = appLocaleFlow.mapLatest { quranRepository.getChapterNames(QuranMeta.chapterRange.toList()) diff --git a/app/src/main/java/com/quranapp/android/viewModels/ReaderIndexViewModel.kt b/app/src/main/java/com/quranapp/android/viewModels/ReaderIndexViewModel.kt index 4724bb459..0a2d45315 100644 --- a/app/src/main/java/com/quranapp/android/viewModels/ReaderIndexViewModel.kt +++ b/app/src/main/java/com/quranapp/android/viewModels/ReaderIndexViewModel.kt @@ -28,8 +28,8 @@ import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json -class ReaderIndexViewModel(application: Application) : AndroidViewModel(application) { - val repository = DatabaseProvider.getQuranRepository(application) +class ReaderIndexViewModel(private val application: Application) : AndroidViewModel(application) { + val repository get() = DatabaseProvider.getQuranRepository(application) private val filtersJson = Json { ignoreUnknownKeys = true @@ -169,4 +169,4 @@ class ReaderIndexViewModel(application: Application) : AndroidViewModel(applicat } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/quranapp/android/viewModels/ReaderProviderViewModel.kt b/app/src/main/java/com/quranapp/android/viewModels/ReaderProviderViewModel.kt index c3eb5335e..b44cd81f1 100644 --- a/app/src/main/java/com/quranapp/android/viewModels/ReaderProviderViewModel.kt +++ b/app/src/main/java/com/quranapp/android/viewModels/ReaderProviderViewModel.kt @@ -7,10 +7,10 @@ import com.quranapp.android.utils.mediaplayer.RecitationController import com.quranapp.android.utils.reader.FontResolver -open class ReaderProviderViewModel(application: Application) : AndroidViewModel(application) { +open class ReaderProviderViewModel(private val application: Application) : AndroidViewModel(application) { val controller = RecitationController.getInstance(application) - val userRepository = DatabaseProvider.getUserRepository(application) - val repository = DatabaseProvider.getQuranRepository(application) + val userRepository get() = DatabaseProvider.getUserRepository(application) + val repository get() = DatabaseProvider.getQuranRepository(application) val fontResolver = FontResolver.getInstance(application) - val externalQuranDb = DatabaseProvider.getExternalQuranDatabase(application) + val externalQuranDb get() = DatabaseProvider.getExternalQuranDatabase(application) } diff --git a/app/src/main/java/com/quranapp/android/viewModels/ReaderViewModel.kt b/app/src/main/java/com/quranapp/android/viewModels/ReaderViewModel.kt index 0f04bc2c5..79f4eae96 100644 --- a/app/src/main/java/com/quranapp/android/viewModels/ReaderViewModel.kt +++ b/app/src/main/java/com/quranapp/android/viewModels/ReaderViewModel.kt @@ -16,7 +16,7 @@ import com.quranapp.android.compose.components.reader.ReaderPreparedData import com.quranapp.android.compose.components.reader.TranslationPageItem import com.quranapp.android.compose.components.reader.TranslationPageSection import com.quranapp.android.compose.utils.preferences.ReaderPreferences -import com.quranapp.android.db.entities.ReadHistoryEntity +import com.quranapp.android.db.entities.user.ReadHistoryEntity import com.quranapp.android.utils.Log import com.quranapp.android.utils.others.ShortcutUtils import com.quranapp.android.utils.quran.QuranMeta diff --git a/app/src/main/java/com/quranapp/android/viewModels/RecitationPlayerViewModel.kt b/app/src/main/java/com/quranapp/android/viewModels/RecitationPlayerViewModel.kt index 7d0db8fb3..e6db42d8e 100644 --- a/app/src/main/java/com/quranapp/android/viewModels/RecitationPlayerViewModel.kt +++ b/app/src/main/java/com/quranapp/android/viewModels/RecitationPlayerViewModel.kt @@ -11,9 +11,10 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn -class RecitationPlayerViewModel(application: Application) : AndroidViewModel(application) { +class RecitationPlayerViewModel(private val application: Application) : + AndroidViewModel(application) { val controller = RecitationController.getInstance(application) - val repository = DatabaseProvider.getQuranRepository(application) + val repository get() = DatabaseProvider.getQuranRepository(application) init { controller.connect() diff --git a/app/src/main/java/com/quranapp/android/viewModels/TafsirReaderViewModel.kt b/app/src/main/java/com/quranapp/android/viewModels/TafsirReaderViewModel.kt index 738f55647..89ae41a9f 100644 --- a/app/src/main/java/com/quranapp/android/viewModels/TafsirReaderViewModel.kt +++ b/app/src/main/java/com/quranapp/android/viewModels/TafsirReaderViewModel.kt @@ -72,7 +72,7 @@ class TafsirReaderViewModel(application: Application) : AndroidViewModel(applica val uiState: StateFlow = _uiState.asStateFlow() private val context get() = getApplication() - val repository = DatabaseProvider.getQuranRepository(context) + val repository get() = DatabaseProvider.getQuranRepository(context) private var contentLoadJob: Job? = null diff --git a/app/src/main/java/com/quranapp/android/viewModels/TranslationViewModel.kt b/app/src/main/java/com/quranapp/android/viewModels/TranslationViewModel.kt index faedf139f..75b93191e 100644 --- a/app/src/main/java/com/quranapp/android/viewModels/TranslationViewModel.kt +++ b/app/src/main/java/com/quranapp/android/viewModels/TranslationViewModel.kt @@ -9,8 +9,8 @@ import com.quranapp.android.components.transls.TranslModel import com.quranapp.android.components.transls.TranslationGroupModel import com.quranapp.android.compose.utils.DataLoadError import com.quranapp.android.compose.utils.preferences.ReaderPreferences -import com.quranapp.android.utils.reader.TranslUtils import com.quranapp.android.search.SearchIndexScheduler +import com.quranapp.android.utils.reader.TranslUtils import com.quranapp.android.utils.reader.factory.QuranTranslationFactory import com.quranapp.android.utils.univ.FileUtils import kotlinx.coroutines.Dispatchers @@ -133,6 +133,11 @@ class TranslationViewModel(application: Application) : AndroidViewModel(applicat } private fun deleteTranslation(slug: String) { + if (TranslUtils.isPrebuilt(slug)) { + return + } + + var updatedSelectedSlugs: Set = emptySet() QuranTranslationFactory(application).use { it.deleteTranslation(slug) SearchIndexScheduler.enqueueRemoveSlug(application.applicationContext, slug) @@ -146,12 +151,17 @@ class TranslationViewModel(application: Application) : AndroidViewModel(applicat ) }.filterNot { it.translations.isEmpty() } + updatedSelectedSlugs = current.selectedSlugs - slug current.copy( translationGroups = updatedGroups, - selectedSlugs = current.selectedSlugs - slug + selectedSlugs = updatedSelectedSlugs ) } } + + viewModelScope.launch { + ReaderPreferences.setTranslations(updatedSelectedSlugs) + } } @@ -244,4 +254,4 @@ class TranslationViewModel(application: Application) : AndroidViewModel(applicat translFactory.close() } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/quranapp/android/viewModels/WbwSettingsViewModel.kt b/app/src/main/java/com/quranapp/android/viewModels/WbwSettingsViewModel.kt index 54d9ca8ac..bcd102df5 100644 --- a/app/src/main/java/com/quranapp/android/viewModels/WbwSettingsViewModel.kt +++ b/app/src/main/java/com/quranapp/android/viewModels/WbwSettingsViewModel.kt @@ -35,8 +35,8 @@ data class WbwSettingsUiState( class WbwSettingsViewModel( application: Application ) : AndroidViewModel(application) { - private val db = DatabaseProvider.getExternalQuranDatabase(context) private val context get() = getApplication() + private val db get() = DatabaseProvider.getExternalQuranDatabase(context) private val _uiState = MutableStateFlow(WbwSettingsUiState()) val uiState: StateFlow = _uiState.asStateFlow() diff --git a/app/src/main/java/com/quranapp/android/views/reader/VotdWidgetReceiver.kt b/app/src/main/java/com/quranapp/android/views/reader/VotdWidgetReceiver.kt index 9696f84c3..f35c8e68c 100644 --- a/app/src/main/java/com/quranapp/android/views/reader/VotdWidgetReceiver.kt +++ b/app/src/main/java/com/quranapp/android/views/reader/VotdWidgetReceiver.kt @@ -90,7 +90,7 @@ private data class VotdWidgetUiState( val verseInfo: String, val backgroundBitmap: Bitmap, val arabicTextBitmap: Bitmap?, - val translationBitmap: Bitmap, + val translationBitmap: Bitmap?, val openReaderIntent: Intent, val headerHeightDp: Float, val footerHeightDp: Float, @@ -218,9 +218,9 @@ private fun VotdGlanceContent(context: Context, state: VotdWidgetUiState?) { verticalAlignment = Alignment.CenterVertically, horizontalAlignment = Alignment.CenterHorizontally ) { - state.arabicTextBitmap?.let { arabicBitmap -> + state.arabicTextBitmap?.let { Image( - provider = ImageProvider(arabicBitmap), + provider = ImageProvider(it), contentDescription = null, modifier = GlanceModifier.fillMaxWidth() .height(state.arabicHeightDp.dp), @@ -230,13 +230,15 @@ private fun VotdGlanceContent(context: Context, state: VotdWidgetUiState?) { Spacer(modifier = GlanceModifier.height(state.textVerticalSpacingDp.dp)) } - Image( - provider = ImageProvider(state.translationBitmap), - contentDescription = null, - modifier = GlanceModifier.fillMaxWidth() - .height(state.translationHeightDp.dp), - contentScale = ContentScale.Fit - ) + state.translationBitmap?.let { + Image( + provider = ImageProvider(it), + contentDescription = null, + modifier = GlanceModifier.fillMaxWidth() + .height(state.translationHeightDp.dp), + contentScale = ContentScale.Fit + ) + } } Box( @@ -366,21 +368,19 @@ private suspend fun buildVotdWidgetState( val verseNo = vwd.verseNo val translation = QuranTranslationFactory(context).use { factory -> - val bookInfo = factory.getTranslationBookInfo(VerseUtils.obtainOptimalSlugForVotd()) + val bookInfo = factory.getTranslationBookInfo(ReaderPreferences.primaryTranslationSlug()) factory.getTranslationsSingleSlugVerse(bookInfo.slug, chapterNo, verseNo) } - val translationText = translation.text - - val translationBitmap = createTextBitmap( + val translationBitmap = if (translation != null) createTextBitmap( context = context, - text = StringUtils.removeHTML(translationText, false), + text = StringUtils.removeHTML(translation.text, false), typeface = if (translation.isUrdu) context.getFont(R.font.noto_nastaliq_urdu_regular) else null, textSize = context.sp2px(20f), color = Color.White.toArgb(), targetMaxWidth = textMaxWidthPx, targetMaxHeight = translationHeightPx - ) + ) else null val openIntent = ReaderFactory.prepareSingleVerseIntent(chapterNo, verseNo).apply { setClass(context, ActivityReader::class.java) diff --git a/app/src/main/res/drawable/hierarchy.xml b/app/src/main/res/drawable/hierarchy.xml new file mode 100644 index 000000000..bd6adc8fd --- /dev/null +++ b/app/src/main/res/drawable/hierarchy.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/prophet_al_yasaa.webp b/app/src/main/res/drawable/prophet_al_yasaa.webp new file mode 100644 index 000000000..b87d69a72 Binary files /dev/null and b/app/src/main/res/drawable/prophet_al_yasaa.webp differ diff --git a/app/src/main/res/drawable/prophet_al_yasaa.xml b/app/src/main/res/drawable/prophet_al_yasaa.xml deleted file mode 100644 index 78e32e9f5..000000000 --- a/app/src/main/res/drawable/prophet_al_yasaa.xml +++ /dev/null @@ -1,103 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/prophet_ayyub.webp b/app/src/main/res/drawable/prophet_ayyub.webp new file mode 100644 index 000000000..632eed91c Binary files /dev/null and b/app/src/main/res/drawable/prophet_ayyub.webp differ diff --git a/app/src/main/res/drawable/prophet_ayyub.xml b/app/src/main/res/drawable/prophet_ayyub.xml deleted file mode 100644 index 23b2224bf..000000000 --- a/app/src/main/res/drawable/prophet_ayyub.xml +++ /dev/null @@ -1,96 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/prophet_dawud.webp b/app/src/main/res/drawable/prophet_dawud.webp new file mode 100644 index 000000000..ad5a3264e Binary files /dev/null and b/app/src/main/res/drawable/prophet_dawud.webp differ diff --git a/app/src/main/res/drawable/prophet_dawud.xml b/app/src/main/res/drawable/prophet_dawud.xml deleted file mode 100644 index b16b00fd3..000000000 --- a/app/src/main/res/drawable/prophet_dawud.xml +++ /dev/null @@ -1,106 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/prophet_dhul_kifl.webp b/app/src/main/res/drawable/prophet_dhul_kifl.webp new file mode 100644 index 000000000..2268e3478 Binary files /dev/null and b/app/src/main/res/drawable/prophet_dhul_kifl.webp differ diff --git a/app/src/main/res/drawable/prophet_dhul_kifl.xml b/app/src/main/res/drawable/prophet_dhul_kifl.xml deleted file mode 100644 index 379e74f57..000000000 --- a/app/src/main/res/drawable/prophet_dhul_kifl.xml +++ /dev/null @@ -1,165 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/prophet_harun.webp b/app/src/main/res/drawable/prophet_harun.webp new file mode 100644 index 000000000..1c2550c18 Binary files /dev/null and b/app/src/main/res/drawable/prophet_harun.webp differ diff --git a/app/src/main/res/drawable/prophet_harun.xml b/app/src/main/res/drawable/prophet_harun.xml deleted file mode 100644 index b0c428ff3..000000000 --- a/app/src/main/res/drawable/prophet_harun.xml +++ /dev/null @@ -1,152 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/prophet_hud.webp b/app/src/main/res/drawable/prophet_hud.webp new file mode 100644 index 000000000..20178698b Binary files /dev/null and b/app/src/main/res/drawable/prophet_hud.webp differ diff --git a/app/src/main/res/drawable/prophet_hud.xml b/app/src/main/res/drawable/prophet_hud.xml deleted file mode 100644 index d9485b32c..000000000 --- a/app/src/main/res/drawable/prophet_hud.xml +++ /dev/null @@ -1,144 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/prophet_iliyas.webp b/app/src/main/res/drawable/prophet_iliyas.webp new file mode 100644 index 000000000..10ede9ced Binary files /dev/null and b/app/src/main/res/drawable/prophet_iliyas.webp differ diff --git a/app/src/main/res/drawable/prophet_iliyas.xml b/app/src/main/res/drawable/prophet_iliyas.xml deleted file mode 100644 index b7a129429..000000000 --- a/app/src/main/res/drawable/prophet_iliyas.xml +++ /dev/null @@ -1,153 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/prophet_ismail.webp b/app/src/main/res/drawable/prophet_ismail.webp new file mode 100644 index 000000000..79f720c0f Binary files /dev/null and b/app/src/main/res/drawable/prophet_ismail.webp differ diff --git a/app/src/main/res/drawable/prophet_ismail.xml b/app/src/main/res/drawable/prophet_ismail.xml deleted file mode 100644 index 70e33fc10..000000000 --- a/app/src/main/res/drawable/prophet_ismail.xml +++ /dev/null @@ -1,198 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/prophet_shuaib.webp b/app/src/main/res/drawable/prophet_shuaib.webp new file mode 100644 index 000000000..0d2c56f75 Binary files /dev/null and b/app/src/main/res/drawable/prophet_shuaib.webp differ diff --git a/app/src/main/res/drawable/prophet_shuaib.xml b/app/src/main/res/drawable/prophet_shuaib.xml deleted file mode 100644 index 32177ea80..000000000 --- a/app/src/main/res/drawable/prophet_shuaib.xml +++ /dev/null @@ -1,182 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/prophet_yahya.webp b/app/src/main/res/drawable/prophet_yahya.webp new file mode 100644 index 000000000..0659d278b Binary files /dev/null and b/app/src/main/res/drawable/prophet_yahya.webp differ diff --git a/app/src/main/res/drawable/prophet_yahya.xml b/app/src/main/res/drawable/prophet_yahya.xml deleted file mode 100644 index 25cc04ab4..000000000 --- a/app/src/main/res/drawable/prophet_yahya.xml +++ /dev/null @@ -1,109 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/prophet_yaqub.webp b/app/src/main/res/drawable/prophet_yaqub.webp new file mode 100644 index 000000000..dd21ac720 Binary files /dev/null and b/app/src/main/res/drawable/prophet_yaqub.webp differ diff --git a/app/src/main/res/drawable/prophet_yaqub.xml b/app/src/main/res/drawable/prophet_yaqub.xml deleted file mode 100644 index 58b42b0f4..000000000 --- a/app/src/main/res/drawable/prophet_yaqub.xml +++ /dev/null @@ -1,153 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/prophet_yunus.webp b/app/src/main/res/drawable/prophet_yunus.webp new file mode 100644 index 000000000..57652554f Binary files /dev/null and b/app/src/main/res/drawable/prophet_yunus.webp differ diff --git a/app/src/main/res/drawable/prophet_yunus.xml b/app/src/main/res/drawable/prophet_yunus.xml deleted file mode 100644 index e13bd94ec..000000000 --- a/app/src/main/res/drawable/prophet_yunus.xml +++ /dev/null @@ -1,103 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/prophet_zakariyya.webp b/app/src/main/res/drawable/prophet_zakariyya.webp new file mode 100644 index 000000000..839d7efee Binary files /dev/null and b/app/src/main/res/drawable/prophet_zakariyya.webp differ diff --git a/app/src/main/res/drawable/prophet_zakariyya.xml b/app/src/main/res/drawable/prophet_zakariyya.xml deleted file mode 100644 index 8486014a0..000000000 --- a/app/src/main/res/drawable/prophet_zakariyya.xml +++ /dev/null @@ -1,110 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/topic_thumbnail.webp b/app/src/main/res/drawable/topic_thumbnail.webp new file mode 100644 index 000000000..75114a879 Binary files /dev/null and b/app/src/main/res/drawable/topic_thumbnail.webp differ diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 78468075c..05bc45c84 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -437,4 +437,8 @@ هل تريد تنزيل الصوت كلمة بكلمة لجميع السور؟ قد يستهلك هذا مساحة تخزين وبيانات جوال بشكل كبير. انتظر حتى تنتهي تنزيلات السور الفردية قبل تنزيل الكل. انتظر حتى يكتمل التنزيل الشامل قبل تنزيل سورة واحدة. + مستكشف موضوعات القرآن + الموضوعات حسب التصنيف + محتوى هذه الصفحة متاح باللغة الإنجليزية فقط. + الحجم التقريبي: %s \ No newline at end of file diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml index c904909f4..2b136ba91 100644 --- a/app/src/main/res/values-bn/strings.xml +++ b/app/src/main/res/values-bn/strings.xml @@ -434,4 +434,8 @@ শব্দভিত্তিক তিলাওয়াতের সব সূরার অডিও ডাউনলোড করবেন? এতে বেশি স্টোরেজ ও মোবাইল ডেটা লাগতে পারে। সব ডাউনলোড করার আগে পৃথক সূরার ডাউনলোড শেষ হতে দিন। একক সূরা ডাউনলোডের আগে পূর্ণ ডাউনলোড শেষ হওয়া পর্যন্ত অপেক্ষা করুন। + কুরআনিক বিষয় অনুসন্ধান + বিষয়ভিত্তিক টপিকসমূহ + এই পৃষ্ঠার বিষয়বস্তু কেবল ইংরেজি ভাষায় উপলব্ধ। + আনুমানিক আকার: %s \ No newline at end of file diff --git a/app/src/main/res/values-ckb/strings.xml b/app/src/main/res/values-ckb/strings.xml index cf5082169..d817af702 100644 --- a/app/src/main/res/values-ckb/strings.xml +++ b/app/src/main/res/values-ckb/strings.xml @@ -434,4 +434,8 @@ دەتەوێت دەنگی تلاوەتی وشە بە وشەی هەموو سوورەتەکان دابەزێنیت؟ ئەمە ڕەنگە بەتوندی شوێنی هەڵگرتن و داتای مۆبایل بەکاربهێنێت. پێش دابەزاندنی هەموو، چاوەڕوانی تەواوبوونی دابەزاندنی سوورەتە تاکەکان بکە. پێش دابەزاندنی سوورەتێکی تاک، چاوەڕوانی تەواوبوونی دابەزاندنی گشتی بکە. + گەڕانی بابەتەکانی قورئان + بابەتەکان بە پێی تێما + ناوەڕۆکی ئەم پەڕەیە تەنها بە زمانی ئینگلیزی بەردەستە. + قەبارەی خەمڵێندراو: %s \ No newline at end of file diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index fa419a1d2..3e1170b7e 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -437,4 +437,8 @@ Wort-für-Wort-Rezitationsaudio für alle Suren herunterladen? Das kann viel Speicher und mobile Daten verbrauchen. Warte, bis einzelne Suren-Downloads abgeschlossen sind, bevor du alles herunterlädst. Warte, bis der vollständige Download abgeschlossen ist, bevor du eine einzelne Sure herunterlädst. + Themen-Explorer + Themen nach Kategorie + Die Inhalte auf dieser Seite sind nur auf Englisch verfügbar. + Geschätzte Größe: %s \ No newline at end of file diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 5c1b96bb1..4f05282bf 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -437,4 +437,8 @@ ¿Descargar el audio de recitación palabra por palabra de todas las suras? Esto puede consumir bastante almacenamiento y datos móviles. Espera a que terminen las descargas de suras individuales antes de descargar todo. Espera a que termine la descarga completa antes de descargar una sola sura. + Explorador de temas + Temas por categoría + El contenido de esta página solo está disponible en inglés. + Tamaño estimado: %s \ No newline at end of file diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 984211710..2ae62ca65 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -434,4 +434,8 @@ صوت تلاوت کلمه‌به‌کلمه همه سوره‌ها دانلود شود؟ این کار ممکن است حجم زیادی از حافظه و دیتای موبایل مصرف کند. پیش از دانلود همه، صبر کنید دانلود سوره‌های تکی تمام شود. پیش از دانلود یک سوره، صبر کنید دانلود کامل به پایان برسد. + کاوش موضوعات قرآن + موضوعات بر اساس دسته‌بندی + محتوای این صفحه فقط به زبان انگلیسی در دسترس است. + حجم تخمینی: %s \ No newline at end of file diff --git a/app/src/main/res/values-fil/strings.xml b/app/src/main/res/values-fil/strings.xml index b425512e2..96fb2fab1 100644 --- a/app/src/main/res/values-fil/strings.xml +++ b/app/src/main/res/values-fil/strings.xml @@ -430,4 +430,8 @@ I-download ang word-by-word recitation audio ng lahat ng surah? Maaaring malaki ang magamit nitong storage at mobile data. Hintayin munang matapos ang mga indibidwal na download ng surah bago i-download lahat. Hintayin munang matapos ang buong download bago mag-download ng isang surah. + Tagahanap ng mga Paksa + Mga paksa ayon sa tema + Ang nilalaman sa pahinang ito ay magagamit lamang sa wikang Ingles. + Tinatayang laki: %s \ No newline at end of file diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 62ee228e8..fb2b0e4a8 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -437,4 +437,8 @@ Télécharger l\'audio de récitation mot à mot de toutes les sourates ? Cela peut consommer beaucoup de stockage et de données mobiles. Attendez la fin des téléchargements de sourates individuelles avant de tout télécharger. Attendez la fin du téléchargement complet avant de télécharger une seule sourate. + Explorateur de thèmes + Thèmes par catégorie + Le contenu de cette page est disponible uniquement en anglais. + Taille estimée : %s \ No newline at end of file diff --git a/app/src/main/res/values-gu/strings.xml b/app/src/main/res/values-gu/strings.xml index c69db3cb4..1dcbb8710 100644 --- a/app/src/main/res/values-gu/strings.xml +++ b/app/src/main/res/values-gu/strings.xml @@ -437,4 +437,8 @@ બધી સૂરાઓ માટે શબ્દ-દર-શબ્દ તિલાવત ઓડિયો ડાઉનલોડ કરશો? તે વધુ સ્ટોરેજ અને મોબાઇલ ડેટા વાપરી શકે છે. બધું ડાઉનલોડ કરતા પહેલાં અલગ સૂરાઓના ડાઉનલોડ પૂર્ણ થવા દો. એક જ સૂરા ડાઉનલોડ કરતા પહેલાં સંપૂર્ણ ડાઉનલોડ પૂર્ણ થવાની રાહ જુઓ. + વિષય એક્સપ્લોરર + થીમ મુજબ વિષયો + આ પેજનું સામગ્રી માત્ર અંગ્રેજી ભાષામાં ઉપલબ્ધ છે. + અંદાજિત કદ: %s \ No newline at end of file diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 98d2dc687..8c586db07 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -441,4 +441,8 @@ क्या सभी सूरह का शब्द-दर-शब्द तिलावत ऑडियो डाउनलोड करना है? इससे काफी स्टोरेज और मोबाइल डेटा खर्च हो सकता है। सब डाउनलोड करने से पहले अलग-अलग सूरह के डाउनलोड पूरे होने दें। एक सूरह डाउनलोड करने से पहले पूरा डाउनलोड खत्म होने दें। + विषय अन्वेषक + विषयवार टॉपिक + इस पेज की सामग्री केवल अंग्रेज़ी भाषा में उपलब्ध है। + अनुमानित आकार: %s \ No newline at end of file diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index c0837f18c..24523c63a 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -429,4 +429,8 @@ Unduh audio tilawah kata-per-kata untuk semua surah? Ini dapat menggunakan banyak penyimpanan dan data seluler. Tunggu unduhan surah satuan selesai sebelum mengunduh semuanya. Tunggu unduhan penuh selesai sebelum mengunduh satu surah. + Penjelajah Topik + Topik berdasarkan tema + Konten di halaman ini hanya tersedia dalam bahasa Inggris. + Perkiraan ukuran: %s \ No newline at end of file diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 970d851f1..1a5319853 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -428,4 +428,8 @@ Scaricare l\'audio di recitazione parola per parola per tutte le sure? Potrebbe consumare molto spazio e dati mobili. Attendi il completamento dei download delle singole sure prima di scaricare tutto. Attendi il completamento del download completo prima di scaricare una singola sura. + Esplora argomenti + Argomenti per tema + I contenuti di questa pagina sono disponibili solo in inglese. + Dimensione stimata: %s \ No newline at end of file diff --git a/app/src/main/res/values-ky/strings.xml b/app/src/main/res/values-ky/strings.xml index 7b19db521..482d5adef 100644 --- a/app/src/main/res/values-ky/strings.xml +++ b/app/src/main/res/values-ky/strings.xml @@ -431,4 +431,8 @@ Бардык сүрөлөр үчүн сөзмө-сөз кыраат аудиосун жүктөп алайынбы? Бул көп сактагычты жана мобилдик трафикти колдонушу мүмкүн. Баарын жүктөөдөн мурда өз-өзүнчө сүрө жүктөөлөрү бүтүшүн күтүңүз. Бир сүрөнү жүктөөдөн мурда толук жүктөө бүтүшүн күтүңүз. + Темалар изилдегичи + Темалар боюнча бөлүмдөр + Бул беттеги мазмун англис тилинде гана жеткиликтүү. + Болжолдуу өлчөмү: %s \ No newline at end of file diff --git a/app/src/main/res/values-ml/strings.xml b/app/src/main/res/values-ml/strings.xml index 06a6c15cb..4ae6f2dfa 100644 --- a/app/src/main/res/values-ml/strings.xml +++ b/app/src/main/res/values-ml/strings.xml @@ -432,4 +432,8 @@ എല്ലാ സൂറങ്ങൾക്കുമുള്ള വാക്ക്‌പ്രതി തിലാവത്ത് ഓഡിയോ ഡൗൺലോഡ് ചെയ്യണോ? ഇത് കൂടുതൽ സ്റ്റോറേജും മൊബൈൽ ഡാറ്റയും ഉപയോഗിക്കാം. എല്ലാം ഡൗൺലോഡ് ചെയ്യുന്നതിന് മുമ്പ് ഓരോ സൂറയുടെയും ഡൗൺലോഡ് പൂർത്തിയാകാൻ കാത്തിരിക്കുക. ഒരു സൂറ ഡൗൺലോഡ് ചെയ്യുന്നതിന് മുമ്പ് മുഴുവൻ ഡൗൺലോഡ് പൂർത്തിയാകുന്നത് വരെ കാത്തിരിക്കുക. + വിഷയാന്വേഷകൻ + തീം അടിസ്ഥാനത്തിലുള്ള വിഷയങ്ങൾ + ഈ പേജിലെ ഉള്ളടക്കം ഇംഗ്ലീഷ് ഭാഷയിൽ മാത്രമാണ് ലഭ്യം. + അനുമാനിത വലുപ്പം: %s \ No newline at end of file diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index d41a5476c..58f17c7d7 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -437,4 +437,8 @@ Baixar o áudio de recitação palavra por palavra de todas as suratas? Isto pode consumir bastante armazenamento e dados móveis. Aguarde o fim dos downloads de suratas individuais antes de baixar tudo. Aguarde o download completo terminar antes de baixar uma única surata. + Explorador de temas + Tópicos por tema + O conteúdo desta página está disponível apenas em inglês. + Tamanho estimado: %s \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index a5edbe0f1..128be3663 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -445,4 +445,8 @@ Скачать пословное аудио чтения для всех сур? Это может занять много памяти и мобильного трафика. Дождитесь завершения загрузки отдельных сур перед загрузкой всего. Дождитесь завершения полной загрузки перед загрузкой одной суры. + Навигатор по темам + Темы по категориям + Содержимое этой страницы доступно только на английском языке. + Предполагаемый размер: %s \ No newline at end of file diff --git a/app/src/main/res/values-sd/strings.xml b/app/src/main/res/values-sd/strings.xml index 228431898..0508891b2 100644 --- a/app/src/main/res/values-sd/strings.xml +++ b/app/src/main/res/values-sd/strings.xml @@ -427,4 +427,8 @@ ڇا سڀني سورتن جي لفظ بہ لفظ تلاوت آڊيو ڊائون لوڊ ڪجي؟ هن سان گهڻي اسٽوريج ۽ موبائل ڊيٽا استعمال ٿي سگهي ٿي۔ سڀ ڊائون لوڊ ڪرڻ کان اڳ انفرادي سورتن جا ڊائون لوڊ مڪمل ٿيڻ ڏيو۔ هڪ سورت ڊائون لوڊ ڪرڻ کان اڳ مڪمل ڊائون لوڊ ختم ٿيڻ جو انتظار ڪريو۔ + موضوعن جو ڳولاڪار + موضوع موجب عنوان + هن صفحي جو مواد صرف انگريزي ٻولي ۾ موجود آهي. + اندازي مطابق سائيز: %s \ No newline at end of file diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index a9e75cb22..428663c3b 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -427,4 +427,8 @@ அனைத்து ஸூராக்களுக்கும் சொல்-சொல்லாக திலாவத் ஒலியைப் பதிவிறக்கவா? இது அதிக சேமிப்பு மற்றும் மொபைல் டேட்டாவை பயன்படுத்தலாம். அனைத்தையும் பதிவிறக்குவதற்கு முன் தனித் ஸூராக்களின் பதிவிறக்கம் முடியும் வரை காத்திருக்கவும். ஒரு ஸூராவைப் பதிவிறக்குவதற்கு முன் முழு பதிவிறக்கம் முடியும் வரை காத்திருக்கவும். + தலைப்பு ஆராய்ச்சி + கருப்பொருள் அடிப்படையிலான தலைப்புகள் + இந்தப் பக்கத்தின் உள்ளடக்கம் ஆங்கில மொழியில் மட்டுமே கிடைக்கும். + மதிப்பிடப்பட்ட அளவு: %s \ No newline at end of file diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 8e816825c..8ff56fbf5 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -434,4 +434,8 @@ Tüm sureler için kelime kelime tilavet sesini indirmek istiyor musunuz? Bu işlem önemli miktarda depolama ve mobil veri kullanabilir. Hepsini indirmeden önce tek tek sure indirmelerinin bitmesini bekleyin. Tek bir sure indirmeden önce toplu indirmenin tamamlanmasını bekleyin. + Konu Gezgini + Temaya göre konular + Bu sayfadaki içerikler yalnızca İngilizce dilinde mevcuttur. + Tahmini boyut: %s \ No newline at end of file diff --git a/app/src/main/res/values-ur/strings.xml b/app/src/main/res/values-ur/strings.xml index b5617ce0f..cee6c239b 100644 --- a/app/src/main/res/values-ur/strings.xml +++ b/app/src/main/res/values-ur/strings.xml @@ -441,4 +441,8 @@ کیا تمام سورتوں کی لفظ بہ لفظ تلاوت آڈیو ڈاؤن لوڈ کرنی ہے؟ اس سے کافی اسٹوریج اور موبائل ڈیٹا استعمال ہو سکتا ہے۔ سب ڈاؤن لوڈ کرنے سے پہلے انفرادی سورتوں کے ڈاؤن لوڈ مکمل ہونے دیں۔ ایک سورت ڈاؤن لوڈ کرنے سے پہلے مکمل ڈاؤن لوڈ ختم ہونے دیں۔ + موضوعات ایکسپلورر + موضوع کے لحاظ سے عنوانات + اس صفحے کا مواد صرف انگریزی زبان میں دستیاب ہے۔ + اندازاً سائز: %s \ No newline at end of file diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 3b02413da..fa4dd36e1 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -427,4 +427,8 @@ 要下载所有苏拉的逐词诵读音频吗?这可能会占用较多存储空间和移动数据。 请先等待单个苏拉下载完成,再执行全部下载。 请先等待完整下载结束,再下载单个苏拉。 + 主题导航 + 按主题分类 + 本页面内容仅提供英文版本。 + 预计大小:%s \ No newline at end of file diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index deb41c8a9..f14281286 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -427,4 +427,8 @@ 要下載所有蘇拉的逐詞誦讀音訊嗎?這可能會耗用較多儲存空間與行動數據。 請先等待單個蘇拉下載完成,再執行全部下載。 請先等待完整下載結束,再下載單個蘇拉。 + 主題導覽 + 依主題分類 + 本頁內容僅提供英文版本。 + 預估大小:%s \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 29ac97dc9..b6afd5a8d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -456,4 +456,8 @@ Wait for individual chapter downloads to finish before downloading all. Wait for the full download to finish before downloading a single chapter. + Ontology Explorer + Topics by Theme + Content on this page is available in English only. + Estimated size: %s diff --git a/app/src/main/res/xml/quran_prophetic_duas.xml b/app/src/main/res/xml/quran_prophetic_duas.xml index 3eca10496..6c65f1579 100644 --- a/app/src/main/res/xml/quran_prophetic_duas.xml +++ b/app/src/main/res/xml/quran_prophetic_duas.xml @@ -1,18 +1,18 @@ - 7:23 - 21:83,38:41 - 2:250 - 2:126-129,14:35-41,26:83-89,37:99-100,60:4-5 - 5:114 - 29:30,26:169 - 3:26-27,4:75,6:162-163,9:59,17:24,17:80,20:114,21:112,23:93-94,23:97-98,23:109,23:118,39:46,46:15 - 7:126,7:143,7:151,7:155-156,2:67,5:25,10:85-86,20:25-35,28:16-17,28:21,28:24 - 11:45,11:47,23:26,23:29,26:117-118,54:10,71:26-28 - 23:39 - 7:89 - 27:19,38:35 - 21:87 - 12:33,12:101 - 3:38,19:4-6,21:89 - \ No newline at end of file + 7:23 + 21:83,38:41 + 2:250 + 2:126-129,14:35-41,26:83-89,37:99-100,60:4-5 + 5:114 + 29:30,26:169 + 3:26-27,4:75,6:162-163,9:59,17:24,17:80,20:114,21:112,23:93-94,23:97-98,23:109,23:118,39:46,46:15 + 7:126,7:143,7:151,7:155-156,2:67,5:25,10:85-86,20:25-35,28:16-17,28:21,28:24 + 11:45,11:47,23:26,23:29,26:117-118,54:10,71:26-28 + 23:39 + 7:89 + 27:19,38:35 + 21:87 + 12:33,12:101 + 3:38,19:4-6,21:89 + diff --git a/app/src/main/res/xml/quran_prophets_reference.xml b/app/src/main/res/xml/quran_prophets_reference.xml index 83b43d079..979105c30 100644 --- a/app/src/main/res/xml/quran_prophets_reference.xml +++ b/app/src/main/res/xml/quran_prophets_reference.xml @@ -1,78 +1,228 @@ - + 2:31-37,3:33,3:59,7:11-35,7:172,17:61,17:70,18:50,19:58,20:115-121,36:60,38:69 - + 6:86,38:48 - + 4:163,6:84,21:83,38:41 - + 2:251,4:163,5:78,6:84,17:55,21:78-79,27:15,27:16,34:10-13,38:17,38:24-30 - + 21:85,38:48 - + 2:248,4:163,6:84,7:122,7:142,7:150,10:75,10:89,19:28,19:53,20:30,20:70,20:90-94,21:48,23:45,25:35,26:13,26:48,28:34,37:114,37:120 - + 7:65-71,11:50-60,11:89,26:124 - + 2:124-140,2:258,2:260,3:33,3:65,3:67-68,3:84,3:95,3:97,4:54,4:125,4:163,6:74-75,6:83-84,6:161,9:70,9:114,11:69,11:74-76,12:6,12:38,14:35,15:51-52,15:57,16:120,16:123,19:41,19:46-47,19:58,21:51,21:60,21:62,21:69,22:26,22:43,22:78,26:69,29:16,29:24-26,29:31-32,33:7,37:83,37:104,37:109,38:45,42:13,43:26,51:24,51:31,53:37,57:26,60:4,87:19 - + 19:56,21:85 - + 6:85,37:123-130 - + 2:87,2:136,2:253,3:45-59,3:84,4:157-159,4:163,4:171,5:46,5:78,5:110-116,6:85,19:19-21,19:29-37,19:88,19:91-92,21:91,33:7,42:13,43:59-63,57:27,61:6,61:14 - + 2:133,2:136,2:140,3:84,4:163,6:84,11:71,12:6,12:38,14:39,19:49,21:72,29:27,37:112-113,38:45 - + 2:125,2:127,2:133,2:136,2:140,3:84,4:163,6:86,14:39,19:54,21:85,38:48 - + 6:86,7:80,9:70,11:70-71,11:74,11:77,11:81,11:89,15:59,15:61,15:68,15:71,21:71,21:74,22:43,25:40,26:160-161,26:167,27:54-56,29:26,29:28-33,29:40,37:133,38:13,50:13,51:36,53:53,54:33-34,54:36,54:43,66:10,69:9 - + 3:144,5:41,11:2,13:43,16:101,17:1,25:1,33:9,33:40,47:2,48:29,61:6,73:1,74:1,88:21 - + 2:51,2:53-55,2:60-61,2:67-71,2:87,2:92,2:108,2:136,2:246,2:248,3:84,4:153,4:164,5:20-25,6:84,6:91,6:154,7:103-160,10:75-88,11:17,11:96,11:110,14:5-6,14:8,17:2,17:101-102,18:60-78,19:51,20:9-11,20:17-40,20:49-52,20:57-77,20:83-97,21:48,22:44,23:45,23:49,25:35,26:10-52,26:61-67,27:7-10,28:3,28:7,28:10,28:15-20,28:28-38,28:43-46,28:48,28:76,29:39,32:23,33:7,33:69,37:114,37:120,40:23,40:26-27,40:37,40:53,41:45,42:13,43:46,43:49,43:52,44:17,46:12,46:30,51:38,53:36,61:5,79:15,87:19 - + 3:33,4:163,6:84,7:59,7:61,7:69,9:70,10:71,11:25-34,11:36-48,11:89,14:9,17:3,17:17,19:58,21:76,22:42,23:23-28,25:37,26:105-106,26:116,29:14,33:7,37:75-79,38:12,40:5,40:31,42:13,50:12,51:46,53:52,54:9,57:26,66:10,71:1,71:21-26 - + 7:73-79,11:61-66,11:89,26:142,27:45,91:13 - + 7:85-93,11:84-95,26:177,29:36 - + 2:102,4:163,6:84,21:78-82,27:15-44,34:12-14,38:30,38:34 - + 3:39,6:85,19:7,19:12,21:90 - + 2:132-133,2:136,2:140,3:84,3:93,4:163,6:84,11:71,12:6,12:13,12:18,12:38,12:66,12:68,12:83,19:6,19:49,19:58,21:72,29:27,38:45 - + 4:163,6:86,10:98,21:87,37:139,68:48 - + 6:84,12:4-101,40:34 - + 3:37-38,6:85,19:2-11,21:89 - \ No newline at end of file + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ff7f41a68..338d2f74f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -35,6 +35,7 @@ navigation-ui-ktx = "2.9.7" accompanist = "0.37.3" glance = "1.2.0-rc01" +coil = "3.4.0" [libraries] @@ -111,6 +112,8 @@ navigation-ui-ktx = { module = "androidx.navigation:navigation-ui-ktx", version. accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" } glance = { module = "androidx.glance:glance", version.ref = "glance" } glance-appwidget = { module = "androidx.glance:glance-appwidget", version.ref = "glance" } +coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } +coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" } [plugins] kotlin-compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlinGradlePlugin" }