Skip to content

Commit 9931604

Browse files
committed
feat: add episode sheet composable
This commit introduces the `EpisodeSheetScreen` composable, which displays detailed information about an episode in a modal bottom sheet. **Key Features:** - **Episode Information:** Displays the episode's title, description, series details, duration, and thumbnail. - **Play Button:** Includes a prominent play button that triggers an action to play the episode. - **Publisher Chip:** Shows an interactive chip representing the episode's publisher. - **Download Button:** Provides a button for downloading the episode. - **Dynamic Content:** Loads episode data dynamically, showing a progress indicator while loading. - **Markdown Support:** Renders episode descriptions using Markdown, allowing for rich text formatting. - **Empty State Handling:** Handles cases where the episode has no description gracefully. - **Preview:** A preview implementation is included. - **Added new preview data class**
1 parent 2f17a89 commit 9931604

File tree

2 files changed

+313
-0
lines changed

2 files changed

+313
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
/*
2+
* Copyright (C) 2025 AniTrend
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
package co.anitrend.episode.component.content.compose
18+
19+
import androidx.compose.foundation.background
20+
import androidx.compose.foundation.layout.Arrangement
21+
import androidx.compose.foundation.layout.Box
22+
import androidx.compose.foundation.layout.Column
23+
import androidx.compose.foundation.layout.Row
24+
import androidx.compose.foundation.layout.Spacer
25+
import androidx.compose.foundation.layout.aspectRatio
26+
import androidx.compose.foundation.layout.fillMaxSize
27+
import androidx.compose.foundation.layout.fillMaxWidth
28+
import androidx.compose.foundation.layout.padding
29+
import androidx.compose.foundation.layout.size
30+
import androidx.compose.foundation.rememberScrollState
31+
import androidx.compose.foundation.shape.CircleShape
32+
import androidx.compose.foundation.shape.RoundedCornerShape
33+
import androidx.compose.foundation.verticalScroll
34+
import androidx.compose.material.icons.Icons
35+
import androidx.compose.material.icons.filled.PlayCircle
36+
import androidx.compose.material.icons.filled.Timer
37+
import androidx.compose.material3.AssistChip
38+
import androidx.compose.material3.Button
39+
import androidx.compose.material3.CircularProgressIndicator
40+
import androidx.compose.material3.ExperimentalMaterial3Api
41+
import androidx.compose.material3.Icon
42+
import androidx.compose.material3.MaterialTheme
43+
import androidx.compose.material3.ModalBottomSheet
44+
import androidx.compose.material3.SheetValue
45+
import androidx.compose.material3.Text
46+
import androidx.compose.material3.rememberModalBottomSheetState
47+
import androidx.compose.runtime.Composable
48+
import androidx.compose.runtime.getValue
49+
import androidx.compose.runtime.livedata.observeAsState
50+
import androidx.compose.runtime.mutableStateOf
51+
import androidx.compose.runtime.remember
52+
import androidx.compose.runtime.setValue
53+
import androidx.compose.ui.Alignment
54+
import androidx.compose.ui.Modifier
55+
import androidx.compose.ui.res.stringResource
56+
import androidx.compose.ui.tooling.preview.PreviewParameter
57+
import androidx.compose.ui.unit.dp
58+
import co.anitrend.android.core.compose.design.image.AniTrendImage
59+
import co.anitrend.android.core.helpers.image.model.RequestImage
60+
import co.anitrend.android.core.koin.MarkdownFlavour
61+
import co.anitrend.android.core.ui.AniTrendPreview
62+
import co.anitrend.android.core.ui.theme.preview.DarkThemeProvider
63+
import co.anitrend.android.core.ui.theme.preview.PreviewTheme
64+
import co.anitrend.common.markdown.ui.compose.MarkdownText
65+
import co.anitrend.domain.episode.entity.Episode
66+
import co.anitrend.episode.component.sheet.viewmodel.EpisodeSheetViewModel
67+
68+
@Composable
69+
private fun EpisodeSheetContent(
70+
modifier: Modifier = Modifier,
71+
episode: Episode,
72+
onPlayClick: (String) -> Unit = {},
73+
onPublisherClick: () -> Unit = {},
74+
onDownloadClick: () -> Unit = {},
75+
) {
76+
val scrollState = rememberScrollState()
77+
Column(
78+
modifier =
79+
modifier
80+
.verticalScroll(scrollState)
81+
.padding(bottom = 32.dp),
82+
) {
83+
Box(
84+
modifier =
85+
Modifier
86+
.fillMaxWidth()
87+
.aspectRatio(1.85f),
88+
) {
89+
AniTrendImage(
90+
image = episode.thumbnail,
91+
imageType = RequestImage.Media.ImageType.BANNER,
92+
modifier = Modifier.fillMaxSize(),
93+
onClick = { onPlayClick(episode.guid) },
94+
)
95+
96+
// Duration badge
97+
Row(
98+
modifier =
99+
Modifier
100+
.padding(16.dp)
101+
.background(
102+
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f),
103+
shape = RoundedCornerShape(16.dp),
104+
).padding(8.dp)
105+
.align(Alignment.TopEnd),
106+
verticalAlignment = Alignment.CenterVertically,
107+
horizontalArrangement = Arrangement.spacedBy(8.dp),
108+
) {
109+
Text(
110+
text = episode.about.episodeDuration,
111+
style = MaterialTheme.typography.bodySmall,
112+
color = MaterialTheme.colorScheme.onSurface,
113+
)
114+
Icon(
115+
imageVector = Icons.Default.Timer,
116+
contentDescription = null,
117+
tint = MaterialTheme.colorScheme.onSurface,
118+
)
119+
}
120+
121+
// Play button
122+
Icon(
123+
imageVector = Icons.Default.PlayCircle,
124+
contentDescription = null,
125+
modifier =
126+
Modifier
127+
.size(42.dp)
128+
.background(
129+
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f),
130+
shape = CircleShape,
131+
).size(84.dp)
132+
.align(Alignment.Center),
133+
tint = MaterialTheme.colorScheme.primary,
134+
)
135+
}
136+
137+
Spacer(modifier = Modifier.size(12.dp))
138+
139+
Text(
140+
text = episode.title,
141+
style = MaterialTheme.typography.bodyLarge,
142+
modifier =
143+
Modifier
144+
.fillMaxWidth()
145+
.padding(horizontal = 16.dp),
146+
)
147+
148+
Spacer(modifier = Modifier.size(8.dp))
149+
150+
// Publisher chip
151+
AssistChip(
152+
onClick = onPublisherClick,
153+
label = {
154+
Text(text = episode.series.seriesPublisher ?: "")
155+
},
156+
modifier =
157+
Modifier
158+
.padding(horizontal = 16.dp),
159+
)
160+
161+
Spacer(modifier = Modifier.size(8.dp))
162+
// Episode description
163+
MarkdownText(
164+
content =
165+
if (episode.description.isNullOrBlank()) {
166+
episode.about.episodeTitle?.let {
167+
stringResource(
168+
co.anitrend.episode.R.string.label_episode_has_no_summary,
169+
it,
170+
)
171+
}
172+
} else {
173+
episode.description
174+
},
175+
flavour = MarkdownFlavour.STANDARD,
176+
modifier =
177+
Modifier
178+
.fillMaxWidth()
179+
.padding(horizontal = 16.dp),
180+
)
181+
182+
Spacer(modifier = Modifier.size(8.dp))
183+
184+
// Download button
185+
186+
Button(
187+
onClick = onDownloadClick,
188+
modifier =
189+
Modifier
190+
.padding(horizontal = 16.dp)
191+
.align(Alignment.End),
192+
) {
193+
Text(text = stringResource(id = co.anitrend.episode.R.string.label_download))
194+
}
195+
}
196+
}
197+
198+
@OptIn(ExperimentalMaterial3Api::class)
199+
@Composable
200+
fun EpisodeSheetScreen(
201+
viewModel: EpisodeSheetViewModel,
202+
onPlayClick: (String) -> Unit = {},
203+
onPublisherClick: () -> Unit = {},
204+
onDownloadClick: () -> Unit = {},
205+
onDismiss: () -> Unit,
206+
) {
207+
var showSheet by remember { mutableStateOf(true) }
208+
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
209+
210+
val sheetShape =
211+
if (sheetState.currentValue == SheetValue.Expanded) {
212+
RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)
213+
} else {
214+
RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp)
215+
}
216+
217+
if (showSheet) {
218+
ModalBottomSheet(
219+
dragHandle = null,
220+
onDismissRequest = {
221+
showSheet = false
222+
onDismiss()
223+
},
224+
sheetState = sheetState,
225+
shape = sheetShape,
226+
) {
227+
val model by viewModel.model.observeAsState()
228+
when (val episode = model) {
229+
null ->
230+
Box(modifier = Modifier.fillMaxWidth()) {
231+
CircularProgressIndicator(
232+
modifier =
233+
Modifier
234+
.size(24.dp)
235+
.padding(16.dp)
236+
.align(alignment = Alignment.Center),
237+
)
238+
}
239+
else ->
240+
EpisodeSheetContent(
241+
episode = episode,
242+
onPlayClick = onPlayClick,
243+
onPublisherClick = onPublisherClick,
244+
onDownloadClick = onDownloadClick,
245+
)
246+
}
247+
}
248+
}
249+
}
250+
251+
@AniTrendPreview.Default
252+
@Composable
253+
private fun EpisodeSheetScreenPreview(
254+
@PreviewParameter(DarkThemeProvider::class) darkTheme: Boolean,
255+
) {
256+
PreviewTheme(darkTheme = darkTheme, wrapInSurface = true) {
257+
EpisodeSheetContent(
258+
episode = PREVIEW_EPISODE,
259+
onPlayClick = {},
260+
onPublisherClick = {},
261+
onDownloadClick = {},
262+
)
263+
}
264+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/*
2+
* Copyright (C) 2025 AniTrend
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
package co.anitrend.episode.component.content.compose
18+
19+
import co.anitrend.domain.common.entity.shared.CoverImage
20+
import co.anitrend.domain.episode.entity.Episode
21+
22+
val PREVIEW_EPISODE =
23+
Episode(
24+
id = 1L,
25+
title = "S01E02 • Special Ops Squad - Night Before the Counteroffensive (2)",
26+
guid = "",
27+
mediaId = 642191L,
28+
description =
29+
"After the Inquiry Eren is assigned to the Survey Team's special operations squad, known as" +
30+
" \"Squad Levi.\" The Squad is composed of the best troops the survey team has, but they're all very strange people. " +
31+
"With a major mission 30 days away, Eren hears from Hanji about the experiments on the titans from Trost.",
32+
subtitles = emptyList(),
33+
series =
34+
Episode.Series(
35+
seriesTitle = "Attack on Titan",
36+
seriesPublisher = "Funimation",
37+
seriesSeason = "S01",
38+
keywords = emptyList(),
39+
rating = "",
40+
),
41+
thumbnail = CoverImage(large = "", medium = ""),
42+
availability = Episode.Availability(freeTime = 0, premiumTime = 0),
43+
about =
44+
Episode.About(
45+
episodeDuration = "23:46",
46+
episodeTitle = "Special Ops Squad",
47+
episodeNumber = "E02",
48+
),
49+
)

0 commit comments

Comments
 (0)