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 @@ +
+ + + + + + + + + +
+ + + @for (button of sidePanelButtons(); track button.pluginId) { + + } + + + + + + + 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; // ===============================