Skip to content

Commit

Permalink
Add support For Importing/Exporting Opml feeds. (#28)
Browse files Browse the repository at this point in the history
* Add import/export opml feeds core logic

* Don't set OpmlResult.Idle immediately after failure

* Add Import/Export Screen UI

* Run ktlintFormat

* Report Import/Export Success state

* Small UI adjustments & fixes
  • Loading branch information
mr3y-the-programmer authored Mar 10, 2024
1 parent bd5907a commit 0592b02
Show file tree
Hide file tree
Showing 25 changed files with 931 additions and 1 deletion.
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ dependencies {
implementation(projects.core.network)
implementation(projects.core.data)
implementation(projects.core.sync)
implementation(projects.core.opml)
implementation(platform(libs.compose.bom))
implementation(libs.compose.htmlconverter)
implementation(libs.kmpalette.core)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.mr3y.podcaster
import android.app.Application
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import com.mr3y.podcaster.core.opml.FileManager
import com.mr3y.podcaster.core.sync.initializeWorkManagerInstance
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
Expand All @@ -13,9 +14,13 @@ class PodcasterApplication : Application(), Configuration.Provider {
@Inject
lateinit var workerFactory: HiltWorkerFactory

@Inject
lateinit var fileManager: FileManager

override fun onCreate() {
super.onCreate()
initializeWorkManagerInstance(this)
fileManager.registerActivityWatcher()
}

override val workManagerConfiguration: Configuration
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,7 @@ sealed interface Destinations : Destination {

@Serializable
data object Licenses : Destinations

@Serializable
data object ImportExport : Destinations
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import com.mr3y.podcaster.ui.presenter.UserPreferences
import com.mr3y.podcaster.ui.screens.DownloadsScreen
import com.mr3y.podcaster.ui.screens.EpisodeDetailsScreen
import com.mr3y.podcaster.ui.screens.ExploreScreen
import com.mr3y.podcaster.ui.screens.ImportExportScreen
import com.mr3y.podcaster.ui.screens.LicensesScreen
import com.mr3y.podcaster.ui.screens.PodcastDetailsScreen
import com.mr3y.podcaster.ui.screens.SettingsScreen
Expand Down Expand Up @@ -97,6 +98,13 @@ fun PodcasterNavGraph(
excludedWindowInsets = excludedWindowInsets,
)
}
composable<Destinations.ImportExport> {
ImportExportScreen(
onNavDrawerClick = onNavDrawerClick,
contentPadding = contentPadding,
excludedWindowInsets = excludedWindowInsets,
)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.mr3y.podcaster.ui.presenter.opml

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.mr3y.podcaster.core.opml.OpmlManager
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import javax.inject.Inject

@HiltViewModel
class OpmlViewModel @Inject constructor(
private val opmlManager: OpmlManager,
) : ViewModel() {

val result = opmlManager.result

fun import() {
viewModelScope.launch {
opmlManager.cancelCurrentRunningTask()
opmlManager.import()
}
}

fun export() {
viewModelScope.launch {
opmlManager.cancelCurrentRunningTask()
opmlManager.export()
}
}

fun consumeResult() {
viewModelScope.launch { opmlManager.resetResultState() }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ val EnStrings = PodcasterStrings(
icon_theme_content_description = "Toggle App Theme",
subscriptions_label = "Subscriptions",
subscriptions_empty_list = "You aren't subscribed to any podcast.\nYour subscriptions will show up here.",
subscriptions_episodes_empty_list = "Start subscribing podcasts by clicking on ☰ icon -> then Explore.",
subscriptions_episodes_empty_list = "Start subscribing podcasts by\n • Clicking on ☰ icon -> then Explore\n • Or import your existing subscriptions collection from ☰ icon -> then Import/Export.",
generic_error_message = "Sorry, Something went wrong",
retry_label = "Retry",
currently_playing = "Currently Playing",
Expand Down Expand Up @@ -62,4 +62,13 @@ val EnStrings = PodcasterStrings(
append("PodcastIndex.org")
}
},
import_export_label = "Import/Export Opml",
import_label = "Import",
export_label = "Export",
import_notice = "If you're importing subscriptions collection make sure you're connected to the internet or the importing will fail",
import_succeeded = "Importing subscriptions collection completed successfully!",
import_network_error = "Importing collection failed! make sure you're connected to the internet.",
import_empty_file_error = "Empty file! nothing to import",
import_corrupted_file_error = "File may be corrupted! process failed.",
import_unknown_error = "Sorry, Something went wrong",
)
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,13 @@ data class PodcasterStrings(
val feedback_and_issues_label: String,
val privacy_policy_label: String,
val powered_by_label: AnnotatedString,
val import_export_label: String,
val import_label: String,
val export_label: String,
val import_notice: String,
val import_succeeded: String,
val import_network_error: String,
val import_empty_file_error: String,
val import_corrupted_file_error: String,
val import_unknown_error: String,
)
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.offset
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.FileDownload
import androidx.compose.material.icons.outlined.ImportExport
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.Icon
Expand Down Expand Up @@ -104,6 +105,12 @@ fun HomeScreen(
createRoutePattern<Destinations.Explore>(),
Destinations.Explore,
),
DrawerTab(
strings.import_export_label,
Icons.Outlined.ImportExport,
createRoutePattern<Destinations.ImportExport>(),
Destinations.ImportExport,
),
DrawerTab(
strings.tab_downloads_label,
Icons.Outlined.FileDownload,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
package com.mr3y.podcaster.ui.screens

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.exclude
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ElevatedButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.mr3y.podcaster.LocalStrings
import com.mr3y.podcaster.core.opml.model.OpmlResult
import com.mr3y.podcaster.ui.components.LoadingIndicator
import com.mr3y.podcaster.ui.components.plus
import com.mr3y.podcaster.ui.presenter.opml.OpmlViewModel
import com.mr3y.podcaster.ui.preview.DynamicColorsParameterProvider
import com.mr3y.podcaster.ui.preview.PodcasterPreview
import com.mr3y.podcaster.ui.theme.PodcasterTheme
import com.mr3y.podcaster.ui.theme.isAppThemeDark
import com.mr3y.podcaster.ui.theme.onPrimaryTertiary
import com.mr3y.podcaster.ui.theme.primaryTertiary
import com.mr3y.podcaster.ui.theme.setStatusBarAppearanceLight

@Composable
fun ImportExportScreen(
onNavDrawerClick: () -> Unit,
contentPadding: PaddingValues,
excludedWindowInsets: WindowInsets?,
modifier: Modifier = Modifier,
viewModel: OpmlViewModel = hiltViewModel(),
) {
val result by viewModel.result.collectAsStateWithLifecycle()
ImportExportScreen(
result = result,
onNavDrawerClick = onNavDrawerClick,
onImporting = viewModel::import,
onExporting = viewModel::export,
onConsumeResult = viewModel::consumeResult,
externalContentPadding = contentPadding,
excludedWindowInsets = excludedWindowInsets,
modifier = modifier,
)
}

@Composable
fun ImportExportScreen(
result: OpmlResult,
onNavDrawerClick: () -> Unit,
onImporting: () -> Unit,
onExporting: () -> Unit,
onConsumeResult: () -> Unit,
externalContentPadding: PaddingValues,
excludedWindowInsets: WindowInsets?,
modifier: Modifier = Modifier,
) {
val snackBarHostState = remember { SnackbarHostState() }
val isDarkTheme = isAppThemeDark()
val context = LocalContext.current
LaunchedEffect(key1 = isDarkTheme) {
context.setStatusBarAppearanceLight(isAppearanceLight = !isDarkTheme)
}
Scaffold(
topBar = {
ImportExportTopAppBar(
onNavDrawerClick = onNavDrawerClick,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
.padding(end = 16.dp),
)
},
snackbarHost = { SnackbarHost(snackBarHostState, Modifier.padding(externalContentPadding)) },
contentWindowInsets = if (excludedWindowInsets != null) ScaffoldDefaults.contentWindowInsets.exclude(excludedWindowInsets) else ScaffoldDefaults.contentWindowInsets,
containerColor = MaterialTheme.colorScheme.surface,
modifier = modifier,
) { contentPadding ->
val strings = LocalStrings.current
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.padding(contentPadding + externalContentPadding)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp)
.padding(top = 56.dp),
) {
ImportOrExportButton(label = strings.import_label, onClick = onImporting, modifier = Modifier.fillMaxWidth())
ImportOrExportButton(label = strings.export_label, onClick = onExporting, modifier = Modifier.fillMaxWidth())
Spacer(modifier = Modifier.height(16.dp))
Text(
text = strings.import_notice,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center,
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(16.dp))
if (result is OpmlResult.Loading) {
LoadingIndicator(modifier = Modifier.fillMaxWidth())
}
LaunchedEffect(result) {
if (result is OpmlResult.Error || result is OpmlResult.Success) {
val message = when (result) {
is OpmlResult.Success -> strings.import_succeeded
is OpmlResult.Error.NoContentInOpmlFile -> strings.import_empty_file_error
is OpmlResult.Error.NetworkError -> strings.import_network_error
is OpmlResult.Error.EncodingError, is OpmlResult.Error.DecodingError -> strings.import_corrupted_file_error
is OpmlResult.Error.UnknownFailure -> strings.import_unknown_error
else -> "" // Not reachable, but when can't infer that yet (see https://youtrack.jetbrains.com/issue/KT-8781/Consider-making-smart-casts-smart-enough-to-handle-exhaustive-value-sets)
}
snackBarHostState.showSnackbar(message)
onConsumeResult()
}
}
}
}
}

@Composable
private fun ImportExportTopAppBar(
onNavDrawerClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val strings = LocalStrings.current
TopAppBar(
navigationIcon = {
IconButton(
onClick = onNavDrawerClick,
) {
Icon(
imageVector = Icons.Filled.Menu,
contentDescription = strings.icon_menu_content_description,
)
}
},
title = {},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent,
),
modifier = modifier,
)
}

@Composable
private fun ImportOrExportButton(
label: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
ElevatedButton(
onClick = onClick,
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.elevatedButtonColors(
containerColor = MaterialTheme.colorScheme.primaryTertiary,
contentColor = MaterialTheme.colorScheme.onPrimaryTertiary,
),
modifier = modifier,
) {
Text(
text = label,
style = MaterialTheme.typography.labelLarge,
)
}
}

@PodcasterPreview
@Composable
fun ImportExportScreenPreview(
@PreviewParameter(DynamicColorsParameterProvider::class) isDynamicColorsOn: Boolean,
) {
PodcasterTheme(dynamicColor = isDynamicColorsOn) {
ImportExportScreen(
result = OpmlResult.Idle,
onNavDrawerClick = {},
onImporting = { },
onExporting = { },
onConsumeResult = {},
externalContentPadding = PaddingValues(0.dp),
excludedWindowInsets = null,
modifier = Modifier.fillMaxSize(),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ interface PodcastsRepository {

fun isPodcastFromSubscriptions(podcastId: Long): Flow<Boolean>

fun isPodcastFromSubscriptionsNonObservable(podcastId: Long): Boolean

fun hasSubscriptions(): Flow<Boolean>

fun hasDownloads(): Flow<Boolean>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ class DefaultPodcastsRepository @Inject constructor(
return podcastsDao.isPodcastAvailable(podcastId)
}

override fun isPodcastFromSubscriptionsNonObservable(podcastId: Long): Boolean {
return podcastsDao.isPodcastAvailableNonObservable(podcastId)
}

override fun hasSubscriptions(): Flow<Boolean> = podcastsDao.hasPodcasts()

override fun hasDownloads(): Flow<Boolean> = podcastsDao.hasDownloads()
Expand Down
Loading

0 comments on commit 0592b02

Please sign in to comment.