Skip to content

Commit 9965ec1

Browse files
committed
Migrate API layer from v3.2 to v4.0, add in-app FAQ detail navigation
Update all DTOs to match phpMyFAQ v4.0 field names (Comment, News, Search, Tag, OpenQuestion, Faq). Paginated endpoints now deserialize the v4.0 `{ success, data, meta }` wrapper. Popular/latest/trending/sticky FAQs use the new FaqPopularItem type with URL parsing for in-app navigation instead of opening a browser. Tests updated with v4.0 response fixtures.
1 parent 58ebd7d commit 9965ec1

48 files changed

Lines changed: 826 additions & 498 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ Native iOS + Android client for phpMyFAQ. Planning stage — no code yet, only `
2222
- **No `admin/api/*`.** Session + CSRF not a fit for native client. App is not a second admin panel.
2323
- **No push in v1.** Server contract missing.
2424
- **Excluded entirely**: `faq/create|update`, `category` POST, `backup/{type}`.
25-
- API surface pinned to phpMyFAQ public `v3.2` (`docs/openapi.yaml` upstream).
25+
- API surface pinned to phpMyFAQ public `v4.0` (`mobile/spec/openapi/v.4.0.yaml`). Most list endpoints return paginated `{ success, data, meta }` wrappers.
2626

2727
## Shared libs (locked)
2828

@@ -52,7 +52,7 @@ Secure storage: iOS Keychain + Android `androidx.security.crypto`. DB encrypted:
5252

5353
Filed as phpMyFAQ issues: tombstones (`faqs/deleted?since=`), ETag on list endpoints, OAuth discovery, push registration contract.
5454

55-
**Already shipped upstream**: `GET /api/v3.2/meta` (single bootstrap call — version, title, language, available languages, features, logo URL, OAuth discovery). App uses it for instance selector + sync bootstrap; legacy `version`+`title`+`language` fan-out kept only as fallback for older installs.
55+
**Already shipped upstream**: `GET /api/v4.0/meta` (single bootstrap call — version, title, language, available languages, features, logo URL, OAuth discovery). App uses it for instance selector + sync bootstrap.
5656

5757
## Working on this repo
5858

mobile/.editorconfig

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
root = true
2+
3+
[*.{kt,kts}]
4+
indent_size = 4
5+
indent_style = space
6+
max_line_length = 120
7+
8+
# Compose convention: @Composable functions are PascalCase
9+
ktlint_function_naming_ignore_when_annotated_with = Composable
10+
ktlint_standard_function-naming = disabled
11+
12+
# Generated code (SQLDelight) may have unconventional names
13+
[**/build/generated/**/*.kt]
14+
ktlint = disabled
15+

