@@ -53,11 +53,29 @@ fun DocumentationScreen(
5353 var selectedTabKey by remember { mutableStateOf<String ?>(null ) }
5454 var searchQuery by remember { mutableStateOf(" " ) }
5555
56+ // Non-null while a native documentation "detail" sub-page is open (see LinkLineView) - e.g.
57+ // tapping "Official HackerScript documentation" pushes this instead of opening the external
58+ // browser, so the whole flow stays natively inside the app with no WebView involved and
59+ // nothing ever "teleports" the user away. Cleared whenever the underlying doc page changes
60+ // (e.g. a language switch) so a stale detail page is never shown on top of new content.
61+ var openDetailKey by remember { mutableStateOf<String ?>(null ) }
62+
5663 // Reset the active tab whenever a new page loads (e.g. after a language change).
5764 LaunchedEffect (docPage) {
5865 if (docPage != null && (selectedTabKey == null || docPage.tabs.none { it.key == selectedTabKey })) {
5966 selectedTabKey = docPage.tabs.firstOrNull()?.key
6067 }
68+ openDetailKey = null
69+ }
70+
71+ if (openDetailKey != null ) {
72+ DocDetailScreen (
73+ detailKey = openDetailKey!! ,
74+ currentLanguage = currentLanguage,
75+ translations = t,
76+ onBack = { openDetailKey = null }
77+ )
78+ return
6179 }
6280
6381 Column (modifier = Modifier .fillMaxSize()) {
@@ -186,11 +204,16 @@ fun DocumentationScreen(
186204 val activeTab = filteredTabs.find { it.key == selectedTabKey } ? : filteredTabs.firstOrNull()
187205 if (activeTab != null ) {
188206 LazyColumn (
189- contentPadding = PaddingValues (start = 20 .dp, end = 20 .dp, bottom = 100 .dp),
207+ // Bumped from 100.dp: the last block in a tab (often exactly this
208+ // kind of LinkLine) could end up positioned right underneath the
209+ // floating bottom nav bar on some devices/insets, which both hid it
210+ // visually and blocked its touches - this padding is now generous
211+ // enough that the last item always clears the nav bar entirely.
212+ contentPadding = PaddingValues (start = 20 .dp, end = 20 .dp, bottom = 150 .dp),
190213 verticalArrangement = Arrangement .spacedBy(10 .dp)
191214 ) {
192215 items(activeTab.blocks) { block ->
193- DocBlockView (block, theme, t)
216+ DocBlockView (block, theme, t, onOpenNativeDetail = { key -> openDetailKey = key } )
194217 }
195218 }
196219 }
@@ -219,7 +242,12 @@ private fun blockTextOf(tab: DocTab): String = buildString {
219242}
220243
221244@Composable
222- private fun DocBlockView (block : DocBlock , theme : com.hackeros.app.data.model.AppTheme , translations : Translations ) {
245+ private fun DocBlockView (
246+ block : DocBlock ,
247+ theme : com.hackeros.app.data.model.AppTheme ,
248+ translations : Translations ,
249+ onOpenNativeDetail : (String ) -> Unit = {}
250+ ) {
223251 when (block) {
224252 is DocBlock .Heading2 -> Text (
225253 block.text, fontSize = 19 .sp, fontWeight = FontWeight .Bold ,
@@ -266,7 +294,18 @@ private fun DocBlockView(block: DocBlock, theme: com.hackeros.app.data.model.App
266294 }
267295 is DocBlock .Command -> CommandBlockView (block.text, theme, translations)
268296 is DocBlock .CodeSample -> CodeSampleView (block.text, theme, translations)
269- is DocBlock .LinkLine -> LinkLineView (block.labelHtml, block.url, theme)
297+ is DocBlock .LinkLine -> LinkLineView (
298+ labelHtml = block.labelHtml,
299+ url = block.url,
300+ theme = theme,
301+ isNative = block.nativeDetailKey != null ,
302+ onClick = {
303+ if (block.nativeDetailKey != null ) {
304+ onOpenNativeDetail(block.nativeDetailKey)
305+ true
306+ } else false
307+ }
308+ )
270309 is DocBlock .ToolsTable -> ToolsTableView (block.rows, theme)
271310 DocBlock .Divider -> HorizontalDivider (color = Color .White .copy(alpha = 0.06f ))
272311 }
@@ -316,16 +355,37 @@ private fun CodeSampleView(code: String, theme: com.hackeros.app.data.model.AppT
316355}
317356
318357@Composable
319- private fun LinkLineView (labelHtml : String , url : String , theme : com.hackeros.app.data.model.AppTheme ) {
358+ private fun LinkLineView (
359+ labelHtml : String ,
360+ url : String ,
361+ theme : com.hackeros.app.data.model.AppTheme ,
362+ isNative : Boolean = false,
363+ onClick : () -> Boolean = { false }
364+ ) {
320365 val uriHandler = androidx.compose.ui.platform.LocalUriHandler .current
321366 Row (
322367 modifier = Modifier
323- .clickable { try { uriHandler.openUri(url) } catch (_: Exception ) {} }
324- .padding(vertical = 2 .dp),
368+ .fillMaxWidth()
369+ .clip(RoundedCornerShape (8 .dp))
370+ .clickable {
371+ // onClick() returns true when this link was handled natively in-app (see
372+ // DocBlock.LinkLine.nativeDetailKey) - the external browser is only ever a
373+ // fallback for links that don't have a native destination.
374+ if (! onClick()) {
375+ try { uriHandler.openUri(url) } catch (_: Exception ) {}
376+ }
377+ }
378+ // A larger touch target than the text itself, and enough vertical padding that
379+ // this row can never end up sitting flush against - or clipped by - the bottom
380+ // nav bar even as the very last item in a tab.
381+ .padding(vertical = 10 .dp, horizontal = 2 .dp),
325382 verticalAlignment = Alignment .CenterVertically ,
326383 horizontalArrangement = Arrangement .spacedBy(6 .dp)
327384 ) {
328- Icon (Icons .Default .OpenInNew , null , tint = theme.primaryColor(), modifier = Modifier .size(13 .dp))
385+ Icon (
386+ if (isNative) Icons .Default .ArrowForward else Icons .Default .OpenInNew ,
387+ null , tint = theme.primaryColor(), modifier = Modifier .size(13 .dp)
388+ )
329389 InlineHtmlText (html = labelHtml, color = theme.primaryColor(), fontSize = 12 .sp)
330390 }
331391}
@@ -358,3 +418,115 @@ private fun copyToClipboard(context: Context, text: String, translations: Transl
358418 cm.setPrimaryClip(ClipData .newPlainText(" command" , text))
359419 Toast .makeText(context, translations.toast_copied, Toast .LENGTH_SHORT ).show()
360420}
421+
422+ /* *
423+ * A fully native, in-app "detail" sub-page for a documentation link (see
424+ * DocBlock.LinkLine.nativeDetailKey / DocContentParser.detailTabsFor). Tapping the link that
425+ * opens this never leaves the app, never opens a browser tab, and doesn't use any WebView - this
426+ * whole screen is plain Compose UI, exactly like the rest of the Documentation tab. It has its
427+ * own small tab bar (reusing the same look as the main doc screen) plus a back button that
428+ * simply clears the local navigation state - nothing "teleports" anywhere.
429+ */
430+ @Composable
431+ private fun DocDetailScreen (
432+ detailKey : String ,
433+ currentLanguage : Language ,
434+ translations : Translations ,
435+ onBack : () -> Unit
436+ ) {
437+ val theme = LocalAppTheme .current
438+ val t = translations
439+ val tabs = remember(detailKey) {
440+ com.hackeros.app.data.docs.DocContentParser .detailTabsFor(detailKey).orEmpty()
441+ }
442+ val title = remember(detailKey) { com.hackeros.app.data.docs.DocContentParser .detailTitleFor(detailKey) }
443+ var selectedTabKey by remember(detailKey) { mutableStateOf(tabs.firstOrNull()?.key) }
444+ val activeTab = tabs.find { it.key == selectedTabKey } ? : tabs.firstOrNull()
445+
446+ Column (modifier = Modifier .fillMaxSize()) {
447+ Row (
448+ modifier = Modifier
449+ .fillMaxWidth()
450+ .padding(start = 12 .dp, end = 24 .dp, top = 8 .dp, bottom = 4 .dp),
451+ verticalAlignment = Alignment .CenterVertically
452+ ) {
453+ IconButton (onClick = onBack) {
454+ Icon (Icons .Default .ArrowBack , contentDescription = t.doc_detail_back, tint = Color .White )
455+ }
456+ Column (modifier = Modifier .padding(start = 4 .dp)) {
457+ Text (
458+ text = title,
459+ fontFamily = FontFamily .Monospace ,
460+ fontWeight = FontWeight .Bold ,
461+ fontSize = 22 .sp,
462+ color = Color .White
463+ )
464+ Text (
465+ text = t.doc_detail_native_notice,
466+ fontSize = 11 .sp,
467+ color = theme.mutedColor()
468+ )
469+ }
470+ }
471+
472+ if (currentLanguage != Language .PL ) {
473+ Box (Modifier .padding(horizontal = 20 .dp, vertical = 6 .dp)) {
474+ Row (
475+ modifier = Modifier
476+ .fillMaxWidth()
477+ .clip(RoundedCornerShape (10 .dp))
478+ .background(theme.primaryColor().copy(alpha = 0.08f ))
479+ .border(1 .dp, theme.primaryColor().copy(alpha = 0.25f ), RoundedCornerShape (10 .dp))
480+ .padding(horizontal = 14 .dp, vertical = 10 .dp),
481+ verticalAlignment = Alignment .CenterVertically ,
482+ horizontalArrangement = Arrangement .spacedBy(8 .dp)
483+ ) {
484+ Icon (Icons .Default .Info , null , tint = theme.primaryColor(), modifier = Modifier .size(14 .dp))
485+ Text (t.doc_detail_pl_only_notice, fontSize = 11 .sp, color = theme.primaryColor())
486+ }
487+ }
488+ }
489+
490+ if (tabs.isEmpty()) {
491+ Box (Modifier .fillMaxWidth().padding(24 .dp), contentAlignment = Alignment .Center ) {
492+ Text (t.doc_no_search_results, color = theme.mutedColor(), fontSize = 12 .sp)
493+ }
494+ return @Column
495+ }
496+
497+ LazyRow (
498+ contentPadding = PaddingValues (horizontal = 20 .dp),
499+ horizontalArrangement = Arrangement .spacedBy(8 .dp),
500+ modifier = Modifier .padding(bottom = 12 .dp)
501+ ) {
502+ items(tabs, key = { it.key }) { tab ->
503+ val active = tab.key == selectedTabKey
504+ Box (
505+ modifier = Modifier
506+ .clip(RoundedCornerShape (20 .dp))
507+ .background(if (active) theme.primaryColor() else Color .White .copy(alpha = 0.06f ))
508+ .clickable { selectedTabKey = tab.key }
509+ .padding(horizontal = 16 .dp, vertical = 8 .dp)
510+ ) {
511+ Text (
512+ tab.label,
513+ fontSize = 12 .sp,
514+ fontWeight = FontWeight .Bold ,
515+ color = if (active) theme.backgroundColor() else theme.mutedColor()
516+ )
517+ }
518+ }
519+ }
520+
521+ if (activeTab != null ) {
522+ LazyColumn (
523+ contentPadding = PaddingValues (start = 20 .dp, end = 20 .dp, bottom = 150 .dp),
524+ verticalArrangement = Arrangement .spacedBy(10 .dp)
525+ ) {
526+ items(activeTab.blocks) { block ->
527+ DocBlockView (block, theme, t)
528+ }
529+ }
530+ }
531+ }
532+ }
0 commit comments