Skip to content

Commit 3101498

Browse files
authored
Merge pull request #523 from akitikkx/feature/notifications-and-providers
feat: notifications and watch providers
2 parents c43d745 + 860bff2 commit 3101498

File tree

24 files changed

+898
-47
lines changed

24 files changed

+898
-47
lines changed
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
name: Feature Development
3+
description: Strict guidelines and workflow for developing new features, ensuring quality, proper branching, UI/UX standards, and comprehensive testing in Upnext.
4+
---
5+
6+
# Feature Development Skill
7+
8+
This skill outlines the strict workflow and quality standards that must be followed when developing new features in the Upnext project.
9+
10+
## 🌿 1. Branching Strategy
11+
- **Always work on a fresh branch** created off the latest `main` branch.
12+
- **Naming convention**: Use `feature/<feature-name>` for new features (e.g., `feature/episode-details`).
13+
- Ensure you have the latest code before branching off to avoid conflicts.
14+
15+
## 🎨 2. UI/UX & Design Standards
16+
- **Refer to the [Frontend Design Skill](../frontend_design/SKILL.md)** for UI/UX best practices.
17+
- Ensure all screens follow the established design flow and architecture.
18+
- Use the existing design system tokens, Material 3 components, and adaptive layouts.
19+
- Do not take shortcuts with UI implementation; visual excellence and smooth user experience are critical.
20+
21+
## 🏗️ 3. Code Architecture & Quality
22+
- Maintain a **high-quality approach** to feature development.
23+
- Adhere to the established app architecture (Clean Architecture, MVVM, Unidirectional Data Flow).
24+
- Avoid hacks, technical debt, or shortcuts. Code must be production-ready and scalable.
25+
- Ensure proper separation of concerns (UI, Domain, Data layers).
26+
27+
## 🧪 4. Testing & Regressions
28+
- **No regressions**: Ensure that new changes do not break existing Unit or Instrumented tests.
29+
- **High Test Coverage**: Add comprehensive tests for all new code.
30+
- Write Unit Tests (JUnit, Mockito, Turbine) for ViewModels, Repositories, and Domain logic.
31+
- Write/Update Instrumented Tests for UI components and Navigation flows where applicable.
32+
- Refer to the **[Android Testing Skill](../android_testing/SKILL.md)** for testing best practices.
33+
34+
## 🔍 5. Code Analysis & Formatting
35+
- **Linting & Detekt**: You must run code analysis tools before finalizing the feature.
36+
- Run `./gradlew :app:ktlintFormat` to format styling.
37+
- Run `./gradlew :app:detekt` to ensure no static analysis rules are broken.
38+
- **Build Verification**: Run `./gradlew :app:assembleDebug` and `./gradlew :app:testDebugUnitTest` to ensure the project compiles and tests pass successfully locally.
39+
- Fix any formatting, linting, or analysis errors immediately.
40+
41+
## 🚀 6. Commit & Push Protocol
42+
- **Only commit and push once all the above steps are completed and verified locally.**
43+
- Commit messages should be clear, descriptive, and follow conventional commit formats.
44+
- **CI Safety**: Ensure that your local verifications are thorough so that Continuous Integration (CI) is NOT the place where bugs, broken tests, or Detekt violations are discovered. All code must be validated locally before the push.

app/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ dependencies {
198198
// Navigation and Serialization
199199
implementation libs.androidx.navigation.compose
200200
implementation libs.kotlinx.serialization.json
201+
implementation libs.accompanist.permissions
201202

202203
// Coroutines
203204
implementation libs.kotlinx.coroutines.core

app/src/main/AndroidManifest.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
<uses-permission android:name="android.permission.INTERNET" />
2626
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
27+
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
2728

2829
<uses-permission android:name="android.permission.WAKE_LOCK" />
2930

@@ -58,6 +59,9 @@
5859
<data
5960
android:host="callback"
6061
android:scheme="theupnextapp" />
62+
<data
63+
android:host="episode"
64+
android:scheme="theupnextapp" />
6165
</intent-filter>
6266
</activity>
6367
<meta-data

app/src/main/java/com/theupnextapp/navigation/Destinations.kt

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.theupnextapp.navigation
22

3+
import com.theupnextapp.domain.EpisodeDetailArg
34
import com.theupnextapp.domain.ShowDetailArg
45
import com.theupnextapp.domain.ShowSeasonEpisodesArg
56
import kotlinx.serialization.Serializable
@@ -95,6 +96,32 @@ sealed interface Destinations {
9596
)
9697
}
9798

