feat(bottomNav): first draft

This commit is contained in:
Johannes Millan 2025-09-10 09:32:08 +02:00
parent bd6bea5e11
commit 599d9f90ef
13 changed files with 461 additions and 63 deletions

View file

@ -27,9 +27,14 @@
} @else {
<div
class="app-container"
[class.has-mobile-bottom-nav]="isShowMobileButtonNav"
[dir]="isRTL ? 'rtl' : 'ltr'"
>
<magic-side-nav [activeWorkContextId]="getActiveWorkContextId()"> </magic-side-nav>
<magic-side-nav
#magicSideNav
[activeWorkContextId]="getActiveWorkContextId()"
>
</magic-side-nav>
<main class="main-content">
<header
@ -52,6 +57,13 @@
</right-panel>
<global-progress-bar></global-progress-bar>
</main>
<!-- Mobile Bottom Navigation -->
@if (isShowMobileButtonNav) {
<mobile-bottom-nav
(toggleMobileNavEvent)="magicSideNav.toggleMobileNav()"
></mobile-bottom-nav>
}
</div>
}
<!-- -->

View file

@ -22,9 +22,22 @@
overflow: hidden;
}
// Mobile bottom nav spacing
.has-mobile-bottom-nav {
@include mq(xs, max) {
.main-content {
padding-bottom: 64px;
}
}
}
// Override magic-side-nav styles for flex layout integration
// Use desktop layout overrides only on larger screens
@media (min-width: 768px) {
@include mq(xs) {
mobile-bottom-nav {
display: none !important;
}
magic-side-nav {
::ng-deep .nav-sidebar {
position: relative !important;

View file

@ -81,6 +81,8 @@ import { ProjectService } from './features/project/project.service';
import { TagService } from './features/tag/tag.service';
import { ContextMenuComponent } from './ui/context-menu/context-menu.component';
import { WorkContextThemeCfg } from './features/work-context/work-context.model';
import { MobileBottomNavComponent } from './core-ui/mobile-bottom-nav/mobile-bottom-nav.component';
import { IS_TOUCH_PRIMARY } from './util/is-mouse-primary';
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>;
@ -118,6 +120,7 @@ const productivityTip: string[] = w.productivityTips && w.productivityTips[w.ran
MatIcon,
TranslatePipe,
ContextMenuComponent,
MobileBottomNavComponent,
],
})
export class AppComponent implements OnDestroy, AfterViewInit {
@ -156,6 +159,7 @@ export class AppComponent implements OnDestroy, AfterViewInit {
readonly globalThemeService = inject(GlobalThemeService);
readonly _store = inject(Store);
readonly T = T;
readonly isShowMobileButtonNav = IS_MOBILE && IS_TOUCH_PRIMARY;
productivityTipTitle: string = productivityTip && productivityTip[0];
productivityTipText: string = productivityTip && productivityTip[1];

View file

@ -142,8 +142,6 @@
</div>
</div>
}
<mobile-side-panel-menu></mobile-side-panel-menu>
}
</nav>
</div>

View file

@ -40,7 +40,6 @@ import { PluginSidePanelBtnsComponent } from '../../plugins/ui/plugin-side-panel
import { WorkContextTitleComponent } from './work-context-title/work-context-title.component';
import { PlayButtonComponent } from './play-button/play-button.component';
import { DesktopPanelButtonsComponent } from './desktop-panel-buttons/desktop-panel-buttons.component';
import { MobileSidePanelMenuComponent } from './mobile-side-panel-menu/mobile-side-panel-menu.component';
import { BreakpointObserver } from '@angular/cdk/layout';
import { toSignal } from '@angular/core/rxjs-interop';
@ -59,7 +58,6 @@ import { toSignal } from '@angular/core/rxjs-interop';
LongPressDirective,
PluginHeaderBtnsComponent,
PluginSidePanelBtnsComponent,
MobileSidePanelMenuComponent,
WorkContextTitleComponent,
PlayButtonComponent,
DesktopPanelButtonsComponent,
@ -84,14 +82,17 @@ export class MainHeaderComponent implements OnDestroy {
T: typeof T = T;
isShowSimpleCounterBtnsMobile = signal(false);
// Convert breakpoint observer to signals
private _isXs$ = this.breakpointObserver.observe('(max-width: 599px)');
// TODO these should live in the layout service
// Convert breakpoint observer to signals - using 768px to match mobile-bottom-nav
private _isXs$ = this.breakpointObserver.observe('(max-width: 768px)');
private _isXxxs$ = this.breakpointObserver.observe('(max-width: 398px)');
// TODO these should live in the layout service
isXs = toSignal(this._isXs$.pipe(map((result) => result.matches)), {
initialValue: false,
});
// TODO these should live in the layout service
isXxxs = toSignal(this._isXxxs$.pipe(map((result) => result.matches)), {
initialValue: false,
});

View file

@ -0,0 +1,106 @@
<nav
class="mobile-bottom-nav"
[class.visible]="isMobile()"
>
<button
mat-button
class="nav-button"
[class.active]="currentRoute().includes('/tag/' + todayTagId + '/tasks')"
[routerLink]="todayRoute"
[matTooltip]="T.G.TODAY | translate"
>
<mat-icon>wb_sunny</mat-icon>
<span class="nav-label">{{ T.G.TODAY | translate }}</span>
</button>
<button
mat-button
class="nav-button"
[class.active]="currentRoute() === '/planner'"
routerLink="/planner"
[matTooltip]="T.F.PLANNER.PLANNER | translate"
>
<mat-icon>edit_calendar</mat-icon>
<span class="nav-label">{{ T.F.PLANNER.PLANNER | translate }}</span>
</button>
<button
mat-fab
class="add-task-button"
color="primary"
(click)="showAddTaskBar()"
[matTooltip]="T.F.TASK.ADD | translate"
>
<mat-icon>add</mat-icon>
</button>
<button
mat-button
class="nav-button"
[matMenuTriggerFor]="panelsMenu"
[matTooltip]="T.MH.SIDE_PANEL_MENU | translate"
>
<mat-icon>view_sidebar</mat-icon>
<span class="nav-label">{{ 'Panels' | translate }}</span>
</button>
<button
mat-button
class="nav-button"
(click)="toggleMobileNav()"
[matTooltip]="'Navigation' | translate"
>
<mat-icon>menu</mat-icon>
<span class="nav-label">{{ 'Menu' | translate }}</span>
</button>
</nav>
<mat-menu
#panelsMenu="matMenu"
class="mobile-bottom-nav-panels-menu"
>
@for (button of sidePanelButtons(); track button.pluginId) {
<button
mat-menu-item
(click)="onPluginButtonClick(button)"
[class.active]="activePluginId() === button.pluginId && isShowPluginPanel()"
>
<plugin-icon
[pluginId]="button.pluginId"
[fallbackIcon]="button.icon || 'extension'"
></plugin-icon>
<span>{{ button.label }}</span>
</button>
}
<button
mat-menu-item
[disabled]="!isWorkViewPage()"
[class.active]="isShowTaskViewCustomizerPanel()"
[class.isCustomized]="taskViewCustomizerService.isCustomized()"
(click)="toggleTaskViewCustomizer()"
>
<mat-icon>filter_list</mat-icon>
<span>{{ T.GCF.KEYBOARD.TOGGLE_TASK_VIEW_CUSTOMIZER_PANEL | translate }}</span>
</button>
<button
mat-menu-item
[disabled]="!isRouteWithSidePanel()"
[class.active]="isShowIssuePanel()"
(click)="toggleIssuePanel()"
>
<mat-icon>dashboard_customize</mat-icon>
<span>{{ T.MH.TOGGLE_SHOW_ISSUE_PANEL | translate }}</span>
</button>
<button
mat-menu-item
[disabled]="!isRouteWithSidePanel()"
[class.active]="isShowNotes()"
(click)="toggleNotes()"
>
<mat-icon>comment</mat-icon>
<span>{{ T.MH.TOGGLE_SHOW_NOTES | translate }}</span>
</button>
</mat-menu>

View file

@ -0,0 +1,173 @@
@use '../../../styles/_globals.scss' as *;
.mobile-bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 64px;
background: var(--mat-surface-container);
border-top: 1px solid var(--sidenav-border-color);
box-shadow: 0 -1px 3px rgba(0, 0, 0, 0.1); // Subtle shadow to enhance border visibility
display: none;
align-items: center;
justify-content: space-between;
padding: 0 8px;
z-index: var(--z-mobile-bottom-nav);
// Glass effect only when background image is present
:host-context(.hasBgImage) & {
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
&.visible {
display: flex;
@media (min-width: 769px) {
display: none;
}
}
.nav-button {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 56px;
min-width: 64px;
border-radius: 16px;
transition: all 0.2s ease;
color: var(--mat-on-surface-variant);
&:hover {
background-color: var(--mat-surface-container-high);
color: var(--mat-on-surface);
}
&.active {
background-color: var(--mat-secondary-container);
color: var(--mat-on-secondary-container);
.nav-label {
font-weight: 500;
}
}
mat-icon {
font-size: 24px;
width: 24px;
height: 24px;
margin-bottom: 2px;
}
.nav-label {
font-size: 10px;
line-height: 12px;
text-transform: none;
font-weight: 400;
letter-spacing: 0.5px;
}
}
.add-task-button {
width: 56px;
height: 56px;
box-shadow: var(--mat-fab-container-elevation-shadow);
transition: all 0.2s ease;
&:hover {
transform: scale(1.05);
box-shadow:
var(--mat-fab-container-elevation-shadow),
0 8px 16px rgba(0, 0, 0, 0.2);
}
&:active {
transform: scale(0.95);
}
mat-icon {
font-size: 28px;
width: 28px;
height: 28px;
}
}
}
// Panel menu styling - global since mat-menu is rendered in overlay
::ng-deep .mobile-bottom-nav-panels-menu {
.mat-mdc-menu-item {
display: flex;
align-items: center;
gap: 12px;
min-height: 48px;
&.active {
background-color: var(--mat-secondary-container);
color: var(--mat-on-secondary-container);
.mat-icon,
plugin-icon {
color: var(--mat-on-secondary-container);
}
}
&:disabled {
opacity: 0.5;
}
.mat-icon {
margin-right: 0;
width: 24px;
height: 24px;
font-size: 24px;
}
plugin-icon {
width: 24px;
height: 24px;
}
span {
flex: 1;
}
}
}
// Animations
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideInFromLeft {
from {
transform: translateX(-100%);
}
to {
transform: translateX(0);
}
}
@keyframes slideInFromBottom {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
// Material Design touch target improvements
.nav-button,
.add-task-button {
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
}

View file

@ -0,0 +1,145 @@
import { ChangeDetectionStrategy, Component, inject, output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router, RouterModule, NavigationEnd } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatMenuModule } from '@angular/material/menu';
import { TranslateModule } from '@ngx-translate/core';
import { BreakpointObserver } from '@angular/cdk/layout';
import { toSignal } from '@angular/core/rxjs-interop';
import { filter, map, startWith } from 'rxjs/operators';
import { LayoutService } from '../layout/layout.service';
import { TaskViewCustomizerService } from '../../features/task-view-customizer/task-view-customizer.service';
import { PluginBridgeService } from '../../plugins/plugin-bridge.service';
import { PluginIconComponent } from '../../plugins/ui/plugin-icon/plugin-icon.component';
import { GlobalConfigService } from '../../features/config/global-config.service';
import { Store } from '@ngrx/store';
import { togglePluginPanel } from '../layout/store/layout.actions';
import {
selectActivePluginId,
selectIsShowPluginPanel,
} from '../layout/store/layout.reducer';
import { TODAY_TAG } from '../../features/tag/tag.const';
import { T } from '../../t.const';
@Component({
selector: 'mobile-bottom-nav',
standalone: true,
imports: [
CommonModule,
RouterModule,
MatIconModule,
MatButtonModule,
MatTooltipModule,
MatMenuModule,
TranslateModule,
PluginIconComponent,
],
templateUrl: './mobile-bottom-nav.component.html',
styleUrls: ['./mobile-bottom-nav.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MobileBottomNavComponent {
private readonly _router = inject(Router);
private readonly _breakpointObserver = inject(BreakpointObserver);
private readonly _layoutService = inject(LayoutService);
private readonly _taskViewCustomizerService = inject(TaskViewCustomizerService);
private readonly _pluginBridge = inject(PluginBridgeService);
private readonly _globalConfigService = inject(GlobalConfigService);
private readonly _store = inject(Store);
readonly T = T;
readonly TODAY_TAG = TODAY_TAG;
readonly todayTagId = TODAY_TAG.id;
readonly todayRoute = `/tag/${TODAY_TAG.id}/tasks`;
// Services for template access
readonly layoutService = this._layoutService;
readonly taskViewCustomizerService = this._taskViewCustomizerService;
// Output events
toggleMobileNavEvent = output<void>();
// Responsive breakpoint
private readonly _isMobile$ = this._breakpointObserver.observe('(max-width: 768px)');
readonly isMobile = toSignal(this._isMobile$.pipe(map((result) => result.matches)), {
initialValue: false,
});
// Current route tracking
readonly currentRoute = toSignal(
this._router.events.pipe(
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
map((event) => event.urlAfterRedirects),
startWith(this._router.url),
),
{ initialValue: this._router.url },
);
// Plugin-related signals
readonly sidePanelButtons = this._pluginBridge.sidePanelButtons;
readonly activePluginId = toSignal(this._store.select(selectActivePluginId));
readonly isShowPluginPanel = toSignal(this._store.select(selectIsShowPluginPanel));
// Route-based computed properties
readonly isRouteWithSidePanel = toSignal(
this._router.events.pipe(
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
map((event) => true), // Always true since right-panel is now global
startWith(true), // Always true since right-panel is now global
),
{ initialValue: true },
);
readonly isWorkViewPage = toSignal(
this._router.events.pipe(
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
map((event) => !!event.urlAfterRedirects.match(/tasks$/)),
startWith(!!this._router.url.match(/tasks$/)),
),
{ initialValue: !!this._router.url.match(/tasks$/) },
);
// Panel state signals from layout service
readonly isShowNotes = this._layoutService.isShowNotes;
readonly isShowIssuePanel = this._layoutService.isShowIssuePanel;
readonly isShowTaskViewCustomizerPanel =
this._layoutService.isShowTaskViewCustomizerPanel;
// Navigation methods
showAddTaskBar(): void {
this._layoutService.showAddTaskBar();
}
toggleMobileNav(): void {
this.toggleMobileNavEvent.emit();
}
// Panel methods
onPluginButtonClick(button: {
pluginId: string;
onClick?: () => void;
label?: string;
icon?: string;
}): void {
this._store.dispatch(togglePluginPanel(button.pluginId));
if (button.onClick) {
button.onClick();
}
}
toggleTaskViewCustomizer(): void {
this._layoutService.toggleTaskViewCustomizerPanel();
}
toggleIssuePanel(): void {
this._layoutService.toggleAddTaskPanel();
}
toggleNotes(): void {
this._layoutService.toggleNotes();
}
}

View file

@ -43,24 +43,3 @@
<!-- <add-task-panel-planner (closePanel)="isPanelOpen = false"></add-task-panel-planner>-->
<!-- </ng-container>-->
<!--</better-simple-drawer>-->
@if (!isPanelOpen) {
<div class="fab-wrapper FAB-BTN">
<button
mat-fab
class="show-xs-only"
color="primary"
(click)="layoutService.showAddTaskBar()"
>
<mat-icon>add</mat-icon>
</button>
<!-- <button-->
<!-- mat-fab-->
<!-- color="primary"-->
<!-- matTooltip="{{ T.F.PLANNER.TASK_DRAWER | translate }}"-->
<!-- (click)="isPanelOpen = true"-->
<!-- >-->
<!-- <mat-icon>playlist_add</mat-icon>-->
<!-- </button>-->
</div>
}

View file

@ -74,13 +74,3 @@
[currentTimeRow]="currentTimeRow()"
></schedule-week>
}
<div class="fab-wrapper show-xs-only FAB-BTN">
<button
mat-fab
color="primary"
(click)="layoutService.showAddTaskBar()"
>
<mat-icon>add</mat-icon>
</button>
</div>

View file

@ -343,14 +343,3 @@
}
</div>
}
<button
(click)="layoutService.showAddTaskBar()"
[class.isRight]="isShowBacklog()"
[matTooltip]="T.MH.ADD_NEW_TASK | translate"
class="show-xs-only add-task-btn FAB-BTN"
color="primary"
mat-fab
>
<mat-icon>add</mat-icon>
</button>

View file

@ -259,19 +259,6 @@ finish-day-btn,
}
}
.add-task-btn {
z-index: 45;
position: fixed;
right: 50%;
bottom: var(--s2);
transform: translateX(50%);
&.isRight {
transform: none;
right: var(--s2);
}
}
.no-tasks-illu-wrapper {
position: relative;
margin-bottom: 32px;

View file

@ -61,6 +61,7 @@
// NOTE needs to be below the z-index of cdk-overlay which is 1000
--z-add-task-bar: 999;
--z-search-bar: 999;
--z-mobile-bottom-nav: 30;
--z-tour: 99;
// ===============================