Skip to content

Commit 05d9483

Browse files
authored
Merge pull request #1526 from DimensionDev/feature/opml
Add OPML support
2 parents 0156915 + 1f30228 commit 05d9483

File tree

39 files changed

+3387
-1790
lines changed

39 files changed

+3387
-1790
lines changed

app/src/main/java/dev/dimension/flare/common/ComposeInAppNotification.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ internal class ComposeInAppNotification : InAppNotification {
4141
_source.value = Event(Notification.StringNotification(message.title, success = true))
4242
}
4343

44+
fun message(
45+
@StringRes messageId: Int,
46+
) {
47+
_source.value = Event(Notification.StringNotification(messageId, success = true))
48+
}
49+
4450
override fun onError(
4551
message: Message,
4652
throwable: Throwable,

app/src/main/java/dev/dimension/flare/ui/route/Route.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,11 @@ internal sealed interface Route : NavKey {
147147
@Serializable
148148
data object Create : Rss
149149

150+
@Serializable
151+
data class OPMLImport(
152+
val url: String,
153+
) : Rss
154+
150155
@Serializable
151156
data class Edit(
152157
val id: Int,

app/src/main/java/dev/dimension/flare/ui/screen/rss/AddRssSourceActivity.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ class AddRssSourceActivity : ComponentActivity() {
3434
},
3535
id = null,
3636
initialUrl = initialText,
37+
onImportOPML = {},
3738
)
3839
}
3940
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
package dev.dimension.flare.ui.screen.rss
2+
3+
import android.content.Context
4+
import android.net.Uri
5+
import androidx.compose.foundation.layout.Arrangement
6+
import androidx.compose.foundation.layout.Box
7+
import androidx.compose.foundation.layout.Column
8+
import androidx.compose.foundation.layout.fillMaxWidth
9+
import androidx.compose.foundation.layout.padding
10+
import androidx.compose.material3.Button
11+
import androidx.compose.material3.CircularWavyProgressIndicator
12+
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
13+
import androidx.compose.material3.MaterialTheme
14+
import androidx.compose.material3.ProvideTextStyle
15+
import androidx.compose.material3.Text
16+
import androidx.compose.runtime.Composable
17+
import androidx.compose.runtime.LaunchedEffect
18+
import androidx.compose.runtime.getValue
19+
import androidx.compose.runtime.mutableStateOf
20+
import androidx.compose.runtime.remember
21+
import androidx.compose.runtime.setValue
22+
import androidx.compose.ui.Alignment
23+
import androidx.compose.ui.Modifier
24+
import androidx.compose.ui.platform.LocalContext
25+
import androidx.compose.ui.res.stringResource
26+
import androidx.compose.ui.unit.dp
27+
import dev.dimension.flare.R
28+
import dev.dimension.flare.ui.model.UiState
29+
import dev.dimension.flare.ui.model.map
30+
import dev.dimension.flare.ui.model.onError
31+
import dev.dimension.flare.ui.model.onLoading
32+
import dev.dimension.flare.ui.model.onSuccess
33+
import dev.dimension.flare.ui.presenter.home.rss.ImportOPMLPresenter
34+
import dev.dimension.flare.ui.theme.screenHorizontalPadding
35+
import kotlinx.coroutines.Dispatchers
36+
import kotlinx.coroutines.withContext
37+
import moe.tlaster.precompose.molecule.producePresenter
38+
39+
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
40+
@Composable
41+
internal fun OPMLImportSheet(
42+
uri: Uri,
43+
onBack: () -> Unit,
44+
) {
45+
val context = LocalContext.current
46+
val state by producePresenter("import_rss_$uri") {
47+
presenter(uri, context)
48+
}
49+
Column(
50+
verticalArrangement = Arrangement.spacedBy(8.dp),
51+
) {
52+
ProvideTextStyle(
53+
MaterialTheme.typography.titleMedium,
54+
) {
55+
Text(
56+
stringResource(R.string.opml_import),
57+
modifier = Modifier.padding(horizontal = screenHorizontalPadding),
58+
)
59+
}
60+
state
61+
.onLoading {
62+
Box(
63+
contentAlignment = Alignment.Center,
64+
) {
65+
CircularWavyProgressIndicator(
66+
modifier = Modifier.padding(horizontal = screenHorizontalPadding),
67+
)
68+
}
69+
}.onError {
70+
Text(text = it.message ?: "Unknown error")
71+
}.onSuccess { state ->
72+
Column {
73+
ImportOPMLContent(
74+
state = state,
75+
onGoBack = onBack,
76+
modifier = Modifier.weight(1f),
77+
)
78+
Button(
79+
onClick = {
80+
onBack.invoke()
81+
},
82+
enabled = !state.importing,
83+
modifier =
84+
Modifier
85+
.fillMaxWidth()
86+
.padding(
87+
horizontal = screenHorizontalPadding,
88+
vertical = 8.dp,
89+
),
90+
) {
91+
Text(stringResource(android.R.string.ok))
92+
}
93+
}
94+
}
95+
}
96+
}
97+
98+
@Composable
99+
private fun presenter(
100+
uri: Uri,
101+
context: Context,
102+
) = run {
103+
var contentState by remember { mutableStateOf<UiState<String>>(UiState.Loading()) }
104+
105+
LaunchedEffect(uri, context) {
106+
contentState =
107+
try {
108+
val content =
109+
withContext(Dispatchers.IO) {
110+
context.contentResolver.openInputStream(uri)?.use {
111+
it.bufferedReader().readText()
112+
}
113+
}
114+
if (content.isNullOrEmpty()) {
115+
UiState.Error(Exception("Empty content"))
116+
} else {
117+
UiState.Success(content)
118+
}
119+
} catch (e: Exception) {
120+
UiState.Error(e)
121+
}
122+
}
123+
124+
contentState.map {
125+
remember(it) { ImportOPMLPresenter(it) }.body()
126+
}
127+
}

app/src/main/java/dev/dimension/flare/ui/screen/rss/RssEntryBuilder.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import androidx.compose.runtime.remember
1313
import androidx.compose.ui.Alignment
1414
import androidx.compose.ui.Modifier
1515
import androidx.compose.ui.unit.dp
16+
import androidx.core.net.toUri
1617
import androidx.navigation3.runtime.EntryProviderScope
1718
import androidx.navigation3.runtime.NavKey
1819
import compose.icons.FontAwesomeIcons
@@ -100,6 +101,9 @@ internal fun EntryProviderScope<NavKey>.rssEntryBuilder(
100101
RssSourceEditSheet(
101102
onDismissRequest = onBack,
102103
id = null,
104+
onImportOPML = {
105+
navigate(Route.Rss.OPMLImport(it))
106+
}
103107
)
104108
}
105109
entry<Route.Rss.Edit>(
@@ -108,6 +112,17 @@ internal fun EntryProviderScope<NavKey>.rssEntryBuilder(
108112
RssSourceEditSheet(
109113
onDismissRequest = onBack,
110114
id = args.id,
115+
onImportOPML = {
116+
navigate(Route.Rss.OPMLImport(it))
117+
},
118+
)
119+
}
120+
entry<Route.Rss.OPMLImport>(
121+
metadata = BottomSheetSceneStrategy.bottomSheet()
122+
) { args ->
123+
OPMLImportSheet(
124+
uri = args.url.toUri(),
125+
onBack = onBack,
111126
)
112127
}
113128
}

app/src/main/java/dev/dimension/flare/ui/screen/rss/RssSourceEditSheet.kt

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
package dev.dimension.flare.ui.screen.rss
22

3+
import androidx.activity.compose.rememberLauncherForActivityResult
4+
import androidx.activity.result.contract.ActivityResultContracts
5+
import androidx.compose.animation.AnimatedVisibility
36
import androidx.compose.foundation.clickable
47
import androidx.compose.foundation.layout.Column
58
import androidx.compose.foundation.layout.Row
@@ -75,6 +78,7 @@ import org.koin.compose.koinInject
7578
@Composable
7679
internal fun RssSourceEditSheet(
7780
onDismissRequest: () -> Unit,
81+
onImportOPML: (String) -> Unit,
7882
id: Int?,
7983
initialUrl: String? = null,
8084
) {
@@ -83,6 +87,13 @@ internal fun RssSourceEditSheet(
8387
LaunchedEffect(Unit) {
8488
focusRequester.requestFocus()
8589
}
90+
val launcher =
91+
rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) {
92+
it?.let {
93+
onDismissRequest.invoke()
94+
onImportOPML.invoke(it.toString())
95+
}
96+
}
8697
Column(
8798
modifier =
8899
Modifier
@@ -151,6 +162,19 @@ internal fun RssSourceEditSheet(
151162
},
152163
)
153164

165+
AnimatedVisibility(state.url.text.isEmpty() && initialUrl == null) {
166+
TextButton(
167+
onClick = {
168+
launcher.launch(arrayOf("*/*"))
169+
},
170+
modifier =
171+
Modifier
172+
.fillMaxWidth(),
173+
) {
174+
Text(stringResource(R.string.opml_import))
175+
}
176+
}
177+
154178
state.checkState.onSuccess { rssState ->
155179
when (rssState) {
156180
is CheckRssSourcePresenter.State.RssState.RssFeed -> {

app/src/main/java/dev/dimension/flare/ui/screen/rss/RssSourcesScreen.kt

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
package dev.dimension.flare.ui.screen.rss
22

3+
import android.content.Context
4+
import android.net.Uri
5+
import androidx.activity.compose.rememberLauncherForActivityResult
6+
import androidx.activity.result.contract.ActivityResultContracts
37
import androidx.compose.foundation.layout.Arrangement
48
import androidx.compose.foundation.layout.padding
59
import androidx.compose.foundation.lazy.LazyColumn
@@ -9,23 +13,32 @@ import androidx.compose.material3.Text
913
import androidx.compose.material3.TopAppBarDefaults
1014
import androidx.compose.runtime.Composable
1115
import androidx.compose.runtime.getValue
16+
import androidx.compose.runtime.mutableStateOf
1217
import androidx.compose.runtime.remember
18+
import androidx.compose.runtime.rememberCoroutineScope
19+
import androidx.compose.runtime.setValue
1320
import androidx.compose.ui.Modifier
1421
import androidx.compose.ui.input.nestedscroll.nestedScroll
22+
import androidx.compose.ui.platform.LocalContext
1523
import androidx.compose.ui.res.stringResource
1624
import androidx.compose.ui.unit.dp
1725
import compose.icons.FontAwesomeIcons
1826
import compose.icons.fontawesomeicons.Solid
27+
import compose.icons.fontawesomeicons.solid.FileExport
1928
import compose.icons.fontawesomeicons.solid.Plus
2029
import dev.dimension.flare.R
30+
import dev.dimension.flare.common.ComposeInAppNotification
2131
import dev.dimension.flare.ui.component.BackButton
2232
import dev.dimension.flare.ui.component.FAIcon
2333
import dev.dimension.flare.ui.component.FlareLargeFlexibleTopAppBar
2434
import dev.dimension.flare.ui.component.FlareScaffold
2535
import dev.dimension.flare.ui.model.UiRssSource
36+
import dev.dimension.flare.ui.presenter.home.rss.ExportOPMLPresenter
2637
import dev.dimension.flare.ui.presenter.invoke
2738
import dev.dimension.flare.ui.theme.screenHorizontalPadding
39+
import kotlinx.coroutines.launch
2840
import moe.tlaster.precompose.molecule.producePresenter
41+
import org.koin.compose.koinInject
2942

3043
@OptIn(ExperimentalMaterial3Api::class)
3144
@Composable
@@ -35,15 +48,38 @@ internal fun RssSourcesScreen(
3548
onClicked: (UiRssSource) -> Unit,
3649
onBack: () -> Unit,
3750
) {
51+
val context = LocalContext.current
3852
val topAppBarScrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
39-
val state by producePresenter { presenter() }
53+
val state by producePresenter {
54+
presenter(context)
55+
}
56+
val filePicker =
57+
rememberLauncherForActivityResult(
58+
ActivityResultContracts.CreateDocument("text/xml"),
59+
) {
60+
if (it != null) {
61+
state.export(it)
62+
}
63+
}
4064
FlareScaffold(
4165
topBar = {
4266
FlareLargeFlexibleTopAppBar(
4367
title = {
4468
Text(text = stringResource(R.string.rss_sources_title))
4569
},
4670
actions = {
71+
if (state.sources.any()) {
72+
IconButton(
73+
onClick = {
74+
filePicker.launch("flare_export.opml")
75+
},
76+
) {
77+
FAIcon(
78+
FontAwesomeIcons.Solid.FileExport,
79+
contentDescription = stringResource(R.string.opml_export),
80+
)
81+
}
82+
}
4783
IconButton(
4884
onClick = {
4985
onAdd.invoke()
@@ -82,7 +118,31 @@ internal fun RssSourcesScreen(
82118
}
83119

84120
@Composable
85-
private fun presenter() =
121+
private fun presenter(context: Context) =
86122
run {
87-
remember { RssListWithTabsPresenter() }.invoke()
123+
val inAppNotification = koinInject<ComposeInAppNotification>()
124+
val exportPresenter = remember { ExportOPMLPresenter() }
125+
val scope = rememberCoroutineScope()
126+
var exporting by remember { mutableStateOf(false) }
127+
val state = remember { RssListWithTabsPresenter() }.invoke()
128+
object : RssListWithTabsPresenter.State by state {
129+
val exporting = exporting
130+
131+
fun export(uri: Uri) {
132+
exporting = true
133+
scope.launch {
134+
runCatching {
135+
exportPresenter.export()
136+
}.getOrNull().let {
137+
if (it != null) {
138+
context.contentResolver.openOutputStream(uri)?.use {
139+
it.write(it.toString().toByteArray())
140+
}
141+
}
142+
}
143+
exporting = false
144+
inAppNotification.message(R.string.export_completed)
145+
}
146+
}
147+
}
88148
}

app/src/main/res/values/strings.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,9 @@
317317
<string name="add_rss_source">Add RSS Source</string>
318318
<string name="edit_rss_source">Edit RSS Source</string>
319319
<string name="delete_rss_source">Delete RSS Source</string>
320+
<string name="opml_import">Import from OPML</string>
321+
<string name="opml_export">Export to OPML</string>
322+
<string name="export_completed">Export completed</string>
320323

321324
<string name="rss_sources_title_label">Title</string>
322325
<string name="rss_sources_url_label">Url</string>

compose-ui/build.gradle.kts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import co.touchlab.skie.configuration.DefaultArgumentInterop
12
import org.jetbrains.compose.compose
23

34
plugins {
@@ -99,6 +100,11 @@ skie {
99100
disableUpload.set(true)
100101
enabled.set(false)
101102
}
103+
features {
104+
group {
105+
DefaultArgumentInterop.Enabled(true)
106+
}
107+
}
102108
}
103109

104110
dependencies {

0 commit comments

Comments
 (0)