Skip to content

Commit f4ebcb4

Browse files
thorstenclaude
andcommitted
feat: Phase 1 read-only MVP — all screens, caching, self-signed cert support
Implements the full Phase 1 read-only MVP for both iOS and Android: Shared module: - All API DTOs (Category, Faq, Search, News, Comment, Tag, OpenQuestion) - Full MyFaqApi with all read endpoints (categories, faqs, search, news, etc.) - FaqRepository with TTL JSON blob caching + stale-while-revalidate fallback - CacheStore backed by SQLDelight cache_entries table + migration - ActiveInstanceManager for per-instance API/repository lifecycle - SharedViewModels (Home, Categories, FaqDetail, Search, Workspaces) - FlowCollector helper for iOS StateFlow observation - KoinIos resolver to bypass reified generics limitation - Self-signed certificate trust for dev (OkHttp + Darwin) - Real HTTP engines (OkHttp Android, Darwin iOS) replacing MockEngine Android (Jetpack Compose): - Navigation with bottom bar (Home, Categories, Search, Settings) - All screens: Workspaces, AddInstance, Home (tabbed), Categories (tree), FaqList, FaqDetail (WebView + tags + comments), Search (debounced), Paywall (coming soon), Settings - Reusable components: FaqCard, ErrorRetry, LoadingIndicator iOS (SwiftUI): - TabView navigation with NavigationStack drill-downs - All screens mirroring Android: Workspaces, AddInstance, Home (segmented), Categories (indented tree), FaqList, FaqDetail (WKWebView + FlowLayout tags), Search, Paywall, Settings - iOS platform Koin module with SecureStore + DatabaseDriverFactory - ATS exception for local dev (NSAllowsArbitraryLoads) Tests: - 13 endpoint deserialization tests covering all Phase 1 API surfaces - UiState sealed interface tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4fb91cf commit f4ebcb4

70 files changed

Lines changed: 4721 additions & 178 deletions

File tree

Some content is hidden

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

