From 1000032db0b223985efa4f793aa573aed04fddb7 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 25 Oct 2025 03:06:45 +0200 Subject: [PATCH] refactor: simplify macos titlebar logic --- lib/screens/about_screen.dart | 14 +- lib/screens/libraries_screen.dart | 5 +- lib/screens/media_detail_screen.dart | 16 +- lib/screens/season_detail_screen.dart | 14 +- lib/screens/server_selection_screen.dart | 3 +- lib/utils/desktop_window_padding.dart | 155 ++++++++++++++++++++ lib/widgets/app_bar_back_button.dart | 107 ++++++++++++++ lib/widgets/desktop_app_bar.dart | 177 ++--------------------- lib/widgets/plex_video_controls.dart | 8 +- 9 files changed, 291 insertions(+), 208 deletions(-) create mode 100644 lib/utils/desktop_window_padding.dart create mode 100644 lib/widgets/app_bar_back_button.dart diff --git a/lib/screens/about_screen.dart b/lib/screens/about_screen.dart index b2913ca9..27e25236 100644 --- a/lib/screens/about_screen.dart +++ b/lib/screens/about_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:package_info_plus/package_info_plus.dart'; import '../widgets/desktop_app_bar.dart'; +import '../widgets/app_bar_back_button.dart'; class AboutScreen extends StatefulWidget { const AboutScreen({super.key}); @@ -38,17 +39,8 @@ class _AboutScreenState extends State { DesktopSliverAppBar( title: const Text('About'), pinned: true, - leading: Container( - margin: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.5), - shape: BoxShape.circle, - ), - child: IconButton( - icon: const Icon(Icons.arrow_back, color: Colors.white), - onPressed: () => Navigator.of(context).pop(), - padding: EdgeInsets.zero, - ), + leading: const AppBarBackButton( + style: BackButtonStyle.circular, ), ), SliverPadding( diff --git a/lib/screens/libraries_screen.dart b/lib/screens/libraries_screen.dart index 04839337..94c69661 100644 --- a/lib/screens/libraries_screen.dart +++ b/lib/screens/libraries_screen.dart @@ -6,6 +6,7 @@ import '../models/plex_filter.dart'; import '../models/plex_user_profile.dart'; import '../widgets/media_card.dart'; import '../widgets/desktop_app_bar.dart'; +import '../widgets/app_bar_back_button.dart'; import '../services/storage_service.dart'; import '../mixins/refreshable.dart'; @@ -518,8 +519,8 @@ class _FiltersBottomSheetState extends State<_FiltersBottomSheet> { ), child: Row( children: [ - IconButton( - icon: const Icon(Icons.arrow_back), + AppBarBackButton( + style: BackButtonStyle.plain, onPressed: _goBack, ), const SizedBox(width: 8), diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 1f2dd69e..b693f15e 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -4,6 +4,7 @@ import '../client/plex_client.dart'; import '../models/plex_metadata.dart'; import '../models/plex_user_profile.dart'; import '../widgets/desktop_app_bar.dart'; +import '../widgets/app_bar_back_button.dart'; import '../widgets/media_context_menu.dart'; import '../utils/app_logger.dart'; import 'season_detail_screen.dart'; @@ -196,19 +197,8 @@ class _MediaDetailScreenState extends State { DesktopSliverAppBar( expandedHeight: headerHeight, pinned: true, - leading: SafeArea( - child: Container( - margin: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.5), - shape: BoxShape.circle, - ), - child: IconButton( - icon: const Icon(Icons.arrow_back, color: Colors.white), - onPressed: () => Navigator.of(context).pop(), - padding: EdgeInsets.zero, - ), - ), + leading: const AppBarBackButton( + style: BackButtonStyle.circular, ), flexibleSpace: FlexibleSpaceBar( background: Stack( diff --git a/lib/screens/season_detail_screen.dart b/lib/screens/season_detail_screen.dart index e0c8e55d..53333820 100644 --- a/lib/screens/season_detail_screen.dart +++ b/lib/screens/season_detail_screen.dart @@ -4,6 +4,7 @@ import '../client/plex_client.dart'; import '../models/plex_metadata.dart'; import '../models/plex_user_profile.dart'; import '../widgets/desktop_app_bar.dart'; +import '../widgets/app_bar_back_button.dart'; import 'video_player_screen.dart'; class SeasonDetailScreen extends StatefulWidget { @@ -58,17 +59,8 @@ class _SeasonDetailScreenState extends State { DesktopSliverAppBar( title: Text(widget.season.title), pinned: true, - leading: Container( - margin: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.5), - shape: BoxShape.circle, - ), - child: IconButton( - icon: const Icon(Icons.arrow_back, color: Colors.white), - onPressed: () => Navigator.of(context).pop(), - padding: EdgeInsets.zero, - ), + leading: const AppBarBackButton( + style: BackButtonStyle.circular, ), ), if (_isLoadingEpisodes) diff --git a/lib/screens/server_selection_screen.dart b/lib/screens/server_selection_screen.dart index b3d379bd..951cd7f6 100644 --- a/lib/screens/server_selection_screen.dart +++ b/lib/screens/server_selection_screen.dart @@ -4,6 +4,7 @@ import '../services/storage_service.dart'; import '../client/plex_client.dart'; import '../config/plex_config.dart'; import '../widgets/server_list_tile.dart'; +import '../widgets/desktop_app_bar.dart'; import 'main_screen.dart'; class ServerSelectionScreen extends StatefulWidget { @@ -114,7 +115,7 @@ class _ServerSelectionScreenState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text('Select Server')), + appBar: const DesktopAppBar(title: Text('Select Server')), body: _isLoading ? const Center(child: CircularProgressIndicator()) : _errorMessage != null diff --git a/lib/utils/desktop_window_padding.dart b/lib/utils/desktop_window_padding.dart new file mode 100644 index 00000000..b64e86c0 --- /dev/null +++ b/lib/utils/desktop_window_padding.dart @@ -0,0 +1,155 @@ +import 'dart:io' show Platform; +import 'package:flutter/material.dart'; +import '../services/fullscreen_state_manager.dart'; + +/// Padding values for desktop window controls +class DesktopWindowPadding { + /// Left padding for macOS traffic lights (normal window mode) + static const double macOSLeft = 80.0; + + /// Left padding for macOS in fullscreen (reduced since traffic lights auto-hide) + static const double macOSLeftFullscreen = 0.0; + + /// Right padding for macOS to prevent actions from being too close to edge + static const double macOSRight = 16.0; +} + +/// Helper class for adjusting app bar widgets to account for desktop window controls +class DesktopAppBarHelper { + /// Builds actions list with appropriate right padding for macOS + static List? buildAdjustedActions(List? actions) { + if (!Platform.isMacOS) { + return actions; + } + + // macOS: Add padding to keep actions away from edge + if (actions != null) { + return [ + ...actions, + SizedBox(width: DesktopWindowPadding.macOSRight), + ]; + } else { + return [SizedBox(width: DesktopWindowPadding.macOSRight)]; + } + } + + /// Builds leading widget with appropriate left padding for macOS traffic lights + /// + /// [includeGestureDetector] - If true, wraps in GestureDetector to prevent window dragging + static Widget? buildAdjustedLeading( + Widget? leading, { + bool includeGestureDetector = false, + }) { + if (!Platform.isMacOS || leading == null) { + return leading; + } + + return ListenableBuilder( + listenable: FullscreenStateManager(), + builder: (context, _) { + final isFullscreen = FullscreenStateManager().isFullscreen; + final leftPadding = isFullscreen + ? DesktopWindowPadding.macOSLeftFullscreen + : DesktopWindowPadding.macOSLeft; + + final paddedWidget = Padding( + padding: EdgeInsets.only(left: leftPadding), + child: leading, + ); + + if (includeGestureDetector) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onPanDown: (_) {}, // Consume pan gestures to prevent window dragging + child: paddedWidget, + ); + } + + return paddedWidget; + }, + ); + } + + /// Builds flexible space with gesture detector on macOS to prevent window dragging + static Widget? buildAdjustedFlexibleSpace(Widget? flexibleSpace) { + if (!Platform.isMacOS || flexibleSpace == null) { + return flexibleSpace; + } + + return GestureDetector( + behavior: HitTestBehavior.translucent, + onPanDown: (_) {}, // Consume pan gestures to prevent window dragging + child: flexibleSpace, + ); + } + + /// Calculates the leading width for SliverAppBar to account for macOS traffic lights + static double? calculateLeadingWidth(Widget? leading) { + if (!Platform.isMacOS || leading == null) { + return null; + } + + final isFullscreen = FullscreenStateManager().isFullscreen; + final leftPadding = isFullscreen + ? DesktopWindowPadding.macOSLeftFullscreen + : DesktopWindowPadding.macOSLeft; + return leftPadding + kToolbarHeight; + } + + /// Wraps a widget with GestureDetector on macOS to prevent window dragging + static Widget wrapWithGestureDetector(Widget child) { + if (!Platform.isMacOS) { + return child; + } + + return GestureDetector( + behavior: HitTestBehavior.translucent, + onPanDown: (_) {}, // Consume pan gestures to prevent window dragging + child: child, + ); + } +} + +/// A widget that adds padding to account for desktop window controls. +/// On macOS, adds left padding for traffic lights (reduced in fullscreen). +class DesktopTitleBarPadding extends StatelessWidget { + final Widget child; + final double? leftPadding; + final double? rightPadding; + + const DesktopTitleBarPadding({ + super.key, + required this.child, + this.leftPadding, + this.rightPadding, + }); + + @override + Widget build(BuildContext context) { + if (!Platform.isMacOS) { + return child; + } + + return ListenableBuilder( + listenable: FullscreenStateManager(), + builder: (context, _) { + final isFullscreen = FullscreenStateManager().isFullscreen; + // In fullscreen, use minimal padding since traffic lights auto-hide + final left = leftPadding ?? + (isFullscreen + ? DesktopWindowPadding.macOSLeftFullscreen + : DesktopWindowPadding.macOSLeft); + final right = rightPadding ?? 0.0; + + if (left == 0.0 && right == 0.0) { + return child; + } + + return Padding( + padding: EdgeInsets.only(left: left, right: right), + child: child, + ); + }, + ); + } +} diff --git a/lib/widgets/app_bar_back_button.dart b/lib/widgets/app_bar_back_button.dart new file mode 100644 index 00000000..8e3b2164 --- /dev/null +++ b/lib/widgets/app_bar_back_button.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; + +/// Defines the visual style of the back button +enum BackButtonStyle { + /// Back button with circular semi-transparent background (used in detail screens) + circular, + + /// Plain back button without background (used in sheets and simple contexts) + plain, + + /// Back button styled for video player overlay + video, +} + +/// A reusable back button widget that provides consistent styling across the app. +/// +/// This widget supports different visual styles through [BackButtonStyle] enum: +/// - [BackButtonStyle.circular]: Semi-transparent circular background for detail screens +/// - [BackButtonStyle.plain]: Simple IconButton for sheets and simple contexts +/// - [BackButtonStyle.video]: Styled for video player overlay +/// +/// Example usage: +/// ```dart +/// AppBarBackButton(style: BackButtonStyle.circular) +/// ``` +class AppBarBackButton extends StatelessWidget { + /// Creates a back button with the specified style. + /// + /// [style] determines the visual appearance of the back button. + /// [onPressed] is called when the button is tapped. If null, defaults to Navigator.pop. + /// [color] overrides the default icon color. If null, uses white for circular/video, theme default for plain. + const AppBarBackButton({ + super.key, + this.style = BackButtonStyle.circular, + this.onPressed, + this.color, + }); + + /// The visual style of the back button + final BackButtonStyle style; + + /// Callback when the button is pressed. Defaults to Navigator.of(context).pop() + final VoidCallback? onPressed; + + /// The color of the back arrow icon. If null, uses style-appropriate default. + final Color? color; + + void _handlePressed(BuildContext context) { + if (onPressed != null) { + onPressed!(); + } else { + Navigator.of(context).pop(); + } + } + + @override + Widget build(BuildContext context) { + switch (style) { + case BackButtonStyle.circular: + return _buildCircularBackButton(context); + case BackButtonStyle.plain: + return _buildPlainBackButton(context); + case BackButtonStyle.video: + return _buildVideoBackButton(context); + } + } + + /// Builds a back button with circular semi-transparent background + Widget _buildCircularBackButton(BuildContext context) { + return SafeArea( + child: Container( + margin: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.5), + shape: BoxShape.circle, + ), + child: IconButton( + icon: Icon(Icons.arrow_back, color: color ?? Colors.white), + onPressed: () => _handlePressed(context), + padding: EdgeInsets.zero, + tooltip: 'Back', + ), + ), + ); + } + + /// Builds a plain back button without background + Widget _buildPlainBackButton(BuildContext context) { + return IconButton( + icon: Icon(Icons.arrow_back, color: color), + onPressed: () => _handlePressed(context), + tooltip: 'Back', + ); + } + + /// Builds a back button styled for video player overlay + Widget _buildVideoBackButton(BuildContext context) { + return Container( + margin: const EdgeInsets.only(left: 8), + child: IconButton( + icon: Icon(Icons.arrow_back, color: color ?? Colors.white), + onPressed: () => _handlePressed(context), + tooltip: 'Back', + ), + ); + } +} diff --git a/lib/widgets/desktop_app_bar.dart b/lib/widgets/desktop_app_bar.dart index 0d82667f..c9eba412 100644 --- a/lib/widgets/desktop_app_bar.dart +++ b/lib/widgets/desktop_app_bar.dart @@ -1,62 +1,5 @@ -import 'dart:io' show Platform; import 'package:flutter/material.dart'; -import '../services/fullscreen_state_manager.dart'; - -class DesktopWindowPadding { - /// Left padding for macOS traffic lights (normal window mode) - static const double macOSLeft = 80.0; - - /// Left padding for macOS in fullscreen (reduced since traffic lights auto-hide) - static const double macOSLeftFullscreen = 0.0; - - /// Right padding for macOS to prevent actions from being too close to edge - static const double macOSRight = 16.0; -} - -/// A widget that adds padding to account for desktop window controls. -/// On macOS, adds left padding for traffic lights (reduced in fullscreen). -class DesktopTitleBarPadding extends StatelessWidget { - final Widget child; - final double? leftPadding; - final double? rightPadding; - - const DesktopTitleBarPadding({ - super.key, - required this.child, - this.leftPadding, - this.rightPadding, - }); - - @override - Widget build(BuildContext context) { - return ListenableBuilder( - listenable: FullscreenStateManager(), - builder: (context, _) { - double left = 0.0; - double right = 0.0; - - if (Platform.isMacOS) { - final isFullscreen = FullscreenStateManager().isFullscreen; - // In fullscreen, use minimal padding since traffic lights auto-hide - left = - leftPadding ?? - (isFullscreen - ? DesktopWindowPadding.macOSLeftFullscreen - : DesktopWindowPadding.macOSLeft); - } - - if (left == 0.0 && right == 0.0) { - return child; - } - - return Padding( - padding: EdgeInsets.only(left: left, right: right), - child: child, - ); - }, - ); - } -} +import '../utils/desktop_window_padding.dart'; /// A custom app bar that automatically handles desktop window controls spacing. /// Use this instead of AppBar for consistent desktop platform behavior. @@ -86,43 +29,10 @@ class DesktopAppBar extends StatelessWidget implements PreferredSizeWidget { @override Widget build(BuildContext context) { - // Add right padding for desktop platforms - List? adjustedActions = actions; - - if (Platform.isMacOS) { - // macOS: Add padding to keep actions away from edge - if (actions != null) { - adjustedActions = [ - ...actions!, - SizedBox(width: DesktopWindowPadding.macOSRight), - ]; - } else { - adjustedActions = [SizedBox(width: DesktopWindowPadding.macOSRight)]; - } - } - - // Wrap leading widget with padding on macOS to avoid traffic lights - Widget? adjustedLeading = leading; - if (Platform.isMacOS && leading != null) { - adjustedLeading = ListenableBuilder( - listenable: FullscreenStateManager(), - builder: (context, _) { - final isFullscreen = FullscreenStateManager().isFullscreen; - final leftPadding = isFullscreen - ? DesktopWindowPadding.macOSLeftFullscreen - : DesktopWindowPadding.macOSLeft; - return Padding( - padding: EdgeInsets.only(left: leftPadding), - child: leading, - ); - }, - ); - } - final appBar = AppBar( title: title != null ? DesktopTitleBarPadding(child: title!) : null, - actions: adjustedActions, - leading: adjustedLeading, + actions: DesktopAppBarHelper.buildAdjustedActions(actions), + leading: DesktopAppBarHelper.buildAdjustedLeading(leading), automaticallyImplyLeading: automaticallyImplyLeading, elevation: elevation, backgroundColor: backgroundColor, @@ -131,17 +41,7 @@ class DesktopAppBar extends StatelessWidget implements PreferredSizeWidget { scrolledUnderElevation: scrolledUnderElevation, ); - // On macOS with transparent titlebar, wrap in GestureDetector to prevent - // window dragging and allow buttons to be clickable - if (Platform.isMacOS) { - return GestureDetector( - behavior: HitTestBehavior.translucent, - onPanDown: (_) {}, // Consume pan gestures to prevent window dragging - child: appBar, - ); - } - - return appBar; + return DesktopAppBarHelper.wrapWithGestureDetector(appBar); } @override @@ -186,69 +86,14 @@ class DesktopSliverAppBar extends StatelessWidget { @override Widget build(BuildContext context) { - // Add right padding for desktop platforms - List? adjustedActions = actions; - - if (Platform.isMacOS) { - // macOS: Add padding to keep actions away from edge - if (actions != null) { - adjustedActions = [ - ...actions!, - SizedBox(width: DesktopWindowPadding.macOSRight), - ]; - } else { - adjustedActions = [SizedBox(width: DesktopWindowPadding.macOSRight)]; - } - } - - // Wrap leading widget with gesture detector and padding on macOS - Widget? adjustedLeading = leading; - if (Platform.isMacOS && leading != null) { - adjustedLeading = ListenableBuilder( - listenable: FullscreenStateManager(), - builder: (context, _) { - final isFullscreen = FullscreenStateManager().isFullscreen; - final leftPadding = isFullscreen - ? DesktopWindowPadding.macOSLeftFullscreen - : DesktopWindowPadding.macOSLeft; - return GestureDetector( - behavior: HitTestBehavior.opaque, - onPanDown: - (_) {}, // Consume pan gestures to prevent window dragging - child: Padding( - padding: EdgeInsets.only(left: leftPadding), - child: leading, - ), - ); - }, - ); - } - - // Wrap flexible space with gesture detector on macOS to prevent window dragging - Widget? adjustedFlexibleSpace = flexibleSpace; - if (Platform.isMacOS && flexibleSpace != null) { - adjustedFlexibleSpace = GestureDetector( - behavior: HitTestBehavior.translucent, - onPanDown: (_) {}, // Consume pan gestures to prevent window dragging - child: flexibleSpace, - ); - } - - // On macOS, increase leading width to account for traffic light spacing - double? leadingWidth; - if (Platform.isMacOS && leading != null) { - final isFullscreen = FullscreenStateManager().isFullscreen; - final leftPadding = isFullscreen - ? DesktopWindowPadding.macOSLeftFullscreen - : DesktopWindowPadding.macOSLeft; - leadingWidth = leftPadding + kToolbarHeight; - } - return SliverAppBar( title: title != null ? DesktopTitleBarPadding(child: title!) : null, - actions: adjustedActions, - leading: adjustedLeading, - leadingWidth: leadingWidth, + actions: DesktopAppBarHelper.buildAdjustedActions(actions), + leading: DesktopAppBarHelper.buildAdjustedLeading( + leading, + includeGestureDetector: true, + ), + leadingWidth: DesktopAppBarHelper.calculateLeadingWidth(leading), automaticallyImplyLeading: automaticallyImplyLeading, elevation: elevation, backgroundColor: backgroundColor, @@ -258,7 +103,7 @@ class DesktopSliverAppBar extends StatelessWidget { floating: floating, pinned: pinned, expandedHeight: expandedHeight, - flexibleSpace: adjustedFlexibleSpace, + flexibleSpace: DesktopAppBarHelper.buildAdjustedFlexibleSpace(flexibleSpace), bottom: bottom, ); } diff --git a/lib/widgets/plex_video_controls.dart b/lib/widgets/plex_video_controls.dart index de07f8af..50906137 100644 --- a/lib/widgets/plex_video_controls.dart +++ b/lib/widgets/plex_video_controls.dart @@ -326,8 +326,8 @@ class _PlexVideoControlsState extends State padding: const EdgeInsets.all(16), child: Row( children: [ - IconButton( - icon: const Icon(Icons.arrow_back, color: Colors.white), + AppBarBackButton( + style: BackButtonStyle.video, onPressed: () => Navigator.of(context).pop(true), ), const SizedBox(width: 16), @@ -547,8 +547,8 @@ class _PlexVideoControlsState extends State ), child: Row( children: [ - IconButton( - icon: const Icon(Icons.arrow_back, color: Colors.white), + AppBarBackButton( + style: BackButtonStyle.video, onPressed: () => Navigator.of(context).pop(true), ), const SizedBox(width: 16),