diff --git a/lib/components/shared/page_window_title_bar.dart b/lib/components/shared/page_window_title_bar.dart index eda6cf1d5..067a2d1fc 100644 --- a/lib/components/shared/page_window_title_bar.dart +++ b/lib/components/shared/page_window_title_bar.dart @@ -1,8 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:spotube/utils/platform.dart'; - +import 'package:titlebar_buttons/titlebar_buttons.dart'; import 'package:window_manager/window_manager.dart'; +import 'dart:math'; +import 'package:flutter/foundation.dart' show kIsWeb; +import 'dart:io' show Platform; class PageWindowTitleBar extends StatefulHookWidget implements PreferredSizeWidget { @@ -49,18 +52,6 @@ class PageWindowTitleBar extends StatefulHookWidget class _PageWindowTitleBarState extends State { @override Widget build(BuildContext context) { - final isMaximized = useState(null); - - maximizeOrRestore() async { - if (await windowManager.isMaximized()) { - await windowManager.unmaximize(); - isMaximized.value = false; - } else { - await windowManager.maximize(); - isMaximized.value = true; - } - } - return GestureDetector( onHorizontalDragStart: (details) { if (kIsDesktop) { @@ -77,24 +68,7 @@ class _PageWindowTitleBarState extends State { automaticallyImplyLeading: widget.automaticallyImplyLeading, actions: [ ...?widget.actions, - if (kIsDesktop && !kIsMacOS) ...[ - IconButton( - icon: const Icon(Icons.minimize), - onPressed: () => windowManager.minimize(), - ), - IconButton( - icon: Icon( - isMaximized.value ?? false - ? Icons.fullscreen_exit - : Icons.fullscreen, - ), - onPressed: maximizeOrRestore, - ), - IconButton( - icon: const Icon(Icons.close), - onPressed: () => windowManager.close(), - ), - ] + const WindowTitleBarButtons(), ], backgroundColor: widget.backgroundColor, foregroundColor: widget.foregroundColor, @@ -110,3 +84,489 @@ class _PageWindowTitleBarState extends State { ); } } + +class WindowTitleBarButtons extends HookWidget { + const WindowTitleBarButtons({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final isMaximized = useState(null); + const type = ThemeType.auto; + + useEffect(() { + if (kIsDesktop) { + windowManager.isMaximized().then((value) { + isMaximized.value = value; + }); + } + return null; + }, []); + + if (!kIsDesktop || kIsMacOS) { + return const SizedBox.shrink(); + } + + if (kIsWindows) { + final theme = Theme.of(context); + final colors = WindowButtonColors( + normal: Colors.transparent, + iconNormal: theme.colorScheme.onBackground, + mouseOver: theme.colorScheme.onBackground.withOpacity(0.1), + mouseDown: theme.colorScheme.onBackground.withOpacity(0.2), + iconMouseOver: theme.colorScheme.onBackground, + iconMouseDown: theme.colorScheme.onBackground, + ); + + final closeColors = WindowButtonColors( + normal: Colors.transparent, + iconNormal: theme.colorScheme.onBackground, + mouseOver: Colors.red, + mouseDown: Colors.red[800]!, + iconMouseOver: Colors.white, + iconMouseDown: Colors.black, + ); + + return Padding( + padding: const EdgeInsets.only(bottom: 25), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MinimizeWindowButton( + onPressed: windowManager.minimize, + colors: colors, + ), + if (isMaximized.value != true) + MaximizeWindowButton( + colors: colors, + onPressed: () { + windowManager.maximize(); + isMaximized.value = true; + }, + ) + else + RestoreWindowButton( + colors: colors, + onPressed: () { + windowManager.unmaximize(); + isMaximized.value = false; + }, + ), + CloseWindowButton( + colors: closeColors, + onPressed: windowManager.close, + ), + ], + ), + ); + } + + return Padding( + padding: const EdgeInsets.only(bottom: 20, left: 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DecoratedMinimizeButton( + type: type, + onPressed: windowManager.minimize, + ), + DecoratedMaximizeButton( + type: type, + onPressed: () async { + if (await windowManager.isMaximized()) { + await windowManager.unmaximize(); + isMaximized.value = false; + } else { + await windowManager.maximize(); + isMaximized.value = true; + } + }, + ), + DecoratedCloseButton( + type: type, + onPressed: windowManager.close, + ), + ], + ), + ); + } +} + +typedef WindowButtonIconBuilder = Widget Function( + WindowButtonContext buttonContext); +typedef WindowButtonBuilder = Widget Function( + WindowButtonContext buttonContext, Widget icon); + +class WindowButtonContext { + BuildContext context; + MouseState mouseState; + Color? backgroundColor; + Color iconColor; + WindowButtonContext( + {required this.context, + required this.mouseState, + this.backgroundColor, + required this.iconColor}); +} + +class WindowButtonColors { + late Color normal; + late Color mouseOver; + late Color mouseDown; + late Color iconNormal; + late Color iconMouseOver; + late Color iconMouseDown; + WindowButtonColors( + {Color? normal, + Color? mouseOver, + Color? mouseDown, + Color? iconNormal, + Color? iconMouseOver, + Color? iconMouseDown}) { + this.normal = normal ?? _defaultButtonColors.normal; + this.mouseOver = mouseOver ?? _defaultButtonColors.mouseOver; + this.mouseDown = mouseDown ?? _defaultButtonColors.mouseDown; + this.iconNormal = iconNormal ?? _defaultButtonColors.iconNormal; + this.iconMouseOver = iconMouseOver ?? _defaultButtonColors.iconMouseOver; + this.iconMouseDown = iconMouseDown ?? _defaultButtonColors.iconMouseDown; + } +} + +final _defaultButtonColors = WindowButtonColors( + normal: Colors.transparent, + iconNormal: const Color(0xFF805306), + mouseOver: const Color(0xFF404040), + mouseDown: const Color(0xFF202020), + iconMouseOver: const Color(0xFFFFFFFF), + iconMouseDown: const Color(0xFFF0F0F0), +); + +class WindowButton extends StatelessWidget { + final WindowButtonBuilder? builder; + final WindowButtonIconBuilder? iconBuilder; + late final WindowButtonColors colors; + final bool animate; + final EdgeInsets? padding; + final VoidCallback? onPressed; + + WindowButton( + {Key? key, + WindowButtonColors? colors, + this.builder, + @required this.iconBuilder, + this.padding, + this.onPressed, + this.animate = false}) + : super(key: key) { + this.colors = colors ?? _defaultButtonColors; + } + + Color getBackgroundColor(MouseState mouseState) { + if (mouseState.isMouseDown) return colors.mouseDown; + if (mouseState.isMouseOver) return colors.mouseOver; + return colors.normal; + } + + Color getIconColor(MouseState mouseState) { + if (mouseState.isMouseDown) return colors.iconMouseDown; + if (mouseState.isMouseOver) return colors.iconMouseOver; + return colors.iconNormal; + } + + @override + Widget build(BuildContext context) { + if (kIsWeb) { + return Container(); + } else { + // Don't show button on macOS + if (Platform.isMacOS) { + return Container(); + } + } + + return MouseStateBuilder( + builder: (context, mouseState) { + WindowButtonContext buttonContext = WindowButtonContext( + mouseState: mouseState, + context: context, + backgroundColor: getBackgroundColor(mouseState), + iconColor: getIconColor(mouseState)); + + var icon = + (iconBuilder != null) ? iconBuilder!(buttonContext) : Container(); + + var fadeOutColor = + getBackgroundColor(MouseState()..isMouseOver = true).withOpacity(0); + var padding = this.padding ?? const EdgeInsets.all(10); + var animationMs = + mouseState.isMouseOver ? (animate ? 100 : 0) : (animate ? 200 : 0); + Widget iconWithPadding = Padding(padding: padding, child: icon); + iconWithPadding = AnimatedContainer( + curve: Curves.easeOut, + duration: Duration(milliseconds: animationMs), + color: buttonContext.backgroundColor ?? fadeOutColor, + child: iconWithPadding); + var button = + (builder != null) ? builder!(buttonContext, icon) : iconWithPadding; + return SizedBox( + width: 45, + height: 32, + child: button, + ); + }, + onPressed: () { + if (onPressed != null) onPressed!(); + }, + ); + } +} + +class MinimizeWindowButton extends WindowButton { + MinimizeWindowButton( + {Key? key, + WindowButtonColors? colors, + VoidCallback? onPressed, + bool? animate}) + : super( + key: key, + colors: colors, + animate: animate ?? false, + iconBuilder: (buttonContext) => + MinimizeIcon(color: buttonContext.iconColor), + onPressed: onPressed, + ); +} + +class MaximizeWindowButton extends WindowButton { + MaximizeWindowButton( + {Key? key, + WindowButtonColors? colors, + VoidCallback? onPressed, + bool? animate}) + : super( + key: key, + colors: colors, + animate: animate ?? false, + iconBuilder: (buttonContext) => + MaximizeIcon(color: buttonContext.iconColor), + onPressed: onPressed, + ); +} + +class RestoreWindowButton extends WindowButton { + RestoreWindowButton( + {Key? key, + WindowButtonColors? colors, + VoidCallback? onPressed, + bool? animate}) + : super( + key: key, + colors: colors, + animate: animate ?? false, + iconBuilder: (buttonContext) => + RestoreIcon(color: buttonContext.iconColor), + onPressed: onPressed, + ); +} + +final _defaultCloseButtonColors = WindowButtonColors( + mouseOver: const Color(0xFFD32F2F), + mouseDown: const Color(0xFFB71C1C), + iconNormal: const Color(0xFF805306), + iconMouseOver: const Color(0xFFFFFFFF)); + +class CloseWindowButton extends WindowButton { + CloseWindowButton( + {Key? key, + WindowButtonColors? colors, + VoidCallback? onPressed, + bool? animate}) + : super( + key: key, + colors: colors ?? _defaultCloseButtonColors, + animate: animate ?? false, + iconBuilder: (buttonContext) => + CloseIcon(color: buttonContext.iconColor), + onPressed: onPressed, + ); +} + +// Switched to CustomPaint icons by https://github.com/esDotDev + +/// Close +class CloseIcon extends StatelessWidget { + final Color color; + const CloseIcon({Key? key, required this.color}) : super(key: key); + @override + Widget build(BuildContext context) => Align( + alignment: Alignment.topLeft, + child: Stack(children: [ + // Use rotated containers instead of a painter because it renders slightly crisper than a painter for some reason. + Transform.rotate( + angle: pi * .25, + child: + Center(child: Container(width: 14, height: 1, color: color))), + Transform.rotate( + angle: pi * -.25, + child: + Center(child: Container(width: 14, height: 1, color: color))), + ]), + ); +} + +/// Maximize +class MaximizeIcon extends StatelessWidget { + final Color color; + const MaximizeIcon({Key? key, required this.color}) : super(key: key); + @override + Widget build(BuildContext context) => _AlignedPaint(_MaximizePainter(color)); +} + +class _MaximizePainter extends _IconPainter { + _MaximizePainter(Color color) : super(color); + @override + void paint(Canvas canvas, Size size) { + Paint p = getPaint(color); + canvas.drawRect(Rect.fromLTRB(0, 0, size.width - 1, size.height - 1), p); + } +} + +/// Restore +class RestoreIcon extends StatelessWidget { + final Color color; + const RestoreIcon({ + Key? key, + required this.color, + }) : super(key: key); + @override + Widget build(BuildContext context) => _AlignedPaint(_RestorePainter(color)); +} + +class _RestorePainter extends _IconPainter { + _RestorePainter(Color color) : super(color); + @override + void paint(Canvas canvas, Size size) { + Paint p = getPaint(color); + canvas.drawRect(Rect.fromLTRB(0, 2, size.width - 2, size.height), p); + canvas.drawLine(const Offset(2, 2), const Offset(2, 0), p); + canvas.drawLine(const Offset(2, 0), Offset(size.width, 0), p); + canvas.drawLine( + Offset(size.width, 0), Offset(size.width, size.height - 2), p); + canvas.drawLine(Offset(size.width, size.height - 2), + Offset(size.width - 2, size.height - 2), p); + } +} + +/// Minimize +class MinimizeIcon extends StatelessWidget { + final Color color; + const MinimizeIcon({Key? key, required this.color}) : super(key: key); + @override + Widget build(BuildContext context) => _AlignedPaint(_MinimizePainter(color)); +} + +class _MinimizePainter extends _IconPainter { + _MinimizePainter(Color color) : super(color); + @override + void paint(Canvas canvas, Size size) { + Paint p = getPaint(color); + canvas.drawLine( + Offset(0, size.height / 2), Offset(size.width, size.height / 2), p); + } +} + +/// Helpers +abstract class _IconPainter extends CustomPainter { + _IconPainter(this.color); + final Color color; + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + +class _AlignedPaint extends StatelessWidget { + const _AlignedPaint(this.painter, {Key? key}) : super(key: key); + final CustomPainter painter; + + @override + Widget build(BuildContext context) { + return Align( + alignment: Alignment.center, + child: CustomPaint(size: const Size(10, 10), painter: painter)); + } +} + +Paint getPaint(Color color, [bool isAntiAlias = false]) => Paint() + ..color = color + ..style = PaintingStyle.stroke + ..isAntiAlias = isAntiAlias + ..strokeWidth = 1; + +typedef MouseStateBuilderCB = Widget Function( + BuildContext context, MouseState mouseState); + +class MouseState { + bool isMouseOver = false; + bool isMouseDown = false; + MouseState(); + @override + String toString() { + return "isMouseDown: $isMouseDown - isMouseOver: $isMouseOver"; + } +} + +T? _ambiguate(T? value) => value; + +class MouseStateBuilder extends StatefulWidget { + final MouseStateBuilderCB builder; + final VoidCallback? onPressed; + const MouseStateBuilder({Key? key, required this.builder, this.onPressed}) + : super(key: key); + @override + _MouseStateBuilderState createState() => _MouseStateBuilderState(); +} + +class _MouseStateBuilderState extends State { + late MouseState _mouseState; + _MouseStateBuilderState() { + _mouseState = MouseState(); + } + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (event) { + setState(() { + _mouseState.isMouseOver = true; + }); + }, + onExit: (event) { + setState(() { + _mouseState.isMouseOver = false; + }); + }, + child: GestureDetector( + onTapDown: (_) { + setState(() { + _mouseState.isMouseDown = true; + }); + }, + onTapCancel: () { + setState(() { + _mouseState.isMouseDown = false; + }); + }, + onTap: () { + setState(() { + _mouseState.isMouseDown = false; + _mouseState.isMouseOver = false; + }); + _ambiguate(WidgetsBinding.instance)!.addPostFrameCallback((_) { + if (widget.onPressed != null) { + widget.onPressed!(); + } + }); + }, + onTapUp: (_) {}, + child: widget.builder(context, _mouseState))); + } +} diff --git a/pubspec.lock b/pubspec.lock index e204880f3..b8e1490cf 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -330,6 +330,14 @@ packages: url: "https://github.com/ThexXTURBOXx/catcher" source: git version: "0.7.1" + change_case: + dependency: transitive + description: + name: change_case + sha256: f4e08feaa845e75e4f5ad2b0e15f24813d7ea6c27e7b78252f0c17f752cf1157 + url: "https://pub.dev" + source: hosted + version: "1.1.0" characters: dependency: transitive description: @@ -782,6 +790,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + gsettings: + dependency: transitive + description: + name: gsettings + sha256: fe90d719e09a6f36607021047e642068a0c98839d9633db00b91633420ae8b0d + url: "https://pub.dev" + source: hosted + version: "0.2.7" hive: dependency: "direct main" description: @@ -1564,6 +1580,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.1" + titlebar_buttons: + dependency: "direct main" + description: + name: titlebar_buttons + sha256: babf62b48b80f290b9ef8b0135df7d9e9bebcb9c27e8380a53df9bb72f3fb03c + url: "https://pub.dev" + source: hosted + version: "1.0.0" tuple: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 14e5cbd2f..1f1f82ee8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -69,6 +69,7 @@ dependencies: git: url: https://github.com/rinukkusu/spotify-dart ref: 9e8ef4556942d42d74874de5491253156e7e6f43 + titlebar_buttons: ^1.0.0 tuple: ^2.0.1 url_launcher: ^6.1.7 uuid: ^3.0.7