diff --git a/lib/screens/channel/chat/chat.dart b/lib/screens/channel/chat/chat.dart index b69f403b..b8a622a4 100644 --- a/lib/screens/channel/chat/chat.dart +++ b/lib/screens/channel/chat/chat.dart @@ -6,6 +6,7 @@ import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; import 'package:frosty/screens/channel/chat/widgets/chat_bottom_bar.dart'; import 'package:frosty/screens/channel/chat/widgets/chat_message.dart'; import 'package:frosty/utils/context_extensions.dart'; +import 'package:frosty/widgets/frosty_dialog.dart'; import 'package:frosty/widgets/frosty_page_view.dart'; import 'package:frosty/widgets/frosty_scrollbar.dart'; @@ -192,6 +193,47 @@ class Chat extends StatelessWidget { if (chatStore.settings.showBTTVEmotes) 'BTTV', if (chatStore.settings.showFFZEmotes) 'FFZ', ], + tabActions: { + if (chatStore + .assetsStore + .recentEmotes + .isNotEmpty) + 0: IconButton( + onPressed: () => showDialog( + context: context, + builder: (context) => FrostyDialog( + title: 'Clear recent emotes', + message: + 'Are you sure you want to clear your recent emotes?', + actions: [ + TextButton( + onPressed: + Navigator.of(context).pop, + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + chatStore + .assetsStore + .recentEmotes + .clear(); + chatStore.updateNotification( + 'Recent emotes cleared', + ); + Navigator.pop(context); + }, + child: const Text('Clear'), + ), + ], + ), + ), + icon: const Icon( + Icons.delete_outline_rounded, + ), + tooltip: 'Clear recent emotes', + iconSize: 20, + ), + }, children: [ RecentEmotesPanel(chatStore: chatStore), if (chatStore.settings.showTwitchEmotes) diff --git a/lib/screens/channel/chat/emote_menu/recent_emotes_panel.dart b/lib/screens/channel/chat/emote_menu/recent_emotes_panel.dart index 9bd71aa1..f4fb7897 100644 --- a/lib/screens/channel/chat/emote_menu/recent_emotes_panel.dart +++ b/lib/screens/channel/chat/emote_menu/recent_emotes_panel.dart @@ -8,7 +8,6 @@ import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/utils/context_extensions.dart'; import 'package:frosty/widgets/alert_message.dart'; import 'package:frosty/widgets/frosty_cached_network_image.dart'; -import 'package:frosty/widgets/frosty_dialog.dart'; import 'package:provider/provider.dart'; class RecentEmotesPanel extends StatelessWidget { @@ -16,122 +15,74 @@ class RecentEmotesPanel extends StatelessWidget { const RecentEmotesPanel({super.key, required this.chatStore}); - Future _showClearDialog(BuildContext context) { - return showDialog( - context: context, - builder: (context) => FrostyDialog( - title: 'Clear recent emotes', - message: 'Are you sure you want to clear your recent emotes?', - actions: [ - TextButton( - onPressed: Navigator.of(context).pop, - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () { - chatStore.assetsStore.recentEmotes.clear(); - chatStore.updateNotification('Recent emotes cleared'); - - Navigator.pop(context); - }, - child: const Text('Clear'), - ), - ], - ), - ); - } - @override Widget build(BuildContext context) { return Observer( builder: (context) { + if (chatStore.assetsStore.recentEmotes.isEmpty) { + return const Center( + child: AlertMessage(message: 'No recent emotes'), + ); + } + return MediaQuery.removePadding( context: context, removeTop: true, - child: CustomScrollView( - slivers: [ - if (chatStore.assetsStore.recentEmotes.isEmpty) - const SliverFillRemaining( - hasScrollBody: false, - child: AlertMessage(message: 'No recent emotes'), - ) - else - SliverGrid.builder( - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: context.isPortrait - ? 8 - : context.read().showVideo - ? 6 - : 16, - ), - itemBuilder: (context, index) { - final emote = chatStore.assetsStore.recentEmotes[index]; - final validEmotes = [ - ...chatStore.assetsStore.emoteToObject.values, - ...chatStore.assetsStore.userEmoteToObject.values, - ]; - final matchingEmotes = validEmotes.where( - (existingEmote) => - existingEmote.name == emote.name && - existingEmote.type == emote.type, - ); + child: GridView.builder( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: context.isPortrait + ? 8 + : context.read().showVideo + ? 6 + : 16, + ), + itemBuilder: (context, index) { + final emote = chatStore.assetsStore.recentEmotes[index]; + final validEmotes = [ + ...chatStore.assetsStore.emoteToObject.values, + ...chatStore.assetsStore.userEmoteToObject.values, + ]; + final matchingEmotes = validEmotes.where( + (existingEmote) => + existingEmote.name == emote.name && + existingEmote.type == emote.type, + ); - return InkWell( - onTap: matchingEmotes.isNotEmpty - ? () => chatStore.addEmote(emote) - : null, - onLongPress: () { - HapticFeedback.lightImpact(); + return InkWell( + onTap: matchingEmotes.isNotEmpty + ? () => chatStore.addEmote(emote) + : null, + onLongPress: () { + HapticFeedback.lightImpact(); - IRCMessage.showEmoteDetailsBottomSheet( - context, - emote: emote, - launchExternal: chatStore.settings.launchUrlExternal, - ); - }, - child: Padding( - padding: const EdgeInsets.all(8), - child: Center( - child: FrostyCachedNetworkImage( - imageUrl: matchingEmotes.isNotEmpty - ? matchingEmotes.first.url - : emote.url, - color: matchingEmotes.isNotEmpty - ? null - : const Color.fromRGBO(255, 255, 255, 0.5), - colorBlendMode: matchingEmotes.isNotEmpty - ? null - : BlendMode.modulate, - height: - emote.height?.toDouble() ?? defaultEmoteSize, - width: emote.width?.toDouble(), - ), - ), - ), - ); - }, - itemCount: chatStore.assetsStore.recentEmotes.length, - ), - SliverFillRemaining( - hasScrollBody: false, - child: Align( - alignment: Alignment.bottomCenter, - child: SafeArea( - child: Padding( - padding: const EdgeInsets.all(12.0), - child: SizedBox( - width: double.infinity, - child: TextButton.icon( - onPressed: () => _showClearDialog(context), - icon: const Icon(Icons.clear_all_rounded), - label: const Text('Clear recent emotes'), - ), - ), + IRCMessage.showEmoteDetailsBottomSheet( + context, + emote: emote, + launchExternal: chatStore.settings.launchUrlExternal, + ); + }, + child: Padding( + padding: const EdgeInsets.all(8), + child: Center( + child: FrostyCachedNetworkImage( + imageUrl: matchingEmotes.isNotEmpty + ? matchingEmotes.first.url + : emote.url, + color: matchingEmotes.isNotEmpty + ? null + : const Color.fromRGBO(255, 255, 255, 0.5), + colorBlendMode: matchingEmotes.isNotEmpty + ? null + : BlendMode.modulate, + height: + emote.height?.toDouble() ?? defaultEmoteSize, + width: emote.width?.toDouble(), ), ), ), - ), - ], + ); + }, + itemCount: chatStore.assetsStore.recentEmotes.length, ), ); }, diff --git a/lib/widgets/frosty_page_view.dart b/lib/widgets/frosty_page_view.dart index 1861ec85..f36e39f7 100644 --- a/lib/widgets/frosty_page_view.dart +++ b/lib/widgets/frosty_page_view.dart @@ -4,17 +4,55 @@ class FrostyPageView extends StatefulWidget { final List headers; final List children; + /// Actions keyed by tab index, shown trailing the tab bar + /// when that tab is selected. + final Map tabActions; + const FrostyPageView({ super.key, required this.headers, required this.children, + this.tabActions = const {}, }) : assert(headers.length == children.length); @override State createState() => _FrostyPageViewState(); } -class _FrostyPageViewState extends State { +class _FrostyPageViewState extends State + with TickerProviderStateMixin { + late TabController _tabController; + + void _handleTabChange() => setState(() {}); + + void _initTabController(int length) { + _tabController = TabController(length: length, vsync: this); + _tabController.addListener(_handleTabChange); + } + + @override + void initState() { + super.initState(); + _initTabController(widget.headers.length); + } + + @override + void didUpdateWidget(FrostyPageView oldWidget) { + super.didUpdateWidget(oldWidget); + if (_tabController.length != widget.headers.length) { + _tabController.removeListener(_handleTabChange); + _tabController.dispose(); + _initTabController(widget.headers.length); + } + } + + @override + void dispose() { + _tabController.removeListener(_handleTabChange); + _tabController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return LayoutBuilder( @@ -26,26 +64,39 @@ class _FrostyPageViewState extends State { return const SizedBox.expand(); } - return DefaultTabController( - length: widget.headers.length, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TabBar( - isScrollable: true, - labelStyle: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, + final trailingAction = widget.tabActions[_tabController.index]; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: TabBar( + controller: _tabController, + isScrollable: true, + labelStyle: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + labelPadding: + const EdgeInsets.symmetric(horizontal: 12), + tabs: widget.headers + .map((header) => Tab(text: header)) + .toList(), + ), ), - labelPadding: const EdgeInsets.symmetric(horizontal: 12), - tabs: widget.headers - .map((header) => Tab(text: header)) - .toList(), + ?trailingAction, + ], + ), + const Divider(), + Expanded( + child: TabBarView( + controller: _tabController, + children: widget.children, ), - const Divider(), - Expanded(child: TabBarView(children: widget.children)), - ], - ), + ), + ], ); }, );