mobile/androidApp/build.gradle.kts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,21 @@ plugins {
66

77
android {
88
namespace = "app.myfaq.android"
9-
compileSdk = libs.versions.android.compileSdk.get().toInt()
9+
compileSdk =
10+
libs.versions.android.compileSdk
11+
.get()
12+
.toInt()
1013

1114
defaultConfig {
1215
applicationId = "app.myfaq.android"
13-
minSdk = libs.versions.android.minSdk.get().toInt()
14-
targetSdk = libs.versions.android.targetSdk.get().toInt()
16+
minSdk =
17+
libs.versions.android.minSdk
18+
.get()
19+
.toInt()
20+
targetSdk =
21+
libs.versions.android.targetSdk
22+
.get()
23+
.toInt()
1524
versionCode = 1
1625
versionName = "0.0.0-foundations"
1726
}
@@ -40,10 +49,11 @@ android {
4049
}
4150

4251
packaging {
43-
resources.excludes += setOf(
44-
"/META-INF/{AL2.0,LGPL2.1}",
45-
"/META-INF/versions/**",
46-
)
52+
resources.excludes +=
53+
setOf(
54+
"/META-INF/{AL2.0,LGPL2.1}",
55+
"/META-INF/versions/**",
56+
)
4757
}
4858
}
4959

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,11 @@ class MyFaqApplication : Application() {
1111
override fun onCreate() {
1212
super.onCreate()
1313

14-
val androidPlatformModule = module {
15-
single { SecureStore(androidContext()) }
16-
single { DatabaseDriverFactory(androidContext()) }
17-
}
14+
val androidPlatformModule =
15+
module {
16+
single { SecureStore(androidContext()) }
17+
single { DatabaseDriverFactory(androidContext()) }
18+
}
1819

1920
initKoin {
2021
androidContext(this@MyFaqApplication)

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

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,6 @@ import androidx.navigation.compose.composable
2323
import androidx.navigation.compose.currentBackStackEntryAsState
2424
import androidx.navigation.compose.rememberNavController
2525
import androidx.navigation.navArgument
26-
import java.net.URLDecoder
27-
import java.net.URLEncoder
2826
import app.myfaq.android.screens.AddInstanceSheet
2927
import app.myfaq.android.screens.CategoriesScreen
3028
import app.myfaq.android.screens.FaqDetailScreen
@@ -36,6 +34,8 @@ import app.myfaq.android.screens.SettingsScreen
3634
import app.myfaq.android.screens.WorkspacesScreen
3735
import app.myfaq.shared.data.ActiveInstanceManager
3836
import org.koin.compose.koinInject
37+
import java.net.URLDecoder
38+
import java.net.URLEncoder
3939

4040
// ── Route constants ────────────────────────────────────────────────
4141

@@ -50,16 +50,24 @@ object Routes {
5050
const val SETTINGS = "settings"
5151
const val PAYWALL = "paywall"
5252

53-
fun faqList(categoryId: Int, categoryName: String): String =
54-
"categories/$categoryId/${URLEncoder.encode(categoryName, "UTF-8")}"
53+
fun faqList(
54+
categoryId: Int,
55+
categoryName: String,
56+
): String = "categories/$categoryId/${URLEncoder.encode(categoryName, "UTF-8")}"
5557

56-
fun faqDetail(categoryId: Int, faqId: Int): String =
57-
"faq/$categoryId/$faqId"
58+
fun faqDetail(
59+
categoryId: Int,
60+
faqId: Int,
61+
): String = "faq/$categoryId/$faqId"
5862
}
5963

6064
// ── Bottom-bar tabs ────────────────────────────────────────────────
6165

62-
enum class BottomTab(val route: String, val label: String, val icon: ImageVector) {
66+
enum class BottomTab(
67+
val route: String,
68+
val label: String,
69+
val icon: ImageVector,
70+
) {
6371
Home(Routes.HOME, "Home", Icons.Default.Home),
6472
Categories(Routes.CATEGORIES, "Categories", Icons.Default.List),
6573
Search(Routes.SEARCH, "Search", Icons.Default.Search),
@@ -150,14 +158,17 @@ fun MyFaqNavHost(aim: ActiveInstanceManager = koinInject()) {
150158

151159
composable(
152160
route = Routes.FAQ_LIST,
153-
arguments = listOf(
154-
navArgument("categoryId") { type = NavType.IntType },
155-
navArgument("categoryName") { type = NavType.StringType },
156-
),
161+
arguments =
162+
listOf(
163+
navArgument("categoryId") { type = NavType.IntType },
164+
navArgument("categoryName") { type = NavType.StringType },
165+
),
157166
) { backStackEntry ->
158167
val categoryId = backStackEntry.arguments?.getInt("categoryId") ?: 0
159-
val categoryName = backStackEntry.arguments?.getString("categoryName")
160-
?.let { URLDecoder.decode(it, "UTF-8") } ?: ""
168+
val categoryName =
169+
backStackEntry.arguments
170+
?.getString("categoryName")
171+
?.let { URLDecoder.decode(it, "UTF-8") } ?: ""
161172
FaqListScreen(
162173
categoryId = categoryId,
163174
categoryName = categoryName,
@@ -170,10 +181,11 @@ fun MyFaqNavHost(aim: ActiveInstanceManager = koinInject()) {
170181

171182
composable(
172183
route = Routes.FAQ_DETAIL,
173-
arguments = listOf(
174-
navArgument("categoryId") { type = NavType.IntType },
175-
navArgument("faqId") { type = NavType.IntType },
176-
),
184+
arguments =
185+
listOf(
186+
navArgument("categoryId") { type = NavType.IntType },
187+
navArgument("faqId") { type = NavType.IntType },
188+
),
177189
) { backStackEntry ->
178190
val categoryId = backStackEntry.arguments?.getInt("categoryId") ?: 0
179191
val faqId = backStackEntry.arguments?.getInt("faqId") ?: 0

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

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,11 @@ fun AddInstanceSheet(
6363
},
6464
) { padding ->
6565
Column(
66-
modifier = Modifier
67-
.padding(padding)
68-
.padding(16.dp)
69-
.fillMaxWidth(),
66+
modifier =
67+
Modifier
68+
.padding(padding)
69+
.padding(16.dp)
70+
.fillMaxWidth(),
7071
) {
7172
OutlinedTextField(
7273
value = url,
@@ -133,9 +134,10 @@ private fun ConfirmationCard(
133134
onConfirm: () -> Unit,
134135
) {
135136
Card(
136-
colors = CardDefaults.cardColors(
137-
containerColor = MaterialTheme.colorScheme.secondaryContainer,
138-
),
137+
colors =
138+
CardDefaults.cardColors(
139+
containerColor = MaterialTheme.colorScheme.secondaryContainer,
140+
),
139141
modifier = Modifier.fillMaxWidth(),
140142
) {
141143
Column(modifier = Modifier.padding(16.dp)) {

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

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,12 @@ fun CategoriesScreen(
5151
) { padding ->
5252
when (val s = state) {
5353
is UiState.Loading -> LoadingIndicator(modifier = Modifier.padding(padding))
54-
is UiState.Error -> ErrorRetry(
55-
message = s.message,
56-
onRetry = { vm.loadCategories() },
57-
modifier = Modifier.padding(padding),
58-
)
54+
is UiState.Error ->
55+
ErrorRetry(
56+
message = s.message,
57+
onRetry = { vm.loadCategories() },
58+
modifier = Modifier.padding(padding),
59+
)
5960
is UiState.Success -> {
6061
val sorted = remember(s.data) { buildFlatTree(s.data) }
6162
LazyColumn(
@@ -83,10 +84,11 @@ private fun CategoryRow(
8384
onClick: () -> Unit,
8485
) {
8586
Row(
86-
modifier = Modifier
87-
.fillMaxWidth()
88-
.clickable(onClick = onClick)
89-
.padding(start = (16 + depth * 24).dp, end = 16.dp, top = 12.dp, bottom = 12.dp),
87+
modifier =
88+
Modifier
89+
.fillMaxWidth()
90+
.clickable(onClick = onClick)
91+
.padding(start = (16 + depth * 24).dp, end = 16.dp, top = 12.dp, bottom = 12.dp),
9092
verticalAlignment = Alignment.CenterVertically,
9193
horizontalArrangement = Arrangement.SpaceBetween,
9294
) {
@@ -111,7 +113,10 @@ private fun buildFlatTree(categories: List<Category>): List<Pair<Category, Int>>
111113
val byParent = categories.groupBy { it.parentId }
112114
val result = mutableListOf<Pair<Category, Int>>()
113115

114-
fun walk(parentId: Int?, depth: Int) {
116+
fun walk(
117+
parentId: Int?,
118+
depth: Int,
119+
) {
115120
byParent[parentId]?.sortedBy { it.name }?.forEach { cat ->
116121
result.add(cat to depth)
117122
walk(cat.id, depth + 1)

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

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,11 @@ fun FaqDetailScreen(
8282
val context = LocalContext.current
8383
val faq = (faqState as UiState.Success<FaqDetail>).data
8484
IconButton(onClick = {
85-
val sendIntent = Intent(Intent.ACTION_SEND).apply {
86-
type = "text/plain"
87-
putExtra(Intent.EXTRA_TEXT, faq.question)
88-
}
85+
val sendIntent =
86+
Intent(Intent.ACTION_SEND).apply {
87+
type = "text/plain"
88+
putExtra(Intent.EXTRA_TEXT, faq.question)
89+
}
8990
context.startActivity(Intent.createChooser(sendIntent, "Share"))
9091
}) {
9192
Icon(Icons.Default.Share, contentDescription = "Share")
@@ -97,11 +98,12 @@ fun FaqDetailScreen(
9798
) { padding ->
9899
when (val s = faqState) {
99100
is UiState.Loading -> LoadingIndicator(modifier = Modifier.padding(padding))
100-
is UiState.Error -> ErrorRetry(
101-
message = s.message,
102-
onRetry = { vm.load(categoryId, faqId) },
103-
modifier = Modifier.padding(padding),
104-
)
101+
is UiState.Error ->
102+
ErrorRetry(
103+
message = s.message,
104+
onRetry = { vm.load(categoryId, faqId) },
105+
modifier = Modifier.padding(padding),
106+
)
105107
is UiState.Success -> {
106108
FaqDetailContent(
107109
faq = s.data,
@@ -127,10 +129,11 @@ private fun FaqDetailContent(
127129
val textColor = MaterialTheme.colorScheme.onSurface.toArgb()
128130

129131
Column(
130-
modifier = modifier
131-
.fillMaxSize()
132-
.verticalScroll(rememberScrollState())
133-
.padding(16.dp),
132+
modifier =
133+
modifier
134+
.fillMaxSize()
135+
.verticalScroll(rememberScrollState())
136+
.padding(16.dp),
134137
) {
135138
// Question heading
136139
Text(
@@ -168,9 +171,10 @@ private fun FaqDetailContent(
168171
val content = buildHtml(faq.answer, bgHex, fgHex)
169172
webView.loadDataWithBaseURL(null, content, "text/html", "UTF-8", null)
170173
},
171-
modifier = Modifier
172-
.fillMaxWidth()
173-
.height(300.dp),
174+
modifier =
175+
Modifier
176+
.fillMaxWidth()
177+
.height(300.dp),
174178
)
175179
}
176180

@@ -240,8 +244,11 @@ private fun CommentsSection(state: UiState<List<Comment>>) {
240244
} else {
241245
TextButton(onClick = { expanded = !expanded }) {
242246
Text(
243-
if (expanded) "Hide comments (${state.data.size})"
244-
else "Show comments (${state.data.size})",
247+
if (expanded) {
248+
"Hide comments (${state.data.size})"
249+
} else {
250+
"Show comments (${state.data.size})"
251+
},
245252
)
246253
}
247254
AnimatedVisibility(visible = expanded) {
@@ -282,7 +289,11 @@ private fun CommentCard(comment: Comment) {
282289
}
283290
}
284291

285-
private fun buildHtml(body: String, bgColor: String, fgColor: String): String =
292+
private fun buildHtml(
293+
body: String,
294+
bgColor: String,
295+
fgColor: String,
296+
): String =
286297
"""
287298
<!DOCTYPE html>
288299
<html>

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

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,12 @@ fun FaqListScreen(
5656
) { padding ->
5757
when (val s = state) {
5858
is UiState.Loading -> LoadingIndicator(modifier = Modifier.padding(padding))
59-
is UiState.Error -> ErrorRetry(
60-
message = s.message,
61-
onRetry = { vm.loadFaqsForCategory(categoryId) },
62-
modifier = Modifier.padding(padding),
63-
)
59+
is UiState.Error ->
60+
ErrorRetry(
61+
message = s.message,
62+
onRetry = { vm.loadFaqsForCategory(categoryId) },
63+
modifier = Modifier.padding(padding),
64+
)
6465
is UiState.Success -> {
6566
LazyColumn(
6667
modifier = Modifier.padding(padding),

0 commit comments

Comments
 (0)