Skip to content

Commit 28c6abf

Browse files
committed
feat: added trending FAQs, news details, and attachments
1 parent b0bbc47 commit 28c6abf

17 files changed

Lines changed: 558 additions & 16 deletions

File tree

mobile/androidApp/src/main/kotlin/app/myfaq/android/Navigation.kt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import app.myfaq.android.screens.CategoriesScreen
2828
import app.myfaq.android.screens.FaqDetailScreen
2929
import app.myfaq.android.screens.FaqListScreen
3030
import app.myfaq.android.screens.HomeScreen
31+
import app.myfaq.android.screens.NewsDetailScreen
3132
import app.myfaq.android.screens.PaywallScreen
3233
import app.myfaq.android.screens.SearchScreen
3334
import app.myfaq.android.screens.SettingsScreen
@@ -46,6 +47,7 @@ object Routes {
4647
const val CATEGORIES = "categories"
4748
const val FAQ_LIST = "categories/{categoryId}/{categoryName}"
4849
const val FAQ_DETAIL = "faq/{categoryId}/{faqId}"
50+
const val NEWS_DETAIL = "news/{newsId}"
4951
const val SEARCH = "search"
5052
const val SETTINGS = "settings"
5153
const val PAYWALL = "paywall"
@@ -59,6 +61,8 @@ object Routes {
5961
categoryId: Int,
6062
faqId: Int,
6163
): String = "faq/$categoryId/$faqId"
64+
65+
fun newsDetail(newsId: Int): String = "news/$newsId"
6266
}
6367

6468
// ── Bottom-bar tabs ────────────────────────────────────────────────
@@ -145,6 +149,9 @@ fun MyFaqNavHost(aim: ActiveInstanceManager = koinInject()) {
145149
onFaqClick = { categoryId, faqId ->
146150
navController.navigate(Routes.faqDetail(categoryId, faqId))
147151
},
152+
onNewsClick = { newsId ->
153+
navController.navigate(Routes.newsDetail(newsId))
154+
},
148155
)
149156
}
150157

@@ -197,6 +204,17 @@ fun MyFaqNavHost(aim: ActiveInstanceManager = koinInject()) {
197204
)
198205
}
199206