mobile/androidApp/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ dependencies {
5454
implementation(libs.androidx.activity.compose)
5555
implementation(libs.androidx.lifecycle.runtime.ktx)
5656
implementation(libs.androidx.lifecycle.viewmodel.compose)
57+
implementation(libs.androidx.navigation.compose)
5758

5859
implementation(platform(libs.compose.bom))
5960
implementation(libs.compose.ui)
@@ -64,4 +65,5 @@ dependencies {
6465

6566
implementation(libs.koin.android)
6667
implementation(libs.koin.core)
68+
implementation(libs.koin.compose)
6769
}

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@ package app.myfaq.android
33
import android.os.Bundle
44
import androidx.activity.ComponentActivity
55
import androidx.activity.compose.setContent
6+
import androidx.activity.enableEdgeToEdge
67

78
class MainActivity : ComponentActivity() {
89
override fun onCreate(savedInstanceState: Bundle?) {
910
super.onCreate(savedInstanceState)
11+
enableEdgeToEdge()
1012
setContent {
1113
MyFaqAppTheme {
12-
PhaseZeroScreen()
14+
MyFaqNavHost()
1315
}
1416
}
1517
}
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
package app.myfaq.android
2+
3+
import androidx.compose.foundation.layout.padding
4+
import androidx.compose.material.icons.Icons
5+
import androidx.compose.material.icons.filled.Home
6+
import androidx.compose.material.icons.filled.List
7+
import androidx.compose.material.icons.filled.Search
8+
import androidx.compose.material.icons.filled.Settings
9+
import androidx.compose.material3.Icon
10+
import androidx.compose.material3.NavigationBar
11+
import androidx.compose.material3.NavigationBarItem
12+
import androidx.compose.material3.Scaffold
13+
import androidx.compose.material3.Text
14+
import androidx.compose.runtime.Composable
15+
import androidx.compose.runtime.collectAsState
16+
import androidx.compose.runtime.getValue
17+
import androidx.compose.ui.Modifier
18+
import androidx.compose.ui.graphics.vector.ImageVector
19+
import androidx.navigation.NavGraph.Companion.findStartDestination
20+
import androidx.navigation.NavType
21+
import androidx.navigation.compose.NavHost
22+
import androidx.navigation.compose.composable
23+
import androidx.navigation.compose.currentBackStackEntryAsState
24+
import androidx.navigation.compose.rememberNavController
25+
import androidx.navigation.navArgument
26+
import java.net.URLDecoder
27+
import java.net.URLEncoder
28+
import app.myfaq.android.screens.AddInstanceSheet
29+
import app.myfaq.android.screens.CategoriesScreen
30+
import app.myfaq.android.screens.FaqDetailScreen
31+
import app.myfaq.android.screens.FaqListScreen
32+
import app.myfaq.android.screens.HomeScreen
33+
import app.myfaq.android.screens.PaywallScreen
34+
import app.myfaq.android.screens.SearchScreen
35+
import app.myfaq.android.screens.SettingsScreen
36+
import app.myfaq.android.screens.WorkspacesScreen
37+
import app.myfaq.shared.data.ActiveInstanceManager
38+
import org.koin.compose.koinInject
39+
40+
// ── Route constants ────────────────────────────────────────────────
41+
42+
object Routes {
43+
const val WORKSPACES = "workspaces"
44+
const val ADD_INSTANCE = "add_instance"
45+
const val HOME = "home"
46+
const val CATEGORIES = "categories"
47+
const val FAQ_LIST = "categories/{categoryId}/{categoryName}"
48+
const val FAQ_DETAIL = "faq/{categoryId}/{faqId}"
49+
const val SEARCH = "search"
50+
const val SETTINGS = "settings"
51+
const val PAYWALL = "paywall"
52+
53+
fun faqList(categoryId: Int, categoryName: String): String =
54+
"categories/$categoryId/${URLEncoder.encode(categoryName, "UTF-8")}"
55+
56+
fun faqDetail(categoryId: Int, faqId: Int): String =
57+
"faq/$categoryId/$faqId"
58+
}
59+
60+
// ── Bottom-bar tabs ────────────────────────────────────────────────
61+
62+
enum class BottomTab(val route: String, val label: String, val icon: ImageVector) {
63+
Home(Routes.HOME, "Home", Icons.Default.Home),
64+
Categories(Routes.CATEGORIES, "Categories", Icons.Default.List),
65+
Search(Routes.SEARCH, "Search", Icons.Default.Search),
66+
Settings(Routes.SETTINGS, "Settings", Icons.Default.Settings),
67+
}
68+
69+
// ── Root scaffold with NavHost ─────────────────────────────────────
70+
71+
@Composable
72+
fun MyFaqNavHost(aim: ActiveInstanceManager = koinInject()) {
73+
val navController = rememberNavController()
74+
val activeInstance by aim.activeInstance.collectAsState()
75+
val hasInstance = activeInstance != null
76+
77+
val navBackStackEntry by navController.currentBackStackEntryAsState()
78+
val currentRoute = navBackStackEntry?.destination?.route
79+
80+
val showBottomBar = hasInstance && currentRoute in BottomTab.entries.map { it.route }
81+
82+
Scaffold(
83+
bottomBar = {
84+
if (showBottomBar) {
85+
NavigationBar {
86+
BottomTab.entries.forEach { tab ->
87+
NavigationBarItem(
88+
icon = { Icon(tab.icon, contentDescription = tab.label) },
89+
label = { Text(tab.label) },
90+
selected = currentRoute == tab.route,
91+
onClick = {
92+
navController.navigate(tab.route) {
93+
popUpTo(navController.graph.findStartDestination().id) {
94+
saveState = true
95+
}
96+
launchSingleTop = true
97+
restoreState = true
98+
}
99+
},
100+
)
101+
}
102+
}
103+
}
104+
},
105+
) { innerPadding ->
106+
NavHost(
107+
navController = navController,
108+
startDestination = if (hasInstance) Routes.HOME else Routes.WORKSPACES,
109+
modifier = Modifier.padding(innerPadding),
110+
) {
111+
composable(Routes.WORKSPACES) {
112+
WorkspacesScreen(
113+
onInstanceSelected = {
114+
navController.navigate(Routes.HOME) {
115+
popUpTo(Routes.WORKSPACES) { inclusive = true }
116+
}
117+
},
118+
onAddInstance = {
119+
navController.navigate(Routes.ADD_INSTANCE)
120+
},
121+
)
122+
}
123+
124+
composable(Routes.ADD_INSTANCE) {
125+
AddInstanceSheet(
126+
onDismiss = { navController.popBackStack() },
127+
onInstanceAdded = {
128+
navController.navigate(Routes.HOME) {
129+
popUpTo(Routes.WORKSPACES) { inclusive = true }
130+
}
131+
},
132+
)
133+
}
134+
135+
composable(Routes.HOME) {
136+
HomeScreen(
137+
onFaqClick = { categoryId, faqId ->
138+
navController.navigate(Routes.faqDetail(categoryId, faqId))
139+
},
140+
)
141+
}
142+
143+
composable(Routes.CATEGORIES) {
144+
CategoriesScreen(
145+
onCategoryClick = { categoryId, categoryName ->
146+
navController.navigate(Routes.faqList(categoryId, categoryName))
147+
},
148+
)
149+
}
150+
151+
composable(
152+
route = Routes.FAQ_LIST,
153+
arguments = listOf(
154+
navArgument("categoryId") { type = NavType.IntType },
155+
navArgument("categoryName") { type = NavType.StringType },
156+
),
157+
) { backStackEntry ->
158+
val categoryId = backStackEntry.arguments?.getInt("categoryId") ?: 0
159+
val categoryName = backStackEntry.arguments?.getString("categoryName")
160+
?.let { URLDecoder.decode(it, "UTF-8") } ?: ""
161+
FaqListScreen(
162+
categoryId = categoryId,
163+
categoryName = categoryName,
164+
onFaqClick = { faqId ->
165+
navController.navigate(Routes.faqDetail(categoryId, faqId))
166+
},
167+
onBack = { navController.popBackStack() },
168+
)
169+
}
170+
171+
composable(
172+
route = Routes.FAQ_DETAIL,
173+
arguments = listOf(
174+
navArgument("categoryId") { type = NavType.IntType },
175+
navArgument("faqId") { type = NavType.IntType },
176+
),
177+
) { backStackEntry ->
178+
val categoryId = backStackEntry.arguments?.getInt("categoryId") ?: 0
179+
val faqId = backStackEntry.arguments?.getInt("faqId") ?: 0
180+
FaqDetailScreen(
181+
categoryId = categoryId,
182+
faqId = faqId,
183+
onBack = { navController.popBackStack() },
184+
onPaywall = { navController.navigate(Routes.PAYWALL) },
185+
)
186+
}
187+
188+
composable(Routes.SEARCH) {
189+
SearchScreen(
190+
onFaqClick = { categoryId, faqId ->
191+
navController.navigate(Routes.faqDetail(categoryId, faqId))
192+
},
193+
)
194+
}
195+
196+
composable(Routes.SETTINGS) {
197+
SettingsScreen(
198+
onSwitchInstance = {
199+
navController.navigate(Routes.WORKSPACES) {
200+
popUpTo(navController.graph.findStartDestination().id) {
201+
inclusive = true
202+
}
203+
}
204+
},
205+
)
206+
}
207+
208+
composable(Routes.PAYWALL) {
209+
PaywallScreen(onBack = { navController.popBackStack() })
210+
}
211+
}
212+
}
213+
}

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

Lines changed: 0 additions & 79 deletions
This file was deleted.

0 commit comments

Comments
 (0)