From c2540ec2cedc700c56feda29b69a51a9d7a91840 Mon Sep 17 00:00:00 2001 From: ayushya Date: Sun, 27 Sep 2026 12:34:06 -0400 Subject: [PATCH] Fix media viewer chrome to match the approved Noo design The viewer's top/bottom bars still used the pre-rework FrostedGlassContainer verbatim: a floating inset pill with a Material colorScheme tint, an outline border and a drop shadow (violating the design system's no-shadow rule), and theme-derived icon colors that went near-invisible against the new dark panel in light mode. The video transport controls were a second, separate floating pill above the action bar instead of one continuous panel. - FrostedGlassContainer: drop the shadow/border/ColorScheme tint for a fixed dark translucent fill matching the approved design canvas: no shadow anywhere in this screen now. - file_viewer_screen.dart: top/bottom bars sit flush edge-to-edge (no floating-pill inset) with a gradient scrim behind each, and use fixed white (not theme-derived) text/icon color, since this chrome always sits over a black media stage regardless of app theme. - ViewerIconButton: same fix - fixed white by default instead of colors.fg1. - MediaActionBar: takes an optional transportControls slot so video's transport row renders inside the same panel as the action row, above a thin divider, instead of as a separate floating pill. - MediaVideoPreview: renders only the video surface now; reports its controller up via onController (only when active, so a neighboring PageView page can't steal the transport row) instead of owning a floating panel. VideoTransportControls is the extracted, chrome-less transport row content, rendered by the parent via MediaActionBar's new slot. Verified live on device: photo and video viewing, favorite/delete coloring, and the merged transport+action panel all match the approved canvas (https://claude.ai/artifact/3AGPqqMdkLSC2ypCh2CQs4). Co-Authored-By: Claude Opus 5.5 --- lib/views/file_viewer_screen.dart | 135 ++++++++++--- lib/widgets/frosted_glass_container.dart | 40 ++-- lib/widgets/viewer/media_action_bar.dart | 42 ++-- lib/widgets/viewer/media_video_preview.dart | 207 +++++++++++--------- lib/widgets/viewer/viewer_icon_button.dart | 24 +-- 5 files changed, 272 insertions(+), 176 deletions(-) diff --git a/lib/views/file_viewer_screen.dart b/lib/views/file_viewer_screen.dart index 2a62a7f..1dc4df4 100644 --- a/lib/views/file_viewer_screen.dart +++ b/lib/views/file_viewer_screen.dart @@ -4,6 +4,7 @@ import 'package:open_file/open_file.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:provider/provider.dart'; +import 'package:video_player/video_player.dart'; import '../models/nextcloud_item.dart'; import '../providers/item_operations.dart'; import '../providers/session_controller.dart'; @@ -141,6 +142,12 @@ class _FileViewerScreenState extends State { late int _currentIndex; late final PageController _pageController; + /// The active page's video controller, once `MediaVideoPreview` reports + /// it - only ever set by the *current* page (see `_buildBody`), so a + /// neighboring `PageView` page can't steal the transport row. Drives + /// `VideoTransportControls` inside the bottom `MediaActionBar` panel. + VideoPlayerController? _videoController; + NextcloudItem get _currentItem => _mediaItems[_currentIndex]; bool get _isOffline => widget.localPathResolver != null; @@ -402,6 +409,28 @@ class _FileViewerScreenState extends State { backgroundColor: colors.fg1.withValues(alpha: 0.16), ), ), + // Scrim behind the top bar, per the approved canvas + // (https://claude.ai/artifact/3AGPqqMdkLSC2ypCh2CQs4): a plain + // gradient fade, not `FrostedGlassContainer`'s blur - just + // enough to keep the back button/title legible over bright + // media without another blurred layer stacked on the bar itself. + if (_isSwipeable) + IgnorePointer( + child: AnimatedOpacity( + duration: const Duration(milliseconds: 220), + opacity: _controlsVisible ? 1 : 0, + child: Container( + height: 150, + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0x8C000000), Colors.transparent], + ), + ), + ), + ), + ), AnimatedSlide( duration: const Duration(milliseconds: 220), curve: Curves.easeInOutCubic, @@ -409,35 +438,41 @@ class _FileViewerScreenState extends State { child: IgnorePointer( ignoring: !_controlsVisible, child: SafeArea( - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 12, 20, 0), - child: FrostedGlassContainer( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 6, + bottom: false, + child: FrostedGlassContainer( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 6, + ), + child: IconTheme.merge( + // Fixed white, not `colors.fg1`: this bar always + // sits on the dark translucent panel over a black + // media stage (see `FrostedGlassContainer`'s doc + // comment), regardless of the app's light/dark + // theme. + data: const IconThemeData( + color: Colors.white, + size: 24, ), - child: IconTheme.merge( - data: IconThemeData(color: colors.fg1, size: 24), - child: Row( - children: [ - NooTopBarButton( - icon: LucideIcons.arrowLeft, - tooltip: 'Back', - onTap: () => Navigator.pop(context), - ), - const SizedBox(width: 4), - Expanded( - child: MarqueeTitle( - text: _currentItem.name, - style: NooText.label.copyWith( - color: colors.fg1, - ), + child: Row( + children: [ + NooTopBarButton( + icon: LucideIcons.arrowLeft, + tooltip: 'Back', + onTap: () => Navigator.pop(context), + ), + const SizedBox(width: 4), + Expanded( + child: MarqueeTitle( + text: _currentItem.name, + style: NooText.label.copyWith( + color: Colors.white, ), ), - const SizedBox(width: 10), - ], - ), + ), + const SizedBox(width: 10), + ], ), ), ), @@ -445,6 +480,26 @@ class _FileViewerScreenState extends State { ), ), ), + if (_isSwipeable) + Align( + alignment: Alignment.bottomCenter, + child: IgnorePointer( + child: AnimatedOpacity( + duration: const Duration(milliseconds: 220), + opacity: _controlsVisible ? 1 : 0, + child: Container( + height: 260, + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [Color(0x99000000), Colors.transparent], + ), + ), + ), + ), + ), + ), Align( alignment: Alignment.bottomCenter, child: AnimatedSlide( @@ -460,6 +515,12 @@ class _FileViewerScreenState extends State { // server - hidden rather than shown-and-failing while // browsing an already-local file from the Offline tab. showServerActions: !_isOffline, + transportControls: + _isSwipeable && + _currentItem.type == NextcloudItemType.video && + _videoController != null + ? VideoTransportControls(controller: _videoController!) + : null, onShare: () => ShareSheet.show(context, _currentItem), onFavorite: () => _toggleFavorite(ops), onDelete: () => _deleteCurrentItem(ops), @@ -484,9 +545,17 @@ class _FileViewerScreenState extends State { return PageView.builder( controller: _pageController, itemCount: _mediaItems.length, - onPageChanged: (index) => setState(() => _currentIndex = index), + onPageChanged: (index) => setState(() { + _currentIndex = index; + // The outgoing page's `onController` stops being passed below (it's + // no longer the active index) and reports null on its own dispose, + // but that can lag a frame or two behind this rebuild - clear + // eagerly so the transport row never briefly shows the old video. + _videoController = null; + }), itemBuilder: (context, index) { final mediaItem = _mediaItems[index]; + final isActive = index == _currentIndex; if (!_isOffline) { if (mediaItem.type == NextcloudItemType.image) { return MediaImagePreview( @@ -499,8 +568,10 @@ class _FileViewerScreenState extends State { key: ValueKey(mediaItem.id), url: session.service!.fileUrl(mediaItem.path), headers: session.service!.authHeaders, - isActive: index == _currentIndex, - controlsBottomOffset: _controlsVisible ? 108 : 24, + isActive: isActive, + onController: isActive + ? (c) => setState(() => _videoController = c) + : null, ); } // Offline: each swiped-to item resolves its own local path on @@ -540,8 +611,10 @@ class _FileViewerScreenState extends State { url: null, headers: const {}, localPath: localPath, - isActive: index == _currentIndex, - controlsBottomOffset: _controlsVisible ? 108 : 24, + isActive: isActive, + onController: isActive + ? (c) => setState(() => _videoController = c) + : null, ); }, ); diff --git a/lib/widgets/frosted_glass_container.dart b/lib/widgets/frosted_glass_container.dart index 93e957a..4015a9d 100644 --- a/lib/widgets/frosted_glass_container.dart +++ b/lib/widgets/frosted_glass_container.dart @@ -1,9 +1,15 @@ import 'dart:ui'; import 'package:flutter/material.dart'; -/// The frosted-glass pill background shared by all floating chrome in the -/// app (bottom nav bar, media viewer action bar): blurred backdrop, a -/// translucent tonal fill, a faint outline, and a soft drop shadow. +/// The blurred/translucent chrome for the media viewer's overlay bars +/// (`file_viewer_screen.dart`'s top bar, `MediaActionBar`, the video +/// transport row) - the one deliberate exception to the design system's +/// flat, no-shadow product UI (DESIGN_SYSTEM.md 1.4), since this chrome +/// floats over photo/video content rather than over the app's own +/// surfaces. No shadow, no outline, no `ColorScheme` tint: a fixed dark +/// translucent fill (`color`) that reads correctly over any media, +/// regardless of the app's light/dark theme - matching the canvas at +/// https://claude.ai/artifact/3AGPqqMdkLSC2ypCh2CQs4. class FrostedGlassContainer extends StatelessWidget { final Widget child; final double borderRadius; @@ -17,39 +23,31 @@ class FrostedGlassContainer extends StatelessWidget { /// adjustable. final double opacity; + /// The tint under the blur. Defaults to the design's fixed dark tone + /// (`#141311`, the spec's `fg-1` dark value) - deliberately not a + /// `ColorScheme`/`NooColors` lookup, since this chrome always sits over a + /// black media stage, not over a themed surface. + final Color color; + const FrostedGlassContainer({ super.key, required this.child, - this.borderRadius = 32, - this.blurSigma = 28, + this.borderRadius = 0, + this.blurSigma = 20, this.opacity = 0.55, + this.color = const Color(0xFF141311), }); @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return ClipRRect( borderRadius: BorderRadius.circular(borderRadius), child: BackdropFilter( filter: ImageFilter.blur(sigmaX: blurSigma, sigmaY: blurSigma), child: Container( decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest.withValues( - alpha: opacity, - ), + color: color.withValues(alpha: opacity), borderRadius: BorderRadius.circular(borderRadius), - border: Border.all( - color: colorScheme.outlineVariant.withValues(alpha: 0.35), - width: 1, - ), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.14), - blurRadius: 24, - offset: const Offset(0, 8), - ), - ], ), child: child, ), diff --git a/lib/widgets/viewer/media_action_bar.dart b/lib/widgets/viewer/media_action_bar.dart index ea95c1c..208c055 100644 --- a/lib/widgets/viewer/media_action_bar.dart +++ b/lib/widgets/viewer/media_action_bar.dart @@ -4,15 +4,19 @@ import '../../theme/design_tokens.dart'; import '../frosted_glass_container.dart'; import 'viewer_icon_button.dart'; -/// The floating action bar (share/favorite/open/download/delete/details) -/// overlaid on the media viewer - kept on `FrostedGlassContainer`'s blurred -/// chrome as a deliberate exception to the design system's flat product UI -/// (see `file_viewer_screen.dart`'s build() comment for why), unlike the -/// now-flat bottom nav bar (`bottom_nav_bar.dart`) this screen sits above. +/// The bottom panel overlaid on the media viewer: an optional transport row +/// ([transportControls], video only) above the action row +/// (share/favorite/open/download/delete/details) - one continuous flush, +/// full-width `FrostedGlassContainer` panel, not two separate floating +/// pills. Kept on that blurred chrome as a deliberate exception to the +/// design system's flat product UI (see `file_viewer_screen.dart`'s +/// build() comment for why), unlike the now-flat bottom nav bar +/// (`bottom_nav_bar.dart`) this screen sits above. class MediaActionBar extends StatelessWidget { final bool isFavorite; final bool isBusy; final bool showServerActions; + final Widget? transportControls; final VoidCallback onShare; final VoidCallback onFavorite; final VoidCallback onDelete; @@ -25,6 +29,7 @@ class MediaActionBar extends StatelessWidget { required this.isFavorite, required this.isBusy, required this.showServerActions, + this.transportControls, required this.onShare, required this.onFavorite, required this.onDelete, @@ -35,15 +40,23 @@ class MediaActionBar extends StatelessWidget { @override Widget build(BuildContext context) { - final colors = context.nooColors; + // This panel always sits on a black media stage regardless of the + // app's own light/dark theme (see FrostedGlassContainer's doc + // comment) - fixed dark-palette values, not `context.nooColors`, so + // favorite/delete stay legible even when the ambient theme is light. + const dark = NooColors.dark; return SafeArea( - child: Padding( - padding: const EdgeInsets.only(bottom: 20, left: 20, right: 20), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 480), - child: FrostedGlassContainer( - child: Padding( + top: false, + child: FrostedGlassContainer( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (transportControls != null) ...[ + transportControls!, + Divider(height: 1, color: Colors.white.withValues(alpha: 0.14)), + ], + Padding( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6), child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, @@ -62,6 +75,7 @@ class MediaActionBar extends StatelessWidget { tooltip: isFavorite ? 'Remove from favorites' : 'Favorite', + color: isFavorite ? dark.accentText : null, onTap: onFavorite, ), ViewerIconButton( @@ -79,7 +93,7 @@ class MediaActionBar extends StatelessWidget { ViewerIconButton( icon: LucideIcons.trash2, tooltip: 'Delete', - color: colors.danger, + color: dark.danger, onTap: isBusy ? null : onDelete, ), ViewerIconButton( @@ -90,7 +104,7 @@ class MediaActionBar extends StatelessWidget { ], ), ), - ), + ], ), ), ); diff --git a/lib/widgets/viewer/media_video_preview.dart b/lib/widgets/viewer/media_video_preview.dart index eac39f5..51576ae 100644 --- a/lib/widgets/viewer/media_video_preview.dart +++ b/lib/widgets/viewer/media_video_preview.dart @@ -6,12 +6,16 @@ import 'package:provider/provider.dart'; import 'package:video_player/video_player.dart'; import '../../providers/settings_controller.dart'; import '../../theme/design_tokens.dart'; -import '../frosted_glass_container.dart'; import '../seek_bar_painter.dart'; import 'viewer_icon_button.dart'; -/// Playing video page for [FileViewerScreen]'s swipeable `PageView`, plus -/// its floating transport controls (play/pause, position, mute, seek bar). +/// Playing video page for [FileViewerScreen]'s swipeable `PageView`. Just +/// the video surface - the transport controls ([VideoTransportControls]) +/// are built by the parent, inside the same bottom panel as +/// [MediaActionBar], once this reports its controller up via +/// [onController]. Keeping playback lifecycle (`initState`/`dispose`) here +/// and the controls' *presentation* in the parent is what lets both sit in +/// one continuous panel without the parent owning playback. class MediaVideoPreview extends StatefulWidget { final String? url; final Map headers; @@ -22,11 +26,12 @@ class MediaVideoPreview extends StatefulWidget { /// `FileViewerScreen.localPathResolver`'s doc comment). final String? localPath; - /// Distance from the bottom of the screen the transport controls should - /// sit at. Passed in by the parent so the controls can track the floating - /// action bar: resting just above it when visible, sliding down to hug - /// the screen edge when the bar (and rest of the chrome) is hidden. - final double controlsBottomOffset; + /// Reports the live controller once initialized, and `null` on dispose, + /// so the parent can render [VideoTransportControls] for it. The parent + /// only passes this for the *active* page (see `file_viewer_screen.dart`'s + /// `_buildBody`) - a neighboring, inactive `PageView` page never reports + /// up, so an adjacent video can't steal the transport row. + final ValueChanged? onController; const MediaVideoPreview({ super.key, @@ -34,7 +39,7 @@ class MediaVideoPreview extends StatefulWidget { required this.headers, this.localPath, this.isActive = true, - this.controlsBottomOffset = 24, + this.onController, }); @override @@ -65,6 +70,7 @@ class _MediaVideoPreviewState extends State { return; } setState(() => _controller = controller); + widget.onController?.call(controller); if (widget.isActive) controller.play(); } catch (e) { if (mounted) setState(() => _error = e.toString()); @@ -81,26 +87,32 @@ class _MediaVideoPreviewState extends State { _controller?.pause(); } } + // Newly given a callback (this page just became active) and already + // initialized: report it, but not synchronously - `didUpdateWidget` + // runs as part of the *parent's* own widget-tree update (the + // `onPageChanged` setState that made this page active), and the + // parent's `onController` calls `setState` itself, which would trip + // "setState called during build" if invoked in the same pass. Defer to + // the next frame instead of waiting for `_init` (which won't run again). + if (oldWidget.onController == null && + widget.onController != null && + _controller != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) widget.onController!(_controller); + }); + } } @override void dispose() { + widget.onController?.call(null); _controller?.dispose(); super.dispose(); } - String _formatDuration(Duration d) { - final minutes = d.inMinutes.remainder(60); - final seconds = d.inSeconds.remainder(60).toString().padLeft(2, '0'); - return '$minutes:$seconds'; - } - @override Widget build(BuildContext context) { final colors = context.nooColors; - final progressBarStyle = context - .watch() - .mediaProgressBarStyle; if (_error != null) { return Center( child: Text( @@ -115,89 +127,88 @@ class _MediaVideoPreviewState extends State { } return SizedBox.expand( - child: Stack( - alignment: Alignment.center, - children: [ - Center( - child: AspectRatio( - aspectRatio: controller.value.aspectRatio, - child: VideoPlayer(controller), - ), - ), - AnimatedPositioned( - duration: const Duration(milliseconds: 220), - curve: Curves.easeInOutCubic, - left: 16, - right: 16, - bottom: widget.controlsBottomOffset, - child: AnimatedBuilder( - animation: controller, - builder: (context, _) { - final fg = colors.fg1; - // Same frosted-glass treatment (and user opacity/blur - // settings) as the back button and media action bar, so the - // transport controls match the rest of the app's chrome. - return FrostedGlassContainer( - borderRadius: 24, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 4, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - ViewerIconButton( - icon: controller.value.isPlaying - ? LucideIcons.pause - : LucideIcons.play, - tooltip: controller.value.isPlaying - ? 'Pause' - : 'Play', - onTap: () => controller.value.isPlaying - ? controller.pause() - : controller.play(), - ), - Expanded( - child: Text( - '${_formatDuration(controller.value.position)} / ${_formatDuration(controller.value.duration)}', - textAlign: TextAlign.center, - style: NooText.meta.copyWith(color: fg), - ), - ), - ViewerIconButton( - icon: controller.value.volume == 0 - ? LucideIcons.volumeX - : LucideIcons.volume2, - tooltip: controller.value.volume == 0 - ? 'Unmute' - : 'Mute', - onTap: () => controller.setVolume( - controller.value.volume == 0 ? 1 : 0, - ), - ), - ], - ), - Padding( - padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), - child: WavySeekBar( - controller: controller, - playedColor: fg, - trackColor: fg.withValues(alpha: 0.3), - style: progressBarStyle, - ), - ), - ], + child: Center( + child: AspectRatio( + aspectRatio: controller.value.aspectRatio, + child: VideoPlayer(controller), + ), + ), + ); + } +} + +/// The transport row content (play/pause, position, mute, seek bar) for +/// [controller] - no panel chrome of its own. Rendered by +/// `file_viewer_screen.dart` inside the same bottom panel as +/// [MediaActionBar], directly above the action row, once +/// [MediaVideoPreview.onController] reports a live controller. +class VideoTransportControls extends StatelessWidget { + final VideoPlayerController controller; + + const VideoTransportControls({super.key, required this.controller}); + + String _formatDuration(Duration d) { + final minutes = d.inMinutes.remainder(60); + final seconds = d.inSeconds.remainder(60).toString().padLeft(2, '0'); + return '$minutes:$seconds'; + } + + @override + Widget build(BuildContext context) { + final progressBarStyle = context + .watch() + .mediaProgressBarStyle; + return AnimatedBuilder( + animation: controller, + builder: (context, _) { + const fg = Colors.white; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + ViewerIconButton( + icon: controller.value.isPlaying + ? LucideIcons.pause + : LucideIcons.play, + tooltip: controller.value.isPlaying ? 'Pause' : 'Play', + onTap: () => controller.value.isPlaying + ? controller.pause() + : controller.play(), + ), + Expanded( + child: Text( + '${_formatDuration(controller.value.position)} / ${_formatDuration(controller.value.duration)}', + textAlign: TextAlign.center, + style: NooText.meta.copyWith(color: fg), ), ), - ); - }, - ), + ViewerIconButton( + icon: controller.value.volume == 0 + ? LucideIcons.volumeX + : LucideIcons.volume2, + tooltip: controller.value.volume == 0 ? 'Unmute' : 'Mute', + onTap: () => controller.setVolume( + controller.value.volume == 0 ? 1 : 0, + ), + ), + ], + ), + Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), + child: WavySeekBar( + controller: controller, + playedColor: fg, + trackColor: fg.withValues(alpha: 0.3), + style: progressBarStyle, + ), + ), + ], ), - ], - ), + ); + }, ); } } diff --git a/lib/widgets/viewer/viewer_icon_button.dart b/lib/widgets/viewer/viewer_icon_button.dart index 08daf54..1505b4e 100644 --- a/lib/widgets/viewer/viewer_icon_button.dart +++ b/lib/widgets/viewer/viewer_icon_button.dart @@ -1,21 +1,22 @@ import 'package:flutter/material.dart'; -import '../../theme/design_tokens.dart'; /// Icon-only action button for the media viewer's floating chrome (top bar, /// bottom action bar, video transport controls) - a fixed 22px icon in a -/// round hit box, colored from [NooColors] rather than Material's -/// [ColorScheme]. Kept as one small widget (matching the pre-rework -/// `_ActionIconButton` it replaces - see `styling.md`) so every floating -/// control in this screen looks the same instead of drifting apart across -/// the three call sites that need one. +/// round hit box. Colored white by default, not from [NooColors]/ +/// [ColorScheme]: this chrome always sits on the dark translucent panel +/// over a black media stage (see `FrostedGlassContainer`'s doc comment), +/// regardless of the app's light/dark theme, so a theme-derived color would +/// go near-invisible in light mode. Kept as one small widget so every +/// floating control in this screen looks the same instead of drifting +/// apart across the three call sites that need one. class ViewerIconButton extends StatelessWidget { final IconData icon; final String tooltip; final VoidCallback? onTap; - /// Overrides the default `fg-1` (e.g. `danger` for delete). Ignored while - /// [onTap] is null - a disabled button always dims to the same faded fg-1 - /// regardless of its normal color. + /// Overrides the default white (e.g. `danger` for delete, `accent-text` + /// for a favorited state). Ignored while [onTap] is null - a disabled + /// button always dims to faded white regardless of its normal color. final Color? color; const ViewerIconButton({ @@ -28,10 +29,9 @@ class ViewerIconButton extends StatelessWidget { @override Widget build(BuildContext context) { - final colors = context.nooColors; final fg = onTap == null - ? colors.fg1.withValues(alpha: 0.4) - : (color ?? colors.fg1); + ? Colors.white.withValues(alpha: 0.4) + : (color ?? Colors.white); return Tooltip( message: tooltip,