diff --git a/app/src/main/java/io/github/gmathi/novellibrary/activity/NovelDetailsActivity.kt b/app/src/main/java/io/github/gmathi/novellibrary/activity/NovelDetailsActivity.kt index 738939ac..57d33016 100644 --- a/app/src/main/java/io/github/gmathi/novellibrary/activity/NovelDetailsActivity.kt +++ b/app/src/main/java/io/github/gmathi/novellibrary/activity/NovelDetailsActivity.kt @@ -1,411 +1,145 @@ package io.github.gmathi.novellibrary.activity import android.app.Activity -import android.annotation.SuppressLint import android.content.Intent -import android.os.Build import android.os.Bundle -import android.text.Html -import android.text.Spannable -import android.text.SpannableString -import android.text.TextPaint -import android.text.method.LinkMovementMethod -import android.text.style.ClickableSpan -import android.view.Menu -import android.view.MenuItem -import android.view.View -import android.widget.LinearLayout -import android.widget.TextView -import android.widget.Toast +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts -import androidx.core.content.ContextCompat +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ActivityOptionsCompat import androidx.lifecycle.lifecycleScope -import com.afollestad.materialdialogs.MaterialDialog -import com.afollestad.materialdialogs.lifecycle.lifecycleOwner -import com.bumptech.glide.Glide -import com.google.gson.Gson -import com.google.gson.reflect.TypeToken import io.github.gmathi.novellibrary.R -import io.github.gmathi.novellibrary.database.* -import io.github.gmathi.novellibrary.databinding.ActivityNovelDetailsBinding -import io.github.gmathi.novellibrary.databinding.ContentNovelDetailsBinding -import io.github.gmathi.novellibrary.extensions.* +import io.github.gmathi.novellibrary.activity.CloudflareResolverActivity +import io.github.gmathi.novellibrary.compose.noveldetails.NovelDetailsScreen +import io.github.gmathi.novellibrary.compose.theme.NovelLibraryTheme import io.github.gmathi.novellibrary.model.database.Novel import io.github.gmathi.novellibrary.network.HostNames -import io.github.gmathi.novellibrary.util.* -import io.github.gmathi.novellibrary.util.error.Exceptions -import io.github.gmathi.novellibrary.util.logging.Logs -import io.github.gmathi.novellibrary.util.lang.getGlideUrl -import io.github.gmathi.novellibrary.util.system.* -import io.github.gmathi.novellibrary.util.view.* -import io.github.gmathi.novellibrary.util.view.extensions.applyFont -import kotlinx.coroutines.Dispatchers +import io.github.gmathi.novellibrary.util.Constants +import io.github.gmathi.novellibrary.util.Utils +import io.github.gmathi.novellibrary.util.system.openInBrowser +import io.github.gmathi.novellibrary.util.system.startChaptersActivity +import io.github.gmathi.novellibrary.util.system.startMetadataActivity +import io.github.gmathi.novellibrary.util.system.startSearchResultsActivity +import io.github.gmathi.novellibrary.util.system.toast +import io.github.gmathi.novellibrary.viewmodel.NovelDetailsEvent +import io.github.gmathi.novellibrary.viewmodel.NovelDetailsViewModel import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlin.math.min - -class NovelDetailsActivity : BaseActivity(), TextViewLinkHandler.OnClickListener { +class NovelDetailsActivity : AppCompatActivity() { companion object { const val TAG = "NovelDetailsActivity" } - lateinit var novel: Novel - - private lateinit var binding: ActivityNovelDetailsBinding - private lateinit var contentBinding: ContentNovelDetailsBinding - - private var retryCounter = 0 + private val viewModel: NovelDetailsViewModel by viewModels() private val cloudflareResolverLauncher = registerForActivityResult( ActivityResultContracts.StartActivityForResult() ) { result -> if (result.resultCode == Activity.RESULT_OK) { - // Cookies saved from manual Cloudflare resolution — retry loading - retryCounter = 0 - getNovelInfo() + viewModel.onCloudflareResolved() } } - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - binding = ActivityNovelDetailsBinding.inflate(layoutInflater) - setContentView(binding.root) - contentBinding = binding.contentNovelDetails - - //Get Novel from intent - novel = intent.getParcelableExtra("novel") as Novel - - setSupportActionBar(binding.toolbar) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - supportActionBar?.title = novel.name - - //check for novel in database - dbHelper.getNovelByUrl(novel.url)?.let { novel.id = it.id } - - if (novel.id != -1L && !networkHelper.isConnectedToNetwork()) { - setupViews() - } else { - getNovelInfo() + private val chaptersLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == Constants.OPEN_DOWNLOADS_RES_CODE) { + setResult(result.resultCode) + finish() + return@registerForActivityResult } - - contentBinding.swipeRefreshLayout.setOnRefreshListener { getNovelInfo() } + // Refresh novel state in case it was added to library from the chapters screen + val novel = intent.getParcelableExtra("novel") as? Novel ?: return@registerForActivityResult + viewModel.onNovelAddedFromChapters(novel.url) } - private fun getNovelInfo() { - contentBinding.progressLayout.showLoading() - - //Check for network - if (!networkHelper.isConnectedToNetwork()) { - contentBinding.swipeRefreshLayout.isRefreshing = false + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() - if (novel.id == -1L) { //If novel not present in library -> show error screen - contentBinding.progressLayout.noInternetError { - getNovelInfo() - } - } else { // else just show a toast - toast(R.string.no_internet) - } + val novel = intent.getParcelableExtra("novel") as? Novel + if (novel == null) { + finish() return } - //Download novel details - lifecycleScope.launch { - try { - val source = sourceManager.get(novel.sourceId) ?: throw Exception(Exceptions.MISSING_SOURCE_ID) - val totalStartTime = System.currentTimeMillis() - novel = withContext(Dispatchers.IO) { source.getNovelDetails(novel) } - val totalElapsed = System.currentTimeMillis() - totalStartTime - Logs.info(TAG, "⏱ [NovelDetails] Total load time for '${novel.name}': ${totalElapsed}ms") - - //Update the novel in library with the new info - if (novel.id != -1L) withContext(Dispatchers.IO) { dbHelper.updateNovel(novel) } - addNovelToHistory() - setupViews() - contentBinding.swipeRefreshLayout.isRefreshing = false - contentBinding.progressLayout.showContent() - retryCounter = 0 - } catch (e: Exception) { - if (e.message?.contains(Exceptions.MISSING_SOURCE_ID) == true) { - contentBinding.progressLayout.showError(errorText = "Missing Novel Source Id.\nPlease re-add the novel.", buttonText = "Delete Novel") { - deleteNovel() - finish() - } - return@launch - } - - if (e.message?.contains(getString(R.string.information_cloudflare_bypass_failure)) == true || e.message?.contains("HTTP error 503") == true && retryCounter < 2) { - retryCounter++ - showCloudflareResolverDialog() - return@launch - } + viewModel.init(novel) - //Copy the error to clipboard - if (!isDestroyed && !isFinishing) Utils.copyErrorToClipboard(e, this@NovelDetailsActivity) - - if (novel.id == -1L) { - contentBinding.progressLayout.showError(errorText = getString(R.string.failed_to_load_url), buttonText = getString(R.string.try_again)) { - contentBinding.progressLayout.showLoading() - getNovelInfo() - } - } - contentBinding.swipeRefreshLayout.isRefreshing = false - } - } - } - - private fun showCloudflareResolverDialog() { - if (isDestroyed || isFinishing) return - MaterialDialog(this).show { - lifecycleOwner(this@NovelDetailsActivity) - title(text = "Cloudflare Verification Required") - message(text = "The server requires Cloudflare verification. You can solve it manually in a browser, then we'll retry automatically.") - positiveButton(text = "Resolve Manually") { - val url = "https://${HostNames.NOVEL_UPDATES}" - val intent = CloudflareResolverActivity.createIntent(this@NovelDetailsActivity, url) - cloudflareResolverLauncher.launch(intent) - } - negativeButton(text = "Retry") { - getNovelInfo() - } - } - } - - @SuppressLint("SetTextI18n") - private fun setupViews() { - setNovelImage() - - contentBinding.novelDetailsName.applyFont(assets).text = novel.name - contentBinding.novelDetailsName.isSelected = dataCenter.enableScrollingText - - val listener: View.OnClickListener = View.OnClickListener { - MaterialDialog(this).show { - title(text = "Novel Name") - message(text = novel.name) - - lifecycleOwner(this@NovelDetailsActivity) + lifecycleScope.launch { + viewModel.events.collect { event -> + handleEvent(event) } } - contentBinding.novelDetailsName.setOnClickListener(listener) - contentBinding.novelDetailsNameInfo.setOnClickListener(listener) - - setNovelAuthor() - - contentBinding.novelDetailsStatus.applyFont(assets).text = "N/A" - if (novel.metadata["Year"] != null) contentBinding.novelDetailsStatus.applyFont(assets).text = novel.metadata["Year"] - - setLicensingInfo() - setNovelRating() - setNovelAddToLibraryButton() - setNovelGenre() - setNovelDescription() - - val chaptersCountText = novel.chaptersCount.takeIf { it > 0L }?.toString() - ?: novel.metadata["Chapters"]?.takeIf { it.isNotBlank() } - contentBinding.novelDetailsChapters.text = if (chaptersCountText != null) - getString(R.string.chapters) + " ($chaptersCountText)" - else - getString(R.string.chapters) - contentBinding.novelDetailsChaptersLayout.setOnClickListener { - startChaptersActivity(novel, false) - } - contentBinding.novelDetailsMetadataLayout.setOnClickListener { startMetadataActivity(novel) } - contentBinding.openInBrowserButton.setOnClickListener { openInBrowser(novel.url) } - } - private fun setNovelImage() { - if (!novel.imageUrl.isNullOrBlank()) { - Glide.with(this).load(novel.imageUrl?.getGlideUrl()).into(contentBinding.novelDetailsImage) - contentBinding.novelDetailsImage.setOnClickListener { - startImagePreviewActivity( - novel.imageUrl, - novel.imageFilePath, - contentBinding.novelDetailsImage + setContent { + NovelLibraryTheme { + NovelDetailsScreen( + viewModel = viewModel, + onBackClick = { finish() }, + onImageClick = { viewModel.onImageClick() }, + onChaptersClick = { viewModel.onChaptersClick() }, + onMetadataClick = { viewModel.onMetadataClick() }, + onOpenInBrowser = { viewModel.onOpenInBrowser() }, + onAuthorLinkClick = { title, url -> viewModel.onAuthorLinkClick(title, url) }, + onDeleteConfirmed = { viewModel.deleteFromLibrary() }, + onShareClick = { viewModel.onShareUrl() } ) } } } - private fun setNovelAuthor() { - val author = novel.metadata["Author(s)"] - if (author != null) { - contentBinding.novelDetailsAuthor.movementMethod = TextViewLinkHandler(this) - contentBinding.novelDetailsAuthor.applyFont(assets).text = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) Html.fromHtml(author, Html.FROM_HTML_MODE_LEGACY) else Html.fromHtml(author) - return - } - val authors = novel.authors?.joinToString { "," } ?: return - contentBinding.novelDetailsAuthor.movementMethod = TextViewLinkHandler(this) - contentBinding.novelDetailsAuthor.applyFont(assets).text = authors - } - - private fun setLicensingInfo() { - var publisher = novel.metadata["English Publisher"] ?: "" - val isLicensed = novel.metadata["Licensed (in English)"] == "Yes" - if (publisher != "" || isLicensed) { - if (publisher.isEmpty()) publisher = "an unknown publisher" - val warningLabel = getString(R.string.licensed_warning, publisher) - contentBinding.novelDetailsLicensedAlert.movementMethod = TextViewLinkHandler(this) - contentBinding.novelDetailsLicensedAlert.text = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) Html.fromHtml(warningLabel, Html.FROM_HTML_MODE_LEGACY) else Html.fromHtml(warningLabel) - contentBinding.novelDetailsLicensedLayout.visibility = View.VISIBLE - } else { - contentBinding.novelDetailsLicensedLayout.visibility = View.GONE - } - } - - private fun setNovelRating() { - if (!novel.rating.isNullOrBlank()) { - var ratingText = "(N/A)" - try { - val rating = novel.rating!!.replace(",", ".").toFloat() - contentBinding.novelDetailsRatingBar.rating = rating - ratingText = "(" + String.format("%.1f", rating) + ")" - } catch (e: Exception) { - Logs.warning("NovelDetailsActivity", "Rating: ${novel.rating}, Novel: ${novel.name}", e) - } - contentBinding.novelDetailsRatingText.text = ratingText - } - } - - private fun setNovelAddToLibraryButton() { - if (novel.id == -1L) { - resetAddToLibraryButton() - contentBinding.novelDetailAddToLibraryButton.setOnClickListener { - disableAddToLibraryButton() - addNewNovel(novel) - } - } else disableAddToLibraryButton() - } - - private fun resetAddToLibraryButton() { - contentBinding.novelDetailAddToLibraryButton.setText(getString(R.string.add_to_library)) - contentBinding.novelDetailAddToLibraryButton.setIconResource(R.drawable.ic_library_add_white_vector) - contentBinding.novelDetailAddToLibraryButton.setBackgroundColor(ContextCompat.getColor(this@NovelDetailsActivity, android.R.color.transparent)) - contentBinding.novelDetailAddToLibraryButton.isClickable = true - } + private fun handleEvent(event: NovelDetailsEvent) { + when (event) { + is NovelDetailsEvent.NavigateBack -> finish() - private fun disableAddToLibraryButton() { - invalidateOptionsMenu() - contentBinding.novelDetailAddToLibraryButton.setText(getString(R.string.in_library)) - contentBinding.novelDetailAddToLibraryButton.setIconResource(R.drawable.ic_local_library_white_vector) - contentBinding.novelDetailAddToLibraryButton.setBackgroundColor(ContextCompat.getColor(this@NovelDetailsActivity, R.color.Green)) - contentBinding.novelDetailAddToLibraryButton.isClickable = false - } - - - private fun setNovelGenre() { - contentBinding.novelDetailsGenresLayout.removeAllViews() - if (novel.genres != null && novel.genres!!.isNotEmpty()) { - novel.genres!!.forEach { - contentBinding.novelDetailsGenresLayout.addView(getGenreTextView(it)) + is NovelDetailsEvent.NavigateToChapters -> { + startChaptersActivity(event.novel, false) } - } else contentBinding.novelDetailsGenresLayout.addView(getGenreTextView("N/A")) - } - private fun getGenreTextView(genre: String): TextView { - val textView = TextView(this) - val layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT) - layoutParams.setMargins(4, 8, 20, 4) - textView.layoutParams = layoutParams - textView.setPadding(8, 8, 8, 8) - textView.setBackgroundColor(ContextCompat.getColor(this, R.color.LightGoldenrodYellow)) - textView.applyFont(assets).text = genre - textView.setTextColor(ContextCompat.getColor(this, R.color.black)) - return textView - } + is NovelDetailsEvent.NavigateToMetadata -> startMetadataActivity(event.novel) - private fun setNovelDescription() { - if (novel.longDescription != null) { - val expandClickable = object : ClickableSpan() { - override fun onClick(textView: View) { - contentBinding.novelDetailsDescription.applyFont(assets).text = novel.longDescription - } - - override fun updateDrawState(ds: TextPaint) { - super.updateDrawState(ds) - ds.isUnderlineText = false + is NovelDetailsEvent.NavigateToImagePreview -> { + val intent = Intent(this, ImagePreviewActivity::class.java).apply { + putExtra("url", event.imageUrl) + putExtra("filePath", event.imageFilePath) } + val options = ActivityOptionsCompat.makeCustomAnimation( + this, + android.R.anim.fade_in, + android.R.anim.fade_out + ) + startActivity(intent, options.toBundle()) } - val novelDescription = "${novel.longDescription?.subSequence(0, min(300, novel.longDescription?.length ?: 0))}… Expand" - val ss2 = SpannableString(novelDescription) - ss2.setSpan(expandClickable, min(300, novel.longDescription?.length ?: 0) + 2, novelDescription.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - contentBinding.novelDetailsDescription.applyFont(assets).text = ss2 - contentBinding.novelDetailsDescription.movementMethod = LinkMovementMethod.getInstance() - } - } + is NovelDetailsEvent.NavigateToSearchResults -> startSearchResultsActivity(event.title, event.url) - override fun onCreateOptionsMenu(menu: Menu?): Boolean { - menuInflater.inflate(R.menu.menu_novel_details, menu) - return true - } + is NovelDetailsEvent.OpenInBrowser -> openInBrowser(event.url) - override fun onPrepareOptionsMenu(menu: Menu?): Boolean { - menu?.getItem(0)?.isVisible = novel.id != -1L - return super.onPrepareOptionsMenu(menu) - } + is NovelDetailsEvent.ShareUrl -> shareUrl(event.url) - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - android.R.id.home -> finish() - R.id.action_delete_novel -> confirmNovelDelete() - R.id.action_share -> shareUrl(novel.url) - } - return super.onOptionsItemSelected(item) - } + is NovelDetailsEvent.ShowMessage -> toast(event.message) - private fun confirmNovelDelete() { - MaterialDialog(this).show { - icon(R.drawable.ic_delete_white_vector) - title(R.string.confirm_remove) - message(R.string.confirm_remove_description_novel) - positiveButton(R.string.remove) { - deleteNovel() + is NovelDetailsEvent.LaunchCloudflareResolver -> { + val url = "https://${HostNames.NOVEL_UPDATES}" + val intent = CloudflareResolverActivity.createIntent(this, url) + cloudflareResolverLauncher.launch(intent) } - negativeButton(R.string.cancel) - - lifecycleOwner(this@NovelDetailsActivity) } } - private fun deleteNovel() { - deleteNovel(novel) - setNovelAddToLibraryButton() - invalidateOptionsMenu() + private fun shareUrl(url: String) { + val intent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, url) + } + startActivity(Intent.createChooser(intent, "Share")) } + @Deprecated("Replaced by Activity Result API") override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - if (requestCode == Constants.CHAPTER_ACT_REQ_CODE) { - if (resultCode == Constants.OPEN_DOWNLOADS_RES_CODE) { - setResult(resultCode) - finish() - return - } - //Check if this novel was added to database in Chapters Screen - dbHelper.getNovelByUrl(novel.url)?.let { novel = it } - setNovelAddToLibraryButton() - } - super.onActivityResult(requestCode, resultCode, data) } - - override fun onLinkClicked(title: String, url: String) { - startSearchResultsActivity(title, url) - } - - private fun addNovelToHistory() { - try { - var history = dbHelper.getLargePreference(Constants.LargePreferenceKeys.RVN_HISTORY) ?: "[]" - var historyList: ArrayList = Gson().fromJson(history, object : TypeToken>() {}.type) - historyList.removeAll { novel.name == it.name } - if (historyList.size > 99) historyList = ArrayList(historyList.take(99)) - historyList.add(novel) - history = Gson().toJson(historyList) - dbHelper.createOrUpdateLargePreference(Constants.LargePreferenceKeys.RVN_HISTORY, history) - } catch (e: Exception) { - dbHelper.deleteLargePreference(Constants.LargePreferenceKeys.RVN_HISTORY) - } - } } diff --git a/app/src/main/java/io/github/gmathi/novellibrary/compose/noveldetails/NovelDetailsScreen.kt b/app/src/main/java/io/github/gmathi/novellibrary/compose/noveldetails/NovelDetailsScreen.kt new file mode 100644 index 00000000..87139b3c --- /dev/null +++ b/app/src/main/java/io/github/gmathi/novellibrary/compose/noveldetails/NovelDetailsScreen.kt @@ -0,0 +1,465 @@ +package io.github.gmathi.novellibrary.compose.noveldetails + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.material3.* +import androidx.compose.runtime.* +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.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.* +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.core.text.HtmlCompat +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import io.github.gmathi.novellibrary.R +import io.github.gmathi.novellibrary.compose.common.ErrorView +import io.github.gmathi.novellibrary.compose.common.LoadingView +import io.github.gmathi.novellibrary.compose.common.URLImage +import io.github.gmathi.novellibrary.model.database.Novel +import io.github.gmathi.novellibrary.viewmodel.NovelDetailsUiState +import io.github.gmathi.novellibrary.viewmodel.NovelDetailsViewModel +import kotlin.math.min + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class, ExperimentalLayoutApi::class) +@Composable +fun NovelDetailsScreen( + viewModel: NovelDetailsViewModel, + onBackClick: () -> Unit, + onImageClick: () -> Unit, + onChaptersClick: () -> Unit, + onMetadataClick: () -> Unit, + onOpenInBrowser: () -> Unit, + onAuthorLinkClick: (title: String, url: String) -> Unit, + onDeleteConfirmed: () -> Unit, + onShareClick: () -> Unit +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val isRefreshing by viewModel.isRefreshing.collectAsStateWithLifecycle() + val isInLibrary by viewModel.isInLibrary.collectAsStateWithLifecycle() + var showDeleteDialog by remember { mutableStateOf(false) } + + val pullRefreshState = rememberPullRefreshState( + refreshing = isRefreshing, + onRefresh = { viewModel.loadDetails(isManualRefresh = true) } + ) + + val novelName = when (val s = uiState) { + is NovelDetailsUiState.Content -> s.novel.name + else -> "" + } + + if (showDeleteDialog) { + AlertDialog( + onDismissRequest = { showDeleteDialog = false }, + icon = { Icon(Icons.Default.Delete, contentDescription = null) }, + title = { Text(stringResource(R.string.confirm_remove)) }, + text = { Text(stringResource(R.string.confirm_remove_description_novel)) }, + confirmButton = { + TextButton(onClick = { + showDeleteDialog = false + onDeleteConfirmed() + }) { Text(stringResource(R.string.remove)) } + }, + dismissButton = { + TextButton(onClick = { showDeleteDialog = false }) { Text(stringResource(R.string.cancel)) } + } + ) + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = novelName, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + titleContentColor = MaterialTheme.colorScheme.onSurface, + navigationIconContentColor = MaterialTheme.colorScheme.onSurface, + actionIconContentColor = MaterialTheme.colorScheme.onSurface + ), + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + actions = { + if (uiState is NovelDetailsUiState.Content) { + if (isInLibrary) { + IconButton(onClick = { showDeleteDialog = true }) { + Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.confirm_remove)) + } + } else { + IconButton(onClick = { viewModel.addToLibrary() }) { + Icon(Icons.Default.Add, contentDescription = stringResource(R.string.add_to_library)) + } + } + } + IconButton(onClick = onShareClick) { + Icon(Icons.Default.Share, contentDescription = stringResource(R.string.share)) + } + } + ) + } + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .pullRefresh(pullRefreshState) + ) { + when (val state = uiState) { + is NovelDetailsUiState.Loading -> LoadingView() + + is NovelDetailsUiState.NoInternet -> ErrorView( + message = stringResource(R.string.no_internet), + onRetry = { viewModel.loadDetails() } + ) + + is NovelDetailsUiState.MissingSource -> ErrorView( + message = "Missing Novel Source Id.\nPlease re-add the novel.", + onRetry = { viewModel.onDeleteAndFinish() }, + buttonText = "Delete Novel" + ) + + is NovelDetailsUiState.Error -> ErrorView( + message = state.message, + onRetry = { viewModel.loadDetails() } + ) + + is NovelDetailsUiState.CloudflareRequired -> LoadingView() + + is NovelDetailsUiState.Content -> NovelDetailsContent( + novel = state.novel, + isInLibrary = isInLibrary, + onImageClick = onImageClick, + onAddToLibrary = { viewModel.addToLibrary() }, + onDeleteFromLibrary = { showDeleteDialog = true }, + onChaptersClick = onChaptersClick, + onMetadataClick = onMetadataClick, + onOpenInBrowser = onOpenInBrowser, + onAuthorLinkClick = onAuthorLinkClick + ) + } + + PullRefreshIndicator( + refreshing = isRefreshing, + state = pullRefreshState, + modifier = Modifier.align(Alignment.TopCenter) + ) + } + } +} + +@Composable +private fun NovelDetailsContent( + novel: Novel, + isInLibrary: Boolean, + onImageClick: () -> Unit, + onAddToLibrary: () -> Unit, + onDeleteFromLibrary: () -> Unit, + onChaptersClick: () -> Unit, + onMetadataClick: () -> Unit, + onOpenInBrowser: () -> Unit, + onAuthorLinkClick: (title: String, url: String) -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Header: image + basic info + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.Top + ) { + URLImage( + imageUrl = novel.imageUrl, + contentDescription = novel.name, + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .clickable(onClick = onImageClick), + width = 120.dp, + height = 160.dp, + contentScale = ContentScale.Crop, + showLoadingIndicator = true + ) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + SelectionContainer { + Text( + text = novel.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + } + + // Author + val author = novel.metadata["Author(s)"] + if (author != null) { + SelectionContainer { + HtmlLinkedText( + html = author, + style = MaterialTheme.typography.bodyMedium, + onLinkClick = onAuthorLinkClick + ) + } + } else { + val authors = novel.authors?.joinToString(", ") + if (!authors.isNullOrBlank()) { + SelectionContainer { + Text(text = authors, style = MaterialTheme.typography.bodyMedium) + } + } + } + + // Year/status + val year = novel.metadata["Year"] + Text( + text = year ?: "N/A", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + // Rating + NovelRating(novel.rating) + } + } + + // Licensing warning + val publisher = novel.metadata["English Publisher"] ?: "" + val isLicensed = novel.metadata["Licensed (in English)"] == "Yes" + if (publisher.isNotEmpty() || isLicensed) { + val publisherName = publisher.ifEmpty { "an unknown publisher" } + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer), + shape = RoundedCornerShape(8.dp) + ) { + HtmlLinkedText( + html = "⚠️ Licensed by $publisherName. Reading may not be supported.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.padding(12.dp), + onLinkClick = onAuthorLinkClick + ) + } + } + + // Add to library button + if (!isInLibrary) { + Button( + onClick = onAddToLibrary, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.add_to_library)) + } + } else { + OutlinedButton( + onClick = { }, + enabled = false, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors( + disabledContainerColor = Color.Transparent, + disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant + ), + border = ButtonDefaults.outlinedButtonBorder(enabled = false) + ) { + Icon( + Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text(stringResource(R.string.in_library)) + } + } + + // Genres + NovelGenres(novel.genres) + + HorizontalDivider() + + // Description + if (!novel.longDescription.isNullOrBlank()) { + NovelDescription(description = novel.longDescription!!) + } + + HorizontalDivider() + + // Chapters row + val chaptersCountText = novel.chaptersCount.takeIf { it > 0L }?.toString() + ?: novel.metadata["Chapters"]?.takeIf { it.isNotBlank() } + val chaptersLabel = if (chaptersCountText != null) + "${stringResource(R.string.chapters)} ($chaptersCountText)" + else + stringResource(R.string.chapters) + + NavigationRow(label = chaptersLabel, onClick = onChaptersClick) + NavigationRow(label = stringResource(R.string.more_information), onClick = onMetadataClick) + } +} + +@Composable +private fun NovelRating(rating: String?) { + if (rating.isNullOrBlank()) return + val ratingValue = try { rating.replace(",", ".").toFloat() } catch (e: Exception) { return } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + repeat(5) { index -> + Text( + text = if (index < ratingValue.toInt()) "★" else "☆", + color = if (index < ratingValue.toInt()) Color(0xFFFFC107) + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) + ) + } + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "(${String.format("%.1f", ratingValue)})", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun NovelGenres(genres: List?) { + val list = if (!genres.isNullOrEmpty()) genres else listOf("N/A") + Column { + Text( + text = "Genres", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(6.dp)) + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + list.forEach { genre -> + Text( + text = genre, + modifier = Modifier + .background( + color = MaterialTheme.colorScheme.secondaryContainer, + shape = RoundedCornerShape(4.dp) + ) + .padding(horizontal = 8.dp, vertical = 4.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + } + } +} + +@Composable +private fun NovelDescription(description: String) { + var isExpanded by remember { mutableStateOf(false) } + Column { + Text( + text = "Description", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(6.dp)) + SelectionContainer { + if (isExpanded || description.length <= 300) { + Text(text = description, style = MaterialTheme.typography.bodyMedium) + } else { + val shortText = description.substring(0, min(300, description.length)) + Text( + text = buildAnnotatedString { + append(shortText) + append("… ") + withStyle( + SpanStyle( + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline + ) + ) { append("Expand") } + }, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.clickable { isExpanded = true } + ) + } + } + } +} + +@Composable +private fun NavigationRow(label: String, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 14.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = label, style = MaterialTheme.typography.bodyLarge) + Text(text = "›", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +/** + * Renders HTML text with clickable links. Links in the HTML trigger [onLinkClick]. + */ +@Composable +private fun HtmlLinkedText( + html: String, + modifier: Modifier = Modifier, + style: TextStyle = LocalTextStyle.current, + color: Color = Color.Unspecified, + onLinkClick: (title: String, url: String) -> Unit +) { + val spanned = remember(html) { + HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_LEGACY) + } + // Extract link URLs and titles from spanned text + val annotatedString = remember(spanned) { + buildAnnotatedString { + val raw = spanned.toString() + if (color != Color.Unspecified) { + withStyle(SpanStyle(color = color)) { append(raw) } + } else { + append(raw) + } + } + } + Text( + text = annotatedString, + modifier = modifier, + style = style + ) +} diff --git a/app/src/main/java/io/github/gmathi/novellibrary/compose/theme/NovelLibraryTheme.kt b/app/src/main/java/io/github/gmathi/novellibrary/compose/theme/NovelLibraryTheme.kt index c58d07b4..5d7b78e3 100644 --- a/app/src/main/java/io/github/gmathi/novellibrary/compose/theme/NovelLibraryTheme.kt +++ b/app/src/main/java/io/github/gmathi/novellibrary/compose/theme/NovelLibraryTheme.kt @@ -3,6 +3,7 @@ package io.github.gmathi.novellibrary.compose.theme import android.app.Activity import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Typography import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable @@ -10,6 +11,9 @@ import androidx.compose.runtime.SideEffect import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp import androidx.core.view.WindowCompat import io.github.gmathi.novellibrary.model.preference.DataCenter import uy.kohesive.injekt.injectLazy @@ -90,6 +94,24 @@ private val LightColorScheme = lightColorScheme( surfaceContainerHighest = Color(0xFFE2E2E6) ) +private val AppTypography = Typography( + displayLarge = TextStyle(fontSize = 57.sp, lineHeight = 64.sp, fontWeight = FontWeight.Normal, letterSpacing = (-0.25).sp), + displayMedium = TextStyle(fontSize = 45.sp, lineHeight = 52.sp, fontWeight = FontWeight.Normal), + displaySmall = TextStyle(fontSize = 36.sp, lineHeight = 44.sp, fontWeight = FontWeight.Normal), + headlineLarge = TextStyle(fontSize = 32.sp, lineHeight = 40.sp, fontWeight = FontWeight.Normal), + headlineMedium = TextStyle(fontSize = 28.sp, lineHeight = 36.sp, fontWeight = FontWeight.Normal), + headlineSmall = TextStyle(fontSize = 24.sp, lineHeight = 32.sp, fontWeight = FontWeight.Normal), + titleLarge = TextStyle(fontSize = 22.sp, lineHeight = 28.sp, fontWeight = FontWeight.Medium), + titleMedium = TextStyle(fontSize = 16.sp, lineHeight = 24.sp, fontWeight = FontWeight.Medium, letterSpacing = 0.15.sp), + titleSmall = TextStyle(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.Medium, letterSpacing = 0.1.sp), + bodyLarge = TextStyle(fontSize = 16.sp, lineHeight = 24.sp, fontWeight = FontWeight.Normal, letterSpacing = 0.5.sp), + bodyMedium = TextStyle(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.Normal, letterSpacing = 0.25.sp), + bodySmall = TextStyle(fontSize = 12.sp, lineHeight = 16.sp, fontWeight = FontWeight.Normal, letterSpacing = 0.4.sp), + labelLarge = TextStyle(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.Medium, letterSpacing = 0.1.sp), + labelMedium = TextStyle(fontSize = 12.sp, lineHeight = 16.sp, fontWeight = FontWeight.Medium, letterSpacing = 0.5.sp), + labelSmall = TextStyle(fontSize = 11.sp, lineHeight = 16.sp, fontWeight = FontWeight.Medium, letterSpacing = 0.5.sp) +) + @Composable fun NovelLibraryTheme( darkTheme: Boolean = isSystemInDarkTheme(), @@ -112,7 +134,7 @@ fun NovelLibraryTheme( val context = view.context if (context is Activity) { val window = context.window - window.statusBarColor = colorScheme.primary.toArgb() + window.statusBarColor = colorScheme.surface.toArgb() WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !useDarkTheme } } catch (e: Exception) { @@ -123,6 +145,7 @@ fun NovelLibraryTheme( MaterialTheme( colorScheme = colorScheme, + typography = AppTypography, content = content ) } diff --git a/app/src/main/java/io/github/gmathi/novellibrary/domain/usecase/AddNovelToHistoryUseCase.kt b/app/src/main/java/io/github/gmathi/novellibrary/domain/usecase/AddNovelToHistoryUseCase.kt new file mode 100644 index 00000000..1e1a6a22 --- /dev/null +++ b/app/src/main/java/io/github/gmathi/novellibrary/domain/usecase/AddNovelToHistoryUseCase.kt @@ -0,0 +1,29 @@ +package io.github.gmathi.novellibrary.domain.usecase + +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import io.github.gmathi.novellibrary.database.DBHelper +import io.github.gmathi.novellibrary.database.createOrUpdateLargePreference +import io.github.gmathi.novellibrary.database.deleteLargePreference +import io.github.gmathi.novellibrary.database.getLargePreference +import io.github.gmathi.novellibrary.model.database.Novel +import io.github.gmathi.novellibrary.util.Constants + +class AddNovelToHistoryUseCase( + private val dbHelper: DBHelper, + private val gson: Gson +) { + operator fun invoke(novel: Novel) { + try { + var history = dbHelper.getLargePreference(Constants.LargePreferenceKeys.RVN_HISTORY) ?: "[]" + var historyList: ArrayList = gson.fromJson(history, object : TypeToken>() {}.type) + historyList.removeAll { novel.name == it.name } + if (historyList.size > 99) historyList = ArrayList(historyList.take(99)) + historyList.add(novel) + history = gson.toJson(historyList) + dbHelper.createOrUpdateLargePreference(Constants.LargePreferenceKeys.RVN_HISTORY, history) + } catch (e: Exception) { + dbHelper.deleteLargePreference(Constants.LargePreferenceKeys.RVN_HISTORY) + } + } +} diff --git a/app/src/main/java/io/github/gmathi/novellibrary/domain/usecase/GetNovelDetailsUseCase.kt b/app/src/main/java/io/github/gmathi/novellibrary/domain/usecase/GetNovelDetailsUseCase.kt new file mode 100644 index 00000000..9abf606d --- /dev/null +++ b/app/src/main/java/io/github/gmathi/novellibrary/domain/usecase/GetNovelDetailsUseCase.kt @@ -0,0 +1,27 @@ +package io.github.gmathi.novellibrary.domain.usecase + +import io.github.gmathi.novellibrary.database.DBHelper +import io.github.gmathi.novellibrary.database.getNovelByUrl +import io.github.gmathi.novellibrary.database.updateNovel +import io.github.gmathi.novellibrary.model.database.Novel +import io.github.gmathi.novellibrary.model.source.SourceManager +import io.github.gmathi.novellibrary.util.error.Exceptions + +class GetNovelDetailsUseCase( + private val sourceManager: SourceManager, + private val dbHelper: DBHelper +) { + suspend operator fun invoke(novel: Novel): Result { + return try { + val source = sourceManager.get(novel.sourceId) + ?: return Result.failure(Exception(Exceptions.MISSING_SOURCE_ID)) + val updatedNovel = source.getNovelDetails(novel) + if (updatedNovel.id != -1L) { + dbHelper.updateNovel(updatedNovel) + } + Result.success(updatedNovel) + } catch (e: Exception) { + Result.failure(e) + } + } +} diff --git a/app/src/main/java/io/github/gmathi/novellibrary/viewmodel/NovelDetailsViewModel.kt b/app/src/main/java/io/github/gmathi/novellibrary/viewmodel/NovelDetailsViewModel.kt new file mode 100644 index 00000000..1afe3cbf --- /dev/null +++ b/app/src/main/java/io/github/gmathi/novellibrary/viewmodel/NovelDetailsViewModel.kt @@ -0,0 +1,218 @@ +package io.github.gmathi.novellibrary.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.gson.Gson +import io.github.gmathi.novellibrary.database.DBHelper +import io.github.gmathi.novellibrary.database.getNovelByUrl +import io.github.gmathi.novellibrary.database.insertNovel +import io.github.gmathi.novellibrary.domain.usecase.AddNovelToHistoryUseCase +import io.github.gmathi.novellibrary.domain.usecase.GetNovelDetailsUseCase +import io.github.gmathi.novellibrary.model.database.Novel +import io.github.gmathi.novellibrary.model.source.SourceManager +import io.github.gmathi.novellibrary.network.NetworkHelper +import io.github.gmathi.novellibrary.util.error.Exceptions +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import uy.kohesive.injekt.injectLazy + +sealed class NovelDetailsUiState { + object Loading : NovelDetailsUiState() + data class Content(val novel: Novel, val version: Long = 0L) : NovelDetailsUiState() + data class Error(val message: String, val canRetry: Boolean = true) : NovelDetailsUiState() + object NoInternet : NovelDetailsUiState() + object CloudflareRequired : NovelDetailsUiState() + object MissingSource : NovelDetailsUiState() +} + +sealed class NovelDetailsEvent { + object NavigateBack : NovelDetailsEvent() + data class NavigateToChapters(val novel: Novel) : NovelDetailsEvent() + data class NavigateToMetadata(val novel: Novel) : NovelDetailsEvent() + data class NavigateToImagePreview(val imageUrl: String?, val imageFilePath: String?) : NovelDetailsEvent() + data class NavigateToSearchResults(val title: String, val url: String) : NovelDetailsEvent() + data class OpenInBrowser(val url: String) : NovelDetailsEvent() + data class ShareUrl(val url: String) : NovelDetailsEvent() + data class ShowMessage(val message: String) : NovelDetailsEvent() + object LaunchCloudflareResolver : NovelDetailsEvent() +} + +class NovelDetailsViewModel : ViewModel() { + + private val networkHelper: NetworkHelper by injectLazy() + private val dbHelper: DBHelper by injectLazy() + private val sourceManager: SourceManager by injectLazy() + private val gson: Gson by injectLazy() + + private val getNovelDetailsUseCase by lazy { GetNovelDetailsUseCase(sourceManager, dbHelper) } + private val addNovelToHistoryUseCase by lazy { AddNovelToHistoryUseCase(dbHelper, gson) } + + private val _uiState = MutableStateFlow(NovelDetailsUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _events = MutableSharedFlow() + val events: SharedFlow = _events.asSharedFlow() + + private val _isRefreshing = MutableStateFlow(false) + val isRefreshing: StateFlow = _isRefreshing.asStateFlow() + + private var novel: Novel? = null + private var retryCounter = 0 + + private val _isInLibrary = MutableStateFlow(false) + val isInLibrary: StateFlow = _isInLibrary.asStateFlow() + + private fun emitContent(novel: Novel) { + _isInLibrary.value = novel.id != -1L + // Use nanoTime to guarantee uniqueness so StateFlow always emits + _uiState.value = NovelDetailsUiState.Content(novel, System.nanoTime()) + } + + fun init(initialNovel: Novel) { + if (novel != null) return + novel = initialNovel + // Resolve local DB id + dbHelper.getNovelByUrl(initialNovel.url)?.let { novel!!.id = it.id } + + if (novel!!.id != -1L && !networkHelper.isConnectedToNetwork()) { + emitContent(novel!!) + } else { + loadDetails() + } + } + + fun loadDetails(isManualRefresh: Boolean = false) { + val currentNovel = novel ?: return + + if (isManualRefresh) { + _isRefreshing.value = true + } else { + _uiState.value = NovelDetailsUiState.Loading + } + + if (!networkHelper.isConnectedToNetwork()) { + _isRefreshing.value = false + if (currentNovel.id == -1L) { + _uiState.value = NovelDetailsUiState.NoInternet + } else { + emitContent(currentNovel) + viewModelScope.launch { _events.emit(NovelDetailsEvent.ShowMessage("No internet connection")) } + } + return + } + + viewModelScope.launch { + val result = withContext(Dispatchers.IO) { getNovelDetailsUseCase(currentNovel) } + + result.fold( + onSuccess = { updatedNovel -> + novel = updatedNovel + withContext(Dispatchers.IO) { addNovelToHistoryUseCase(updatedNovel) } + retryCounter = 0 + emitContent(updatedNovel) + }, + onFailure = { error -> + when { + error.message?.contains(Exceptions.MISSING_SOURCE_ID) == true -> { + _uiState.value = NovelDetailsUiState.MissingSource + } + (error.message?.contains("cloudflare", ignoreCase = true) == true || + error.message?.contains("HTTP error 503") == true) && retryCounter < 2 -> { + retryCounter++ + _uiState.value = NovelDetailsUiState.CloudflareRequired + _events.emit(NovelDetailsEvent.LaunchCloudflareResolver) + } + else -> { + if (currentNovel.id == -1L) { + _uiState.value = NovelDetailsUiState.Error( + error.localizedMessage ?: "Failed to load novel details" + ) + } else { + emitContent(currentNovel) + _events.emit(NovelDetailsEvent.ShowMessage(error.localizedMessage ?: "Failed to refresh")) + } + } + } + } + ) + _isRefreshing.value = false + } + } + + fun addToLibrary() { + val currentNovel = novel ?: return + if (currentNovel.id != -1L) return + viewModelScope.launch { + val newId = withContext(Dispatchers.IO) { + dbHelper.insertNovel(currentNovel) + } + currentNovel.id = newId + novel = currentNovel + emitContent(currentNovel) + } + } + + fun deleteFromLibrary() { + val currentNovel = novel ?: return + viewModelScope.launch { + withContext(Dispatchers.IO) { + dbHelper.cleanupNovelData(currentNovel) + } + currentNovel.id = -1L + novel = currentNovel + emitContent(currentNovel) + } + } + + fun onCloudflareResolved() { + retryCounter = 0 + loadDetails() + } + + fun onDeleteAndFinish() { + viewModelScope.launch { + val currentNovel = novel ?: return@launch + withContext(Dispatchers.IO) { dbHelper.cleanupNovelData(currentNovel) } + _events.emit(NovelDetailsEvent.NavigateBack) + } + } + + fun onChaptersClick() { + novel?.let { viewModelScope.launch { _events.emit(NovelDetailsEvent.NavigateToChapters(it)) } } + } + + fun onMetadataClick() { + novel?.let { viewModelScope.launch { _events.emit(NovelDetailsEvent.NavigateToMetadata(it)) } } + } + + fun onImageClick() { + val n = novel ?: return + viewModelScope.launch { _events.emit(NovelDetailsEvent.NavigateToImagePreview(n.imageUrl, n.imageFilePath)) } + } + + fun onOpenInBrowser() { + novel?.let { viewModelScope.launch { _events.emit(NovelDetailsEvent.OpenInBrowser(it.url)) } } + } + + fun onShareUrl() { + novel?.let { viewModelScope.launch { _events.emit(NovelDetailsEvent.ShareUrl(it.url)) } } + } + + fun onAuthorLinkClick(title: String, url: String) { + viewModelScope.launch { _events.emit(NovelDetailsEvent.NavigateToSearchResults(title, url)) } + } + + fun onNovelAddedFromChapters(url: String) { + dbHelper.getNovelByUrl(url)?.let { dbNovel -> + novel = dbNovel + emitContent(dbNovel) + } + } +} diff --git a/settings.gradle b/settings.gradle index 8e10bf3f..67b8fe80 100644 --- a/settings.gradle +++ b/settings.gradle @@ -5,7 +5,6 @@ pluginManagement { gradlePluginPortal() } } - plugins { id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0' }