diff --git a/src/app/app.component.html b/src/app/app.component.html
index 21dcc706b0..e298195304 100644
--- a/src/app/app.component.html
+++ b/src/app/app.component.html
@@ -27,9 +27,14 @@
} @else {
-
+
+
+
+
+ @if (isShowMobileButtonNav) {
+
+ }
}
diff --git a/src/app/app.component.scss b/src/app/app.component.scss
index ec3b211005..14a9739535 100644
--- a/src/app/app.component.scss
+++ b/src/app/app.component.scss
@@ -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;
diff --git a/src/app/app.component.ts b/src/app/app.component.ts
index 18381c2eda..3d54fb4608 100644
--- a/src/app/app.component.ts
+++ b/src/app/app.component.ts
@@ -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;
@@ -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];
diff --git a/src/app/core-ui/main-header/main-header.component.html b/src/app/core-ui/main-header/main-header.component.html
index 2cf9515a03..7bd1b271fe 100644
--- a/src/app/core-ui/main-header/main-header.component.html
+++ b/src/app/core-ui/main-header/main-header.component.html
@@ -142,8 +142,6 @@
}
-
-
}
diff --git a/src/app/core-ui/main-header/main-header.component.ts b/src/app/core-ui/main-header/main-header.component.ts
index b52e2351ec..65e2e20c47 100644
--- a/src/app/core-ui/main-header/main-header.component.ts
+++ b/src/app/core-ui/main-header/main-header.component.ts
@@ -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,
});
diff --git a/src/app/core-ui/mobile-bottom-nav/mobile-bottom-nav.component.html b/src/app/core-ui/mobile-bottom-nav/mobile-bottom-nav.component.html
new file mode 100644
index 0000000000..46e593f455
--- /dev/null
+++ b/src/app/core-ui/mobile-bottom-nav/mobile-bottom-nav.component.html
@@ -0,0 +1,106 @@
+
+
+
diff --git a/src/app/core-ui/mobile-bottom-nav/mobile-bottom-nav.component.scss b/src/app/core-ui/mobile-bottom-nav/mobile-bottom-nav.component.scss
new file mode 100644
index 0000000000..3e6660bf13
--- /dev/null
+++ b/src/app/core-ui/mobile-bottom-nav/mobile-bottom-nav.component.scss
@@ -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;
+}
diff --git a/src/app/core-ui/mobile-bottom-nav/mobile-bottom-nav.component.ts b/src/app/core-ui/mobile-bottom-nav/mobile-bottom-nav.component.ts
new file mode 100644
index 0000000000..2981ca64b8
--- /dev/null
+++ b/src/app/core-ui/mobile-bottom-nav/mobile-bottom-nav.component.ts
@@ -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();
+
+ // 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();
+ }
+}
diff --git a/src/app/features/planner/planner.component.html b/src/app/features/planner/planner.component.html
index 35249d7ad8..0b4a4d1a22 100644
--- a/src/app/features/planner/planner.component.html
+++ b/src/app/features/planner/planner.component.html
@@ -43,24 +43,3 @@
-
-@if (!isPanelOpen) {
-
-
-
-
-
-
-
-
-
-
-
-}
diff --git a/src/app/features/schedule/schedule/schedule.component.html b/src/app/features/schedule/schedule/schedule.component.html
index f25e2bd8e3..91dd1c5d28 100644
--- a/src/app/features/schedule/schedule/schedule.component.html
+++ b/src/app/features/schedule/schedule/schedule.component.html
@@ -74,13 +74,3 @@
[currentTimeRow]="currentTimeRow()"
>
}
-
-
-
-
diff --git a/src/app/features/work-view/work-view.component.html b/src/app/features/work-view/work-view.component.html
index a8ee87b636..103d19fb18 100644
--- a/src/app/features/work-view/work-view.component.html
+++ b/src/app/features/work-view/work-view.component.html
@@ -343,14 +343,3 @@
}
}
-
-
diff --git a/src/app/features/work-view/work-view.component.scss b/src/app/features/work-view/work-view.component.scss
index 53ce1ae90b..e72ac4b2c0 100644
--- a/src/app/features/work-view/work-view.component.scss
+++ b/src/app/features/work-view/work-view.component.scss
@@ -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;
diff --git a/src/styles/_css-variables.scss b/src/styles/_css-variables.scss
index 5a967695e8..475f5fef59 100644
--- a/src/styles/_css-variables.scss
+++ b/src/styles/_css-variables.scss
@@ -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;
// ===============================