afarquran3
package com.hajipc.afarquran3 // ⚠️ Change this to your actual package name
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.LibraryBooks
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.*
import androidx.datastore.preferences.preferencesDataStore
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
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.google.gson.Gson
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import java.io.InputStreamReader
import java.util.concurrent.ConcurrentHashMap
// ==========================================
// 1. DATA MODELS (Offline JSON matching)
// ==========================================
data class RawAyah(
val sura: String,
val aya: String,
val arabic_text: String?,
val translation: String,
val footnotes: String?
)
data class Ayah(
val id: String,
val surahNumber: Int,
val ayahNumber: Int,
val arabicText: String,
val translationText: String,
val footnotes: String,
val normalizedArabic: String = "",
val normalizedTranslation: String = ""
)
data class SurahInfo(val number: Int, val englishName: String, val arabicName: String, val numberOfAyahs: Int)
// ==========================================
// 2. MAIN ACTIVITY
// ==========================================
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val surahRepository = SurahRepository.getInstance(applicationContext)
setContent {
val settingsManager = remember { AppSettingsManager(applicationContext) }
val currentTheme by settingsManager.currentTheme.collectAsStateWithLifecycle()
QuranAppTheme(appTheme = currentTheme) {
AppNavigation(surahRepository = surahRepository, settingsManager = settingsManager)
}
}
}
}
private sealed interface AppScreen {
val route: String
data object SurahList : AppScreen { override val route: String = "surah_list" }
data class SurahDetail(val surahNumber: Int, val ayahNumber: Int? = null) : AppScreen {
override val route: String = "surah_detail/$surahNumber" + if (ayahNumber != null) "?ayah=$ayahNumber" else ""
companion object {
const val surahArg = "surahNumber"
const val ayahArg = "ayahNumber"
val routeWithArgs: String = "surah_detail/{$surahArg}?ayah={$ayahArg}"
}
}
data object Search : AppScreen { override val route: String = "search" }
data object Settings : AppScreen { override val route: String = "settings" }
data object About : AppScreen { override val route: String = "about" }
data object References : AppScreen { override val route: String = "references" }
data object PrivacyPolicy : AppScreen { override val route: String = "privacy_policy" }
data object Bookmarks : AppScreen { override val route: String = "bookmarks" }
}
@Composable
private fun AppNavigation(surahRepository: SurahRepository, settingsManager: AppSettingsManager) {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = AppScreen.SurahList.route) {
composable(AppScreen.SurahList.route) { SurahListScreen(surahRepository, navController, settingsManager) }
composable(
route = AppScreen.SurahDetail.routeWithArgs,
arguments = listOf(
navArgument(AppScreen.SurahDetail.surahArg) { type = NavType.IntType },
navArgument(AppScreen.SurahDetail.ayahArg) { type = NavType.IntType; defaultValue = -1 }
)
) { entry ->
val surahNumber = entry.arguments?.getInt(AppScreen.SurahDetail.surahArg) ?: 1
val targetAyah = entry.arguments?.getInt(AppScreen.SurahDetail.ayahArg) ?: -1
val surahInfo = surahRepository.getSurahInfo(surahNumber)
if (surahInfo != null) {
val viewModel = viewModel<SurahDetailViewModel>(
factory = viewModelFactory { initializer { SurahDetailViewModel(surahRepository, surahNumber) } }
)
SurahDetailScreen(surahInfo, viewModel, navController, targetAyah, settingsManager)
}
}
composable(AppScreen.Search.route) {
val viewModel = viewModel<SearchViewModel>(factory = viewModelFactory { initializer { SearchViewModel(surahRepository) } })
SearchScreen(navController, viewModel)
}
composable(AppScreen.Bookmarks.route) { BookmarksScreen(navController, settingsManager, surahRepository) }
composable(AppScreen.Settings.route) { SettingsScreen(navController) }
composable(AppScreen.About.route) { AboutScreen(navController) }
composable(AppScreen.References.route) { ReferencesScreen(navController) }
composable(AppScreen.PrivacyPolicy.route) { PrivacyPolicyScreen(navController) }
}
}
// ==========================================
// 3. OFFLINE REPOSITORY (Reads JSON from Assets)
// ==========================================
class SurahRepository private constructor(private val context: Context) {
val surahList = MutableStateFlow(getAllSurahs())
private val ayahCache = ConcurrentHashMap<Int, List<Ayah>>()
private val searchIndex = ArrayList<Ayah>()
val isDatabaseReady = MutableStateFlow(false)
init {
CoroutineScope(Dispatchers.IO).launch { loadDatabaseFromAssets() }
}
private fun loadDatabaseFromAssets() {
try {
val inputStream = context.assets.open("afar_quran.json")
val reader = InputStreamReader(inputStream)
val type = object : TypeToken<List<RawAyah>>() {}.type
val rawAyahs: List<RawAyah> = Gson().fromJson(reader, type)
reader.close()
val groupedBySurah = rawAyahs.groupBy { it.sura.toInt() }
val tempSearchList = ArrayList<Ayah>()
for ((suraNum, rawList) in groupedBySurah) {
val parsedList = rawList.map { apiAyah ->
var arText = apiAyah.arabic_text ?: ""
// Strip Bismillah for non-Fatiha/Tawbah
if (suraNum != 1 && suraNum != 9 && apiAyah.aya == "1") {
val bismillah = "بِسْمِ ٱللَّهِ ٱلرَّحْمَـٰنِ ٱلرَّحِيمِ"
if (arText.startsWith(bismillah)) arText = arText.substring(bismillah.length).trim()
}
Ayah(
id = "${apiAyah.sura}:${apiAyah.aya}",
surahNumber = suraNum,
ayahNumber = apiAyah.aya.toInt(),
arabicText = arText,
translationText = apiAyah.translation,
footnotes = apiAyah.footnotes ?: "",
normalizedArabic = normalizeText(arText),
normalizedTranslation = normalizeText(apiAyah.translation)
)
}
ayahCache[suraNum] = parsedList
tempSearchList.addAll(parsedList)
}
synchronized(searchIndex) {
searchIndex.clear()
searchIndex.addAll(tempSearchList)
}
isDatabaseReady.value = true
} catch (e: Exception) {
e.printStackTrace()
isDatabaseReady.value = true // Let app proceed even if error to show empty state gracefully
}
}
fun getSurahInfo(surahNumber: Int): SurahInfo? = surahList.value.find { it.number == surahNumber }
fun getAyahsForSurah(surahNumber: Int): List<Ayah> = ayahCache[surahNumber] ?: emptyList()
fun getAyah(surahNumber: Int, ayahNumber: Int): Ayah? {
val ayahs = getAyahsForSurah(surahNumber)
return ayahs.find { it.ayahNumber == ayahNumber }
}
suspend fun fastSearch(query: String, isDirectMatch: Boolean): List<Ayah> = withContext(Dispatchers.Default) {
val normalizedQuery = normalizeText(query)
if (normalizedQuery.isBlank()) return@withContext emptyList()
val snapshot = synchronized(searchIndex) { ArrayList(searchIndex) }
val regexBoundary = if (isDirectMatch) Regex("\\b${Regex.escape(normalizedQuery)}\\b", RegexOption.IGNORE_CASE) else null
snapshot.filter { ayah ->
val matchesArabic = ayah.normalizedArabic.contains(normalizedQuery, ignoreCase = true)
val matchesTrans = ayah.normalizedTranslation.contains(normalizedQuery, ignoreCase = true)
if (!isDirectMatch) matchesArabic || matchesTrans
else (matchesArabic && regexBoundary?.containsMatchIn(ayah.normalizedArabic) == true) ||
(matchesTrans && regexBoundary?.containsMatchIn(ayah.normalizedTranslation) == true)
}
}
companion object {
@Volatile private var INSTANCE: SurahRepository? = null
fun getInstance(context: Context): SurahRepository = INSTANCE ?: synchronized(this) {
INSTANCE ?: SurahRepository(context).also { INSTANCE = it }
}
}
}
// ==========================================
// 4. VIEWMODELS
// ==========================================
class SurahDetailViewModel(repository: SurahRepository, surahNumber: Int) : ViewModel() {
val ayahs: StateFlow<List<Ayah>> = repository.isDatabaseReady
.filter { it }
.map { repository.getAyahsForSurah(surahNumber) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
}
data class SearchUiState(val searchQuery: String = "", val searchResults: List<Ayah> = emptyList(), val isLoading: Boolean = false, val isDirectMatch: Boolean = false)
class SearchViewModel(private val surahRepository: SurahRepository) : ViewModel() {
private val _uiState = MutableStateFlow(SearchUiState())
val uiState: StateFlow<SearchUiState> = _uiState.asStateFlow()
private var searchJob: Job? = null
fun onQueryChanged(query: String) {
_uiState.update { it.copy(searchQuery = query, isLoading = query.isNotBlank()) }
executeSearch(query, _uiState.value.isDirectMatch)
}
fun onDirectMatchToggle(enabled: Boolean) {
_uiState.update { it.copy(isDirectMatch = enabled) }
executeSearch(_uiState.value.searchQuery, enabled)
}
private fun executeSearch(query: String, directMatch: Boolean) {
searchJob?.cancel()
if (query.isBlank()) { _uiState.update { it.copy(searchResults = emptyList(), isLoading = false) }; return }
searchJob = viewModelScope.launch {
delay(300)
val results = surahRepository.fastSearch(query, directMatch)
_uiState.update { it.copy(searchResults = results, isLoading = false) }
}
}
}
// ==========================================
// 5. UI: LIST & NAVIGATION DRAWER
// ==========================================
data class DrawerMenuItem(val icon: ImageVector, val label: String, val onClick: () -> Unit, val isSelected: Boolean = false)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SurahListScreen(surahRepository: SurahRepository, navController: NavHostController, settingsManager: AppSettingsManager) {
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope()
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(rememberTopAppBarState())
val context = LocalContext.current
var selectedItemLabel by rememberSaveable { mutableStateOf("Quran Surahs") }
val isDatabaseReady by surahRepository.isDatabaseReady.collectAsStateWithLifecycle()
val surahList by surahRepository.surahList.collectAsStateWithLifecycle()
val lastReadSurah by settingsManager.lastReadSurah.collectAsStateWithLifecycle()
val lastReadAyah by settingsManager.lastReadAyah.collectAsStateWithLifecycle()
val mainItems = listOf(
DrawerMenuItem(Icons.Default.Home, "Quran Surahs", { selectedItemLabel = "Quran Surahs"; scope.launch { drawerState.close() } }, selectedItemLabel == "Quran Surahs"),
DrawerMenuItem(Icons.Default.Bookmark, "Bookmarks", { scope.launch { drawerState.close() }; navController.navigate(AppScreen.Bookmarks.route) }),
DrawerMenuItem(Icons.Default.Search, "Search", { scope.launch { drawerState.close() }; navController.navigate(AppScreen.Search.route) }),
DrawerMenuItem(Icons.Default.Settings, "Settings", { scope.launch { drawerState.close() }; navController.navigate(AppScreen.Settings.route) })
)
val infoItems = listOf(
DrawerMenuItem(Icons.Default.MenuBook, "References & Sources", { scope.launch { drawerState.close() }; navController.navigate(AppScreen.References.route) }),
DrawerMenuItem(Icons.Default.Info, "About App", { scope.launch { drawerState.close() }; navController.navigate(AppScreen.About.route) }),
DrawerMenuItem(Icons.Default.PrivacyTip, "Privacy Policy", { scope.launch { drawerState.close() }; navController.navigate(AppScreen.PrivacyPolicy.route) })
)
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
ModalDrawerSheet(modifier = Modifier.width(300.dp)) {
Column(modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {
Column(modifier = Modifier.fillMaxWidth().padding(24.dp)) {
Surface(shape = CircleShape, color = MaterialTheme.colorScheme.primaryContainer, modifier = Modifier.size(64.dp)) {
Box(contentAlignment = Alignment.Center) { Icon(Icons.AutoMirrored.Filled.LibraryBooks, null, modifier = Modifier.size(32.dp)) }
}
Spacer(Modifier.height(16.dp))
Text("Qafra Af Quran", style = MaterialTheme.typography.titleLarge)
}
HorizontalDivider(modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp))
Column(modifier = Modifier.padding(horizontal = 12.dp)) { mainItems.forEach { item -> DrawerItemView(item) } }
HorizontalDivider(modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp))
Column(modifier = Modifier.padding(horizontal = 12.dp)) {
infoItems.forEach { item -> DrawerItemView(item) }
NavigationDrawerItem(
label = { Text("Share App") }, selected = false,
onClick = {
val sendIntent = Intent(Intent.ACTION_SEND).apply { type = "text/plain"; putExtra(Intent.EXTRA_TEXT, "Check out this Offline Qafra Af Quran app!") }
try { context.startActivity(Intent.createChooser(sendIntent, "Share via")) } catch (e: Exception) { Toast.makeText(context, "No app found to share", Toast.LENGTH_SHORT).show() }
},
icon = { Icon(Icons.Default.Share, null) }, shape = RoundedCornerShape(50), modifier = Modifier.padding(vertical = 4.dp)
)
}
}
}
}
) {
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
TopAppBar(
title = { Text("Qafra Af Quran") },
navigationIcon = { IconButton(onClick = { scope.launch { drawerState.open() } }) { Icon(Icons.Default.Menu, "Menu") } },
actions = { IconButton(onClick = { navController.navigate(AppScreen.Search.route) }) { Icon(Icons.Filled.Search, "Search") } },
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
scrollBehavior = scrollBehavior
)
}
) { paddingValues ->
if (!isDatabaseReady) {
CenteredMessage {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator()
Spacer(Modifier.height(16.dp))
Text("Loading Offline Database...")
}
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(top = paddingValues.calculateTopPadding(), bottom = 16.dp)
) {
if (lastReadSurah > 0) {
item {
val lastSurahInfo = surahList.find { it.number == lastReadSurah }
if (lastSurahInfo != null) {
Card(
modifier = Modifier.fillMaxWidth().padding(16.dp).clickable { navController.navigate(AppScreen.SurahDetail(lastReadSurah, lastReadAyah).route) },
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)
) {
Row(modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text("Continue Reading", style = MaterialTheme.typography.labelSmall)
Text("${lastSurahInfo.englishName}", style = MaterialTheme.typography.titleMedium)
Text("Ayah $lastReadAyah", style = MaterialTheme.typography.bodySmall)
}
Icon(Icons.Default.History, null)
}
}
}
}
}
items(surahList, key = { it.number }) { surah ->
SurahListItem(surah, onClick = { navController.navigate(AppScreen.SurahDetail(surah.number).route) })
HorizontalDivider(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
}
}
}
}
}
}
@Composable
private fun DrawerItemView(item: DrawerMenuItem) {
NavigationDrawerItem(
label = { Text(item.label) }, selected = item.isSelected, onClick = item.onClick,
icon = { Icon(item.icon, null) }, shape = RoundedCornerShape(50), modifier = Modifier.padding(vertical = 4.dp),
colors = NavigationDrawerItemDefaults.colors(selectedContainerColor = MaterialTheme.colorScheme.secondaryContainer)
)
}
// ==========================================
// 6. UI: SURAH DETAIL (AYAH LIST)
// ==========================================
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SurahDetailScreen(surahInfo: SurahInfo, viewModel: SurahDetailViewModel, navController: NavHostController, targetAyahNumber: Int, settingsManager: AppSettingsManager) {
val ayahs by viewModel.ayahs.collectAsStateWithLifecycle()
val fontSize by settingsManager.currentFontSize.collectAsStateWithLifecycle()
val bookmarks by settingsManager.bookmarks.collectAsStateWithLifecycle()
val coroutineScope = rememberCoroutineScope()
val context = LocalContext.current
val listState = rememberLazyListState()
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(rememberTopAppBarState())
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }.distinctUntilChanged().debounce(2000).collect { index ->
if (ayahs.isNotEmpty() && index < ayahs.size) {
settingsManager.saveLastRead(surahInfo.number, ayahs[index].ayahNumber)
}
}
}
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
TopAppBar(
title = { Text(surahInfo.arabicName, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center) },
navigationIcon = { IconButton(onClick = { navController.popBackStack() }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } },
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
scrollBehavior = scrollBehavior
)
}
) { paddingValues ->
Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
if (ayahs.isEmpty()) {
CenteredMessage { CircularProgressIndicator() }
} else {
LaunchedEffect(targetAyahNumber) { if (targetAyahNumber > 0) { val index = ayahs.indexOfFirst { it.ayahNumber == targetAyahNumber }; if (index != -1) listState.scrollToItem(index) } }
LazyColumn(state = listState, modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp)) {
if (surahInfo.number != 1 && surahInfo.number != 9) {
item {
Text("بِسْمِ ٱللَّهِ ٱلرَّحْمَـٰنِ ٱلرَّحِيمِ", style = TextStyle(fontSize = 28.sp * fontSize.scaleFactor, textAlign = TextAlign.Center), modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp))
HorizontalDivider(modifier = Modifier.padding(bottom = 8.dp))
}
}
items(ayahs, key = { it.id }) { ayah ->
val isBookmarked = bookmarks.contains("${ayah.surahNumber}:${ayah.ayahNumber}")
AyahListItemCard(
ayah = ayah,
fontSizeScaleFactor = fontSize.scaleFactor,
isBookmarked = isBookmarked,
onBookmarkClick = {
coroutineScope.launch {
if (isBookmarked) settingsManager.removeBookmark(ayah.surahNumber, ayah.ayahNumber) else settingsManager.addBookmark(ayah.surahNumber, ayah.ayahNumber)
}
},
onShareClick = {
val sendIntent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, "${surahInfo.englishName} ${ayah.surahNumber}:${ayah.ayahNumber}\n\n${ayah.arabicText}\n\n${ayah.translationText}\n\n- Shared via Qafra Af Quran Offline")
}
try { context.startActivity(Intent.createChooser(sendIntent, "Share Ayah")) } catch (e: Exception) { Toast.makeText(context, "No app found to share", Toast.LENGTH_SHORT).show() }
}
)
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
}
}
}
}
}
}
// ==========================================
// 7. UI: COMPONENTS & SETTINGS
// ==========================================
@Composable
private fun SurahListItem(surahInfo: SurahInfo, onClick: () -> Unit) {
Card(modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow), elevation = CardDefaults.cardElevation(0.dp)) {
Row(modifier = Modifier.padding(16.dp, 12.dp).fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Surface(shape = CircleShape, color = MaterialTheme.colorScheme.primaryContainer, modifier = Modifier.size(42.dp)) {
Box(contentAlignment = Alignment.Center) { Text("${surahInfo.number}", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onPrimaryContainer) }
}
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(surahInfo.englishName, style = MaterialTheme.typography.titleMedium)
Text("${surahInfo.numberOfAyahs} Ayahs", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Text(surahInfo.arabicName, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface)
}
}
}
@Composable
private fun AyahListItemCard(ayah: Ayah, fontSizeScaleFactor: Float, isBookmarked: Boolean = false, onBookmarkClick: (() -> Unit)? = null, onShareClick: (() -> Unit)? = null) {
val arabicStyle = remember(fontSizeScaleFactor) { TextStyle(fontSize = 24.sp * fontSizeScaleFactor, lineHeight = 32.sp * fontSizeScaleFactor * 1.3f, textAlign = TextAlign.Right) }
val transStyle = remember(fontSizeScaleFactor) { TextStyle(fontSize = 16.sp * fontSizeScaleFactor, lineHeight = 24.sp * fontSizeScaleFactor * 1.2f, textAlign = TextAlign.Left) }
Card(colors = CardDefaults.cardColors(Color.Transparent)) {
SelectionContainer {
Column(modifier = Modifier.padding(vertical = 8.dp).fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Row {
if (onBookmarkClick != null) IconButton(onClick = onBookmarkClick, modifier = Modifier.size(32.dp)) { Icon(if (isBookmarked) Icons.Default.Bookmark else Icons.Default.BookmarkBorder, null, tint = if(isBookmarked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline, modifier = Modifier.size(20.dp)) }
if (onShareClick != null) IconButton(onClick = onShareClick, modifier = Modifier.size(32.dp)) { Icon(Icons.Default.Share, null, tint = MaterialTheme.colorScheme.outline, modifier = Modifier.size(20.dp)) }
}
Text("${ayah.surahNumber}:${ayah.ayahNumber}", style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp * fontSizeScaleFactor), color = MaterialTheme.colorScheme.onSurfaceVariant)
}
if (ayah.arabicText.isNotEmpty()) Text(ayah.arabicText, style = arabicStyle, color = MaterialTheme.colorScheme.primary, modifier = Modifier.fillMaxWidth())
Text(ayah.translationText, style = transStyle, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.fillMaxWidth())
if (ayah.footnotes.isNotEmpty()) {
Surface(color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(8.dp)) {
Text(text = "Footnote: ${ayah.footnotes}", modifier = Modifier.padding(8.dp), fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SearchScreen(navController: NavHostController, viewModel: SearchViewModel) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
topBar = {
Column {
TopAppBar(
title = {
TextField(
value = uiState.searchQuery, onValueChange = viewModel::onQueryChanged, placeholder = { Text("Search Offline Quran...") },
modifier = Modifier.fillMaxWidth(), singleLine = true, leadingIcon = { Icon(Icons.Filled.Search, null) },
colors = TextFieldDefaults.colors(focusedContainerColor = MaterialTheme.colorScheme.surfaceContainer, unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainer, focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent)
)
},
navigationIcon = { IconButton(onClick = { navController.popBackStack() }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } },
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surfaceContainer)
)
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().clickable { viewModel.onDirectMatchToggle(!uiState.isDirectMatch) }.padding(horizontal = 16.dp, vertical = 4.dp)) {
Checkbox(checked = uiState.isDirectMatch, onCheckedChange = { viewModel.onDirectMatchToggle(it) })
Spacer(Modifier.width(8.dp))
Text("Direct Match", style = MaterialTheme.typography.bodyMedium)
}
}
}
) { paddingValues ->
Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
when {
uiState.isLoading -> CenteredMessage { CircularProgressIndicator() }
uiState.searchQuery.isBlank() -> CenteredMessage { Text("Start typing to search the entire offline Quran") }
uiState.searchResults.isEmpty() -> CenteredMessage { Text("No results found") }
else -> {
LazyColumn(contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp)) {
items(uiState.searchResults, key = { "search_${it.id}" }) { ayah ->
SearchAyahListItem(ayah, "Surah ${ayah.surahNumber}", uiState.searchQuery, { navController.navigate(AppScreen.SurahDetail(ayah.surahNumber, ayah.ayahNumber).route) }, 1.0f)
HorizontalDivider()
}
}
}
}
}
}
}
@Composable private fun SearchAyahListItem(ayah: Ayah, surahName: String, searchQuery: String, onAyahClick: () -> Unit, fontSizeScaleFactor: Float) {
val arabicStyle = remember(fontSizeScaleFactor) { TextStyle(fontSize = 20.sp * fontSizeScaleFactor) }
val transStyle = remember(fontSizeScaleFactor) { TextStyle(fontSize = 14.sp * fontSizeScaleFactor) }
Card(modifier = Modifier.fillMaxWidth().clickable(onClick = onAyahClick).padding(vertical = 8.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer)) {
Column(modifier = Modifier.padding(16.dp)) {
Text("$surahName (${ayah.surahNumber}:${ayah.ayahNumber})", color = MaterialTheme.colorScheme.primary)
HighlightedText(ayah.arabicText, searchQuery, Color.Yellow, arabicStyle, TextAlign.Right, Modifier.fillMaxWidth())
HighlightedText(ayah.translationText, searchQuery, Color.Yellow, transStyle, TextAlign.Left, Modifier.fillMaxWidth())
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun BookmarksScreen(navController: NavHostController, settingsManager: AppSettingsManager, repository: SurahRepository) {
val bookmarks by settingsManager.bookmarks.collectAsStateWithLifecycle()
val fontSize by settingsManager.currentFontSize.collectAsStateWithLifecycle()
var bookmarkedAyahs by remember { mutableStateOf<List<Ayah>>(emptyList()) }
LaunchedEffect(bookmarks) {
val list = mutableListOf<Ayah>()
bookmarks.forEach {
val parts = it.split(":")
if(parts.size == 2) repository.getAyah(parts[0].toInt(), parts[1].toInt())?.let { ayah -> list.add(ayah) }
}
bookmarkedAyahs = list.sortedBy { it.surahNumber * 1000 + it.ayahNumber }
}
Scaffold(topBar = { TopAppBar(title = { Text("Bookmarks") }, navigationIcon = { IconButton(onClick = { navController.popBackStack() }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } }) }) {
if (bookmarkedAyahs.isEmpty()) CenteredMessage { Text("No bookmarks yet") }
else LazyColumn(modifier = Modifier.padding(it)) {
items(bookmarkedAyahs) { ayah -> AyahListItemCard(ayah, fontSize.scaleFactor) }
}
}
}
// Settings, About, References, Privacy
@OptIn(ExperimentalMaterial3Api::class)
@Composable private fun SettingsScreen(navController: NavHostController) {
val context = LocalContext.current.applicationContext; val coroutineScope = rememberCoroutineScope()
val settingsManager = remember { AppSettingsManager(context) }
val currentFontSize by settingsManager.currentFontSize.collectAsStateWithLifecycle(); val currentTheme by settingsManager.currentTheme.collectAsStateWithLifecycle()
Scaffold(topBar = { TopAppBar(title = { Text("Settings") }, navigationIcon = { IconButton(onClick = { navController.popBackStack() }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } }) }) { paddingValues ->
Column(modifier = Modifier.padding(paddingValues).fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
Text("App Theme", style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.primary)
AppTheme.entries.forEach { theme -> Row(Modifier.fillMaxWidth().clickable { coroutineScope.launch { settingsManager.saveTheme(theme) } }.padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) { RadioButton(selected = currentTheme == theme, onClick = null); Spacer(Modifier.width(16.dp)); Text(theme.name) } }
HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
Text("Reading Font Size", style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.primary)
FontSize.entries.forEach { size -> Row(Modifier.fillMaxWidth().clickable { coroutineScope.launch { settingsManager.saveFontSize(size) } }.padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) { RadioButton(selected = currentFontSize == size, onClick = null); Spacer(Modifier.width(16.dp)); Text(size.name) } }
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable private fun AboutScreen(navController: NavHostController) {
Scaffold(topBar = { TopAppBar(title = { Text("About") }, navigationIcon = { IconButton(onClick = { navController.popBackStack() }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } }) }) {
Column(modifier = Modifier.padding(it).padding(16.dp)) {
Text("Qafra Af Quran App", style = MaterialTheme.typography.headlineMedium)
Text("Developed for reading the Holy Quran with Afar (Hamza) translation provided entirely offline.")
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable private fun ReferencesScreen(navController: NavHostController) {
Scaffold(topBar = { TopAppBar(title = { Text("References") }, navigationIcon = { IconButton(onClick = { navController.popBackStack() }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } }) }) {
Column(modifier = Modifier.padding(it).padding(16.dp)) {
Text("Translation Text Source: Quranenc.com", color = MaterialTheme.colorScheme.primary)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable private fun PrivacyPolicyScreen(navController: NavHostController) {
Scaffold(topBar = { TopAppBar(title = { Text("Privacy Policy") }, navigationIcon = { IconButton(onClick = { navController.popBackStack() }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } }) }) {
Column(modifier = Modifier.padding(it).padding(16.dp)) {
Text("This app works 100% offline. No user data is tracked, transmitted, or collected. Preferences are saved locally on your device.")
}
}
}
// Utils
@Composable private fun CenteredMessage(content: @Composable () -> Unit) { Box(modifier = Modifier.fillMaxSize().padding(16.dp), contentAlignment = Alignment.Center) { content() } }
private fun normalizeText(text: String): String = text.replace(Regex("[\\u064B-\\u0655\\u0670\\u0640]"), "") // Basic Arabic diacritics removal for search
@Composable private fun HighlightedText(text: String, query: String, highlightColor: Color, style: TextStyle, textAlign: TextAlign, modifier: Modifier) {
if (query.isBlank()) { Text(text, style = style, textAlign = textAlign, modifier = modifier); return }
val annotated = buildAnnotatedString {
val pattern = Regex(Regex.escape(query), RegexOption.IGNORE_CASE)
var idx = 0
pattern.findAll(text).forEach { m ->
if (m.range.first > idx) append(text.substring(idx, m.range.first))
withStyle(SpanStyle(background = highlightColor, color = Color.Black)) { append(text.substring(m.range)) }
idx = m.range.last + 1
}
if (idx < text.length) append(text.substring(idx))
}
Text(annotated, style = style, textAlign = textAlign, modifier = modifier)
}
// DataStore Settings
enum class FontSize(val scaleFactor: Float) { SMALL(0.8f), MEDIUM(1.0f), LARGE(1.3f), VERY_LARGE(1.6f) }
enum class AppTheme { SYSTEM, LIGHT, DARK, SEPIA, GREEN, OLED }
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "qafra_app_settings")
class AppSettingsManager(context: Context) {
private val dataStore = context.dataStore
val currentFontSize = dataStore.data.map { try { FontSize.valueOf(it[stringPreferencesKey("font_size")] ?: "MEDIUM") } catch(e:Exception){ FontSize.MEDIUM } }.stateIn(CoroutineScope(Dispatchers.IO), SharingStarted.WhileSubscribed(5000), FontSize.MEDIUM)
val currentTheme = dataStore.data.map { try { AppTheme.valueOf(it[stringPreferencesKey("app_theme")] ?: "SYSTEM") } catch(e:Exception){ AppTheme.SYSTEM } }.stateIn(CoroutineScope(Dispatchers.IO), SharingStarted.WhileSubscribed(5000), AppTheme.SYSTEM)
val lastReadSurah = dataStore.data.map { it[intPreferencesKey("last_read_surah")] ?: -1 }.stateIn(CoroutineScope(Dispatchers.IO), SharingStarted.WhileSubscribed(5000), -1)
val lastReadAyah = dataStore.data.map { it[intPreferencesKey("last_read_ayah")] ?: -1 }.stateIn(CoroutineScope(Dispatchers.IO), SharingStarted.WhileSubscribed(5000), -1)
val bookmarks = dataStore.data.map { it[stringSetPreferencesKey("bookmarks")] ?: emptySet() }.stateIn(CoroutineScope(Dispatchers.IO), SharingStarted.WhileSubscribed(5000), emptySet())
suspend fun saveFontSize(s: FontSize) { dataStore.edit { it[stringPreferencesKey("font_size")] = s.name } }
suspend fun saveTheme(t: AppTheme) { dataStore.edit { it[stringPreferencesKey("app_theme")] = t.name } }
suspend fun saveLastRead(surah: Int, ayah: Int) { dataStore.edit { it[intPreferencesKey("last_read_surah")] = surah; it[intPreferencesKey("last_read_ayah")] = ayah } }
suspend fun addBookmark(surah: Int, ayah: Int) { dataStore.edit { prefs -> val current = prefs[stringSetPreferencesKey("bookmarks")] ?: emptySet(); prefs[stringSetPreferencesKey("bookmarks")] = current + "$surah:$ayah" } }
suspend fun removeBookmark(surah: Int, ayah: Int) { dataStore.edit { prefs -> val current = prefs[stringSetPreferencesKey("bookmarks")] ?: emptySet(); prefs[stringSetPreferencesKey("bookmarks")] = current - "$surah:$ayah" } }
}
@Composable fun QuranAppTheme(appTheme: AppTheme = AppTheme.SYSTEM, content: @Composable () -> Unit) {
val context = LocalContext.current
val colorScheme = when (appTheme) {
AppTheme.SEPIA -> lightColorScheme(primary = Color(0xFF3E2723), onPrimary = Color.White, background = Color(0xFFF4ECD8), surface = Color(0xFFF4ECD8), surfaceContainer = Color(0xFFEBE0C5))
AppTheme.GREEN -> lightColorScheme(primary = Color(0xFF1B5E20), onPrimary = Color.White, background = Color(0xFFF1F8E9), surface = Color(0xFFF1F8E9), surfaceContainer = Color(0xFFDCEDC8))
AppTheme.OLED -> darkColorScheme(primary = Color(0xFFFFD54F), onPrimary = Color.Black, background = Color.Black, surface = Color.Black, surfaceContainer = Color(0xFF121212))
AppTheme.DARK -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) dynamicDarkColorScheme(context) else darkColorScheme(primary = Color(0xFFFFD54F))
AppTheme.LIGHT -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) dynamicLightColorScheme(context) else lightColorScheme()
AppTheme.SYSTEM -> if (isSystemInDarkTheme()) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) dynamicDarkColorScheme(context) else darkColorScheme(primary = Color(0xFFFFD54F)) } else { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) dynamicLightColorScheme(context) else lightColorScheme() }
}
MaterialTheme(colorScheme = colorScheme, content = content)
}
// Full 114 Surahs Static List for UI
private fun getAllSurahs(): List<SurahInfo> = listOf(
SurahInfo(1, "Al-Fatihah", "الفاتحة", 7), SurahInfo(2, "Al-Baqarah", "البقرة", 286), SurahInfo(3, "Ali 'Imran", "آل عمران", 200),
SurahInfo(4, "An-Nisa", "النساء", 176), SurahInfo(5, "Al-Ma'idah", "المائدة", 120), SurahInfo(6, "Al-An'am", "الأنعام", 165),
SurahInfo(7, "Al-A'raf", "الأعراف", 206), SurahInfo(8, "Al-Anfal", "الأنفال", 75), SurahInfo(9, "At-Tawbah", "التوبة", 129),
SurahInfo(10, "Yunus", "يونس", 109), SurahInfo(11, "Hud", "هود", 123), SurahInfo(12, "Yusuf", "يوسف", 111),
SurahInfo(13, "Ar-Ra'd", "الرعد", 43), SurahInfo(14, "Ibrahim", "إبراهيم", 52), SurahInfo(15, "Al-Hijr", "الحجر", 99),
SurahInfo(16, "An-Nahl", "النحل", 128), SurahInfo(17, "Al-Isra", "الإسراء", 111), SurahInfo(18, "Al-Kahf", "الكهف", 110),
SurahInfo(19, "Maryam", "مريم", 98), SurahInfo(20, "Ta-Ha", "طه", 135), SurahInfo(21, "Al-Anbiya", "الأنبياء", 112),
SurahInfo(22, "Al-Hajj", "الحج", 78), SurahInfo(23, "Al-Mu'minun", "المؤمنون", 118), SurahInfo(24, "An-Nur", "النور", 64),
SurahInfo(25, "Al-Furqan", "الفرقان", 77), SurahInfo(26, "Ash-Shu'ara", "الشعراء", 227), SurahInfo(27, "An-Naml", "النمل", 93),
SurahInfo(28, "Al-Qasas", "القصص", 88), SurahInfo(29, "Al-'Ankabut", "العنكبوت", 69), SurahInfo(30, "Ar-Rum", "الروم", 60),
SurahInfo(31, "Luqman", "لقمان", 34), SurahInfo(32, "As-Sajdah", "السجدة", 30), SurahInfo(33, "Al-Ahzab", "الأحزاب", 73),
SurahInfo(34, "Saba", "سبأ", 54), SurahInfo(35, "Fatir", "فاطر", 45), SurahInfo(36, "Ya-Sin", "يس", 83),
SurahInfo(37, "As-Saffat", "الصافات", 182), SurahInfo(38, "Sad", "ص", 88), SurahInfo(39, "Az-Zumar", "الزمر", 75),
SurahInfo(40, "Ghafir", "غافر", 85), SurahInfo(41, "Fussilat", "فصلت", 54), SurahInfo(42, "Ash-Shura", "الشورى", 53),
SurahInfo(43, "Az-Zukhruf", "الزخرف", 89), SurahInfo(44, "Ad-Dukhan", "الدخان", 59), SurahInfo(45, "Al-Jathiyah", "الجاثية", 37),
SurahInfo(46, "Al-Ahqaf", "الأحقاف", 35), SurahInfo(47, "Muhammad", "محمد", 38), SurahInfo(48, "Al-Fath", "الفتح", 29),
SurahInfo(49, "Al-Hujurat", "الحجرات", 18), SurahInfo(50, "Qaf", "ق", 45), SurahInfo(51, "Ad-Dhariyat", "الذاريات", 60),
SurahInfo(52, "At-Tur", "الطور", 49), SurahInfo(53, "An-Najm", "النجم", 62), SurahInfo(54, "Al-Qamar", "القمر", 55),
SurahInfo(55, "Ar-Rahman", "الرحمن", 78), SurahInfo(56, "Al-Waqi'ah", "الواقعة", 96), SurahInfo(57, "Al-Hadid", "الحديد", 29),
SurahInfo(58, "Al-Mujadila", "المجادلة", 22), SurahInfo(59, "Al-Hashr", "الحشر", 24), SurahInfo(60, "Al-Mumtahanah", "الممتحنة", 13),
SurahInfo(61, "As-Saff", "الصف", 14), SurahInfo(62, "Al-Jumu'ah", "الجمعة", 11), SurahInfo(63, "Al-Munafiqun", "المنافقون", 11),
SurahInfo(64, "At-Taghabun", "التغابن", 18), SurahInfo(65, "At-Talaq", "الطلاق", 12), SurahInfo(66, "At-Tahrim", "التحريم", 12),
SurahInfo(67, "Al-Mulk", "الملك", 30), SurahInfo(68, "Al-Qalam", "القلم", 52), SurahInfo(69, "Al-Haqqah", "الحاقة", 52),
SurahInfo(70, "Al-Ma'arij", "المعارج", 44), SurahInfo(71, "Nuh", "نوح", 28), SurahInfo(72, "Al-Jinn", "الجن", 28),
SurahInfo(73, "Al-Muzzammil", "المزمل", 20), SurahInfo(74, "Al-Muddaththir", "المدثر", 56), SurahInfo(75, "Al-Qiyamah", "القيامة", 40),
SurahInfo(76, "Al-Insan", "الإنسان", 31), SurahInfo(77, "Al-Mursalat", "المرسلات", 50), SurahInfo(78, "An-Naba", "النبأ", 40),
SurahInfo(79, "An-Nazi'at", "النازعات", 46), SurahInfo(80, "'Abasa", "عبس", 42), SurahInfo(81, "At-Takwir", "التكوير", 29),
SurahInfo(82, "Al-Infitar", "الإنفطار", 19), SurahInfo(83, "Al-Mutaffifin", "المطففين", 36), SurahInfo(84, "Al-Inshiqaq", "الإنشقاق", 25),
SurahInfo(85, "Al-Buruj", "البروج", 22), SurahInfo(86, "At-Tariq", "الطارق", 17), SurahInfo(87, "Al-A'la", "الأعلى", 19),
SurahInfo(88, "Al-Ghashiyah", "الغاشية", 26), SurahInfo(89, "Al-Fajr", "الفجر", 30), SurahInfo(90, "Al-Balad", "البلد", 20),
SurahInfo(91, "Ash-Shams", "الشمس", 15), SurahInfo(92, "Al-Layl", "الليل", 21), SurahInfo(93, "Ad-Duhaa", "الضحى", 11),
SurahInfo(94, "Ash-Sharh", "الشرح", 8), SurahInfo(95, "At-Tin", "التين", 8), SurahInfo(96, "Al-'Alaq", "العلق", 19),
SurahInfo(97, "Al-Qadr", "القدر", 5), SurahInfo(98, "Al-Bayyinah", "البينة", 8), SurahInfo(99, "Az-Zalzalah", "الزلزلة", 8),
SurahInfo(100, "Al-'Adiyat", "العاديات", 11), SurahInfo(101, "Al-Qari'ah", "القارعة", 11), SurahInfo(102, "At-Takathur", "التكاثر", 8),
SurahInfo(103, "Al-'Asr", "العصر", 3), SurahInfo(104, "Al-Humazah", "الهمزة", 9), SurahInfo(105, "Al-Fil", "الفيل", 5),
SurahInfo(106, "Quraysh", "قريش", 4), SurahInfo(107, "Al-Ma'un", "الماعون", 7), SurahInfo(108, "Al-Kawthar", "الكوثر", 3),
SurahInfo(109, "Al-Kafirun", "الكافرون", 6), SurahInfo(110, "An-Nasr", "النصر", 3), SurahInfo(111, "Al-Masad", "المسد", 5),
SurahInfo(112, "Al-Ikhlas", "الإخلاص", 4), SurahInfo(113, "Al-Falaq", "الفلق", 5), SurahInfo(114, "An-Nas", "الناس", 6)
)