Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support For Importing/Exporting Opml feeds. #28

Merged
merged 6 commits into from
Mar 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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