99+
@Serializable
100+
data class EpisodeDetail(
101+
val showTraktId: Int,
102+
val seasonNumber: Int,
103+
val episodeNumber: Int,
104+
val showTitle: String? = null,
105+
val showId: Int? = null,
106+
val imdbID: String? = null,
107+
val isAuthorizedOnTrakt: Boolean? = false,
108+
val showImageUrl: String? = null,
109+
val showBackgroundUrl: String? = null,
110+
) : Destinations {
111+
fun toArg() =
112+
EpisodeDetailArg(
113+
showTraktId = showTraktId,
114+
seasonNumber = seasonNumber,
115+
episodeNumber = episodeNumber,
116+
showTitle = showTitle,
117+
showId = showId,
118+
imdbID = imdbID,
119+
isAuthorizedOnTrakt = isAuthorizedOnTrakt,
120+
showImageUrl = showImageUrl,
121+
showBackgroundUrl = showBackgroundUrl,
122+
)
123+
}
124+
98125
@Serializable
99126
data object EmptyDetail : Destinations
100127
}

app/src/main/java/com/theupnextapp/ui/dashboard/DashboardScreen.kt

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -462,13 +462,14 @@ fun DashboardScreen(
462462
.width(160.dp)
463463
.clickable {
464464
val direction =
465-
Destinations.ShowSeasonEpisodes(
465+
Destinations.EpisodeDetail(
466+
showTraktId = traktId ?: 0,
467+
seasonNumber = season ?: 0,
468+
episodeNumber = number ?: 0,
469+
showTitle = historyItem.show?.title,
466470
showId = tvmazeId,
467-
seasonNumber = historyItem.episode?.season,
468471
imdbID = imdbId,
469472
isAuthorizedOnTrakt = true,
470-
showTraktId = traktId,
471-
showTitle = historyItem.show?.title,
472473
showImageUrl = imageUrl,
473474
)
474475
navController.navigate(direction)
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
/*
2+
* MIT License
3+
*
4+
* Copyright (c) 2026 Ahmed Tikiwa
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
7+
* associated documentation files (the "Software"), to deal in the Software without restriction,
8+
* including without limitation the rights to use, copy, modify, merge, publish, distribute,
9+
* sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
10+
* furnished to do so, subject to the following conditions:
11+
*/
12+
13+
package com.theupnextapp.ui.episodeDetail
14+
15+
import androidx.compose.foundation.background
16+
import androidx.compose.foundation.layout.Arrangement
17+
import androidx.compose.foundation.layout.Box
18+
import androidx.compose.foundation.layout.Column
19+
import androidx.compose.foundation.layout.Row
20+
import androidx.compose.foundation.layout.Spacer
21+
import androidx.compose.foundation.layout.WindowInsets
22+
import androidx.compose.foundation.layout.asPaddingValues
23+
import androidx.compose.foundation.layout.fillMaxSize
24+
import androidx.compose.foundation.layout.fillMaxWidth
25+
import androidx.compose.foundation.layout.height
26+
import androidx.compose.foundation.layout.padding
27+
import androidx.compose.foundation.layout.statusBars
28+
import androidx.compose.foundation.rememberScrollState
29+
import androidx.compose.foundation.verticalScroll
30+
import androidx.compose.material.icons.Icons
31+
import androidx.compose.material.icons.filled.ArrowBack
32+
import androidx.compose.material.icons.filled.Star
33+
import androidx.compose.material3.CircularProgressIndicator
34+
import androidx.compose.material3.ElevatedCard
35+
import androidx.compose.material3.ExperimentalMaterial3Api
36+
import androidx.compose.material3.Icon
37+
import androidx.compose.material3.IconButton
38+
import androidx.compose.material3.MaterialTheme
39+
import androidx.compose.material3.Scaffold
40+
import androidx.compose.material3.Text
41+
import androidx.compose.material3.TopAppBar
42+
import androidx.compose.material3.TopAppBarDefaults
43+
import androidx.compose.runtime.Composable
44+
import androidx.compose.runtime.collectAsState
45+
import androidx.compose.runtime.getValue
46+
import androidx.compose.ui.Alignment
47+
import androidx.compose.ui.Modifier
48+
import androidx.compose.ui.graphics.Brush
49+
import androidx.compose.ui.graphics.Color
50+
import androidx.compose.ui.layout.ContentScale
51+
import androidx.compose.ui.platform.LocalContext
52+
import androidx.compose.ui.res.stringResource
53+
import androidx.compose.ui.text.font.FontWeight
54+
import androidx.compose.ui.unit.dp
55+
import androidx.hilt.navigation.compose.hiltViewModel
56+
import androidx.navigation.NavController
57+
import coil.compose.AsyncImage
58+
import coil.request.ImageRequest
59+
import com.theupnextapp.R
60+
import com.theupnextapp.domain.EpisodeDetailArg
61+
import java.text.SimpleDateFormat
62+
import java.util.Locale
63+
64+
@OptIn(ExperimentalMaterial3Api::class)
65+
@Suppress("MagicNumber")
66+
@Composable
67+
fun EpisodeDetailScreen(
68+
episodeDetailArg: EpisodeDetailArg?,
69+
viewModel: EpisodeDetailViewModel = hiltViewModel(),
70+
navController: NavController,
71+
) {
72+
val uiState by viewModel.uiState.collectAsState()
73+
val scrollState = rememberScrollState()
74+
75+
Scaffold(
76+
topBar = {
77+
TopAppBar(
78+
title = { Text(text = episodeDetailArg?.showTitle ?: stringResource(id = R.string.title_episode_detail)) },
79+
navigationIcon = {
80+
IconButton(onClick = { navController.popBackStack() }) {
81+
Icon(
82+
imageVector = Icons.Default.ArrowBack,
83+
contentDescription = stringResource(id = R.string.back_arrow_content_description),
84+
)
85+
}
86+
},
87+
colors =
88+
TopAppBarDefaults.topAppBarColors(
89+
containerColor = Color.Transparent,
90+
scrolledContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f),
91+
),
92+
)
93+
},
94+
) { paddingValues ->
95+
Box(
96+
modifier =
97+
Modifier
98+
.fillMaxSize()
99+
.padding(bottom = paddingValues.calculateBottomPadding()),
100+
) {
101+
val backdropUrl = episodeDetailArg?.showBackgroundUrl ?: episodeDetailArg?.showImageUrl
102+
if (!backdropUrl.isNullOrEmpty()) {
103+
AsyncImage(
104+
model =
105+
ImageRequest.Builder(LocalContext.current)
106+
.data(backdropUrl)
107+
.crossfade(true)
108+
.build(),
109+
contentDescription = "Show Backdrop",
110+
modifier =
111+
Modifier
112+
.fillMaxWidth()
113+
.height(350.dp),
114+
contentScale = ContentScale.Crop,
115+
)
116+
Box(
117+
modifier =
118+
Modifier
119+
.fillMaxWidth()
120+
.height(350.dp)
121+
.background(
122+
Brush.verticalGradient(
123+
colors =
124+
listOf(
125+
Color.Transparent,
126+
MaterialTheme.colorScheme.background,
127+
),
128+
startY = 100f,
129+
),
130+
),
131+
)
132+
}
133+
134+
Box(
135+
modifier =
136+
Modifier
137+
.fillMaxSize()
138+
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 56.dp),
139+
) {
140+
when {
141+
uiState.isLoading -> {
142+
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
143+
}
144+
uiState.error != null -> {
145+
Text(
146+
text = stringResource(R.string.error_fetching_episode_details),
147+
color = MaterialTheme.colorScheme.error,
148+
modifier = Modifier.align(Alignment.Center),
149+
)
150+
}
151+
uiState.episodeDetail != null -> {
152+
Column(
153+
modifier =
154+
Modifier
155+
.fillMaxSize()
156+
.verticalScroll(scrollState),
157+
) {
158+
Spacer(modifier = Modifier.height(if (!backdropUrl.isNullOrEmpty()) 140.dp else 16.dp))
159+
160+
ElevatedCard(
161+
modifier =
162+
Modifier
163+
.fillMaxWidth()
164+
.padding(horizontal = 16.dp, vertical = 16.dp),
165+
shape = MaterialTheme.shapes.extraLarge,
166+
) {
167+
Column(
168+
modifier = Modifier.padding(24.dp),
169+
verticalArrangement = Arrangement.spacedBy(16.dp),
170+
) {
171+
Text(
172+
text = uiState.episodeDetail?.title ?: stringResource(id = R.string.title_unknown),
173+
style = MaterialTheme.typography.headlineMedium,
174+
fontWeight = FontWeight.Bold,
175+
)
176+
177+
Row(
178+
modifier = Modifier.fillMaxWidth(),
179+
horizontalArrangement = Arrangement.SpaceBetween,
180+
verticalAlignment = Alignment.CenterVertically,
181+
) {
182+
Text(
183+
text = "Season ${uiState.episodeDetail?.season} • Episode ${uiState.episodeDetail?.number}",
184+
style = MaterialTheme.typography.titleMedium,
185+
color = MaterialTheme.colorScheme.secondary,
186+
)
187+
188+
if (uiState.episodeDetail?.rating != null && uiState.episodeDetail?.rating!! > 0.0) {
189+
Row(verticalAlignment = Alignment.CenterVertically) {
190+
Icon(
191+
imageVector = Icons.Default.Star,
192+
contentDescription = "Rating",
193+
tint = Color(0xFFFFC107),
194+
modifier = Modifier.padding(end = 4.dp),
195+
)
196+
Text(
197+
text = String.format(Locale.getDefault(), "%.1f", uiState.episodeDetail?.rating),
198+
style = MaterialTheme.typography.bodyLarge,
199+
fontWeight = FontWeight.Medium,
200+
)
201+
}
202+
}
203+
}
204+
205+
uiState.episodeDetail?.firstAired?.let { aired ->
206+
Text(
207+
text = "Aired: ${formatDate(aired)}",
208+
style = MaterialTheme.typography.bodyMedium,
209+
color = MaterialTheme.colorScheme.onSurfaceVariant,
210+
)
211+
}
212+
213+
Spacer(modifier = Modifier.height(8.dp))
214+
215+
Text(
216+
text = "Overview",
217+
style = MaterialTheme.typography.titleMedium,
218+
fontWeight = FontWeight.SemiBold,
219+
)
220+
221+
Text(
222+
text = uiState.episodeDetail?.overview ?: stringResource(id = R.string.no_overview_available),
223+
style = MaterialTheme.typography.bodyLarge,
224+
lineHeight = MaterialTheme.typography.bodyLarge.lineHeight * 1.5f,
225+
color = MaterialTheme.colorScheme.onSurfaceVariant,
226+
)
227+
}
228+
}
229+
Spacer(modifier = Modifier.height(32.dp))
230+
}
231+
}
232+
}
233+
}
234+
}
235+
}
236+
}
237+
238+
private fun formatDate(dateString: String): String {
239+
return try {
240+
val parser = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault())
241+
val formatter = SimpleDateFormat("MMM d, yyyy", Locale.getDefault())
242+
parser.parse(dateString)?.let { formatter.format(it) } ?: dateString
243+
} catch (e: Exception) {
244+
dateString
245+
}
246+
}

0 commit comments

Comments
 (0)