import 'package:auto_size_text/auto_size_text.dart'; import 'package:debounce_throttle/debounce_throttle.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hbb/common.dart'; import 'package:flutter_hbb/models/platform_model.dart'; import 'package:get/get.dart'; import 'package:provider/provider.dart'; import '../../consts.dart'; import '../../desktop/widgets/tabbar_widget.dart'; import '../../models/chat_model.dart'; import '../../models/model.dart'; import 'chat_page.dart'; class DraggableChatWindow extends StatelessWidget { const DraggableChatWindow( {Key? key, this.position = Offset.zero, required this.width, required this.height, required this.chatModel}) : super(key: key); final Offset position; final double width; final double height; final ChatModel chatModel; @override Widget build(BuildContext context) { if (draggablePositions.chatWindow.isInvalid()) { draggablePositions.chatWindow.update(position); } return isIOS ? IOSDraggable( position: draggablePositions.chatWindow, chatModel: chatModel, width: width, height: height, builder: (context) { return Column( children: [ _buildMobileAppBar(context), Expanded( child: ChatPage(chatModel: chatModel), ), ], ); }, ) : Draggable( checkKeyboard: true, position: draggablePositions.chatWindow, width: width, height: height, chatModel: chatModel, builder: (context, onPanUpdate) { final child = Scaffold( resizeToAvoidBottomInset: false, appBar: CustomAppBar( onPanUpdate: onPanUpdate, appBar: (isDesktop || isWebDesktop) ? _buildDesktopAppBar(context) : _buildMobileAppBar(context), ), body: ChatPage(chatModel: chatModel), ); return Container( decoration: BoxDecoration(border: Border.all(color: MyTheme.border)), child: child); }); } Widget _buildMobileAppBar(BuildContext context) { return Container( color: Theme.of(context).colorScheme.primary, height: 50, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 15), child: Text( translate("Chat"), style: const TextStyle( color: Colors.white, fontFamily: 'WorkSans', fontWeight: FontWeight.bold, fontSize: 20), )), Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ IconButton( onPressed: () { chatModel.hideChatWindowOverlay(); }, icon: const Icon( Icons.keyboard_arrow_down, color: Colors.white, )), IconButton( onPressed: () { chatModel.hideChatWindowOverlay(); chatModel.hideChatIconOverlay(); }, icon: const Icon( Icons.close, color: Colors.white, )) ], ) ], ), ); } Widget _buildDesktopAppBar(BuildContext context) { return Container( decoration: BoxDecoration( border: Border( bottom: BorderSide( color: Theme.of(context).hintColor.withOpacity(0.4)))), height: 38, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 8), child: Obx(() => Opacity( opacity: chatModel.isWindowFocus.value ? 1.0 : 0.4, child: Row(children: [ Icon(Icons.chat_bubble_outline, size: 20, color: Theme.of(context).colorScheme.primary), SizedBox(width: 6), Text(translate("Chat")) ])))), Padding( padding: EdgeInsets.all(2), child: ActionIcon( message: 'Close', icon: IconFont.close, onTap: chatModel.hideChatWindowOverlay, isClose: true, boxSize: 32, )) ], ), ); } } class CustomAppBar extends StatelessWidget implements PreferredSizeWidget { final GestureDragUpdateCallback onPanUpdate; final Widget appBar; const CustomAppBar( {Key? key, required this.onPanUpdate, required this.appBar}) : super(key: key); @override Widget build(BuildContext context) { return GestureDetector(onPanUpdate: onPanUpdate, child: appBar); } @override Size get preferredSize => const Size.fromHeight(kToolbarHeight); } /// floating buttons of back/home/recent actions for android class DraggableMobileActions extends StatelessWidget { DraggableMobileActions( {this.onBackPressed, this.onRecentPressed, this.onHomePressed, this.onHidePressed, required this.position, required this.width, required this.height, required this.scale}); final double scale; final DraggableKeyPosition position; final double width; final double height; final VoidCallback? onBackPressed; final VoidCallback? onHomePressed; final VoidCallback? onRecentPressed; final VoidCallback? onHidePressed; @override Widget build(BuildContext context) { return Draggable( position: position, width: scale * width, height: scale * height, builder: (_, onPanUpdate) { return GestureDetector( onPanUpdate: onPanUpdate, child: Card( color: Colors.transparent, shadowColor: Colors.transparent, child: Container( decoration: BoxDecoration( color: MyTheme.accent.withOpacity(0.4), borderRadius: BorderRadius.all(Radius.circular(15 * scale))), child: Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ IconButton( color: Colors.white, onPressed: onBackPressed, splashRadius: kDesktopIconButtonSplashRadius, icon: const Icon(Icons.arrow_back), iconSize: 24 * scale), IconButton( color: Colors.white, onPressed: onHomePressed, splashRadius: kDesktopIconButtonSplashRadius, icon: const Icon(Icons.home), iconSize: 24 * scale), IconButton( color: Colors.white, onPressed: onRecentPressed, splashRadius: kDesktopIconButtonSplashRadius, icon: const Icon(Icons.more_horiz), iconSize: 24 * scale), const VerticalDivider( width: 0, thickness: 2, indent: 10, endIndent: 10, ), IconButton( color: Colors.white, onPressed: onHidePressed, splashRadius: kDesktopIconButtonSplashRadius, icon: const Icon(Icons.keyboard_arrow_down), iconSize: 24 * scale), ], ), ))); }); } } class DraggableKeyPosition { final String key; Offset _pos; late Debouncer _debouncerStore; DraggableKeyPosition(this.key) : _pos = DraggablePositions.kInvalidDraggablePosition; get pos => _pos; _loadPosition(String k) { final value = bind.getLocalFlutterOption(k: k); if (value.isNotEmpty) { final parts = value.split(','); if (parts.length == 2) { return Offset(double.parse(parts[0]), double.parse(parts[1])); } } return DraggablePositions.kInvalidDraggablePosition; } load() { _pos = _loadPosition(key); _debouncerStore = Debouncer(const Duration(milliseconds: 500), onChanged: (v) => _store(), initialValue: 0); } update(Offset pos) { _pos = pos; _triggerStore(); } // Adjust position to keep it in the screen // Only used for desktop and web desktop tryAdjust(double w, double h, double scale) { final size = MediaQuery.of(Get.context!).size; w = w * scale; h = h * scale; double x = _pos.dx; double y = _pos.dy; if (x + w > size.width) { x = size.width - w; } final tabBarHeight = isDesktop ? kDesktopRemoteTabBarHeight : 0; if (y + h > (size.height - tabBarHeight)) { y = size.height - tabBarHeight - h; } if (x < 0) { x = 0; } if (y < 0) { y = 0; } if (x != _pos.dx || y != _pos.dy) { update(Offset(x, y)); } } isInvalid() { return _pos == DraggablePositions.kInvalidDraggablePosition; } _triggerStore() => _debouncerStore.value = _debouncerStore.value + 1; _store() { bind.setLocalFlutterOption(k: key, v: '${_pos.dx},${_pos.dy}'); } } class DraggablePositions { static const kChatWindow = 'draggablePositionChat'; static const kMobileActions = 'draggablePositionMobile'; static const kIOSDraggable = 'draggablePositionIOS'; static const kInvalidDraggablePosition = Offset(-999999, -999999); final chatWindow = DraggableKeyPosition(kChatWindow); final mobileActions = DraggableKeyPosition(kMobileActions); final iOSDraggable = DraggableKeyPosition(kIOSDraggable); load() { chatWindow.load(); mobileActions.load(); iOSDraggable.load(); } } DraggablePositions draggablePositions = DraggablePositions(); class Draggable extends StatefulWidget { Draggable( {Key? key, this.checkKeyboard = false, this.checkScreenSize = false, required this.position, required this.width, required this.height, this.chatModel, required this.builder}) : super(key: key); final bool checkKeyboard; final bool checkScreenSize; final DraggableKeyPosition position; final double width; final double height; final ChatModel? chatModel; final Widget Function(BuildContext, GestureDragUpdateCallback) builder; @override State createState() => _DraggableState(); } class _DraggableState extends State { late ChatModel? _chatModel; bool _keyboardVisible = false; double _saveHeight = 0; double _lastBottomHeight = 0; @override void initState() { super.initState(); _chatModel = widget.chatModel; } get position => widget.position.pos; void onPanUpdate(DragUpdateDetails d) { final offset = d.delta; final size = MediaQuery.of(context).size; double x = 0; double y = 0; if (position.dx + offset.dx + widget.width > size.width) { x = size.width - widget.width; } else if (position.dx + offset.dx < 0) { x = 0; } else { x = position.dx + offset.dx; } if (position.dy + offset.dy + widget.height > size.height) { y = size.height - widget.height; } else if (position.dy + offset.dy < 0) { y = 0; } else { y = position.dy + offset.dy; } setState(() { widget.position.update(Offset(x, y)); }); _chatModel?.setChatWindowPosition(position); } checkScreenSize() {} checkKeyboard() { final bottomHeight = MediaQuery.of(context).viewInsets.bottom; final currentVisible = bottomHeight != 0; // save if (!_keyboardVisible && currentVisible) { _saveHeight = position.dy; } // reset if (_lastBottomHeight > 0 && bottomHeight == 0) { setState(() { widget.position.update(Offset(position.dx, _saveHeight)); }); } // onKeyboardVisible if (_keyboardVisible && currentVisible) { final sumHeight = bottomHeight + widget.height; final contextHeight = MediaQuery.of(context).size.height; if (sumHeight + position.dy > contextHeight) { final y = contextHeight - sumHeight; setState(() { widget.position.update(Offset(position.dx, y)); }); } } _keyboardVisible = currentVisible; _lastBottomHeight = bottomHeight; } @override Widget build(BuildContext context) { if (widget.checkKeyboard) { checkKeyboard(); } if (widget.checkScreenSize) { checkScreenSize(); } return Stack(children: [ Positioned( top: position.dy, left: position.dx, width: widget.width, height: widget.height, child: widget.builder(context, onPanUpdate)) ]); } } class IOSDraggable extends StatefulWidget { const IOSDraggable( {Key? key, this.chatModel, required this.position, required this.width, required this.height, required this.builder}) : super(key: key); final DraggableKeyPosition position; final ChatModel? chatModel; final double width; final double height; final Widget Function(BuildContext) builder; @override IOSDraggableState createState() => IOSDraggableState(); } class IOSDraggableState extends State { late ChatModel? _chatModel; late double _width; late double _height; bool _keyboardVisible = false; double _saveHeight = 0; double _lastBottomHeight = 0; @override void initState() { super.initState(); _chatModel = widget.chatModel; _width = widget.width; _height = widget.height; } DraggableKeyPosition get position => widget.position; checkKeyboard() { final bottomHeight = MediaQuery.of(context).viewInsets.bottom; final currentVisible = bottomHeight != 0; // save if (!_keyboardVisible && currentVisible) { _saveHeight = position.pos.dy; } // reset if (_lastBottomHeight > 0 && bottomHeight == 0) { setState(() { position.update(Offset(position.pos.dx, _saveHeight)); }); } // onKeyboardVisible if (_keyboardVisible && currentVisible) { final sumHeight = bottomHeight + _height; final contextHeight = MediaQuery.of(context).size.height; if (sumHeight + position.pos.dy > contextHeight) { final y = contextHeight - sumHeight; setState(() { position.update(Offset(position.pos.dx, y)); }); } } _keyboardVisible = currentVisible; _lastBottomHeight = bottomHeight; } @override Widget build(BuildContext context) { checkKeyboard(); return Stack( children: [ Positioned( left: position.pos.dx, top: position.pos.dy, child: GestureDetector( onPanUpdate: (details) { setState(() { position.update(position.pos + details.delta); }); _chatModel?.setChatWindowPosition(position.pos); }, child: Material( child: Container( width: _width, height: _height, decoration: BoxDecoration(border: Border.all(color: MyTheme.border)), child: widget.builder(context), ), ), ), ), ], ); } } class QualityMonitor extends StatelessWidget { final QualityMonitorModel qualityMonitorModel; QualityMonitor(this.qualityMonitorModel); Widget _row(String info, String? value, {Color? rightColor}) { return Row( children: [ Expanded( flex: 8, child: AutoSizeText(info, style: TextStyle(color: Color.fromARGB(255, 210, 210, 210)), textAlign: TextAlign.right, maxLines: 1)), Spacer(flex: 1), Expanded( flex: 8, child: AutoSizeText(value ?? '', style: TextStyle(color: rightColor ?? Colors.white), maxLines: 1)), ], ); } @override Widget build(BuildContext context) => ChangeNotifierProvider.value( value: qualityMonitorModel, child: Consumer( builder: (context, qualityMonitorModel, child) => qualityMonitorModel .show ? Container( constraints: BoxConstraints(maxWidth: 200), padding: const EdgeInsets.all(8), color: MyTheme.canvasColor.withAlpha(150), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _row("Speed", qualityMonitorModel.data.speed ?? '-'), _row("FPS", qualityMonitorModel.data.fps ?? '-'), // let delay be 0 if fps is 0 _row( "Delay", "${qualityMonitorModel.data.delay == null ? '-' : (qualityMonitorModel.data.fps ?? "").replaceAll(' ', '').replaceAll('0', '').isEmpty ? 0 : qualityMonitorModel.data.delay}ms", rightColor: Colors.green), _row("Target Bitrate", "${qualityMonitorModel.data.targetBitrate ?? '-'}kb"), _row( "Codec", qualityMonitorModel.data.codecFormat ?? '-'), _row("Chroma", qualityMonitorModel.data.chroma ?? '-'), ], ), ) : const SizedBox.shrink())); } class BlockableOverlayState extends OverlayKeyState { final _middleBlocked = false.obs; VoidCallback? onMiddleBlockedClick; // to-do use listener RxBool get middleBlocked => _middleBlocked; void addMiddleBlockedListener(void Function(bool) cb) { _middleBlocked.listen(cb); } void setMiddleBlocked(bool blocked) { if (blocked != _middleBlocked.value) { _middleBlocked.value = blocked; } } void applyFfi(FFI ffi) { ffi.dialogManager.setOverlayState(this); ffi.chatModel.setOverlayState(this); // make remote page penetrable automatically, effective for chat over remote onMiddleBlockedClick = () { setMiddleBlocked(false); }; } } class BlockableOverlay extends StatelessWidget { final Widget underlying; final List? upperLayer; final BlockableOverlayState state; BlockableOverlay( {required this.underlying, required this.state, this.upperLayer}); @override Widget build(BuildContext context) { final initialEntries = [ OverlayEntry(builder: (_) => underlying), /// middle layer OverlayEntry( builder: (context) => Obx(() => Listener( onPointerDown: (_) { state.onMiddleBlockedClick?.call(); }, child: Container( color: state.middleBlocked.value ? Colors.transparent : null)))), ]; if (upperLayer != null) { initialEntries.addAll(upperLayer!); } /// set key return Overlay(key: state.key, initialEntries: initialEntries); } }