refactor: simplify macos titlebar logic

This commit is contained in:
edde746
2025-10-25 03:06:45 +02:00
parent dea2bc35ea
commit 1000032db0
9 changed files with 291 additions and 208 deletions
+3 -11
View File
@@ -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<AboutScreen> {
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(
+3 -2
View File
@@ -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),
+3 -13
View File
@@ -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<MediaDetailScreen> {
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(
+3 -11
View File
@@ -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<SeasonDetailScreen> {
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)
+2 -1
View File
@@ -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<ServerSelectionScreen> {
@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
+155
View File
@@ -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<Widget>? buildAdjustedActions(List<Widget>? 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,
);
},
);
}
}
+107
View File
@@ -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',
),
);
}
}
+11 -166
View File
@@ -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<Widget>? 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<Widget>? 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,
);
}
+4 -4
View File
@@ -326,8 +326,8 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
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<PlexVideoControls>
),
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),