207+
composable(
208+
route = Routes.NEWS_DETAIL,
209+
arguments = listOf(navArgument("newsId") { type = NavType.IntType }),
210+
) { backStackEntry ->
211+
val newsId = backStackEntry.arguments?.getInt("newsId") ?: 0
212+
NewsDetailScreen(
213+
newsId = newsId,
214+
onBack = { navController.popBackStack() },
215+
)
216+
}
217+
200218
composable(Routes.SEARCH) {
201219
SearchScreen(
202220
onFaqClick = { categoryId, faqId ->

mobile/androidApp/src/main/kotlin/app/myfaq/android/screens/FaqDetailScreen.kt

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package app.myfaq.android.screens
22

33
import android.annotation.SuppressLint
44
import android.content.Intent
5+
import android.net.Uri
56
import android.webkit.WebView
67
import androidx.compose.animation.AnimatedVisibility
78
import androidx.compose.foundation.isSystemInDarkTheme
@@ -67,6 +68,7 @@ fun FaqDetailScreen(
6768
val vm = remember { FaqDetailViewModel(aim) }
6869
val faqState by vm.faq.collectAsState()
6970
val commentsState by vm.comments.collectAsState()
71+
val attachmentsState by vm.attachments.collectAsState()
7072

7173
LaunchedEffect(categoryId, faqId) { vm.load(categoryId, faqId) }
7274

@@ -110,6 +112,7 @@ fun FaqDetailScreen(
110112
FaqDetailContent(
111113
faq = s.data,
112114
commentsState = commentsState,
115+
attachmentsState = attachmentsState,
113116
onPaywall = onPaywall,
114117
modifier = Modifier.padding(padding),
115118
)
@@ -123,6 +126,7 @@ fun FaqDetailScreen(
123126
private fun FaqDetailContent(
124127
faq: FaqDetail,
125128
commentsState: UiState<List<Comment>>,
129+
attachmentsState: UiState<List<app.myfaq.shared.api.dto.Attachment>>,
126130
onPaywall: () -> Unit,
127131
modifier: Modifier = Modifier,
128132
) {
@@ -215,6 +219,12 @@ private fun FaqDetailContent(
215219
}
216220
}
217221

222+
// Attachments
223+
if (attachmentsState is UiState.Success && (attachmentsState as UiState.Success).data.isNotEmpty()) {
224+
Spacer(modifier = Modifier.height(12.dp))
225+
AttachmentsSection((attachmentsState as UiState.Success).data)
226+
}
227+
218228
Spacer(modifier = Modifier.height(16.dp))
219229

220230
// Rate button (paywall)
@@ -234,6 +244,25 @@ private fun FaqDetailContent(
234244
}
235245
}
236246

247+
@Composable
248+
private fun AttachmentsSection(attachments: List<app.myfaq.shared.api.dto.Attachment>) {
249+
val context = LocalContext.current
250+
Text("Attachments", style = MaterialTheme.typography.titleSmall)
251+
Spacer(modifier = Modifier.height(4.dp))
252+
attachments.forEach { attachment ->
253+
TextButton(
254+
onClick = {
255+
val url = attachment.url
256+
if (!url.isNullOrBlank()) {
257+
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
258+
}
259+
},
260+
) {
261+
Text("📎 ${attachment.filename}")
262+
}
263+
}
264+
}
265+
237266
@Composable
238267
private fun CommentsSection(state: UiState<List<Comment>>) {
239268
var expanded by remember { mutableStateOf(false) }

mobile/androidApp/src/main/kotlin/app/myfaq/android/screens/HomeScreen.kt

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package app.myfaq.android.screens
22

3+
import androidx.compose.foundation.clickable
34
import androidx.compose.foundation.layout.Arrangement
45
import androidx.compose.foundation.layout.Box
56
import androidx.compose.foundation.layout.Column
@@ -44,13 +45,15 @@ private enum class HomeTab(
4445
Sticky("Sticky"),
4546
Popular("Popular"),
4647
Latest("Latest"),
48+
Trending("Trending"),
4749
News("News"),
4850
}
4951

5052
@OptIn(ExperimentalMaterial3Api::class)
5153
@Composable
5254
fun HomeScreen(
5355
onFaqClick: (categoryId: Int, faqId: Int) -> Unit,
56+
onNewsClick: (newsId: Int) -> Unit = {},
5457
aim: ActiveInstanceManager = koinInject(),
5558
) {
5659
val vm = remember { HomeViewModel(aim) }
@@ -117,11 +120,25 @@ fun HomeScreen(
117120
}
118121
},
119122
)
123+
HomeTab.Trending ->
124+
FaqTabContent(
125+
state = vm.trending.collectAsState().value,
126+
onRetry = { vm.loadTrending() },
127+
onRefresh = { vm.loadTrending() },
128+
onItemClick = { item ->
129+
val catId = item.parsedCategoryId
130+
val faqId = item.parsedFaqId
131+
if (catId != null && faqId != null) {
132+
onFaqClick(catId, faqId)
133+
}
134+
},
135+
)
120136
HomeTab.News ->
121137
NewsTabContent(
122138
state = vm.news.collectAsState().value,
123139
onRetry = { vm.loadNews() },
124140
onRefresh = { vm.loadNews() },
141+
onNewsClick = onNewsClick,
125142
)
126143
}
127144
}
@@ -182,6 +199,7 @@ private fun NewsTabContent(
182199
state: UiState<List<NewsItem>>,
183200
onRetry: () -> Unit,
184201
onRefresh: () -> Unit,
202+
onNewsClick: (newsId: Int) -> Unit = {},
185203
) {
186204
when (state) {
187205
is UiState.Loading -> LoadingIndicator()
@@ -210,7 +228,7 @@ private fun NewsTabContent(
210228
verticalArrangement = Arrangement.spacedBy(12.dp),
211229
) {
212230
items(state.data, key = { it.id }) { news ->
213-
NewsCard(news)
231+
NewsCard(news, onClick = { onNewsClick(news.id) })
214232
}
215233
}
216234
}
@@ -220,8 +238,8 @@ private fun NewsTabContent(
220238
}
221239

222240
@Composable
223-
private fun NewsCard(news: NewsItem) {
224-
Card(modifier = Modifier.fillMaxWidth()) {
241+
private fun NewsCard(news: NewsItem, onClick: () -> Unit = {}) {
242+
Card(modifier = Modifier.fillMaxWidth().clickable(onClick = onClick)) {
225243
Column(modifier = Modifier.padding(16.dp)) {
226244
Text(
227245
text = news.header,
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
package app.myfaq.android.screens
2+
3+
import android.annotation.SuppressLint
4+
import android.webkit.WebView
5+
import androidx.compose.foundation.isSystemInDarkTheme
6+
import androidx.compose.foundation.layout.Column
7+
import androidx.compose.foundation.layout.Spacer
8+
import androidx.compose.foundation.layout.fillMaxSize
9+
import androidx.compose.foundation.layout.fillMaxWidth
10+
import androidx.compose.foundation.layout.height
11+
import androidx.compose.foundation.layout.padding
12+
import androidx.compose.foundation.rememberScrollState
13+
import androidx.compose.foundation.verticalScroll
14+
import androidx.compose.material.icons.Icons
15+
import androidx.compose.material.icons.automirrored.filled.ArrowBack
16+
import androidx.compose.material3.ExperimentalMaterial3Api
17+
import androidx.compose.material3.Icon
18+
import androidx.compose.material3.IconButton
19+
import androidx.compose.material3.MaterialTheme
20+
import androidx.compose.material3.Scaffold
21+
import androidx.compose.material3.Text
22+
import androidx.compose.material3.TopAppBar
23+
import androidx.compose.runtime.Composable
24+
import androidx.compose.runtime.LaunchedEffect
25+
import androidx.compose.runtime.collectAsState
26+
import androidx.compose.runtime.getValue
27+
import androidx.compose.runtime.mutableStateOf
28+
import androidx.compose.runtime.remember
29+
import androidx.compose.runtime.setValue
30+
import androidx.compose.ui.Modifier
31+
import androidx.compose.ui.graphics.toArgb
32+
import androidx.compose.ui.platform.LocalContext
33+
import androidx.compose.ui.unit.dp
34+
import androidx.compose.ui.viewinterop.AndroidView
35+
import app.myfaq.android.screens.components.ErrorRetry
36+
import app.myfaq.android.screens.components.LoadingIndicator
37+
import app.myfaq.shared.api.dto.NewsItem
38+
import app.myfaq.shared.data.ActiveInstanceManager
39+
import app.myfaq.shared.ui.NewsDetailViewModel
40+
import app.myfaq.shared.ui.UiState
41+
import org.koin.compose.koinInject
42+
43+
@OptIn(ExperimentalMaterial3Api::class)
44+
@Composable
45+
fun NewsDetailScreen(
46+
newsId: Int,
47+
onBack: () -> Unit,
48+
aim: ActiveInstanceManager = koinInject(),
49+
) {
50+
val vm = remember { NewsDetailViewModel(aim, newsId) }
51+
val state by vm.news.collectAsState()
52+
53+
LaunchedEffect(newsId) { vm.load() }
54+
55+
Scaffold(
56+
topBar = {
57+
TopAppBar(
58+
title = { Text("News") },
59+
navigationIcon = {
60+
IconButton(onClick = onBack) {
61+
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
62+
}
63+
},
64+
)
65+
},
66+
) { padding ->
67+
when (val s = state) {
68+
is UiState.Loading -> LoadingIndicator(modifier = Modifier.padding(padding))
69+
is UiState.Error -> ErrorRetry(
70+
message = s.message,
71+
onRetry = { vm.load() },
72+
modifier = Modifier.padding(padding),
73+
)
74+
is UiState.Success -> NewsDetailContent(
75+
news = s.data,
76+
modifier = Modifier.padding(padding),
77+
)
78+
}
79+
}
80+
}
81+
82+
@Composable
83+
private fun NewsDetailContent(
84+
news: NewsItem,
85+
modifier: Modifier = Modifier,
86+
) {
87+
val isDark = isSystemInDarkTheme()
88+
val bgColor = MaterialTheme.colorScheme.surface.toArgb()
89+
val textColor = MaterialTheme.colorScheme.onSurface.toArgb()
90+
91+
Column(
92+
modifier = modifier
93+
.fillMaxSize()
94+
.verticalScroll(rememberScrollState())
95+
.padding(16.dp),
96+
) {
97+
Text(
98+
text = news.header,
99+
style = MaterialTheme.typography.headlineSmall,
100+
)
101+
102+
if (!news.date.isNullOrBlank()) {
103+
Spacer(Modifier.height(4.dp))
104+
Text(
105+
text = news.date!!,
106+
style = MaterialTheme.typography.labelSmall,
107+
color = MaterialTheme.colorScheme.onSurfaceVariant,
108+
)
109+
}
110+
111+
if (!news.authorName.isNullOrBlank()) {
112+
Spacer(Modifier.height(2.dp))
113+
Text(
114+
text = news.authorName!!,
115+
style = MaterialTheme.typography.labelSmall,
116+
color = MaterialTheme.colorScheme.onSurfaceVariant,
117+
)
118+
}
119+
120+
if (news.content.isNotBlank()) {
121+
Spacer(Modifier.height(16.dp))
122+
val bgHex = String.format("#%06X", bgColor and 0xFFFFFF)
123+
val fgHex = String.format("#%06X", textColor and 0xFFFFFF)
124+
val htmlContent = buildNewsHtml(news.content, bgHex, fgHex)
125+
val density = LocalContext.current.resources.displayMetrics.density
126+
var webViewHeight by remember { mutableStateOf(200.dp) }
127+
128+
AndroidView(
129+
factory = { ctx ->
130+
WebView(ctx).apply {
131+
@SuppressLint("SetJavaScriptEnabled")
132+
settings.javaScriptEnabled = true
133+
setBackgroundColor(bgColor)
134+
webViewClient = object : android.webkit.WebViewClient() {
135+
override fun onPageFinished(view: WebView?, url: String?) {
136+
view?.evaluateJavascript("document.body.scrollHeight") { heightStr ->
137+
val heightPx = heightStr.toFloatOrNull() ?: return@evaluateJavascript
138+
webViewHeight = (heightPx / density).dp + 16.dp
139+
}
140+
}
141+
}
142+
loadDataWithBaseURL(null, htmlContent, "text/html", "UTF-8", null)
143+
}
144+
},
145+
update = { webView ->
146+
webView.setBackgroundColor(bgColor)
147+
webView.loadDataWithBaseURL(null, buildNewsHtml(news.content, bgHex, fgHex), "text/html", "UTF-8", null)
148+
},
149+
modifier = Modifier.fillMaxWidth().height(webViewHeight),
150+
)
151+
}
152+
}
153+
}
154+
155+
private fun buildNewsHtml(body: String, bgColor: String, fgColor: String): String =
156+
"""
157+
<!DOCTYPE html><html><head>
158+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
159+
<style>
160+
body { background-color: $bgColor; color: $fgColor;
161+
font-family: -apple-system, sans-serif; font-size: 16px;
162+
padding: 0; margin: 0; word-wrap: break-word; }
163+
img { max-width: 100%; height: auto; }
164+
a { color: #1a73e8; }
165+
</style>
166+
</head><body>$body</body></html>
167+
""".trimIndent()

0 commit comments

Comments
 (0)