feat(mobile-ux): implement auto-hiding bars on scroll and widen reader container

This commit is contained in:
2026-06-15 19:25:44 +02:00
parent c94e8f0acb
commit 4432c901f0
7 changed files with 80 additions and 4 deletions
@@ -8,7 +8,7 @@
@inject IReaderStateService StateService
@inject IThemeService ThemeService
<div class="nexus-unified-mobile-toolbar @(ThemeService.IsLightMode ? "theme-light" : "theme-dark")">
<div class="nexus-unified-mobile-toolbar @(ThemeService.IsLightMode ? "theme-light" : "theme-dark") @(StateService.IsBarsHidden ? "immersive-zen-mode" : "")">
<!-- Tab 1: Progress (Postęp) -->
<button class="nav-toggle-btn progress-btn" @onclick="ToggleCheckpoints" aria-label="Postęp" title="Rozdziały i checkpoints">
<div class="progress-ring-wrapper">
@@ -112,8 +112,11 @@
protected override void OnInitialized()
{
ThemeService.OnThemeChanged += HandleThemeChanged;
StateService.OnBarsHiddenChanged += HandleBarsHiddenChanged;
}
private Task HandleBarsHiddenChanged() => InvokeAsync(StateHasChanged);
private void HandleThemeChanged(ThemeMode mode) => InvokeAsync(StateHasChanged);
private double GetDashOffset()
@@ -160,5 +163,6 @@
public void Dispose()
{
ThemeService.OnThemeChanged -= HandleThemeChanged;
StateService.OnBarsHiddenChanged -= HandleBarsHiddenChanged;
}
}
@@ -13,8 +13,16 @@
box-sizing: border-box;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
overflow: visible; /* Critical to show elevated FAB */
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease;
}
.nexus-unified-mobile-toolbar.immersive-zen-mode {
transform: translateY(calc(100% + 24px)) !important;
opacity: 0;
pointer-events: none;
}
/* Light Mode: Premium Paper Look */
.nexus-unified-mobile-toolbar.theme-light {
background: rgba(244, 241, 234, 0.9);
@@ -20,10 +20,10 @@
@inject NavigationManager Navigation
@inject ILogger<ReaderCanvas> Logger
<div class="reader-canvas @(ThemeService.IsLightMode ? "theme-light" : "theme-dark")">
<div class="reader-canvas @(ThemeService.IsLightMode ? "theme-light" : "theme-dark") @(StateService.IsBarsHidden ? "immersive-zen-mode" : "")">
@if (_isMobile && ViewModel != null)
{
<header class="nexus-mobile-reader-header">
<header class="nexus-mobile-reader-header @(StateService.IsBarsHidden ? "immersive-zen-mode" : "")">
<button class="nexus-mobile-escape-btn" @onclick="HandleEscape" aria-label="Powrót do pulpitu">
<NexusIcon Name="chevron-left" Size="18" />
<span>Pulpit</span>
@@ -130,6 +130,7 @@
ThemeService.OnThemeChanged += HandleThemeChanged;
NavigationService.OnNavigationChanged += OnNavigationChanged;
QuizService.OnQuizUpdated += HandleUpdate;
StateService.OnBarsHiddenChanged += HandleBarsHiddenChanged;
InteractionService.OnScrollToBlockRequested += HandleScrollRequested;
InteractionService.OnHighlightBlockRequested += HandleHighlightRequested;
@@ -250,7 +251,7 @@
if (_selfReference != null)
{
await module.InvokeVoidAsync("initObserver", _selfReference, ".reader-flow-container", ".block-wrapper");
_scrollListenerReference = await module.InvokeAsync<IJSObjectReference>("initScrollListener", _selfReference, ".reader-flow-container");
_scrollListenerReference = await module.InvokeAsync<IJSObjectReference>("initScrollListener", _selfReference, ".reader-canvas");
}
}
catch (Exception ex)
@@ -266,6 +267,17 @@
await InteractionService.NotifyScrollPercentChanged(percent);
}
[JSInvokable]
public async Task HandleScrollDelta(bool hideBars)
{
if (StateService.IsBarsHidden != hideBars)
{
StateService.IsBarsHidden = hideBars;
}
}
private Task HandleBarsHiddenChanged() => InvokeAsync(StateHasChanged);
[JSInvokable]
public async Task HandleBlockReached(string blockId, string content)
{
@@ -471,6 +483,7 @@
ThemeService.OnThemeChanged -= HandleThemeChanged;
NavigationService.OnNavigationChanged -= OnNavigationChanged;
QuizService.OnQuizUpdated -= HandleUpdate;
StateService.OnBarsHiddenChanged -= HandleBarsHiddenChanged;
InteractionService.OnScrollToBlockRequested -= HandleScrollRequested;
InteractionService.OnHighlightBlockRequested -= HandleHighlightRequested;
@@ -344,7 +344,14 @@
/* Ensure content is clear of bottom toolbar */
}
.reader-canvas.immersive-zen-mode {
padding-top: calc(10px + env(safe-area-inset-top, 0px)) !important;
padding-bottom: calc(10px + env(safe-area-inset-bottom, 0px)) !important;
}
.reader-flow-container {
padding-left: 18px !important;
padding-right: 18px !important;
padding-bottom: 4rem;
/* Safe breathing room */
}
@@ -381,8 +388,14 @@
padding: 0 1rem;
z-index: 1000;
box-sizing: border-box;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.nexus-mobile-reader-header.immersive-zen-mode {
transform: translateY(-100%);
}
.theme-light .nexus-mobile-reader-header {
background: rgba(249, 249, 249, 0.8);
border-bottom-color: rgba(0, 0, 0, 0.08);
@@ -11,4 +11,7 @@ public interface IReaderStateService
List<string> CurrentCheckpoints { get; set; }
string CurrentBlockId { get; set; }
MobileReaderTab ActiveTab { get; set; }
bool IsBarsHidden { get; set; }
event Func<Task>? OnBarsHiddenChanged;
}
@@ -14,6 +14,9 @@ public sealed class ReaderStateService : IReaderStateService
private List<string> _checkpoints = new();
private string _blockId = string.Empty;
private MobileReaderTab _activeTab = MobileReaderTab.Reader;
private bool _barsHidden;
public event Func<Task>? OnBarsHiddenChanged;
public int CurrentScrollPercentage
{
@@ -38,4 +41,23 @@ public sealed class ReaderStateService : IReaderStateService
get { lock (_lock) return _activeTab; }
set { lock (_lock) _activeTab = value; }
}
public bool IsBarsHidden
{
get { lock (_lock) return _barsHidden; }
set
{
bool changed;
lock (_lock)
{
changed = _barsHidden != value;
_barsHidden = value;
}
if (changed && OnBarsHiddenChanged != null)
{
_ = OnBarsHiddenChanged.Invoke();
}
}
}
}
@@ -26,6 +26,7 @@ export function initScrollListener(dotNetHelper, scrollContainerSelector) {
if (!container) return null;
let isThrottled = false;
let lastScrollTop = 0;
const onScroll = () => {
if (isThrottled) return;
@@ -44,6 +45,17 @@ export function initScrollListener(dotNetHelper, scrollContainerSelector) {
// Ensure bounds
percentage = Math.max(0, Math.min(100, percentage));
// Scroll delta detection:
// Hide bars on scroll down, show on scroll up. Force show when close to top.
const delta = scrollTop - lastScrollTop;
if (scrollTop <= 10) {
dotNetHelper.invokeMethodAsync('HandleScrollDelta', false);
} else if (Math.abs(delta) > 5) {
const hideBars = delta > 0;
dotNetHelper.invokeMethodAsync('HandleScrollDelta', hideBars);
}
lastScrollTop = scrollTop;
dotNetHelper.invokeMethodAsync('HandleScrollPercentChanged', percentage);
isThrottled = false;
});
@@ -60,3 +72,4 @@ export function initScrollListener(dotNetHelper, scrollContainerSelector) {
}
};
}